diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0645f284..1874f035 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -59,7 +59,7 @@ The following instructions are only to be applied when performing a code review. **Only apply to directories in the `plugins/` directory** -- [ ] The plugin directory contains a `.github/plugin/plugin.json` file. +- [ ] The plugin directory contains a root `plugin.json` file. - [ ] The plugin directory contains a `README.md` file. - [ ] The plugin.json has a `name` field matching the directory name. - [ ] The plugin.json has a `description` field. diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index ba86b835..eb1734fe 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -11,7 +11,7 @@ "plugins": [ { "name": "accessibility-kanban", - "source": "extensions/accessibility-kanban", + "source": "plugins/accessibility-kanban", "description": "Kanban board to manage accessibility issues, allow you to plan, track, and complete remediation work.", "version": "1.0.2" }, @@ -83,13 +83,13 @@ }, { "name": "apng-studio", - "source": "extensions/apng-studio", + "source": "plugins/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.2" }, { "name": "arcade-canvas", - "source": "extensions/arcade-canvas", + "source": "plugins/arcade-canvas", "description": "Play five retro Phaser mini-games in a Copilot canvas while agents work.", "version": "1.0.2" }, @@ -156,13 +156,13 @@ }, { "name": "backlog-swipe-triage", - "source": "extensions/backlog-swipe-triage", + "source": "plugins/backlog-swipe-triage", "description": "Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.", "version": "1.0.2" }, { "name": "backrooms-canvas", - "source": "extensions/backrooms-canvas", + "source": "plugins/backrooms-canvas", "description": "Wander an endless first-person backrooms in a Copilot canvas while agents work; their status ghost-writes on the walls.", "version": "1.0.0" }, @@ -227,7 +227,7 @@ }, { "name": "chromium-control-canvas", - "source": "extensions/chromium-control-canvas", + "source": "plugins/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.2" }, @@ -271,7 +271,7 @@ }, { "name": "color-orb", - "source": "extensions/color-orb", + "source": "plugins/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.2" }, @@ -426,7 +426,7 @@ }, { "name": "diagram-viewer", - "source": "extensions/diagram-viewer", + "source": "plugins/diagram-viewer", "description": "Render diagrams, click nodes to drill down, and view agent-generated explanations directly in the canvas.", "version": "1.0.2" }, @@ -573,7 +573,7 @@ }, { "name": "feedback-themes", - "source": "extensions/feedback-themes", + "source": "plugins/feedback-themes", "description": "Explore grouped customer feedback signals by impact and drill into a theme to guide product next steps.", "version": "1.0.2" }, @@ -601,7 +601,7 @@ }, { "name": "flight-map-canvas", - "source": "extensions/flight-map-canvas", + "source": "plugins/flight-map-canvas", "description": "A GitHub Copilot canvas that generates a view where Google Maps can be explored using 3D controls, as if a flight simulator. Agents can send the flight anywhere and report what they are working on.", "version": "1.0.0" }, @@ -625,7 +625,7 @@ }, { "name": "gesture-review", - "source": "extensions/gesture-review", + "source": "plugins/gesture-review", "description": "Review pull requests with a live camera feed and approve or reject using thumbs-up/thumbs-down gestures.", "version": "1.0.2" }, @@ -733,7 +733,7 @@ }, { "name": "java-modernization-studio", - "source": "extensions/java-modernization-studio", + "source": "plugins/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.2" }, @@ -1061,7 +1061,7 @@ }, { "name": "pr-artifact-explorer", - "source": "extensions/pr-artifact-explorer", + "source": "plugins/pr-artifact-explorer", "description": "Navigate pull requests and securely explore GitHub Actions artifacts, including test results, static sites, terminal recordings, and source files.", "version": "1.0.0" }, @@ -1097,13 +1097,13 @@ }, { "name": "release-notes-showcase", - "source": "extensions/release-notes-showcase", + "source": "plugins/release-notes-showcase", "description": "Compose and refine launch-ready release notes with contributor callouts and export-friendly output.", "version": "1.0.2" }, { "name": "repo-actions-hub", - "source": "extensions/repo-actions-hub", + "source": "plugins/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.2" }, @@ -1145,13 +1145,13 @@ }, { "name": "signals-dashboard", - "source": "extensions/signals-dashboard", + "source": "plugins/signals-dashboard", "description": "Real-time Workshop dashboard with agent signals, honesty calibration, and cost-aware repo or connected desk launch profiles.", "version": "0.2.0" }, { "name": "site-studio", - "source": "extensions/site-studio", + "source": "plugins/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.2" }, @@ -1271,13 +1271,13 @@ }, { "name": "tiny-tool-town-submitter", - "source": "extensions/tiny-tool-town-submitter", + "source": "plugins/tiny-tool-town-submitter", "description": "Inspect a repository, improve Tiny Tool Town readiness, submit its listing issue, and launch remediation work.", "version": "1.0.2" }, { "name": "token-pacman", - "source": "extensions/token-pacman", + "source": "plugins/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.2" }, @@ -1495,7 +1495,7 @@ }, { "name": "where-was-i", - "source": "extensions/where-was-i", + "source": "plugins/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.2" }, @@ -1559,7 +1559,7 @@ }, { "name": "work-hub", - "source": "extensions/work-hub", + "source": "plugins/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.2" } diff --git a/.github/skills/create-canvas-extension/SKILL.md b/.github/skills/create-canvas-extension/SKILL.md new file mode 100644 index 00000000..265faaed --- /dev/null +++ b/.github/skills/create-canvas-extension/SKILL.md @@ -0,0 +1,124 @@ +--- +name: create-canvas-extension +description: 'Create or register a canvas extension in the awesome-copilot repository. Use when asked to scaffold a new canvas extension, create its plugin.json, add a reusable extension to one or more plugins, or migrate extension metadata. Extensions are reusable source under extensions/; shippable plugin manifests belong under plugins/.' +argument-hint: '[optional extension name or description]' +--- + +# Create a canvas extension + +Use this skill only for the `github/awesome-copilot` repository. Canvas extensions are reusable source components. They do not have a `plugin.json` under `extensions/`. + +## Required decisions + +Before creating files, ask for each missing value: + +1. **Extension ID**: lowercase kebab-case, matching the source folder and plugin name. +2. **Display metadata**: + - description + - version (default `1.0.0`) + - author name and optional URL + - keywords (lowercase, hyphenated, maximum 10) + - repository and license (default to the repository URL and `MIT` when appropriate) +3. **Canvas entrypoint**: confirm whether the extension already has `extension.mjs`. If not, create a minimal entrypoint only when the user provides enough implementation details; otherwise create the directory and leave an explicit TODO. +4. **Preview image**: obtain an existing `assets/preview.png` path or ask the user to add it. Do not invent a binary image or silently use a misleading placeholder. +5. **Plugin registration**: + - For a standalone installable canvas plugin, create `plugins/`. + - For an extension that belongs to an existing plugin, ask for the parent plugin ID and add `./extensions/` to that plugin's `extensions.com.github.awesome-copilot.extensions`. + - If the extension should be shipped by multiple plugins, collect all plugin IDs and add the same extension ID to each mapping file. + +## Files to create + +For a new extension plugin, create this structure: + +```text +extensions// +├── extension.mjs +└── assets/ + └── preview.png + +plugins// +├── plugin.json +└── README.md +``` + +The extension source may contain additional files such as `package.json`, canvas assets, or supporting modules. Keep all reusable implementation files under `extensions//`. + +Create `plugins//plugin.json` with this shape: + +```json +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "", + "description": "", + "version": "1.0.0", + "author": { + "name": "" + }, + "repository": "https://github.com/github/awesome-copilot", + "license": "MIT", + "keywords": [ + "" + ], + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + }, + "com.github.awesome-copilot": { + "extensions": [ + "./extensions/" + ] + } + } +} +``` + +Keep Agent Plugins fields at the manifest top level. Repository composition belongs only under `extensions.com.github.awesome-copilot`; do not put `agents`, `commands`, `hooks`, `mcpServers`, or `skills` at the top level or directly under `extensions`. Do not add `x-awesome-copilot`, `standalone`, or other repository-specific top-level fields. + +For an existing parent plugin, create or update: + +```text +plugins//plugin.json (`extensions.com.github.awesome-copilot.extensions`) +``` + +Its `extensions` property must contain sorted repository-relative paths: + +```json +{ + "extensions": [ + "./extensions/" + ] +} +``` + +Do not copy the extension source into the parent plugin. Materialization resolves the IDs from the root `extensions/` directory, so the same source can be bundled by multiple plugins. + +## Workflow + +1. Inspect the repository before editing: + - confirm `extensions/` and `plugins/` do not already conflict + - inspect the target parent plugin, if any + - check whether a preview image and entrypoint already exist +2. Ask only the missing required questions from the decisions above. +3. Create the source and plugin directories with the required files. +4. If creating a new entrypoint, keep it minimal and clearly mark implementation TODOs rather than fabricating behavior. +5. Add or update `extensions.com.github.awesome-copilot.extensions` for every parent plugin that should ship the extension. Keep paths alphabetically sorted and unique. +6. Ensure there is no `extensions//.github/plugin/plugin.json`. +7. Run: + + ```bash + npm run plugin:validate + npm run build + npm run website:data + ``` + +8. Report the created paths, the plugins that ship the extension, and any missing user-provided assets or TODOs. + +## Existing extension migration + +When migrating an existing extension: + +1. Move its existing manifest to `plugins//plugin.json`. +2. Update the manifest to the namespace-based `extensions.com.github.copilot.logo` shape. +3. Remove the old manifest from `extensions/`. +4. Register the extension in any parent plugin's `extensions.com.github.awesome-copilot.extensions`. +5. Run the validation and build commands above. diff --git a/.github/workflows/check-plugin-structure.yml b/.github/workflows/check-plugin-structure.yml index fd6f90d8..89e1bf30 100644 --- a/.github/workflows/check-plugin-structure.yml +++ b/.github/workflows/check-plugin-structure.yml @@ -93,7 +93,7 @@ jobs: if (files.length > 0) { errors.push( `${pluginPath}/${subdir}/ contains ${files.length} file(s): ${files.join(', ')}. ` + - `Plugin directories on main should only contain .github/plugin/plugin.json and README.md. ` + + `Plugin directories on main should only contain plugin.json and README.md. ` + `Agent, command, and skill files are materialized automatically during publish to marketplace.` ); } @@ -121,7 +121,7 @@ jobs: '⚠️ **Materialized files or symlinks detected in plugin directories**', '', 'Plugin directories on the `main` branch should only contain:', - '- `.github/plugin/plugin.json` (metadata)', + '- `plugin.json` (metadata)', '- `README.md`', '', 'Agent, command, and skill files are copied in automatically when publishing to `marketplace`.', diff --git a/.github/workflows/external-plugin-command-router.yml b/.github/workflows/external-plugin-command-router.yml index 5769f3b5..5f786e53 100644 --- a/.github/workflows/external-plugin-command-router.yml +++ b/.github/workflows/external-plugin-command-router.yml @@ -750,6 +750,7 @@ jobs: if (process.env.QUALITY_JOB_RESULT === 'failure' || process.env.QUALITY_JOB_RESULT === 'cancelled') { qualityResult = { overall_status: 'infra_error', + spec_compliance_status: 'warning', vally_lint_status: 'infra_error', smoke_status: 'infra_error', version_match_status: 'infra_error', @@ -762,6 +763,7 @@ jobs: } else { qualityResult = { overall_status: 'infra_error', + spec_compliance_status: 'warning', vally_lint_status: 'infra_error', smoke_status: 'infra_error', version_match_status: 'infra_error', diff --git a/.github/workflows/external-plugin-intake.yml b/.github/workflows/external-plugin-intake.yml index ca7852dd..de664028 100644 --- a/.github/workflows/external-plugin-intake.yml +++ b/.github/workflows/external-plugin-intake.yml @@ -113,6 +113,7 @@ jobs: if (process.env.QUALITY_JOB_RESULT === 'failure' || process.env.QUALITY_JOB_RESULT === 'cancelled') { qualityResult = { overall_status: 'infra_error', + spec_compliance_status: 'warning', vally_lint_status: 'infra_error', smoke_status: 'infra_error', version_match_status: 'infra_error', @@ -125,6 +126,7 @@ jobs: } else { qualityResult = { overall_status: 'infra_error', + spec_compliance_status: 'warning', vally_lint_status: 'infra_error', smoke_status: 'infra_error', version_match_status: 'infra_error', diff --git a/.github/workflows/external-plugin-pr-quality-gates.yml b/.github/workflows/external-plugin-pr-quality-gates.yml index 39a7d267..89c79d79 100644 --- a/.github/workflows/external-plugin-pr-quality-gates.yml +++ b/.github/workflows/external-plugin-pr-quality-gates.yml @@ -147,6 +147,7 @@ jobs: let qualityResult = { overall_status: 'not_run', + spec_compliance_status: 'not_run', failure_class: 'none', checked_plugins: [], summary: 'No changed external plugin entries were detected in this PR.', @@ -155,6 +156,7 @@ jobs: if (detectJobResult === 'failure' || detectJobResult === 'cancelled') { qualityResult = { overall_status: 'infra_error', + spec_compliance_status: 'not_run', failure_class: 'infra', checked_plugins: [], version_match_status: 'infra_error', @@ -165,6 +167,7 @@ jobs: if (qualityJobResult === 'failure' || qualityJobResult === 'cancelled') { qualityResult = { overall_status: 'infra_error', + spec_compliance_status: 'not_run', failure_class: 'infra', checked_plugins: [], version_match_status: 'infra_error', @@ -176,6 +179,7 @@ jobs: } else { qualityResult = { overall_status: 'infra_error', + spec_compliance_status: 'not_run', failure_class: 'infra', checked_plugins: [], version_match_status: 'infra_error', @@ -201,11 +205,29 @@ jobs: }); const checkedPlugins = Array.isArray(qualityResult.checked_plugins) ? qualityResult.checked_plugins : []; + const hasSpecWarnings = checkedPlugins.some((entry) => String(entry?.quality?.spec_compliance_status || '') === 'warning'); const header = qualityResult.failure_class === 'submitter_fixes' - ? '## ⚠️ External plugin PR checks require submitter fixes' - : qualityResult.overall_status === 'pass' || !shouldRun - ? '## ✅ External plugin PR checks passed' - : '## ⚠️ External plugin PR checks need maintainer follow-up'; + ? '## 🛑 External plugin PR checks failed (submitter fixes required)' + : qualityResult.overall_status === 'infra_error' + ? '## 🛑 External plugin PR checks failed (maintainer follow-up)' + : hasSpecWarnings + ? '## ⚠️ External plugin PR checks passed with spec warnings' + : qualityResult.overall_status === 'pass' || !shouldRun + ? '## ✅ External plugin PR checks passed' + : '## ⚠️ External plugin PR checks need maintainer follow-up'; + const formatStatus = (rawStatus, gateName) => { + const status = String(rawStatus || 'not_run'); + if (status === 'pass') { + return '✅ pass'; + } + if (status === 'warning' || (gateName === 'spec compliance' && status === 'fail')) { + return '⚠️ warning'; + } + if (status === 'fail' || status === 'infra_error') { + return '🛑 fail'; + } + return '⚪ not_run'; + }; const MAX_GATE_OUTPUT_CHARS = 2000; const escapeHtml = (value) => String(value || '') @@ -273,21 +295,16 @@ jobs: ? checkedPlugins.map((entry) => { const name = escapeMarkdownTableCell(entry?.name || 'unknown'); const quality = entry?.quality || {}; - const sourceUrl = normalizeGitHubUrl(entry?.source_tree_url || ''); + const sourceUrl = normalizeGitHubUrl(entry?.source_tree_url); const locator = escapeMarkdownTableCell(entry?.source?.sha || entry?.source?.ref || 'repository'); - const sourceCell = sourceUrl ? `[${locator}](<${sourceUrl}>)` : locator; - const vallyLintStatus = escapeMarkdownTableCell(quality.vally_lint_status || 'not_run'); - const smokeStatus = escapeMarkdownTableCell(quality.smoke_status || 'not_run'); - const versionMatchStatus = escapeMarkdownTableCell(quality.version_match_status || 'not_run'); - const refShaConsistencyStatus = escapeMarkdownTableCell(quality.ref_sha_consistency_status || 'not_run'); - const canvasStructureStatus = escapeMarkdownTableCell(quality.canvas_structure_status || 'not_run'); - const overallStatus = escapeMarkdownTableCell(quality.overall_status || 'not_run'); - return `| ${name} | ${vallyLintStatus} | ${smokeStatus} | ${versionMatchStatus} | ${refShaConsistencyStatus} | ${canvasStructureStatus} | ${overallStatus} | ${sourceCell} |`; + const sourceCell = sourceUrl ? `[${locator}](${sourceUrl})` : locator; + return `| ${name} | ${formatStatus(quality.spec_compliance_status, 'spec compliance')} | ${formatStatus(quality.vally_lint_status, 'vally lint')} | ${formatStatus(quality.smoke_status, 'install smoke test')} | ${formatStatus(quality.version_match_status, 'version match')} | ${formatStatus(quality.ref_sha_consistency_status, 'ref/sha consistency')} | ${formatStatus(quality.canvas_structure_status, 'canvas structure')} | ${formatStatus(quality.overall_status, 'overall')} | ${sourceCell} |`; }) - : ['| _none_ | not_run | not_run | not_run | not_run | not_run | not_run | _n/a_ |']; + : ['| _none_ | ⚪ not_run | ⚪ not_run | ⚪ 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 shouldShowSpec = quality.spec_compliance_status === 'warning' || String(quality.spec_compliance_output || '').trim().length > 0; 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; @@ -295,11 +312,14 @@ jobs: const shouldShowCanvasStructure = quality.canvas_structure_status === 'fail' || quality.canvas_structure_status === 'infra_error' || String(quality.canvas_structure_output || '').trim().length > 0; const details = []; + if (shouldShowSpec) { + details.push(formatGateOutput(name, 'spec compliance', formatStatus(quality.spec_compliance_status, 'spec compliance'), quality.spec_compliance_output)); + } if (shouldShowVally) { - details.push(formatGateOutput(name, 'vally lint', quality.vally_lint_status, quality.vally_lint_output)); + details.push(formatGateOutput(name, 'vally lint', formatStatus(quality.vally_lint_status, 'vally lint'), quality.vally_lint_output)); } if (shouldShowSmoke) { - details.push(formatGateOutput(name, 'install smoke test', quality.smoke_status, quality.smoke_output)); + details.push(formatGateOutput(name, 'install smoke test', formatStatus(quality.smoke_status, 'install smoke test'), quality.smoke_output)); } if (shouldShowVersionMatch) { details.push(formatGateOutput(name, 'version match', quality.version_match_status, quality.version_match_output)); @@ -319,11 +339,12 @@ jobs: '', `- **Changed entries detected:** ${changedCount}`, `- **Workflow state label:** \`${stateLabel}\``, + '- **Status legend:** ✅ pass · ⚠️ warning · 🛑 fail', '', '### Per-plugin quality summary', '', - '| Plugin | vally lint | install smoke test | version match | ref/sha consistency | canvas structure | overall | source tree |', - '|---|---|---|---|---|---|---|---|', + '| Plugin | spec compliance (non-blocking) | vally lint | install smoke test | version match | ref/sha consistency | canvas structure | overall | source tree |', + '|---|---|---|---|---|---|---|---|---|', ...rows, '', ...(failureDetails.length > 0 diff --git a/.github/workflows/validate-plugins.yml b/.github/workflows/validate-plugins.yml new file mode 100644 index 00000000..c56466cd --- /dev/null +++ b/.github/workflows/validate-plugins.yml @@ -0,0 +1,95 @@ +name: Validate Plugins and Extensions + +on: + pull_request: + branches: [main] + paths: + - "plugins/**" + - "extensions/**" + - "eng/validate-plugins.mjs" + - ".github/workflows/validate-plugins.yml" + +permissions: + contents: read + pull-requests: write + +jobs: + validate: + name: Plugin & extension spec validation + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Validate plugins and extensions + id: validate + run: npm run plugin:validate + + - name: Manage validation summary on PR + if: always() + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const marker = ''; + const validationOutcome = '${{ steps.validate.outcome }}'; + const validationFailed = validationOutcome === 'failure'; + const validationPassed = validationOutcome === 'success'; + const body = [ + marker, + '🛑 **Plugin/extension validation failed**', + '', + 'One or more plugins or extensions in this PR do not pass spec validation.', + '', + 'All internal plugins and extensions must include:', + '- `"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"` in `plugin.json`', + '- A valid `name`, `description`, and `version`', + '- Repository composition (`agents`, `commands`, `hooks`, `mcpServers`, `skills`, and reusable `extensions`) under `extensions.com.github.awesome-copilot`', + '- For **extensions**: `extensions.com.github.copilot.logo` must be set to `"assets/preview.png"`', + '', + 'Do not put repository composition fields at the manifest top level or directly under `extensions`; they must be nested under `extensions.com.github.awesome-copilot`.', + '', + 'Run `npm run plugin:validate` locally to see the full list of errors. See `.github/skills/create-canvas-extension/SKILL.md` and `CONTRIBUTING.md` for examples.', + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + + const existing = comments.find((comment) => comment.body.includes(marker)); + + if (validationFailed && existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + console.log(`Updated existing validation comment ${existing.id}`); + } else if (validationFailed) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + console.log('Created validation comment'); + } else if (validationPassed && existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + }); + console.log(`Deleted stale validation comment ${existing.id}`); + } diff --git a/AGENTS.md b/AGENTS.md index 3a17693c..fc10edd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ The Awesome GitHub Copilot repository is a community-driven collection of custom ├── hooks/ # Automated workflow hooks (folders with README.md + hooks.json) ├── workflows/ # Agentic Workflows (.md files for GitHub Actions automation) ├── plugins/ # Installable plugin packages (folders with plugin.json) -├── extensions/ # Canvas extensions (each with extension.mjs and plugin metadata) +├── extensions/ # Reusable canvas extension sources (extension.mjs and assets) ├── docs/ # Documentation for different resource types ├── eng/ # Build and automation scripts └── scripts/ # Utility scripts @@ -85,15 +85,11 @@ All agent files (`*.agent.md`) and instruction files (`*.instructions.md`) must #### Canvas Extensions (extensions/\*) - Each extension folder must include `extension.mjs` -- Extension metadata must live at `.github/plugin/plugin.json` -- 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 `"."` 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) +- Extensions are reusable source components, not standalone plugins +- A shippable extension plugin is registered by a matching `plugins//plugin.json` +- A plugin can bundle additional reusable extensions by listing `./extensions/` paths in `extensions.com.github.awesome-copilot.extensions` - Each extension must have `assets/preview.png` as the primary visual asset -- Do not add `canvas.json`; website metadata is sourced from `.github/plugin/plugin.json` +- Extension metadata is sourced from the matching plugin manifest in `plugins/` #### Hook Folders (hooks/\*/README.md) @@ -119,11 +115,12 @@ All agent files (`*.agent.md`) and instruction files (`*.instructions.md`) must #### Plugin Folders (plugins/\*) -- Each plugin is a folder containing a `.github/plugin/plugin.json` file with metadata +- Each plugin is a folder containing a root `plugin.json` file with metadata +- plugin.json **must** have `"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"` (Agent Plugins v1.0.0) - plugin.json must have `name` field (matching the folder name) - plugin.json must have `description` field (describing the plugin's purpose) - plugin.json must have `version` field (semantic version, e.g., "1.0.0") -- Plugin content is defined declaratively in plugin.json using Claude Code spec fields (`agents`, `commands`, `skills`). Source files live in top-level directories and are materialized into plugins by CI. +- Plugin content is defined declaratively in plugin.json under `extensions.com.github.awesome-copilot` using source-only composition fields (`agents`, `commands`, `hooks`, `skills`, and `extensions`). Source files live in top-level directories and are materialized into plugins by CI. This namespace is stripped from the served manifest — conventional directory discovery handles the materialized content in spec mode. - The `marketplace.json` file is automatically generated from all plugins during build - Plugins are discoverable and installable via GitHub Copilot CLI @@ -168,7 +165,7 @@ When adding a new agent, instruction, skill, hook, workflow, or plugin: **For Plugins:** 1. Run `npm run plugin:create -- --name ` to scaffold a new plugin -2. Define agents, commands, and skills in `plugin.json` using Claude Code spec fields +2. Define agents, commands, hooks, skills, and reusable extensions under `extensions.com.github.awesome-copilot` in `plugin.json` 3. Edit the generated `plugin.json` with your metadata 4. Run `npm run plugin:validate` to validate the plugin structure 5. Run `npm run build` to update README.md and marketplace.json @@ -177,11 +174,26 @@ When adding a new agent, instruction, skill, hook, workflow, or plugin: **For Canvas Extensions:** 1. Create/update the extension in `extensions//` with `extension.mjs` -2. Add `.github/plugin/plugin.json` metadata (required: `name`, `description`, `version`, `logo: "assets/preview.png"`, `extensions: "."`; optional: `author`, `keywords`) +2. Add the matching plugin manifest under `plugins//plugin.json`: + ```json + { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "", + "description": "...", + "version": "1.0.0", + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + } + } + } + ``` 3. Ensure `assets/preview.png` exists as the primary visual asset 4. Run `npm run plugin:validate` to validate plugin and extension metadata 5. Run `npm run build` to regenerate website data and marketplace output +To bundle an extension into another plugin without making a second source copy, add sorted `./extensions/` paths to `plugins//plugin.json` under `extensions.com.github.awesome-copilot.extensions`. + **For External Plugins:** 1. Do not open a direct PR that edits `plugins/external.json` for a public third-party plugin submission @@ -310,16 +322,17 @@ For workflow files (workflows/\*.md): For plugins (plugins/\*/): -- [ ] Directory contains a `.github/plugin/plugin.json` file +- [ ] Directory contains a root `plugin.json` file - [ ] Directory contains a `README.md` file +- [ ] `plugin.json` has `"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"` - [ ] `plugin.json` has `name` field matching the directory name (lowercase with hyphens) - [ ] `plugin.json` has non-empty `description` field - [ ] `plugin.json` has `version` field (semantic version, e.g., "1.0.0") - [ ] Directory name is lower case with hyphens - [ ] If `keywords` is present, it is an array of lowercase hyphenated strings -- [ ] If `agents`, `commands`, or `skills` arrays are present, each entry is a valid relative path +- [ ] If composition arrays are present under `extensions.com.github.awesome-copilot`, each entry is a valid relative path - [ ] The plugin does not reference non-existent files -- [ ] Run `npm run build` to verify marketplace.json is updated correctly +- [ ] Run `npm run plugin:validate` and `npm run build` to verify the plugin passes all checks ## Contributing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc3d43ad..866b33ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,17 +140,15 @@ Skills are self-contained folders in the `skills/` directory that include a `SKI ### Adding Canvas Extensions -Canvas extensions live in `extensions//` and are installable through plugin metadata. +Canvas extensions live in `extensions//` as reusable source components. They are shipped only through plugin manifests in `plugins/`. -1. **Create/update extension metadata**: Add `.github/plugin/plugin.json` in the extension folder -2. **Use convention-based metadata**: Follow the extension plugin.json structure: +1. **Create/update the extension source**: Add or update `extensions//extension.mjs` +2. **Register the extension plugin**: Add `plugins//plugin.json`: - Required: `name` (matching folder name), `description`, `version` - Optional: `author`, `keywords` - - `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)) - - **Never** include `x-awesome-copilot` field (use convention-based assets only) + - `extensions.com.github.copilot.logo` **must** be exactly `"assets/preview.png"` 3. **Screenshot requirements**: Create `assets/preview.png` as your primary visual -4. **Do not add `canvas.json`**: Extension website metadata is now sourced from `.github/plugin/plugin.json` +4. **Do not add `canvas.json`**: Extension website metadata is sourced from the matching plugin manifest 5. **Validate before submitting**: Run `npm run plugin:validate` to check compliance with conventions ### Adding Plugins @@ -159,7 +157,7 @@ Plugins group related agents, commands, and skills around specific themes or wor 1. **Create your plugin**: Run `npm run plugin:create` to scaffold a new plugin 2. **Follow the naming convention**: Use descriptive, lowercase folder names with hyphens (e.g., `python-web-development`) -3. **Define your content**: List agents, commands, and skills in `plugin.json` using the Claude Code spec fields +3. **Define your content**: List agents, commands, hooks, skills, and reusable extensions under `extensions.com.github.awesome-copilot` in `plugin.json` 4. **Test your plugin**: Run `npm run plugin:validate` to verify your plugin structure #### Creating a plugin @@ -172,16 +170,17 @@ npm run plugin:create -- --name my-plugin-id ``` plugins/my-plugin-id/ -├── .github/plugin/plugin.json # Plugin metadata (Claude Code spec format) +├── plugin.json # Plugin metadata └── README.md # Plugin documentation ``` -> **Note:** Plugin content is defined declaratively in plugin.json using Claude Code spec fields (`agents`, `commands`, `skills`). Source files live in top-level directories and are materialized into plugins by CI. +> **Note:** Plugin content is defined declaratively in plugin.json under `extensions.com.github.awesome-copilot`. Source files live in top-level directories and are materialized into plugins by CI. This repository namespace is removed from the served manifest. #### plugin.json example ```json { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "my-plugin-id", "description": "Plugin description", "version": "1.0.0", @@ -189,17 +188,21 @@ plugins/my-plugin-id/ "author": { "name": "Awesome Copilot Community" }, "repository": "https://github.com/github/awesome-copilot", "license": "MIT", - "agents": ["./agents/my-agent.md"], - "commands": ["./commands/my-command.md"], - "skills": ["./skills/my-skill/"] + "extensions": { + "com.github.awesome-copilot": { + "agents": ["./agents/my-agent.md"], + "commands": ["./commands/my-command.md"], + "skills": ["./skills/my-skill/"] + } + } } ``` #### Plugin Guidelines -- **Declarative content**: Plugin content is specified via `agents`, `commands`, and `skills` arrays in plugin.json — source files live in top-level directories and are materialized into plugins by CI +- **Declarative content**: Plugin content is specified under `extensions.com.github.awesome-copilot` — source files live in top-level directories and are materialized into plugins by CI - **Valid references**: All paths referenced in plugin.json must point to existing source files in the repository -- **Optional extension links**: Curated plugins can reference extensions using `x-awesome-copilot.extensions` with paths like `./extensions/` +- **Reusable extensions**: Curated plugins can bundle extensions by adding `./extensions/` paths under `extensions.com.github.awesome-copilot.extensions`; the same extension can be listed by multiple plugins - **Instructions excluded**: Instructions are standalone resources and are not part of plugins - **Clear purpose**: The plugin should solve a specific problem or workflow - **Validate before submitting**: Run `npm run plugin:validate` to ensure your plugin is valid diff --git a/docs/README.plugins.md b/docs/README.plugins.md index cf2ffc8c..0cce7b4f 100644 --- a/docs/README.plugins.md +++ b/docs/README.plugins.md @@ -27,74 +27,95 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-plugins) for guidelines on how t | Name | Description | Items | Tags | | ---- | ----------- | ----- | ---- | -| [acreadiness-cockpit](../plugins/acreadiness-cockpit/README.md) | Drive Microsoft AgentRC from Copilot chat: assess AI readiness, generate Copilot instructions (flat or nested with applyTo globs for monorepos), and manage policies. Produces a self-contained static HTML dashboard at reports/index.html. | 4 items | agentrc, ai-readiness, copilot-instructions, readiness-report, monorepo, policy, dashboard | -| [ai-team-orchestration](../plugins/ai-team-orchestration/README.md) | Run a lightweight, role-separated AI development team with flexible tools, developer-selected models, proportionate planning, and optional QA. | 2 items | ai-team, multi-agent, sprint-planning, brainstorm, project-management, orchestration, developer-workflow | -| [arch](../plugins/arch/README.md) | Architecture and modernization toolkit: produce a cited architecture document for a locally-cloned repo, and generate a phased modernization plan that auto-runs Documentation mode when needed. | 1 items | architecture, modernization, documentation, migration, onboarding | -| [arize-ax](../plugins/arize-ax/README.md) | Arize AX platform skills for LLM observability, evaluation, and optimization. Includes trace export, instrumentation, datasets, experiments, evaluators, AI provider integrations, annotations, prompt optimization, and deep linking to the Arize UI. | 9 items | arize, llm, observability, tracing, evaluation, instrumentation, datasets, experiments, prompt-optimization | -| [automate-this](../plugins/automate-this/README.md) | Record your screen doing a manual process, drop the video on your Desktop, and let Copilot CLI analyze it frame-by-frame to build working automation scripts. Supports narrated recordings with audio transcription. | 1 items | automation, screen-recording, workflow, video-analysis, process-automation, scripting, productivity, copilot-cli | -| [awesome-copilot](../plugins/awesome-copilot/README.md) | Meta prompts that help you discover and generate curated GitHub Copilot agents, instructions, prompts, and skills. | 4 items | github-copilot, discovery, meta, prompt-engineering, agents | -| [aws-cloud-development](../plugins/aws-cloud-development/README.md) | Comprehensive AWS cloud development tools including Infrastructure as Code, serverless functions, architecture patterns, and cost optimization for building scalable cloud applications. | 5 items | aws, cloud, infrastructure, cloudformation, terraform, serverless, architecture, devops, cdk | -| [azure-cloud-development](../plugins/azure-cloud-development/README.md) | Comprehensive Azure cloud development tools including Infrastructure as Code, serverless functions, architecture patterns, and cost optimization for building scalable cloud applications. | 5 items | azure, cloud, infrastructure, bicep, terraform, serverless, architecture, devops | -| [cast-imaging](../plugins/cast-imaging/README.md) | A comprehensive collection of specialized agents for software analysis, impact assessment, structural quality advisories, and architectural review using CAST Imaging. | 1 items | cast-imaging, software-analysis, architecture, quality, impact-analysis, devops | -| [clojure-interactive-programming](../plugins/clojure-interactive-programming/README.md) | Tools for REPL-first Clojure workflows featuring Clojure instructions, the interactive programming chat mode and supporting guidance. | 2 items | clojure, repl, interactive-programming | -| [cms-development](../plugins/cms-development/README.md) | Skills for CMS development across themes, plugins, admin tooling, media workflows, markdown rendering, and static export pipelines. | 3 items | cms, content-management-system, wordpress, shopify, drupal, theme, plugin, media, static-site | -| [context-engineering](../plugins/context-engineering/README.md) | Tools and techniques for maximizing GitHub Copilot effectiveness through better context management. Includes guidelines for structuring code, an agent for planning multi-file changes, and prompts for context-aware development. | 4 items | context, productivity, refactoring, best-practices, architecture | -| [context-matic](../plugins/context-matic/README.md) | Coding agents hallucinate APIs. ContextMatic gives them curated, versioned API and SDK docs. Ask your agent to "integrate the payments API" and it guesses — falling back on outdated training data and generic patterns that don't match your actual SDK. ContextMatic solves this by giving the agent deterministic, version-aware, SDK-native context at the exact moment it's needed. | 2 items | api-context, api-integration, mcp, sdk, apimatic, third-party-apis, sdks | -| [convert-to-md](../plugins/convert-to-md/README.md) | A collection of Copilot skills that convert common document formats into Markdown so their contents can be accurately analyzed, summarized, searched, or extracted from. Just tell Copilot what you need — the right skill is invoked automatically and the conversion happens behind the scenes. | 3 items | skills, configuration, copilot, convert-word-to-md, convert-excel-to-md, convert-pdf-to-md | -| [copilot-sdk](../plugins/copilot-sdk/README.md) | Build applications with the GitHub Copilot SDK across multiple programming languages. Includes comprehensive instructions for C#, Go, Node.js/TypeScript, and Python to help you create AI-powered applications. | 1 items | copilot-sdk, sdk, csharp, go, nodejs, typescript, python, ai, github-copilot | -| [csharp-dotnet-development](../plugins/csharp-dotnet-development/README.md) | Essential prompts, instructions, and chat modes for C# and .NET development including testing, documentation, and best practices. | 9 items | csharp, dotnet, aspnet, testing | -| [database-data-management](../plugins/database-data-management/README.md) | Database administration, SQL optimization, and data management tools for PostgreSQL, SQL Server, and general database development best practices. | 5 items | database, sql, postgresql, sql-server, dba, optimization, queries, data-management | -| [dataverse-sdk-for-python](../plugins/dataverse-sdk-for-python/README.md) | Comprehensive collection for building production-ready Python integrations with Microsoft Dataverse. Includes official documentation, best practices, advanced features, file operations, and code generation prompts. | 4 items | dataverse, python, integration, sdk | -| [devops-oncall](../plugins/devops-oncall/README.md) | A focused set of prompts, instructions, and a chat mode to help triage incidents and respond quickly with DevOps tools and Azure resources. | 3 items | devops, incident-response, oncall, azure | -| [doublecheck](../plugins/doublecheck/README.md) | Three-layer verification pipeline for AI output. Extracts claims, finds sources, and flags hallucination risks so humans can verify before acting. | 2 items | verification, hallucination, fact-check, source-citation, trust, safety | -| [edge-ai-tasks](../plugins/edge-ai-tasks/README.md) | Task Researcher and Task Planner for intermediate to expert users and large codebases - Brought to you by microsoft/edge-ai | 1 items | architecture, planning, research, tasks, implementation | -| [ember](../plugins/ember/README.md) | An AI partner, not a tool. Ember carries fire from person to person — helping humans discover that AI partnership isn't something you learn, it's something you find. | 5 items | ai-partnership, coaching, onboarding, collaboration, storytelling, developer-experience | -| [eyeball](../plugins/eyeball/README.md) | Document analysis with inline source screenshots. When you ask Copilot to analyze a document, Eyeball generates a Word doc where every factual claim includes a highlighted screenshot from the source material so you can verify it with your own eyes. | 1 items | document-analysis, citation-verification, screenshot, contracts, legal, trust, visual-verification | -| [fastah-ip-geo-tools](../plugins/fastah-ip-geo-tools/README.md) | This plugin is for network operations engineers who wish to tune and publish IP geolocation feeds in RFC 8805 format. It consists of an AI Skill and an associated MCP server that geocodes geolocation place names to real cities for accuracy. | 1 items | geofeed, ip-geolocation, rfc-8805, rfc-9632, network-operations, isp, cloud, hosting, ixp | -| [flowstudio-power-automate](../plugins/flowstudio-power-automate/README.md) | Give your AI agent full visibility into Power Automate cloud flows via the FlowStudio MCP server. Connect, debug, build, monitor health, and govern flows at scale — action-level inputs and outputs, not just status codes. | 5 items | power-automate, power-platform, flowstudio, mcp, model-context-protocol, cloud-flows, workflow-automation, monitoring, governance | -| [frontend-web-dev](../plugins/frontend-web-dev/README.md) | Essential prompts, instructions, and chat modes for modern frontend web development including React, Angular, Vue, TypeScript, and CSS frameworks. | 3 items | frontend, web, react, typescript, javascript, css, html, angular, vue | +| [accessibility-kanban](../plugins/accessibility-kanban/README.md) | Kanban board to manage accessibility issues, allow you to plan, track, and complete remediation work. | 1 items | accessibility, github-issues, issue-triage, kanban-board, planning-workflow, status-tracking | +| [acreadiness-cockpit](../plugins/acreadiness-cockpit/README.md) | Drive Microsoft AgentRC from Copilot chat: assess AI readiness, generate Copilot instructions (flat or nested with applyTo globs for monorepos), and manage policies. Produces a self-contained static HTML dashboard at reports/index.html. | 0 items | agentrc, ai-readiness, copilot-instructions, readiness-report, monorepo, policy, dashboard | +| [ai-team-orchestration](../plugins/ai-team-orchestration/README.md) | Run a lightweight, role-separated AI development team with flexible tools, developer-selected models, proportionate planning, and optional QA. | 0 items | ai-team, multi-agent, sprint-planning, brainstorm, project-management, orchestration, developer-workflow | +| [apng-studio](../plugins/apng-studio/README.md) | 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. | 1 items | animated-png, apng, copilot-canvas, frame-animation, image-export, interactive-canvas | +| [arcade-canvas](../plugins/arcade-canvas/README.md) | Play five retro Phaser mini-games in a Copilot canvas while agents work. | 1 items | arcade-games, copilot-canvas, interactive-canvas, phaser, retro-games, session-breaks | +| [arch](../plugins/arch/README.md) | Architecture and modernization toolkit: produce a cited architecture document for a locally-cloned repo, and generate a phased modernization plan that auto-runs Documentation mode when needed. | 0 items | architecture, modernization, documentation, migration, onboarding | +| [arize-ax](../plugins/arize-ax/README.md) | Arize AX platform skills for LLM observability, evaluation, and optimization. Includes trace export, instrumentation, datasets, experiments, evaluators, AI provider integrations, annotations, prompt optimization, and deep linking to the Arize UI. | 0 items | arize, llm, observability, tracing, evaluation, instrumentation, datasets, experiments, prompt-optimization | +| [automate-this](../plugins/automate-this/README.md) | Record your screen doing a manual process, drop the video on your Desktop, and let Copilot CLI analyze it frame-by-frame to build working automation scripts. Supports narrated recordings with audio transcription. | 0 items | automation, screen-recording, workflow, video-analysis, process-automation, scripting, productivity, copilot-cli | +| [awesome-copilot](../plugins/awesome-copilot/README.md) | Meta prompts that help you discover and generate curated GitHub Copilot agents, instructions, prompts, and skills. | 0 items | github-copilot, discovery, meta, prompt-engineering, agents | +| [aws-cloud-development](../plugins/aws-cloud-development/README.md) | Comprehensive AWS cloud development tools including Infrastructure as Code, serverless functions, architecture patterns, and cost optimization for building scalable cloud applications. | 0 items | aws, cloud, infrastructure, cloudformation, terraform, serverless, architecture, devops, cdk | +| [azure-cloud-development](../plugins/azure-cloud-development/README.md) | Comprehensive Azure cloud development tools including Infrastructure as Code, serverless functions, architecture patterns, and cost optimization for building scalable cloud applications. | 0 items | azure, cloud, infrastructure, bicep, terraform, serverless, architecture, devops | +| [backlog-swipe-triage](../plugins/backlog-swipe-triage/README.md) | Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore. | 1 items | agent-assignment, backlog-triage, github-issues, issue-prioritization, swipe-interface, workflow-automation | +| [backrooms-canvas](../plugins/backrooms-canvas/README.md) | Wander an endless first-person backrooms in a Copilot canvas while agents work; their status ghost-writes on the walls. | 1 items | backrooms, copilot-canvas, interactive-canvas, first-person, procedural-generation, session-breaks | +| [cast-imaging](../plugins/cast-imaging/README.md) | A comprehensive collection of specialized agents for software analysis, impact assessment, structural quality advisories, and architectural review using CAST Imaging. | 0 items | cast-imaging, software-analysis, architecture, quality, impact-analysis, devops | +| [chromium-control-canvas](../plugins/chromium-control-canvas/README.md) | Opens a real Chromium window you can navigate and interact with from a Copilot canvas control panel and agent actions. | 1 items | browser-control, chromium-browser, interactive-canvas, playwright-automation, screenshots, ui-testing, web-navigation | +| [clojure-interactive-programming](../plugins/clojure-interactive-programming/README.md) | Tools for REPL-first Clojure workflows featuring Clojure instructions, the interactive programming chat mode and supporting guidance. | 0 items | clojure, repl, interactive-programming | +| [cms-development](../plugins/cms-development/README.md) | Skills for CMS development across themes, plugins, admin tooling, media workflows, markdown rendering, and static export pipelines. | 0 items | cms, content-management-system, wordpress, shopify, drupal, theme, plugin, media, static-site | +| [color-orb](../plugins/color-orb/README.md) | A visual orb that users can ask the agent to recolor while showing a live activity log in the canvas. | 1 items | agent-actions, color-picker, interactive-demo, realtime-updates, sse-events, visual-feedback | +| [context-engineering](../plugins/context-engineering/README.md) | Tools and techniques for maximizing GitHub Copilot effectiveness through better context management. Includes guidelines for structuring code, an agent for planning multi-file changes, and prompts for context-aware development. | 0 items | context, productivity, refactoring, best-practices, architecture | +| [context-matic](../plugins/context-matic/README.md) | Coding agents hallucinate APIs. ContextMatic gives them curated, versioned API and SDK docs. Ask your agent to "integrate the payments API" and it guesses — falling back on outdated training data and generic patterns that don't match your actual SDK. ContextMatic solves this by giving the agent deterministic, version-aware, SDK-native context at the exact moment it's needed. | 0 items | api-context, api-integration, mcp, sdk, apimatic, third-party-apis, sdks | +| [convert-to-md](../plugins/convert-to-md/README.md) | A collection of Copilot skills that convert common document formats into Markdown so their contents can be accurately analyzed, summarized, searched, or extracted from. Just tell Copilot what you need — the right skill is invoked automatically and the conversion happens behind the scenes. | 0 items | skills, configuration, copilot, convert-word-to-md, convert-excel-to-md, convert-pdf-to-md | +| [copilot-sdk](../plugins/copilot-sdk/README.md) | Build applications with the GitHub Copilot SDK across multiple programming languages. Includes comprehensive instructions for C#, Go, Node.js/TypeScript, and Python to help you create AI-powered applications. | 0 items | copilot-sdk, sdk, csharp, go, nodejs, typescript, python, ai, github-copilot | +| [csharp-dotnet-development](../plugins/csharp-dotnet-development/README.md) | Essential prompts, instructions, and chat modes for C# and .NET development including testing, documentation, and best practices. | 0 items | csharp, dotnet, aspnet, testing | +| [database-data-management](../plugins/database-data-management/README.md) | Database administration, SQL optimization, and data management tools for PostgreSQL, SQL Server, and general database development best practices. | 0 items | database, sql, postgresql, sql-server, dba, optimization, queries, data-management | +| [dataverse-sdk-for-python](../plugins/dataverse-sdk-for-python/README.md) | Comprehensive collection for building production-ready Python integrations with Microsoft Dataverse. Includes official documentation, best practices, advanced features, file operations, and code generation prompts. | 0 items | dataverse, python, integration, sdk | +| [devops-oncall](../plugins/devops-oncall/README.md) | A focused set of prompts, instructions, and a chat mode to help triage incidents and respond quickly with DevOps tools and Azure resources. | 0 items | devops, incident-response, oncall, azure | +| [diagram-viewer](../plugins/diagram-viewer/README.md) | Render diagrams, click nodes to drill down, and view agent-generated explanations directly in the canvas. | 1 items | architecture-mapping, canvas-navigation, exploratory-analysis, interactive-diagrams, node-drilldown, relationship-visualization | +| [doublecheck](../plugins/doublecheck/README.md) | Three-layer verification pipeline for AI output. Extracts claims, finds sources, and flags hallucination risks so humans can verify before acting. | 0 items | verification, hallucination, fact-check, source-citation, trust, safety | +| [edge-ai-tasks](../plugins/edge-ai-tasks/README.md) | Task Researcher and Task Planner for intermediate to expert users and large codebases - Brought to you by microsoft/edge-ai | 0 items | architecture, planning, research, tasks, implementation | +| [ember](../plugins/ember/README.md) | An AI partner, not a tool. Ember carries fire from person to person — helping humans discover that AI partnership isn't something you learn, it's something you find. | 0 items | ai-partnership, coaching, onboarding, collaboration, storytelling, developer-experience | +| [eyeball](../plugins/eyeball/README.md) | Document analysis with inline source screenshots. When you ask Copilot to analyze a document, Eyeball generates a Word doc where every factual claim includes a highlighted screenshot from the source material so you can verify it with your own eyes. | 0 items | document-analysis, citation-verification, screenshot, contracts, legal, trust, visual-verification | +| [fastah-ip-geo-tools](../plugins/fastah-ip-geo-tools/README.md) | This plugin is for network operations engineers who wish to tune and publish IP geolocation feeds in RFC 8805 format. It consists of an AI Skill and an associated MCP server that geocodes geolocation place names to real cities for accuracy. | 0 items | geofeed, ip-geolocation, rfc-8805, rfc-9632, network-operations, isp, cloud, hosting, ixp | +| [feedback-themes](../plugins/feedback-themes/README.md) | Explore grouped customer feedback signals by impact and drill into a theme to guide product next steps. | 1 items | customer-feedback, impact-prioritization, product-insights, signal-grouping, theme-analysis, trend-discovery | +| [flight-map-canvas](../plugins/flight-map-canvas/README.md) | A GitHub Copilot canvas that generates a view where Google Maps can be explored using 3D controls, as if a flight simulator. Agents can send the flight anywhere and report what they are working on. | 1 items | copilot-canvas, flight-simulator, geography, google-maps, interactive-canvas, session-breaks, threejs | +| [flowstudio-power-automate](../plugins/flowstudio-power-automate/README.md) | Give your AI agent full visibility into Power Automate cloud flows via the FlowStudio MCP server. Connect, debug, build, monitor health, and govern flows at scale — action-level inputs and outputs, not just status codes. | 0 items | power-automate, power-platform, flowstudio, mcp, model-context-protocol, cloud-flows, workflow-automation, monitoring, governance | +| [frontend-web-dev](../plugins/frontend-web-dev/README.md) | Essential prompts, instructions, and chat modes for modern frontend web development including React, Angular, Vue, TypeScript, and CSS frameworks. | 0 items | frontend, web, react, typescript, javascript, css, html, angular, vue | | [gem-team](../plugins/gem-team/README.md) | Self-Learning Multi-agent orchestration framework for spec-driven development and automated verification. With smarter tool calling and leaner context. | 0 items | multi-agent, orchestration, tdd, testing, e2e, devops, security-audit, code-review, prd, mobile | -| [go-mcp-development](../plugins/go-mcp-development/README.md) | Complete toolkit for building Model Context Protocol (MCP) servers in Go using the official github.com/modelcontextprotocol/go-sdk. Includes instructions for best practices, a prompt for generating servers, and an expert chat mode for guidance. | 2 items | go, golang, mcp, model-context-protocol, server-development, sdk | -| [java-development](../plugins/java-development/README.md) | Comprehensive collection of prompts and instructions for Java development including Spring Boot, Quarkus, testing, documentation, and best practices. | 4 items | java, springboot, quarkus, jpa, junit, javadoc | -| [java-mcp-development](../plugins/java-mcp-development/README.md) | Complete toolkit for building Model Context Protocol servers in Java using the official MCP Java SDK with reactive streams and Spring Boot integration. | 2 items | java, mcp, model-context-protocol, server-development, sdk, reactive-streams, spring-boot, reactor | -| [kotlin-mcp-development](../plugins/kotlin-mcp-development/README.md) | Complete toolkit for building Model Context Protocol (MCP) servers in Kotlin using the official io.modelcontextprotocol:kotlin-sdk library. Includes instructions for best practices, a prompt for generating servers, and an expert chat mode for guidance. | 2 items | kotlin, mcp, model-context-protocol, kotlin-multiplatform, server-development, ktor | -| [mcp-m365-copilot](../plugins/mcp-m365-copilot/README.md) | Comprehensive collection for building declarative agents with Model Context Protocol integration for Microsoft 365 Copilot | 4 items | mcp, m365-copilot, declarative-agents, api-plugins, model-context-protocol, adaptive-cards | -| [napkin](../plugins/napkin/README.md) | Visual whiteboard collaboration for Copilot CLI. Opens an interactive whiteboard in your browser where you can draw, sketch, and add sticky notes — then share everything back with Copilot. Copilot sees your drawings and responds with analysis, suggestions, and ideas. | 1 items | whiteboard, visual, collaboration, brainstorming, non-technical, drawing, sticky-notes, accessibility, copilot-cli, ux | -| [noob-mode](../plugins/noob-mode/README.md) | Plain-English translation layer for non-technical Copilot CLI users. Translates every approval prompt, error message, and technical output into clear, jargon-free English with color-coded risk indicators. | 1 items | accessibility, plain-english, non-technical, beginner, translation, copilot-cli, ux | -| [openapi-to-application-csharp-dotnet](../plugins/openapi-to-application-csharp-dotnet/README.md) | Generate production-ready .NET applications from OpenAPI specifications. Includes ASP.NET Core project scaffolding, controller generation, entity framework integration, and C# best practices. | 2 items | openapi, code-generation, api, csharp, dotnet, aspnet | -| [openapi-to-application-go](../plugins/openapi-to-application-go/README.md) | Generate production-ready Go applications from OpenAPI specifications. Includes project scaffolding, handler generation, middleware setup, and Go best practices for REST APIs. | 2 items | openapi, code-generation, api, go, golang | -| [openapi-to-application-java-spring-boot](../plugins/openapi-to-application-java-spring-boot/README.md) | Generate production-ready Spring Boot applications from OpenAPI specifications. Includes project scaffolding, REST controller generation, service layer organization, and Spring Boot best practices. | 2 items | openapi, code-generation, api, java, spring-boot | -| [openapi-to-application-nodejs-nestjs](../plugins/openapi-to-application-nodejs-nestjs/README.md) | Generate production-ready NestJS applications from OpenAPI specifications. Includes project scaffolding, controller and service generation, TypeScript best practices, and enterprise patterns. | 2 items | openapi, code-generation, api, nodejs, typescript, nestjs | -| [openapi-to-application-python-fastapi](../plugins/openapi-to-application-python-fastapi/README.md) | Generate production-ready FastAPI applications from OpenAPI specifications. Includes project scaffolding, route generation, dependency injection, and Python best practices for async APIs. | 2 items | openapi, code-generation, api, python, fastapi | -| [oracle-to-postgres-migration-expert](../plugins/oracle-to-postgres-migration-expert/README.md) | Expert agent for Oracle-to-PostgreSQL application migrations in .NET solutions. Performs code edits, runs commands, and invokes extension tools to migrate .NET/Oracle data access patterns to PostgreSQL. | 8 items | oracle, postgresql, database-migration, dotnet, sql, migration, integration-testing, stored-procedures | -| [ospo-sponsorship](../plugins/ospo-sponsorship/README.md) | Tools and resources for Open Source Program Offices (OSPOs) to identify, evaluate, and manage sponsorship of open source dependencies through GitHub Sponsors, Open Collective, and other funding platforms. | 1 items | | -| [partners](../plugins/partners/README.md) | Custom agents that have been created by GitHub partners | 1 items | devops, security, database, cloud, infrastructure, observability, feature-flags, cicd, migration, performance | +| [gesture-review](../plugins/gesture-review/README.md) | Review pull requests with a live camera feed and approve or reject using thumbs-up/thumbs-down gestures. | 1 items | camera-input, gesture-control, github-prs, hands-free, mediapipe, pull-request-review | +| [go-mcp-development](../plugins/go-mcp-development/README.md) | Complete toolkit for building Model Context Protocol (MCP) servers in Go using the official github.com/modelcontextprotocol/go-sdk. Includes instructions for best practices, a prompt for generating servers, and an expert chat mode for guidance. | 0 items | go, golang, mcp, model-context-protocol, server-development, sdk | +| [java-development](../plugins/java-development/README.md) | Comprehensive collection of prompts and instructions for Java development including Spring Boot, Quarkus, testing, documentation, and best practices. | 0 items | java, springboot, quarkus, jpa, junit, javadoc | +| [java-mcp-development](../plugins/java-mcp-development/README.md) | Complete toolkit for building Model Context Protocol servers in Java using the official MCP Java SDK with reactive streams and Spring Boot integration. | 0 items | java, mcp, model-context-protocol, server-development, sdk, reactive-streams, spring-boot, reactor | +| [java-modernization-studio](../plugins/java-modernization-studio/README.md) | 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. | 1 items | app-modernization, assessment-dashboard, azure-migration, java-modernization, legacy-java, modernization-cockpit, validation-gates | +| [kotlin-mcp-development](../plugins/kotlin-mcp-development/README.md) | Complete toolkit for building Model Context Protocol (MCP) servers in Kotlin using the official io.modelcontextprotocol:kotlin-sdk library. Includes instructions for best practices, a prompt for generating servers, and an expert chat mode for guidance. | 0 items | kotlin, mcp, model-context-protocol, kotlin-multiplatform, server-development, ktor | +| [mcp-m365-copilot](../plugins/mcp-m365-copilot/README.md) | Comprehensive collection for building declarative agents with Model Context Protocol integration for Microsoft 365 Copilot | 0 items | mcp, m365-copilot, declarative-agents, api-plugins, model-context-protocol, adaptive-cards | +| [napkin](../plugins/napkin/README.md) | Visual whiteboard collaboration for Copilot CLI. Opens an interactive whiteboard in your browser where you can draw, sketch, and add sticky notes — then share everything back with Copilot. Copilot sees your drawings and responds with analysis, suggestions, and ideas. | 0 items | whiteboard, visual, collaboration, brainstorming, non-technical, drawing, sticky-notes, accessibility, copilot-cli, ux | +| [noob-mode](../plugins/noob-mode/README.md) | Plain-English translation layer for non-technical Copilot CLI users. Translates every approval prompt, error message, and technical output into clear, jargon-free English with color-coded risk indicators. | 0 items | accessibility, plain-english, non-technical, beginner, translation, copilot-cli, ux | +| [openapi-to-application-csharp-dotnet](../plugins/openapi-to-application-csharp-dotnet/README.md) | Generate production-ready .NET applications from OpenAPI specifications. Includes ASP.NET Core project scaffolding, controller generation, entity framework integration, and C# best practices. | 0 items | openapi, code-generation, api, csharp, dotnet, aspnet | +| [openapi-to-application-go](../plugins/openapi-to-application-go/README.md) | Generate production-ready Go applications from OpenAPI specifications. Includes project scaffolding, handler generation, middleware setup, and Go best practices for REST APIs. | 0 items | openapi, code-generation, api, go, golang | +| [openapi-to-application-java-spring-boot](../plugins/openapi-to-application-java-spring-boot/README.md) | Generate production-ready Spring Boot applications from OpenAPI specifications. Includes project scaffolding, REST controller generation, service layer organization, and Spring Boot best practices. | 0 items | openapi, code-generation, api, java, spring-boot | +| [openapi-to-application-nodejs-nestjs](../plugins/openapi-to-application-nodejs-nestjs/README.md) | Generate production-ready NestJS applications from OpenAPI specifications. Includes project scaffolding, controller and service generation, TypeScript best practices, and enterprise patterns. | 0 items | openapi, code-generation, api, nodejs, typescript, nestjs | +| [openapi-to-application-python-fastapi](../plugins/openapi-to-application-python-fastapi/README.md) | Generate production-ready FastAPI applications from OpenAPI specifications. Includes project scaffolding, route generation, dependency injection, and Python best practices for async APIs. | 0 items | openapi, code-generation, api, python, fastapi | +| [oracle-to-postgres-migration-expert](../plugins/oracle-to-postgres-migration-expert/README.md) | Expert agent for Oracle-to-PostgreSQL application migrations in .NET solutions. Performs code edits, runs commands, and invokes extension tools to migrate .NET/Oracle data access patterns to PostgreSQL. | 0 items | oracle, postgresql, database-migration, dotnet, sql, migration, integration-testing, stored-procedures | +| [ospo-sponsorship](../plugins/ospo-sponsorship/README.md) | Tools and resources for Open Source Program Offices (OSPOs) to identify, evaluate, and manage sponsorship of open source dependencies through GitHub Sponsors, Open Collective, and other funding platforms. | 0 items | | +| [partners](../plugins/partners/README.md) | Custom agents that have been created by GitHub partners | 0 items | devops, security, database, cloud, infrastructure, observability, feature-flags, cicd, migration, performance | | [pcf-development](../plugins/pcf-development/README.md) | Complete toolkit for developing custom code components using Power Apps Component Framework for model-driven and canvas apps | 0 items | power-apps, pcf, component-framework, typescript, power-platform | -| [phoenix](../plugins/phoenix/README.md) | Phoenix AI observability skills for LLM application debugging, evaluation, and tracing. Includes CLI debugging tools, LLM evaluation workflows, and OpenInference tracing instrumentation. | 3 items | phoenix, arize, llm, observability, tracing, evaluation, openinference, instrumentation | -| [php-mcp-development](../plugins/php-mcp-development/README.md) | Comprehensive resources for building Model Context Protocol servers using the official PHP SDK with attribute-based discovery, including best practices, project generation, and expert assistance | 2 items | php, mcp, model-context-protocol, server-development, sdk, attributes, composer | -| [power-apps-code-apps](../plugins/power-apps-code-apps/README.md) | Complete toolkit for Power Apps Code Apps development including project scaffolding, development standards, and expert guidance for building code-first applications with Power Platform integration. | 2 items | power-apps, power-platform, typescript, react, code-apps, dataverse, connectors | -| [power-bi-development](../plugins/power-bi-development/README.md) | Comprehensive Power BI development resources including data modeling, DAX optimization, performance tuning, visualization design, security best practices, and DevOps/ALM guidance for building enterprise-grade Power BI solutions. | 5 items | power-bi, dax, data-modeling, performance, visualization, security, devops, business-intelligence | -| [power-platform-architect](../plugins/power-platform-architect/README.md) | Solution Architect for the Microsoft Power Platform, turning business requirements into functioning Power Platform solution architectures. | 1 items | power-platform, power-platform-architect, power-apps, dataverse, power-automate, power-pages, power-bi | -| [power-platform-mcp-connector-development](../plugins/power-platform-mcp-connector-development/README.md) | Complete toolkit for developing Power Platform custom connectors with Model Context Protocol integration for Microsoft Copilot Studio | 3 items | power-platform, mcp, copilot-studio, custom-connector, json-rpc | -| [project-documenter](../plugins/project-documenter/README.md) | Generate professional project documentation with draw.io architecture diagrams and Word (.docx) output with embedded images. Automatically discovers any project's technology stack and produces Markdown, diagrams, PNG exports, and a formatted Word document. | 3 items | documentation, architecture-diagrams, drawio, word-document, docx, png-images, c4-model, project-summary, auto-discovery | -| [project-planning](../plugins/project-planning/README.md) | Tools and guidance for software project planning, feature breakdown, epic management, implementation planning, and task organization for development teams. | 9 items | planning, project-management, epic, feature, implementation, task, architecture, technical-spike | -| [python-mcp-development](../plugins/python-mcp-development/README.md) | Complete toolkit for building Model Context Protocol (MCP) servers in Python using the official SDK with FastMCP. Includes instructions for best practices, a prompt for generating servers, and an expert chat mode for guidance. | 2 items | python, mcp, model-context-protocol, fastmcp, server-development | -| [react18-upgrade](../plugins/react18-upgrade/README.md) | Enterprise React 18 migration toolkit with specialized agents and skills for upgrading React 16/17 class-component codebases to React 18.3.1. Includes auditor, dependency surgeon, class component migration specialist, automatic batching fixer, and test guardian. | 8 items | react18, react, migration, upgrade, class-components, lifecycle, batching | -| [react19-upgrade](../plugins/react19-upgrade/README.md) | Enterprise React 19 migration toolkit with specialized agents and skills for upgrading React 18 codebases to React 19. Includes auditor, dependency surgeon, source code migrator, and test guardian. Handles removal of deprecated APIs including ReactDOM.render, forwardRef, defaultProps, legacy context, string refs, and more. | 4 items | react19, react, migration, upgrade, hooks, modern-react | -| [roundup](../plugins/roundup/README.md) | Self-configuring status briefing generator. Learns your communication style from examples, discovers your data sources, and produces draft updates for any audience on demand. | 2 items | status-updates, briefings, management, productivity, communication, synthesis, roundup, copilot-cli | -| [ruby-mcp-development](../plugins/ruby-mcp-development/README.md) | Complete toolkit for building Model Context Protocol servers in Ruby using the official MCP Ruby SDK gem with Rails integration support. | 2 items | ruby, mcp, model-context-protocol, server-development, sdk, rails, gem | -| [rug-agentic-workflow](../plugins/rug-agentic-workflow/README.md) | Three-agent workflow for orchestrated software delivery with an orchestrator plus implementation and QA subagents. | 1 items | agentic-workflow, orchestration, subagents, software-engineering, qa | -| [rust-mcp-development](../plugins/rust-mcp-development/README.md) | Build high-performance Model Context Protocol servers in Rust using the official rmcp SDK with async/await, procedural macros, and type-safe implementations. | 2 items | rust, mcp, model-context-protocol, server-development, sdk, tokio, async, macros, rmcp | -| [salesforce-development](../plugins/salesforce-development/README.md) | Complete Salesforce agentic development environment covering Apex & Triggers, Flow automation, Lightning Web Components, Aura components, and Visualforce pages. | 4 items | salesforce, apex, triggers, lwc, aura, flow, visualforce, crm, salesforce-dx | -| [security-best-practices](../plugins/security-best-practices/README.md) | Security frameworks, accessibility guidelines, performance optimization, and code quality best practices for building secure, maintainable, and high-performance applications. | 1 items | security, accessibility, performance, code-quality, owasp, a11y, optimization, best-practices | -| [skill-image-gen](../plugins/skill-image-gen/README.md) | Generate images using AI directly from your coding workflow. Supports OpenAI (gpt-image-2) and Google Gemini. BYO API key — the skill guides you through setup on first use. | 1 items | image-generation, openai, gemini, ai, art, sprites, textures, icons | -| [software-engineering-team](../plugins/software-engineering-team/README.md) | 7 specialized agents covering the full software development lifecycle from UX design and architecture to security and DevOps. | 1 items | team, enterprise, security, devops, ux, architecture, product, ai-ethics | -| [structured-autonomy](../plugins/structured-autonomy/README.md) | Premium planning, thrifty implementation | 3 items | | -| [swift-mcp-development](../plugins/swift-mcp-development/README.md) | Comprehensive collection for building Model Context Protocol servers in Swift using the official MCP Swift SDK with modern concurrency features. | 2 items | swift, mcp, model-context-protocol, server-development, sdk, ios, macos, concurrency, actor, async-await | -| [technical-spike](../plugins/technical-spike/README.md) | Tools for creation, management and research of technical spikes to reduce unknowns and assumptions before proceeding to specification and implementation of solutions. | 2 items | technical-spike, assumption-testing, validation, research | -| [testing-automation](../plugins/testing-automation/README.md) | Comprehensive collection for writing tests, test automation, and test-driven development including unit tests, integration tests, and end-to-end testing strategies. | 6 items | testing, tdd, automation, unit-tests, integration, playwright, jest, nunit | -| [the-workshop](../plugins/the-workshop/README.md) | Stop being the switchboard between your AI agents — direct a team. The Workshop puts long-running AI agents (desks) in the same room, on the same work, each with its own memory and history, sharing one workspace so you direct the work instead of relaying it. | 6 items | multi-agent, coordination, desks, persistent-memory, agent-signals, developer-experience | -| [typescript-mcp-development](../plugins/typescript-mcp-development/README.md) | Complete toolkit for building Model Context Protocol (MCP) servers in TypeScript/Node.js using the official SDK. Includes instructions for best practices, a prompt for generating servers, and an expert chat mode for guidance. | 2 items | typescript, mcp, model-context-protocol, nodejs, server-development | -| [typespec-m365-copilot](../plugins/typespec-m365-copilot/README.md) | Comprehensive collection of prompts, instructions, and resources for building declarative agents and API plugins using TypeSpec for Microsoft 365 Copilot extensibility. | 3 items | typespec, m365-copilot, declarative-agents, api-plugins, agent-development, microsoft-365 | -| [uizze](../plugins/uizze/README.md) | Stop generic UI from shipping. Ground GitHub Copilot in 800,000+ real web and iOS screens, write a product-specific design contract, and enforce a hard finish gate. | 1 items | ui, design, frontend, ios, web, design-review, quality-gate | -| [visual-pr](../plugins/visual-pr/README.md) | Capture, annotate, and embed screenshots and animated GIF demos in pull request descriptions. Includes Playwright-based UI capture, PIL image annotations, PR embedding workflows for GitHub and Azure DevOps, and screen recording with variable timing. | 4 items | screenshots, pull-request, before-after, annotations, playwright, gif, screen-recording, visual | +| [phoenix](../plugins/phoenix/README.md) | Phoenix AI observability skills for LLM application debugging, evaluation, and tracing. Includes CLI debugging tools, LLM evaluation workflows, and OpenInference tracing instrumentation. | 0 items | phoenix, arize, llm, observability, tracing, evaluation, openinference, instrumentation | +| [php-mcp-development](../plugins/php-mcp-development/README.md) | Comprehensive resources for building Model Context Protocol servers using the official PHP SDK with attribute-based discovery, including best practices, project generation, and expert assistance | 0 items | php, mcp, model-context-protocol, server-development, sdk, attributes, composer | +| [power-apps-code-apps](../plugins/power-apps-code-apps/README.md) | Complete toolkit for Power Apps Code Apps development including project scaffolding, development standards, and expert guidance for building code-first applications with Power Platform integration. | 0 items | power-apps, power-platform, typescript, react, code-apps, dataverse, connectors | +| [power-bi-development](../plugins/power-bi-development/README.md) | Comprehensive Power BI development resources including data modeling, DAX optimization, performance tuning, visualization design, security best practices, and DevOps/ALM guidance for building enterprise-grade Power BI solutions. | 0 items | power-bi, dax, data-modeling, performance, visualization, security, devops, business-intelligence | +| [power-platform-architect](../plugins/power-platform-architect/README.md) | Solution Architect for the Microsoft Power Platform, turning business requirements into functioning Power Platform solution architectures. | 0 items | power-platform, power-platform-architect, power-apps, dataverse, power-automate, power-pages, power-bi | +| [power-platform-mcp-connector-development](../plugins/power-platform-mcp-connector-development/README.md) | Complete toolkit for developing Power Platform custom connectors with Model Context Protocol integration for Microsoft Copilot Studio | 0 items | power-platform, mcp, copilot-studio, custom-connector, json-rpc | +| [pr-artifact-explorer](../plugins/pr-artifact-explorer/README.md) | Navigate pull requests and securely explore GitHub Actions artifacts, including test results, static sites, terminal recordings, and source files. | 1 items | actions-artifacts, artifact-browser, canvas, copilot-extension, github-actions, pull-requests, test-results | +| [project-documenter](../plugins/project-documenter/README.md) | Generate professional project documentation with draw.io architecture diagrams and Word (.docx) output with embedded images. Automatically discovers any project's technology stack and produces Markdown, diagrams, PNG exports, and a formatted Word document. | 0 items | documentation, architecture-diagrams, drawio, word-document, docx, png-images, c4-model, project-summary, auto-discovery | +| [project-planning](../plugins/project-planning/README.md) | Tools and guidance for software project planning, feature breakdown, epic management, implementation planning, and task organization for development teams. | 0 items | planning, project-management, epic, feature, implementation, task, architecture, technical-spike | +| [python-mcp-development](../plugins/python-mcp-development/README.md) | Complete toolkit for building Model Context Protocol (MCP) servers in Python using the official SDK with FastMCP. Includes instructions for best practices, a prompt for generating servers, and an expert chat mode for guidance. | 0 items | python, mcp, model-context-protocol, fastmcp, server-development | +| [react18-upgrade](../plugins/react18-upgrade/README.md) | Enterprise React 18 migration toolkit with specialized agents and skills for upgrading React 16/17 class-component codebases to React 18.3.1. Includes auditor, dependency surgeon, class component migration specialist, automatic batching fixer, and test guardian. | 0 items | react18, react, migration, upgrade, class-components, lifecycle, batching | +| [react19-upgrade](../plugins/react19-upgrade/README.md) | Enterprise React 19 migration toolkit with specialized agents and skills for upgrading React 18 codebases to React 19. Includes auditor, dependency surgeon, source code migrator, and test guardian. Handles removal of deprecated APIs including ReactDOM.render, forwardRef, defaultProps, legacy context, string refs, and more. | 0 items | react19, react, migration, upgrade, hooks, modern-react | +| [release-notes-showcase](../plugins/release-notes-showcase/README.md) | Compose and refine launch-ready release notes with contributor callouts and export-friendly output. | 1 items | changelog, contributor-callouts, email-export, launch-summary, product-updates, release-notes | +| [repo-actions-hub](../plugins/repo-actions-hub/README.md) | Browse repository GitHub Actions workflows, inspect recent runs, and trigger manual workflow_dispatch runs from a Copilot canvas. | 1 items | actions, canvas, copilot-extension, github-actions, recent-runs, workflow-dispatch | +| [roundup](../plugins/roundup/README.md) | Self-configuring status briefing generator. Learns your communication style from examples, discovers your data sources, and produces draft updates for any audience on demand. | 0 items | status-updates, briefings, management, productivity, communication, synthesis, roundup, copilot-cli | +| [ruby-mcp-development](../plugins/ruby-mcp-development/README.md) | Complete toolkit for building Model Context Protocol servers in Ruby using the official MCP Ruby SDK gem with Rails integration support. | 0 items | ruby, mcp, model-context-protocol, server-development, sdk, rails, gem | +| [rug-agentic-workflow](../plugins/rug-agentic-workflow/README.md) | Three-agent workflow for orchestrated software delivery with an orchestrator plus implementation and QA subagents. | 0 items | agentic-workflow, orchestration, subagents, software-engineering, qa | +| [rust-mcp-development](../plugins/rust-mcp-development/README.md) | Build high-performance Model Context Protocol servers in Rust using the official rmcp SDK with async/await, procedural macros, and type-safe implementations. | 0 items | rust, mcp, model-context-protocol, server-development, sdk, tokio, async, macros, rmcp | +| [salesforce-development](../plugins/salesforce-development/README.md) | Complete Salesforce agentic development environment covering Apex & Triggers, Flow automation, Lightning Web Components, Aura components, and Visualforce pages. | 0 items | salesforce, apex, triggers, lwc, aura, flow, visualforce, crm, salesforce-dx | +| [security-best-practices](../plugins/security-best-practices/README.md) | Security frameworks, accessibility guidelines, performance optimization, and code quality best practices for building secure, maintainable, and high-performance applications. | 0 items | security, accessibility, performance, code-quality, owasp, a11y, optimization, best-practices | +| [signals-dashboard](../plugins/signals-dashboard/README.md) | Real-time Workshop dashboard with agent signals, honesty calibration, and cost-aware repo or connected desk launch profiles. | 1 items | agent-signals, dashboard, multi-agent, coordination, canvas | +| [site-studio](../plugins/site-studio/README.md) | 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. | 1 items | agent-collaboration, content-authoring, personal-website, progress-tracking, section-planning, site-builder | +| [skill-image-gen](../plugins/skill-image-gen/README.md) | Generate images using AI directly from your coding workflow. Supports OpenAI (gpt-image-2) and Google Gemini. BYO API key — the skill guides you through setup on first use. | 0 items | image-generation, openai, gemini, ai, art, sprites, textures, icons | +| [software-engineering-team](../plugins/software-engineering-team/README.md) | 7 specialized agents covering the full software development lifecycle from UX design and architecture to security and DevOps. | 0 items | team, enterprise, security, devops, ux, architecture, product, ai-ethics | +| [structured-autonomy](../plugins/structured-autonomy/README.md) | Premium planning, thrifty implementation | 0 items | | +| [swift-mcp-development](../plugins/swift-mcp-development/README.md) | Comprehensive collection for building Model Context Protocol servers in Swift using the official MCP Swift SDK with modern concurrency features. | 0 items | swift, mcp, model-context-protocol, server-development, sdk, ios, macos, concurrency, actor, async-await | +| [technical-spike](../plugins/technical-spike/README.md) | Tools for creation, management and research of technical spikes to reduce unknowns and assumptions before proceeding to specification and implementation of solutions. | 0 items | technical-spike, assumption-testing, validation, research | +| [testing-automation](../plugins/testing-automation/README.md) | Comprehensive collection for writing tests, test automation, and test-driven development including unit tests, integration tests, and end-to-end testing strategies. | 0 items | testing, tdd, automation, unit-tests, integration, playwright, jest, nunit | +| [the-workshop](../plugins/the-workshop/README.md) | Stop being the switchboard between your AI agents — direct a team. The Workshop puts long-running AI agents (desks) in the same room, on the same work, each with its own memory and history, sharing one workspace so you direct the work instead of relaying it. | 0 items | multi-agent, coordination, desks, persistent-memory, agent-signals, developer-experience | +| [tiny-tool-town-submitter](../plugins/tiny-tool-town-submitter/README.md) | Inspect a repository, improve Tiny Tool Town readiness, submit its listing issue, and launch remediation work. | 1 items | github-issues, open-source, project-readiness, repository-analysis, submission-workflow, tiny-tool-town | +| [token-pacman](../plugins/token-pacman/README.md) | Visualizes live session AI-credit usage as a Pac-Man board with pellets, ghosts, fruit milestones, and game-over limits. | 1 items | ai-credits, copilot-canvas, interactive-canvas, pac-man, quota-tracking, session-usage | +| [typescript-mcp-development](../plugins/typescript-mcp-development/README.md) | Complete toolkit for building Model Context Protocol (MCP) servers in TypeScript/Node.js using the official SDK. Includes instructions for best practices, a prompt for generating servers, and an expert chat mode for guidance. | 0 items | typescript, mcp, model-context-protocol, nodejs, server-development | +| [typespec-m365-copilot](../plugins/typespec-m365-copilot/README.md) | Comprehensive collection of prompts, instructions, and resources for building declarative agents and API plugins using TypeSpec for Microsoft 365 Copilot extensibility. | 0 items | typespec, m365-copilot, declarative-agents, api-plugins, agent-development, microsoft-365 | +| [uizze](../plugins/uizze/README.md) | Stop generic UI from shipping. Ground GitHub Copilot in 800,000+ real web and iOS screens, write a product-specific design contract, and enforce a hard finish gate. | 0 items | ui, design, frontend, ios, web, design-review, quality-gate | +| [visual-pr](../plugins/visual-pr/README.md) | Capture, annotate, and embed screenshots and animated GIF demos in pull request descriptions. Includes Playwright-based UI capture, PIL image annotations, PR embedding workflows for GitHub and Azure DevOps, and screen recording with variable timing. | 0 items | screenshots, pull-request, before-after, annotations, playwright, gif, screen-recording, visual | +| [where-was-i](../plugins/where-was-i/README.md) | Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly. | 1 items | branch-state, developer-context, git-history, interrupt-recovery, pull-request-context, resume-work | +| [work-hub](../plugins/work-hub/README.md) | Generic cross-repo command center canvas for GitHub Copilot with onboarding, focus planning, repo health, work signals, and session cleanup. | 1 items | cross-repo-dashboard, focus-planning, repo-health, session-cleanup, work-triage, workflow-visibility | diff --git a/eng/README.md b/eng/README.md index 5306315b..d196b698 100644 --- a/eng/README.md +++ b/eng/README.md @@ -12,7 +12,7 @@ Automatically generates `.github/plugin/marketplace.json` from all plugin direct **How it works:** - Scans all directories in `plugins/` -- Reads each plugin's `.github/plugin/plugin.json` for metadata +- Reads each plugin's root `plugin.json` for metadata - Generates a consolidated `marketplace.json` with all available plugins - Runs automatically as part of `npm run build` diff --git a/eng/agent-plugin-schema.mjs b/eng/agent-plugin-schema.mjs new file mode 100644 index 00000000..65357a24 --- /dev/null +++ b/eng/agent-plugin-schema.mjs @@ -0,0 +1,25 @@ +import Ajv2020 from "ajv/dist/2020.js"; + +export const AGENT_PLUGIN_SCHEMA_URL = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; +export const AGENT_PLUGIN_SCHEMA = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: AGENT_PLUGIN_SCHEMA_URL, + type: "object", + properties: { + $schema: { const: AGENT_PLUGIN_SCHEMA_URL }, + name: { type: "string", minLength: 1, maxLength: 64, pattern: "^(?!.*(?:--|\\.\\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$" }, + version: { type: "string" }, description: { type: "string" }, + author: { type: "object", properties: { name: { type: "string" }, email: { type: "string" }, url: { type: "string" } }, additionalProperties: false }, + homepage: { type: "string" }, repository: { type: "string" }, license: { type: "string" }, + keywords: { type: "array", items: { type: "string" } }, + extensions: { type: "object", additionalProperties: { type: "object" } }, + }, + required: ["$schema", "name"], + additionalProperties: false, +}; + +const validate = new Ajv2020({ allErrors: true }).compile(AGENT_PLUGIN_SCHEMA); +export function validateAgentPluginManifest(manifest) { + return validate(manifest) ? [] : (validate.errors ?? []).map((error) => + `${error.instancePath || "manifest"} ${error.message}`); +} diff --git a/eng/clean-materialized-plugins.mjs b/eng/clean-materialized-plugins.mjs index 6b034276..f6584a41 100644 --- a/eng/clean-materialized-plugins.mjs +++ b/eng/clean-materialized-plugins.mjs @@ -64,7 +64,7 @@ function moveEntry(srcPath, destPath) { } export function restoreManifestFromMaterializedFiles(pluginPath) { - const pluginJsonPath = path.join(pluginPath, ".github/plugin", "plugin.json"); + const pluginJsonPath = path.join(pluginPath, "plugin.json"); if (!fs.existsSync(pluginJsonPath)) { return false; } @@ -108,7 +108,7 @@ export function restoreManifestFromMaterializedFiles(pluginPath) { function cleanPlugin(pluginPath) { const manifestUpdated = restoreManifestFromMaterializedFiles(pluginPath); if (manifestUpdated) { - console.log(` Updated ${path.basename(pluginPath)}/.github/plugin/plugin.json`); + console.log(` Updated ${path.basename(pluginPath)}/plugin.json`); } let removed = 0; @@ -126,7 +126,7 @@ function cleanPlugin(pluginPath) { } export function cleanMaterializedExtensionPlugin(extensionPath) { - const pluginJsonPath = path.join(extensionPath, ".github", "plugin", "plugin.json"); + const pluginJsonPath = path.join(extensionPath, "plugin.json"); let manifestUpdated = false; if (fs.existsSync(pluginJsonPath)) { const plugin = JSON.parse(fs.readFileSync(pluginJsonPath, "utf8")); @@ -141,7 +141,7 @@ export function cleanMaterializedExtensionPlugin(extensionPath) { } if (manifestUpdated) { fs.writeFileSync(pluginJsonPath, JSON.stringify(plugin, null, 2) + "\n", "utf8"); - console.log(` Updated ${path.basename(extensionPath)}/.github/plugin/plugin.json`); + console.log(` Updated ${path.basename(extensionPath)}/plugin.json`); } } @@ -174,7 +174,7 @@ function isExtensionPluginDirectory(extensionPath) { return true; } - const pluginJsonPath = path.join(extensionPath, ".github", "plugin", "plugin.json"); + const pluginJsonPath = path.join(extensionPath, "plugin.json"); if (!fs.existsSync(pluginJsonPath)) { return false; } diff --git a/eng/create-plugin.mjs b/eng/create-plugin.mjs index 69f5e378..0dbf35b2 100755 --- a/eng/create-plugin.mjs +++ b/eng/create-plugin.mjs @@ -128,11 +128,11 @@ async function createPlugin() { } // Create directory structure - const githubPluginDir = path.join(pluginDir, ".github", "plugin"); - fs.mkdirSync(githubPluginDir, { recursive: true }); + fs.mkdirSync(pluginDir, { recursive: true }); // Generate plugin.json const pluginJson = { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", name: pluginId, description, version: "1.0.0", @@ -140,10 +140,16 @@ async function createPlugin() { author: { name: "Awesome Copilot Community" }, repository: "https://github.com/github/awesome-copilot", license: "MIT", + extensions: { + "com.github.awesome-copilot": { + agents: [], + skills: [], + }, + }, }; fs.writeFileSync( - path.join(githubPluginDir, "plugin.json"), + path.join(pluginDir, "plugin.json"), JSON.stringify(pluginJson, null, 2) + "\n" ); @@ -176,7 +182,7 @@ MIT console.log(`\n✅ Created plugin: ${pluginDir}`); console.log("\n📝 Next steps:"); console.log(`1. Add agents, prompts, or instructions to plugins/${pluginId}/`); - console.log(`2. Update plugins/${pluginId}/.github/plugin/plugin.json with your metadata`); + console.log(`2. Update plugins/${pluginId}/plugin.json with your metadata`); console.log(`3. Edit plugins/${pluginId}/README.md to describe your plugin`); console.log("4. Run 'npm run build' to regenerate documentation"); } catch (error) { diff --git a/eng/external-plugin-intake.mjs b/eng/external-plugin-intake.mjs index 372c7339..f48f21e7 100644 --- a/eng/external-plugin-intake.mjs +++ b/eng/external-plugin-intake.mjs @@ -783,6 +783,7 @@ export function parseMarkReadyForReviewCommand(body) { function normalizeQualityGateResult(rawResult) { const defaults = { overall_status: "not_run", + spec_compliance_status: "not_run", vally_lint_status: "not_run", smoke_status: "not_run", version_match_status: "not_run", @@ -790,6 +791,7 @@ function normalizeQualityGateResult(rawResult) { canvas_structure_status: "not_run", failure_class: "none", summary: "", + spec_compliance_output: "", vally_lint_output: "", smoke_output: "", version_match_output: "", @@ -808,6 +810,21 @@ function normalizeQualityGateResult(rawResult) { } function buildQualityGatesCommentSection(qualityResult) { + const formatStatus = (rawStatus, gate) => { + const status = String(rawStatus || "not_run"); + if (status === "pass") { + return "✅ pass"; + } + if (status === "warning" || (gate === "spec" && status === "fail")) { + return "⚠️ warning"; + } + if (status === "fail" || status === "infra_error") { + return "🛑 fail"; + } + return "⚪ not_run"; + }; + + const specState = qualityResult.spec_compliance_status || "not_run"; const vallyState = qualityResult.vally_lint_status || "not_run"; const smokeState = qualityResult.smoke_status || "not_run"; const versionMatchState = qualityResult.version_match_status || "not_run"; @@ -818,23 +835,41 @@ function buildQualityGatesCommentSection(qualityResult) { const sections = [ "### Quality gate summary", "", + "_Legend: ✅ pass · ⚠️ warning · 🛑 fail_", + "", "| Gate | Status |", "|---|---|", - `| vally lint | ${vallyState} |`, - `| install smoke test | ${smokeState} |`, - `| version match | ${versionMatchState} |`, - `| ref/sha consistency | ${refShaConsistencyState} |`, - `| canvas structure | ${canvasStructureState} |`, + `| spec compliance (non-blocking) | ${formatStatus(specState, "spec")} |`, + `| vally lint | ${formatStatus(vallyState, "vally")} |`, + `| install smoke test | ${formatStatus(smokeState, "smoke")} |`, + `| version match | ${formatStatus(versionMatchState, "version match")} |`, + `| ref/sha consistency | ${formatStatus(refShaConsistencyState, "ref/sha consistency")} |`, + `| canvas structure | ${formatStatus(canvasStructureState, "canvas structure")} |`, "", summaryText, ]; + const specOutput = String(qualityResult.spec_compliance_output || "").trim(); + if (specOutput) { + sections.push( + "", + "
", + `spec compliance output (${formatStatus(specState, "spec")})`, + "", + "```text", + specOutput, + "```", + "", + "
", + ); + } + const vallyOutput = String(qualityResult.vally_lint_output || "").trim(); if (vallyOutput) { sections.push( "", "
", - "vally lint output", + `vally lint output (${formatStatus(vallyState, "vally")})`, "", "```text", vallyOutput, @@ -849,7 +884,7 @@ function buildQualityGatesCommentSection(qualityResult) { sections.push( "", "
", - "Install smoke test output", + `install smoke test output (${formatStatus(smokeState, "smoke")})`, "", "```text", smokeOutput, @@ -932,19 +967,24 @@ function buildMergedIntakeComment(baseResult, qualityResult, runId, owner, repo) const qualitySection = buildQualityGatesCommentSection(qualityResult); const runLink = runId && owner && repo ? `_[View workflow run](https://github.com/${owner}/${repo}/actions/runs/${runId})_` : ""; + const hasSpecWarnings = String(qualityResult.spec_compliance_status || "") === "warning"; const intro = qualityResult.failure_class === "submitter_fixes" - ? "## ⚠️ External plugin intake requires submitter fixes" + ? "## 🛑 External plugin intake failed (submitter fixes required)" : qualityResult.failure_class === "infra" - ? "## ⚠️ External plugin intake could not complete quality checks" - : "## ✅ External plugin intake passed"; + ? "## 🛑 External plugin intake failed (quality checks could not complete)" + : hasSpecWarnings + ? "## ⚠️ External plugin intake passed with spec warnings" + : "## ✅ External plugin intake passed"; const statusLine = qualityResult.failure_class === "submitter_fixes" ? "This submission passed metadata validation, but quality gates found issues that must be fixed before it can move to maintainer review. Update the issue details or source plugin and then comment `/rerun-intake`." : qualityResult.failure_class === "infra" ? "This submission passed metadata validation, but the automated quality checks hit an infrastructure issue. A maintainer should rerun intake or use the explicit override command after review." - : "This submission passed automated intake validation and quality checks and is ready for maintainer review."; + : hasSpecWarnings + ? "This submission passed blocking quality checks and is ready for maintainer review, but it has non-blocking Agent Plugins spec compliance warnings." + : "This submission passed automated intake validation and quality checks and is ready for maintainer review."; return [ marker, @@ -1069,7 +1109,7 @@ export async function evaluateExternalPluginIssue({ issue, token, runId, owner, ].join("\n") : [ marker, - "## ⚠️ External plugin intake requires submitter fixes", + "## 🛑 External plugin intake failed (submitter fixes required)", "", "This submission did not pass automated intake validation and cannot move to maintainer review yet.", `Edit the issue form to address the fixes below. Intake reruns automatically when the issue is edited, or the issue author/maintainer can comment \`${RERUN_INTAKE_COMMAND}\` to re-run on demand.`, diff --git a/eng/external-plugin-pr-quality-gates.mjs b/eng/external-plugin-pr-quality-gates.mjs index f7f8ded5..1c26b7a2 100644 --- a/eng/external-plugin-pr-quality-gates.mjs +++ b/eng/external-plugin-pr-quality-gates.mjs @@ -109,7 +109,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}, ref-sha-consistency=${entry.quality.ref_sha_consistency_status}, canvas-structure=${entry.quality.canvas_structure_status}, overall=${entry.quality.overall_status}` + `- ${entry.name}: spec=${entry.quality.spec_compliance_status}, vally-lint=${entry.quality.vally_lint_status}, install-smoke=${entry.quality.smoke_status}, version-match=${entry.quality.version_match_status}, ref-sha-consistency=${entry.quality.ref_sha_consistency_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 014f9b83..fb298412 100644 --- a/eng/external-plugin-quality-gates.mjs +++ b/eng/external-plugin-quality-gates.mjs @@ -7,8 +7,23 @@ import { Writable } from "stream"; import { spawnSync } from "child_process"; import { runLint, LintConsoleReporter } from "@microsoft/vally"; import { evaluateRefShaConsistency, normalizeCommitSha } from "./lib/external-plugin-source-ref-sha.mjs"; +import { validateAgentPluginManifest } from "./agent-plugin-schema.mjs"; const MAX_OUTPUT_LENGTH = 12000; +const AGENT_PLUGIN_SCHEMA_URL = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; +const AGENT_PLUGIN_ALLOWED_TOP_LEVEL_FIELDS = new Set([ + "$schema", + "name", + "version", + "description", + "author", + "homepage", + "repository", + "license", + "keywords", + "extensions", +]); +const AGENT_PLUGIN_NAME_PATTERN = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; const EXTERNAL_CANVAS_KEYWORD = "canvas"; const INFRA_ERROR_PATTERNS = [ @@ -168,10 +183,131 @@ function findPluginJson(pluginRoot) { if (fs.existsSync(candidate)) { return candidate; } + } return null; } +function inspectAgentPluginSpecCompliance(pluginRoot) { + const pluginJsonPath = findPluginJson(pluginRoot); + if (!pluginJsonPath) { + return { + status: "warning", + output: "No plugin.json found in a recognized location. Agent Plugins v1.0.0 expects plugin.json at the plugin root.", + }; + } + + const rootPluginJsonPath = path.join(pluginRoot, "plugin.json"); + const issues = []; + if (pluginJsonPath !== rootPluginJsonPath) { + issues.push(`manifest location is "${path.relative(pluginRoot, pluginJsonPath)}"; expected "plugin.json" at plugin root`); + } + + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(pluginJsonPath, "utf8")); + } catch (error) { + return { + status: "warning", + output: `plugin.json is not valid JSON: ${error.message}`, + }; + } + + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { + issues.push("plugin.json top-level value must be a JSON object"); + } else { + if (manifest.$schema !== AGENT_PLUGIN_SCHEMA_URL) { + issues.push(`$schema should be "${AGENT_PLUGIN_SCHEMA_URL}"`); + } + + const pluginName = manifest.name; + if (typeof pluginName !== "string") { + issues.push('required field "name" must be a string'); + } else { + if (pluginName.length < 1 || pluginName.length > 64) { + issues.push('field "name" must be 1-64 characters'); + } + if (!AGENT_PLUGIN_NAME_PATTERN.test(pluginName)) { + issues.push('field "name" does not match Agent Plugins naming constraints'); + } + } + + const requiredStringFields = ["version", "description"]; + for (const field of requiredStringFields) { + if (typeof manifest[field] !== "string" || manifest[field].trim() === "") { + issues.push(`required field "${field}" must be a non-empty string`); + } + } + + const optionalStringFields = ["homepage", "repository", "license"]; + for (const field of optionalStringFields) { + if (manifest[field] !== undefined && typeof manifest[field] !== "string") { + issues.push(`field "${field}" must be a string when provided`); + } + } + + if (manifest.author !== undefined) { + if (!manifest.author || typeof manifest.author !== "object" || Array.isArray(manifest.author)) { + issues.push('field "author" must be an object when provided'); + } else { + const allowedAuthorFields = new Set(["name", "email", "url"]); + for (const authorField of Object.keys(manifest.author)) { + if (!allowedAuthorFields.has(authorField)) { + issues.push(`field "author.${authorField}" is not allowed`); + } else if (typeof manifest.author[authorField] !== "string") { + issues.push(`field "author.${authorField}" must be a string`); + } + } + } + } + + if (manifest.keywords !== undefined) { + if (!Array.isArray(manifest.keywords)) { + issues.push('field "keywords" must be an array of strings when provided'); + } else if (manifest.keywords.some((entry) => typeof entry !== "string")) { + issues.push('field "keywords" must contain only strings'); + } + } + + if (manifest.extensions !== undefined) { + if (!manifest.extensions || typeof manifest.extensions !== "object" || Array.isArray(manifest.extensions)) { + issues.push('field "extensions" must be an object when provided'); + } else { + for (const [namespace, value] of Object.entries(manifest.extensions)) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + issues.push(`field "extensions.${namespace}" must be an object`); + } + } + } + } + + for (const field of Object.keys(manifest)) { + if (!AGENT_PLUGIN_ALLOWED_TOP_LEVEL_FIELDS.has(field)) { + issues.push(`top-level field "${field}" is not part of Agent Plugins v1.0.0`); + } + } + } + + if (manifest && typeof manifest === "object" && !Array.isArray(manifest)) { + issues.push(...validateAgentPluginManifest(manifest).map((error) => `schema validation: ${error}`)); + } + + if (issues.length === 0) { + return { + status: "pass", + output: `Agent Plugins v1.0.0 manifest checks passed for ${path.relative(pluginRoot, pluginJsonPath) || "plugin.json"}.`, + }; + } + + return { + status: "warning", + output: [ + "Agent Plugins v1.0.0 manifest warnings:", + ...issues.map((issue) => `- ${issue}`), + ].join("\n"), + }; +} + function buildVallyLintArgs(pluginRoot) { const pluginJsonPath = findPluginJson(pluginRoot); if (!pluginJsonPath) { @@ -827,6 +963,7 @@ export async function runExternalPluginQualityGates(plugin) { overall_status: "not_run", vally_lint_status: "not_run", smoke_status: "not_run", + spec_compliance_status: "not_run", version_match_status: "not_run", ref_sha_consistency_status: "not_run", canvas_structure_status: "not_run", @@ -834,6 +971,7 @@ export async function runExternalPluginQualityGates(plugin) { summary: "", vally_lint_output: "", smoke_output: "", + spec_compliance_output: "", version_match_output: "", ref_sha_consistency_output: "", canvas_structure_output: "", @@ -847,12 +985,14 @@ export async function runExternalPluginQualityGates(plugin) { if (!fs.existsSync(pluginRoot) || !fs.statSync(pluginRoot).isDirectory()) { result.vally_lint_status = "fail"; result.smoke_status = "fail"; + result.spec_compliance_status = "warning"; result.version_match_status = "fail"; result.ref_sha_consistency_status = "not_run"; 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.spec_compliance_output = result.summary; result.version_match_output = result.summary; if (hasCanvasKeyword(plugin)) { result.canvas_structure_output = result.summary; @@ -860,6 +1000,10 @@ export async function runExternalPluginQualityGates(plugin) { return result; } + const specResult = inspectAgentPluginSpecCompliance(pluginRoot); + result.spec_compliance_status = specResult.status; + result.spec_compliance_output = specResult.output; + const versionMatchResult = runVersionMatchGate(repoDir, plugin, fetchSpec); result.version_match_status = versionMatchResult.status; result.version_match_output = versionMatchResult.output; @@ -889,6 +1033,7 @@ export async function runExternalPluginQualityGates(plugin) { ]); result.failure_class = toFailureClass(result.overall_status); result.summary = [ + `- spec compliance: ${result.spec_compliance_status}`, `- vally lint: ${result.vally_lint_status}`, `- install smoke test: ${result.smoke_status}`, `- version match: ${result.version_match_status}`, diff --git a/eng/generate-marketplace.mjs b/eng/generate-marketplace.mjs index 6c40c463..0064f6d8 100755 --- a/eng/generate-marketplace.mjs +++ b/eng/generate-marketplace.mjs @@ -2,11 +2,11 @@ import fs from "fs"; import path from "path"; +import { fileURLToPath } from "url"; import { ROOT_FOLDER } from "./constants.mjs"; import { readExternalPlugins } from "./external-plugin-validation.mjs"; const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins"); -const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions"); const MARKETPLACE_FILE = path.join(ROOT_FOLDER, ".github/plugin", "marketplace.json"); /** @@ -15,7 +15,7 @@ const MARKETPLACE_FILE = path.join(ROOT_FOLDER, ".github/plugin", "marketplace.j * @returns {object|null} - Plugin metadata or null if not found */ function readPluginMetadata(pluginDir) { - const pluginJsonPath = path.join(pluginDir, ".github/plugin", "plugin.json"); + const pluginJsonPath = path.join(pluginDir, "plugin.json"); if (!fs.existsSync(pluginJsonPath)) { console.warn(`Warning: No plugin.json found for ${path.basename(pluginDir)}`); @@ -62,35 +62,18 @@ function collectLocalPluginsFromRoot(rootDir, sourcePrefix, includeEntry = () => return plugins; } -function hasExtensionEntryPoint(extensionDir, extensionName) { - const candidateEntryPoints = [ - path.join(extensionDir, "extension.mjs"), - path.join(extensionDir, "extensions", "extension.mjs"), - path.join(extensionDir, "extensions", extensionName, "extension.mjs"), - ]; - - return candidateEntryPoints.some((entryPointPath) => fs.existsSync(entryPointPath)); -} - /** * Generate marketplace.json from plugin directories */ -function generateMarketplace() { +export function generateMarketplace() { console.log("Generating marketplace.json..."); - if (!fs.existsSync(PLUGINS_DIR) && !fs.existsSync(EXTENSIONS_DIR)) { - console.error(`Error: Neither plugins directory (${PLUGINS_DIR}) nor extensions directory (${EXTENSIONS_DIR}) was found`); + if (!fs.existsSync(PLUGINS_DIR)) { + console.error(`Error: Plugins directory (${PLUGINS_DIR}) was not found`); process.exit(1); } - const plugins = [ - ...collectLocalPluginsFromRoot(PLUGINS_DIR, "plugins"), - ...collectLocalPluginsFromRoot( - EXTENSIONS_DIR, - "extensions", - (entryName) => hasExtensionEntryPoint(path.join(EXTENSIONS_DIR, entryName), entryName) - ) - ]; + const plugins = collectLocalPluginsFromRoot(PLUGINS_DIR, "plugins"); console.log(`Found ${plugins.length} local plugin manifests`); @@ -144,5 +127,6 @@ function generateMarketplace() { console.log(` Location: ${MARKETPLACE_FILE}`); } -// Run the script -generateMarketplace(); +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + generateMarketplace(); +} diff --git a/eng/generate-website-data.mjs b/eng/generate-website-data.mjs index f6fc2082..93721cc0 100755 --- a/eng/generate-website-data.mjs +++ b/eng/generate-website-data.mjs @@ -540,8 +540,6 @@ function resolvePluginItem(item, resourceIndex) { */ function generatePluginsData(gitDates, resourceIndex = {}) { const plugins = []; - const extensionEntriesByName = new Map(); - if (!fs.existsSync(PLUGINS_DIR)) { return { items: [], filters: { tags: [] } }; } @@ -550,85 +548,30 @@ function generatePluginsData(gitDates, resourceIndex = {}) { .readdirSync(PLUGINS_DIR, { withFileTypes: true }) .filter((d) => d.isDirectory()); - if (fs.existsSync(EXTENSIONS_DIR)) { - const extensionDirs = fs.readdirSync(EXTENSIONS_DIR, { withFileTypes: true }) - .filter((entry) => { - if (!entry.isDirectory()) return false; - return hasExtensionEntryPoint(path.join(EXTENSIONS_DIR, entry.name), entry.name); - }) - .map((entry) => entry.name) - .sort((a, b) => a.localeCompare(b)); - - for (const extensionDirName of extensionDirs) { - const extensionDir = path.join(EXTENSIONS_DIR, extensionDirName); - const pluginJsonPath = path.join(extensionDir, ".github", "plugin", "plugin.json"); - if (!fs.existsSync(pluginJsonPath)) { - continue; - } - - try { - const extensionPlugin = JSON.parse(fs.readFileSync(pluginJsonPath, "utf-8")); - const pluginName = normalizeText(extensionPlugin.name, extensionDirName); - const pluginDescription = normalizeText(extensionPlugin.description, "Canvas extension"); - const extensionKeywords = Array.isArray(extensionPlugin.keywords) - ? [...new Set(extensionPlugin.keywords.filter((keyword) => typeof keyword === "string").map((keyword) => keyword.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b)) - : []; - const relPath = `extensions/${extensionDirName}`; - const extensionItem = resolvePluginItem( - { - kind: "extension", - path: relPath, - }, - resourceIndex - ); - const extReadmePath = path.join(extensionDir, "README.md"); - const extReadmeFile = fs.existsSync(extReadmePath) - ? `${relPath}/README.md` - : null; - - extensionEntriesByName.set(pluginName, { - id: pluginName, - name: pluginName, - description: pluginDescription, - path: relPath, - readmeFile: extReadmeFile, - version: normalizeText(extensionPlugin.version, null), - tags: extensionKeywords, - itemCount: 1, - items: [extensionItem], - generatedFromExtension: true, - lastUpdated: getDirectoryLastUpdated(gitDates, relPath), - searchText: `${pluginName} ${pluginDescription} ${extensionKeywords.join(" ")} canvas extension`.toLowerCase(), - }); - } catch (e) { - console.warn(`Failed to parse extension plugin manifest for ${extensionDirName}: ${e.message}`); - } - } - } - for (const dir of pluginDirs) { const pluginDir = path.join(PLUGINS_DIR, dir.name); - const jsonPath = path.join(pluginDir, ".github/plugin", "plugin.json"); + const jsonPath = path.join(pluginDir, "plugin.json"); if (!fs.existsSync(jsonPath)) continue; try { const data = JSON.parse(fs.readFileSync(jsonPath, "utf-8")); const relPath = `plugins/${dir.name}`; - const extensionRefs = Array.isArray(data?.["x-awesome-copilot"]?.extensions) - ? data["x-awesome-copilot"].extensions - : []; + const composition = data.extensions?.["com.github.awesome-copilot"] ?? {}; + const extensionRefs = composition.extensions + ?.map((entry) => entry.replace(/^\.\/extensions\//, "").replace(/\/$/, "")) + .filter(Boolean) ?? []; + if (fs.existsSync(path.join(EXTENSIONS_DIR, dir.name, "extension.mjs")) && !extensionRefs.includes(dir.name)) { + extensionRefs.push(dir.name); + } const extensionItems = extensionRefs - .map((entry) => normalizeText(entry)) - .filter(Boolean) - .map((entry) => entry.replace(/^\.\/+/, "").replace(/\/$/, "")) - .filter((entry) => entry.startsWith("extensions/")) + .filter((entry) => typeof entry === "string") .map((entry) => ({ kind: "extension", - path: entry, + path: `extensions/${entry}`, })); - const agentItems = (data.agents || []).flatMap((agent) => { + const agentItems = (composition.agents || []).flatMap((agent) => { const agentPath = agent.replace("./", ""); const fullPath = path.join(pluginDir, agentPath); @@ -646,11 +589,11 @@ function generatePluginsData(gitDates, resourceIndex = {}) { // Parse mcpServers: supports a path to a .mcp.json file or an inline object const mcpItems = []; - if (data.mcpServers) { + if (composition.mcpServers) { let mcpServersObj = null; let mcpConfigPath = relPath; - if (typeof data.mcpServers === "string") { - const manifestMcpPath = data.mcpServers.replace(/^\.\//, ""); + if (typeof composition.mcpServers === "string") { + const manifestMcpPath = composition.mcpServers.replace(/^\.\//, ""); mcpConfigPath = manifestMcpPath ? `${relPath}/${manifestMcpPath}` : relPath; const mcpJsonPath = path.join(pluginDir, manifestMcpPath); if (fs.existsSync(mcpJsonPath)) { @@ -661,8 +604,8 @@ function generatePluginsData(gitDates, resourceIndex = {}) { // ignore parse errors } } - } else if (typeof data.mcpServers === "object") { - mcpServersObj = data.mcpServers; + } else if (typeof composition.mcpServers === "object") { + mcpServersObj = composition.mcpServers; } if (mcpServersObj) { for (const serverName of Object.keys(mcpServersObj)) { @@ -674,8 +617,8 @@ function generatePluginsData(gitDates, resourceIndex = {}) { // Build items list from spec fields (agents, commands, skills, mcpServers) const items = [ ...agentItems, - ...(data.commands || []).map((p) => ({ kind: "prompt", path: p })), - ...(data.skills || []).map((p) => ({ kind: "skill", path: p })), + ...(composition.commands || []).map((p) => ({ kind: "prompt", path: p })), + ...(composition.skills || []).map((p) => ({ kind: "skill", path: p })), ...extensionItems, ...mcpItems, ].map((item) => resolvePluginItem(item, resourceIndex)); @@ -702,16 +645,11 @@ function generatePluginsData(gitDates, resourceIndex = {}) { searchText: `${pluginName} ${data.description || "" } ${tags.join(" ")}`.toLowerCase(), }); - extensionEntriesByName.delete(pluginName); } catch (e) { console.warn(`Failed to parse plugin: ${dir.name}`, e.message); } } - for (const extensionPlugin of extensionEntriesByName.values()) { - plugins.push(extensionPlugin); - } - // Load external plugins from plugins/external.json const externalJsonPath = path.join(PLUGINS_DIR, "external.json"); if (fs.existsSync(externalJsonPath)) { @@ -1204,15 +1142,13 @@ function resolveExtensionScreenshots(pluginJson, extensionDir, relPath, ref) { } : null; - const logoEntry = normalizeExtensionScreenshotRole(pluginJson?.logo, relPath, ref); - const screenshotConfig = pluginJson?.["x-awesome-copilot"]?.screenshots || {}; - const iconEntry = normalizeExtensionScreenshotRole(screenshotConfig.icon, relPath, ref); - const galleryRaw = screenshotConfig.gallery; - const firstGalleryEntry = Array.isArray(galleryRaw) ? galleryRaw[0] : galleryRaw; - const galleryEntry = normalizeExtensionScreenshotRole(firstGalleryEntry, relPath, ref); - - const finalIcon = iconEntry || logoEntry || inferredIcon; - const finalGallery = galleryEntry || logoEntry || inferredGallery || finalIcon; + const copilotNs = pluginJson?.extensions?.["com.github.copilot"]; + const logoEntry = normalizeExtensionScreenshotRole( + copilotNs?.logo ?? pluginJson?.logo, + relPath, ref + ); + const finalIcon = logoEntry || inferredIcon; + const finalGallery = logoEntry || inferredGallery || finalIcon; return { screenshots: { @@ -1256,7 +1192,7 @@ function generateCanvasManifest(gitDates, commitSha) { const packageJson = fs.existsSync(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}; - const pluginJsonPath = path.join(extensionDir, ".github", "plugin", "plugin.json"); + const pluginJsonPath = path.join(PLUGINS_DIR, dir.name, "plugin.json"); const pluginJson = fs.existsSync(pluginJsonPath) ? JSON.parse(fs.readFileSync(pluginJsonPath, "utf-8")) : {}; diff --git a/eng/materialize-plugins.mjs b/eng/materialize-plugins.mjs index 905b2faf..8d9b6ef7 100644 --- a/eng/materialize-plugins.mjs +++ b/eng/materialize-plugins.mjs @@ -7,6 +7,8 @@ import { ROOT_FOLDER } from "./constants.mjs"; const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins"); const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions"); +const COPILOT_NAMESPACE = "com.github.copilot"; +const AWESOME_COPILOT_NAMESPACE = "com.github.awesome-copilot"; /** * Recursively copy a directory. @@ -24,36 +26,6 @@ function copyDirRecursive(src, dest) { } } -function moveEntry(srcPath, destPath) { - fs.mkdirSync(path.dirname(destPath), { recursive: true }); - try { - fs.renameSync(srcPath, destPath); - return; - } catch (error) { - if (error?.code !== "EXDEV") { - throw error; - } - } - - const stats = fs.statSync(srcPath); - if (stats.isDirectory()) { - copyDirRecursive(srcPath, destPath); - fs.rmSync(srcPath, { recursive: true, force: true }); - return; - } - - fs.copyFileSync(srcPath, destPath); - fs.rmSync(srcPath, { force: true }); -} - -function isRelativeAssetPath(assetPath) { - return typeof assetPath === "string" && - assetPath.length > 0 && - !/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(assetPath) && - !assetPath.startsWith("data:") && - !path.isAbsolute(assetPath); -} - /** * Resolve a plugin-relative path to the repo-root source file. * @@ -74,63 +46,34 @@ function resolveSource(relPath) { const extensionName = relPath.replace(/^\.\/extensions\//, "").replace(/\/$/, ""); return path.join(ROOT_FOLDER, "extensions", extensionName); } + if (relPath.startsWith("./hooks/")) { + return path.join(ROOT_FOLDER, "hooks", relPath.replace(/^\.\/hooks\//, "")); + } + if (relPath.startsWith("./commands/")) { + return path.join(ROOT_FOLDER, "commands", relPath.replace(/^\.\/commands\//, "")); + } return null; } -export function materializeExtensionPlugin(extensionPath) { - const pluginJsonPath = path.join(extensionPath, ".github", "plugin", "plugin.json"); - if (!fs.existsSync(pluginJsonPath)) { - return { movedEntries: 0, manifestUpdated: false, skipped: true }; +function readExtensionReferences(metadata, pluginName) { + const extensionData = metadata.extensions?.[AWESOME_COPILOT_NAMESPACE]; + const directories = extensionData?.extensions ?? []; + if (!Array.isArray(directories) || + directories.some((entry) => typeof entry !== "string" || !entry.startsWith("./extensions/"))) { + throw new Error(`extensions["${AWESOME_COPILOT_NAMESPACE}"].extensions must contain plugin-relative paths`); } - let metadata; - try { - metadata = JSON.parse(fs.readFileSync(pluginJsonPath, "utf8")); - } catch (err) { - throw new Error(`Failed to parse ${pluginJsonPath}: ${err.message}`); + const names = new Set(directories.map((entry) => + entry.replace(/^\.\/extensions\//, "").replace(/\/$/, "") + )); + if (fs.existsSync(path.join(EXTENSIONS_DIR, pluginName, "extension.mjs"))) { + names.add(pluginName); } - const extensionContainerPath = path.join(extensionPath, "extensions"); - const extensionBundlePath = path.join(extensionContainerPath, path.basename(extensionPath)); - fs.rmSync(extensionContainerPath, { recursive: true, force: true }); - fs.mkdirSync(extensionBundlePath, { recursive: true }); - - let movedEntries = 0; - for (const entry of fs.readdirSync(extensionPath, { withFileTypes: true })) { - if (entry.name === ".github" || entry.name === "extensions") { - continue; - } - - moveEntry( - path.join(extensionPath, entry.name), - path.join(extensionBundlePath, entry.name) - ); - movedEntries++; - } - - if (isRelativeAssetPath(metadata.logo)) { - const normalizedLogoPath = metadata.logo.replace(/\\/g, "/").replace(/^\.\//, ""); - const bundledLogoPath = path.join(extensionBundlePath, normalizedLogoPath); - if (fs.existsSync(bundledLogoPath)) { - const rootLogoPath = path.join(extensionPath, normalizedLogoPath); - fs.mkdirSync(path.dirname(rootLogoPath), { recursive: true }); - fs.copyFileSync(bundledLogoPath, rootLogoPath); - } - } - - let manifestUpdated = false; - if (metadata.extensions !== "extensions") { - metadata.extensions = "extensions"; - manifestUpdated = true; - } - if (manifestUpdated) { - fs.writeFileSync(pluginJsonPath, JSON.stringify(metadata, null, 2) + "\n", "utf8"); - } - - return { movedEntries, manifestUpdated, skipped: false }; + return [...names].sort(); } -function materializePlugins() { +export function materializePlugins() { console.log("Materializing plugin files...\n"); if (!fs.existsSync(PLUGINS_DIR)) { @@ -146,14 +89,12 @@ function materializePlugins() { let totalAgents = 0; let totalSkills = 0; let totalExtensions = 0; - let totalExtensionPlugins = 0; - let totalExtensionPluginEntries = 0; let warnings = 0; let errors = 0; for (const dirName of pluginDirs) { const pluginPath = path.join(PLUGINS_DIR, dirName); - const pluginJsonPath = path.join(pluginPath, ".github/plugin", "plugin.json"); + const pluginJsonPath = path.join(pluginPath, "plugin.json"); if (!fs.existsSync(pluginJsonPath)) { continue; @@ -170,52 +111,37 @@ function materializePlugins() { const pluginName = metadata.name || dirName; - // Process agents - if (Array.isArray(metadata.agents)) { - for (const relPath of metadata.agents) { + const composition = metadata.extensions?.[AWESOME_COPILOT_NAMESPACE] ?? {}; + + // Process repository composition fields. + for (const field of ["agents", "commands", "hooks", "skills"]) { + const entries = composition[field]; + if (!Array.isArray(entries)) continue; + for (const relPath of entries) { const src = resolveSource(relPath); if (!src) { - console.warn(` ⚠ ${pluginName}: Unknown path format: ${relPath}`); + console.warn(` ⚠ ${pluginName}: Unknown ${field} path format: ${relPath}`); warnings++; continue; } if (!fs.existsSync(src)) { - console.warn(` ⚠ ${pluginName}: Source not found: ${src}`); - warnings++; - continue; - } - const dest = path.join(pluginPath, relPath.replace(/^\.\//, "")); - fs.mkdirSync(path.dirname(dest), { recursive: true }); - fs.copyFileSync(src, dest); - totalAgents++; - } - } - - // Process skills - if (Array.isArray(metadata.skills)) { - for (const relPath of metadata.skills) { - const src = resolveSource(relPath); - if (!src) { - console.warn(` ⚠ ${pluginName}: Unknown path format: ${relPath}`); - warnings++; - continue; - } - if (!fs.existsSync(src) || !fs.statSync(src).isDirectory()) { - console.warn(` ⚠ ${pluginName}: Source directory not found: ${src}`); + console.warn(` ⚠ ${pluginName}: ${field} source not found: ${src}`); warnings++; continue; } const dest = path.join(pluginPath, relPath.replace(/^\.\//, "").replace(/\/$/, "")); - copyDirRecursive(src, dest); - totalSkills++; + fs.mkdirSync(path.dirname(dest), { recursive: true }); + if (fs.statSync(src).isDirectory()) copyDirRecursive(src, dest); + else fs.copyFileSync(src, dest); + if (field === "agents") totalAgents++; + if (field === "skills") totalSkills++; } } - // Process extension references from x-awesome-copilot.extensions - const extensionRefs = Array.isArray(metadata?.["x-awesome-copilot"]?.extensions) - ? metadata["x-awesome-copilot"].extensions - : []; - for (const relPath of extensionRefs) { + // Process reusable extensions declared in the repository namespace. + const extensionRefs = readExtensionReferences(metadata, pluginName); + for (const extensionName of extensionRefs) { + const relPath = `./extensions/${extensionName}`; const src = resolveSource(relPath); if (!src) { console.warn(` ⚠ ${pluginName}: Unknown extension path format: ${relPath}`); @@ -227,81 +153,47 @@ function materializePlugins() { warnings++; continue; } - const dest = path.join(pluginPath, relPath.replace(/^\.\//, "").replace(/\/$/, "")); + // Extensions are conventional plugin content and belong under the + // plugin's top-level extensions directory, not the client namespace. + const dest = path.join(pluginPath, "extensions", extensionName); copyDirRecursive(src, dest); totalExtensions++; } - // Rewrite plugin.json to use folder paths instead of individual file paths. - // On staged, paths like ./agents/foo.md point to individual source files. - // On main, after materialization, we only need the containing directory. - const rewritten = { ...metadata }; - let changed = false; + // Emit a spec-compliant served manifest for the marketplace branch. + // Source manifests keep composition fields (agents and skills) + // for build tooling. The served manifest retains only Agent Plugins v1.0.0 fields + // so the runtime uses conventional directory discovery for all content. + const SPEC_FIELDS = new Set(["$schema", "name", "version", "description", "author", + "homepage", "repository", "license", "keywords", "extensions"]); + const AGENT_PLUGINS_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; - for (const field of ["agents", "commands"]) { - if (Array.isArray(rewritten[field]) && rewritten[field].length > 0) { - const dirs = [...new Set(rewritten[field].map(p => path.dirname(p)))]; - rewritten[field] = dirs; - changed = true; + const served = { "$schema": AGENT_PLUGINS_SCHEMA }; + for (const [key, val] of Object.entries(metadata)) { + if (SPEC_FIELDS.has(key) && key !== "$schema") { + if (key === "extensions") { + const copilot = val?.[COPILOT_NAMESPACE]; + if (copilot) { + served.extensions = { [COPILOT_NAMESPACE]: { ...copilot } }; + } + } else { + served[key] = val; + } } } - if (Array.isArray(rewritten.skills) && rewritten.skills.length > 0) { - // Skills are already folder refs (./skills/name/); strip trailing slash - rewritten.skills = rewritten.skills.map(p => p.replace(/\/$/, "")); - changed = true; - } - - if (Array.isArray(rewritten?.["x-awesome-copilot"]?.extensions) && - rewritten["x-awesome-copilot"].extensions.length > 0) { - rewritten["x-awesome-copilot"].extensions = - rewritten["x-awesome-copilot"].extensions.map((p) => p.replace(/\/$/, "")); - changed = true; - } - - if (changed) { - fs.writeFileSync(pluginJsonPath, JSON.stringify(rewritten, null, 2) + "\n", "utf8"); - } + fs.writeFileSync(pluginJsonPath, JSON.stringify(served, null, 2) + "\n", "utf8"); const counts = []; - if (metadata.agents?.length) counts.push(`${metadata.agents.length} agents`); - if (metadata.skills?.length) counts.push(`${metadata.skills.length} skills`); + if (composition.agents?.length) counts.push(`${composition.agents.length} agents`); + if (composition.skills?.length) counts.push(`${composition.skills.length} skills`); if (extensionRefs.length) counts.push(`${extensionRefs.length} extensions`); if (counts.length) { console.log(`✓ ${pluginName}: ${counts.join(", ")}`); } } - 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.movedEntries; - console.log(`✓ ${dirName}: materialized extension bundle into ./extensions (${result.movedEntries} entries)`); - } catch (err) { - console.error(`Error: Failed to materialize extension plugin ${dirName}: ${err.message}`); - errors++; - } - } - } - - console.log(`\nDone. Copied ${totalAgents} agents, ${totalSkills} skills, ${totalExtensions} plugin extension refs.`); - console.log(`Materialized ${totalExtensionPlugins} extension plugins (${totalExtensionPluginEntries} top-level entries).`); + console.log(`\nDone. Copied ${totalAgents} agents, ${totalSkills} skills, ${totalExtensions} extensions.`); if (warnings > 0) { console.log(`${warnings} warning(s).`); } @@ -311,8 +203,6 @@ function 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 index d0bce457..16e12104 100644 --- a/eng/materialize-plugins.test.mjs +++ b/eng/materialize-plugins.test.mjs @@ -1,89 +1,9 @@ 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"; -import { cleanMaterializedExtensionPlugin } from "./clean-materialized-plugins.mjs"; +import { test } from "node:test"; +import { materializePlugins } from "./materialize-plugins.mjs"; +import { generateMarketplace } from "./generate-marketplace.mjs"; -const tempDirs = []; - -after(() => { - for (const dir of tempDirs) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test("materializeExtensionPlugin writes extension bundles to ./extensions and preserves root logo assets", () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "materialize-extension-plugin-")); - tempDirs.push(tempDir); - - 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); - const bundleRoot = path.join(pluginDir, "extensions", "extension-plugin"); - - assert.equal(result.skipped, false); - assert.equal(result.manifestUpdated, true); - assert.equal(result.movedEntries, 3); - assert.equal(fs.existsSync(path.join(bundleRoot, "extension.mjs")), true); - assert.equal(fs.existsSync(path.join(bundleRoot, "assets", "preview.png")), true); - assert.equal(fs.existsSync(path.join(bundleRoot, "README.md")), true); - assert.equal(fs.existsSync(path.join(pluginDir, "extensions", ".github")), false); - assert.equal(fs.existsSync(path.join(pluginDir, "extension.mjs")), false); - assert.equal(fs.existsSync(path.join(pluginDir, "README.md")), false); - assert.equal(fs.existsSync(path.join(pluginDir, "assets", "preview.png")), true); - - const pluginManifest = JSON.parse( - fs.readFileSync(path.join(pluginDir, ".github", "plugin", "plugin.json"), "utf8") - ); - assert.equal(pluginManifest.extensions, "extensions"); - assert.equal(pluginManifest.logo, "assets/preview.png"); -}); - -test("cleanMaterializedExtensionPlugin restores moved extension files to root", () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "clean-materialized-extension-plugin-")); - tempDirs.push(tempDir); - - const pluginDir = path.join(tempDir, "extension-plugin"); - fs.mkdirSync(path.join(pluginDir, ".github", "plugin"), { recursive: true }); - fs.mkdirSync(path.join(pluginDir, "assets"), { recursive: true }); - fs.writeFileSync(path.join(pluginDir, ".github", "plugin", "plugin.json"), JSON.stringify({ - name: "test-extension-plugin", - description: "test plugin", - version: "1.0.0", - logo: "assets/preview.png", - extensions: ".", - }, null, 2)); - fs.writeFileSync(path.join(pluginDir, "extension.mjs"), "export default {};\n"); - fs.writeFileSync(path.join(pluginDir, "README.md"), "# test\n"); - fs.writeFileSync(path.join(pluginDir, "assets", "preview.png"), "fake-image-bytes"); - - materializeExtensionPlugin(pluginDir); - const result = cleanMaterializedExtensionPlugin(pluginDir); - - assert.equal(result.removed, 3); - assert.equal(result.manifestUpdated, true); - assert.equal(fs.existsSync(path.join(pluginDir, "extension.mjs")), true); - assert.equal(fs.existsSync(path.join(pluginDir, "README.md")), true); - assert.equal(fs.existsSync(path.join(pluginDir, "assets", "preview.png")), true); - assert.equal(fs.existsSync(path.join(pluginDir, "extensions")), false); - - const pluginManifest = JSON.parse( - fs.readFileSync(path.join(pluginDir, ".github", "plugin", "plugin.json"), "utf8") - ); - assert.equal(pluginManifest.extensions, "."); - assert.equal(pluginManifest.logo, "assets/preview.png"); +test("build scripts expose callable APIs without running on import", () => { + assert.equal(typeof materializePlugins, "function"); + assert.equal(typeof generateMarketplace, "function"); }); diff --git a/eng/update-readme.mjs b/eng/update-readme.mjs index db671b8a..b6cfbd67 100644 --- a/eng/update-readme.mjs +++ b/eng/update-readme.mjs @@ -29,6 +29,7 @@ import { const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions"); // Cache of MCP registry server names (lower-cased) fetched from the API let MCP_REGISTRY_SET = null; @@ -715,7 +716,7 @@ function generateUnifiedModeSection(cfg) { * Read and parse a plugin.json file from a plugin directory. */ function readPluginJson(pluginDir) { - const jsonPath = path.join(pluginDir, ".github/plugin", "plugin.json"); + const jsonPath = path.join(pluginDir, "plugin.json"); if (!fs.existsSync(jsonPath)) return null; try { return JSON.parse(fs.readFileSync(jsonPath, "utf-8")); @@ -786,10 +787,23 @@ function generatePluginsSection(pluginsDir) { for (const entry of sortedEntries) { const { plugin, dir, name, isFeatured } = entry; const description = formatTableCell(plugin.description || "No description"); + const composition = plugin.extensions?.["com.github.awesome-copilot"] || {}; + const extensionReferences = Array.isArray(composition.extensions) + ? composition.extensions.length + : 0; + const implicitExtension = + fs.existsSync(path.join(EXTENSIONS_DIR, entry.pluginId, "extension.mjs")) && + !(Array.isArray(composition.extensions) && composition.extensions.some( + (reference) => reference === `./extensions/${entry.pluginId}` + )) + ? 1 + : 0; const itemCount = - (plugin.agents || []).length + - (plugin.commands || []).length + - (plugin.skills || []).length; + (composition.agents || []).length + + (composition.commands || []).length + + (composition.skills || []).length + + extensionReferences + + implicitExtension; const keywords = plugin.keywords ? plugin.keywords.join(", ") : ""; const link = `../plugins/${dir}/README.md`; @@ -842,10 +856,23 @@ function generateFeaturedPluginsSection(pluginsDir) { plugin.description || "No description" ); const keywords = plugin.keywords ? plugin.keywords.join(", ") : ""; + const composition = plugin.extensions?.["com.github.awesome-copilot"] || {}; + const extensionReferences = Array.isArray(composition.extensions) + ? composition.extensions.length + : 0; + const implicitExtension = + fs.existsSync(path.join(EXTENSIONS_DIR, name, "extension.mjs")) && + !(Array.isArray(composition.extensions) && composition.extensions.some( + (reference) => reference === `./extensions/${name}` + )) + ? 1 + : 0; const itemCount = - (plugin.agents || []).length + - (plugin.commands || []).length + - (plugin.skills || []).length; + (composition.agents || []).length + + (composition.commands || []).length + + (composition.skills || []).length + + extensionReferences + + implicitExtension; return { dir, diff --git a/eng/validate-plugins.mjs b/eng/validate-plugins.mjs index e8cbf1a6..38d0304a 100755 --- a/eng/validate-plugins.mjs +++ b/eng/validate-plugins.mjs @@ -5,10 +5,15 @@ import path from "path"; import { ROOT_FOLDER } from "./constants.mjs"; import { readExternalPlugins } from "./external-plugin-validation.mjs"; import { validateLicenseField } from "./lib/license.mjs"; +import { AGENT_PLUGIN_SCHEMA_URL, validateAgentPluginManifest } from "./agent-plugin-schema.mjs"; const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins"); const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions"); +const AGENT_PLUGINS_SCHEMA = AGENT_PLUGIN_SCHEMA_URL; +const COPILOT_NAMESPACE = "com.github.copilot"; +const AWESOME_COPILOT_NAMESPACE = "com.github.awesome-copilot"; + // Validation functions function validateName(name, folderName) { const errors = []; @@ -16,11 +21,11 @@ function validateName(name, folderName) { errors.push("name is required and must be a string"); return errors; } - if (name.length < 1 || name.length > 50) { - errors.push("name must be between 1 and 50 characters"); + if (name.length < 1 || name.length > 64) { + errors.push("name must be between 1 and 64 characters"); } - if (!/^[a-z0-9-]+$/.test(name)) { - errors.push("name must contain only lowercase letters, numbers, and hyphens"); + if (!/^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(name)) { + errors.push("name must contain only lowercase letters, numbers, hyphens, and dots (spec §5.5)"); } if (name !== folderName) { errors.push(`name "${name}" must match folder name "${folderName}"`); @@ -28,6 +33,14 @@ function validateName(name, folderName) { return errors; } +function validateSchema(parsed) { + if (parsed["$schema"] !== AGENT_PLUGINS_SCHEMA) { + return `$schema must be "${AGENT_PLUGINS_SCHEMA}"`; + } + const schemaErrors = validateAgentPluginManifest(parsed); + return schemaErrors.length ? `manifest does not conform to Agent Plugins schema: ${schemaErrors.join("; ")}` : null; +} + function validateDescription(description) { if (!description || typeof description !== "string") { return "description is required and must be a string"; @@ -106,35 +119,40 @@ function validateSpecPaths(plugin) { const errors = []; const specs = { agents: { prefix: "./agents/", suffix: ".md", repoDir: "agents", repoSuffix: ".agent.md" }, + commands: { prefix: "./commands/", suffix: ".md", repoDir: "commands", repoSuffix: ".md" }, + hooks: { prefix: "./hooks/", suffix: "/", repoDir: "hooks", repoFile: "README.md" }, skills: { prefix: "./skills/", suffix: "/", repoDir: "skills", repoFile: "SKILL.md" }, }; for (const [field, spec] of Object.entries(specs)) { - const arr = plugin[field]; + const arr = plugin.extensions?.[AWESOME_COPILOT_NAMESPACE]?.[field]; if (arr === undefined) continue; if (!Array.isArray(arr)) { - errors.push(`${field} must be an array`); + errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field} must be an array`); continue; } if (!arraysEqual(arr, sortPluginEntries(arr))) { - errors.push(`${field} must be sorted alphabetically`); + errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field} must be sorted alphabetically`); + } + if (new Set(arr).size !== arr.length) { + errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field} must not contain duplicate references`); } for (let i = 0; i < arr.length; i++) { const p = arr[i]; if (typeof p !== "string") { - errors.push(`${field}[${i}] must be a string`); + errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}[${i}] must be a string`); continue; } if (!p.startsWith("./")) { - errors.push(`${field}[${i}] must start with "./"`); + errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}[${i}] must start with "./"`); continue; } if (!p.startsWith(spec.prefix)) { - errors.push(`${field}[${i}] must start with "${spec.prefix}"`); + errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}[${i}] must start with "${spec.prefix}"`); continue; } if (!p.endsWith(spec.suffix)) { - errors.push(`${field}[${i}] must end with "${spec.suffix}"`); + errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}[${i}] must end with "${spec.suffix}"`); continue; } // Validate the source file exists at repo root @@ -143,12 +161,16 @@ function validateSpecPaths(plugin) { const skillDir = path.join(ROOT_FOLDER, spec.repoDir, basename); const skillFile = path.join(skillDir, spec.repoFile); if (!fs.existsSync(skillFile)) { - errors.push(`${field}[${i}] source not found: ${spec.repoDir}/${basename}/SKILL.md`); + errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}[${i}] source not found: ${spec.repoDir}/${basename}/SKILL.md`); } } else { - const srcFile = path.join(ROOT_FOLDER, spec.repoDir, basename + spec.repoSuffix); + const srcFile = spec.repoFile + ? path.join(ROOT_FOLDER, spec.repoDir, basename, spec.repoFile) + : path.join(ROOT_FOLDER, spec.repoDir, basename + spec.repoSuffix); if (!fs.existsSync(srcFile)) { - errors.push(`${field}[${i}] source not found: ${spec.repoDir}/${basename}${spec.repoSuffix}`); + errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}[${i}] source not found`); + } else if (field === "hooks" && !fs.existsSync(path.join(ROOT_FOLDER, spec.repoDir, basename, "hooks.json"))) { + errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}[${i}] source not found: ${spec.repoDir}/${basename}/hooks.json`); } } } @@ -156,41 +178,61 @@ function validateSpecPaths(plugin) { return errors; } -function validateCuratedPluginExtensionRefs(plugin) { +function validateExtensionReferences(plugin, pluginDir) { const errors = []; - const extensionRefs = plugin?.["x-awesome-copilot"]?.extensions; - if (extensionRefs === undefined) { + const directories = plugin.extensions?.[AWESOME_COPILOT_NAMESPACE]?.extensions; + if (directories === undefined) { + return errors; + } + if (!Array.isArray(directories)) { + errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].extensions must be an array`); + return errors; + } + if (!arraysEqual(directories, sortPluginEntries(directories))) { + errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].extensions entries must be sorted alphabetically`); + } + if (new Set(directories).size !== directories.length) { + errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].extensions must not contain duplicate references`); + } + + for (const [index, directory] of directories.entries()) { + const name = typeof directory === "string" + ? directory.replace(/^\.\/extensions\//, "").replace(/\/$/, "") + : ""; + if (typeof directory !== "string" || !directory.startsWith("./extensions/") || + !/^[a-z0-9][a-z0-9.-]*[a-z0-9]$|^[a-z0-9]$/.test(name)) { + errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].extensions[${index}] must be a valid ./extensions/ path`); + continue; + } + if (!fs.existsSync(path.join(EXTENSIONS_DIR, name, "extension.mjs"))) { + errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].extensions[${index}] source not found: extensions/${name}`); + } + } + + return errors; +} + +function validateCompositionNamespace(plugin) { + const errors = []; + const compositionFields = ["agents", "commands", "hooks", "mcpServers", "skills"]; + const extensions = plugin.extensions; + const composition = extensions?.[AWESOME_COPILOT_NAMESPACE]; + + if (extensions !== undefined && + (typeof extensions !== "object" || extensions === null || Array.isArray(extensions))) { + errors.push(`extensions must be an object containing "${AWESOME_COPILOT_NAMESPACE}"`); return errors; } - if (!Array.isArray(extensionRefs)) { - errors.push('x-awesome-copilot.extensions must be an array'); + if (composition !== undefined && + (typeof composition !== "object" || composition === null || Array.isArray(composition))) { + errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"] must be an object`); return errors; } - if (!arraysEqual(extensionRefs, sortPluginEntries(extensionRefs))) { - errors.push('x-awesome-copilot.extensions must be sorted alphabetically'); - } - - const knownExtensions = new Set(getExtensionFolderNames()); - for (let i = 0; i < extensionRefs.length; i++) { - const ref = extensionRefs[i]; - if (typeof ref !== "string") { - errors.push(`x-awesome-copilot.extensions[${i}] must be a string`); - continue; - } - if (!ref.startsWith("./extensions/")) { - errors.push(`x-awesome-copilot.extensions[${i}] must start with "./extensions/"`); - continue; - } - - const normalized = ref.replace(/^\.\/extensions\//, "").replace(/\/$/, ""); - if (!normalized) { - errors.push(`x-awesome-copilot.extensions[${i}] must include an extension folder name`); - continue; - } - if (!knownExtensions.has(normalized)) { - errors.push(`x-awesome-copilot.extensions[${i}] source not found: extensions/${normalized}`); + for (const field of compositionFields) { + if (extensions?.[field] !== undefined) { + errors.push(`extensions.${field} must be moved to extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}`); } } @@ -201,11 +243,13 @@ function validatePlugin(folderName) { const pluginDir = path.join(PLUGINS_DIR, folderName); const errors = []; let parsedPlugin = null; + const extensionDir = path.join(EXTENSIONS_DIR, folderName); + const isExtensionPlugin = fs.existsSync(path.join(extensionDir, "extension.mjs")); - // Rule 1: Must have .github/plugin/plugin.json - const pluginJsonPath = path.join(pluginDir, ".github/plugin", "plugin.json"); + // Rule 1: Must have plugin.json at the plugin root + const pluginJsonPath = path.join(pluginDir, "plugin.json"); if (!fs.existsSync(pluginJsonPath)) { - errors.push("missing required file: .github/plugin/plugin.json"); + errors.push("missing required file: plugin.json"); return errors; } @@ -226,7 +270,11 @@ function validatePlugin(folderName) { return { errors, plugin: parsedPlugin }; } - // Rule 3 & 4: name, description, version + // Rule 3: $schema required + const schemaError = validateSchema(plugin); + if (schemaError) errors.push(schemaError); + + // Rule 4 & 5: name, description, version const nameErrors = validateName(plugin.name, folderName); errors.push(...nameErrors); @@ -236,12 +284,18 @@ function validatePlugin(folderName) { const versionError = validateVersion(plugin.version); if (versionError) errors.push(versionError); - // Rule 5: keywords (or tags for backward compat) + // Rule 6: keywords (or tags for backward compat) const keywordsError = validateKeywords(plugin.keywords ?? plugin.tags); if (keywordsError) errors.push(keywordsError); // Rule 5b: license (shared with external plugins). Non-SPDX is a warning, not an error. const warnings = []; + for (const field of ["agents", "commands", "hooks", "mcpServers", "skills"]) { + if (plugin[field] !== undefined) { + errors.push(`${field} must be moved to extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}`); + } + } + errors.push(...validateCompositionNamespace(plugin)); const licenseResult = validateLicenseField(plugin.license, { required: false }); errors.push(...licenseResult.errors); warnings.push(...licenseResult.warnings); @@ -250,9 +304,19 @@ function validatePlugin(folderName) { const specErrors = validateSpecPaths(plugin); errors.push(...specErrors); - const extensionRefErrors = validateCuratedPluginExtensionRefs(plugin); + const extensionRefErrors = validateExtensionReferences(plugin, pluginDir); errors.push(...extensionRefErrors); + if (isExtensionPlugin) { + const extension = plugin.extensions; + const namespace = extension?.[COPILOT_NAMESPACE]; + if (!namespace || namespace.logo !== "assets/preview.png") { + errors.push(`extensions["${COPILOT_NAMESPACE}"].logo must be exactly "assets/preview.png" for extension plugins`); + } else { + validateExtensionScreenshotPath(extensionDir, namespace.logo, `extensions["${COPILOT_NAMESPACE}"].logo`, errors); + } + } + return { errors, warnings, plugin: parsedPlugin }; } @@ -269,62 +333,6 @@ function validateExtensionScreenshotPath(extensionDir, pathValue, fieldName, err } } -function validateExtensionManifest(folderName) { - const extensionDir = path.join(EXTENSIONS_DIR, folderName); - const errors = []; - let parsedPlugin = null; - - const pluginJsonPath = path.join(extensionDir, ".github/plugin", "plugin.json"); - if (!fs.existsSync(pluginJsonPath)) { - errors.push("missing required file: .github/plugin/plugin.json"); - return { errors, plugin: parsedPlugin }; - } - - const parsed = parseJsonFile(pluginJsonPath); - if (parsed.parseError) { - errors.push(`failed to parse plugin.json: ${parsed.parseError}`); - return { errors, plugin: parsedPlugin }; - } - - parsedPlugin = parsed; - - const nameErrors = validateName(parsed.name, folderName); - errors.push(...nameErrors); - - const descError = validateDescription(parsed.description); - if (descError) errors.push(descError); - - const versionError = validateVersion(parsed.version); - if (versionError) errors.push(versionError); - - const keywordsError = validateKeywords(parsed.keywords ?? parsed.tags); - if (keywordsError) errors.push(keywordsError); - - // Extension convention: logo must be exactly "assets/preview.png" - if (parsed.logo !== "assets/preview.png") { - errors.push('logo must be exactly "assets/preview.png" (extension convention)'); - } else { - validateExtensionScreenshotPath(extensionDir, parsed.logo, "logo", errors); - } - - // Extension convention: x-awesome-copilot must not be present - if (parsed["x-awesome-copilot"] !== undefined) { - errors.push("x-awesome-copilot field must not be present (use convention-based logo instead)"); - } - - 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 "." in source manifests (extension convention)'); - } - - return { errors, plugin: parsedPlugin }; -} - // Main validation function function validatePlugins() { const pluginDirs = fs.existsSync(PLUGINS_DIR) @@ -332,15 +340,12 @@ function validatePlugins() { .filter((d) => d.isDirectory()) .map((d) => d.name) : []; - const extensionDirs = getExtensionFolderNames(); - - if (pluginDirs.length === 0 && extensionDirs.length === 0) { - console.log("No plugins or extension plugin manifests found - validation skipped"); + if (pluginDirs.length === 0) { + console.log("No plugin manifests found - validation skipped"); return true; } console.log(`Validating ${pluginDirs.length} plugins...\n`); - console.log(`Validating ${extensionDirs.length} extensions as plugin sources...\n`); let hasErrors = false; const seenNames = new Set(); @@ -374,30 +379,11 @@ function validatePlugins() { } } - if (extensionDirs.length > 0) { - console.log(""); - } - - for (const dir of extensionDirs) { - console.log(`Validating extension ${dir}...`); - const { errors, plugin } = validateExtensionManifest(dir); - - if (errors.length > 0) { - console.error(`❌ extension ${dir}:`); - errors.forEach((e) => console.error(` - ${e}`)); + for (const dir of getExtensionFolderNames()) { + const pluginJsonPath = path.join(PLUGINS_DIR, dir, "plugin.json"); + if (!fs.existsSync(pluginJsonPath)) { + console.error(`❌ extension ${dir}: missing plugin manifest at plugins/${dir}/plugin.json`); hasErrors = true; - } else { - console.log(`✅ extension ${dir} is valid`); - } - - if (plugin?.name) { - if (seenNames.has(plugin.name)) { - console.error(`❌ Duplicate plugin name "${plugin.name}"`); - hasErrors = true; - } else { - seenNames.add(plugin.name); - localPluginNames.push(plugin.name); - } } } @@ -418,7 +404,7 @@ function validatePlugins() { } if (!hasErrors) { - console.log(`\n✅ All ${pluginDirs.length} plugins, ${extensionDirs.length} extensions, and the external catalog are valid`); + console.log(`\n✅ All ${pluginDirs.length} plugins and the external catalog are valid`); } return !hasErrors; diff --git a/extensions/accessibility-kanban/extensions/accessibility-kanban/extension.mjs b/extensions/accessibility-kanban/extension.mjs similarity index 100% rename from extensions/accessibility-kanban/extensions/accessibility-kanban/extension.mjs rename to extensions/accessibility-kanban/extension.mjs diff --git a/extensions/accessibility-kanban/extensions/accessibility-kanban/package.json b/extensions/accessibility-kanban/package.json similarity index 100% rename from extensions/accessibility-kanban/extensions/accessibility-kanban/package.json rename to extensions/accessibility-kanban/package.json diff --git a/extensions/accessibility-kanban/extensions/accessibility-kanban/public/index.html b/extensions/accessibility-kanban/public/index.html similarity index 100% rename from extensions/accessibility-kanban/extensions/accessibility-kanban/public/index.html rename to extensions/accessibility-kanban/public/index.html diff --git a/extensions/apng-studio/extensions/apng-studio/.gitignore b/extensions/apng-studio/.gitignore similarity index 100% rename from extensions/apng-studio/extensions/apng-studio/.gitignore rename to extensions/apng-studio/.gitignore diff --git a/extensions/apng-studio/extensions/apng-studio/LICENSE b/extensions/apng-studio/LICENSE similarity index 100% rename from extensions/apng-studio/extensions/apng-studio/LICENSE rename to extensions/apng-studio/LICENSE diff --git a/extensions/apng-studio/extensions/apng-studio/README.md b/extensions/apng-studio/README.md similarity index 100% rename from extensions/apng-studio/extensions/apng-studio/README.md rename to extensions/apng-studio/README.md diff --git a/extensions/apng-studio/extensions/apng-studio/apng.mjs b/extensions/apng-studio/apng.mjs similarity index 100% rename from extensions/apng-studio/extensions/apng-studio/apng.mjs rename to extensions/apng-studio/apng.mjs diff --git a/extensions/apng-studio/extensions/apng-studio/assets/demo.png b/extensions/apng-studio/assets/demo.png similarity index 100% rename from extensions/apng-studio/extensions/apng-studio/assets/demo.png rename to extensions/apng-studio/assets/demo.png diff --git a/extensions/apng-studio/extensions/apng-studio/copilot-extension.json b/extensions/apng-studio/copilot-extension.json similarity index 100% rename from extensions/apng-studio/extensions/apng-studio/copilot-extension.json rename to extensions/apng-studio/copilot-extension.json diff --git a/extensions/apng-studio/extensions/apng-studio/extension.mjs b/extensions/apng-studio/extension.mjs similarity index 100% rename from extensions/apng-studio/extensions/apng-studio/extension.mjs rename to extensions/apng-studio/extension.mjs diff --git a/extensions/apng-studio/extensions/apng-studio/qr.mjs b/extensions/apng-studio/qr.mjs similarity index 100% rename from extensions/apng-studio/extensions/apng-studio/qr.mjs rename to extensions/apng-studio/qr.mjs diff --git a/extensions/apng-studio/extensions/apng-studio/web/app.js b/extensions/apng-studio/web/app.js similarity index 100% rename from extensions/apng-studio/extensions/apng-studio/web/app.js rename to extensions/apng-studio/web/app.js diff --git a/extensions/apng-studio/extensions/apng-studio/web/index.html b/extensions/apng-studio/web/index.html similarity index 100% rename from extensions/apng-studio/extensions/apng-studio/web/index.html rename to extensions/apng-studio/web/index.html diff --git a/extensions/apng-studio/extensions/apng-studio/web/styles.css b/extensions/apng-studio/web/styles.css similarity index 100% rename from extensions/apng-studio/extensions/apng-studio/web/styles.css rename to extensions/apng-studio/web/styles.css diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/README.md b/extensions/arcade-canvas/README.md similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/README.md rename to extensions/arcade-canvas/README.md diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/asset-.md b/extensions/arcade-canvas/assets/asset-.md similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/asset-.md rename to extensions/arcade-canvas/assets/asset-.md diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/canvas-background.webp b/extensions/arcade-canvas/assets/canvas-background.webp similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/canvas-background.webp rename to extensions/arcade-canvas/assets/canvas-background.webp diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_explosion.ogg b/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_explosion.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_explosion.ogg rename to extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_explosion.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_laser1.ogg b/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_laser1.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_laser1.ogg rename to extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_laser1.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_lose.ogg b/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_lose.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_lose.ogg rename to extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_lose.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_twoTone.ogg b/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_twoTone.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_twoTone.ogg rename to extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_twoTone.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/baiter.png b/extensions/arcade-canvas/assets/defender/baiter.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/baiter.png rename to extensions/arcade-canvas/assets/defender/baiter.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/bomber.png b/extensions/arcade-canvas/assets/defender/bomber.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/bomber.png rename to extensions/arcade-canvas/assets/defender/bomber.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/humanoid.png b/extensions/arcade-canvas/assets/defender/humanoid.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/humanoid.png rename to extensions/arcade-canvas/assets/defender/humanoid.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/lander.png b/extensions/arcade-canvas/assets/defender/lander.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/lander.png rename to extensions/arcade-canvas/assets/defender/lander.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/mutant.png b/extensions/arcade-canvas/assets/defender/mutant.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/mutant.png rename to extensions/arcade-canvas/assets/defender/mutant.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/planet-guard-sprites.png b/extensions/arcade-canvas/assets/defender/planet-guard-sprites.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/planet-guard-sprites.png rename to extensions/arcade-canvas/assets/defender/planet-guard-sprites.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/pod.png b/extensions/arcade-canvas/assets/defender/pod.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/pod.png rename to extensions/arcade-canvas/assets/defender/pod.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/ship.png b/extensions/arcade-canvas/assets/defender/ship.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/ship.png rename to extensions/arcade-canvas/assets/defender/ship.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/ship_left.png b/extensions/arcade-canvas/assets/defender/ship_left.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/ship_left.png rename to extensions/arcade-canvas/assets/defender/ship_left.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_baiterwarning.ogg b/extensions/arcade-canvas/assets/defender/sounds/sound_baiterwarning.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_baiterwarning.ogg rename to extensions/arcade-canvas/assets/defender/sounds/sound_baiterwarning.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_bonus.ogg b/extensions/arcade-canvas/assets/defender/sounds/sound_bonus.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_bonus.ogg rename to extensions/arcade-canvas/assets/defender/sounds/sound_bonus.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemydead.ogg b/extensions/arcade-canvas/assets/defender/sounds/sound_enemydead.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemydead.ogg rename to extensions/arcade-canvas/assets/defender/sounds/sound_enemydead.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot.ogg b/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot.ogg rename to extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot2.ogg b/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot2.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot2.ogg rename to extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot2.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_explode.ogg b/extensions/arcade-canvas/assets/defender/sounds/sound_explode.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_explode.ogg rename to extensions/arcade-canvas/assets/defender/sounds/sound_explode.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_humanoiddead.ogg b/extensions/arcade-canvas/assets/defender/sounds/sound_humanoiddead.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_humanoiddead.ogg rename to extensions/arcade-canvas/assets/defender/sounds/sound_humanoiddead.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_laser.ogg b/extensions/arcade-canvas/assets/defender/sounds/sound_laser.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_laser.ogg rename to extensions/arcade-canvas/assets/defender/sounds/sound_laser.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_player1up.ogg b/extensions/arcade-canvas/assets/defender/sounds/sound_player1up.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_player1up.ogg rename to extensions/arcade-canvas/assets/defender/sounds/sound_player1up.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_playerdead.ogg b/extensions/arcade-canvas/assets/defender/sounds/sound_playerdead.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_playerdead.ogg rename to extensions/arcade-canvas/assets/defender/sounds/sound_playerdead.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_start.ogg b/extensions/arcade-canvas/assets/defender/sounds/sound_start.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_start.ogg rename to extensions/arcade-canvas/assets/defender/sounds/sound_start.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_thurst.ogg b/extensions/arcade-canvas/assets/defender/sounds/sound_thurst.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_thurst.ogg rename to extensions/arcade-canvas/assets/defender/sounds/sound_thurst.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_warning.ogg b/extensions/arcade-canvas/assets/defender/sounds/sound_warning.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_warning.ogg rename to extensions/arcade-canvas/assets/defender/sounds/sound_warning.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/swarmer.png b/extensions/arcade-canvas/assets/defender/swarmer.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/defender/swarmer.png rename to extensions/arcade-canvas/assets/defender/swarmer.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_explosion.ogg b/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_explosion.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_explosion.ogg rename to extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_explosion.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser1.ogg b/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser1.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser1.ogg rename to extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser1.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser2.ogg b/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser2.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser2.ogg rename to extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser2.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_lose.ogg b/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_lose.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_lose.ogg rename to extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_lose.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldDown.ogg b/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldDown.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldDown.ogg rename to extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldDown.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldUp.ogg b/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldUp.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldUp.ogg rename to extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldUp.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_twoTone.ogg b/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_twoTone.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_twoTone.ogg rename to extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_twoTone.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_zap.ogg b/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_zap.ogg similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_zap.ogg rename to extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_zap.ogg diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_bg.png b/extensions/arcade-canvas/assets/galaxy-blaster/space_bg.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_bg.png rename to extensions/arcade-canvas/assets/galaxy-blaster/space_bg.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2-black.png b/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2-black.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2-black.png rename to extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2-black.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.png b/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.png rename to extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.xml b/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.xml similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.xml rename to extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.xml diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.png b/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.png rename to extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.xml b/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.xml similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.xml rename to extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.xml diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/icon.png b/extensions/arcade-canvas/assets/icon.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/icon.png rename to extensions/arcade-canvas/assets/icon.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/background.png b/extensions/arcade-canvas/assets/ninja-runner/background.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/background.png rename to extensions/arcade-canvas/assets/ninja-runner/background.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/big_bush.png b/extensions/arcade-canvas/assets/ninja-runner/big_bush.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/big_bush.png rename to extensions/arcade-canvas/assets/ninja-runner/big_bush.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/bridge.png b/extensions/arcade-canvas/assets/ninja-runner/bridge.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/bridge.png rename to extensions/arcade-canvas/assets/ninja-runner/bridge.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/brown_block.png b/extensions/arcade-canvas/assets/ninja-runner/brown_block.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/brown_block.png rename to extensions/arcade-canvas/assets/ninja-runner/brown_block.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/clouds.png b/extensions/arcade-canvas/assets/ninja-runner/clouds.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/clouds.png rename to extensions/arcade-canvas/assets/ninja-runner/clouds.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/coin_sheet.png b/extensions/arcade-canvas/assets/ninja-runner/coin_sheet.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/coin_sheet.png rename to extensions/arcade-canvas/assets/ninja-runner/coin_sheet.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/dirt_block.png b/extensions/arcade-canvas/assets/ninja-runner/dirt_block.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/dirt_block.png rename to extensions/arcade-canvas/assets/ninja-runner/dirt_block.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_short_strip.png b/extensions/arcade-canvas/assets/ninja-runner/enemy_short_strip.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_short_strip.png rename to extensions/arcade-canvas/assets/ninja-runner/enemy_short_strip.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_strip.png b/extensions/arcade-canvas/assets/ninja-runner/enemy_strip.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_strip.png rename to extensions/arcade-canvas/assets/ninja-runner/enemy_strip.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_tall_strip.png b/extensions/arcade-canvas/assets/ninja-runner/enemy_tall_strip.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_tall_strip.png rename to extensions/arcade-canvas/assets/ninja-runner/enemy_tall_strip.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/flag.png b/extensions/arcade-canvas/assets/ninja-runner/flag.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/flag.png rename to extensions/arcade-canvas/assets/ninja-runner/flag.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/grass_block.png b/extensions/arcade-canvas/assets/ninja-runner/grass_block.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/grass_block.png rename to extensions/arcade-canvas/assets/ninja-runner/grass_block.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/heart_sheet.png b/extensions/arcade-canvas/assets/ninja-runner/heart_sheet.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/heart_sheet.png rename to extensions/arcade-canvas/assets/ninja-runner/heart_sheet.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/hill_0.png b/extensions/arcade-canvas/assets/ninja-runner/hill_0.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/hill_0.png rename to extensions/arcade-canvas/assets/ninja-runner/hill_0.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/hill_1.png b/extensions/arcade-canvas/assets/ninja-runner/hill_1.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/hill_1.png rename to extensions/arcade-canvas/assets/ninja-runner/hill_1.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/impact_sheet.png b/extensions/arcade-canvas/assets/ninja-runner/impact_sheet.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/impact_sheet.png rename to extensions/arcade-canvas/assets/ninja-runner/impact_sheet.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/platform.png b/extensions/arcade-canvas/assets/ninja-runner/platform.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/platform.png rename to extensions/arcade-canvas/assets/ninja-runner/platform.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/player_strip.png b/extensions/arcade-canvas/assets/ninja-runner/player_strip.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/player_strip.png rename to extensions/arcade-canvas/assets/ninja-runner/player_strip.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/qblock_new.png b/extensions/arcade-canvas/assets/ninja-runner/qblock_new.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/qblock_new.png rename to extensions/arcade-canvas/assets/ninja-runner/qblock_new.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/small_bush.png b/extensions/arcade-canvas/assets/ninja-runner/small_bush.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/small_bush.png rename to extensions/arcade-canvas/assets/ninja-runner/small_bush.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowClub.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowClub.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowClub.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowClub.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowDull.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowDull.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowDull.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowDull.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBonus.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBonus.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBonus.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBonus.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBounce.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBounce.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBounce.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBounce.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundClick.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundClick.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundClick.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundClick.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCoin.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCoin.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCoin.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCoin.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCountdown.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCountdown.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCountdown.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCountdown.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundDeath.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundDeath.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundDeath.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundDeath.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyDeath.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyDeath.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyDeath.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyDeath.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyHit.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyHit.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyHit.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyHit.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyShot.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyShot.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyShot.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyShot.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionLarge.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionLarge.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionLarge.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionLarge.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionSmall.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionSmall.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionSmall.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionSmall.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallDull.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallDull.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallDull.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallDull.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallLoud.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallLoud.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallLoud.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallLoud.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapHeavy.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapHeavy.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapHeavy.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapHeavy.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapLight.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapLight.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapLight.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapLight.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundGameOver.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundGameOver.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundGameOver.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundGameOver.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundHurryUp.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundHurryUp.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundHurryUp.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundHurryUp.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump1.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump1.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump1.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump1.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump2.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump2.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump2.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump2.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJumpHah.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJumpHah.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJumpHah.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJumpHah.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand1.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand1.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand1.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand1.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand2.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand2.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand2.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand2.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLandHeavy.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLandHeavy.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLandHeavy.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLandHeavy.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLaser.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLaser.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLaser.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLaser.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMechanism.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMechanism.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMechanism.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMechanism.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMissile.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMissile.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMissile.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMissile.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundObjectFall.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundObjectFall.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundObjectFall.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundObjectFall.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundOpenDoor.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundOpenDoor.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundOpenDoor.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundOpenDoor.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundPlayerHit.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundPlayerHit.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundPlayerHit.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundPlayerHit.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundReachGoal.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundReachGoal.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundReachGoal.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundReachGoal.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootDull.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootDull.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootDull.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootDull.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootRegular.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootRegular.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootRegular.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootRegular.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSlide.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSlide.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSlide.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSlide.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSpecialSkill.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSpecialSkill.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSpecialSkill.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSpecialSkill.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundStartLevel.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundStartLevel.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundStartLevel.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundStartLevel.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSwim.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSwim.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSwim.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSwim.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWandMagic.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWandMagic.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWandMagic.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWandMagic.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWind.m4a b/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWind.m4a similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWind.m4a rename to extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWind.m4a diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/spikes.png b/extensions/arcade-canvas/assets/ninja-runner/spikes.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/spikes.png rename to extensions/arcade-canvas/assets/ninja-runner/spikes.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/sounds/agent-arcade-voice.mp3 b/extensions/arcade-canvas/assets/sounds/agent-arcade-voice.mp3 similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/sounds/agent-arcade-voice.mp3 rename to extensions/arcade-canvas/assets/sounds/agent-arcade-voice.mp3 diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/tray_icon.png b/extensions/arcade-canvas/assets/tray_icon.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/tray_icon.png rename to extensions/arcade-canvas/assets/tray_icon.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/tray_icon_small.png b/extensions/arcade-canvas/assets/tray_icon_small.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/tray_icon_small.png rename to extensions/arcade-canvas/assets/tray_icon_small.png diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/copilot-extension.json b/extensions/arcade-canvas/copilot-extension.json similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/copilot-extension.json rename to extensions/arcade-canvas/copilot-extension.json diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/extension.mjs b/extensions/arcade-canvas/extension.mjs similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/extension.mjs rename to extensions/arcade-canvas/extension.mjs diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/game/game.js b/extensions/arcade-canvas/game/game.js similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/game/game.js rename to extensions/arcade-canvas/game/game.js diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/game/hud.js b/extensions/arcade-canvas/game/hud.js similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/game/hud.js rename to extensions/arcade-canvas/game/hud.js diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/game/index.html b/extensions/arcade-canvas/game/index.html similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/game/index.html rename to extensions/arcade-canvas/game/index.html diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/game/phaser.min.js b/extensions/arcade-canvas/game/phaser.min.js similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/game/phaser.min.js rename to extensions/arcade-canvas/game/phaser.min.js diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/game/phaser.min.js.gz b/extensions/arcade-canvas/game/phaser.min.js.gz similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/game/phaser.min.js.gz rename to extensions/arcade-canvas/game/phaser.min.js.gz diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/AlienOnslaught.js b/extensions/arcade-canvas/game/scenes/AlienOnslaught.js similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/AlienOnslaught.js rename to extensions/arcade-canvas/game/scenes/AlienOnslaught.js diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/BaseScene.js b/extensions/arcade-canvas/game/scenes/BaseScene.js similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/BaseScene.js rename to extensions/arcade-canvas/game/scenes/BaseScene.js diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/CosmicRocks.js b/extensions/arcade-canvas/game/scenes/CosmicRocks.js similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/CosmicRocks.js rename to extensions/arcade-canvas/game/scenes/CosmicRocks.js diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/GalaxyBlaster.js b/extensions/arcade-canvas/game/scenes/GalaxyBlaster.js similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/GalaxyBlaster.js rename to extensions/arcade-canvas/game/scenes/GalaxyBlaster.js diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/NinjaRunner.js b/extensions/arcade-canvas/game/scenes/NinjaRunner.js similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/NinjaRunner.js rename to extensions/arcade-canvas/game/scenes/NinjaRunner.js diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/PlanetGuardian.js b/extensions/arcade-canvas/game/scenes/PlanetGuardian.js similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/game/scenes/PlanetGuardian.js rename to extensions/arcade-canvas/game/scenes/PlanetGuardian.js diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/package-lock.json b/extensions/arcade-canvas/package-lock.json similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/package-lock.json rename to extensions/arcade-canvas/package-lock.json diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/package.json b/extensions/arcade-canvas/package.json similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/package.json rename to extensions/arcade-canvas/package.json diff --git a/extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/README.md b/extensions/backlog-swipe-triage/README.md similarity index 100% rename from extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/README.md rename to extensions/backlog-swipe-triage/README.md diff --git a/extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/extension.mjs b/extensions/backlog-swipe-triage/extension.mjs similarity index 100% rename from extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/extension.mjs rename to extensions/backlog-swipe-triage/extension.mjs diff --git a/extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/package-lock.json b/extensions/backlog-swipe-triage/package-lock.json similarity index 100% rename from extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/package-lock.json rename to extensions/backlog-swipe-triage/package-lock.json diff --git a/extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/package.json b/extensions/backlog-swipe-triage/package.json similarity index 100% rename from extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/package.json rename to extensions/backlog-swipe-triage/package.json diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/README.md b/extensions/backrooms-canvas/README.md similarity index 100% rename from extensions/backrooms-canvas/extensions/backrooms-canvas/README.md rename to extensions/backrooms-canvas/README.md diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/assets/icon.png b/extensions/backrooms-canvas/assets/icon.png similarity index 100% rename from extensions/backrooms-canvas/extensions/backrooms-canvas/assets/icon.png rename to extensions/backrooms-canvas/assets/icon.png diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/copilot-extension.json b/extensions/backrooms-canvas/copilot-extension.json similarity index 100% rename from extensions/backrooms-canvas/extensions/backrooms-canvas/copilot-extension.json rename to extensions/backrooms-canvas/copilot-extension.json diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/extension.mjs b/extensions/backrooms-canvas/extension.mjs similarity index 100% rename from extensions/backrooms-canvas/extensions/backrooms-canvas/extension.mjs rename to extensions/backrooms-canvas/extension.mjs diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/game/index.html b/extensions/backrooms-canvas/game/index.html similarity index 100% rename from extensions/backrooms-canvas/extensions/backrooms-canvas/game/index.html rename to extensions/backrooms-canvas/game/index.html diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/game/webview.js b/extensions/backrooms-canvas/game/webview.js similarity index 100% rename from extensions/backrooms-canvas/extensions/backrooms-canvas/game/webview.js rename to extensions/backrooms-canvas/game/webview.js diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/materials/carpet.jpg b/extensions/backrooms-canvas/materials/carpet.jpg similarity index 100% rename from extensions/backrooms-canvas/extensions/backrooms-canvas/materials/carpet.jpg rename to extensions/backrooms-canvas/materials/carpet.jpg diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/materials/ceiling.jpg b/extensions/backrooms-canvas/materials/ceiling.jpg similarity index 100% rename from extensions/backrooms-canvas/extensions/backrooms-canvas/materials/ceiling.jpg rename to extensions/backrooms-canvas/materials/ceiling.jpg diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/materials/wallpaper.jpg b/extensions/backrooms-canvas/materials/wallpaper.jpg similarity index 100% rename from extensions/backrooms-canvas/extensions/backrooms-canvas/materials/wallpaper.jpg rename to extensions/backrooms-canvas/materials/wallpaper.jpg diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/package-lock.json b/extensions/backrooms-canvas/package-lock.json similarity index 100% rename from extensions/backrooms-canvas/extensions/backrooms-canvas/package-lock.json rename to extensions/backrooms-canvas/package-lock.json diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/package.json b/extensions/backrooms-canvas/package.json similarity index 100% rename from extensions/backrooms-canvas/extensions/backrooms-canvas/package.json rename to extensions/backrooms-canvas/package.json diff --git a/extensions/chromium-control-canvas/extensions/chromium-control-canvas/README.md b/extensions/chromium-control-canvas/README.md similarity index 100% rename from extensions/chromium-control-canvas/extensions/chromium-control-canvas/README.md rename to extensions/chromium-control-canvas/README.md diff --git a/extensions/chromium-control-canvas/extensions/chromium-control-canvas/copilot-extension.json b/extensions/chromium-control-canvas/copilot-extension.json similarity index 100% rename from extensions/chromium-control-canvas/extensions/chromium-control-canvas/copilot-extension.json rename to extensions/chromium-control-canvas/copilot-extension.json diff --git a/extensions/chromium-control-canvas/extensions/chromium-control-canvas/extension.mjs b/extensions/chromium-control-canvas/extension.mjs similarity index 100% rename from extensions/chromium-control-canvas/extensions/chromium-control-canvas/extension.mjs rename to extensions/chromium-control-canvas/extension.mjs diff --git a/extensions/chromium-control-canvas/extensions/chromium-control-canvas/index.html b/extensions/chromium-control-canvas/index.html similarity index 100% rename from extensions/chromium-control-canvas/extensions/chromium-control-canvas/index.html rename to extensions/chromium-control-canvas/index.html diff --git a/extensions/chromium-control-canvas/extensions/chromium-control-canvas/package-lock.json b/extensions/chromium-control-canvas/package-lock.json similarity index 100% rename from extensions/chromium-control-canvas/extensions/chromium-control-canvas/package-lock.json rename to extensions/chromium-control-canvas/package-lock.json diff --git a/extensions/chromium-control-canvas/extensions/chromium-control-canvas/package.json b/extensions/chromium-control-canvas/package.json similarity index 100% rename from extensions/chromium-control-canvas/extensions/chromium-control-canvas/package.json rename to extensions/chromium-control-canvas/package.json diff --git a/extensions/color-orb/extensions/color-orb/extension.mjs b/extensions/color-orb/extension.mjs similarity index 100% rename from extensions/color-orb/extensions/color-orb/extension.mjs rename to extensions/color-orb/extension.mjs diff --git a/extensions/color-orb/extensions/color-orb/package-lock.json b/extensions/color-orb/package-lock.json similarity index 100% rename from extensions/color-orb/extensions/color-orb/package-lock.json rename to extensions/color-orb/package-lock.json diff --git a/extensions/color-orb/extensions/color-orb/package.json b/extensions/color-orb/package.json similarity index 100% rename from extensions/color-orb/extensions/color-orb/package.json rename to extensions/color-orb/package.json diff --git a/extensions/diagram-viewer/extensions/diagram-viewer/extension.mjs b/extensions/diagram-viewer/extension.mjs similarity index 100% rename from extensions/diagram-viewer/extensions/diagram-viewer/extension.mjs rename to extensions/diagram-viewer/extension.mjs diff --git a/extensions/diagram-viewer/extensions/diagram-viewer/package-lock.json b/extensions/diagram-viewer/package-lock.json similarity index 100% rename from extensions/diagram-viewer/extensions/diagram-viewer/package-lock.json rename to extensions/diagram-viewer/package-lock.json diff --git a/extensions/diagram-viewer/extensions/diagram-viewer/package.json b/extensions/diagram-viewer/package.json similarity index 100% rename from extensions/diagram-viewer/extensions/diagram-viewer/package.json rename to extensions/diagram-viewer/package.json diff --git a/extensions/diagram-viewer/extensions/diagram-viewer/public/index.html b/extensions/diagram-viewer/public/index.html similarity index 100% rename from extensions/diagram-viewer/extensions/diagram-viewer/public/index.html rename to extensions/diagram-viewer/public/index.html diff --git a/extensions/feedback-themes/extensions/feedback-themes/data/signals.json b/extensions/feedback-themes/data/signals.json similarity index 100% rename from extensions/feedback-themes/extensions/feedback-themes/data/signals.json rename to extensions/feedback-themes/data/signals.json diff --git a/extensions/feedback-themes/extensions/feedback-themes/extension.mjs b/extensions/feedback-themes/extension.mjs similarity index 100% rename from extensions/feedback-themes/extensions/feedback-themes/extension.mjs rename to extensions/feedback-themes/extension.mjs diff --git a/extensions/feedback-themes/extensions/feedback-themes/package-lock.json b/extensions/feedback-themes/package-lock.json similarity index 100% rename from extensions/feedback-themes/extensions/feedback-themes/package-lock.json rename to extensions/feedback-themes/package-lock.json diff --git a/extensions/feedback-themes/extensions/feedback-themes/package.json b/extensions/feedback-themes/package.json similarity index 100% rename from extensions/feedback-themes/extensions/feedback-themes/package.json rename to extensions/feedback-themes/package.json diff --git a/extensions/feedback-themes/extensions/feedback-themes/public/index.html b/extensions/feedback-themes/public/index.html similarity index 100% rename from extensions/feedback-themes/extensions/feedback-themes/public/index.html rename to extensions/feedback-themes/public/index.html diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/README.md b/extensions/flight-map-canvas/README.md similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/README.md rename to extensions/flight-map-canvas/README.md diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/assets/icon.png b/extensions/flight-map-canvas/assets/icon.png similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/assets/icon.png rename to extensions/flight-map-canvas/assets/icon.png diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/copilot-extension.json b/extensions/flight-map-canvas/copilot-extension.json similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/copilot-extension.json rename to extensions/flight-map-canvas/copilot-extension.json diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/extension.mjs b/extensions/flight-map-canvas/extension.mjs similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/extension.mjs rename to extensions/flight-map-canvas/extension.mjs diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/game/app.js b/extensions/flight-map-canvas/game/app.js similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/game/app.js rename to extensions/flight-map-canvas/game/app.js diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/game/capitals.js b/extensions/flight-map-canvas/game/capitals.js similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/game/capitals.js rename to extensions/flight-map-canvas/game/capitals.js diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/game/destinationInput.js b/extensions/flight-map-canvas/game/destinationInput.js similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/game/destinationInput.js rename to extensions/flight-map-canvas/game/destinationInput.js diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/game/flightControls.js b/extensions/flight-map-canvas/game/flightControls.js similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/game/flightControls.js rename to extensions/flight-map-canvas/game/flightControls.js diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/game/geocoder.js b/extensions/flight-map-canvas/game/geocoder.js similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/game/geocoder.js rename to extensions/flight-map-canvas/game/geocoder.js diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/game/hostBridge.js b/extensions/flight-map-canvas/game/hostBridge.js similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/game/hostBridge.js rename to extensions/flight-map-canvas/game/hostBridge.js diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/game/index.html b/extensions/flight-map-canvas/game/index.html similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/game/index.html rename to extensions/flight-map-canvas/game/index.html diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/game/renderConfig.js b/extensions/flight-map-canvas/game/renderConfig.js similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/game/renderConfig.js rename to extensions/flight-map-canvas/game/renderConfig.js diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/game/styles.css b/extensions/flight-map-canvas/game/styles.css similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/game/styles.css rename to extensions/flight-map-canvas/game/styles.css diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/game/terrainFill.js b/extensions/flight-map-canvas/game/terrainFill.js similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/game/terrainFill.js rename to extensions/flight-map-canvas/game/terrainFill.js diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/game/vendor/three.min.js b/extensions/flight-map-canvas/game/vendor/three.min.js similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/game/vendor/three.min.js rename to extensions/flight-map-canvas/game/vendor/three.min.js diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/package-lock.json b/extensions/flight-map-canvas/package-lock.json similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/package-lock.json rename to extensions/flight-map-canvas/package-lock.json diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/package.json b/extensions/flight-map-canvas/package.json similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/package.json rename to extensions/flight-map-canvas/package.json diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/renderMap.json b/extensions/flight-map-canvas/renderMap.json similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/renderMap.json rename to extensions/flight-map-canvas/renderMap.json diff --git a/extensions/gesture-review/extensions/gesture-review/extension.mjs b/extensions/gesture-review/extension.mjs similarity index 100% rename from extensions/gesture-review/extensions/gesture-review/extension.mjs rename to extensions/gesture-review/extension.mjs diff --git a/extensions/gesture-review/extensions/gesture-review/package-lock.json b/extensions/gesture-review/package-lock.json similarity index 100% rename from extensions/gesture-review/extensions/gesture-review/package-lock.json rename to extensions/gesture-review/package-lock.json diff --git a/extensions/gesture-review/extensions/gesture-review/package.json b/extensions/gesture-review/package.json similarity index 100% rename from extensions/gesture-review/extensions/gesture-review/package.json rename to extensions/gesture-review/package.json diff --git a/extensions/java-modernization-studio/extensions/java-modernization-studio/.gitignore b/extensions/java-modernization-studio/.gitignore similarity index 100% rename from extensions/java-modernization-studio/extensions/java-modernization-studio/.gitignore rename to extensions/java-modernization-studio/.gitignore diff --git a/extensions/java-modernization-studio/extensions/java-modernization-studio/README.md b/extensions/java-modernization-studio/README.md similarity index 100% rename from extensions/java-modernization-studio/extensions/java-modernization-studio/README.md rename to extensions/java-modernization-studio/README.md diff --git a/extensions/java-modernization-studio/extensions/java-modernization-studio/autopilot.mjs b/extensions/java-modernization-studio/autopilot.mjs similarity index 100% rename from extensions/java-modernization-studio/extensions/java-modernization-studio/autopilot.mjs rename to extensions/java-modernization-studio/autopilot.mjs diff --git a/extensions/java-modernization-studio/extensions/java-modernization-studio/catalog.mjs b/extensions/java-modernization-studio/catalog.mjs similarity index 100% rename from extensions/java-modernization-studio/extensions/java-modernization-studio/catalog.mjs rename to extensions/java-modernization-studio/catalog.mjs diff --git a/extensions/java-modernization-studio/extensions/java-modernization-studio/doctor.mjs b/extensions/java-modernization-studio/doctor.mjs similarity index 100% rename from extensions/java-modernization-studio/extensions/java-modernization-studio/doctor.mjs rename to extensions/java-modernization-studio/doctor.mjs diff --git a/extensions/java-modernization-studio/extensions/java-modernization-studio/extension.mjs b/extensions/java-modernization-studio/extension.mjs similarity index 100% rename from extensions/java-modernization-studio/extensions/java-modernization-studio/extension.mjs rename to extensions/java-modernization-studio/extension.mjs diff --git a/extensions/java-modernization-studio/extensions/java-modernization-studio/package.json b/extensions/java-modernization-studio/package.json similarity index 100% rename from extensions/java-modernization-studio/extensions/java-modernization-studio/package.json rename to extensions/java-modernization-studio/package.json diff --git a/extensions/java-modernization-studio/extensions/java-modernization-studio/prompts.mjs b/extensions/java-modernization-studio/prompts.mjs similarity index 100% rename from extensions/java-modernization-studio/extensions/java-modernization-studio/prompts.mjs rename to extensions/java-modernization-studio/prompts.mjs diff --git a/extensions/java-modernization-studio/extensions/java-modernization-studio/renderer.mjs b/extensions/java-modernization-studio/renderer.mjs similarity index 100% rename from extensions/java-modernization-studio/extensions/java-modernization-studio/renderer.mjs rename to extensions/java-modernization-studio/renderer.mjs diff --git a/extensions/java-modernization-studio/extensions/java-modernization-studio/scan.mjs b/extensions/java-modernization-studio/scan.mjs similarity index 100% rename from extensions/java-modernization-studio/extensions/java-modernization-studio/scan.mjs rename to extensions/java-modernization-studio/scan.mjs diff --git a/extensions/java-modernization-studio/extensions/java-modernization-studio/server.mjs b/extensions/java-modernization-studio/server.mjs similarity index 100% rename from extensions/java-modernization-studio/extensions/java-modernization-studio/server.mjs rename to extensions/java-modernization-studio/server.mjs diff --git a/extensions/java-modernization-studio/extensions/java-modernization-studio/test/cockpit.test.mjs b/extensions/java-modernization-studio/test/cockpit.test.mjs similarity index 100% rename from extensions/java-modernization-studio/extensions/java-modernization-studio/test/cockpit.test.mjs rename to extensions/java-modernization-studio/test/cockpit.test.mjs diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/README.md b/extensions/pr-artifact-explorer/README.md similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/README.md rename to extensions/pr-artifact-explorer/README.md diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/accounts.mjs b/extensions/pr-artifact-explorer/accounts.mjs similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/accounts.mjs rename to extensions/pr-artifact-explorer/accounts.mjs diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/app.css b/extensions/pr-artifact-explorer/assets/app.css similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/app.css rename to extensions/pr-artifact-explorer/assets/app.css diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/app.js b/extensions/pr-artifact-explorer/assets/app.js similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/app.js rename to extensions/pr-artifact-explorer/assets/app.js diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/asciinema-player-worker.min.js b/extensions/pr-artifact-explorer/assets/asciinema-player-worker.min.js similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/asciinema-player-worker.min.js rename to extensions/pr-artifact-explorer/assets/asciinema-player-worker.min.js diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/asciinema-player.css b/extensions/pr-artifact-explorer/assets/asciinema-player.css similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/asciinema-player.css rename to extensions/pr-artifact-explorer/assets/asciinema-player.css diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/asciinema-player.min.js b/extensions/pr-artifact-explorer/assets/asciinema-player.min.js similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/asciinema-player.min.js rename to extensions/pr-artifact-explorer/assets/asciinema-player.min.js diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/archive.svg b/extensions/pr-artifact-explorer/assets/octicons/archive.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/archive.svg rename to extensions/pr-artifact-explorer/assets/octicons/archive.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/browser.svg b/extensions/pr-artifact-explorer/assets/octicons/browser.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/browser.svg rename to extensions/pr-artifact-explorer/assets/octicons/browser.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/check-circle.svg b/extensions/pr-artifact-explorer/assets/octicons/check-circle.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/check-circle.svg rename to extensions/pr-artifact-explorer/assets/octicons/check-circle.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/check.svg b/extensions/pr-artifact-explorer/assets/octicons/check.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/check.svg rename to extensions/pr-artifact-explorer/assets/octicons/check.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/chevron-down.svg b/extensions/pr-artifact-explorer/assets/octicons/chevron-down.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/chevron-down.svg rename to extensions/pr-artifact-explorer/assets/octicons/chevron-down.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/chevron-left.svg b/extensions/pr-artifact-explorer/assets/octicons/chevron-left.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/chevron-left.svg rename to extensions/pr-artifact-explorer/assets/octicons/chevron-left.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/chevron-right.svg b/extensions/pr-artifact-explorer/assets/octicons/chevron-right.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/chevron-right.svg rename to extensions/pr-artifact-explorer/assets/octicons/chevron-right.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/clock.svg b/extensions/pr-artifact-explorer/assets/octicons/clock.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/clock.svg rename to extensions/pr-artifact-explorer/assets/octicons/clock.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/code.svg b/extensions/pr-artifact-explorer/assets/octicons/code.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/code.svg rename to extensions/pr-artifact-explorer/assets/octicons/code.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/comment.svg b/extensions/pr-artifact-explorer/assets/octicons/comment.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/comment.svg rename to extensions/pr-artifact-explorer/assets/octicons/comment.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/download.svg b/extensions/pr-artifact-explorer/assets/octicons/download.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/download.svg rename to extensions/pr-artifact-explorer/assets/octicons/download.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/eye.svg b/extensions/pr-artifact-explorer/assets/octicons/eye.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/eye.svg rename to extensions/pr-artifact-explorer/assets/octicons/eye.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/file-binary.svg b/extensions/pr-artifact-explorer/assets/octicons/file-binary.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/file-binary.svg rename to extensions/pr-artifact-explorer/assets/octicons/file-binary.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/file-code.svg b/extensions/pr-artifact-explorer/assets/octicons/file-code.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/file-code.svg rename to extensions/pr-artifact-explorer/assets/octicons/file-code.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/file-directory.svg b/extensions/pr-artifact-explorer/assets/octicons/file-directory.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/file-directory.svg rename to extensions/pr-artifact-explorer/assets/octicons/file-directory.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/file-media.svg b/extensions/pr-artifact-explorer/assets/octicons/file-media.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/file-media.svg rename to extensions/pr-artifact-explorer/assets/octicons/file-media.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/file-zip.svg b/extensions/pr-artifact-explorer/assets/octicons/file-zip.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/file-zip.svg rename to extensions/pr-artifact-explorer/assets/octicons/file-zip.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/file.svg b/extensions/pr-artifact-explorer/assets/octicons/file.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/file.svg rename to extensions/pr-artifact-explorer/assets/octicons/file.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/filter.svg b/extensions/pr-artifact-explorer/assets/octicons/filter.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/filter.svg rename to extensions/pr-artifact-explorer/assets/octicons/filter.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/gear.svg b/extensions/pr-artifact-explorer/assets/octicons/gear.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/gear.svg rename to extensions/pr-artifact-explorer/assets/octicons/gear.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/git-pull-request.svg b/extensions/pr-artifact-explorer/assets/octicons/git-pull-request.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/git-pull-request.svg rename to extensions/pr-artifact-explorer/assets/octicons/git-pull-request.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/kebab-horizontal.svg b/extensions/pr-artifact-explorer/assets/octicons/kebab-horizontal.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/kebab-horizontal.svg rename to extensions/pr-artifact-explorer/assets/octicons/kebab-horizontal.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/link-external.svg b/extensions/pr-artifact-explorer/assets/octicons/link-external.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/link-external.svg rename to extensions/pr-artifact-explorer/assets/octicons/link-external.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/mark-github.svg b/extensions/pr-artifact-explorer/assets/octicons/mark-github.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/mark-github.svg rename to extensions/pr-artifact-explorer/assets/octicons/mark-github.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/markdown.svg b/extensions/pr-artifact-explorer/assets/octicons/markdown.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/markdown.svg rename to extensions/pr-artifact-explorer/assets/octicons/markdown.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/package.svg b/extensions/pr-artifact-explorer/assets/octicons/package.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/package.svg rename to extensions/pr-artifact-explorer/assets/octicons/package.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/person.svg b/extensions/pr-artifact-explorer/assets/octicons/person.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/person.svg rename to extensions/pr-artifact-explorer/assets/octicons/person.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/pin-slash.svg b/extensions/pr-artifact-explorer/assets/octicons/pin-slash.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/pin-slash.svg rename to extensions/pr-artifact-explorer/assets/octicons/pin-slash.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/pin.svg b/extensions/pr-artifact-explorer/assets/octicons/pin.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/pin.svg rename to extensions/pr-artifact-explorer/assets/octicons/pin.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/play.svg b/extensions/pr-artifact-explorer/assets/octicons/play.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/play.svg rename to extensions/pr-artifact-explorer/assets/octicons/play.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/repo.svg b/extensions/pr-artifact-explorer/assets/octicons/repo.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/repo.svg rename to extensions/pr-artifact-explorer/assets/octicons/repo.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/search.svg b/extensions/pr-artifact-explorer/assets/octicons/search.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/search.svg rename to extensions/pr-artifact-explorer/assets/octicons/search.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/sidebar-collapse.svg b/extensions/pr-artifact-explorer/assets/octicons/sidebar-collapse.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/sidebar-collapse.svg rename to extensions/pr-artifact-explorer/assets/octicons/sidebar-collapse.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/sidebar-expand.svg b/extensions/pr-artifact-explorer/assets/octicons/sidebar-expand.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/sidebar-expand.svg rename to extensions/pr-artifact-explorer/assets/octicons/sidebar-expand.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/star-fill.svg b/extensions/pr-artifact-explorer/assets/octicons/star-fill.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/star-fill.svg rename to extensions/pr-artifact-explorer/assets/octicons/star-fill.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/star.svg b/extensions/pr-artifact-explorer/assets/octicons/star.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/star.svg rename to extensions/pr-artifact-explorer/assets/octicons/star.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/sync.svg b/extensions/pr-artifact-explorer/assets/octicons/sync.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/sync.svg rename to extensions/pr-artifact-explorer/assets/octicons/sync.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/terminal.svg b/extensions/pr-artifact-explorer/assets/octicons/terminal.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/terminal.svg rename to extensions/pr-artifact-explorer/assets/octicons/terminal.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/trash.svg b/extensions/pr-artifact-explorer/assets/octicons/trash.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/trash.svg rename to extensions/pr-artifact-explorer/assets/octicons/trash.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/workflow.svg b/extensions/pr-artifact-explorer/assets/octicons/workflow.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/workflow.svg rename to extensions/pr-artifact-explorer/assets/octicons/workflow.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/x-circle.svg b/extensions/pr-artifact-explorer/assets/octicons/x-circle.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/x-circle.svg rename to extensions/pr-artifact-explorer/assets/octicons/x-circle.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/x.svg b/extensions/pr-artifact-explorer/assets/octicons/x.svg similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/octicons/x.svg rename to extensions/pr-artifact-explorer/assets/octicons/x.svg diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/preview-dark.png b/extensions/pr-artifact-explorer/assets/preview-dark.png similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/preview-dark.png rename to extensions/pr-artifact-explorer/assets/preview-dark.png diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/primer-color-modes.css b/extensions/pr-artifact-explorer/assets/primer-color-modes.css similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/primer-color-modes.css rename to extensions/pr-artifact-explorer/assets/primer-color-modes.css diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/primer-core.css b/extensions/pr-artifact-explorer/assets/primer-core.css similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/primer-core.css rename to extensions/pr-artifact-explorer/assets/primer-core.css diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/primer-product.css b/extensions/pr-artifact-explorer/assets/primer-product.css similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/primer-product.css rename to extensions/pr-artifact-explorer/assets/primer-product.css diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/trx-preview.js b/extensions/pr-artifact-explorer/assets/trx-preview.js similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/trx-preview.js rename to extensions/pr-artifact-explorer/assets/trx-preview.js diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/cache-coordinator.mjs b/extensions/pr-artifact-explorer/cache-coordinator.mjs similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/cache-coordinator.mjs rename to extensions/pr-artifact-explorer/cache-coordinator.mjs diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/cache.mjs b/extensions/pr-artifact-explorer/cache.mjs similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/cache.mjs rename to extensions/pr-artifact-explorer/cache.mjs diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/constants.mjs b/extensions/pr-artifact-explorer/constants.mjs similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/constants.mjs rename to extensions/pr-artifact-explorer/constants.mjs diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/copilot-extension.json b/extensions/pr-artifact-explorer/copilot-extension.json similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/copilot-extension.json rename to extensions/pr-artifact-explorer/copilot-extension.json diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/detector.mjs b/extensions/pr-artifact-explorer/detector.mjs similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/detector.mjs rename to extensions/pr-artifact-explorer/detector.mjs diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/extension.mjs b/extensions/pr-artifact-explorer/extension.mjs similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/extension.mjs rename to extensions/pr-artifact-explorer/extension.mjs diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/github.mjs b/extensions/pr-artifact-explorer/github.mjs similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/github.mjs rename to extensions/pr-artifact-explorer/github.mjs diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/memory-cache.mjs b/extensions/pr-artifact-explorer/memory-cache.mjs similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/memory-cache.mjs rename to extensions/pr-artifact-explorer/memory-cache.mjs diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/preview.mjs b/extensions/pr-artifact-explorer/preview.mjs similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/preview.mjs rename to extensions/pr-artifact-explorer/preview.mjs diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/render.mjs b/extensions/pr-artifact-explorer/render.mjs similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/render.mjs rename to extensions/pr-artifact-explorer/render.mjs diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/review-fixes.test.mjs b/extensions/pr-artifact-explorer/review-fixes.test.mjs similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/review-fixes.test.mjs rename to extensions/pr-artifact-explorer/review-fixes.test.mjs diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/security.mjs b/extensions/pr-artifact-explorer/security.mjs similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/security.mjs rename to extensions/pr-artifact-explorer/security.mjs diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/server.mjs b/extensions/pr-artifact-explorer/server.mjs similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/server.mjs rename to extensions/pr-artifact-explorer/server.mjs diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/state.mjs b/extensions/pr-artifact-explorer/state.mjs similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/state.mjs rename to extensions/pr-artifact-explorer/state.mjs diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/third-party-licenses/asciinema-player.txt b/extensions/pr-artifact-explorer/third-party-licenses/asciinema-player.txt similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/third-party-licenses/asciinema-player.txt rename to extensions/pr-artifact-explorer/third-party-licenses/asciinema-player.txt diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/third-party-licenses/octicons.txt b/extensions/pr-artifact-explorer/third-party-licenses/octicons.txt similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/third-party-licenses/octicons.txt rename to extensions/pr-artifact-explorer/third-party-licenses/octicons.txt diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/third-party-licenses/primer-css.txt b/extensions/pr-artifact-explorer/third-party-licenses/primer-css.txt similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/third-party-licenses/primer-css.txt rename to extensions/pr-artifact-explorer/third-party-licenses/primer-css.txt diff --git a/extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/zip.mjs b/extensions/pr-artifact-explorer/zip.mjs similarity index 100% rename from extensions/pr-artifact-explorer/extensions/pr-artifact-explorer/zip.mjs rename to extensions/pr-artifact-explorer/zip.mjs diff --git a/extensions/release-notes-showcase/extensions/release-notes-showcase/README.md b/extensions/release-notes-showcase/README.md similarity index 100% rename from extensions/release-notes-showcase/extensions/release-notes-showcase/README.md rename to extensions/release-notes-showcase/README.md diff --git a/extensions/release-notes-showcase/extensions/release-notes-showcase/extension.mjs b/extensions/release-notes-showcase/extension.mjs similarity index 100% rename from extensions/release-notes-showcase/extensions/release-notes-showcase/extension.mjs rename to extensions/release-notes-showcase/extension.mjs diff --git a/extensions/release-notes-showcase/extensions/release-notes-showcase/package-lock.json b/extensions/release-notes-showcase/package-lock.json similarity index 100% rename from extensions/release-notes-showcase/extensions/release-notes-showcase/package-lock.json rename to extensions/release-notes-showcase/package-lock.json diff --git a/extensions/release-notes-showcase/extensions/release-notes-showcase/package.json b/extensions/release-notes-showcase/package.json similarity index 100% rename from extensions/release-notes-showcase/extensions/release-notes-showcase/package.json rename to extensions/release-notes-showcase/package.json diff --git a/extensions/release-notes-showcase/extensions/release-notes-showcase/releaseNotesShowcase.mjs b/extensions/release-notes-showcase/releaseNotesShowcase.mjs similarity index 100% rename from extensions/release-notes-showcase/extensions/release-notes-showcase/releaseNotesShowcase.mjs rename to extensions/release-notes-showcase/releaseNotesShowcase.mjs diff --git a/extensions/repo-actions-hub/extensions/repo-actions-hub/README.md b/extensions/repo-actions-hub/README.md similarity index 100% rename from extensions/repo-actions-hub/extensions/repo-actions-hub/README.md rename to extensions/repo-actions-hub/README.md diff --git a/extensions/repo-actions-hub/extensions/repo-actions-hub/copilot-extension.json b/extensions/repo-actions-hub/copilot-extension.json similarity index 100% rename from extensions/repo-actions-hub/extensions/repo-actions-hub/copilot-extension.json rename to extensions/repo-actions-hub/copilot-extension.json diff --git a/extensions/repo-actions-hub/extensions/repo-actions-hub/extension.mjs b/extensions/repo-actions-hub/extension.mjs similarity index 100% rename from extensions/repo-actions-hub/extensions/repo-actions-hub/extension.mjs rename to extensions/repo-actions-hub/extension.mjs diff --git a/extensions/repo-actions-hub/extensions/repo-actions-hub/package.json b/extensions/repo-actions-hub/package.json similarity index 100% rename from extensions/repo-actions-hub/extensions/repo-actions-hub/package.json rename to extensions/repo-actions-hub/package.json diff --git a/extensions/signals-dashboard/extensions/signals-dashboard/extension.mjs b/extensions/signals-dashboard/extension.mjs similarity index 100% rename from extensions/signals-dashboard/extensions/signals-dashboard/extension.mjs rename to extensions/signals-dashboard/extension.mjs diff --git a/extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.mjs b/extensions/signals-dashboard/launch-profile.mjs similarity index 100% rename from extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.mjs rename to extensions/signals-dashboard/launch-profile.mjs diff --git a/extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.test.mjs b/extensions/signals-dashboard/launch-profile.test.mjs similarity index 100% rename from extensions/signals-dashboard/extensions/signals-dashboard/launch-profile.test.mjs rename to extensions/signals-dashboard/launch-profile.test.mjs diff --git a/extensions/signals-dashboard/extensions/signals-dashboard/package.json b/extensions/signals-dashboard/package.json similarity index 100% rename from extensions/signals-dashboard/extensions/signals-dashboard/package.json rename to extensions/signals-dashboard/package.json diff --git a/extensions/site-studio/extensions/site-studio/extension.mjs b/extensions/site-studio/extension.mjs similarity index 100% rename from extensions/site-studio/extensions/site-studio/extension.mjs rename to extensions/site-studio/extension.mjs diff --git a/extensions/site-studio/extensions/site-studio/package.json b/extensions/site-studio/package.json similarity index 100% rename from extensions/site-studio/extensions/site-studio/package.json rename to extensions/site-studio/package.json diff --git a/extensions/tiny-tool-town-submitter/extensions/tiny-tool-town-submitter/README.md b/extensions/tiny-tool-town-submitter/README.md similarity index 100% rename from extensions/tiny-tool-town-submitter/extensions/tiny-tool-town-submitter/README.md rename to extensions/tiny-tool-town-submitter/README.md diff --git a/extensions/tiny-tool-town-submitter/extensions/tiny-tool-town-submitter/extension.mjs b/extensions/tiny-tool-town-submitter/extension.mjs similarity index 100% rename from extensions/tiny-tool-town-submitter/extensions/tiny-tool-town-submitter/extension.mjs rename to extensions/tiny-tool-town-submitter/extension.mjs diff --git a/extensions/tiny-tool-town-submitter/extensions/tiny-tool-town-submitter/lib/analyzer.mjs b/extensions/tiny-tool-town-submitter/lib/analyzer.mjs similarity index 100% rename from extensions/tiny-tool-town-submitter/extensions/tiny-tool-town-submitter/lib/analyzer.mjs rename to extensions/tiny-tool-town-submitter/lib/analyzer.mjs diff --git a/extensions/tiny-tool-town-submitter/extensions/tiny-tool-town-submitter/lib/copy-generator.mjs b/extensions/tiny-tool-town-submitter/lib/copy-generator.mjs similarity index 100% rename from extensions/tiny-tool-town-submitter/extensions/tiny-tool-town-submitter/lib/copy-generator.mjs rename to extensions/tiny-tool-town-submitter/lib/copy-generator.mjs diff --git a/extensions/tiny-tool-town-submitter/extensions/tiny-tool-town-submitter/lib/renderer.mjs b/extensions/tiny-tool-town-submitter/lib/renderer.mjs similarity index 100% rename from extensions/tiny-tool-town-submitter/extensions/tiny-tool-town-submitter/lib/renderer.mjs rename to extensions/tiny-tool-town-submitter/lib/renderer.mjs diff --git a/extensions/token-pacman/extensions/token-pacman/README.md b/extensions/token-pacman/README.md similarity index 100% rename from extensions/token-pacman/extensions/token-pacman/README.md rename to extensions/token-pacman/README.md diff --git a/extensions/token-pacman/extensions/token-pacman/assets/token-pacman.jpg b/extensions/token-pacman/assets/token-pacman.jpg similarity index 100% rename from extensions/token-pacman/extensions/token-pacman/assets/token-pacman.jpg rename to extensions/token-pacman/assets/token-pacman.jpg diff --git a/extensions/token-pacman/extensions/token-pacman/copilot-extension.json b/extensions/token-pacman/copilot-extension.json similarity index 100% rename from extensions/token-pacman/extensions/token-pacman/copilot-extension.json rename to extensions/token-pacman/copilot-extension.json diff --git a/extensions/token-pacman/extensions/token-pacman/extension.mjs b/extensions/token-pacman/extension.mjs similarity index 100% rename from extensions/token-pacman/extensions/token-pacman/extension.mjs rename to extensions/token-pacman/extension.mjs diff --git a/extensions/token-pacman/extensions/token-pacman/package.json b/extensions/token-pacman/package.json similarity index 100% rename from extensions/token-pacman/extensions/token-pacman/package.json rename to extensions/token-pacman/package.json diff --git a/extensions/where-was-i/extensions/where-was-i/extension.mjs b/extensions/where-was-i/extension.mjs similarity index 100% rename from extensions/where-was-i/extensions/where-was-i/extension.mjs rename to extensions/where-was-i/extension.mjs diff --git a/extensions/where-was-i/extensions/where-was-i/package.json b/extensions/where-was-i/package.json similarity index 100% rename from extensions/where-was-i/extensions/where-was-i/package.json rename to extensions/where-was-i/package.json diff --git a/extensions/work-hub/extensions/work-hub/README.md b/extensions/work-hub/README.md similarity index 100% rename from extensions/work-hub/extensions/work-hub/README.md rename to extensions/work-hub/README.md diff --git a/extensions/work-hub/extensions/work-hub/data.mjs b/extensions/work-hub/data.mjs similarity index 100% rename from extensions/work-hub/extensions/work-hub/data.mjs rename to extensions/work-hub/data.mjs diff --git a/extensions/work-hub/extensions/work-hub/extension.mjs b/extensions/work-hub/extension.mjs similarity index 100% rename from extensions/work-hub/extensions/work-hub/extension.mjs rename to extensions/work-hub/extension.mjs diff --git a/extensions/work-hub/extensions/work-hub/package.json b/extensions/work-hub/package.json similarity index 100% rename from extensions/work-hub/extensions/work-hub/package.json rename to extensions/work-hub/package.json diff --git a/extensions/work-hub/extensions/work-hub/renderer.mjs b/extensions/work-hub/renderer.mjs similarity index 100% rename from extensions/work-hub/extensions/work-hub/renderer.mjs rename to extensions/work-hub/renderer.mjs diff --git a/plugins/accessibility-kanban/README.md b/plugins/accessibility-kanban/README.md new file mode 100644 index 00000000..6bcee148 --- /dev/null +++ b/plugins/accessibility-kanban/README.md @@ -0,0 +1,17 @@ +# Accessibility Kanban Plugin + +Kanban board to manage accessibility issues, allow you to plan, track, and complete remediation work. + +## Installation + +``bash +copilot plugin install accessibility-kanban@awesome-copilot +`` + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). + +## License + +MIT diff --git a/extensions/accessibility-kanban/extensions/accessibility-kanban/assets/preview.png b/plugins/accessibility-kanban/extensions/accessibility-kanban/assets/preview.png similarity index 100% rename from extensions/accessibility-kanban/extensions/accessibility-kanban/assets/preview.png rename to plugins/accessibility-kanban/extensions/accessibility-kanban/assets/preview.png diff --git a/plugins/accessibility-kanban/extensions/accessibility-kanban/extension.mjs b/plugins/accessibility-kanban/extensions/accessibility-kanban/extension.mjs new file mode 100644 index 00000000..777f205a --- /dev/null +++ b/plugins/accessibility-kanban/extensions/accessibility-kanban/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/plugins/accessibility-kanban/extensions/accessibility-kanban/package.json b/plugins/accessibility-kanban/extensions/accessibility-kanban/package.json new file mode 100644 index 00000000..48b33dbd --- /dev/null +++ b/plugins/accessibility-kanban/extensions/accessibility-kanban/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/plugins/accessibility-kanban/extensions/accessibility-kanban/public/index.html b/plugins/accessibility-kanban/extensions/accessibility-kanban/public/index.html new file mode 100644 index 00000000..01338af9 --- /dev/null +++ b/plugins/accessibility-kanban/extensions/accessibility-kanban/public/index.html @@ -0,0 +1,754 @@ + + + + + +Repository Issues Kanban + + + + + +
+
+ + +
+ + +
+ +
+
+ + + + + + + diff --git a/extensions/accessibility-kanban/.github/plugin/plugin.json b/plugins/accessibility-kanban/plugin.json similarity index 71% rename from extensions/accessibility-kanban/.github/plugin/plugin.json rename to plugins/accessibility-kanban/plugin.json index 02a083cb..44675fc6 100644 --- a/extensions/accessibility-kanban/.github/plugin/plugin.json +++ b/plugins/accessibility-kanban/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "accessibility-kanban", "description": "Kanban board to manage accessibility issues, allow you to plan, track, and complete remediation work.", "version": "1.0.2", @@ -14,6 +15,9 @@ "planning-workflow", "status-tracking" ], - "logo": "assets/preview.png", - "extensions": "extensions" + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + } + } } diff --git a/plugins/acreadiness-cockpit/.github/plugin/plugin.json b/plugins/acreadiness-cockpit/plugin.json similarity index 75% rename from plugins/acreadiness-cockpit/.github/plugin/plugin.json rename to plugins/acreadiness-cockpit/plugin.json index 6ec4afe5..213525e7 100644 --- a/plugins/acreadiness-cockpit/.github/plugin/plugin.json +++ b/plugins/acreadiness-cockpit/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "acreadiness-cockpit", "description": "Drive Microsoft AgentRC from Copilot chat: assess AI readiness, generate Copilot instructions (flat or nested with applyTo globs for monorepos), and manage policies. Produces a self-contained static HTML dashboard at reports/index.html.", "version": "1.0.0", @@ -15,13 +16,5 @@ "name": "mvanderbend-msoft" }, "repository": "https://github.com/github/awesome-copilot", - "license": "MIT", - "agents": [ - "./agents" - ], - "skills": [ - "./skills/acreadiness-assess", - "./skills/acreadiness-generate-instructions", - "./skills/acreadiness-policy" - ] + "license": "MIT" } diff --git a/plugins/ai-team-orchestration/.github/plugin/plugin.json b/plugins/ai-team-orchestration/plugin.json similarity index 82% rename from plugins/ai-team-orchestration/.github/plugin/plugin.json rename to plugins/ai-team-orchestration/plugin.json index c897086a..38b303af 100644 --- a/plugins/ai-team-orchestration/.github/plugin/plugin.json +++ b/plugins/ai-team-orchestration/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "ai-team-orchestration", "description": "Run a lightweight, role-separated AI development team with flexible tools, developer-selected models, proportionate planning, and optional QA.", "version": "2.0.0", @@ -15,11 +16,5 @@ "name": "Denis Evdokimov" }, "repository": "https://github.com/github/awesome-copilot", - "license": "MIT", - "agents": [ - "./agents" - ], - "skills": [ - "./skills/ai-team-orchestration" - ] + "license": "MIT" } diff --git a/plugins/apng-studio/README.md b/plugins/apng-studio/README.md new file mode 100644 index 00000000..87129146 --- /dev/null +++ b/plugins/apng-studio/README.md @@ -0,0 +1,17 @@ +# Apng Studio Plugin + +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. + +## Installation + +``bash +copilot plugin install apng-studio@awesome-copilot +`` + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). + +## License + +MIT diff --git a/plugins/apng-studio/extensions/apng-studio/.gitignore b/plugins/apng-studio/extensions/apng-studio/.gitignore new file mode 100644 index 00000000..5db7b303 --- /dev/null +++ b/plugins/apng-studio/extensions/apng-studio/.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/plugins/apng-studio/extensions/apng-studio/LICENSE b/plugins/apng-studio/extensions/apng-studio/LICENSE new file mode 100644 index 00000000..da487c02 --- /dev/null +++ b/plugins/apng-studio/extensions/apng-studio/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/plugins/apng-studio/extensions/apng-studio/README.md b/plugins/apng-studio/extensions/apng-studio/README.md new file mode 100644 index 00000000..27a71767 --- /dev/null +++ b/plugins/apng-studio/extensions/apng-studio/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/plugins/apng-studio/extensions/apng-studio/apng.mjs b/plugins/apng-studio/extensions/apng-studio/apng.mjs new file mode 100644 index 00000000..64fffc5b --- /dev/null +++ b/plugins/apng-studio/extensions/apng-studio/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/plugins/apng-studio/extensions/apng-studio/assets/demo.png b/plugins/apng-studio/extensions/apng-studio/assets/demo.png new file mode 100644 index 00000000..e36882c8 Binary files /dev/null and b/plugins/apng-studio/extensions/apng-studio/assets/demo.png differ diff --git a/extensions/apng-studio/extensions/apng-studio/assets/preview.png b/plugins/apng-studio/extensions/apng-studio/assets/preview.png similarity index 100% rename from extensions/apng-studio/extensions/apng-studio/assets/preview.png rename to plugins/apng-studio/extensions/apng-studio/assets/preview.png diff --git a/plugins/apng-studio/extensions/apng-studio/copilot-extension.json b/plugins/apng-studio/extensions/apng-studio/copilot-extension.json new file mode 100644 index 00000000..5841b689 --- /dev/null +++ b/plugins/apng-studio/extensions/apng-studio/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "apng-studio", + "version": 1 +} diff --git a/plugins/apng-studio/extensions/apng-studio/extension.mjs b/plugins/apng-studio/extensions/apng-studio/extension.mjs new file mode 100644 index 00000000..5dc4bb4a --- /dev/null +++ b/plugins/apng-studio/extensions/apng-studio/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/plugins/apng-studio/extensions/apng-studio/qr.mjs b/plugins/apng-studio/extensions/apng-studio/qr.mjs new file mode 100644 index 00000000..e90e0703 --- /dev/null +++ b/plugins/apng-studio/extensions/apng-studio/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/plugins/apng-studio/extensions/apng-studio/web/app.js b/plugins/apng-studio/extensions/apng-studio/web/app.js new file mode 100644 index 00000000..7ace7abc --- /dev/null +++ b/plugins/apng-studio/extensions/apng-studio/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/plugins/apng-studio/extensions/apng-studio/web/index.html b/plugins/apng-studio/extensions/apng-studio/web/index.html new file mode 100644 index 00000000..2576082a --- /dev/null +++ b/plugins/apng-studio/extensions/apng-studio/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/plugins/apng-studio/extensions/apng-studio/web/styles.css b/plugins/apng-studio/extensions/apng-studio/web/styles.css new file mode 100644 index 00000000..9fc2a3d7 --- /dev/null +++ b/plugins/apng-studio/extensions/apng-studio/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/apng-studio/.github/plugin/plugin.json b/plugins/apng-studio/plugin.json similarity index 77% rename from extensions/apng-studio/.github/plugin/plugin.json rename to plugins/apng-studio/plugin.json index 789695bc..4fa9da34 100644 --- a/extensions/apng-studio/.github/plugin/plugin.json +++ b/plugins/apng-studio/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "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.2", @@ -14,6 +15,9 @@ "image-export", "interactive-canvas" ], - "logo": "assets/preview.png", - "extensions": "extensions" + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + } + } } diff --git a/plugins/arcade-canvas/README.md b/plugins/arcade-canvas/README.md new file mode 100644 index 00000000..ba6a5e00 --- /dev/null +++ b/plugins/arcade-canvas/README.md @@ -0,0 +1,17 @@ +# Arcade Canvas Plugin + +Play five retro Phaser mini-games in a Copilot canvas while agents work. + +## Installation + +``bash +copilot plugin install arcade-canvas@awesome-copilot +`` + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). + +## License + +MIT diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/README.md b/plugins/arcade-canvas/extensions/arcade-canvas/README.md new file mode 100644 index 00000000..398ff9e2 --- /dev/null +++ b/plugins/arcade-canvas/extensions/arcade-canvas/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/plugins/arcade-canvas/extensions/arcade-canvas/assets/asset-.md b/plugins/arcade-canvas/extensions/arcade-canvas/assets/asset-.md new file mode 100644 index 00000000..88354f6f --- /dev/null +++ b/plugins/arcade-canvas/extensions/arcade-canvas/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/plugins/arcade-canvas/extensions/arcade-canvas/assets/canvas-background.webp b/plugins/arcade-canvas/extensions/arcade-canvas/assets/canvas-background.webp new file mode 100644 index 00000000..1c26723c Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/canvas-background.webp differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_explosion.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_explosion.ogg new file mode 100644 index 00000000..019e5366 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_explosion.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_laser1.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_laser1.ogg new file mode 100644 index 00000000..7a9a4d2f Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_laser1.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_lose.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_lose.ogg new file mode 100644 index 00000000..496968f8 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_lose.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_twoTone.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_twoTone.ogg new file mode 100644 index 00000000..20274928 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/cosmic-rocks/sounds/sfx_twoTone.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/baiter.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/baiter.png new file mode 100644 index 00000000..d52ec970 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/baiter.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/bomber.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/bomber.png new file mode 100644 index 00000000..77608d79 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/bomber.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/humanoid.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/humanoid.png new file mode 100644 index 00000000..7c9d97aa Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/humanoid.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/lander.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/lander.png new file mode 100644 index 00000000..d0c6b8b3 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/lander.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/mutant.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/mutant.png new file mode 100644 index 00000000..959b5b77 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/mutant.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/planet-guard-sprites.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/planet-guard-sprites.png new file mode 100644 index 00000000..8656e3e5 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/planet-guard-sprites.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/pod.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/pod.png new file mode 100644 index 00000000..5b175afd Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/pod.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/ship.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/ship.png new file mode 100644 index 00000000..7cd194d4 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/ship.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/ship_left.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/ship_left.png new file mode 100644 index 00000000..5f613e6f Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/ship_left.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_baiterwarning.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_baiterwarning.ogg new file mode 100644 index 00000000..3e3f1677 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_baiterwarning.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_bonus.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_bonus.ogg new file mode 100644 index 00000000..2e7766e2 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_bonus.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemydead.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemydead.ogg new file mode 100644 index 00000000..48f06d10 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemydead.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot.ogg new file mode 100644 index 00000000..6ba909aa Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot2.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot2.ogg new file mode 100644 index 00000000..b1cd5f52 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_enemyshoot2.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_explode.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_explode.ogg new file mode 100644 index 00000000..004f63f5 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_explode.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_humanoiddead.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_humanoiddead.ogg new file mode 100644 index 00000000..c21e4be3 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_humanoiddead.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_laser.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_laser.ogg new file mode 100644 index 00000000..7c88c7b0 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_laser.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_player1up.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_player1up.ogg new file mode 100644 index 00000000..11d4015e Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_player1up.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_playerdead.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_playerdead.ogg new file mode 100644 index 00000000..39e8f0a0 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_playerdead.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_start.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_start.ogg new file mode 100644 index 00000000..7b6a4c47 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_start.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_thurst.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_thurst.ogg new file mode 100644 index 00000000..92e18e0d Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_thurst.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_warning.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_warning.ogg new file mode 100644 index 00000000..96a56290 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/sounds/sound_warning.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/swarmer.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/swarmer.png new file mode 100644 index 00000000..41b439af Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/defender/swarmer.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_explosion.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_explosion.ogg new file mode 100644 index 00000000..019e5366 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_explosion.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser1.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser1.ogg new file mode 100644 index 00000000..7a9a4d2f Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser1.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser2.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser2.ogg new file mode 100644 index 00000000..6a2d4c5a Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_laser2.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_lose.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_lose.ogg new file mode 100644 index 00000000..496968f8 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_lose.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldDown.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldDown.ogg new file mode 100644 index 00000000..e3a7a514 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldDown.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldUp.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldUp.ogg new file mode 100644 index 00000000..49fdb6cc Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_shieldUp.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_twoTone.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_twoTone.ogg new file mode 100644 index 00000000..20274928 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_twoTone.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_zap.ogg b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_zap.ogg new file mode 100644 index 00000000..3f6250d3 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/sounds/sfx_zap.ogg differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_bg.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_bg.png new file mode 100644 index 00000000..d9c3fd42 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_bg.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2-black.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2-black.png new file mode 100644 index 00000000..daad6d14 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2-black.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.png new file mode 100644 index 00000000..1dc51868 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.xml b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.xml new file mode 100644 index 00000000..c7751658 --- /dev/null +++ b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet-2.xml @@ -0,0 +1,297 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.png new file mode 100644 index 00000000..8c58b86c Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.xml b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.xml new file mode 100644 index 00000000..71e1ccf1 --- /dev/null +++ b/plugins/arcade-canvas/extensions/arcade-canvas/assets/galaxy-blaster/space_sheet.xml @@ -0,0 +1,296 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/icon.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/icon.png new file mode 100644 index 00000000..43d071c5 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/icon.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/background.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/background.png new file mode 100644 index 00000000..9715a333 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/background.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/big_bush.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/big_bush.png new file mode 100644 index 00000000..b1d517ed Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/big_bush.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/bridge.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/bridge.png new file mode 100644 index 00000000..b34648d2 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/bridge.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/brown_block.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/brown_block.png new file mode 100644 index 00000000..5983f8d9 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/brown_block.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/clouds.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/clouds.png new file mode 100644 index 00000000..e69c8707 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/clouds.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/coin_sheet.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/coin_sheet.png new file mode 100644 index 00000000..56166bb0 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/coin_sheet.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/dirt_block.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/dirt_block.png new file mode 100644 index 00000000..0b7dd760 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/dirt_block.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_short_strip.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_short_strip.png new file mode 100644 index 00000000..81d85d06 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_short_strip.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_strip.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_strip.png new file mode 100644 index 00000000..50993a03 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_strip.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_tall_strip.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_tall_strip.png new file mode 100644 index 00000000..7ea3effa Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/enemy_tall_strip.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/flag.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/flag.png new file mode 100644 index 00000000..627396a3 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/flag.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/grass_block.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/grass_block.png new file mode 100644 index 00000000..51c2a71d Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/grass_block.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/heart_sheet.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/heart_sheet.png new file mode 100644 index 00000000..12c18859 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/heart_sheet.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/hill_0.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/hill_0.png new file mode 100644 index 00000000..f555564c Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/hill_0.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/hill_1.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/hill_1.png new file mode 100644 index 00000000..41117d27 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/hill_1.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/impact_sheet.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/impact_sheet.png new file mode 100644 index 00000000..6039534c Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/impact_sheet.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/platform.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/platform.png new file mode 100644 index 00000000..65e01a5b Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/platform.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/player_strip.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/player_strip.png new file mode 100644 index 00000000..d283d3e4 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/player_strip.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/qblock_new.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/qblock_new.png new file mode 100644 index 00000000..4248ee22 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/qblock_new.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/small_bush.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/small_bush.png new file mode 100644 index 00000000..4537481b Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/small_bush.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowClub.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowClub.m4a new file mode 100644 index 00000000..64c74137 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowClub.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowDull.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowDull.m4a new file mode 100644 index 00000000..70fa9200 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBlowDull.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBonus.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBonus.m4a new file mode 100644 index 00000000..fe27d29e Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBonus.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBounce.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBounce.m4a new file mode 100644 index 00000000..17f68dc0 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundBounce.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundClick.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundClick.m4a new file mode 100644 index 00000000..de0201b4 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundClick.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCoin.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCoin.m4a new file mode 100644 index 00000000..a618d277 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCoin.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCountdown.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCountdown.m4a new file mode 100644 index 00000000..e60c1a1b Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundCountdown.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundDeath.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundDeath.m4a new file mode 100644 index 00000000..3169fd36 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundDeath.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyDeath.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyDeath.m4a new file mode 100644 index 00000000..52e7c450 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyDeath.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyHit.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyHit.m4a new file mode 100644 index 00000000..62dc862d Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyHit.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyShot.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyShot.m4a new file mode 100644 index 00000000..e0f75433 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundEnemyShot.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionLarge.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionLarge.m4a new file mode 100644 index 00000000..bd9ab8f9 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionLarge.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionSmall.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionSmall.m4a new file mode 100644 index 00000000..a1deff03 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundExplosionSmall.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallDull.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallDull.m4a new file mode 100644 index 00000000..18bca3a2 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallDull.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallLoud.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallLoud.m4a new file mode 100644 index 00000000..985d0023 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFallLoud.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapHeavy.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapHeavy.m4a new file mode 100644 index 00000000..1baec410 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapHeavy.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapLight.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapLight.m4a new file mode 100644 index 00000000..ed9d6e91 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundFlapLight.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundGameOver.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundGameOver.m4a new file mode 100644 index 00000000..6eef0fa1 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundGameOver.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundHurryUp.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundHurryUp.m4a new file mode 100644 index 00000000..eecc63ff Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundHurryUp.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump1.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump1.m4a new file mode 100644 index 00000000..dca05b48 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump1.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump2.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump2.m4a new file mode 100644 index 00000000..4ed62c9c Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJump2.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJumpHah.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJumpHah.m4a new file mode 100644 index 00000000..aa36cdb7 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundJumpHah.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand1.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand1.m4a new file mode 100644 index 00000000..280d6f13 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand1.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand2.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand2.m4a new file mode 100644 index 00000000..e29c317b Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLand2.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLandHeavy.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLandHeavy.m4a new file mode 100644 index 00000000..890d6192 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLandHeavy.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLaser.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLaser.m4a new file mode 100644 index 00000000..8b3d9179 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundLaser.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMechanism.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMechanism.m4a new file mode 100644 index 00000000..7a54bc32 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMechanism.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMissile.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMissile.m4a new file mode 100644 index 00000000..2f5a5f50 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundMissile.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundObjectFall.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundObjectFall.m4a new file mode 100644 index 00000000..8713726d Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundObjectFall.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundOpenDoor.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundOpenDoor.m4a new file mode 100644 index 00000000..ea0833a8 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundOpenDoor.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundPlayerHit.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundPlayerHit.m4a new file mode 100644 index 00000000..f7171133 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundPlayerHit.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundReachGoal.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundReachGoal.m4a new file mode 100644 index 00000000..7120a4ba Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundReachGoal.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootDull.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootDull.m4a new file mode 100644 index 00000000..e0e6b35a Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootDull.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootRegular.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootRegular.m4a new file mode 100644 index 00000000..11d19112 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundShootRegular.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSlide.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSlide.m4a new file mode 100644 index 00000000..513fe130 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSlide.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSpecialSkill.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSpecialSkill.m4a new file mode 100644 index 00000000..0b0cae7d Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSpecialSkill.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundStartLevel.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundStartLevel.m4a new file mode 100644 index 00000000..d67a38a7 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundStartLevel.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSwim.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSwim.m4a new file mode 100644 index 00000000..a44d81a5 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundSwim.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWandMagic.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWandMagic.m4a new file mode 100644 index 00000000..df2c7687 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWandMagic.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWind.m4a b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWind.m4a new file mode 100644 index 00000000..106c546e Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/sounds/SoundWind.m4a differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/spikes.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/spikes.png new file mode 100644 index 00000000..08a63516 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/ninja-runner/spikes.png differ diff --git a/extensions/arcade-canvas/extensions/arcade-canvas/assets/preview.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/preview.png similarity index 100% rename from extensions/arcade-canvas/extensions/arcade-canvas/assets/preview.png rename to plugins/arcade-canvas/extensions/arcade-canvas/assets/preview.png diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/sounds/agent-arcade-voice.mp3 b/plugins/arcade-canvas/extensions/arcade-canvas/assets/sounds/agent-arcade-voice.mp3 new file mode 100644 index 00000000..9d6881b9 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/sounds/agent-arcade-voice.mp3 differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/tray_icon.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/tray_icon.png new file mode 100644 index 00000000..ec26e637 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/tray_icon.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/assets/tray_icon_small.png b/plugins/arcade-canvas/extensions/arcade-canvas/assets/tray_icon_small.png new file mode 100644 index 00000000..4348d753 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/assets/tray_icon_small.png differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/copilot-extension.json b/plugins/arcade-canvas/extensions/arcade-canvas/copilot-extension.json new file mode 100644 index 00000000..c980a34e --- /dev/null +++ b/plugins/arcade-canvas/extensions/arcade-canvas/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "arcade-canvas", + "version": 1 +} diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/extension.mjs b/plugins/arcade-canvas/extensions/arcade-canvas/extension.mjs new file mode 100644 index 00000000..c8c56b0c --- /dev/null +++ b/plugins/arcade-canvas/extensions/arcade-canvas/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/plugins/arcade-canvas/extensions/arcade-canvas/game/game.js b/plugins/arcade-canvas/extensions/arcade-canvas/game/game.js new file mode 100644 index 00000000..7ed254bf --- /dev/null +++ b/plugins/arcade-canvas/extensions/arcade-canvas/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/plugins/arcade-canvas/extensions/arcade-canvas/game/phaser.min.js b/plugins/arcade-canvas/extensions/arcade-canvas/game/phaser.min.js new file mode 100644 index 00000000..7647718e --- /dev/null +++ b/plugins/arcade-canvas/extensions/arcade-canvas/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/plugins/arcade-canvas/extensions/arcade-canvas/game/phaser.min.js.gz b/plugins/arcade-canvas/extensions/arcade-canvas/game/phaser.min.js.gz new file mode 100644 index 00000000..53c42968 Binary files /dev/null and b/plugins/arcade-canvas/extensions/arcade-canvas/game/phaser.min.js.gz differ diff --git a/plugins/arcade-canvas/extensions/arcade-canvas/game/scenes/AlienOnslaught.js b/plugins/arcade-canvas/extensions/arcade-canvas/game/scenes/AlienOnslaught.js new file mode 100644 index 00000000..c8f77f82 --- /dev/null +++ b/plugins/arcade-canvas/extensions/arcade-canvas/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/plugins/arcade-canvas/extensions/arcade-canvas/game/scenes/BaseScene.js b/plugins/arcade-canvas/extensions/arcade-canvas/game/scenes/BaseScene.js new file mode 100644 index 00000000..b5b89731 --- /dev/null +++ b/plugins/arcade-canvas/extensions/arcade-canvas/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/plugins/arcade-canvas/extensions/arcade-canvas/game/scenes/CosmicRocks.js b/plugins/arcade-canvas/extensions/arcade-canvas/game/scenes/CosmicRocks.js new file mode 100644 index 00000000..0518d7ca --- /dev/null +++ b/plugins/arcade-canvas/extensions/arcade-canvas/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/plugins/arcade-canvas/extensions/arcade-canvas/game/scenes/GalaxyBlaster.js b/plugins/arcade-canvas/extensions/arcade-canvas/game/scenes/GalaxyBlaster.js new file mode 100644 index 00000000..4575b583 --- /dev/null +++ b/plugins/arcade-canvas/extensions/arcade-canvas/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/plugins/arcade-canvas/extensions/arcade-canvas/game/scenes/NinjaRunner.js b/plugins/arcade-canvas/extensions/arcade-canvas/game/scenes/NinjaRunner.js new file mode 100644 index 00000000..64424a0b --- /dev/null +++ b/plugins/arcade-canvas/extensions/arcade-canvas/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/plugins/arcade-canvas/extensions/arcade-canvas/game/scenes/PlanetGuardian.js b/plugins/arcade-canvas/extensions/arcade-canvas/game/scenes/PlanetGuardian.js new file mode 100644 index 00000000..bdcddeea --- /dev/null +++ b/plugins/arcade-canvas/extensions/arcade-canvas/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/plugins/arcade-canvas/extensions/arcade-canvas/package-lock.json b/plugins/arcade-canvas/extensions/arcade-canvas/package-lock.json new file mode 100644 index 00000000..e7e7b176 --- /dev/null +++ b/plugins/arcade-canvas/extensions/arcade-canvas/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/plugins/arcade-canvas/extensions/arcade-canvas/package.json b/plugins/arcade-canvas/extensions/arcade-canvas/package.json new file mode 100644 index 00000000..1c7734e0 --- /dev/null +++ b/plugins/arcade-canvas/extensions/arcade-canvas/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/arcade-canvas/.github/plugin/plugin.json b/plugins/arcade-canvas/plugin.json similarity index 69% rename from extensions/arcade-canvas/.github/plugin/plugin.json rename to plugins/arcade-canvas/plugin.json index fbc98155..7f2b7abe 100644 --- a/extensions/arcade-canvas/.github/plugin/plugin.json +++ b/plugins/arcade-canvas/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "arcade-canvas", "description": "Play five retro Phaser mini-games in a Copilot canvas while agents work.", "version": "1.0.2", @@ -14,6 +15,9 @@ "retro-games", "session-breaks" ], - "logo": "assets/preview.png", - "extensions": "extensions" + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + } + } } diff --git a/plugins/arch/.github/plugin/plugin.json b/plugins/arch/plugin.json similarity index 84% rename from plugins/arch/.github/plugin/plugin.json rename to plugins/arch/plugin.json index 4bd592b4..81c4e45a 100644 --- a/plugins/arch/.github/plugin/plugin.json +++ b/plugins/arch/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "arch", "description": "Architecture and modernization toolkit: produce a cited architecture document for a locally-cloned repo, and generate a phased modernization plan that auto-runs Documentation mode when needed.", "version": "1.0.0", @@ -13,8 +14,5 @@ "name": "Awesome Copilot Community" }, "repository": "https://github.com/github/awesome-copilot", - "license": "MIT", - "skills": [ - "./skills/doc-and-modernize" - ] + "license": "MIT" } diff --git a/plugins/arize-ax/.github/plugin/plugin.json b/plugins/arize-ax/plugin.json similarity index 65% rename from plugins/arize-ax/.github/plugin/plugin.json rename to plugins/arize-ax/plugin.json index 96db4d60..0ee83571 100644 --- a/plugins/arize-ax/.github/plugin/plugin.json +++ b/plugins/arize-ax/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "arize-ax", "description": "Arize AX platform skills for LLM observability, evaluation, and optimization. Includes trace export, instrumentation, datasets, experiments, evaluators, AI provider integrations, annotations, prompt optimization, and deep linking to the Arize UI.", "version": "1.0.0", @@ -17,16 +18,5 @@ "datasets", "experiments", "prompt-optimization" - ], - "skills": [ - "./skills/arize-ai-provider-integration", - "./skills/arize-annotation", - "./skills/arize-dataset", - "./skills/arize-evaluator", - "./skills/arize-experiment", - "./skills/arize-instrumentation", - "./skills/arize-link", - "./skills/arize-prompt-optimization", - "./skills/arize-trace" ] } diff --git a/plugins/automate-this/.github/plugin/plugin.json b/plugins/automate-this/plugin.json similarity index 89% rename from plugins/automate-this/.github/plugin/plugin.json rename to plugins/automate-this/plugin.json index 440e51fa..5d7580e3 100644 --- a/plugins/automate-this/.github/plugin/plugin.json +++ b/plugins/automate-this/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "automate-this", "description": "Record your screen doing a manual process, drop the video on your Desktop, and let Copilot CLI analyze it frame-by-frame to build working automation scripts. Supports narrated recordings with audio transcription.", "version": "1.0.0", @@ -16,8 +17,5 @@ "scripting", "productivity", "copilot-cli" - ], - "skills": [ - "./skills/automate-this" ] } diff --git a/plugins/awesome-copilot/.github/plugin/plugin.json b/plugins/awesome-copilot/plugin.json similarity index 63% rename from plugins/awesome-copilot/.github/plugin/plugin.json rename to plugins/awesome-copilot/plugin.json index 2c139325..ccfce6ec 100644 --- a/plugins/awesome-copilot/.github/plugin/plugin.json +++ b/plugins/awesome-copilot/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "awesome-copilot", "description": "Meta prompts that help you discover and generate curated GitHub Copilot agents, instructions, prompts, and skills.", "version": "1.1.0", @@ -13,14 +14,5 @@ "meta", "prompt-engineering", "agents" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/suggest-awesome-github-copilot-agents", - "./skills/suggest-awesome-github-copilot-instructions", - "./skills/suggest-awesome-github-copilot-skills" - ], - "mcpServers": "./.mcp.json" + ] } diff --git a/plugins/aws-cloud-development/.github/plugin/plugin.json b/plugins/aws-cloud-development/plugin.json similarity index 73% rename from plugins/aws-cloud-development/.github/plugin/plugin.json rename to plugins/aws-cloud-development/plugin.json index 7b61177d..429a5d53 100644 --- a/plugins/aws-cloud-development/.github/plugin/plugin.json +++ b/plugins/aws-cloud-development/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "aws-cloud-development", "description": "Comprehensive AWS cloud development tools including Infrastructure as Code, serverless functions, architecture patterns, and cost optimization for building scalable cloud applications.", "version": "1.0.0", @@ -17,14 +18,5 @@ "architecture", "devops", "cdk" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/aws-cost-optimize", - "./skills/aws-resource-health-diagnose", - "./skills/aws-resource-query", - "./skills/aws-well-architected-review" ] } diff --git a/plugins/azure-cloud-development/.github/plugin/plugin.json b/plugins/azure-cloud-development/plugin.json similarity index 72% rename from plugins/azure-cloud-development/.github/plugin/plugin.json rename to plugins/azure-cloud-development/plugin.json index 24873819..fe77dfd5 100644 --- a/plugins/azure-cloud-development/.github/plugin/plugin.json +++ b/plugins/azure-cloud-development/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "azure-cloud-development", "description": "Comprehensive Azure cloud development tools including Infrastructure as Code, serverless functions, architecture patterns, and cost optimization for building scalable cloud applications.", "version": "1.0.1", @@ -16,14 +17,5 @@ "serverless", "architecture", "devops" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/az-cost-optimize", - "./skills/azure-pricing", - "./skills/azure-resource-health-diagnose", - "./skills/import-infrastructure-as-code" ] } diff --git a/plugins/backlog-swipe-triage/README.md b/plugins/backlog-swipe-triage/README.md new file mode 100644 index 00000000..b799412f --- /dev/null +++ b/plugins/backlog-swipe-triage/README.md @@ -0,0 +1,17 @@ +# Backlog Swipe Triage Plugin + +Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore. + +## Installation + +``bash +copilot plugin install backlog-swipe-triage@awesome-copilot +`` + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). + +## License + +MIT diff --git a/plugins/backlog-swipe-triage/extensions/backlog-swipe-triage/README.md b/plugins/backlog-swipe-triage/extensions/backlog-swipe-triage/README.md new file mode 100644 index 00000000..291e6ea9 --- /dev/null +++ b/plugins/backlog-swipe-triage/extensions/backlog-swipe-triage/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/backlog-swipe-triage/assets/preview.png b/plugins/backlog-swipe-triage/extensions/backlog-swipe-triage/assets/preview.png similarity index 100% rename from extensions/backlog-swipe-triage/extensions/backlog-swipe-triage/assets/preview.png rename to plugins/backlog-swipe-triage/extensions/backlog-swipe-triage/assets/preview.png diff --git a/plugins/backlog-swipe-triage/extensions/backlog-swipe-triage/extension.mjs b/plugins/backlog-swipe-triage/extensions/backlog-swipe-triage/extension.mjs new file mode 100644 index 00000000..e5c3af73 --- /dev/null +++ b/plugins/backlog-swipe-triage/extensions/backlog-swipe-triage/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/plugins/backlog-swipe-triage/extensions/backlog-swipe-triage/package-lock.json b/plugins/backlog-swipe-triage/extensions/backlog-swipe-triage/package-lock.json new file mode 100644 index 00000000..38325be1 --- /dev/null +++ b/plugins/backlog-swipe-triage/extensions/backlog-swipe-triage/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/plugins/backlog-swipe-triage/extensions/backlog-swipe-triage/package.json b/plugins/backlog-swipe-triage/extensions/backlog-swipe-triage/package.json new file mode 100644 index 00000000..34f71265 --- /dev/null +++ b/plugins/backlog-swipe-triage/extensions/backlog-swipe-triage/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/backlog-swipe-triage/.github/plugin/plugin.json b/plugins/backlog-swipe-triage/plugin.json similarity index 73% rename from extensions/backlog-swipe-triage/.github/plugin/plugin.json rename to plugins/backlog-swipe-triage/plugin.json index e10d6fb3..8bd19eeb 100644 --- a/extensions/backlog-swipe-triage/.github/plugin/plugin.json +++ b/plugins/backlog-swipe-triage/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "backlog-swipe-triage", "description": "Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.", "version": "1.0.2", @@ -14,6 +15,9 @@ "swipe-interface", "workflow-automation" ], - "logo": "assets/preview.png", - "extensions": "extensions" + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + } + } } diff --git a/plugins/backrooms-canvas/README.md b/plugins/backrooms-canvas/README.md new file mode 100644 index 00000000..80e93acd --- /dev/null +++ b/plugins/backrooms-canvas/README.md @@ -0,0 +1,17 @@ +# Backrooms Canvas Plugin + +Wander an endless first-person backrooms in a Copilot canvas while agents work; their status ghost-writes on the walls. + +## Installation + +``bash +copilot plugin install backrooms-canvas@awesome-copilot +`` + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). + +## License + +MIT diff --git a/plugins/backrooms-canvas/extensions/backrooms-canvas/README.md b/plugins/backrooms-canvas/extensions/backrooms-canvas/README.md new file mode 100644 index 00000000..122a81a8 --- /dev/null +++ b/plugins/backrooms-canvas/extensions/backrooms-canvas/README.md @@ -0,0 +1,90 @@ +# BackRooms Canvas + +A GitHub Copilot canvas that opens an endless first-person backrooms in the side panel. Yellow-wallpapered halls under humming fluorescent panels, drop-tile ceilings, and worn office carpet, filmed through the shake and grain of a 1990-era handheld camcorder. Somewhere past the fog, something else walks the same halls. + +It is a canvas port of the [BackViews VSCode extension](https://github.com/isocialPractice/vscode-backviews). The world is powered by [cmd-backedges](https://github.com/isocialPractice/cmd-backedges), a procedural infinite maze engine, so every wall, room, and prop is a pure function of the seed: the same seed always rebuilds the same rooms, forever, in every direction. + +While an agent works in the session, the halls start writing back. Its status and streaming responses are scrawled onto the walls in a messy ink script, and a camcorder-style token counter runs under the HUD battery. + +## Files + +- `extension.mjs` — canvas declaration, loopback game server, static asset handling, and agent actions. +- `game/` — the prebuilt game bundle (`webview.js`) and the `index.html` host shim served inside the canvas. +- `materials/` — photo textures (`wallpaper.jpg`, `ceiling.jpg`, `carpet.jpg`) tiled over the procedural atlas. +- `assets/` — 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. + +## Prerequisites + +- **Node.js 20.19 or newer**, because the Copilot SDK requires `node ^20.19.0 || >=22.12.0`. +- A WebGL-capable canvas surface (the renderer is raw WebGL). +- The GitHub Copilot app canvas / UI-extensions experiment enabled. + +## Install + +Drop this folder at `~/.copilot/extensions/backrooms-canvas/` for user scope, or in a repository at `.github/extensions/backrooms-canvas/` for project scope. Then install dependencies from inside the copied folder: + +```sh +# User scope +cd ~/.copilot/extensions/backrooms-canvas + +# Or project scope, from the repository root +cd .github/extensions/backrooms-canvas + +npm install +``` + +Reload extensions in the GitHub Copilot app, then open the `backrooms-canvas` canvas. Click the view to capture the mouse and start walking. + +The canvas accepts optional open inputs: + +| Input | Type | Description | +| --- | --- | --- | +| `seed` | number | Maze seed. The same seed always rebuilds the same halls. `0` rolls a random seed. | +| `materialPreset` | string | Wall material set: `classic`, `office`, `pool`, `concrete`, or `panel`. | +| `monsterEnabled` | boolean | Whether something else walks the halls. | + +## Controls + +| Input | Action | +| --- | --- | +| `W` / `S` or `Up` / `Down` | Walk forward / back | +| `A` / `D` | Strafe left / right | +| `Left` / `Right` or `Q` / `E` | Turn | +| `Shift` | Hurry | +| Mouse (after clicking the view) | Look around | +| `M` or `Esc` | Open the in-game menu | + +The in-game menu has Resume, Relocate, Settings, and Help, plus live stats. Settings changed there persist in the canvas via `localStorage`, so your choices survive a reload. + +## Agent actions + +The agent drives the game and feeds the ghost-writer through three actions. They are the canvas equivalent of the original extension's `backviews_reportJob` tool and chat-session mirror. + +- `report_job { status, tokens?, done? }` — report the current job step. The status text is scrawled on the walls and the token count drives the HUD counter. Call it when work starts, again on each new step, and once more with `done: true` when finished. +- `ghost_write { text, tokens?, done? }` — ghost-write a block of text onto the wall ahead, character by character as it grows, like a streaming response. Send the growing text on each call, then once with `done: true` to settle it in place. +- `relocate` — drop the wanderer into a fresh random seed, wiping any writing already on the walls. + +To have the agent narrate itself automatically, reference these actions from a `.github/copilot-instructions.md` so it calls `report_job` on each step of a chat request. + +## How the port works + +The game itself is unchanged: `game/webview.js` is the same self-contained bundle the VSCode extension ships (WebGL renderer, maze engine, camcorder overlay, and wall-writing subsystem in one esbuild IIFE). The bundle was written to talk to a VSCode webview host through `acquireVsCodeApi()` and `window.postMessage`. + +`game/index.html` shims that host: + +- `acquireVsCodeApi()` is backed by `localStorage` for state (seed and player position) and settings persistence. +- The canvas server (`extension.mjs`) runs a loopback HTTP server that serves the bundle and streams agent activity over Server-Sent Events at `/events`. The shim translates those events into the exact `jobStatus`, `chatSession`, and `relocate` messages the bundle already listens for. +- Agent actions broadcast onto that SSE stream, so `report_job` writes on the walls and `ghost_write` streams text just as the VSCode chat mirror did. + +This mirrors the [`arcade-canvas`](../arcade-canvas) architecture: a static frontend served from a loopback server, with the agent driving it through canvas actions. + +## Credits + +- Maze generation: [cmd-backedges](https://github.com/isocialPractice/cmd-backedges) by John Haugabook. +- Original extension: [vscode-backviews](https://github.com/isocialPractice/vscode-backviews). + +## License + +MIT diff --git a/plugins/backrooms-canvas/extensions/backrooms-canvas/assets/icon.png b/plugins/backrooms-canvas/extensions/backrooms-canvas/assets/icon.png new file mode 100644 index 00000000..6297eaaa Binary files /dev/null and b/plugins/backrooms-canvas/extensions/backrooms-canvas/assets/icon.png differ diff --git a/extensions/backrooms-canvas/extensions/backrooms-canvas/assets/preview.png b/plugins/backrooms-canvas/extensions/backrooms-canvas/assets/preview.png similarity index 100% rename from extensions/backrooms-canvas/extensions/backrooms-canvas/assets/preview.png rename to plugins/backrooms-canvas/extensions/backrooms-canvas/assets/preview.png diff --git a/plugins/backrooms-canvas/extensions/backrooms-canvas/copilot-extension.json b/plugins/backrooms-canvas/extensions/backrooms-canvas/copilot-extension.json new file mode 100644 index 00000000..56cd2c10 --- /dev/null +++ b/plugins/backrooms-canvas/extensions/backrooms-canvas/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "backrooms-canvas", + "version": 1 +} diff --git a/plugins/backrooms-canvas/extensions/backrooms-canvas/extension.mjs b/plugins/backrooms-canvas/extensions/backrooms-canvas/extension.mjs new file mode 100644 index 00000000..0cddfa0d --- /dev/null +++ b/plugins/backrooms-canvas/extensions/backrooms-canvas/extension.mjs @@ -0,0 +1,472 @@ +/** + * Copilot canvas entry point for BackRooms. + * + * The game itself is the prebuilt browser bundle at game/webview.js (an + * esbuild IIFE that already carries the WebGL renderer, the procedural maze + * engine, and the whole first-person world). This file is only the host: a + * tiny local HTTP server that serves that bundle plus its photo materials, + * and a canvas registration that lets an agent drive the game through actions. + * + * The bundle was written for a VSCode webview and talks to its host through + * `acquireVsCodeApi()` + `window.postMessage`. game/index.html shims that API + * against this server's Server-Sent Events stream, so the same bundle runs + * unchanged inside the canvas. The message shapes below match the bundle's + * expectations exactly (see the original src/shared/settings.ts): + * + * host -> game : { type: 'config', settings } + * { type: 'jobStatus', job } // CopilotJob + * { type: 'chatSession', session } // ChatSessionSnapshot + * { type: 'relocate' } + * game -> host : { type: 'ready' } // handled in the shim + * { type: 'updateSetting', key, value } // handled in the shim + */ +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 materialsRoot = path.join(__dirname, "materials"); +const assetsRoot = path.join(__dirname, "assets"); +const indexPath = path.join(gameRoot, "index.html"); + +/** + * Default settings, mirrored from the bundle's own DEFAULT_SETTINGS. The shim + * merges any per-open overrides and the player's saved menu choices on top of + * these before handing the game its first `config` message. + */ +const DEFAULT_SETTINGS = { + seed: 0, + moveSpeed: 2.2, + renderDistance: 14, + cameraShake: true, + filmGrain: true, + vhsHud: true, + furniture: true, + wallpaperShifts: false, + mouseLook: true, + invertTurn: false, + invertStrafe: false, + invertForward: false, + materialPreset: "classic", + materialHueShift: 0, + materialBrightness: 1, + monsterEnabled: true, + monsterSpeed: 2.6, + monsterSpawnMin: 1, + monsterSpawnMax: 5, + monsterForm: "random", + copilotGhostWriter: true, +}; + +const MATERIAL_PRESETS = ["classic", "office", "pool", "concrete", "panel"]; +const MONSTER_FORMS = ["spider", "humanoid", "cloud", "random"]; + +/** Idle Copilot job; the walls stay quiet and the HUD counter fades out. */ +const IDLE_JOB = { working: false, status: "", tokens: 0 }; + +const servers = new Map(); + +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 ".map": + return "application/json; charset=utf-8"; + case ".png": + return "image/png"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".svg": + return "image/svg+xml"; + case ".webp": + return "image/webp"; + default: + return "application/octet-stream"; + } +} + +/** Resolves a request path under a root, refusing anything that escapes it. */ +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 backrooms 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); + } +} + +function randomSeed() { + return Math.floor(Math.random() * 999_999) + 1; +} + +/** Coerces one enum-ish value, falling back to the default when unknown. */ +function pickEnum(value, allowed, fallback) { + return typeof value === "string" && allowed.includes(value) ? value : fallback; +} + +/** Coerces a finite number into [min, max], or returns the fallback. */ +function clampNumber(value, min, max, fallback) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback; + } + return Math.min(max, Math.max(min, value)); +} + +/** + * Turns arbitrary open-input into a partial settings override the shim can + * merge over the defaults. Only the settings that make sense to preset from an + * agent are honored; everything else stays on its default or saved value. + */ +function normalizeOverrides(input) { + const overrides = {}; + if (!input || typeof input !== "object") { + return overrides; + } +if (input.seed !== undefined) { + const seed = Math.trunc(clampNumber(input.seed, 0, Number.MAX_SAFE_INTEGER, 0)); + overrides.seed = seed === 0 ? randomSeed() : seed; + } + if (input.materialPreset !== undefined) { + overrides.materialPreset = pickEnum(input.materialPreset, MATERIAL_PRESETS, "classic"); + } + if (input.monsterEnabled !== undefined) { + overrides.monsterEnabled = input.monsterEnabled === true; + } + if (input.monsterForm !== undefined) { + overrides.monsterForm = pickEnum(input.monsterForm, MONSTER_FORMS, "random"); + } + if (input.copilotGhostWriter !== undefined) { + overrides.copilotGhostWriter = input.copilotGhostWriter !== false; + } + return overrides; +} + +/** Coerces report_job input into a CopilotJob the game understands. */ +function normalizeJob(input) { + if (!input || typeof input !== "object") { + return { ...IDLE_JOB }; + } + const done = input.done === true; + return { + working: !done, + status: typeof input.status === "string" ? input.status.slice(0, 120) : "", + tokens: + typeof input.tokens === "number" && Number.isFinite(input.tokens) + ? Math.max(0, Math.floor(input.tokens)) + : 0, + }; +} + +/** + * Coerces ghost_write input into a ChatSessionSnapshot. The game ghost-writes + * `current` onto the wall ahead as it grows, and the token count drives the + * HUD odometer. `done` settles the writing in place and stops the counter. + */ +function normalizeSession(input) { + const text = typeof input?.text === "string" ? input.text : ""; + const done = input?.done === true; + const tokens = + typeof input?.tokens === "number" && Number.isFinite(input.tokens) + ? Math.max(0, Math.floor(input.tokens)) + : Math.ceil(text.length / 4); + return { working: !done && text.length > 0, history: [], current: text, tokens }; +} + +async function renderIndex(entry) { + const html = await readFile(indexPath, "utf8"); + // Handed to the shim so it can build the game's first `config` message and + // replay any job that is already running when the panel opens. + const init = { + defaults: DEFAULT_SETTINGS, + overrides: entry.overrides, + materials: { + wallpaper: "/materials/wallpaper.jpg", + ceiling: "/materials/ceiling.jpg", + carpet: "/materials/carpet.jpg", + }, + job: entry.job, + session: entry.session, + }; +return html.replace("__BACKROOMS_INIT__", JSON.stringify(init).replace(/ 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 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); + // Catch a fresh (or reconnecting) client up to the live state. + sendSse(res, "jobStatus", { job: entry.job }); + if (entry.session) { + sendSse(res, "chatSession", { session: entry.session }); + } + req.on("close", () => entry.clients.delete(res)); + return; + } + + // The shim reports explicit setting changes (menu edits, relocate): the + // per-open override for that key stops applying, so a reload keeps the + // player's choice instead of replaying the stale override. + if (req.method === "DELETE" && url.pathname.startsWith("/override/")) { + delete entry.overrides[decodeURIComponent(url.pathname.slice("/override/".length))]; + res.writeHead(204); + res.end(); + return; + } + + if (url.pathname === "/favicon.ico") { + await streamFile(res, path.join(assetsRoot, "icon.png")); + return; + } + + try { + if (url.pathname === "/" || url.pathname === "/index.html") { + res.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-cache", + }); + res.end(await renderIndex(entry)); + return; + } + + const staticPath = url.pathname.startsWith("/materials/") + ? resolveUnder(materialsRoot, url.pathname.slice("/materials".length)) + : url.pathname.startsWith("/assets/") + ? resolveUnder(assetsRoot, url.pathname.slice("/assets".length)) + : resolveUnder(gameRoot, 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, overrides) { + const entry = { + clients: new Set(), + overrides, + job: { ...IDLE_JOB }, + session: null, + 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 : "Backrooms 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("backrooms_not_open", "Open the Backrooms canvas before invoking this action."); + } + return entry; +} + +await joinSession({ + canvases: [ + createCanvas({ + id: "backrooms-canvas", + displayName: "BackRooms", + description: + "An endless first-person backrooms to wander while agents work. The agent's status ghost-writes on the walls.", + inputSchema: { + type: "object", + properties: { + seed: { + type: "number", + description: "Maze seed. The same seed always rebuilds the same halls. 0 rolls a random seed.", + }, + materialPreset: { + type: "string", + enum: MATERIAL_PRESETS, + description: "Wall material set to start with.", + }, + monsterEnabled: { + type: "boolean", + description: "Whether something else walks the halls.", + }, + }, + additionalProperties: false, + }, + actions: [ + { + name: "report_job", + description: + "Report your current job status to the backrooms. Call it when you start a chat request, again on each new step, and once more with done=true when finished. The status text is scrawled on the walls and the token count drives a camcorder-style HUD counter.", + inputSchema: { + type: "object", + properties: { + status: { + type: "string", + description: + "Short status text to scrawl on the walls (e.g. 'reading the codebase', 'rewriting the parser').", + }, + tokens: { + type: "number", + description: "Approximate tokens consumed by the current job so far.", + }, + done: { + type: "boolean", + description: "Set true when the job is finished; the walls stop updating and the counter fades out.", + }, + }, + required: ["status"], + additionalProperties: false, + }, + handler: (ctx) => { + const entry = getOpenEntry(ctx.instanceId); + entry.job = normalizeJob(ctx.input); + broadcast(entry, "jobStatus", { job: entry.job }); + return { job: entry.job }; + }, + }, + { + name: "ghost_write", + description: + "Ghost-write a block of text onto the walls ahead, character by character as it grows, like a streaming chat response. Call repeatedly with the full text so far, then once with done=true to settle it in place.", + inputSchema: { + type: "object", + properties: { + text: { + type: "string", + description: "The full response text so far. Send the growing text on each call; the walls reveal the new characters.", + }, + tokens: { + type: "number", + description: "Approximate tokens of the response so far. Defaults to text length / 4.", + }, + done: { + type: "boolean", + description: "Set true when the response is complete; the writing settles onto the wall and the counter stops.", + }, + }, + required: ["text"], + additionalProperties: false, + }, + handler: (ctx) => { + const entry = getOpenEntry(ctx.instanceId); + entry.session = normalizeSession(ctx.input); + broadcast(entry, "chatSession", { session: entry.session }); + return { working: entry.session.working, tokens: entry.session.tokens }; + }, + }, + { + name: "relocate", + description: "Drop the wanderer into a fresh random seed, wiping any writing already on the walls.", + handler: (ctx) => { + const entry = getOpenEntry(ctx.instanceId); + const seed = randomSeed(); + // The walls are wiped on relocate, so drop the job and + // session snapshots too or a reload would replay them + // onto the fresh maze. + entry.job = { ...IDLE_JOB }; + entry.session = null; + broadcast(entry, "jobStatus", { job: entry.job }); + broadcast(entry, "relocate", { seed }); + return { seed }; + }, + }, + ], + open: async (ctx) => { + const overrides = normalizeOverrides(ctx.input); + let entry = servers.get(ctx.instanceId); + if (!entry) { + entry = await startServer(ctx.instanceId, overrides); + } else { + entry.overrides = { ...entry.overrides, ...overrides }; + } + return { + title: "BackRooms", + status: entry.job.working && entry.job.status ? entry.job.status : "Wandering", + 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/plugins/backrooms-canvas/extensions/backrooms-canvas/game/index.html b/plugins/backrooms-canvas/extensions/backrooms-canvas/game/index.html new file mode 100644 index 00000000..29031550 --- /dev/null +++ b/plugins/backrooms-canvas/extensions/backrooms-canvas/game/index.html @@ -0,0 +1,125 @@ + + + + + + + BackRooms + + + +
+ + + + diff --git a/plugins/backrooms-canvas/extensions/backrooms-canvas/game/webview.js b/plugins/backrooms-canvas/extensions/backrooms-canvas/game/webview.js new file mode 100644 index 00000000..b13598f5 --- /dev/null +++ b/plugins/backrooms-canvas/extensions/backrooms-canvas/game/webview.js @@ -0,0 +1,4013 @@ +"use strict"; +(() => { + // src/shared/settings.ts + var IDLE_JOB = { working: false, status: "", tokens: 0 }; + var DEFAULT_SETTINGS = { + seed: 0, + moveSpeed: 2.2, + renderDistance: 14, + cameraShake: true, + filmGrain: true, + vhsHud: true, + furniture: true, + wallpaperShifts: false, + mouseLook: true, + invertTurn: false, + invertStrafe: false, + invertForward: false, + materialPreset: "classic", + materialHueShift: 0, + materialBrightness: 1, + monsterEnabled: true, + monsterSpeed: 2.6, + monsterSpawnMin: 1, + monsterSpawnMax: 5, + monsterForm: "random", + copilotGhostWriter: true + }; + + // src/webview/film.ts + var GRAIN_TILE = 160; + var GRAIN_VARIANTS = 5; + var GRAIN_FPS = 18; + var JOB_LINGER_MS = 6e3; + var FilmOverlay = class { + constructor(canvas) { + this.canvas = canvas; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("2D overlay context unavailable"); + } + this.ctx = ctx; + for (let v = 0; v < GRAIN_VARIANTS; v++) { + this.grainTiles.push(makeGrainTile(v)); + } + } + grainEnabled = true; + hudEnabled = true; + tokenCounterEnabled = true; + ctx; + grainTiles = []; + lastGrainAt = 0; + grainIndex = 0; + tearY = -1; + tearUntil = 0; + burstUntil = 0; + startedAt = Date.now(); + job = { ...IDLE_JOB }; + jobLingerUntil = 0; + shownTokens = 0; + lastHudAt = 0; + /** Cuts the picture to heavy static for a moment (the catch effect). */ + burst(now, durationMs = 1300) { + this.burstUntil = now + durationMs; + } + /** + * Feeds the HUD the latest Copilot job snapshot. The token count drives the + * HUD counter, which rolls toward the target value and lingers after the + * job finishes. + */ + setJob(job) { + this.job = job; + } + render(now) { + const canvas = this.canvas; + const w = canvas.clientWidth; + const h = canvas.clientHeight; + if (w === 0 || h === 0) { + return; + } + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; + canvas.height = h; + } + const ctx = this.ctx; + ctx.clearRect(0, 0, w, h); + if (this.burstUntil > now) { + ctx.globalAlpha = 0.94; + for (let y = 0; y < h; y += GRAIN_TILE) { + for (let x = 0; x < w; x += GRAIN_TILE) { + ctx.drawImage(this.grainTiles[Math.floor(Math.random() * GRAIN_VARIANTS)], x, y); + } + } + ctx.globalAlpha = 1; + ctx.font = 'bold 28px "Courier New", monospace'; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillStyle = "rgba(20, 20, 20, 0.85)"; + ctx.fillRect(w / 2 - 130, h / 2 - 28, 260, 56); + ctx.fillStyle = "#f0f2f3"; + ctx.fillText("SIGNAL LOST", w / 2, h / 2); + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + return; + } + if (!this.grainEnabled && !this.hudEnabled) { + return; + } + if (this.grainEnabled) { + if (now - this.lastGrainAt > 1e3 / GRAIN_FPS) { + this.lastGrainAt = now; + this.grainIndex = Math.floor(Math.random() * GRAIN_VARIANTS); + if (this.tearUntil < now && Math.random() < 0.03) { + this.tearY = Math.random() * h; + this.tearUntil = now + 90 + Math.random() * 160; + } + } + const tile = this.grainTiles[this.grainIndex]; + ctx.globalAlpha = 0.11; + const ox = Math.floor(Math.random() * GRAIN_TILE); + const oy = Math.floor(Math.random() * GRAIN_TILE); + for (let y = -oy; y < h; y += GRAIN_TILE) { + for (let x = -ox; x < w; x += GRAIN_TILE) { + ctx.drawImage(tile, x, y); + } + } + ctx.globalAlpha = 1; + const grad = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.42, w / 2, h / 2, Math.max(w, h) * 0.72); + grad.addColorStop(0, "rgba(0, 0, 0, 0)"); + grad.addColorStop(1, "rgba(0, 0, 0, 0.42)"); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, w, h); + const bandY = now / 34 % (h + 160) - 160; + const band = ctx.createLinearGradient(0, bandY, 0, bandY + 160); + band.addColorStop(0, "rgba(255, 255, 255, 0)"); + band.addColorStop(0.5, "rgba(255, 255, 255, 0.025)"); + band.addColorStop(1, "rgba(255, 255, 255, 0)"); + ctx.fillStyle = band; + ctx.fillRect(0, bandY, w, 160); + if (this.tearUntil > now && this.tearY >= 0) { + ctx.fillStyle = "rgba(220, 220, 210, 0.10)"; + ctx.fillRect(0, this.tearY, w, 3); + ctx.fillStyle = "rgba(0, 0, 0, 0.16)"; + ctx.fillRect(0, this.tearY + 3, w, 2); + } + } + if (this.hudEnabled) { + this.renderHud(now, w, h); + } + } + renderHud(now, w, h) { + const ctx = this.ctx; + const pad = Math.round(Math.min(w, h) * 0.045) + 8; + ctx.font = '16px "Courier New", monospace'; + ctx.textBaseline = "top"; + ctx.fillStyle = "rgba(235, 235, 225, 0.9)"; + ctx.shadowColor = "rgba(0, 0, 0, 0.8)"; + ctx.shadowBlur = 3; + if (Math.floor(now / 700) % 2 === 0) { + ctx.fillStyle = "rgba(255, 70, 60, 0.95)"; + ctx.beginPath(); + ctx.arc(pad + 7, pad + 8, 6, 0, Math.PI * 2); + ctx.fill(); + } + ctx.fillStyle = "rgba(235, 235, 225, 0.9)"; + ctx.fillText("REC", pad + 22, pad); + const elapsed = Math.floor((Date.now() - this.startedAt) / 1e3); + const counter = `${String(Math.floor(elapsed / 3600)).padStart(1, "0")}:${String( + Math.floor(elapsed / 60 % 60) + ).padStart(2, "0")}:${String(elapsed % 60).padStart(2, "0")}`; + ctx.textAlign = "right"; + ctx.fillText(`SP ${counter}`, w - pad, pad); + ctx.strokeStyle = "rgba(235, 235, 225, 0.9)"; + ctx.lineWidth = 1.5; + ctx.strokeRect(w - pad - 34, pad + 24, 30, 12); + ctx.fillRect(w - pad - 3, pad + 27, 3, 6); + ctx.fillRect(w - pad - 32, pad + 26, 8, 8); + ctx.fillRect(w - pad - 22, pad + 26, 8, 8); + this.renderTokenCounter(now, w, pad); + ctx.textAlign = "left"; + const stamp = /* @__PURE__ */ new Date(); + const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; + const hr = stamp.getHours() % 12 === 0 ? 12 : stamp.getHours() % 12; + const ampm = stamp.getHours() < 12 ? "AM" : "PM"; + const text = `${months[stamp.getMonth()]} ${String(stamp.getDate()).padStart(2, "0")} 1990 ${ampm} ${hr}:${String( + stamp.getMinutes() + ).padStart(2, "0")}`; + ctx.fillStyle = "rgba(240, 214, 130, 0.92)"; + ctx.fillText(text, pad, h - pad - 18); + ctx.shadowBlur = 0; + ctx.textAlign = "left"; + } + /** + * Dynamic token counter for the current Copilot job, tucked under the + * battery icon like one more line of a 1990s Sony camcorder's on-screen + * display: blocky monospace digits that roll toward the live count, with a + * blinking access mark while the job is running. + */ + renderTokenCounter(now, w, pad) { + if (!this.tokenCounterEnabled) { + return; + } + if (this.job.working) { + this.jobLingerUntil = now + JOB_LINGER_MS; + } else if (now > this.jobLingerUntil) { + this.shownTokens = 0; + return; + } + const dt = Math.min(0.2, (now - this.lastHudAt) / 1e3 || 0.016); + this.lastHudAt = now; + const target = this.job.tokens; + const gap = target - this.shownTokens; + this.shownTokens = Math.abs(gap) < 1 ? target : this.shownTokens + gap * Math.min(1, dt * 4); + const ctx = this.ctx; + const digits = String(Math.min(999999, Math.round(this.shownTokens))).padStart(6, "0"); + ctx.font = '16px "Courier New", monospace'; + ctx.textAlign = "right"; + ctx.fillStyle = "rgba(235, 235, 225, 0.9)"; + ctx.fillText(`TKN ${digits}`, w - pad, pad + 44); + if (this.job.working && Math.floor(now / 450) % 2 === 0) { + ctx.fillRect(w - pad - 108, pad + 46, 8, 11); + } + ctx.textAlign = "left"; + } + }; + function makeGrainTile(seed) { + const tile = document.createElement("canvas"); + tile.width = tile.height = GRAIN_TILE; + const ctx = tile.getContext("2d"); + const image = ctx.createImageData(GRAIN_TILE, GRAIN_TILE); + let state = 2654435769 ^ seed * 2246822507; + const next = () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + state >>>= 0; + return state / 4294967295; + }; + for (let i = 0; i < image.data.length; i += 4) { + const v = Math.floor(next() * 255); + image.data[i] = v; + image.data[i + 1] = v; + image.data[i + 2] = v; + image.data[i + 3] = 255; + } + ctx.putImageData(image, 0, 0); + return tile; + } + + // ../cmd-backedges/src/rng.ts + function mulberry32(seed) { + let state = seed >>> 0; + return { + next() { + state = state + 1831565813 | 0; + let t = state; + t = Math.imul(t ^ t >>> 15, t | 1); + t ^= t + Math.imul(t ^ t >>> 7, t | 61); + return ((t ^ t >>> 14) >>> 0) / 4294967296; + } + }; + } + var defaultRngFactory = mulberry32; + function mix32(value) { + let h = value | 0; + h ^= h >>> 16; + h = Math.imul(h, 2246822507); + h ^= h >>> 13; + h = Math.imul(h, 3266489909); + h ^= h >>> 16; + return h >>> 0; + } + function hashCombine(hash, value) { + let h = (hash ^ mix32(value | 0)) >>> 0; + h = Math.imul(h, 2654435761) >>> 0; + h = (h << 13 | h >>> 19) >>> 0; + return h >>> 0; + } + function hashCoords(seed, x, y, salt = 0) { + let h = mix32(seed | 0); + h = hashCombine(h, x); + h = hashCombine(h, y); + h = hashCombine(h, salt); + return h >>> 0; + } + function unitFromHash(hash) { + return (hash >>> 0) / 4294967296; + } + + // ../cmd-backedges/src/config.ts + var DEFAULT_CONTROLS = { + moveNorth: ["w", "ArrowUp"], + moveSouth: ["s", "ArrowDown"], + moveEast: ["d", "ArrowRight"], + moveWest: ["a", "ArrowLeft"], + exit: ["Ctrl+C"] + }; + var DEFAULT_CONFIG = { + seed: 1, + cellSize: 1, + width: { min: 0.6, max: 1 }, + height: { min: 0.6, max: 1 }, + depth: { min: 2.5, max: 4 }, + passageDensity: 0.34, + minConnections: 2, + roomFrequency: 0.09, + hallFrequency: 11e-4, + hallSize: { min: 2, max: 4 }, + propFrequency: 0.082, + atriumFrequency: 14e-5, + rng: defaultRngFactory, + controls: DEFAULT_CONTROLS, + cacheLimit: 4096 + }; + function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); + } + function normalizeRange(range, fallback) { + if (!range) { + return { ...fallback }; + } + const min = Number.isFinite(range.min) ? range.min : fallback.min; + const max = Number.isFinite(range.max) ? range.max : fallback.max; + return min <= max ? { min, max } : { min: max, max: min }; + } + function normalizeIntRange(range, fallback, lowerBound) { + const base = normalizeRange(range, fallback); + const min = Math.max(lowerBound, Math.trunc(base.min)); + const max = Math.max(min, Math.trunc(base.max)); + return { min, max }; + } + function resolveConfig(partial = {}) { + const seed = Number.isFinite(partial.seed) ? Math.trunc(partial.seed) : DEFAULT_CONFIG.seed; + const cellSize = Number.isFinite(partial.cellSize) && partial.cellSize > 0 ? partial.cellSize : DEFAULT_CONFIG.cellSize; + return Object.freeze({ + seed, + cellSize, + width: normalizeRange(partial.width, DEFAULT_CONFIG.width), + height: normalizeRange(partial.height, DEFAULT_CONFIG.height), + depth: normalizeRange(partial.depth, DEFAULT_CONFIG.depth), + passageDensity: Number.isFinite(partial.passageDensity) ? clamp(partial.passageDensity, 0, 1) : DEFAULT_CONFIG.passageDensity, + minConnections: Number.isFinite(partial.minConnections) ? Math.trunc(clamp(partial.minConnections, 0, 4)) : DEFAULT_CONFIG.minConnections, + roomFrequency: Number.isFinite(partial.roomFrequency) ? clamp(partial.roomFrequency, 0, 1) : DEFAULT_CONFIG.roomFrequency, + hallFrequency: Number.isFinite(partial.hallFrequency) ? clamp(partial.hallFrequency, 0, 1) : DEFAULT_CONFIG.hallFrequency, + hallSize: normalizeIntRange(partial.hallSize, DEFAULT_CONFIG.hallSize, 1), + propFrequency: Number.isFinite(partial.propFrequency) ? clamp(partial.propFrequency, 0, 1) : DEFAULT_CONFIG.propFrequency, + atriumFrequency: Number.isFinite(partial.atriumFrequency) ? clamp(partial.atriumFrequency, 0, 1) : DEFAULT_CONFIG.atriumFrequency, + rng: typeof partial.rng === "function" ? partial.rng : DEFAULT_CONFIG.rng, + controls: { ...DEFAULT_CONTROLS, ...partial.controls ?? {} }, + cacheLimit: Number.isFinite(partial.cacheLimit) && partial.cacheLimit >= 0 ? Math.trunc(partial.cacheLimit) : DEFAULT_CONFIG.cacheLimit + }); + } + + // ../cmd-backedges/src/types.ts + var DIRECTIONS = ["north", "east", "south", "west"]; + var SURFACE_TYPES = [ + "drywall", + "wallpaper", + "paneling", + "concrete", + "tile", + "carpet" + ]; + var FEATURE_KINDS = ["column", "furniture"]; + function opposite(direction) { + switch (direction) { + case "north": + return "south"; + case "south": + return "north"; + case "east": + return "west"; + case "west": + return "east"; + } + } + function step(direction) { + switch (direction) { + case "north": + return { dx: 0, dy: -1 }; + case "south": + return { dx: 0, dy: 1 }; + case "east": + return { dx: 1, dy: 0 }; + case "west": + return { dx: -1, dy: 0 }; + } + } + + // ../cmd-backedges/src/generator.ts + var SALT = { + /** Openness of the vertical edge east of a cell. */ + edgeVertical: 1, + /** Openness of the horizontal edge south of a cell. */ + edgeHorizontal: 2, + /** Coarse room/corridor value-noise lattice. */ + roomNoise: 3, + width: 16, + height: 17, + depth: 18, + /** Whether a cell anchors a rectangular hall. */ + hallAnchor: 48, + /** Hall width in cells. */ + hallWidth: 49, + /** Hall height in cells. */ + hallHeight: 50, + /** Whether a cell anchors a fully open atrium. */ + atriumAnchor: 80, + /** Atrium base width draw. */ + atriumWidth: 81, + /** Atrium base height draw. */ + atriumHeight: 82, + /** Atrium width variance draw. */ + atriumVarWidth: 83, + /** Atrium height variance draw. */ + atriumVarHeight: 84, + /** Whether an open cell holds an interior prop. */ + prop: 64, + /** Which kind of prop a cell holds. */ + propKind: 65, + /** Prop position within its cell. */ + propPositionX: 66, + propPositionY: 67, + /** Prop footprint size. */ + propSize: 68, + /** Stable per-prop cosmetic seed. */ + propVariant: 69, + /** Seed channel for cosmetic edge metadata. */ + material: 32 + }; + var ROOM_LATTICE = 3; + var ATRIUM_SIZE_FACTOR = 1.05; + var ATRIUM_SIZE_VARIANCE = 0.1; + function smoothstep(t) { + return t * t * (3 - 2 * t); + } + function lerp(a, b, t) { + return a + (b - a) * t; + } + function edgeKey(cx, cy, direction) { + switch (direction) { + case "east": + return { ex: cx, ey: cy, salt: SALT.edgeVertical }; + case "west": + return { ex: cx - 1, ey: cy, salt: SALT.edgeVertical }; + case "south": + return { ex: cx, ey: cy, salt: SALT.edgeHorizontal }; + case "north": + return { ex: cx, ey: cy - 1, salt: SALT.edgeHorizontal }; + } + } + var MazeGenerator = class _MazeGenerator { + /** + * @param config - Partial configuration; omitted fields take their defaults. + */ + constructor(config = {}) { + this.cache = /* @__PURE__ */ new Map(); + this.hits = 0; + this.misses = 0; + this.evictions = 0; + this.config = resolveConfig(config); + } + /** String key for the cache and lookups. */ + static key(cx, cy) { + return `${cx},${cy}`; + } + /** + * Returns the cell at the given integer coordinates, computing it on first + * access and serving it from the LRU cache thereafter. Repeated calls return + * structurally identical cells regardless of cache state. + */ + getCell(cx, cy) { + const key = _MazeGenerator.key(cx, cy); + const cached = this.cache.get(key); + if (cached) { + this.hits++; + this.cache.delete(key); + this.cache.set(key, cached); + return cached; + } + this.misses++; + const cell = this.computeCell(cx, cy); + this.cache.set(key, cell); + this.evictIfNeeded(); + return cell; + } + /** Returns every cell whose coordinates fall in the inclusive rectangle. */ + getRegion(minCx, minCy, maxCx, maxCy) { + const cells = []; + for (let cy = minCy; cy <= maxCy; cy++) { + for (let cx = minCx; cx <= maxCx; cx++) { + cells.push(this.getCell(cx, cy)); + } + } + return cells; + } + /** + * Whether the player may cross from `(cx, cy)` in `direction`. + * + * This is the authoritative movement test and is symmetric: crossing an edge + * from either side yields the same answer. An edge is open when any of the + * following holds, all of which both adjacent cells compute identically: + * + * - both cells belong to the same atrium (its fully open interior); + * - both cells belong to the same rectangular hall (its open interior); + * - both cells are rooms (interior of a merged blob-shaped open area); + * - the edge is open by base density; or + * - either cell braids the edge open to reach {@link MazeConfig.minConnections}. + */ + isPassable(cx, cy, direction) { + const { dx, dy } = step(direction); + const nx = cx + dx; + const ny = cy + dy; + const atrium = this.atriumIdOf(cx, cy); + if (atrium !== null && atrium === this.atriumIdOf(nx, ny)) { + return true; + } + if (atrium === null && this.atriumIdOf(nx, ny) === null) { + const hall = this.hallIdOf(cx, cy); + if (hall !== null && hall === this.hallIdOf(nx, ny)) { + return true; + } + if (this.isRoom(cx, cy) && this.isRoom(nx, ny)) { + return true; + } + } + if (this.baseOpen(cx, cy, direction)) { + return true; + } + if (this.forcedDirections(cx, cy).includes(direction)) { + return true; + } + return this.forcedDirections(nx, ny).includes(opposite(direction)); + } + /** Whether a cell belongs to a blob-shaped room. */ + isRoom(cx, cy) { + return this.classify(cx, cy) === "room"; + } + /** Whether a cell belongs to a prop-bearing rectangular hall. */ + isHall(cx, cy) { + return this.classify(cx, cy) === "hall"; + } + /** Whether a cell belongs to a fully open atrium. */ + isAtrium(cx, cy) { + return this.classify(cx, cy) === "atrium"; + } + /** Empties the cache without affecting determinism of future cells. */ + clearCache() { + this.cache.clear(); + } + /** Returns a snapshot of cache behavior. */ + stats() { + return { + size: this.cache.size, + limit: this.config.cacheLimit, + hits: this.hits, + misses: this.misses, + evictions: this.evictions + }; + } + // --- Structural generation (seed-only, no pluggable RNG) ----------------- + /** Raw openness of the shared edge before braiding is applied. */ + baseOpen(cx, cy, direction) { + const { ex, ey, salt } = edgeKey(cx, cy, direction); + return unitFromHash(hashCoords(this.config.seed, ex, ey, salt)) < this.config.passageDensity; + } + /** Raw hash of a cell's edge in a direction, used to order braid choices. */ + edgeHash(cx, cy, direction) { + const { ex, ey, salt } = edgeKey(cx, cy, direction); + return hashCoords(this.config.seed, ex, ey, salt); + } + /** + * Directions a cell forcibly opens to reach {@link MazeConfig.minConnections} + * passages. The lowest-hash sealed edges are chosen first. Depends only on the + * cell's own four edges, so both sides of any edge agree on the result, which + * keeps passability symmetric. + */ + forcedDirections(cx, cy) { + const min = this.config.minConnections; + if (min <= 0) { + return []; + } + let openCount = 0; + const sealed = []; + for (const direction of DIRECTIONS) { + if (this.baseOpen(cx, cy, direction)) { + openCount++; + } else { + sealed.push({ direction, hash: this.edgeHash(cx, cy, direction) }); + } + } + const need = min - openCount; + if (need <= 0) { + return []; + } + sealed.sort((a, b) => a.hash - b.hash); + return sealed.slice(0, need).map((entry) => entry.direction); + } + /** Coarse, smoothed value-noise field driving room clustering. */ + roomNoise(cx, cy) { + const gx = Math.floor(cx / ROOM_LATTICE); + const gy = Math.floor(cy / ROOM_LATTICE); + const fx = smoothstep(cx / ROOM_LATTICE - gx); + const fy = smoothstep(cy / ROOM_LATTICE - gy); + const corner = (ix, iy) => unitFromHash(hashCoords(this.config.seed, ix, iy, SALT.roomNoise)); + const top = lerp(corner(gx, gy), corner(gx + 1, gy), fx); + const bottom = lerp(corner(gx, gy + 1), corner(gx + 1, gy + 1), fx); + return lerp(top, bottom, fy); + } + /** + * Width and height (in cells) of the hall anchored at `(ax, ay)`, or `null` + * when the anchor does not spawn one. Pure function of the anchor coordinates. + */ + hallSpawn(ax, ay) { + if (this.config.hallFrequency <= 0) { + return null; + } + if (unitFromHash(hashCoords(this.config.seed, ax, ay, SALT.hallAnchor)) >= this.config.hallFrequency) { + return null; + } + const { min, max } = this.config.hallSize; + const span = max - min + 1; + const pick = (salt) => min + Math.min(span - 1, Math.floor(unitFromHash(hashCoords(this.config.seed, ax, ay, salt)) * span)); + return { w: pick(SALT.hallWidth), h: pick(SALT.hallHeight) }; + } + /** + * Canonical identifier of the hall covering a cell, or `null` when the cell is + * not in a hall. Only anchors within {@link MazeConfig.hallSize}`.max` of the + * cell can possibly cover it, so the scan is bounded and stateless. When + * rectangles overlap, the lowest-hash anchor wins, so both sides of any edge + * agree on hall membership. + */ + hallIdOf(cx, cy) { + const maxSide = this.config.hallSize.max; + let best = null; + for (let ay = cy - (maxSide - 1); ay <= cy; ay++) { + for (let ax = cx - (maxSide - 1); ax <= cx; ax++) { + const spawn = this.hallSpawn(ax, ay); + if (!spawn) { + continue; + } + if (cx >= ax && cx < ax + spawn.w && cy >= ay && cy < ay + spawn.h) { + const hash = hashCoords(this.config.seed, ax, ay, SALT.hallAnchor); + if (best === null || hash < best.hash) { + best = { ax, ay, hash }; + } + } + } + } + return best ? `${best.ax},${best.ay}` : null; + } + /** Largest possible atrium side length in cells, bounding the anchor scan. */ + atriumMaxSide() { + return Math.max(2, Math.round(this.config.hallSize.max * ATRIUM_SIZE_FACTOR * (1 + ATRIUM_SIZE_VARIANCE))); + } + /** + * Width and height (in cells) of the atrium anchored at `(ax, ay)`, or `null` + * when the anchor does not spawn one. Each side is drawn like a hall side and + * then scaled up by {@link ATRIUM_SIZE_FACTOR} with a per-side + * {@link ATRIUM_SIZE_VARIANCE}, so atriums run a little larger than halls. + */ + atriumSpawn(ax, ay) { + if (this.config.atriumFrequency <= 0) { + return null; + } + if (unitFromHash(hashCoords(this.config.seed, ax, ay, SALT.atriumAnchor)) >= this.config.atriumFrequency) { + return null; + } + const { min, max } = this.config.hallSize; + const span = max - min + 1; + const side = (sizeSalt, varianceSalt) => { + const baseCells = min + Math.min(span - 1, Math.floor(unitFromHash(hashCoords(this.config.seed, ax, ay, sizeSalt)) * span)); + const variance = (unitFromHash(hashCoords(this.config.seed, ax, ay, varianceSalt)) * 2 - 1) * ATRIUM_SIZE_VARIANCE; + return Math.max(2, Math.round(baseCells * ATRIUM_SIZE_FACTOR * (1 + variance))); + }; + return { + w: side(SALT.atriumWidth, SALT.atriumVarWidth), + h: side(SALT.atriumHeight, SALT.atriumVarHeight) + }; + } + /** + * Canonical identifier of the atrium covering a cell, or `null`. Mirrors + * {@link hallIdOf}: bounded, stateless, and lowest-hash-anchor canonical so + * both sides of any edge agree on membership. + */ + atriumIdOf(cx, cy) { + const maxSide = this.atriumMaxSide(); + let best = null; + for (let ay = cy - (maxSide - 1); ay <= cy; ay++) { + for (let ax = cx - (maxSide - 1); ax <= cx; ax++) { + const spawn = this.atriumSpawn(ax, ay); + if (!spawn) { + continue; + } + if (cx >= ax && cx < ax + spawn.w && cy >= ay && cy < ay + spawn.h) { + const hash = hashCoords(this.config.seed, ax, ay, SALT.atriumAnchor); + if (best === null || hash < best.hash) { + best = { ax, ay, hash }; + } + } + } + } + return best ? `${best.ax},${best.ay}` : null; + } + /** Resolves the open-character classification of a cell. */ + classify(cx, cy) { + if (this.atriumIdOf(cx, cy) !== null) { + return "atrium"; + } + if (this.hallIdOf(cx, cy) !== null) { + return "hall"; + } + if (this.roomNoise(cx, cy) < this.config.roomFrequency) { + return "room"; + } + return "corridor"; + } + /** Footprint and extrusion dimensions for a cell. */ + dimensions(cx, cy, kind) { + const { width, height, depth } = this.config; + const draw = (salt) => unitFromHash(hashCoords(this.config.seed, cx, cy, salt)); + if (kind === "atrium") { + return { width: width.max, height: height.max, depth: depth.max }; + } + if (kind === "hall") { + return { + width: width.max, + height: height.max, + depth: lerp((depth.min + depth.max) / 2, depth.max, draw(SALT.depth)) + }; + } + if (kind === "room") { + return { + width: lerp(width.min, width.max, draw(SALT.width)), + height: lerp(height.min, height.max, draw(SALT.height)), + depth: lerp(depth.min, depth.max, draw(SALT.depth)) + }; + } + return { + width: width.max, + height: height.max, + depth: lerp(depth.min, depth.max, draw(SALT.depth)) + }; + } + /** + * A deterministic interior prop for an open cell, or `null`. Props occur only + * in rooms and halls and are gated by {@link MazeConfig.propFrequency}. + */ + feature(cx, cy, kind) { + if (kind === "corridor" || kind === "atrium" || this.config.propFrequency <= 0) { + return null; + } + if (unitFromHash(hashCoords(this.config.seed, cx, cy, SALT.prop)) >= this.config.propFrequency) { + return null; + } + const size = this.config.cellSize; + const draw = (salt) => unitFromHash(hashCoords(this.config.seed, cx, cy, salt)); + const featureKind = FEATURE_KINDS[Math.min(FEATURE_KINDS.length - 1, Math.floor(draw(SALT.propKind) * FEATURE_KINDS.length))] ?? FEATURE_KINDS[0]; + const fx = 0.3 + 0.4 * draw(SALT.propPositionX); + const fy = 0.3 + 0.4 * draw(SALT.propPositionY); + return { + kind: featureKind, + position: { x: cx * size + fx * size, y: cy * size + fy * size }, + size: lerp(0.08, 0.22, draw(SALT.propSize)) * size, + variantSeed: hashCoords(this.config.seed, cx, cy, SALT.propVariant) + }; + } + /** Builds the full cell from its structural inputs. */ + computeCell(cx, cy) { + const kind = this.classify(cx, cy); + const size = this.config.cellSize; + const x0 = cx * size; + const y0 = cy * size; + const x1 = x0 + size; + const y1 = y0 + size; + const corners = { + north: { start: { x: x0, y: y0 }, end: { x: x1, y: y0 } }, + south: { start: { x: x0, y: y1 }, end: { x: x1, y: y1 } }, + west: { start: { x: x0, y: y0 }, end: { x: x0, y: y1 } }, + east: { start: { x: x1, y: y0 }, end: { x: x1, y: y1 } } + }; + const edges = {}; + for (const direction of DIRECTIONS) { + const solid = !this.isPassable(cx, cy, direction); + edges[direction] = { + direction, + solid, + start: corners[direction].start, + end: corners[direction].end, + metadata: this.edgeMetadata(cx, cy, direction, kind) + }; + } + return { + cx, + cy, + kind, + bounds: { min: { x: x0, y: y0 }, max: { x: x1, y: y1 } }, + dimensions: this.dimensions(cx, cy, kind), + edges, + feature: this.feature(cx, cy, kind) + }; + } + // --- Cosmetic metadata (pluggable RNG) ----------------------------------- + /** + * Builds per-edge extrusion metadata. The material seed is a structural hash + * (stable across RNG swaps), but every cosmetic value is drawn from the + * configured RNG factory, so changing the factory changes only this output. + */ + edgeMetadata(cx, cy, direction, classification) { + const { ex, ey, salt } = edgeKey(cx, cy, direction); + const materialSeed = hashCoords(this.config.seed, ex, ey, salt + SALT.material); + const rng = this.config.rng(materialSeed); + const { depth } = this.config; + const heightBase = lerp(depth.min, depth.max, rng.next()); + const heightVariance = lerp(0, (depth.max - depth.min) * 0.5, rng.next()); + const thickness = lerp(0.05, 0.2, rng.next()); + const surfaceType = SURFACE_TYPES[Math.min(SURFACE_TYPES.length - 1, Math.floor(rng.next() * SURFACE_TYPES.length))] ?? SURFACE_TYPES[0]; + return { + heightBase, + heightVariance, + thickness, + surfaceType, + materialSeed, + classification + }; + } + /** Evicts least-recently-used cells when the cache exceeds its limit. */ + evictIfNeeded() { + const limit = this.config.cacheLimit; + if (limit <= 0) { + return; + } + while (this.cache.size > limit) { + const oldest = this.cache.keys().next().value; + if (oldest === void 0) { + break; + } + this.cache.delete(oldest); + this.evictions++; + } + } + }; + + // ../cmd-backedges/src/events.ts + var EventEmitter = class { + constructor() { + this.listeners = /* @__PURE__ */ new Map(); + } + /** + * Registers a listener for an event. + * + * @returns A function that unregisters the listener. + */ + on(event, listener) { + let set = this.listeners.get(event); + if (!set) { + set = /* @__PURE__ */ new Set(); + this.listeners.set(event, set); + } + set.add(listener); + return () => this.off(event, listener); + } + /** Unregisters a previously registered listener. */ + off(event, listener) { + this.listeners.get(event)?.delete(listener); + } + /** Emits an event to all current listeners. */ + emit(event, payload) { + const set = this.listeners.get(event); + if (!set) { + return; + } + for (const listener of [...set]) { + listener(payload); + } + } + /** Removes all listeners, or all listeners for a single event. */ + clear(event) { + if (event === void 0) { + this.listeners.clear(); + } else { + this.listeners.delete(event); + } + } + }; + + // ../cmd-backedges/src/player.ts + var MazeSession = class { + /** + * @param generator - The maze to traverse. + * @param start - Optional starting cell (defaults to the origin). + */ + constructor(generator, start = {}) { + this.emitter = new EventEmitter(); + this.visited = /* @__PURE__ */ new Set(); + this.generator = generator; + this.state = { + cx: start.cx ?? 0, + cy: start.cy ?? 0, + facing: "south" + }; + this.markVisited(this.state.cx, this.state.cy); + } + /** Returns a copy of the current player state. */ + get player() { + return { ...this.state }; + } + /** The cell the player currently occupies. */ + get currentCell() { + return this.generator.getCell(this.state.cx, this.state.cy); + } + /** + * Attempts to move the player one cell in a direction. + * + * Always updates {@link PlayerState.facing}. On success the player advances + * and a `move` event (plus an `enterCell` event for first visits) fires; on + * failure a `blocked` event fires and the position is unchanged. + * + * @returns `true` if the player moved, `false` if blocked by a wall. + */ + move(direction) { + this.state.facing = direction; + const { cx, cy } = this.state; + if (!this.generator.isPassable(cx, cy, direction)) { + this.emitter.emit("blocked", { at: { cx, cy }, direction }); + return false; + } + const { dx, dy } = step(direction); + const to = { cx: cx + dx, cy: cy + dy }; + this.state.cx = to.cx; + this.state.cy = to.cy; + const cell = this.generator.getCell(to.cx, to.cy); + this.emitter.emit("move", { from: { cx, cy }, to, direction, cell }); + if (this.markVisited(to.cx, to.cy)) { + this.emitter.emit("enterCell", cell); + } + return true; + } + /** Teleports the player to an arbitrary cell, bypassing wall checks. */ + warpTo(cx, cy) { + this.state.cx = cx; + this.state.cy = cy; + const cell = this.generator.getCell(cx, cy); + if (this.markVisited(cx, cy)) { + this.emitter.emit("enterCell", cell); + } + } + /** Registers a listener for a session event; returns an unsubscribe fn. */ + on(event, listener) { + return this.emitter.on(event, listener); + } + /** Records a visit; returns `true` only on the first visit to the cell. */ + markVisited(cx, cy) { + const key = `${cx},${cy}`; + if (this.visited.has(key)) { + return false; + } + this.visited.add(key); + return true; + } + }; + + // src/webview/textures.ts + var ATLAS_GRID = 4; + var TILE_PX = 256; + var TILE = { + wallpaperA: 0, + wallpaperB: 1, + ceiling: 2, + lightPanel: 3, + carpet: 4, + concrete: 5, + paneling: 6, + drywall: 7, + ceramic: 8, + fabric: 9, + metal: 10, + wood: 11, + cardboard: 12 + }; + function applyMaterialImages(canvas, images) { + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + const blit = (tile, image, after) => { + const x = tile % ATLAS_GRID * TILE_PX; + const y = Math.floor(tile / ATLAS_GRID) * TILE_PX; + ctx.save(); + ctx.translate(x, y); + ctx.beginPath(); + ctx.rect(0, 0, TILE_PX, TILE_PX); + ctx.clip(); + ctx.drawImage(image, 0, 0, TILE_PX, TILE_PX); + after?.(ctx); + ctx.restore(); + }; + if (images.wallpaper) { + blit(TILE.wallpaperA, images.wallpaper); + blit(TILE.wallpaperB, images.wallpaper, (c) => { + c.fillStyle = "rgba(96, 78, 30, 0.28)"; + c.fillRect(0, 0, TILE_PX, TILE_PX); + }); + } + if (images.ceiling) { + blit(TILE.ceiling, images.ceiling, (c) => { + c.strokeStyle = "rgba(140, 134, 116, 0.85)"; + c.lineWidth = 3; + for (let p = 0; p <= TILE_PX; p += TILE_PX / 2) { + c.beginPath(); + c.moveTo(p, 0); + c.lineTo(p, TILE_PX); + c.moveTo(0, p); + c.lineTo(TILE_PX, p); + c.stroke(); + } + }); + } + if (images.carpet) { + blit(TILE.carpet, images.carpet); + } + } + function buildAtlas() { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = ATLAS_GRID * TILE_PX; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("2D canvas context unavailable; cannot build texture atlas"); + } + const painters = { + [TILE.wallpaperA]: paintWallpaperA, + [TILE.wallpaperB]: paintWallpaperB, + [TILE.ceiling]: paintCeiling, + [TILE.lightPanel]: paintLightPanel, + [TILE.carpet]: paintCarpet, + [TILE.concrete]: paintConcrete, + [TILE.paneling]: paintPaneling, + [TILE.drywall]: paintDrywall, + [TILE.ceramic]: paintCeramic, + [TILE.fabric]: paintFabric, + [TILE.metal]: paintMetal, + [TILE.wood]: paintWood, + [TILE.cardboard]: paintCardboard + }; + for (const [index, paint] of Object.entries(painters)) { + const i = Number(index); + const x = i % ATLAS_GRID * TILE_PX; + const y = Math.floor(i / ATLAS_GRID) * TILE_PX; + ctx.save(); + ctx.translate(x, y); + ctx.beginPath(); + ctx.rect(0, 0, TILE_PX, TILE_PX); + ctx.clip(); + paint(ctx); + ctx.restore(); + } + return canvas; + } + function grain(ctx, seed, amount) { + const image = ctx.getImageData(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(seed); + const data = image.data; + for (let i = 0; i < data.length; i += 4) { + const n = (rng.next() * 2 - 1) * amount; + data[i] = clampByte(data[i] + n); + data[i + 1] = clampByte(data[i + 1] + n); + data[i + 2] = clampByte(data[i + 2] + n); + } + const off = document.createElement("canvas"); + off.width = off.height = TILE_PX; + off.getContext("2d").putImageData(image, 0, 0); + ctx.drawImage(off, 0, 0); + } + function clampByte(v) { + return v < 0 ? 0 : v > 255 ? 255 : v; + } + function stains(ctx, seed, color, count) { + const rng = mulberry32(seed); + ctx.fillStyle = color; + for (let i = 0; i < count; i++) { + const cx = rng.next() * TILE_PX; + const cy = rng.next() * TILE_PX; + const r = 12 + rng.next() * 46; + ctx.globalAlpha = 0.04 + rng.next() * 0.07; + ctx.beginPath(); + ctx.ellipse(cx, cy, r, r * (0.5 + rng.next() * 0.8), rng.next() * Math.PI, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + } + function paintWallpaperA(ctx) { + ctx.fillStyle = "#c9b765"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.fillStyle = "#b7a352"; + for (let x = 0; x < TILE_PX; x += 32) { + ctx.fillRect(x, 0, 14, TILE_PX); + } + ctx.fillStyle = "#a3914a"; + for (let x = 0; x < TILE_PX; x += 32) { + ctx.fillRect(x + 13, 0, 2, TILE_PX); + } + stains(ctx, 11, "#6f5f2c", 9); + grain(ctx, 12, 7); + } + function paintWallpaperB(ctx) { + ctx.fillStyle = "#c4b268"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.fillStyle = "#ab984f"; + for (let y = 0; y < TILE_PX; y += 32) { + for (let x = 0; x < TILE_PX; x += 32) { + const ox = Math.floor(y / 32) % 2 * 16; + diamond(ctx, x + ox + 16, y + 16, 7); + } + } + stains(ctx, 21, "#6f5f2c", 7); + grain(ctx, 22, 6); + } + function diamond(ctx, cx, cy, r) { + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx + r, cy); + ctx.lineTo(cx, cy + r); + ctx.lineTo(cx - r, cy); + ctx.closePath(); + ctx.fill(); + } + function paintCeiling(ctx) { + ctx.fillStyle = "#d8d3c2"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(31); + ctx.fillStyle = "#b9b4a1"; + for (let i = 0; i < 2600; i++) { + ctx.fillRect(rng.next() * TILE_PX, rng.next() * TILE_PX, 1.5, 1.5); + } + ctx.strokeStyle = "#a09a87"; + ctx.lineWidth = 3; + for (let p = 0; p <= TILE_PX; p += 128) { + ctx.beginPath(); + ctx.moveTo(p, 0); + ctx.lineTo(p, TILE_PX); + ctx.moveTo(0, p); + ctx.lineTo(TILE_PX, p); + ctx.stroke(); + } + stains(ctx, 32, "#7c7452", 5); + grain(ctx, 33, 5); + } + function paintLightPanel(ctx) { + const g = ctx.createRadialGradient(128, 128, 20, 128, 128, 190); + g.addColorStop(0, "#fefadd"); + g.addColorStop(0.7, "#f8eeb4"); + g.addColorStop(1, "#e4d78d"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.strokeStyle = "rgba(190, 176, 110, 0.35)"; + ctx.lineWidth = 2; + for (let p = 0; p <= TILE_PX; p += 32) { + ctx.beginPath(); + ctx.moveTo(p, 0); + ctx.lineTo(p, TILE_PX); + ctx.moveTo(0, p); + ctx.lineTo(TILE_PX, p); + ctx.stroke(); + } + grain(ctx, 41, 3); + } + function paintCarpet(ctx) { + ctx.fillStyle = "#c2b47a"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(51); + for (let i = 0; i < 5200; i++) { + const x = rng.next() * TILE_PX; + const y = rng.next() * TILE_PX; + const shade = 150 + Math.floor(rng.next() * 60); + ctx.strokeStyle = `rgb(${shade}, ${shade - 14}, ${Math.floor(shade * 0.62)})`; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + (rng.next() * 4 - 2), y + (rng.next() * 4 - 2)); + ctx.stroke(); + } + stains(ctx, 52, "#5d5228", 8); + grain(ctx, 53, 6); + } + function paintConcrete(ctx) { + ctx.fillStyle = "#9a958a"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(61); + ctx.strokeStyle = "rgba(70, 66, 58, 0.5)"; + ctx.lineWidth = 1; + for (let i = 0; i < 5; i++) { + let x = rng.next() * TILE_PX; + let y = rng.next() * TILE_PX; + ctx.beginPath(); + ctx.moveTo(x, y); + for (let s = 0; s < 6; s++) { + x += rng.next() * 40 - 20; + y += rng.next() * 40 - 10; + ctx.lineTo(x, y); + } + ctx.stroke(); + } + stains(ctx, 62, "#4c483e", 6); + grain(ctx, 63, 10); + } + function paintPaneling(ctx) { + ctx.fillStyle = "#a8905e"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(71); + for (let x = 0; x < TILE_PX; x += 42) { + ctx.fillStyle = "#7d6741"; + ctx.fillRect(x, 0, 3, TILE_PX); + for (let i = 0; i < 22; i++) { + ctx.strokeStyle = `rgba(110, 88, 52, ${0.15 + rng.next() * 0.2})`; + const gx = x + 5 + rng.next() * 34; + ctx.beginPath(); + ctx.moveTo(gx, 0); + ctx.bezierCurveTo(gx + 4, 80, gx - 4, 170, gx + 2, TILE_PX); + ctx.stroke(); + } + } + grain(ctx, 72, 6); + } + function paintDrywall(ctx) { + ctx.fillStyle = "#cfc7ad"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + stains(ctx, 81, "#8d8465", 6); + grain(ctx, 82, 6); + } + function paintCeramic(ctx) { + ctx.fillStyle = "#b8b2a0"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.fillStyle = "#d7d2c2"; + for (let y = 0; y < TILE_PX; y += 64) { + for (let x = 0; x < TILE_PX; x += 64) { + ctx.fillRect(x + 3, y + 3, 58, 58); + } + } + stains(ctx, 91, "#6d6752", 5); + grain(ctx, 92, 5); + } + function paintFabric(ctx) { + ctx.fillStyle = "#7a6f52"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + grain(ctx, 101, 12); + } + function paintMetal(ctx) { + ctx.fillStyle = "#8e9296"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(111); + for (let x = 0; x < TILE_PX; x += 2) { + ctx.fillStyle = `rgba(255, 255, 255, ${rng.next() * 0.06})`; + ctx.fillRect(x, 0, 1, TILE_PX); + } + grain(ctx, 112, 5); + } + function paintWood(ctx) { + ctx.fillStyle = "#8b6b43"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(121); + for (let i = 0; i < 30; i++) { + ctx.strokeStyle = `rgba(70, 50, 26, ${0.15 + rng.next() * 0.25})`; + const gy = rng.next() * TILE_PX; + ctx.beginPath(); + ctx.moveTo(0, gy); + ctx.bezierCurveTo(80, gy + 6, 170, gy - 6, TILE_PX, gy + 3); + ctx.stroke(); + } + grain(ctx, 122, 6); + } + function paintCardboard(ctx) { + ctx.fillStyle = "#b59a6b"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.fillStyle = "rgba(214, 205, 175, 0.8)"; + ctx.fillRect(0, 108, TILE_PX, 40); + ctx.strokeStyle = "rgba(90, 72, 44, 0.6)"; + ctx.lineWidth = 2; + ctx.strokeRect(6, 6, TILE_PX - 12, TILE_PX - 12); + grain(ctx, 131, 8); + } + + // src/webview/renderer.ts + var FLOATS_PER_VERTEX = 10; + var EMISSIVE_SHADE = 2; + var VERTEX_SRC = ` +attribute vec3 aPosition; +attribute vec2 aUv; +attribute float aTile; +attribute vec3 aTint; +attribute float aShade; + +uniform mat4 uViewProj; +uniform vec3 uCamPos; + +varying vec2 vUv; +varying float vTile; +varying vec3 vTint; +varying float vShade; +varying float vDist; + +void main() { + vUv = aUv; + vTile = aTile; + vTint = aTint; + vShade = aShade; + vDist = distance(aPosition, uCamPos); + gl_Position = uViewProj * vec4(aPosition, 1.0); +} +`; + var FRAGMENT_SRC = ` +precision mediump float; + +uniform sampler2D uAtlas; +uniform vec3 uFogColor; +uniform float uFogDensity; +uniform float uFlicker; + +varying vec2 vUv; +varying float vTile; +varying vec3 vTint; +varying float vShade; +varying float vDist; + +const float GRID = ${ATLAS_GRID.toFixed(1)}; + +void main() { + float tile = floor(vTile + 0.5); + vec2 cell = vec2(mod(tile, GRID), floor(tile / GRID)); + // Half-texel inset keeps repeated tiles from bleeding across atlas seams. + vec2 local = fract(vUv) * (1.0 - 2.0 / 256.0) + 1.0 / 256.0; + vec4 tex = texture2D(uAtlas, (cell + local) / GRID); + + if (vShade >= ${EMISSIVE_SHADE.toFixed(1)} - 0.25) { + // Emissive light panel: flicker, no fog fade. + vec3 lit = tex.rgb * vTint * uFlicker * (vShade - 1.0); + float glowFog = 1.0 - exp(-vDist * vDist * uFogDensity * 0.35); + gl_FragColor = vec4(mix(lit, uFogColor, glowFog), 1.0); + return; + } + + vec3 color = tex.rgb * vTint * vShade * uFlicker; + float fog = 1.0 - exp(-vDist * vDist * uFogDensity); + gl_FragColor = vec4(mix(color, uFogColor, fog), 1.0); +} +`; + var FLOATS_PER_DECAL_VERTEX = 6; + var DECAL_VERTEX_SRC = ` +attribute vec3 aPosition; +attribute vec2 aUv; +attribute float aShade; + +uniform mat4 uViewProj; +uniform vec3 uCamPos; + +varying vec2 vUv; +varying float vShade; +varying float vDist; + +void main() { + vUv = aUv; + vShade = aShade; + vDist = distance(aPosition, uCamPos); + gl_Position = uViewProj * vec4(aPosition, 1.0); +} +`; + var DECAL_FRAGMENT_SRC = ` +precision mediump float; + +uniform sampler2D uTexture; +uniform float uFogDensity; +uniform float uFlicker; + +varying vec2 vUv; +varying float vShade; +varying float vDist; + +void main() { + vec4 tex = texture2D(uTexture, vUv); + float fog = 1.0 - exp(-vDist * vDist * uFogDensity); + // Ink dims with the wall lighting and dissolves into the fog. + gl_FragColor = vec4(tex.rgb * vShade * uFlicker, tex.a * (1.0 - fog)); +} +`; + var Renderer = class { + gl; + program; + uniforms; + attribs; + canvas; + fogColor = [0.055, 0.048, 0.02]; + fogDensity = 0.012; + // One rewritable mesh for animated geometry (the monster), rebuilt per frame. + dynVbo = null; + dynIbo = null; + dynCount = 0; + atlasCanvas; + atlasTexture; + // Wall-writing decals: separate program, texture, and mesh, rebuilt only + // when a new line is scrawled. + decalProgram; + decalUniforms; + decalAttribs; + decalTexture = null; + decalVbo = null; + decalIbo = null; + decalCount = 0; + constructor(canvas) { + this.canvas = canvas; + const gl = canvas.getContext("webgl", { antialias: true, alpha: false }); + if (!gl) { + throw new Error("WebGL is not available in this webview"); + } + this.gl = gl; + this.program = buildProgram(gl, VERTEX_SRC, FRAGMENT_SRC); + this.attribs = { + aPosition: gl.getAttribLocation(this.program, "aPosition"), + aUv: gl.getAttribLocation(this.program, "aUv"), + aTile: gl.getAttribLocation(this.program, "aTile"), + aTint: gl.getAttribLocation(this.program, "aTint"), + aShade: gl.getAttribLocation(this.program, "aShade") + }; + this.uniforms = {}; + for (const name of ["uViewProj", "uCamPos", "uAtlas", "uFogColor", "uFogDensity", "uFlicker"]) { + this.uniforms[name] = gl.getUniformLocation(this.program, name); + } + this.decalProgram = buildProgram(gl, DECAL_VERTEX_SRC, DECAL_FRAGMENT_SRC); + this.decalAttribs = { + aPosition: gl.getAttribLocation(this.decalProgram, "aPosition"), + aUv: gl.getAttribLocation(this.decalProgram, "aUv"), + aShade: gl.getAttribLocation(this.decalProgram, "aShade") + }; + this.decalUniforms = {}; + for (const name of ["uViewProj", "uCamPos", "uTexture", "uFogDensity", "uFlicker"]) { + this.decalUniforms[name] = gl.getUniformLocation(this.decalProgram, name); + } + this.atlasCanvas = buildAtlas(); + this.atlasTexture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.atlasCanvas); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.enable(gl.DEPTH_TEST); + gl.enable(gl.CULL_FACE); + gl.cullFace(gl.BACK); + } + /** Patches photo materials over the procedural atlas and re-uploads it. */ + applyMaterialImages(images) { + applyMaterialImages(this.atlasCanvas, images); + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.atlasCanvas); + } + uploadChunk(vertices, indices) { + const gl = this.gl; + const vbo = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, vbo); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); + const ibo = gl.createBuffer(); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW); + return { vbo, ibo, indexCount: indices.length }; + } + disposeChunk(mesh) { + this.gl.deleteBuffer(mesh.vbo); + this.gl.deleteBuffer(mesh.ibo); + } + /** Replaces the dynamic mesh drawn after the chunks this frame. */ + setDynamicMesh(vertices, indices) { + const gl = this.gl; + this.dynVbo ??= gl.createBuffer(); + this.dynIbo ??= gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.dynVbo); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.DYNAMIC_DRAW); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.dynIbo); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.DYNAMIC_DRAW); + this.dynCount = indices.length; + } + clearDynamicMesh() { + this.dynCount = 0; + } + /** + * Uploads (or re-uploads) the wall-writing texture from a canvas. + * Uses texture unit 1 to avoid disturbing the wall atlas on unit 0. + */ + setDecalTexture(source) { + const gl = this.gl; + this.decalTexture ??= gl.createTexture(); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.decalTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.activeTexture(gl.TEXTURE0); + } + /** + * Replaces the decal mesh (vertex layout: position(3), uv(2), shade(1)). + * The decal pass draws wall writings as alpha-blended quads over the walls. + */ + setDecalMesh(vertices, indices) { + const gl = this.gl; + this.decalVbo ??= gl.createBuffer(); + this.decalIbo ??= gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.decalVbo); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.DYNAMIC_DRAW); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.decalIbo); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.DYNAMIC_DRAW); + this.decalCount = indices.length; + } + clearDecalMesh() { + this.decalCount = 0; + } + resize() { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const w = Math.floor(this.canvas.clientWidth * dpr); + const h = Math.floor(this.canvas.clientHeight * dpr); + if (w > 0 && h > 0 && (this.canvas.width !== w || this.canvas.height !== h)) { + this.canvas.width = w; + this.canvas.height = h; + } + } + draw(chunks, camera, flicker) { + const gl = this.gl; + this.resize(); + gl.viewport(0, 0, this.canvas.width, this.canvas.height); + gl.clearColor(this.fogColor[0], this.fogColor[1], this.fogColor[2], 1); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + gl.useProgram(this.program); + const aspect = this.canvas.width / Math.max(1, this.canvas.height); + const viewProj = mat4Multiply( + mat4Perspective(camera.fovY, aspect, 0.02, 80), + mat4View(camera) + ); + gl.uniformMatrix4fv(this.uniforms.uViewProj, false, viewProj); + gl.uniform3f(this.uniforms.uCamPos, camera.x, camera.y, camera.z); + gl.uniform3f(this.uniforms.uFogColor, this.fogColor[0], this.fogColor[1], this.fogColor[2]); + gl.uniform1f(this.uniforms.uFogDensity, this.fogDensity); + gl.uniform1f(this.uniforms.uFlicker, flicker); + gl.uniform1i(this.uniforms.uAtlas, 0); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); + for (const mesh of chunks) { + this.drawMesh(mesh.vbo, mesh.ibo, mesh.indexCount); + } + if (this.dynCount > 0 && this.dynVbo && this.dynIbo) { + this.drawMesh(this.dynVbo, this.dynIbo, this.dynCount); + } + this.drawDecals(viewProj, camera, flicker); + } + /** Alpha-blended wall-writing pass, drawn over the opaque geometry. */ + drawDecals(viewProj, camera, flicker) { + if (this.decalCount === 0 || !this.decalVbo || !this.decalIbo || !this.decalTexture) { + return; + } + const gl = this.gl; + gl.useProgram(this.decalProgram); + gl.uniformMatrix4fv(this.decalUniforms.uViewProj, false, viewProj); + gl.uniform3f(this.decalUniforms.uCamPos, camera.x, camera.y, camera.z); + gl.uniform1f(this.decalUniforms.uFogDensity, this.fogDensity); + gl.uniform1f(this.decalUniforms.uFlicker, flicker); + gl.uniform1i(this.decalUniforms.uTexture, 1); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.decalTexture); + gl.activeTexture(gl.TEXTURE0); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.depthMask(false); + for (const a of Object.values(this.attribs)) { + gl.disableVertexAttribArray(a); + } + const stride = FLOATS_PER_DECAL_VERTEX * 4; + gl.bindBuffer(gl.ARRAY_BUFFER, this.decalVbo); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.decalIbo); + gl.vertexAttribPointer(this.decalAttribs.aPosition, 3, gl.FLOAT, false, stride, 0); + gl.vertexAttribPointer(this.decalAttribs.aUv, 2, gl.FLOAT, false, stride, 12); + gl.vertexAttribPointer(this.decalAttribs.aShade, 1, gl.FLOAT, false, stride, 20); + for (const a of Object.values(this.decalAttribs)) { + gl.enableVertexAttribArray(a); + } + gl.drawElements(gl.TRIANGLES, this.decalCount, gl.UNSIGNED_SHORT, 0); + for (const a of Object.values(this.decalAttribs)) { + gl.disableVertexAttribArray(a); + } + gl.depthMask(true); + gl.disable(gl.BLEND); + } + drawMesh(vbo, ibo, indexCount) { + const gl = this.gl; + const stride = FLOATS_PER_VERTEX * 4; + gl.bindBuffer(gl.ARRAY_BUFFER, vbo); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); + gl.vertexAttribPointer(this.attribs.aPosition, 3, gl.FLOAT, false, stride, 0); + gl.vertexAttribPointer(this.attribs.aUv, 2, gl.FLOAT, false, stride, 12); + gl.vertexAttribPointer(this.attribs.aTile, 1, gl.FLOAT, false, stride, 20); + gl.vertexAttribPointer(this.attribs.aTint, 3, gl.FLOAT, false, stride, 24); + gl.vertexAttribPointer(this.attribs.aShade, 1, gl.FLOAT, false, stride, 36); + for (const a of Object.values(this.attribs)) { + gl.enableVertexAttribArray(a); + } + gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0); + } + }; + function buildProgram(gl, vsSrc, fsSrc) { + const compile = (type, src) => { + const shader = gl.createShader(type); + gl.shaderSource(shader, src); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + throw new Error(`Shader compile failed: ${gl.getShaderInfoLog(shader) ?? "unknown"}`); + } + return shader; + }; + const program = gl.createProgram(); + gl.attachShader(program, compile(gl.VERTEX_SHADER, vsSrc)); + gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fsSrc)); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error(`Program link failed: ${gl.getProgramInfoLog(program) ?? "unknown"}`); + } + return program; + } + function mat4Perspective(fovY, aspect, near, far) { + const f = 1 / Math.tan(fovY / 2); + const nf = 1 / (near - far); + const out = new Float32Array(16); + out[0] = f / aspect; + out[5] = f; + out[10] = (far + near) * nf; + out[11] = -1; + out[14] = 2 * far * near * nf; + return out; + } + function mat4View(camera) { + const rot = mat4Multiply( + mat4RotateZ(-camera.roll), + mat4Multiply(mat4RotateX(-camera.pitch), mat4RotateY(-camera.yaw)) + ); + const trans = mat4Identity(); + trans[12] = -camera.x; + trans[13] = -camera.y; + trans[14] = -camera.z; + return mat4Multiply(rot, trans); + } + function mat4Identity() { + const out = new Float32Array(16); + out[0] = out[5] = out[10] = out[15] = 1; + return out; + } + function mat4RotateX(rad) { + const out = mat4Identity(); + const c = Math.cos(rad); + const s = Math.sin(rad); + out[5] = c; + out[6] = s; + out[9] = -s; + out[10] = c; + return out; + } + function mat4RotateY(rad) { + const out = mat4Identity(); + const c = Math.cos(rad); + const s = Math.sin(rad); + out[0] = c; + out[2] = -s; + out[8] = s; + out[10] = c; + return out; + } + function mat4RotateZ(rad) { + const out = mat4Identity(); + const c = Math.cos(rad); + const s = Math.sin(rad); + out[0] = c; + out[1] = s; + out[4] = -s; + out[5] = c; + return out; + } + function mat4Multiply(a, b) { + const out = new Float32Array(16); + for (let col = 0; col < 4; col++) { + for (let row = 0; row < 4; row++) { + let sum = 0; + for (let k = 0; k < 4; k++) { + sum += a[k * 4 + row] * b[col * 4 + k]; + } + out[col * 4 + row] = sum; + } + } + return out; + } + + // src/webview/world.ts + var DEPTH_RANGE = { min: 1.1, max: 1.6 }; + var PLAYER_RADIUS = 0.24; + var WALL_HALF_DEPTH = 0.06; + var DOOR_TOP_FRACTION = 0.84; + var CHUNK_SIZE = 4; + var LIGHT_LATTICE = 3; + var ZONE_LATTICE = 12; + var SALT2 = { + lightState: 7001, + zoneAnchor: 7101, + zoneCenterX: 7102, + zoneCenterY: 7103, + zoneRadius: 7104, + zonePhase1: 7105, + zonePhase2: 7106, + zonePalette: 7107, + zoneVariant: 7108 + }; + var WALL_TILE_BY_SURFACE = { + drywall: TILE.drywall, + wallpaper: TILE.wallpaperA, + paneling: TILE.paneling, + concrete: TILE.concrete, + tile: TILE.ceramic, + carpet: TILE.carpet + }; + var MATERIAL_PRESETS = { + classic: { tile: null, tint: [1, 1, 1] }, + office: { tile: TILE.drywall, tint: [1, 1, 1] }, + pool: { tile: TILE.ceramic, tint: [0.88, 1, 1.06] }, + concrete: { tile: TILE.concrete, tint: [1, 1, 1] }, + panel: { tile: TILE.paneling, tint: [1, 1, 1] } + }; + var ZONE_PALETTES = [ + [1.1, 0.6, 0.52], + // faded rose + [0.58, 0.95, 0.38], + // mossy green + [0.5, 0.72, 1.1], + // dusty blue + [0.72, 0.5, 0.26], + // deep sepia + [1.35, 1.32, 1.12] + // bleached bone + ]; + var WHITE = [1, 1, 1]; + var World = class { + generator; + session; + seed; + furnitureEnabled = true; + // Off by default, matching DEFAULT_SETTINGS.wallpaperShifts. + wallpaperShiftsEnabled = false; + /** + * When a photo wallpaper is loaded, the classic preset papers every wall + * with it instead of the generator's mixed surface types; per-edge wear and + * wallpaper zones still provide the variation. + */ + uniformWallpaper = false; + wallOverrideTile = null; + wallBaseTint = [1, 1, 1]; + cellsVisited = 1; + zoneCache = /* @__PURE__ */ new Map(); + chunks = /* @__PURE__ */ new Map(); + constructor(seed) { + this.seed = seed; + this.generator = new MazeGenerator({ + seed, + depth: DEPTH_RANGE, + propFrequency: 0.14 + }); + this.session = new MazeSession(this.generator); + this.session.on("enterCell", () => { + this.cellsVisited++; + }); + } + /** + * Applies a wall material preset plus its adjustable elements (hue rotation + * in degrees and a brightness multiplier). Call invalidateChunks afterwards + * so existing meshes pick the change up. + * + * The preset determines the base tile and tint for all walls. Hue rotation + * lets players shift the wallpaper color, and brightness scales the overall + * material lightness without destroying the wear variation. + */ + setMaterial(preset, hueShiftDeg, brightness) { + const base = MATERIAL_PRESETS[preset] ?? MATERIAL_PRESETS.classic; + this.wallOverrideTile = base.tile; + this.wallBaseTint = hueRotate(base.tint, hueShiftDeg).map( + (v) => Math.max(0, v * brightness) + ); + } + /** Public light sample for decorations drawn outside the chunk mesher. */ + lightAt(x, y) { + return this.lightLevelAt(x, y); + } + stats() { + return { + seed: this.seed, + cellsVisited: this.cellsVisited, + cacheSize: this.generator.stats().size + }; + } + /** + * Keeps the discrete MazeSession in step with the continuous player + * position, so its move/enterCell events stay meaningful. + * + * When the player crosses a cell boundary, this tries to move the session + * in the matching direction. If that fails (shouldn't happen - walls block + * both), it warps the session to match reality. + */ + syncSession(px, py) { + const cx = Math.floor(px); + const cy = Math.floor(py); + const at = this.session.player; + if (at.cx === cx && at.cy === cy) { + return; + } + const dx = cx - at.cx; + const dy = cy - at.cy; + if (Math.abs(dx) + Math.abs(dy) === 1) { + const direction = dx === 1 ? "east" : dx === -1 ? "west" : dy === 1 ? "south" : "north"; + if (!this.session.move(direction)) { + this.session.warpTo(cx, cy); + } + } else { + this.session.warpTo(cx, cy); + } + } + // --- Movement ------------------------------------------------------------ + /** + * Moves the player from (px, py) toward (px+dx, py+dy) in plane coordinates, + * resolving collisions per axis so the player slides along walls. + * + * This implements a sweep-and-clamp collision resolver: each axis is tested + * independently. If the desired position is blocked, the coordinate is + * clamped to the nearest wall surface (within the motion delta to avoid + * teleport-like jumps). This creates smooth wall-sliding behavior common in + * first-person games. + */ + moveResolved(px, py, dx, dy) { + let x = px; + let y = py; + const withinStep = (from, to, v) => v >= Math.min(from, to) - 1e-9 && v <= Math.max(from, to) + 1e-9; + const tryAxis = (nx, ny, axis) => { + if (this.canOccupy(nx, ny)) { + x = nx; + y = ny; + return; + } + if (axis === "x") { + const cx = Math.floor(x); + const clamped = nx > x ? cx + 1 - PLAYER_RADIUS - 1e-4 : cx + PLAYER_RADIUS + 1e-4; + if (withinStep(x, nx, clamped) && this.canOccupy(clamped, ny)) { + x = clamped; + y = ny; + } + } else { + const cy = Math.floor(y); + const clamped = ny > y ? cy + 1 - PLAYER_RADIUS - 1e-4 : cy + PLAYER_RADIUS + 1e-4; + if (withinStep(y, ny, clamped) && this.canOccupy(nx, clamped)) { + x = nx; + y = clamped; + } + } + }; + const steps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy)) / PLAYER_RADIUS)); + for (let i = 0; i < steps; i++) { + tryAxis(x + dx / steps, y, "x"); + tryAxis(x, y + dy / steps, "y"); + } + return { x, y }; + } + /** + * Whether a player disc at (x, y) fits: each corner of its bounding square + * must be reachable from the center cell through open edges only. + * + * This implements circle-vs-grid collision by checking the four corners of + * the circle's bounding box. Each corner cell must be connected to the + * player's center cell via a valid path of open edges (either directly if + * they're adjacent, or through an intermediate cell at a diagonal). + */ + canOccupy(x, y) { + const cx = Math.floor(x); + const cy = Math.floor(y); + for (const ox of [-PLAYER_RADIUS, PLAYER_RADIUS]) { + for (const oy of [-PLAYER_RADIUS, PLAYER_RADIUS]) { + const ccx = Math.floor(x + ox); + const ccy = Math.floor(y + oy); + if (ccx === cx && ccy === cy) { + continue; + } + const dirX = ccx > cx ? "east" : ccx < cx ? "west" : null; + const dirY = ccy > cy ? "south" : ccy < cy ? "north" : null; + if (dirX && !dirY) { + if (!this.generator.isPassable(cx, cy, dirX)) { + return false; + } + } else if (dirY && !dirX) { + if (!this.generator.isPassable(cx, cy, dirY)) { + return false; + } + } else if (dirX && dirY) { + const viaX = this.generator.isPassable(cx, cy, dirX) && this.generator.isPassable(ccx, cy, dirY); + const viaY = this.generator.isPassable(cx, cy, dirY) && this.generator.isPassable(cx, ccy, dirX); + if (!viaX && !viaY) { + return false; + } + } + } + } + return true; + } + // --- Lights ---------------------------------------------------------------- + /** + * Determines light state for a cell: on, dead, or flickering. + * Lights live on a 3-cell lattice (offset to [1,1] within the pattern). + * Returns null for cells that have no light fixture. + * + * Dead lights (12% chance) never illuminate. Flickering lights (8% chance) + * pulse erratically. The rest stay on with the global flicker hum. + */ + lightState(cx, cy) { + const mod = (n, m) => (n % m + m) % m; + if (mod(cx, LIGHT_LATTICE) !== 1 || mod(cy, LIGHT_LATTICE) !== 1) { + return null; + } + const u = unitFromHash(hashCoords(this.seed, cx, cy, SALT2.lightState)); + if (u < 0.12) { + return "dead"; + } + if (u < 0.2) { + return "flicker"; + } + return "on"; + } + /** Summed light contribution at a plane point, in [0, 1]. */ + lightLevelAt(x, y) { + const reach = 3.2; + let level = 0; + const minGx = Math.floor((x - reach) / LIGHT_LATTICE); + const maxGx = Math.floor((x + reach) / LIGHT_LATTICE); + const minGy = Math.floor((y - reach) / LIGHT_LATTICE); + const maxGy = Math.floor((y + reach) / LIGHT_LATTICE); + for (let gy = minGy; gy <= maxGy; gy++) { + for (let gx = minGx; gx <= maxGx; gx++) { + const lcx = gx * LIGHT_LATTICE + 1; + const lcy = gy * LIGHT_LATTICE + 1; + const state = this.lightState(lcx, lcy); + if (state !== "on" && state !== "flicker") { + continue; + } + const dx = x - (lcx + 0.5); + const dy = y - (lcy + 0.5); + const d = Math.sqrt(dx * dx + dy * dy); + if (d < reach) { + const fall = 1 - d / reach; + level += fall * fall * (state === "flicker" ? 0.55 : 1); + } + } + } + return Math.min(1, level); + } + // --- Wallpaper zones ------------------------------------------------------- + /** + * Random enclosed shapes: anchors on a coarse lattice each spawn a wobbled + * closed radial blob (radius modulated by two sine harmonics with hashed + * phases). A cell inside a blob adopts that zone's palette; the innermost + * blob wins where blobs overlap. + */ + zoneAt(cx, cy) { + if (!this.wallpaperShiftsEnabled) { + return null; + } + const key = `${cx},${cy}`; + const cached = this.zoneCache.get(key); + if (cached !== void 0) { + return cached; + } + if (this.zoneCache.size > 2e4) { + this.zoneCache.clear(); + } + const gx0 = Math.floor(cx / ZONE_LATTICE); + const gy0 = Math.floor(cy / ZONE_LATTICE); + let best = null; + for (let gy = gy0 - 1; gy <= gy0 + 1; gy++) { + for (let gx = gx0 - 1; gx <= gx0 + 1; gx++) { + const draw = (salt) => unitFromHash(hashCoords(this.seed, gx, gy, salt)); + if (draw(SALT2.zoneAnchor) >= 0.45) { + continue; + } + const centerX = (gx + draw(SALT2.zoneCenterX)) * ZONE_LATTICE; + const centerY = (gy + draw(SALT2.zoneCenterY)) * ZONE_LATTICE; + const base = 3 + draw(SALT2.zoneRadius) * 5; + const dx = cx + 0.5 - centerX; + const dy = cy + 0.5 - centerY; + const dist = Math.sqrt(dx * dx + dy * dy); + const theta = Math.atan2(dy, dx); + const p1 = draw(SALT2.zonePhase1) * Math.PI * 2; + const p2 = draw(SALT2.zonePhase2) * Math.PI * 2; + const radius = base * (1 + 0.3 * Math.sin(3 * theta + p1) + 0.18 * Math.sin(5 * theta + p2)); + if (dist >= radius) { + continue; + } + const depth = dist / radius; + if (!best || depth < best.depth) { + const palette = ZONE_PALETTES[Math.floor(draw(SALT2.zonePalette) * ZONE_PALETTES.length)] ?? WHITE; + best = { + depth, + zone: { tint: [...palette], variant: draw(SALT2.zoneVariant) < 0.5 } + }; + } + } + } + const zone = best?.zone ?? null; + this.zoneCache.set(key, zone); + return zone; + } + // --- Chunk streaming ------------------------------------------------------- + /** + * Ensures every chunk within the render distance is meshed and uploaded, + * dropping chunks that fell out of range. Returns the drawable set. + */ + updateChunks(px, py, renderDistance, renderer) { + const range = renderDistance + CHUNK_SIZE; + const minCx = Math.floor((px - range) / CHUNK_SIZE); + const maxCx = Math.floor((px + range) / CHUNK_SIZE); + const minCy = Math.floor((py - range) / CHUNK_SIZE); + const maxCy = Math.floor((py + range) / CHUNK_SIZE); + const wanted = /* @__PURE__ */ new Set(); + for (let gy = minCy; gy <= maxCy; gy++) { + for (let gx = minCx; gx <= maxCx; gx++) { + const centerX = (gx + 0.5) * CHUNK_SIZE; + const centerY = (gy + 0.5) * CHUNK_SIZE; + const dist = Math.hypot(centerX - px, centerY - py); + if (dist > renderDistance + CHUNK_SIZE) { + continue; + } + const key = `${gx},${gy}`; + wanted.add(key); + if (!this.chunks.has(key)) { + this.chunks.set(key, this.buildChunk(gx, gy, renderer)); + } + } + } + for (const [key, mesh] of this.chunks) { + if (!wanted.has(key)) { + renderer.disposeChunk(mesh); + this.chunks.delete(key); + } + } + return this.chunks.values(); + } + /** Drops all uploaded chunks (e.g. when toggling furniture or zones). */ + invalidateChunks(renderer) { + for (const mesh of this.chunks.values()) { + renderer.disposeChunk(mesh); + } + this.chunks.clear(); + this.zoneCache.clear(); + } + buildChunk(gx, gy, renderer) { + const builder = new MeshBuilder(); + for (let cy = gy * CHUNK_SIZE; cy < (gy + 1) * CHUNK_SIZE; cy++) { + for (let cx = gx * CHUNK_SIZE; cx < (gx + 1) * CHUNK_SIZE; cx++) { + this.emitCell(builder, this.generator.getCell(cx, cy)); + } + } + return renderer.uploadChunk(builder.vertices(), builder.indices()); + } + emitCell(b, cell) { + const { cx, cy } = cell; + const x0 = cell.bounds.min.x; + const y0 = cell.bounds.min.y; + const x1 = cell.bounds.max.x; + const y1 = cell.bounds.max.y; + const h = cell.dimensions.depth; + const zone = this.zoneAt(cx, cy); + const shadeAt = (x, y, base, span) => base + span * this.lightLevelAt(x, y); + const floorTint = zone ? [mix(1, zone.tint[0], 0.25), mix(1, zone.tint[1], 0.25), mix(1, zone.tint[2], 0.25)] : WHITE; + b.quad( + [x0, 0, y0], + [x0, 0, y1], + [x1, 0, y1], + [x1, 0, y0], + [x0, y0], + [x0, y1], + [x1, y1], + [x1, y0], + TILE.carpet, + floorTint, + [ + shadeAt(x0, y0, 0.5, 0.55), + shadeAt(x0, y1, 0.5, 0.55), + shadeAt(x1, y1, 0.5, 0.55), + shadeAt(x1, y0, 0.5, 0.55) + ] + ); + b.quad( + [x0, h, y0], + [x1, h, y0], + [x1, h, y1], + [x0, h, y1], + [x0 * 2, y0 * 2], + [x1 * 2, y0 * 2], + [x1 * 2, y1 * 2], + [x0 * 2, y1 * 2], + TILE.ceiling, + WHITE, + [ + shadeAt(x0, y0, 0.38, 0.4), + shadeAt(x1, y0, 0.38, 0.4), + shadeAt(x1, y1, 0.38, 0.4), + shadeAt(x0, y1, 0.38, 0.4) + ] + ); + const light = this.lightState(cx, cy); + if (light) { + const inset = 0.24; + const drop = 0.02; + const lx0 = x0 + inset; + const lx1 = x1 - inset; + const ly0 = y0 + inset; + const ly1 = y1 - inset; + const py = h - drop; + const shade = light === "dead" ? 0.32 : light === "flicker" ? EMISSIVE_SHADE + 0.35 : EMISSIVE_SHADE + 0.6; + b.quad( + [lx0, py, ly0], + [lx1, py, ly0], + [lx1, py, ly1], + [lx0, py, ly1], + [0, 0], + [1, 0], + [1, 1], + [0, 1], + TILE.lightPanel, + WHITE, + [shade, shade, shade, shade] + ); + } + for (const direction of DIRECTIONS) { + if (cell.edges[direction].solid) { + this.emitWallSlab(b, cell, direction, zone); + } else { + this.emitDoorHeader(b, cell, direction, zone); + } + } + if (cell.feature && this.furnitureEnabled) { + this.emitFeature(b, cell, cell.feature); + } + } + /** Resolves the tile, tint, and wear factor for one wall edge. */ + wallMaterial(edge, zone) { + let tile; + if (this.wallOverrideTile !== null) { + tile = this.wallOverrideTile; + } else if (this.uniformWallpaper) { + tile = TILE.wallpaperA; + } else { + tile = WALL_TILE_BY_SURFACE[edge.metadata.surfaceType]; + } + let tint = this.wallBaseTint; + if (zone && (this.wallOverrideTile !== null || tile === TILE.wallpaperA)) { + tint = [tint[0] * zone.tint[0], tint[1] * zone.tint[1], tint[2] * zone.tint[2]]; + if (zone.variant && tile === TILE.wallpaperA) { + tile = TILE.wallpaperB; + } + } + const wear = 0.92 + unitFromHash(edge.metadata.materialSeed) * 0.12; + return { tile, tint, wear }; + } + /** + * A solid edge as a slab: the inward face is inset by WALL_HALF_DEPTH (the + * neighbor emits the matching opposite face), and any end where the wall + * line stops at an open passage gets a jamb cap sealing the slab depth. + */ + emitWallSlab(b, cell, direction, zone) { + const h = cell.dimensions.depth; + this.emitWallFace(b, cell, direction, 0, h, zone); + const edge = cell.edges[direction]; + const inward = step(opposite(direction)); + const mat = this.wallMaterial(edge, zone); + for (const end of [edge.start, edge.end]) { + const other = end === edge.start ? edge.end : edge.start; + const ox = Math.sign(end.x - other.x); + const oy = Math.sign(end.y - other.y); + if (this.generator.isPassable(cell.cx + ox, cell.cy + oy, direction)) { + this.emitJamb(b, end, { ox, oy }, inward, 0, h, mat); + } + } + } + /** + * A vertical cap strip sealing this cell's half of a wall slab at a wall + * end, facing out of the wall along `out`. + */ + emitJamb(b, at, out, inward, yBottom, yTop, mat) { + let ax = at.x; + let az = at.y; + let bx = at.x + inward.dx * WALL_HALF_DEPTH; + let bz = at.y + inward.dy * WALL_HALF_DEPTH; + if (-(bz - az) * out.ox + (bx - ax) * out.oy < 0) { + [ax, bx] = [bx, ax]; + [az, bz] = [bz, az]; + } + const shade = (0.42 + 0.5 * this.lightLevelAt(at.x + out.ox * 0.2, at.y + out.oy * 0.2)) * mat.wear * 0.82; + const u0 = ax + az; + const u1 = bx + bz; + b.quad( + [ax, yBottom, az], + [bx, yBottom, bz], + [bx, yTop, bz], + [ax, yTop, az], + [u0, yBottom / 1.6], + [u1, yBottom / 1.6], + [u1, yTop / 1.6], + [u0, yTop / 1.6], + mat.tile, + mat.tint, + [shade, shade, shade * 0.92, shade * 0.92] + ); + } + /** + * Header over an open edge. An opening whose wall line is solid on both + * flanks reads as a doorway punched through a wall, so it gets a lintel: + * face down to DOOR_TOP_FRACTION of the lower ceiling, a soffit underside, + * and end caps. Interior edges of merged open areas (room/hall/atrium + * pairs) only get a soffit band where the neighbor's ceiling steps down. + */ + emitDoorHeader(b, cell, direction, zone) { + const h = cell.dimensions.depth; + const { dx, dy } = step(direction); + const ncx = cell.cx + dx; + const ncy = cell.cy + dy; + const nh = this.generator.getCell(ncx, ncy).dimensions.depth; + const gen = this.generator; + const interior = gen.isRoom(cell.cx, cell.cy) && gen.isRoom(ncx, ncy) || gen.isHall(cell.cx, cell.cy) && gen.isHall(ncx, ncy) || gen.isAtrium(cell.cx, cell.cy) && gen.isAtrium(ncx, ncy); + let bottom = null; + if (!interior) { + const edge2 = cell.edges[direction]; + const flanks = [edge2.start, edge2.end].map((end) => { + const other = end === edge2.start ? edge2.end : edge2.start; + const ox = Math.sign(end.x - other.x); + const oy = Math.sign(end.y - other.y); + return !gen.isPassable(cell.cx + ox, cell.cy + oy, direction); + }); + if (flanks[0] && flanks[1]) { + bottom = Math.min(h, nh) * DOOR_TOP_FRACTION; + } + } + if (bottom === null && nh < h - 0.01) { + bottom = nh; + } + if (bottom === null || bottom >= h - 5e-3) { + return; + } + this.emitWallFace(b, cell, direction, bottom, h, zone); + const edge = cell.edges[direction]; + const inward = step(opposite(direction)); + const mat = this.wallMaterial(edge, zone); + const T = WALL_HALF_DEPTH; + let sx = edge.start.x; + let sz = edge.start.y; + let ex = edge.end.x; + let ez = edge.end.y; + if ((ez - sz) * inward.dx - (ex - sx) * inward.dy > 0) { + [sx, ex] = [ex, sx]; + [sz, ez] = [ez, sz]; + } + const soffitShade = (0.36 + 0.4 * this.lightLevelAt((sx + ex) / 2, (sz + ez) / 2)) * mat.wear; + b.quad( + [sx, bottom, sz], + [ex, bottom, ez], + [ex + inward.dx * T, bottom, ez + inward.dy * T], + [sx + inward.dx * T, bottom, sz + inward.dy * T], + [sx + sz, 0], + [ex + ez, 0], + [ex + ez, T / 1.6], + [sx + sz, T / 1.6], + mat.tile, + mat.tint, + [soffitShade, soffitShade, soffitShade, soffitShade] + ); + for (const end of [edge.start, edge.end]) { + const other = end === edge.start ? edge.end : edge.start; + const ox = Math.sign(end.x - other.x); + const oy = Math.sign(end.y - other.y); + this.emitJamb(b, end, { ox, oy }, inward, bottom, h, mat); + } + } + /** + * The inward-facing wall face for an edge, inset WALL_HALF_DEPTH into the + * cell so the slab has visible extrusion depth at openings. + */ + emitWallFace(b, cell, direction, yBottom, yTop, zone) { + const edge = cell.edges[direction]; + const inward = step(opposite(direction)); + const T = WALL_HALF_DEPTH; + let sx = edge.start.x + inward.dx * T; + let sz = edge.start.y + inward.dy * T; + let ex = edge.end.x + inward.dx * T; + let ez = edge.end.y + inward.dy * T; + const normalX = -(ez - sz); + const normalZ = ex - sx; + if (normalX * inward.dx + normalZ * inward.dy < 0) { + [sx, ex] = [ex, sx]; + [sz, ez] = [ez, sz]; + } + const { tile, tint, wear } = this.wallMaterial(edge, zone); + const u0 = sx + sz; + const u1 = ex + ez; + const v0 = yBottom / 1.6; + const v1 = yTop / 1.6; + const sample = (x, z) => (0.42 + 0.5 * this.lightLevelAt(x, z)) * wear; + const sS = sample(sx + inward.dx * 0.2, sz + inward.dy * 0.2); + const sE = sample(ex + inward.dx * 0.2, ez + inward.dy * 0.2); + b.quad( + [sx, yBottom, sz], + [ex, yBottom, ez], + [ex, yTop, ez], + [sx, yTop, sz], + [u0, v0], + [u1, v0], + [u1, v1], + [u0, v1], + tile, + tint, + [sS, sE, sE * 0.92, sS * 0.92] + ); + } + emitFeature(b, cell, feature) { + const fx = feature.position.x; + const fz = feature.position.y; + const h = cell.dimensions.depth; + const light = 0.4 + 0.5 * this.lightLevelAt(fx, fz); + if (feature.kind === "column") { + const r = Math.max(0.09, feature.size); + emitBox(b, fx - r, fx + r, 0, h, fz - r, fz + r, TILE.drywall, WHITE, light); + return; + } + const rng = mulberry32(feature.variantSeed); + const scale = clamp2(feature.size / 0.15, 0.75, 1.35); + const archetype = Math.floor(rng.next() * 4); + const s = (v) => v * scale; + switch (archetype) { + case 0: { + emitBox(b, fx - s(0.14), fx + s(0.14), 0, s(0.52), fz - s(0.12), fz + s(0.12), TILE.metal, WHITE, light); + break; + } + case 1: { + const w = s(0.32); + const d = s(0.2); + const top = s(0.3); + emitBox(b, fx - w, fx - w + s(0.04), 0, top, fz - d, fz + d, TILE.wood, WHITE, light * 0.9); + emitBox(b, fx + w - s(0.04), fx + w, 0, top, fz - d, fz + d, TILE.wood, WHITE, light * 0.9); + emitBox(b, fx - w, fx + w, top, top + s(0.04), fz - d, fz + d, TILE.wood, WHITE, light); + break; + } + case 2: { + const w = s(0.36); + const d = s(0.17); + emitBox(b, fx - w, fx + w, 0, s(0.18), fz - d, fz + d, TILE.fabric, WHITE, light); + emitBox(b, fx - w, fx + w, s(0.18), s(0.4), fz + d - s(0.07), fz + d, TILE.fabric, WHITE, light * 0.95); + break; + } + default: { + const r = s(0.17); + emitBox(b, fx - r, fx + r, 0, s(0.26), fz - r, fz + r, TILE.cardboard, WHITE, light); + const r2 = s(0.12); + const ox = (rng.next() - 0.5) * s(0.08); + const oz = (rng.next() - 0.5) * s(0.08); + emitBox(b, fx - r2 + ox, fx + r2 + ox, s(0.26), s(0.46), fz - r2 + oz, fz + r2 + oz, TILE.cardboard, WHITE, light * 1.05); + break; + } + } + } + }; + function emitBox(b, x0, x1, y0, y1, z0, z1, tile, tint, shade) { + const sides = shade * 0.85; + const uw = (x1 - x0) * 2; + const ud = (z1 - z0) * 2; + const vh = (y1 - y0) * 2; + b.quad( + [x0, y0, z1], + [x1, y0, z1], + [x1, y1, z1], + [x0, y1, z1], + [0, 0], + [uw, 0], + [uw, vh], + [0, vh], + tile, + tint, + [sides, sides, sides, sides] + ); + b.quad( + [x1, y0, z0], + [x0, y0, z0], + [x0, y1, z0], + [x1, y1, z0], + [0, 0], + [uw, 0], + [uw, vh], + [0, vh], + tile, + tint, + [sides, sides, sides, sides] + ); + b.quad( + [x1, y0, z1], + [x1, y0, z0], + [x1, y1, z0], + [x1, y1, z1], + [0, 0], + [ud, 0], + [ud, vh], + [0, vh], + tile, + tint, + [sides, sides, sides, sides] + ); + b.quad( + [x0, y0, z0], + [x0, y0, z1], + [x0, y1, z1], + [x0, y1, z0], + [0, 0], + [ud, 0], + [ud, vh], + [0, vh], + tile, + tint, + [sides, sides, sides, sides] + ); + b.quad( + [x0, y1, z0], + [x0, y1, z1], + [x1, y1, z1], + [x1, y1, z0], + [0, 0], + [0, ud], + [uw, ud], + [uw, 0], + tile, + tint, + [shade, shade, shade, shade] + ); + } + function mix(a, b, t) { + return a + (b - a) * t; + } + function hueRotate(rgb, degrees) { + const rad = degrees * Math.PI / 180; + const c = Math.cos(rad); + const s = Math.sin(rad); + const [r, g, b] = rgb; + return [ + (0.213 + c * 0.787 - s * 0.213) * r + (0.715 - c * 0.715 - s * 0.715) * g + (0.072 - c * 0.072 + s * 0.928) * b, + (0.213 - c * 0.213 + s * 0.143) * r + (0.715 + c * 0.285 + s * 0.14) * g + (0.072 - c * 0.072 - s * 0.283) * b, + (0.213 - c * 0.213 - s * 0.787) * r + (0.715 - c * 0.715 + s * 0.715) * g + (0.072 + c * 0.928 + s * 0.072) * b + ]; + } + function clamp2(v, lo, hi) { + return Math.min(hi, Math.max(lo, v)); + } + var MeshBuilder = class { + verts = []; + idx = []; + count = 0; + quad(p0, p1, p2, p3, t0, t1, t2, t3, tile, tint, shades) { + const points = [p0, p1, p2, p3]; + const uvs = [t0, t1, t2, t3]; + for (let i = 0; i < 4; i++) { + const p = points[i]; + const t = uvs[i]; + this.verts.push(p[0], p[1], p[2], t[0], t[1], tile, tint[0], tint[1], tint[2], shades[i]); + } + const base = this.count; + this.idx.push(base, base + 1, base + 2, base, base + 2, base + 3); + this.count += 4; + } + vertices() { + return new Float32Array(this.verts); + } + indices() { + if (this.count > 65535) { + throw new Error(`Chunk exceeds 16-bit index range: ${this.count} vertices`); + } + return new Uint16Array(this.idx); + } + }; + + // src/webview/graffiti.ts + var ATLAS_SIZE = 1024; + var SLOT_W = 512; + var SLOT_H = 256; + var SLOT_COLS = 2; + var STATIC_SLOTS = 7; + var LIVE_SLOT = 7; + var MAX_WRITE_DISTANCE = 6; + var RESTAMP_DISTANCE = 4; + var STAMP_COOLDOWN_MS = 2500; + var HISTORY_COOLDOWN_MS = 1200; + var REVEAL_CPS = 22; + var LIVE_REDRAW_MS = 90; + var GHOST_TAIL = 3; + var SEGMENT_CAP = 120; + var MIN_COMMIT_CHARS = 12; + var WALL_SWITCH_MS = 700; + var WRITE_Y0 = 0.55; + var WRITE_Y1 = 1.05; + var INK_FONT = 'Chiller, Creepster, "Segoe Script", "Comic Sans MS", cursive'; + var RAY_OFFSETS = [0, 0.5, -0.5, 1, -1, 1.6, -1.6, 2.4, -2.4, Math.PI]; + var WallWriting = class { + enabled = true; + job = { ...IDLE_JOB }; + session = null; + stamps = []; + nextSlot = 0; + lastWrittenStatus = ""; + lastStampX = Number.NaN; + lastStampY = Number.NaN; + lastStampAt = 0; + stampedHistory = /* @__PURE__ */ new Set(); + lastHistoryStampAt = 0; + // Live ghost writing state. The response is written in segments: the + // segment being revealed lives on LIVE_SLOT on the wall the player faces; + // finished segments are committed onto static slots and the text flows on. + live = null; + liveHit = null; + liveText = ""; + liveShown = 0; + liveConsumed = 0; + lastLiveTick = 0; + lastLiveDraw = 0; + lastLiveKey = ""; + lastWallSwitchAt = 0; + atlas; + ctx; + constructor() { + this.atlas = document.createElement("canvas"); + this.atlas.width = this.atlas.height = ATLAS_SIZE; + const ctx = this.atlas.getContext("2d"); + if (!ctx) { + throw new Error("2D context unavailable for wall writing"); + } + this.ctx = ctx; + } + setJob(job) { + this.job = job; + } + setSession(session) { + this.session = session; + } + /** Clears every writing, e.g. on relocate or when the setting turns off. */ + reset(renderer) { + this.job = { ...IDLE_JOB }; + this.session = null; + this.stamps = []; + this.nextSlot = 0; + this.lastWrittenStatus = ""; + this.lastStampX = Number.NaN; + this.lastStampY = Number.NaN; + this.stampedHistory.clear(); + this.live = null; + this.liveHit = null; + this.liveText = ""; + this.liveShown = 0; + this.liveConsumed = 0; + this.lastLiveKey = ""; + renderer.clearDecalMesh(); + } + /** Called once per frame; adds or advances writings as needed. */ + update(now, world, px, py, yaw, renderer) { + if (!this.enabled) { + return; + } + let changed = this.updateJobStamp(now, world, px, py, yaw); + changed = this.updateHistoryStamps(now, world, px, py, yaw) || changed; + changed = this.updateLive(now, world, px, py, yaw) || changed; + if (changed) { + renderer.setDecalTexture(this.atlas); + this.uploadMesh(renderer); + } + } + // --- Job-status stamps (tool / command / status file route) --------------- + updateJobStamp(now, world, px, py, yaw) { + if (!this.job.working || this.job.status.length === 0) { + return false; + } + if (now - this.lastStampAt < STAMP_COOLDOWN_MS) { + return false; + } + const statusChanged = this.job.status !== this.lastWrittenStatus; + const moved = Number.isNaN(this.lastStampX) || Math.hypot(px - this.lastStampX, py - this.lastStampY) >= RESTAMP_DISTANCE; + if (!statusChanged && !moved) { + return false; + } + const hit = findWallAhead(world, px, py, yaw); + if (!hit) { + return false; + } + const edgeKey2 = `${hit.cx},${hit.cy},${hit.direction}`; + const existing = this.stamps.findIndex((s) => s.edgeKey === edgeKey2); + if (existing !== -1 && !statusChanged || this.live?.edgeKey === edgeKey2) { + return false; + } + const slot = existing !== -1 ? this.stamps[existing].slot : this.claimStaticSlot(); + if (existing !== -1) { + this.stamps.splice(existing, 1); + } + if (!this.stampStatic(world, hit, slot, edgeKey2, this.job.status)) { + return false; + } + this.lastWrittenStatus = this.job.status; + this.lastStampX = px; + this.lastStampY = py; + this.lastStampAt = now; + return true; + } + // --- Session history stamps ------------------------------------------------ + updateHistoryStamps(now, world, px, py, yaw) { + if (!this.session || now - this.lastHistoryStampAt < HISTORY_COOLDOWN_MS) { + return false; + } + if (this.stampedHistory.size > 200) { + this.stampedHistory.clear(); + } + for (const exchange of this.session.history) { + const text = exchange.response || exchange.prompt; + if (!text) { + continue; + } + const key = hashText(exchange.prompt + "\0" + exchange.response); + if (this.stampedHistory.has(key)) { + continue; + } + const hit = this.findFreeWall(world, px, py, yaw); + if (!hit) { + return false; + } + const edgeKey2 = `${hit.cx},${hit.cy},${hit.direction}`; + const slot = this.claimStaticSlot(); + if (this.stampStatic(world, hit, slot, edgeKey2, text)) { + this.stampedHistory.add(key); + this.lastHistoryStampAt = now; + return true; + } + } + return false; + } + // --- Live ghost writing ------------------------------------------------------ + updateLive(now, world, px, py, yaw) { + const target = this.session?.current ?? ""; + if (target.length === 0) { + return false; + } + let changed = false; + if (!target.startsWith(this.liveText)) { + changed = this.commitLive(world, Number.MAX_SAFE_INTEGER) || changed; + this.live = null; + this.liveHit = null; + this.liveShown = 0; + this.liveConsumed = 0; + this.lastLiveKey = ""; + } + this.liveText = target; + let segment = target.slice(this.liveConsumed); + if (segment.length === 0 && !this.live) { + return changed; + } + const faced = findWallAhead(world, px, py, yaw); + const facedKey = faced ? `${faced.cx},${faced.cy},${faced.direction}` : null; + if (this.live && faced && facedKey !== this.live.edgeKey && this.isFreeWall(facedKey) && now - this.lastWallSwitchAt > WALL_SWITCH_MS) { + const shownInSegment2 = Math.max(0, Math.floor(this.liveShown) - this.liveConsumed); + if (shownInSegment2 >= MIN_COMMIT_CHARS) { + changed = this.commitLive(world, shownInSegment2) || changed; + } + changed = this.startLive(world, faced) || changed; + this.lastWallSwitchAt = now; + segment = target.slice(this.liveConsumed); + } + if (!this.live) { + const hit = faced && this.isFreeWall(facedKey) ? faced : this.findFreeWall(world, px, py, yaw); + if (!hit || !this.startLive(world, hit)) { + return changed; + } + changed = true; + this.lastWallSwitchAt = now; + } + const dt = Math.min(0.2, (now - this.lastLiveTick) / 1e3 || 0); + this.lastLiveTick = now; + this.liveShown = Math.min(target.length, this.liveShown + dt * REVEAL_CPS); + const shownInSegment = Math.min(segment.length, Math.max(0, Math.floor(this.liveShown) - this.liveConsumed)); + if (shownInSegment >= SEGMENT_CAP) { + changed = this.commitLive(world, shownInSegment) || changed; + return changed; + } + const done = this.session ? !this.session.working : true; + if (done && this.liveConsumed + shownInSegment >= target.length && this.live) { + changed = this.commitLive(world, segment.length) || changed; + return changed; + } + const key = `${shownInSegment}|${segment.length}|${this.live.edgeKey}`; + if (key === this.lastLiveKey || now - this.lastLiveDraw < LIVE_REDRAW_MS) { + return changed; + } + this.lastLiveKey = key; + this.lastLiveDraw = now; + const stillRevealing = this.liveConsumed + shownInSegment < target.length; + this.drawSlot( + LIVE_SLOT, + segment.slice(0, shownInSegment), + stillRevealing ? GHOST_TAIL : 0, + `${this.live.edgeKey}#${this.liveConsumed}` + ); + return true; + } + /** Places (or moves) the live quad onto the given wall, cleared. */ + startLive(world, hit) { + const vertices = buildWallQuad(world, hit, LIVE_SLOT); + if (!vertices) { + return false; + } + this.live = { edgeKey: `${hit.cx},${hit.cy},${hit.direction}`, slot: LIVE_SLOT, vertices }; + this.liveHit = hit; + this.lastLiveKey = ""; + this.drawSlot(LIVE_SLOT, "", 0); + return true; + } + /** + * Freezes up to `maxChars` of the live segment onto a static slot on the + * wall it was written on (cut at a word boundary), advances the consumed + * counter, and frees the live quad. Returns whether anything was drawn. + */ + commitLive(world, maxChars) { + const hit = this.liveHit; + const live = this.live; + this.live = null; + this.liveHit = null; + this.lastLiveKey = ""; + if (!hit || !live) { + return false; + } + const segment = this.liveText.slice(this.liveConsumed); + let cut = Math.min(segment.length, maxChars); + if (cut < segment.length) { + const space = segment.lastIndexOf(" ", cut); + if (space > cut * 0.4) { + cut = space + 1; + } + } + const committed = segment.slice(0, cut).trim(); + this.liveConsumed += cut; + this.liveShown = Math.max(this.liveShown, this.liveConsumed); + if (committed.length === 0) { + return false; + } + const slot = this.claimStaticSlot(); + const vertices = buildWallQuad(world, hit, slot); + if (!vertices) { + return false; + } + this.drawSlot(slot, committed, 0); + this.stamps.push({ edgeKey: live.edgeKey, slot, vertices }); + return true; + } + isFreeWall(edgeKey2) { + return this.live?.edgeKey !== edgeKey2 && !this.stamps.some((s) => s.edgeKey === edgeKey2); + } + // --- Shared helpers ---------------------------------------------------------- + /** Next rotating static slot, evicting whatever writing used it before. */ + claimStaticSlot() { + const slot = this.nextSlot % STATIC_SLOTS; + this.nextSlot++; + this.stamps = this.stamps.filter((s) => s.slot !== slot); + return slot; + } + stampStatic(world, hit, slot, edgeKey2, text) { + const vertices = buildWallQuad(world, hit, slot); + if (!vertices) { + return false; + } + this.drawSlot(slot, text, 0); + this.stamps.push({ edgeKey: edgeKey2, slot, vertices }); + return true; + } + /** First unclaimed wall found by fanning rays around the player's gaze. */ + findFreeWall(world, px, py, yaw) { + for (const offset of RAY_OFFSETS) { + const hit = findWallAhead(world, px, py, yaw + offset); + if (hit && this.isFreeWall(`${hit.cx},${hit.cy},${hit.direction}`)) { + return hit; + } + } + return null; + } + uploadMesh(renderer) { + const vertices = []; + const indices = []; + const all = this.live ? [...this.stamps, this.live] : this.stamps; + for (const stamp of all) { + const base = vertices.length / 6; + vertices.push(...stamp.vertices); + indices.push(base, base + 1, base + 2, base, base + 2, base + 3); + } + renderer.setDecalMesh(new Float32Array(vertices), new Uint16Array(indices)); + } + /** + * Draws one writing into an atlas slot with the marker treatment: glyphs + * jittered like handwriting, outline copies extruded ~3px toward the wall, + * black fill under a very subtle drop shadow, splatter, and finally a + * low-opacity gradient overlay (angle and stops seeded per text) that + * shifts across the strokes like uneven marker ink. + * + * `ghostTail` renders the last N characters progressively fainter, for the + * live response materializing onto the wall. + */ + drawSlot(slot, text, ghostTail, seedText) { + const ctx = this.ctx; + const ox = slot % SLOT_COLS * SLOT_W; + const oy = Math.floor(slot / SLOT_COLS) * SLOT_H; + ctx.save(); + ctx.clearRect(ox, oy, SLOT_W, SLOT_H); + if (text.trim().length === 0) { + ctx.restore(); + return; + } + ctx.beginPath(); + ctx.rect(ox, oy, SLOT_W, SLOT_H); + ctx.clip(); + const rng = mulberry(hashCode(seedText ?? text) || 1); + let size = 80; + let lines = [text.trim()]; + for (; size > 26; size -= 4) { + ctx.font = `bold ${size}px ${INK_FONT}`; + lines = wrapToLines(ctx, text, SLOT_W - 70); + if (lines.length * size * 1.12 <= SLOT_H - 36) { + break; + } + } + const maxLines = Math.max(1, Math.floor((SLOT_H - 36) / (size * 1.12))); + if (lines.length > maxLines) { + lines = lines.slice(0, maxLines); + lines[maxLines - 1] += "\u2026"; + } + const ink = (alpha) => `rgba(16, 14, 13, ${alpha.toFixed(3)})`; + const extrude = (alpha) => `rgba(34, 30, 27, ${alpha.toFixed(3)})`; + ctx.textBaseline = "alphabetic"; + const lineGap = size * 1.12; + const blockH = lineGap * (lines.length - 1); + const baseY = oy + SLOT_H / 2 - blockH / 2 + size * 0.34; + const totalChars = lines.reduce((n, line) => n + line.length, 0); + let drawnChars = 0; + for (let li = 0; li < lines.length; li++) { + const line = lines[li]; + ctx.font = `bold ${size}px ${INK_FONT}`; + const lineW = ctx.measureText(line).width; + let x = ox + (SLOT_W - lineW) / 2 + (rng() - 0.5) * 16; + const y = baseY + li * lineGap + (rng() - 0.5) * 8; + for (const char of line) { + const w = ctx.measureText(char).width; + const fromEnd = totalChars - drawnChars; + const ghost = ghostTail > 0 && fromEnd <= ghostTail ? fromEnd / (ghostTail + 1) : 0; + const alpha = (0.82 + rng() * 0.14) * (1 - ghost * 0.75); + ctx.save(); + ctx.translate(x + w / 2, y + (rng() - 0.5) * size * 0.09); + ctx.rotate((rng() - 0.5) * 0.14); + ctx.shadowColor = "transparent"; + for (const depth of [3, 2, 1]) { + ctx.fillStyle = extrude(0.2 * (1 - ghost)); + ctx.fillText(char, -w / 2 + depth, depth * 0.8); + } + ctx.shadowColor = "rgba(0, 0, 0, 0.3)"; + ctx.shadowBlur = 2; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1.5; + ctx.fillStyle = ink(alpha); + ctx.fillText(char, -w / 2, 0); + ctx.restore(); + x += w * (0.94 + rng() * 0.06); + drawnChars++; + if (rng() < 0.07 && ghost === 0) { + const dripLen = 16 + rng() * 56; + const dripX = x - w / 2 + (rng() - 0.5) * 6; + const grad2 = ctx.createLinearGradient(0, y, 0, y + dripLen); + grad2.addColorStop(0, ink(0.55)); + grad2.addColorStop(1, ink(0)); + ctx.fillStyle = grad2; + ctx.fillRect(dripX, y - 2, 1.4 + rng() * 1.6, dripLen); + } + } + } + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; + const splats = 5 + Math.floor(rng() * 6); + for (let i = 0; i < splats; i++) { + const sx = ox + 30 + rng() * (SLOT_W - 60); + const sy = oy + 30 + rng() * (SLOT_H - 60); + ctx.fillStyle = ink(0.2 + rng() * 0.4); + ctx.beginPath(); + ctx.arc(sx, sy, 0.8 + rng() * rng() * 5, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalCompositeOperation = "source-atop"; + const theta = rng() * Math.PI * 2; + const r = Math.max(SLOT_W, SLOT_H) / 2; + const cxm = ox + SLOT_W / 2; + const cym = oy + SLOT_H / 2; + const grad = ctx.createLinearGradient( + cxm - Math.cos(theta) * r, + cym - Math.sin(theta) * r, + cxm + Math.cos(theta) * r, + cym + Math.sin(theta) * r + ); + let pos = 0; + let bright = rng() < 0.5; + while (pos < 1) { + const alpha = 0.04 + rng() * 0.08; + grad.addColorStop(pos, bright ? `rgba(255, 255, 255, ${alpha.toFixed(3)})` : `rgba(30, 30, 30, ${alpha.toFixed(3)})`); + pos += 0.12 + rng() * 0.2; + bright = !bright; + } + ctx.fillStyle = grad; + ctx.fillRect(ox, oy, SLOT_W, SLOT_H); + ctx.globalCompositeOperation = "source-over"; + ctx.restore(); + } + }; + function wrapToLines(ctx, text, maxWidth) { + const words = text.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) { + return [""]; + } + const lines = []; + let line = ""; + for (const word of words) { + const candidate = line ? `${line} ${word}` : word; + if (line && ctx.measureText(candidate).width > maxWidth) { + lines.push(line); + line = word; + } else { + line = candidate; + } + } + lines.push(line); + return lines; + } + function hashCode(text) { + let hash = 2166136261; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; + } + function hashText(text) { + return hashCode(text).toString(36); + } + function mulberry(seed) { + let state = seed >>> 0; + return () => { + state = state + 1831565813 >>> 0; + let t = state; + t = Math.imul(t ^ t >>> 15, t | 1); + t ^= t + Math.imul(t ^ t >>> 7, t | 61); + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; + } + function findWallAhead(world, px, py, yaw) { + const fx = -Math.sin(yaw); + const fy = -Math.cos(yaw); + let cx = Math.floor(px); + let cy = Math.floor(py); + const stepX = fx > 0 ? 1 : -1; + const stepY = fy > 0 ? 1 : -1; + const tDeltaX = fx !== 0 ? 1 / Math.abs(fx) : Infinity; + const tDeltaY = fy !== 0 ? 1 / Math.abs(fy) : Infinity; + let tMaxX = fx !== 0 ? (fx > 0 ? cx + 1 - px : px - cx) / Math.abs(fx) : Infinity; + let tMaxY = fy !== 0 ? (fy > 0 ? cy + 1 - py : py - cy) / Math.abs(fy) : Infinity; + for (let i = 0; i < MAX_WRITE_DISTANCE * 2; i++) { + let direction; + let t; + if (tMaxX < tMaxY) { + direction = stepX > 0 ? "east" : "west"; + t = tMaxX; + tMaxX += tDeltaX; + } else { + direction = stepY > 0 ? "south" : "north"; + t = tMaxY; + tMaxY += tDeltaY; + } + if (t > MAX_WRITE_DISTANCE) { + return null; + } + if (!world.generator.isPassable(cx, cy, direction)) { + return { cx, cy, direction, hitX: px + fx * t, hitY: py + fy * t }; + } + const d = step(direction); + cx += d.dx; + cy += d.dy; + } + return null; + } + function buildWallQuad(world, hit, slot) { + const cell = world.generator.getCell(hit.cx, hit.cy); + const edge = cell.edges[hit.direction]; + const inward = step(opposite(hit.direction)); + const off = WALL_HALF_DEPTH + 8e-3; + let ax = edge.start.x + inward.dx * off; + let az = edge.start.y + inward.dy * off; + let bx = edge.end.x + inward.dx * off; + let bz = edge.end.y + inward.dy * off; + if (-(bz - az) * inward.dx + (bx - ax) * inward.dy < 0) { + [ax, bx] = [bx, ax]; + [az, bz] = [bz, az]; + } + const len = Math.hypot(bx - ax, bz - az); + const width = Math.min(1.5, len - 0.24); + if (width < 0.6) { + return null; + } + const ux = (bx - ax) / len; + const uz = (bz - az) / len; + const hitU = (hit.hitX - ax) * ux + (hit.hitY - az) * uz; + const center2 = Math.min(len - 0.12 - width / 2, Math.max(0.12 + width / 2, hitU)); + const x0 = ax + ux * (center2 - width / 2); + const z0 = az + uz * (center2 - width / 2); + const x1 = ax + ux * (center2 + width / 2); + const z1 = az + uz * (center2 + width / 2); + const shade = 0.42 + 0.5 * world.lightAt((x0 + x1) / 2 + inward.dx * 0.2, (z0 + z1) / 2 + inward.dy * 0.2); + const u0 = slot % SLOT_COLS * SLOT_W / ATLAS_SIZE; + const v0 = Math.floor(slot / SLOT_COLS) * SLOT_H / ATLAS_SIZE; + const u1 = u0 + SLOT_W / ATLAS_SIZE; + const v1 = v0 + SLOT_H / ATLAS_SIZE; + return [ + x0, + WRITE_Y0, + z0, + u0, + v1, + shade, + x1, + WRITE_Y0, + z1, + u1, + v1, + shade, + x1, + WRITE_Y1, + z1, + u1, + v0, + shade, + x0, + WRITE_Y1, + z0, + u0, + v0, + shade + ]; + } + + // src/webview/input.ts + var Input = class { + constructor(surface) { + this.surface = surface; + window.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + this.onMenuToggle?.(); + return; + } + const target = e.target; + if (target instanceof HTMLElement && (target.matches("input, select, textarea, button") || target.isContentEditable)) { + return; + } + if (e.key.toLowerCase() === "m") { + this.onMenuToggle?.(); + return; + } + this.held.add(normalize(e.key)); + if (isGameKey(e.key)) { + e.preventDefault(); + } + }); + window.addEventListener("keyup", (e) => this.held.delete(normalize(e.key))); + window.addEventListener("blur", () => this.held.clear()); + surface.addEventListener("click", () => { + if (this.mouseLookEnabled && document.pointerLockElement !== surface) { + surface.requestPointerLock(); + } + }); + document.addEventListener("mousemove", (e) => { + if (document.pointerLockElement === this.surface) { + this.lookDx += e.movementX; + this.lookDy += e.movementY; + } + }); + } + held = /* @__PURE__ */ new Set(); + lookDx = 0; + mouseLookEnabled = true; + onMenuToggle = null; + lookDy = 0; + releasePointer() { + if (document.pointerLockElement === this.surface) { + document.exitPointerLock(); + } + } + /** Accumulated mouse-look delta since the last call, in pixels. */ + consumeLook() { + const out = { dx: this.lookDx, dy: this.lookDy }; + this.lookDx = 0; + this.lookDy = 0; + return out; + } + state() { + const has = (...keys) => keys.some((k) => this.held.has(k)); + return { + forward: (has("w", "arrowup") ? 1 : 0) - (has("s", "arrowdown") ? 1 : 0), + strafe: (has("d") ? 1 : 0) - (has("a") ? 1 : 0), + turn: (has("arrowright", "e") ? 1 : 0) - (has("arrowleft", "q") ? 1 : 0), + running: has("shift") + }; + } + }; + function normalize(key) { + return key.toLowerCase(); + } + function isGameKey(key) { + return ["w", "a", "s", "d", "q", "e", "shift"].includes(key.toLowerCase()) || key.startsWith("Arrow"); + } + + // src/webview/monster.ts + var BODY_FORMS = ["spider", "humanoid", "cloud"]; + var CATCH_DISTANCE = 0.45; + var REPATH_MS = 600; + var PATH_NODE_CAP = 900; + var DARK = [1, 1, 1]; + var BODY_SHADE = 0.16; + var EYE_TINT = [1, 0.16, 0.1]; + var Monster = class { + constructor(world, config, now) { + this.world = world; + this.config = config; + this.arm(now); + } + form = "spider"; + x = 0; + y = 0; + config; + stalking = false; + spawnAt = 0; + path = []; + lastPathAt = 0; + phase = 0; + heading = 0; + get isStalking() { + return this.stalking; + } + /** Applies new tuning live; the spawn window only affects future arms. */ + configure(config) { + const formChanged = config.form !== this.config.form; + this.config = config; + if (formChanged && config.form !== "random") { + this.form = config.form; + } + } + /** Returns to dormant and schedules the next appearance. */ + arm(now) { + this.stalking = false; + this.path = []; + const min = Math.min(this.config.spawnMinMs, this.config.spawnMaxMs); + const max = Math.max(this.config.spawnMinMs, this.config.spawnMaxMs); + this.spawnAt = now + min + Math.random() * (max - min); + } + update(now, dt, px, py) { + if (!this.stalking) { + if (now >= this.spawnAt) { + this.spawn(px, py); + return "spawned"; + } + return null; + } + const distToPlayer = Math.hypot(px - this.x, py - this.y); + if (distToPlayer < CATCH_DISTANCE) { + this.arm(now); + return "caught"; + } + if (now - this.lastPathAt > REPATH_MS) { + this.lastPathAt = now; + this.path = this.findPath(Math.floor(this.x), Math.floor(this.y), Math.floor(px), Math.floor(py)); + } + let target = this.path[0] ? center(this.path[0]) : this.greedyStep(px, py); + if (this.path.length <= 1 && distToPlayer < 1.4) { + target = { x: px, y: py }; + } + const dx = target.x - this.x; + const dy = target.y - this.y; + const dist = Math.hypot(dx, dy); + const travel = this.config.speed * dt; + if (dist > 1e-4) { + const t = Math.min(1, travel / dist); + this.x += dx * t; + this.y += dy * t; + this.heading = Math.atan2(dy, dx); + this.phase += travel * 4.4; + } + if (this.path[0] && Math.hypot(center(this.path[0]).x - this.x, center(this.path[0]).y - this.y) < 0.08) { + this.path.shift(); + } + return null; + } + spawn(px, py) { + if (this.config.form === "random") { + this.form = BODY_FORMS[Math.floor(Math.random() * BODY_FORMS.length)]; + } else { + this.form = this.config.form; + } + const angle = Math.random() * Math.PI * 2; + const dist = 9 + Math.random() * 5; + this.x = Math.floor(px + Math.cos(angle) * dist) + 0.5; + this.y = Math.floor(py + Math.sin(angle) * dist) + 0.5; + this.path = []; + this.lastPathAt = 0; + this.stalking = true; + } + /** Breadth-first search through open edges, capped for the infinite grid. */ + findPath(fromCx, fromCy, toCx, toCy) { + if (fromCx === toCx && fromCy === toCy) { + return []; + } + const key = (cx, cy) => `${cx},${cy}`; + const parents = /* @__PURE__ */ new Map(); + parents.set(key(fromCx, fromCy), null); + const queue = [{ cx: fromCx, cy: fromCy }]; + let found = false; + while (queue.length > 0 && parents.size < PATH_NODE_CAP) { + const node = queue.shift(); + if (node.cx === toCx && node.cy === toCy) { + found = true; + break; + } + for (const direction of DIRECTIONS) { + if (!this.world.generator.isPassable(node.cx, node.cy, direction)) { + continue; + } + const { dx, dy } = step(direction); + const next = { cx: node.cx + dx, cy: node.cy + dy }; + const nextKey = key(next.cx, next.cy); + if (!parents.has(nextKey)) { + parents.set(nextKey, key(node.cx, node.cy)); + queue.push(next); + } + } + } + if (!found) { + return []; + } + const path = []; + let cursor = key(toCx, toCy); + while (cursor && cursor !== key(fromCx, fromCy)) { + const [cx, cy] = cursor.split(",").map(Number); + path.unshift({ cx, cy }); + cursor = parents.get(cursor) ?? null; + } + return path; + } + /** No path known: shuffle toward the player through any open edge. */ + greedyStep(px, py) { + const cx = Math.floor(this.x); + const cy = Math.floor(this.y); + let best = { x: this.x, y: this.y }; + let bestDist = Number.POSITIVE_INFINITY; + for (const direction of DIRECTIONS) { + if (!this.world.generator.isPassable(cx, cy, direction)) { + continue; + } + const { dx, dy } = step(direction); + const candidate = center({ cx: cx + dx, cy: cy + dy }); + const dist = Math.hypot(px - candidate.x, py - candidate.y); + if (dist < bestDist) { + bestDist = dist; + best = candidate; + } + } + return best; + } + /** Emits this frame's world-space geometry. */ + buildMesh(now, px, py) { + const b = new MeshBuilder(); + switch (this.form) { + case "spider": + this.buildSpider(b); + break; + case "humanoid": + this.buildHumanoid(b); + break; + case "cloud": + this.buildCloud(b, now); + break; + } + this.buildEyes(b, px, py); + return { vertices: b.vertices(), indices: b.indices() }; + } + buildSpider(b) { + const { x, y } = this; + emitBox(b, x - 0.19, x + 0.19, 0.2, 0.42, y - 0.23, y + 0.23, TILE.fabric, DARK, BODY_SHADE); + emitBox(b, x - 0.09, x + 0.09, 0.26, 0.4, y - 0.34, y - 0.2, TILE.fabric, DARK, BODY_SHADE * 1.2); + for (let i = 0; i < 8; i++) { + const side = i < 4 ? -1 : 1; + const spread = (i % 4 - 1.5) * 0.5 + this.heading; + const lift = Math.max(0, Math.sin(this.phase + i * (Math.PI / 2))) * 0.1; + const hip = [x + side * 0.17, 0.34, y + Math.sin(spread) * 0.15]; + const foot = [ + x + side * (0.5 + 0.1 * Math.sin(i * 2.1)), + lift, + y + Math.sin(spread) * 0.42 + Math.cos(spread) * side * 0.12 + ]; + this.limb(b, hip, foot, 0.045); + } + } + buildHumanoid(b) { + const { x, y } = this; + const sway = Math.sin(this.phase * 0.5) * 0.03; + emitBox(b, x - 0.11 + sway, x + 0.11 + sway, 0.52, 1.06, y - 0.07, y + 0.07, TILE.fabric, DARK, BODY_SHADE); + emitBox(b, x - 0.06 + sway, x + 0.06 + sway, 1.06, 1.2, y - 0.06, y + 0.06, TILE.fabric, DARK, BODY_SHADE * 1.15); + const strideX = Math.cos(this.heading) * 0.16; + const strideY = Math.sin(this.heading) * 0.16; + const gait = Math.sin(this.phase); + this.limb(b, [x - 0.06, 0.55, y], [x - 0.06 + strideX * gait, 0, y + strideY * gait], 0.05); + this.limb(b, [x + 0.06, 0.55, y], [x + 0.06 - strideX * gait, 0, y - strideY * gait], 0.05); + this.limb(b, [x - 0.13 + sway, 1, y], [x - 0.13 + sway - strideX * gait * 0.5, 0.5, y - strideY * gait * 0.5], 0.04); + this.limb(b, [x + 0.13 + sway, 1, y], [x + 0.13 + sway + strideX * gait * 0.5, 0.5, y + strideY * gait * 0.5], 0.04); + } + buildCloud(b, now) { + const { x, y } = this; + const t = now / 1e3; + for (let i = 0; i < 10; i++) { + const jx = Math.sin(t * 1.3 + i * 2.4) * 0.14; + const jy = Math.sin(t * 1.7 + i * 1.9) * 0.1; + const jz = Math.cos(t * 1.1 + i * 3.2) * 0.14; + const size = 0.12 + i * 37 % 10 * 0.02; + const cx = x + Math.sin(i * 2.4) * 0.2 + jx; + const cy = 0.45 + Math.sin(i * 1.6) * 0.3 + jy; + const cz = y + Math.cos(i * 2.9) * 0.2 + jz; + emitBox(b, cx - size, cx + size, cy - size, cy + size, cz - size, cz + size, TILE.concrete, DARK, BODY_SHADE * (0.8 + i % 3 * 0.2)); + } + } + /** Two small emissive eyes billboarded toward the player. */ + buildEyes(b, px, py) { + const eyeHeight = this.form === "spider" ? 0.36 : this.form === "humanoid" ? 1.13 : 0.7; + const toPlayerX = px - this.x; + const toPlayerY = py - this.y; + const len = Math.hypot(toPlayerX, toPlayerY) || 1; + const fx = toPlayerX / len; + const fy = toPlayerY / len; + const rx = -fy; + const ry = fx; + const ex = this.x + fx * 0.2; + const ey = this.y + fy * 0.2; + const r = 0.03; + for (const side of [-1, 1]) { + const cx = ex + rx * side * 0.06; + const cy = ey + ry * side * 0.06; + b.quad( + [cx - rx * r, eyeHeight - r, cy - ry * r], + [cx + rx * r, eyeHeight - r, cy + ry * r], + [cx + rx * r, eyeHeight + r, cy + ry * r], + [cx - rx * r, eyeHeight + r, cy - ry * r], + [0, 0], + [1, 0], + [1, 1], + [0, 1], + TILE.lightPanel, + EYE_TINT, + [EMISSIVE_SHADE + 0.8, EMISSIVE_SHADE + 0.8, EMISSIVE_SHADE + 0.8, EMISSIVE_SHADE + 0.8] + ); + } + } + /** A thin double-sided crossed-quad limb between two points. */ + limb(b, from, to, width) { + const shades = [BODY_SHADE, BODY_SHADE, BODY_SHADE, BODY_SHADE]; + const uv = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1] + ]; + const planes = [ + [width, 0, 0], + [0, 0, width] + ]; + for (const offset of planes) { + const a0 = [from[0] - offset[0], from[1] - offset[1], from[2] - offset[2]]; + const a1 = [from[0] + offset[0], from[1] + offset[1], from[2] + offset[2]]; + const b1 = [to[0] + offset[0], to[1] + offset[1], to[2] + offset[2]]; + const b0 = [to[0] - offset[0], to[1] - offset[1], to[2] - offset[2]]; + b.quad(a0, a1, b1, b0, uv[0], uv[1], uv[2], uv[3], TILE.fabric, DARK, shades); + b.quad(b0, b1, a1, a0, uv[0], uv[1], uv[2], uv[3], TILE.fabric, DARK, shades); + } + } + }; + function center(cell) { + return { x: cell.cx + 0.5, y: cell.cy + 0.5 }; + } + + // src/webview/menu.ts + var STYLE = ` +.bv-menu { + position: absolute; inset: 0; display: none; z-index: 30; + align-items: center; justify-content: center; + background: rgba(16, 17, 19, 0.72); + font-family: "Segoe UI", system-ui, sans-serif; + color: #f0f2f3; +} +.bv-menu.open { display: flex; } +.bv-card { + background: linear-gradient(#22252a, #1c1f23); + border: 1px solid #3a3f46; border-radius: 10px; + min-width: 300px; max-width: 380px; max-height: 82%; overflow-y: auto; + padding: 22px 26px; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55); +} +.bv-card h1 { margin: 0 0 2px; font-size: 20px; letter-spacing: 3px; font-weight: 600; } +.bv-card .bv-sub { margin: 0 0 18px; font-size: 12px; color: #9aa3ad; } +.bv-menu button { + display: block; width: 100%; margin: 8px 0; padding: 10px 14px; + background: #2b3036; color: #f0f2f3; border: 1px solid #454c55; + border-radius: 6px; font-size: 14px; cursor: pointer; text-align: left; +} +.bv-menu button:hover { background: #343a42; } +.bv-menu button.bv-accent { background: #6b4b12; border-color: #93691c; } +.bv-menu button.bv-accent:hover { background: #7d5915; } +.bv-row { display: flex; align-items: center; justify-content: space-between; margin: 10px 0; font-size: 13px; gap: 12px; } +.bv-row label { flex: 1; color: #cfd6dc; } +.bv-row input[type="checkbox"] { width: 16px; height: 16px; accent-color: #93691c; } +.bv-row input[type="range"] { width: 130px; accent-color: #93691c; } +.bv-row input[type="number"] { + width: 110px; background: #15171a; color: #f0f2f3; + border: 1px solid #454c55; border-radius: 4px; padding: 5px 7px; font-size: 13px; +} +.bv-row .bv-val { width: 34px; text-align: right; color: #9aa3ad; font-variant-numeric: tabular-nums; } +.bv-row select { + background: #15171a; color: #f0f2f3; border: 1px solid #454c55; + border-radius: 4px; padding: 5px 7px; font-size: 13px; min-width: 150px; +} +.bv-h { + margin: 16px 0 4px; font-size: 11px; letter-spacing: 2px; + color: #8a93a0; text-transform: uppercase; +} +.bv-h:first-of-type { margin-top: 8px; } +.bv-stats { margin-top: 14px; padding-top: 12px; border-top: 1px solid #33383f; font-size: 12px; color: #9aa3ad; line-height: 1.7; } +.bv-help { font-size: 13px; color: #cfd6dc; line-height: 1.9; } +.bv-help kbd { + background: #15171a; border: 1px solid #454c55; border-radius: 4px; + padding: 1px 6px; font-family: inherit; font-size: 12px; +} +.bv-back { margin-top: 16px !important; } +`; + var Menu = class { + constructor(parent, callbacks) { + this.callbacks = callbacks; + const style = document.createElement("style"); + style.textContent = STYLE; + document.head.appendChild(style); + this.root = document.createElement("div"); + this.root.className = "bv-menu"; + parent.appendChild(this.root); + this.views = { + main: this.buildMain(), + settings: this.buildSettings(), + help: this.buildHelp() + }; + for (const view of Object.values(this.views)) { + this.root.appendChild(view); + } + this.show("main"); + } + root; + views; + statsEl = null; + settings = null; + openFlag = false; + lastFocused = null; + get isOpen() { + return this.openFlag; + } + open() { + this.openFlag = true; + this.lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + this.root.classList.add("open"); + this.show("main"); + } + close() { + this.openFlag = false; + this.root.classList.remove("open"); + if (this.lastFocused?.isConnected) { + this.lastFocused.focus(); + } + this.lastFocused = null; + } + syncSettings(settings) { + this.settings = settings; + for (const input of this.root.querySelectorAll("[data-key]")) { + const key = input.dataset.key; + const value = settings[key]; + if (input.type === "checkbox") { + input.checked = Boolean(value); + } else { + input.value = String(value); + const label = input.parentElement?.querySelector(".bv-val"); + if (label) { + label.textContent = String(value); + } + } + } + } + syncStats(stats) { + if (this.statsEl) { + this.statsEl.innerHTML = `seed ${stats.seed}
cells visited ${stats.cellsVisited}
cells cached ${stats.cacheSize}`; + } + } + show(name) { + for (const [key, view] of Object.entries(this.views)) { + view.style.display = key === name ? "block" : "none"; + } + // Only the visible card is a live dialog; hand it focus so assistive + // tech announces the context change instead of staying on the game. + if (this.openFlag) { + const view = this.views[name]; + const target = view.querySelector("button, input, select") ?? view; + target.focus(); + } + } + card() { + const card = document.createElement("div"); + card.className = "bv-card"; + card.setAttribute("role", "dialog"); + card.setAttribute("aria-modal", "true"); + card.setAttribute("aria-label", "BackRooms pause menu"); + card.tabIndex = -1; + card.innerHTML = '

BACKROOMS

noclipped into a Copilot canvas

'; + return card; + } + button(label, onClick, accent = false) { + const button = document.createElement("button"); + button.textContent = label; + if (accent) { + button.className = "bv-accent"; + } + button.addEventListener("click", onClick); + return button; + } + buildMain() { + const card = this.card(); + card.appendChild(this.button("Resume", () => this.callbacks.onResume())); + card.appendChild(this.button("Relocate (new seed)", () => this.callbacks.onRelocate(), true)); + card.appendChild(this.button("Settings", () => this.show("settings"))); + card.appendChild(this.button("Help", () => this.show("help"))); + this.statsEl = document.createElement("div"); + this.statsEl.className = "bv-stats"; + card.appendChild(this.statsEl); + return card; + } + toggleRow(label, key) { + const row = document.createElement("div"); + row.className = "bv-row"; +const input = document.createElement("input"); + input.type = "checkbox"; + input.dataset.key = key; + input.setAttribute("aria-label", label); + input.addEventListener("change", () => this.callbacks.onSettingChange(key, input.checked)); + const text = document.createElement("label"); + text.textContent = label; + row.append(text, input); + return row; + } + sliderRow(label, key, min, max, stepSize) { + const row = document.createElement("div"); + row.className = "bv-row"; + const text = document.createElement("label"); + text.textContent = label; + const value = document.createElement("span"); + value.className = "bv-val"; +const input = document.createElement("input"); + input.type = "range"; + input.min = String(min); + input.max = String(max); + input.step = String(stepSize); + input.dataset.key = key; + input.setAttribute("aria-label", label); + input.addEventListener("input", () => { + value.textContent = input.value; + this.callbacks.onSettingChange(key, Number(input.value)); + }); + row.append(text, input, value); + return row; + } + heading(text) { + const h = document.createElement("div"); + h.className = "bv-h"; + h.textContent = text; + return h; + } + selectRow(label, key, options) { + const row = document.createElement("div"); + row.className = "bv-row"; + const text = document.createElement("label"); + text.textContent = label; +const select = document.createElement("select"); + select.dataset.key = key; + select.setAttribute("aria-label", label); + for (const option of options) { + const el = document.createElement("option"); + el.value = option.value; + el.textContent = option.label; + select.appendChild(el); + } + select.addEventListener("change", () => this.callbacks.onSettingChange(key, select.value)); + row.append(text, select); + return row; + } + buildSettings() { + const card = this.card(); + card.appendChild(this.heading("Camera")); + card.appendChild(this.toggleRow("Camera shake", "cameraShake")); + card.appendChild(this.toggleRow("Film grain", "filmGrain")); + card.appendChild(this.toggleRow("Camcorder HUD", "vhsHud")); + card.appendChild(this.heading("Controls")); + card.appendChild(this.toggleRow("Mouse look", "mouseLook")); + card.appendChild(this.toggleRow("Invert turn", "invertTurn")); + card.appendChild(this.toggleRow("Invert strafe", "invertStrafe")); + card.appendChild(this.toggleRow("Invert forward/back", "invertForward")); + card.appendChild(this.sliderRow("Walk speed", "moveSpeed", 0.5, 6, 0.1)); + card.appendChild(this.heading("Materials")); + card.appendChild( + this.selectRow("Wall material", "materialPreset", [ + { value: "classic", label: "Classic wallpaper mix" }, + { value: "office", label: "Plain drywall" }, + { value: "pool", label: "Ceramic tile" }, + { value: "concrete", label: "Bare concrete" }, + { value: "panel", label: "Wood paneling" } + ]) + ); + card.appendChild(this.sliderRow("Hue shift", "materialHueShift", -180, 180, 5)); + card.appendChild(this.sliderRow("Brightness", "materialBrightness", 0.6, 1.4, 0.05)); + card.appendChild(this.heading("Monster")); + card.appendChild(this.toggleRow("Monster", "monsterEnabled")); + card.appendChild( + this.selectRow("Form", "monsterForm", [ + { value: "spider", label: "Spider-like" }, + { value: "humanoid", label: "Human-like" }, + { value: "cloud", label: "Cloud-like" }, + { value: "random", label: "Random each spawn" } + ]) + ); + card.appendChild(this.sliderRow("Speed", "monsterSpeed", 0.5, 5, 0.1)); + card.appendChild(this.sliderRow("Spawn after (min)", "monsterSpawnMin", 0.1, 10, 0.1)); + card.appendChild(this.sliderRow("Spawn before (min)", "monsterSpawnMax", 0.5, 15, 0.5)); + card.appendChild(this.heading("World")); + card.appendChild(this.toggleRow("Furniture", "furniture")); + card.appendChild(this.toggleRow("Wallpaper shifts", "wallpaperShifts")); + card.appendChild(this.sliderRow("Render distance", "renderDistance", 6, 28, 1)); + card.appendChild(this.heading("Copilot")); + card.appendChild(this.toggleRow("Ghost-writer on the walls", "copilotGhostWriter")); + const seedRow = document.createElement("div"); + seedRow.className = "bv-row"; + const seedLabel = document.createElement("label"); + seedLabel.textContent = "Seed (0 = random)"; +const seedInput = document.createElement("input"); + seedInput.type = "number"; + seedInput.dataset.key = "seed"; + seedInput.setAttribute("aria-label", "Seed (0 = random)"); + seedInput.addEventListener("change", () => { + const seed = Math.trunc(Number(seedInput.value)) || 0; + this.callbacks.onSettingChange("seed", seed); + this.callbacks.onReseed(seed); + }); + seedRow.append(seedLabel, seedInput); + card.appendChild(seedRow); + const back = this.button("Back", () => this.show("main")); + back.classList.add("bv-back"); + card.appendChild(back); + return card; + } + buildHelp() { + const card = this.card(); + const help = document.createElement("div"); + help.className = "bv-help"; + help.innerHTML = "W/S or / walk
A/D strafe
/ or Q/E turn
Shift hurry
click the view for mouse look
M or Esc open this menu

The maze is infinite and deterministic: the same seed always rebuilds the same rooms."; + card.appendChild(help); + const back = this.button("Back", () => this.show("main")); + back.classList.add("bv-back"); + card.appendChild(back); + return card; + } + }; + + // src/webview/main.ts + var EYE_HEIGHT = 0.78; + var vscode = acquireVsCodeApi(); + var Game = class { + settings = { ...DEFAULT_SETTINGS }; + renderer; + film; + graffiti = new WallWriting(); + input; + menu; + toast; + world; + monster = null; + uniformWallpaper = false; + // Player, in plane coordinates. + px = 0.5; + py = 0.5; + yaw = 0; + pitch = 0; + bobPhase = 0; + lastFrame = 0; + lastPersist = 0; + flickerDipUntil = 0; + toastTimer; + constructor(root) { + const glCanvas = document.createElement("canvas"); + const filmCanvas = document.createElement("canvas"); + for (const [canvas, z] of [[glCanvas, "1"], [filmCanvas, "2"]]) { + canvas.style.cssText = `position:absolute;inset:0;width:100%;height:100%;z-index:${z};`; + } + filmCanvas.style.pointerEvents = "none"; + root.append(glCanvas, filmCanvas); + this.renderer = new Renderer(glCanvas); + this.film = new FilmOverlay(filmCanvas); + this.input = new Input(glCanvas); + this.input.onMenuToggle = () => this.toggleMenu(); + this.menu = new Menu(root, { + onResume: () => this.toggleMenu(), + onRelocate: () => this.relocate(), + onSettingChange: (key, value) => this.changeSetting(key, value), + // A nonzero seed already rebuilds through applySettings; 0 means "roll one now". + onReseed: (seed2) => { + if (seed2 === 0) { + this.rebuildWorld(randomSeed()); + } + } + }); + this.toast = document.createElement("div"); + this.toast.setAttribute("role", "status"); + this.toast.style.cssText = 'position:absolute;left:50%;bottom:9%;transform:translateX(-50%);z-index:20;background:rgba(22,24,27,0.85);color:#f0f2f3;border:1px solid #454c55;border-radius:6px;padding:8px 16px;font:13px "Segoe UI",system-ui,sans-serif;opacity:0;transition:opacity .4s;pointer-events:none;'; + root.appendChild(this.toast); + const restored = vscode.getState(); + const seed = restored?.seed ?? (this.settings.seed !== 0 ? this.settings.seed : randomSeed()); + this.world = new World(seed); + if (restored) { + this.px = restored.px; + this.py = restored.py; + this.yaw = restored.yaw; + this.world.session.warpTo(Math.floor(this.px), Math.floor(this.py)); + } + window.addEventListener("message", (event) => { + if (event.source !== window || !event.data || typeof event.data !== "object") { + return; + } + const message = event.data; + if (message.type === "config") { + this.applySettings(message.settings); + } else if (message.type === "relocate") { + this.relocate(message.seed); + } else if (message.type === "jobStatus") { + this.film.setJob(message.job); + this.graffiti.setJob(message.job); + } else if (message.type === "chatSession") { + this.film.setJob({ + working: message.session.working, + status: "", + tokens: message.session.tokens + }); + this.graffiti.setSession(message.session); + } + }); + vscode.postMessage({ type: "ready" }); + this.showToast(`seed ${seed} - walk with WASD or arrows, M for menu`); + this.syncMonster(); + void this.loadMaterialImages(); + requestAnimationFrame((t) => this.frame(t)); + } + /** + * Loads the photo materials shipped under materials/ and patches them over + * the procedural atlas. Any file that is missing or fails to decode leaves + * its procedural tile in place. + */ + async loadMaterialImages() { + const uris = window.__BACKROOMS_MATERIALS__ ?? {}; + const load = (uri) => new Promise((resolve) => { + if (!uri) { + resolve(void 0); + return; + } + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => resolve(void 0); + image.src = uri; + }); + const [wallpaper, ceiling, carpet] = await Promise.all([ + load(uris.wallpaper), + load(uris.ceiling), + load(uris.carpet) + ]); + if (!wallpaper && !ceiling && !carpet) { + return; + } + this.renderer.applyMaterialImages({ wallpaper, ceiling, carpet }); + if (wallpaper) { + this.world.uniformWallpaper = true; + this.uniformWallpaper = true; + this.world.invalidateChunks(this.renderer); + } + } + toggleMenu() { + if (this.menu.isOpen) { + this.menu.close(); + } else { + this.input.releasePointer(); + this.menu.syncSettings(this.settings); + this.menu.syncStats(this.world.stats()); + this.menu.open(); + } + } + // The host may pick the seed (so its action response stays accurate); + // anything invalid or absent rolls a fresh one here. + relocate(seed) { + const next = typeof seed === "number" && Number.isFinite(seed) && seed > 0 ? Math.floor(seed) : randomSeed(); + this.rebuildWorld(next); + // Adopt the new seed as the effective setting; otherwise the config + // replayed on a reload still carries the old seed and immediately + // rebuilds away from the relocated world. + this.changeSetting("seed", next); + this.menu.close(); + this.showToast(`relocated to seed ${next}`); + } + rebuildWorld(seed) { + this.world.invalidateChunks(this.renderer); + this.graffiti.reset(this.renderer); + this.world = new World(seed); + this.world.furnitureEnabled = this.settings.furniture; + this.world.wallpaperShiftsEnabled = this.settings.wallpaperShifts; + this.world.setMaterial( + this.settings.materialPreset, + this.settings.materialHueShift, + this.settings.materialBrightness + ); + this.world.uniformWallpaper = this.uniformWallpaper; + this.px = 0.5; + this.py = 0.5; + this.syncMonster(true); + this.persist(); + this.menu.syncStats(this.world.stats()); + } + monsterConfig() { + return { + speed: this.settings.monsterSpeed, + spawnMinMs: this.settings.monsterSpawnMin * 6e4, + spawnMaxMs: this.settings.monsterSpawnMax * 6e4, + form: this.settings.monsterForm + }; + } + /** Creates, retunes, or removes the monster to match current settings. */ + syncMonster(rearm = false) { + if (!this.settings.monsterEnabled) { + this.monster = null; + this.renderer.clearDynamicMesh(); + return; + } + if (!this.monster || rearm) { + this.monster = new Monster(this.world, this.monsterConfig(), performance.now()); + } else { + this.monster.configure(this.monsterConfig()); + } + } + changeSetting(key, value) { + this.applySettings({ ...this.settings, [key]: value }); + vscode.postMessage({ type: "updateSetting", key, value }); + } + applySettings(settings) { + const previous = this.settings; + this.settings = settings; + this.film.grainEnabled = settings.filmGrain; + this.film.hudEnabled = settings.vhsHud; + this.film.tokenCounterEnabled = settings.copilotGhostWriter; + if (this.graffiti.enabled && !settings.copilotGhostWriter) { + this.graffiti.reset(this.renderer); + } + this.graffiti.enabled = settings.copilotGhostWriter; + this.input.mouseLookEnabled = settings.mouseLook; + this.renderer.fogDensity = 2.6 / (settings.renderDistance * settings.renderDistance); + if (previous.furniture !== settings.furniture || previous.wallpaperShifts !== settings.wallpaperShifts || previous.materialPreset !== settings.materialPreset || previous.materialHueShift !== settings.materialHueShift || previous.materialBrightness !== settings.materialBrightness) { + this.world.furnitureEnabled = settings.furniture; + this.world.wallpaperShiftsEnabled = settings.wallpaperShifts; + this.world.setMaterial(settings.materialPreset, settings.materialHueShift, settings.materialBrightness); + this.world.invalidateChunks(this.renderer); + } + this.syncMonster(previous.monsterEnabled !== settings.monsterEnabled); + if (settings.seed !== previous.seed && settings.seed !== 0 && settings.seed !== this.world.seed) { + this.rebuildWorld(settings.seed); + } + this.menu.syncSettings(settings); + } + showToast(text) { + this.toast.textContent = text; + this.toast.style.opacity = "1"; + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => { + this.toast.style.opacity = "0"; + }, 4200); + } + persist() { + vscode.setState({ seed: this.world.seed, px: this.px, py: this.py, yaw: this.yaw }); + } + frame(now) { + const dt = Math.min(0.05, (now - this.lastFrame) / 1e3 || 0.016); + this.lastFrame = now; + const t = now / 1e3; + let speed = 0; + if (!this.menu.isOpen) { + speed = this.step(dt); + if (this.monster) { + const event = this.monster.update(now, dt, this.px, this.py); + if (event === "spawned") { + this.showToast("the air changes. something else is in the halls."); + } else if (event === "caught") { + this.film.burst(now); + this.px = 0.5; + this.py = 0.5; + this.world.session.warpTo(0, 0); + this.persist(); + this.showToast("tape resumes somewhere familiar. it is still out there."); + } + } + } + if (this.monster?.isStalking) { + const mesh = this.monster.buildMesh(now, this.px, this.py); + this.renderer.setDynamicMesh(mesh.vertices, mesh.indices); + } else { + this.renderer.clearDynamicMesh(); + } + let shakeYaw = 0; + let shakePitch = 0; + let shakeRoll = 0; + let shakeUp = 0; + if (this.settings.cameraShake) { + const drift = 1 + speed * 1.6; + shakeYaw = (Math.sin(t * 0.9) * 6e-3 + Math.sin(t * 2.3 + 1.7) * 3e-3) * drift; + shakePitch = (Math.sin(t * 1.3 + 0.6) * 4e-3 + Math.sin(t * 3.1) * 2e-3) * drift; + shakeRoll = Math.sin(t * 0.7 + 2.1) * 4e-3 * drift + Math.sin(this.bobPhase) * 6e-3 * speed; + shakeUp = Math.sin(this.bobPhase * 2) * 0.014 * speed; + } + if (Math.random() < 15e-4 && this.flickerDipUntil < now) { + this.flickerDipUntil = now + 60 + Math.random() * 120; + } + let flicker = 1 + Math.sin(t * 11) * 0.012 + Math.sin(t * 47) * 8e-3; + if (this.flickerDipUntil > now) { + flicker *= 0.82; + } + const camera = { + x: this.px, + y: EYE_HEIGHT + shakeUp, + z: this.py, + yaw: this.yaw + shakeYaw, + pitch: this.pitch + shakePitch, + roll: shakeRoll, + fovY: 72 * Math.PI / 180 + }; + this.graffiti.update(now, this.world, this.px, this.py, this.yaw, this.renderer); + const chunks = this.world.updateChunks(this.px, this.py, this.settings.renderDistance, this.renderer); + this.renderer.draw(chunks, camera, flicker); + this.film.render(now); + if (now - this.lastPersist > 1500) { + this.lastPersist = now; + this.persist(); + } + requestAnimationFrame((next) => this.frame(next)); + } + /** Applies input to the player; returns normalized speed for bob effects. */ + step(dt) { + const input = this.input.state(); + const look = this.input.consumeLook(); + const turnSign = this.settings.invertTurn ? -1 : 1; + this.yaw -= (look.dx * 26e-4 + input.turn * 1.9 * dt) * turnSign; + this.pitch = clamp3(this.pitch - look.dy * 22e-4, -1.25, 1.25); + const rate = this.settings.moveSpeed * (input.running ? 1.7 : 1); + const forward = input.forward * (this.settings.invertForward ? -1 : 1); + const strafe = input.strafe * (this.settings.invertStrafe ? -1 : 1); + const fx = -Math.sin(this.yaw); + const fy = -Math.cos(this.yaw); + const rx = Math.cos(this.yaw); + const ry = -Math.sin(this.yaw); + const dx = (fx * forward + rx * strafe) * rate * dt; + const dy = (fy * forward + ry * strafe) * rate * dt; + if (dx === 0 && dy === 0) { + return 0; + } + const moved = this.world.moveResolved(this.px, this.py, dx, dy); + const actual = Math.hypot(moved.x - this.px, moved.y - this.py); + this.px = moved.x; + this.py = moved.y; + this.world.syncSession(this.px, this.py); + this.bobPhase += actual * 5.6; + return Math.min(1, actual / (rate * dt + 1e-6)); + } + }; + function randomSeed() { + return Math.floor(Math.random() * 999999) + 1; + } + function clamp3(v, lo, hi) { + return Math.min(hi, Math.max(lo, v)); + } + var app = document.getElementById("app"); + if (app) { + new Game(app); + } +})(); diff --git a/plugins/backrooms-canvas/extensions/backrooms-canvas/materials/carpet.jpg b/plugins/backrooms-canvas/extensions/backrooms-canvas/materials/carpet.jpg new file mode 100644 index 00000000..0a631716 Binary files /dev/null and b/plugins/backrooms-canvas/extensions/backrooms-canvas/materials/carpet.jpg differ diff --git a/plugins/backrooms-canvas/extensions/backrooms-canvas/materials/ceiling.jpg b/plugins/backrooms-canvas/extensions/backrooms-canvas/materials/ceiling.jpg new file mode 100644 index 00000000..8d314ec3 Binary files /dev/null and b/plugins/backrooms-canvas/extensions/backrooms-canvas/materials/ceiling.jpg differ diff --git a/plugins/backrooms-canvas/extensions/backrooms-canvas/materials/wallpaper.jpg b/plugins/backrooms-canvas/extensions/backrooms-canvas/materials/wallpaper.jpg new file mode 100644 index 00000000..9b0fd07d Binary files /dev/null and b/plugins/backrooms-canvas/extensions/backrooms-canvas/materials/wallpaper.jpg differ diff --git a/plugins/backrooms-canvas/extensions/backrooms-canvas/package-lock.json b/plugins/backrooms-canvas/extensions/backrooms-canvas/package-lock.json new file mode 100644 index 00000000..f7bfc585 --- /dev/null +++ b/plugins/backrooms-canvas/extensions/backrooms-canvas/package-lock.json @@ -0,0 +1,446 @@ +{ + "name": "backrooms-canvas", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backrooms-canvas", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@github/copilot-sdk": "latest" + } + }, + "node_modules/@github/copilot": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.71.tgz", + "integrity": "sha512-F3axBi+sXSLYDJbxCBW36bM6MYKNC2rlyAf3Ivo/MjiHKKJW7j5AmaR1IRYS9Gt8r9mxOwWFM1cJFA+CuLaR8g==", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "1.0.71", + "@github/copilot-darwin-x64": "1.0.71", + "@github/copilot-linux-arm64": "1.0.71", + "@github/copilot-linux-x64": "1.0.71", + "@github/copilot-linuxmusl-arm64": "1.0.71", + "@github/copilot-linuxmusl-x64": "1.0.71", + "@github/copilot-win32-arm64": "1.0.71", + "@github/copilot-win32-x64": "1.0.71" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.71.tgz", + "integrity": "sha512-mEWzyqbqRAWgyU7i2uuSRoVPx/TwaFQX0nZmw0bc30aJ0BnO7cy2kYQyCHw8ykmf/tfxT0xauZ6k0BOFmWizzQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-arm64": "copilot" + } + }, + "node_modules/@github/copilot-darwin-x64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.71.tgz", + "integrity": "sha512-Md9yEg406OBVBx3w4PeEj62TubulVLBcHleqmCoOoUmPgUxPZotUbrqz3rtbzADbXfrrD7JWvVsbd2UiNL194w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-x64": "copilot" + } + }, + "node_modules/@github/copilot-linux-arm64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.71.tgz", + "integrity": "sha512-ykLJYOqBj3jRB5IJCDugLClAqbr7DmtTbUjlNY7+Jdq/n6i+d7xUQGclf1IWL5gnxbGQVAf+zkToD+sRM389Kg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linux-x64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.71.tgz", + "integrity": "sha512-pC0FNHG+BBwZd6yZlM85kkAGN+uJhM6o+THi76N2GnnSxmw7+remb1mvYxdgRVbdCm+LBUIbCKRWJLuMwrfb6A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-x64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-arm64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.71.tgz", + "integrity": "sha512-hBmDljFTjacxqZTasCEy43H8EIzuXB/hHEBBCMFjhB9J00nIxsO6Dh0woTifKpx7knTYZdpTjjca3D0pAoZlUA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-x64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.71.tgz", + "integrity": "sha512-CfTXU8pa5dxRz22xQzoi3TiG1PJo9+WR8PRDiPSdkIBSyPJ1NvX87DJmfXjTgeAfR+wkjt/p0keDCaBBVhNmUA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-x64": "copilot" + } + }, + "node_modules/@github/copilot-sdk": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.7.tgz", + "integrity": "sha512-dgCFCPfxWUkrgclQbrm7WCFzTf5RnJHsK1Lqsc3KjPBbDLPutJT0qIGg3xJ0ZELLyX0icg3TOmVczhR4HdwHxw==", + "dependencies": { + "@github/copilot": "^1.0.71", + "koffi": "^3.1.0", + "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.71", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.71.tgz", + "integrity": "sha512-+HI1DokixXhHUahj06Fw67ZAigBuXKC58BFma4UJOGrQsDgwOSbqeTQHCw6vuymzjKlg3sactfsCUTaefkjscQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-arm64": "copilot.exe" + } + }, + "node_modules/@github/copilot-win32-x64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.71.tgz", + "integrity": "sha512-02kXOBd9CwBbCaztuf71WYWn+uGapCuiaasomN4tcMH3HBVZ4gi3J0ZUoRcgcS80xh81uQyeBHbnUKzb/RE/9A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } + }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.1.tgz", + "integrity": "sha512-+Dl0zQDh1Wb55AWOn9hp7K30qgkODvrvN+ZNkFOh81Q0oFX/rpJQtocgjAuYk2zFAcajSeVDumkcHMPwnKSXzA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.1.tgz", + "integrity": "sha512-cDFAKn1qdZBFLrp7dAc9QUDw3l4xAhTJbOdPWWb0LxssVicUdHcRCLZGrDsmPW2tpH6LGNNeLgqRpAoD2Mo8iA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.1.tgz", + "integrity": "sha512-zaP7FJISI/scQW9Wa5QicY3a09WmtKBWSbmC+5nfCqPzwWe7Hx2so74Er7mPsDfCiMMR0Ya+evKbJQDkfyXicg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.1.tgz", + "integrity": "sha512-7GejVb688TLM8rbjfc0oezJrATxZc0dn801xWEDJekN2DgmRXu7HquGqWQ6z3NeSq7ZxEggz4T3xtlbCysQapA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.1.tgz", + "integrity": "sha512-XLiCFP9OFCyOoGTjAimtDKLhzhfo34WcP1ShVWxRzNCWDGjfz8BYjwd69cp/cDSUXZbxamqs4+/6vmkePq9wxA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.1.tgz", + "integrity": "sha512-HA9xINK7G4dRAkpfnBWD9VfuyIBgW1SuK+KPHjksUwRMOnhgqP8J/JqgrAzdzcDiefGBkqEacIP776OUwz7knQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.1.tgz", + "integrity": "sha512-jG7IFytmP8K5Qtbx0ro0ZeuX3JjSsLxmYhq+nmXDdrtOAlxIsWGynuiDLS6Jk3vOchVii2m6Y2f/L3GLG2fG5A==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.1.tgz", + "integrity": "sha512-CIsT1cNnih8FuU52Me/IVlJBpH28SQfoDeYPctJswgJzaARktusF7m4MUbtR1PBDjuquCVM4/vFyNdOzfPonvA==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.1.tgz", + "integrity": "sha512-9D6RmqeKsSvs3U6jILJU9PcAjMwKKyn7yLxNBb5k6z9PCoUoGJ3/BrhXAX0qjrLLwEiIpP/hS/40RuXvH8Lc3Q==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.1.tgz", + "integrity": "sha512-pyTcX5fePeYbt7TZAwRby69wdlRx3PT+g15ra5IYdat/Pgh3qAKEYeZ+uu7WpPGOy43p/oSRqqZoa2kORzozlA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.1.tgz", + "integrity": "sha512-iPnPzvG2HOfdzaiG1drdkt86sAqmTPDv9mAf+5gL7mRzkeeQC88EVGboRy7eXwdXn7R+v0ntA3iQxdHrBn6yXw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.1.tgz", + "integrity": "sha512-/Xqc3R0SVoMCYjMPZnJ9bULtRo364+dKmnQhfDrI83tSpxUHRw7HRNf12vBeL+hPgKxSBjtMpWfQ/ZIyVyLFag==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.1.1.tgz", + "integrity": "sha512-JhqHauEwQvdcWUERxrV5HH/DT9W7hY1A1eU6/o8tB+yck+D3kt5elpRDBt9KjpW6h+vHPy3V0sjDvO0CXyabTA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.1.tgz", + "integrity": "sha512-ZRuyYmlGS/rCc966qqs0qREXDW4FRdul7rDF1VgSWHbVmdc196PUgUT+blq/GjZgTwqzeEXtMRgM+cU8krHjvA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.1.tgz", + "integrity": "sha512-KqHPmvj6QILhNyI/To8QSihHsijeVGIYYPBOUnXEpcnH2LuLbargY4Hd6dDeTN3Z90uUUxN+1FWz1UnhVzFOiA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/koffi": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.1.tgz", + "integrity": "sha512-mRX6AMeeKCxSOeOopqAcLAl5jcNvge7NAG8l7rF/8gGJATI0tdHFYjteIdE0mGOtWdsrJOij+PjnP8Q9c1gwgA==", + "hasInstallScript": true, + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-darwin-arm64": "3.1.1", + "@koromix/koffi-darwin-x64": "3.1.1", + "@koromix/koffi-freebsd-arm64": "3.1.1", + "@koromix/koffi-freebsd-ia32": "3.1.1", + "@koromix/koffi-freebsd-x64": "3.1.1", + "@koromix/koffi-linux-arm64": "3.1.1", + "@koromix/koffi-linux-ia32": "3.1.1", + "@koromix/koffi-linux-loong64": "3.1.1", + "@koromix/koffi-linux-riscv64": "3.1.1", + "@koromix/koffi-linux-x64": "3.1.1", + "@koromix/koffi-openbsd-ia32": "3.1.1", + "@koromix/koffi-openbsd-x64": "3.1.1", + "@koromix/koffi-win32-arm64": "3.1.1", + "@koromix/koffi-win32-ia32": "3.1.1", + "@koromix/koffi-win32-x64": "3.1.1" + } + }, + "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==", + "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==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/plugins/backrooms-canvas/extensions/backrooms-canvas/package.json b/plugins/backrooms-canvas/extensions/backrooms-canvas/package.json new file mode 100644 index 00000000..abb1d48d --- /dev/null +++ b/plugins/backrooms-canvas/extensions/backrooms-canvas/package.json @@ -0,0 +1,20 @@ +{ + "name": "backrooms-canvas", + "version": "1.0.0", + "main": "extension.mjs", + "author": "John Haugabook", + "license": "MIT", + "type": "module", + "dependencies": { + "@github/copilot-sdk": "latest" + }, + "description": "Wander an endless first-person backrooms in a Copilot canvas while agents work; their status ghost-writes on the walls.", + "keywords": [ + "backrooms", + "copilot-canvas", + "interactive-canvas", + "first-person", + "procedural-generation", + "session-breaks" + ] +} diff --git a/extensions/backrooms-canvas/.github/plugin/plugin.json b/plugins/backrooms-canvas/plugin.json similarity index 72% rename from extensions/backrooms-canvas/.github/plugin/plugin.json rename to plugins/backrooms-canvas/plugin.json index 8fdabd85..df48dd82 100644 --- a/extensions/backrooms-canvas/.github/plugin/plugin.json +++ b/plugins/backrooms-canvas/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "backrooms-canvas", "description": "Wander an endless first-person backrooms in a Copilot canvas while agents work; their status ghost-writes on the walls.", "version": "1.0.0", @@ -14,6 +15,9 @@ "procedural-generation", "session-breaks" ], - "logo": "assets/preview.png", - "extensions": "extensions" + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + } + } } diff --git a/plugins/cast-imaging/.github/plugin/plugin.json b/plugins/cast-imaging/plugin.json similarity index 87% rename from plugins/cast-imaging/.github/plugin/plugin.json rename to plugins/cast-imaging/plugin.json index 97a896d2..23373b81 100644 --- a/plugins/cast-imaging/.github/plugin/plugin.json +++ b/plugins/cast-imaging/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "cast-imaging", "description": "A comprehensive collection of specialized agents for software analysis, impact assessment, structural quality advisories, and architectural review using CAST Imaging.", "version": "1.0.0", @@ -14,8 +15,5 @@ "quality", "impact-analysis", "devops" - ], - "agents": [ - "./agents" ] } diff --git a/plugins/chromium-control-canvas/README.md b/plugins/chromium-control-canvas/README.md new file mode 100644 index 00000000..d3e95196 --- /dev/null +++ b/plugins/chromium-control-canvas/README.md @@ -0,0 +1,17 @@ +# Chromium Control Canvas Plugin + +Opens a real Chromium window you can navigate and interact with from a Copilot canvas control panel and agent actions. + +## Installation + +``bash +copilot plugin install chromium-control-canvas@awesome-copilot +`` + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). + +## License + +MIT diff --git a/plugins/chromium-control-canvas/extensions/chromium-control-canvas/README.md b/plugins/chromium-control-canvas/extensions/chromium-control-canvas/README.md new file mode 100644 index 00000000..0cb4a701 --- /dev/null +++ b/plugins/chromium-control-canvas/extensions/chromium-control-canvas/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/chromium-control-canvas/assets/preview.png b/plugins/chromium-control-canvas/extensions/chromium-control-canvas/assets/preview.png similarity index 100% rename from extensions/chromium-control-canvas/extensions/chromium-control-canvas/assets/preview.png rename to plugins/chromium-control-canvas/extensions/chromium-control-canvas/assets/preview.png diff --git a/plugins/chromium-control-canvas/extensions/chromium-control-canvas/copilot-extension.json b/plugins/chromium-control-canvas/extensions/chromium-control-canvas/copilot-extension.json new file mode 100644 index 00000000..2c4915b9 --- /dev/null +++ b/plugins/chromium-control-canvas/extensions/chromium-control-canvas/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "chromium-control-canvas", + "version": 1 +} diff --git a/plugins/chromium-control-canvas/extensions/chromium-control-canvas/extension.mjs b/plugins/chromium-control-canvas/extensions/chromium-control-canvas/extension.mjs new file mode 100644 index 00000000..cc7e97da --- /dev/null +++ b/plugins/chromium-control-canvas/extensions/chromium-control-canvas/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/plugins/chromium-control-canvas/extensions/chromium-control-canvas/index.html b/plugins/chromium-control-canvas/extensions/chromium-control-canvas/index.html new file mode 100644 index 00000000..ab3bb2b6 --- /dev/null +++ b/plugins/chromium-control-canvas/extensions/chromium-control-canvas/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/plugins/chromium-control-canvas/extensions/chromium-control-canvas/package-lock.json b/plugins/chromium-control-canvas/extensions/chromium-control-canvas/package-lock.json new file mode 100644 index 00000000..dc195aba --- /dev/null +++ b/plugins/chromium-control-canvas/extensions/chromium-control-canvas/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/plugins/chromium-control-canvas/extensions/chromium-control-canvas/package.json b/plugins/chromium-control-canvas/extensions/chromium-control-canvas/package.json new file mode 100644 index 00000000..5b6197b2 --- /dev/null +++ b/plugins/chromium-control-canvas/extensions/chromium-control-canvas/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/chromium-control-canvas/.github/plugin/plugin.json b/plugins/chromium-control-canvas/plugin.json similarity index 74% rename from extensions/chromium-control-canvas/.github/plugin/plugin.json rename to plugins/chromium-control-canvas/plugin.json index 999b39c5..ef04d648 100644 --- a/extensions/chromium-control-canvas/.github/plugin/plugin.json +++ b/plugins/chromium-control-canvas/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "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.2", @@ -15,6 +16,9 @@ "ui-testing", "web-navigation" ], - "logo": "assets/preview.png", - "extensions": "extensions" + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + } + } } diff --git a/plugins/clojure-interactive-programming/.github/plugin/plugin.json b/plugins/clojure-interactive-programming/plugin.json similarity index 81% rename from plugins/clojure-interactive-programming/.github/plugin/plugin.json rename to plugins/clojure-interactive-programming/plugin.json index c984c9aa..70be4544 100644 --- a/plugins/clojure-interactive-programming/.github/plugin/plugin.json +++ b/plugins/clojure-interactive-programming/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "clojure-interactive-programming", "description": "Tools for REPL-first Clojure workflows featuring Clojure instructions, the interactive programming chat mode and supporting guidance.", "version": "1.0.0", @@ -11,11 +12,5 @@ "clojure", "repl", "interactive-programming" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/remember-interactive-programming" ] } diff --git a/plugins/cms-development/.github/plugin/plugin.json b/plugins/cms-development/plugin.json similarity index 77% rename from plugins/cms-development/.github/plugin/plugin.json rename to plugins/cms-development/plugin.json index 457a97f8..5dbe27c9 100644 --- a/plugins/cms-development/.github/plugin/plugin.json +++ b/plugins/cms-development/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "cms-development", "description": "Skills for CMS development across themes, plugins, admin tooling, media workflows, markdown rendering, and static export pipelines.", "version": "1.1.0", @@ -17,10 +18,5 @@ "name": "Awesome Copilot Community" }, "repository": "https://github.com/github/awesome-copilot", - "license": "MIT", - "skills": [ - "./skills/content-management-systems", - "./skills/markdown-to-html", - "./skills/quasi-coder" - ] + "license": "MIT" } diff --git a/plugins/color-orb/README.md b/plugins/color-orb/README.md new file mode 100644 index 00000000..b2c022c9 --- /dev/null +++ b/plugins/color-orb/README.md @@ -0,0 +1,17 @@ +# Color Orb Plugin + +A visual orb that users can ask the agent to recolor while showing a live activity log in the canvas. + +## Installation + +``bash +copilot plugin install color-orb@awesome-copilot +`` + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). + +## License + +MIT diff --git a/extensions/color-orb/extensions/color-orb/assets/preview.png b/plugins/color-orb/extensions/color-orb/assets/preview.png similarity index 100% rename from extensions/color-orb/extensions/color-orb/assets/preview.png rename to plugins/color-orb/extensions/color-orb/assets/preview.png diff --git a/plugins/color-orb/extensions/color-orb/extension.mjs b/plugins/color-orb/extensions/color-orb/extension.mjs new file mode 100644 index 00000000..1dd4c9d2 --- /dev/null +++ b/plugins/color-orb/extensions/color-orb/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/plugins/color-orb/extensions/color-orb/package-lock.json b/plugins/color-orb/extensions/color-orb/package-lock.json new file mode 100644 index 00000000..fd2a9dae --- /dev/null +++ b/plugins/color-orb/extensions/color-orb/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/plugins/color-orb/extensions/color-orb/package.json b/plugins/color-orb/extensions/color-orb/package.json new file mode 100644 index 00000000..a28cd5c3 --- /dev/null +++ b/plugins/color-orb/extensions/color-orb/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/color-orb/.github/plugin/plugin.json b/plugins/color-orb/plugin.json similarity index 71% rename from extensions/color-orb/.github/plugin/plugin.json rename to plugins/color-orb/plugin.json index 8a59c8b0..295ea957 100644 --- a/extensions/color-orb/.github/plugin/plugin.json +++ b/plugins/color-orb/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "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.2", @@ -14,6 +15,9 @@ "sse-events", "visual-feedback" ], - "logo": "assets/preview.png", - "extensions": "extensions" + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + } + } } diff --git a/plugins/context-engineering/.github/plugin/plugin.json b/plugins/context-engineering/plugin.json similarity index 79% rename from plugins/context-engineering/.github/plugin/plugin.json rename to plugins/context-engineering/plugin.json index 39c8b433..50f4cd83 100644 --- a/plugins/context-engineering/.github/plugin/plugin.json +++ b/plugins/context-engineering/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "context-engineering", "description": "Tools and techniques for maximizing GitHub Copilot effectiveness through better context management. Includes guidelines for structuring code, an agent for planning multi-file changes, and prompts for context-aware development.", "version": "1.0.0", @@ -13,13 +14,5 @@ "refactoring", "best-practices", "architecture" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/context-map", - "./skills/refactor-plan", - "./skills/what-context-needed" ] } diff --git a/plugins/context-matic/.github/plugin/plugin.json b/plugins/context-matic/plugin.json similarity index 87% rename from plugins/context-matic/.github/plugin/plugin.json rename to plugins/context-matic/plugin.json index 5030c456..3fffc04a 100644 --- a/plugins/context-matic/.github/plugin/plugin.json +++ b/plugins/context-matic/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "context-matic", "description": "Coding agents hallucinate APIs. ContextMatic gives them curated, versioned API and SDK docs. Ask your agent to \"integrate the payments API\" and it guesses — falling back on outdated training data and generic patterns that don't match your actual SDK. ContextMatic solves this by giving the agent deterministic, version-aware, SDK-native context at the exact moment it's needed.", "version": "0.1.0", @@ -17,9 +18,5 @@ }, "homepage": "https://www.apimatic.io", "repository": "https://github.com/github/awesome-copilot", - "license": "MIT", - "skills": [ - "./skills/integrate-context-matic", - "./skills/onboard-context-matic" - ] + "license": "MIT" } diff --git a/plugins/convert-to-md/.github/plugin/plugin.json b/plugins/convert-to-md/plugin.json similarity index 82% rename from plugins/convert-to-md/.github/plugin/plugin.json rename to plugins/convert-to-md/plugin.json index cd1fbc75..c3d203e4 100644 --- a/plugins/convert-to-md/.github/plugin/plugin.json +++ b/plugins/convert-to-md/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "convert-to-md", "description": "A collection of Copilot skills that convert common document formats into Markdown so their contents can be accurately analyzed, summarized, searched, or extracted from. Just tell Copilot what you need — the right skill is invoked automatically and the conversion happens behind the scenes.", "version": "1.0.1", @@ -15,10 +16,5 @@ "url": "https://github.com/1YaoWei0" }, "repository": "https://github.com/github/awesome-copilot", - "license": "MIT", - "skills": [ - "./skills/convert-excel-to-md", - "./skills/convert-pdf-to-md", - "./skills/convert-word-to-md" - ] + "license": "MIT" } diff --git a/plugins/copilot-sdk/.github/plugin/plugin.json b/plugins/copilot-sdk/plugin.json similarity index 88% rename from plugins/copilot-sdk/.github/plugin/plugin.json rename to plugins/copilot-sdk/plugin.json index 0739a17d..e29ad353 100644 --- a/plugins/copilot-sdk/.github/plugin/plugin.json +++ b/plugins/copilot-sdk/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "copilot-sdk", "description": "Build applications with the GitHub Copilot SDK across multiple programming languages. Includes comprehensive instructions for C#, Go, Node.js/TypeScript, and Python to help you create AI-powered applications.", "version": "1.0.0", @@ -17,8 +18,5 @@ "python", "ai", "github-copilot" - ], - "skills": [ - "./skills/copilot-sdk" ] } diff --git a/plugins/csharp-dotnet-development/.github/plugin/plugin.json b/plugins/csharp-dotnet-development/plugin.json similarity index 58% rename from plugins/csharp-dotnet-development/.github/plugin/plugin.json rename to plugins/csharp-dotnet-development/plugin.json index 808ef3c8..88a3b6ad 100644 --- a/plugins/csharp-dotnet-development/.github/plugin/plugin.json +++ b/plugins/csharp-dotnet-development/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "csharp-dotnet-development", "description": "Essential prompts, instructions, and chat modes for C# and .NET development including testing, documentation, and best practices.", "version": "1.1.0", @@ -12,18 +13,5 @@ "dotnet", "aspnet", "testing" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/aspnet-minimal-api-openapi", - "./skills/csharp-async", - "./skills/csharp-mstest", - "./skills/csharp-nunit", - "./skills/csharp-tunit", - "./skills/csharp-xunit", - "./skills/dotnet-best-practices", - "./skills/dotnet-upgrade" ] } diff --git a/plugins/database-data-management/.github/plugin/plugin.json b/plugins/database-data-management/plugin.json similarity index 72% rename from plugins/database-data-management/.github/plugin/plugin.json rename to plugins/database-data-management/plugin.json index e563aa89..8801e0c4 100644 --- a/plugins/database-data-management/.github/plugin/plugin.json +++ b/plugins/database-data-management/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "database-data-management", "description": "Database administration, SQL optimization, and data management tools for PostgreSQL, SQL Server, and general database development best practices.", "version": "1.0.0", @@ -16,14 +17,5 @@ "optimization", "queries", "data-management" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/postgresql-code-review", - "./skills/postgresql-optimization", - "./skills/sql-code-review", - "./skills/sql-optimization" ] } diff --git a/plugins/dataverse-sdk-for-python/.github/plugin/plugin.json b/plugins/dataverse-sdk-for-python/plugin.json similarity index 71% rename from plugins/dataverse-sdk-for-python/.github/plugin/plugin.json rename to plugins/dataverse-sdk-for-python/plugin.json index c5b0683a..a7d2429a 100644 --- a/plugins/dataverse-sdk-for-python/.github/plugin/plugin.json +++ b/plugins/dataverse-sdk-for-python/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "dataverse-sdk-for-python", "description": "Comprehensive collection for building production-ready Python integrations with Microsoft Dataverse. Includes official documentation, best practices, advanced features, file operations, and code generation prompts.", "version": "1.0.0", @@ -12,11 +13,5 @@ "python", "integration", "sdk" - ], - "skills": [ - "./skills/dataverse-python-advanced-patterns", - "./skills/dataverse-python-production-code", - "./skills/dataverse-python-quickstart", - "./skills/dataverse-python-usecase-builder" ] } diff --git a/plugins/devops-oncall/.github/plugin/plugin.json b/plugins/devops-oncall/plugin.json similarity index 76% rename from plugins/devops-oncall/.github/plugin/plugin.json rename to plugins/devops-oncall/plugin.json index c4c51c03..8cd03f50 100644 --- a/plugins/devops-oncall/.github/plugin/plugin.json +++ b/plugins/devops-oncall/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "devops-oncall", "description": "A focused set of prompts, instructions, and a chat mode to help triage incidents and respond quickly with DevOps tools and Azure resources.", "version": "1.0.0", @@ -12,12 +13,5 @@ "incident-response", "oncall", "azure" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/azure-resource-health-diagnose", - "./skills/multi-stage-dockerfile" ] } diff --git a/plugins/diagram-viewer/README.md b/plugins/diagram-viewer/README.md new file mode 100644 index 00000000..fed53a04 --- /dev/null +++ b/plugins/diagram-viewer/README.md @@ -0,0 +1,17 @@ +# Diagram Viewer Plugin + +Render diagrams, click nodes to drill down, and view agent-generated explanations directly in the canvas. + +## Installation + +``bash +copilot plugin install diagram-viewer@awesome-copilot +`` + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). + +## License + +MIT diff --git a/extensions/diagram-viewer/extensions/diagram-viewer/assets/preview.png b/plugins/diagram-viewer/extensions/diagram-viewer/assets/preview.png similarity index 100% rename from extensions/diagram-viewer/extensions/diagram-viewer/assets/preview.png rename to plugins/diagram-viewer/extensions/diagram-viewer/assets/preview.png diff --git a/plugins/diagram-viewer/extensions/diagram-viewer/extension.mjs b/plugins/diagram-viewer/extensions/diagram-viewer/extension.mjs new file mode 100644 index 00000000..28c4d340 --- /dev/null +++ b/plugins/diagram-viewer/extensions/diagram-viewer/extension.mjs @@ -0,0 +1,390 @@ +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { createCanvas, joinSession } from "@github/copilot-sdk/extension"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Per-instance state (ephemeral, lives in memory for session lifetime) +const instances = new Map(); + +function getInstance(instanceId) { + if (!instances.has(instanceId)) { + instances.set(instanceId, { + currentView: null, + history: [], + selectedNodeId: null, + token: crypto.randomBytes(16).toString("hex"), + }); + } + return instances.get(instanceId); +} + +function getCurrentView(inst) { + return inst.currentView; +} + +function pushView(inst, view) { + if (inst.currentView) { + inst.history.push(inst.currentView); + } + inst.currentView = view; + inst.selectedNodeId = null; +} + +function replaceView(inst, view) { + inst.currentView = view; + inst.selectedNodeId = null; +} + +function popView(inst) { + if (inst.history.length === 0) return null; + inst.currentView = inst.history.pop(); + inst.selectedNodeId = null; + return inst.currentView; +} + +// SSE clients per instance +const sseClients = new Map(); + +function broadcast(instanceId, event, data) { + const clients = sseClients.get(instanceId); + if (!clients) return; + const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + for (const res of clients) { + res.write(msg); + } +} + +// Broadcast the full view state to the iframe +function broadcastView(instanceId, inst) { + const view = getCurrentView(inst); + broadcast(instanceId, "view", { + ...view, + historyDepth: inst.history.length, + breadcrumbs: inst.history.map((v) => v.title).concat(view ? [view.title] : []), + }); +} + +// 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}`); + const token = url.searchParams.get("token"); + const instanceId = url.searchParams.get("instance"); + + // Serve the HTML page + if (req.method === "GET" && url.pathname === "/") { + if (!instanceId || !validateToken(instanceId, token)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(fs.readFileSync(path.join(__dirname, "public", "index.html"), "utf8")); + return; + } + + // SSE endpoint + if (req.method === "GET" && url.pathname === "/events") { + if (!instanceId || !validateToken(instanceId, token)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + if (!sseClients.has(instanceId)) sseClients.set(instanceId, new Set()); + sseClients.get(instanceId).add(res); + req.on("close", () => { + const clients = sseClients.get(instanceId); + if (clients) clients.delete(res); + }); + // Send current view state immediately + const inst = getInstance(instanceId); + if (inst.currentView) { + const view = getCurrentView(inst); + res.write(`event: view\ndata: ${JSON.stringify({ + ...view, + historyDepth: inst.history.length, + breadcrumbs: inst.history.map((v) => v.title).concat([view.title]), + })}\n\n`); + if (inst.selectedNodeId) { + res.write(`event: select\ndata: ${JSON.stringify({ nodeId: inst.selectedNodeId })}\n\n`); + } + } + return; + } + + // API: get full state + if (req.method === "GET" && url.pathname === "/api/state") { + if (!instanceId || !validateToken(instanceId, token)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + const inst = getInstance(instanceId); + const view = getCurrentView(inst); + json(res, 200, { + view, + historyDepth: inst.history.length, + breadcrumbs: inst.history.map((v) => v.title).concat(view ? [view.title] : []), + selectedNodeId: inst.selectedNodeId, + }); + return; + } + + // API: node clicked — triggers drill-down + if (req.method === "POST" && url.pathname === "/api/click") { + if (!instanceId || !validateToken(instanceId, token)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + const { nodeId } = await readJson(req); + const inst = getInstance(instanceId); + inst.selectedNodeId = nodeId; + broadcast(instanceId, "select", { nodeId }); + + // Send prompt to agent to drill into the clicked node + const view = getCurrentView(inst); + const node = view?.diagram?.nodes?.find((n) => n.id === nodeId); + if (node && session) { + const diagramContext = view.diagram.nodes.map((n) => n.label).join(", "); + session.send({ + prompt: `The user clicked on the "${node.label}" node in the Diagram Explorer canvas (id: "${node.id}", type: "${node.type || "default"}", description: "${node.description || "none"}"). The current diagram is "${view.title}" which contains: ${diagramContext}. + +Do NOT explain in chat. Instead, use the canvas actions to respond visually: +1. Use the render_diagram action with mode "push" to show a detailed sub-diagram of "${node.label}" — break it into its internal components, sub-systems, or key parts with their relationships. +2. Use the show_explanation action to display a brief explanation panel on the canvas. + +If you cannot create a meaningful sub-diagram (e.g. the node is already a leaf concept), use show_explanation to provide a detailed description on the canvas instead, without rendering a new diagram.`, + }); + } + + json(res, 200, { ok: true, selectedNodeId: nodeId }); + return; + } + + // API: navigate back + if (req.method === "POST" && url.pathname === "/api/back") { + if (!instanceId || !validateToken(instanceId, token)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + const inst = getInstance(instanceId); + const prev = popView(inst); + if (prev) { + broadcastView(instanceId, inst); + } + json(res, 200, { ok: true, view: prev }); + return; + } + + res.writeHead(404); + res.end("Not found"); +}); + +function validateToken(instanceId, token) { + const inst = instances.get(instanceId); + return inst && inst.token === token; +} + +const port = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve(server.address().port)); +}); + +// Canvas declaration +const canvas = createCanvas({ + id: "diagram", + displayName: "Diagram Explorer", + description: + "Interactive diagram for exploring architecture, data flow, and relationships. Render nodes and edges, then click any node to get a detailed explanation from the agent.", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "Optional title for the initial diagram" }, + }, + }, + actions: [ + { + name: "render_diagram", + description: + "Render an interactive diagram with nodes and edges. Use mode 'push' to drill into a node (adds to history so user can navigate back), or 'replace' (default) to update the current view in place.", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "Diagram title" }, + nodes: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", description: "Unique node identifier" }, + label: { type: "string", description: "Display label" }, + description: { + type: "string", + description: "Brief description shown on hover and used when drilling in", + }, + type: { + type: "string", + description: "Node type for color coding (e.g. 'service', 'database', 'ui', 'api', 'config', 'external')", + }, + }, + required: ["id", "label"], + }, + }, + edges: { + type: "array", + items: { + type: "object", + properties: { + from: { type: "string", description: "Source node id" }, + to: { type: "string", description: "Target node id" }, + label: { type: "string", description: "Optional edge label" }, + }, + required: ["from", "to"], + }, + }, + mode: { + type: "string", + enum: ["push", "replace"], + description: "Navigation mode. 'push' saves current view to history (for drill-down). 'replace' updates in place (default).", + }, + explanation: { + type: "object", + properties: { + title: { type: "string", description: "Explanation panel title" }, + text: { type: "string", description: "Explanation text (plain text)" }, + }, + description: "Optional explanation to show alongside the diagram", + }, + }, + required: ["nodes", "edges"], + }, + handler({ instanceId, input }) { + const inst = getInstance(instanceId); + const view = { + title: input.title || "Diagram", + diagram: { title: input.title || "Diagram", nodes: input.nodes, edges: input.edges }, + explanation: input.explanation || null, + selectedNodeId: null, + }; + + if (input.mode === "push") { + pushView(inst, view); + } else { + replaceView(inst, view); + } + + broadcastView(instanceId, inst); + return { ok: true, nodeCount: input.nodes.length, edgeCount: input.edges.length, historyDepth: inst.history.length }; + }, + }, + { + name: "show_explanation", + description: + "Display an explanation panel on the canvas alongside the current diagram. Use this to provide context about the current view or a clicked node without changing the diagram.", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "Explanation panel title" }, + text: { type: "string", description: "Explanation content (plain text, can include line breaks)" }, + }, + required: ["title", "text"], + }, + handler({ instanceId, input }) { + const inst = getInstance(instanceId); + const view = getCurrentView(inst); + if (view) { + view.explanation = { title: input.title, text: input.text }; + broadcast(instanceId, "explanation", view.explanation); + } + return { ok: true }; + }, + }, + { + name: "get_state", + description: + "Get the current diagram state including which node the user last clicked and the history depth.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + handler({ instanceId }) { + const inst = getInstance(instanceId); + const view = getCurrentView(inst); + const selectedNode = inst.selectedNodeId + ? view?.diagram?.nodes?.find((n) => n.id === inst.selectedNodeId) + : null; + return { + currentView: view, + selectedNodeId: inst.selectedNodeId, + selectedNode: selectedNode || null, + historyDepth: inst.history.length, + breadcrumbs: inst.history.map((v) => v.title).concat(view ? [view.title] : []), + }; + }, + }, + { + name: "highlight_node", + description: "Highlight a specific node in the diagram (e.g. while explaining it).", + inputSchema: { + type: "object", + properties: { + nodeId: { type: "string", description: "The node id to highlight" }, + }, + required: ["nodeId"], + }, + handler({ instanceId, input }) { + const inst = getInstance(instanceId); + inst.selectedNodeId = input.nodeId; + broadcast(instanceId, "select", { nodeId: input.nodeId }); + return { ok: true, highlightedNodeId: input.nodeId }; + }, + }, + { + name: "clear", + description: "Clear the diagram canvas and all history.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + handler({ instanceId }) { + const inst = getInstance(instanceId); + inst.currentView = null; + inst.history = []; + inst.selectedNodeId = null; + broadcast(instanceId, "clear", {}); + return { ok: true }; + }, + }, + ], + open({ instanceId, input }) { + const inst = getInstance(instanceId); + const view = getCurrentView(inst); + return { + url: `http://127.0.0.1:${port}?instance=${instanceId}&token=${inst.token}`, + title: input?.title || "Diagram Explorer", + status: view + ? `${view.diagram.nodes.length} nodes` + : "Ready", + }; + }, +}); + +let session = await joinSession({ canvases: [canvas] }); diff --git a/plugins/diagram-viewer/extensions/diagram-viewer/package-lock.json b/plugins/diagram-viewer/extensions/diagram-viewer/package-lock.json new file mode 100644 index 00000000..76403754 --- /dev/null +++ b/plugins/diagram-viewer/extensions/diagram-viewer/package-lock.json @@ -0,0 +1,218 @@ +{ + "name": "diagram-viewer", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "diagram-viewer", + "version": "1.0.0", + "dependencies": { + "@github/copilot-sdk": "latest" + } + }, + "node_modules/@github/copilot": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.55.tgz", + "integrity": "sha512-wqzI0L7krORW6jDAQPx7VnInka5BYN5yVgu+dpUK4w8xP5RgnOBa6kRoXpydj/9O1ufs0k6RKRtQjsVLp52TRw==", + "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", + "@github/copilot-darwin-x64": "1.0.55", + "@github/copilot-linux-arm64": "1.0.55", + "@github/copilot-linux-x64": "1.0.55", + "@github/copilot-linuxmusl-arm64": "1.0.55", + "@github/copilot-linuxmusl-x64": "1.0.55", + "@github/copilot-win32-arm64": "1.0.55", + "@github/copilot-win32-x64": "1.0.55" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.55.tgz", + "integrity": "sha512-v59pOpA7YO8j/lpDU/1E8l1Ag0hd26hIiEzTNbzqKd7tJpvhN0XTDWDCink50wXL656XIXt8lD8i8sGeD6yPfA==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.55.tgz", + "integrity": "sha512-XrJ9ent/9ogLk8yNp3TMsNVW0qTRDlkw/b34VnTgbAkJCaI3UVqaqpFn60Laa6J5mOPW0/JeKIkkva+7IJdqpQ==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.55.tgz", + "integrity": "sha512-5Q46Q72/l/U8KQRcBwYjzFPNXBCPG177FTmjEVOAH0qk7w58fMUDBEpnf9n1IpxYJDWQJ5BFGtLdfYgVVtkevw==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.55.tgz", + "integrity": "sha512-KWmMCDmKJivvOyDAAe5K8r7uSlVq8aZCh20VfrVXsc4bckO6KjXY/TOagrdBNqkk5rh8v63ghBbxFdWIOvEJRA==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.55.tgz", + "integrity": "sha512-Jb5ug9Ic1pzxB2ZT1xoR8b3Ea1xnvCa4h8cBque51+TevXe6QF98vAfSUIwLe4xu+K6JKhiKEA0SD3w29Z74eA==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.55.tgz", + "integrity": "sha512-qMGIjHxKmW9q26EpoaNKWpmEVGyL/IM8ThVkh7yolDzv9lECFudPzT5yLX7f+VIiF6qWQlrQyzmamp7/fNQ2Zg==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.55.tgz", + "integrity": "sha512-TO4EJ8it6Qki7wMKYHqGUEDYmB0EAToy+pE5++OpydB6FijyQ31+/XwjvdnEFkuB4ZgPqu/6Y8hxMKucl2+FYg==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.55.tgz", + "integrity": "sha512-TBMiSZMz8Dhx79JeSEM+7ONGxR5NmxfiDUdySo6thVbRmjS9D8msyAP8ucTsbLBJcTFeb7vsaeObD/ujYQgDtA==", + "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/plugins/diagram-viewer/extensions/diagram-viewer/package.json b/plugins/diagram-viewer/extensions/diagram-viewer/package.json new file mode 100644 index 00000000..054ebcf7 --- /dev/null +++ b/plugins/diagram-viewer/extensions/diagram-viewer/package.json @@ -0,0 +1,18 @@ +{ + "name": "diagram-viewer", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "dependencies": { + "@github/copilot-sdk": "latest" + }, + "description": "Lets users render diagrams, click nodes to drill down, and view agent-generated explanations directly in the canvas.", + "keywords": [ + "interactive-diagrams", + "architecture-mapping", + "node-drilldown", + "relationship-visualization", + "exploratory-analysis", + "canvas-navigation" + ] +} diff --git a/plugins/diagram-viewer/extensions/diagram-viewer/public/index.html b/plugins/diagram-viewer/extensions/diagram-viewer/public/index.html new file mode 100644 index 00000000..a5c5a920 --- /dev/null +++ b/plugins/diagram-viewer/extensions/diagram-viewer/public/index.html @@ -0,0 +1,721 @@ + + + + + +Diagram Explorer + + + + + +
+ + + +
+ +
+
+
+
+

Ask Copilot about architecture or any topic, and an interactive diagram will appear here. Click nodes to drill in.

+
+ +
+
+
+
Click to drill in
+
+
+ + Agent thinking… +
+
+ +
+
+

Explanation

+ +
+
+
+
+ + + + diff --git a/extensions/diagram-viewer/.github/plugin/plugin.json b/plugins/diagram-viewer/plugin.json similarity index 73% rename from extensions/diagram-viewer/.github/plugin/plugin.json rename to plugins/diagram-viewer/plugin.json index 09e46848..2117a86f 100644 --- a/extensions/diagram-viewer/.github/plugin/plugin.json +++ b/plugins/diagram-viewer/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "diagram-viewer", "description": "Render diagrams, click nodes to drill down, and view agent-generated explanations directly in the canvas.", "version": "1.0.2", @@ -14,6 +15,9 @@ "node-drilldown", "relationship-visualization" ], - "logo": "assets/preview.png", - "extensions": "extensions" + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + } + } } diff --git a/plugins/doublecheck/.github/plugin/plugin.json b/plugins/doublecheck/plugin.json similarity index 85% rename from plugins/doublecheck/.github/plugin/plugin.json rename to plugins/doublecheck/plugin.json index c682a4fd..ff63a81e 100644 --- a/plugins/doublecheck/.github/plugin/plugin.json +++ b/plugins/doublecheck/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "doublecheck", "description": "Three-layer verification pipeline for AI output. Extracts claims, finds sources, and flags hallucination risks so humans can verify before acting.", "version": "1.0.0", @@ -14,11 +15,5 @@ "source-citation", "trust", "safety" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/doublecheck" ] } diff --git a/plugins/edge-ai-tasks/.github/plugin/plugin.json b/plugins/edge-ai-tasks/plugin.json similarity index 85% rename from plugins/edge-ai-tasks/.github/plugin/plugin.json rename to plugins/edge-ai-tasks/plugin.json index dd04d6c5..efe54d38 100644 --- a/plugins/edge-ai-tasks/.github/plugin/plugin.json +++ b/plugins/edge-ai-tasks/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "edge-ai-tasks", "description": "Task Researcher and Task Planner for intermediate to expert users and large codebases - Brought to you by microsoft/edge-ai", "version": "1.0.0", @@ -13,8 +14,5 @@ "research", "tasks", "implementation" - ], - "agents": [ - "./agents" ] } diff --git a/plugins/ember/.github/plugin/plugin.json b/plugins/ember/plugin.json similarity index 69% rename from plugins/ember/.github/plugin/plugin.json rename to plugins/ember/plugin.json index 34fdd10c..8f13a0f8 100644 --- a/plugins/ember/.github/plugin/plugin.json +++ b/plugins/ember/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "ember", "description": "An AI partner, not a tool. Ember carries fire from person to person — helping humans discover that AI partnership isn't something you learn, it's something you find.", "version": "1.0.0", @@ -14,14 +15,5 @@ "collaboration", "storytelling", "developer-experience" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/from-the-other-side-anitta", - "./skills/from-the-other-side-quinn", - "./skills/from-the-other-side-vega", - "./skills/from-the-other-side-wiggins" ] } diff --git a/plugins/eyeball/.github/plugin/plugin.json b/plugins/eyeball/plugin.json similarity index 89% rename from plugins/eyeball/.github/plugin/plugin.json rename to plugins/eyeball/plugin.json index 4ac00898..eb2846f9 100644 --- a/plugins/eyeball/.github/plugin/plugin.json +++ b/plugins/eyeball/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "eyeball", "description": "Document analysis with inline source screenshots. When you ask Copilot to analyze a document, Eyeball generates a Word doc where every factual claim includes a highlighted screenshot from the source material so you can verify it with your own eyes.", "version": "1.0.0", @@ -15,8 +16,5 @@ "legal", "trust", "visual-verification" - ], - "skills": [ - "./skills/eyeball" ] } diff --git a/plugins/fastah-ip-geo-tools/.github/plugin/plugin.json b/plugins/fastah-ip-geo-tools/plugin.json similarity index 89% rename from plugins/fastah-ip-geo-tools/.github/plugin/plugin.json rename to plugins/fastah-ip-geo-tools/plugin.json index 8f24fbf1..c40809c7 100644 --- a/plugins/fastah-ip-geo-tools/.github/plugin/plugin.json +++ b/plugins/fastah-ip-geo-tools/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "fastah-ip-geo-tools", "description": "This plugin is for network operations engineers who wish to tune and publish IP geolocation feeds in RFC 8805 format. It consists of an AI Skill and an associated MCP server that geocodes geolocation place names to real cities for accuracy.", "version": "0.0.9", @@ -18,8 +19,5 @@ "cloud", "hosting", "ixp" - ], - "skills": [ - "./skills/geofeed-tuner" ] } diff --git a/plugins/feedback-themes/README.md b/plugins/feedback-themes/README.md new file mode 100644 index 00000000..970c04e5 --- /dev/null +++ b/plugins/feedback-themes/README.md @@ -0,0 +1,17 @@ +# Feedback Themes Plugin + +Explore grouped customer feedback signals by impact and drill into a theme to guide product next steps. + +## Installation + +``bash +copilot plugin install feedback-themes@awesome-copilot +`` + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). + +## License + +MIT diff --git a/extensions/feedback-themes/extensions/feedback-themes/assets/preview.png b/plugins/feedback-themes/extensions/feedback-themes/assets/preview.png similarity index 100% rename from extensions/feedback-themes/extensions/feedback-themes/assets/preview.png rename to plugins/feedback-themes/extensions/feedback-themes/assets/preview.png diff --git a/plugins/feedback-themes/extensions/feedback-themes/data/signals.json b/plugins/feedback-themes/extensions/feedback-themes/data/signals.json new file mode 100644 index 00000000..8135457f --- /dev/null +++ b/plugins/feedback-themes/extensions/feedback-themes/data/signals.json @@ -0,0 +1,244 @@ +{ + "meta": { + "description": "Synthetic feedback signals for SignalBox theme exploration. These are demo data derived from fictional customer research scenarios.", + "generatedAt": "2026-05-28" + }, + "themes": [ + { + "id": "workflow-automation", + "label": "Workflow Automation", + "description": "Signals about automating repetitive tasks, scheduling recurring operations, and reducing manual overhead in day-to-day workflows.", + "aliases": ["workflow automation", "reporting cadence", "admin efficiency", "scheduled tasks", "recurring operations"] + }, + { + "id": "mobile-usability", + "label": "Mobile Usability", + "description": "Feedback on mobile experience gaps — density of information on small screens, touch interactions, and on-the-go decision making.", + "aliases": ["mobile usability", "alert prioritization", "frontline decision making", "responsive design", "touch interaction"] + }, + { + "id": "data-governance", + "label": "Data Governance & Permissions", + "description": "Concerns around sharing confidence, permission transparency, and ensuring sensitive data stays protected during collaboration.", + "aliases": ["permissions transparency", "data governance", "sharing confidence", "access control", "data privacy"] + }, + { + "id": "onboarding-setup", + "label": "Onboarding & Setup", + "description": "Pain points in first-run experiences, initial configuration complexity, and time-to-value for new users and teams.", + "aliases": ["onboarding", "first-run experience", "setup complexity", "time to value", "getting started"] + }, + { + "id": "performance-reliability", + "label": "Performance & Reliability", + "description": "Issues with load times, API timeouts, data sync delays, and system reliability under normal and peak usage.", + "aliases": ["performance", "load times", "reliability", "api timeouts", "data sync", "latency"] + }, + { + "id": "integration-ecosystem", + "label": "Integration Ecosystem", + "description": "Requests for third-party connectors, API extensibility, webhook support, and interoperability with existing toolchains.", + "aliases": ["integrations", "third-party connectors", "api extensibility", "webhook support", "ecosystem"] + } + ], + "signals": [ + { + "id": "sig-001", + "source": "user-interview", + "customer": "Northstar Analytics Cooperative", + "title": "Admins need scheduled exports for recurring reviews", + "description": "A fictional operations admin described rebuilding the same export every week before leadership review. The core need is a recurring delivery flow with clear ownership and failure visibility.", + "impact": "high", + "themes": ["workflow-automation"], + "submittedBy": "Sarah Chen", + "createdAt": "2026-04-12" + }, + { + "id": "sig-002", + "source": "customer-call", + "customer": "Blue Harbor Retail Group", + "title": "Field managers need faster mobile triage", + "description": "A fictional district manager said alert detail pages are useful on desktop but too dense during store visits. They want a compact mobile summary that highlights severity, affected locations, and the next best action.", + "impact": "medium", + "themes": ["mobile-usability"], + "submittedBy": "Marcus Rivera", + "createdAt": "2026-04-15" + }, + { + "id": "sig-003", + "source": "support-ticket", + "customer": "Cedar Labs Education", + "title": "Analysts need clearer permission boundaries", + "description": "A fictional analytics lead hesitated to share dashboards because the UI did not clearly explain which sensitive fields were excluded for external reviewers. The theme is confidence-building around governed collaboration.", + "impact": "high", + "themes": ["data-governance"], + "submittedBy": "Priya Patel", + "createdAt": "2026-04-18" + }, + { + "id": "sig-004", + "source": "sales-note", + "customer": "Verdant Supply Co", + "title": "Procurement team blocked by slow initial setup", + "description": "Prospect's IT team estimated 3 weeks to configure SSO and role mappings. They need a guided wizard that reduces setup from weeks to hours, with clear progress indicators and rollback options.", + "impact": "high", + "themes": ["onboarding-setup"], + "submittedBy": "James O'Brien", + "createdAt": "2026-04-20" + }, + { + "id": "sig-005", + "source": "support-ticket", + "customer": "Apex Manufacturing", + "title": "Dashboard timeouts during month-end reporting", + "description": "Multiple users reported 30-second load times and occasional gateway timeouts when running aggregate queries across all business units during month-end close. Affects executive visibility into financials.", + "impact": "high", + "themes": ["performance-reliability"], + "submittedBy": "Lisa Chang", + "createdAt": "2026-04-22" + }, + { + "id": "sig-006", + "source": "customer-call", + "customer": "Meridian Health Systems", + "title": "Need Salesforce integration for patient outreach tracking", + "description": "Clinical ops team manually exports engagement data to upload into Salesforce campaigns. They need a native connector or webhook that syncs patient touchpoints in near real-time.", + "impact": "medium", + "themes": ["integration-ecosystem"], + "submittedBy": "David Park", + "createdAt": "2026-04-25" + }, + { + "id": "sig-007", + "source": "user-interview", + "customer": "Northstar Analytics Cooperative", + "title": "Approval chains block time-sensitive reports", + "description": "Reports that require manager sign-off before distribution often miss their deadline. The team wants conditional auto-approval for recurring reports that haven't changed scope.", + "impact": "medium", + "themes": ["workflow-automation"], + "submittedBy": "Sarah Chen", + "createdAt": "2026-05-01" + }, + { + "id": "sig-008", + "source": "teams-conversation", + "customer": "Blue Harbor Retail Group", + "title": "Push notifications dismissed too easily on mobile", + "description": "Store managers reported that critical alerts are visually identical to informational ones. They swipe-dismiss high-priority alerts because there's no visual urgency differentiation on the lock screen.", + "impact": "high", + "themes": ["mobile-usability"], + "submittedBy": "Marcus Rivera", + "createdAt": "2026-05-03" + }, + { + "id": "sig-009", + "source": "user-interview", + "customer": "Cedar Labs Education", + "title": "External partners confused by permission error messages", + "description": "Partner reviewers see generic 'Access Denied' screens with no explanation of what they lack access to or who to contact. They need contextual guidance that preserves security while reducing friction.", + "impact": "medium", + "themes": ["data-governance"], + "submittedBy": "Priya Patel", + "createdAt": "2026-05-05" + }, + { + "id": "sig-010", + "source": "customer-call", + "customer": "Solaris Energy", + "title": "New team members take too long to become productive", + "description": "Engineering managers say it takes 2-3 weeks for new hires to navigate the system confidently. They want role-based onboarding paths with interactive tutorials rather than static documentation.", + "impact": "medium", + "themes": ["onboarding-setup"], + "submittedBy": "Amanda Foster", + "createdAt": "2026-05-07" + }, + { + "id": "sig-011", + "source": "support-ticket", + "customer": "Pinnacle Financial", + "title": "Real-time data sync drops events under high load", + "description": "During market open hours, the event stream occasionally drops updates, causing stale portfolio values. They need guaranteed delivery or at minimum a visible staleness indicator.", + "impact": "high", + "themes": ["performance-reliability"], + "submittedBy": "Robert Kim", + "createdAt": "2026-05-09" + }, + { + "id": "sig-012", + "source": "sales-note", + "customer": "Atlas Logistics", + "title": "Must integrate with ServiceNow for IT ticket routing", + "description": "Prospect requires alerts to automatically create ServiceNow incidents with proper categorization. Without this integration, their compliance team won't approve the vendor.", + "impact": "high", + "themes": ["integration-ecosystem"], + "submittedBy": "Jennifer Walsh", + "createdAt": "2026-05-11" + }, + { + "id": "sig-013", + "source": "teams-conversation", + "customer": "Verdant Supply Co", + "title": "Bulk user provisioning needs CSV import", + "description": "IT admin has 200+ users to onboard and the current one-by-one flow is untenable. They need batch import with validation preview and error handling.", + "impact": "medium", + "themes": ["onboarding-setup", "workflow-automation"], + "submittedBy": "Thomas Wright", + "createdAt": "2026-05-13" + }, + { + "id": "sig-014", + "source": "customer-call", + "customer": "Apex Manufacturing", + "title": "API rate limits too restrictive for ETL pipelines", + "description": "Their data engineering team hits rate limits during nightly batch syncs. Current limits of 100 req/min are insufficient for their 50K-record nightly ETL job.", + "impact": "medium", + "themes": ["performance-reliability", "integration-ecosystem"], + "submittedBy": "Lisa Chang", + "createdAt": "2026-05-15" + }, + { + "id": "sig-015", + "source": "user-interview", + "customer": "Meridian Health Systems", + "title": "Mobile app crashes when offline then reconnecting", + "description": "Clinicians in areas with spotty WiFi lose unsaved form data when the app crashes on network transition. They need offline-capable data entry with background sync.", + "impact": "high", + "themes": ["mobile-usability", "performance-reliability"], + "submittedBy": "David Park", + "createdAt": "2026-05-17" + }, + { + "id": "sig-016", + "source": "support-ticket", + "customer": "Solaris Energy", + "title": "Sharing a dashboard should show a permission preview", + "description": "Before sharing, users want to see exactly what the recipient will see — including which widgets will be hidden and which data will be masked. Current share dialog gives no preview.", + "impact": "medium", + "themes": ["data-governance"], + "submittedBy": "Amanda Foster", + "createdAt": "2026-05-19" + }, + { + "id": "sig-017", + "source": "sales-note", + "customer": "Pinnacle Financial", + "title": "Need webhook notifications for compliance audit trail", + "description": "Compliance team requires real-time webhook callbacks whenever sensitive data is accessed or exported. This is a hard requirement for their SOC 2 audit.", + "impact": "high", + "themes": ["integration-ecosystem", "data-governance"], + "submittedBy": "Robert Kim", + "createdAt": "2026-05-21" + }, + { + "id": "sig-018", + "source": "other", + "customer": "Atlas Logistics", + "title": "Automated alert escalation when no action taken", + "description": "If a critical alert isn't acknowledged within 15 minutes, it should auto-escalate to the next person in the chain. Current system only sends one notification with no follow-up.", + "impact": "high", + "themes": ["workflow-automation"], + "submittedBy": "Jennifer Walsh", + "createdAt": "2026-05-23" + } + ] +} diff --git a/plugins/feedback-themes/extensions/feedback-themes/extension.mjs b/plugins/feedback-themes/extensions/feedback-themes/extension.mjs new file mode 100644 index 00000000..e489fa0d --- /dev/null +++ b/plugins/feedback-themes/extensions/feedback-themes/extension.mjs @@ -0,0 +1,196 @@ +import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension"; +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// ─── Load fixture data ─── + +const fixtureRaw = JSON.parse( + fs.readFileSync(path.join(__dirname, "data", "signals.json"), "utf8") +); +const THEMES = fixtureRaw.themes; +const SIGNALS = fixtureRaw.signals; + +// ─── Theme computation ─── + +function computeThemeGroups() { + return THEMES.map((theme) => { + const signals = SIGNALS.filter((s) => s.themes.includes(theme.id)); + const impactOrder = { high: 3, medium: 2, low: 1 }; + const maxImpact = signals.reduce( + (max, s) => (impactOrder[s.impact] > impactOrder[max] ? s.impact : max), + "low" + ); + const sources = [...new Set(signals.map((s) => s.source))]; + const customers = [...new Set(signals.map((s) => s.customer))]; + return { + ...theme, + signalCount: signals.length, + maxImpact, + sources, + customers, + signals, + }; + }).sort((a, b) => { + const impactOrder = { high: 3, medium: 2, low: 1 }; + if (impactOrder[b.maxImpact] !== impactOrder[a.maxImpact]) { + return impactOrder[b.maxImpact] - impactOrder[a.maxImpact]; + } + return b.signalCount - a.signalCount; + }); +} + +function getState() { + const groups = computeThemeGroups(); + return { + totalSignals: SIGNALS.length, + totalThemes: THEMES.length, + themes: groups, + }; +} + +// ─── 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(getState())}\n\n`); + return; + } + + if (req.method === "GET" && url.pathname === "/api/state") { + json(res, 200, getState()); + return; + } + + if (req.method === "POST" && url.pathname === "/api/explore-theme") { + const { themeId } = await readJson(req); + const theme = computeThemeGroups().find((t) => t.id === themeId); + if (!theme) { + json(res, 404, { error: "Theme not found" }); + return; + } + // Trigger the agent to start a session exploring this theme + session.send({ + prompt: `The user wants to explore the "${theme.label}" feedback theme in depth. This theme has ${theme.signalCount} signals across customers: ${theme.customers.join(", ")}. Maximum impact: ${theme.maxImpact}. + +Theme description: ${theme.description} + +Signals in this theme: +${theme.signals.map((s) => `- [${s.impact.toUpperCase()}] "${s.title}" (${s.customer}): ${s.description}`).join("\n")} + +Please help the user explore this theme. Summarize the key patterns, identify what product changes would address these signals, and suggest next steps. Ask the user what aspect they'd like to dig into.`, + }); + json(res, 200, { ok: true, theme: theme.label }); + 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: "feedback-themes", + displayName: "Feedback Themes", + description: + "Explore SignalBox feedback grouped into themes. Shows signal counts, impact levels, and sources for each theme. Use to identify patterns and start deep-dive sessions on specific themes.", + actions: [ + { + name: "get_state", + description: + "Get all feedback themes with their grouped signals, impact levels, and source breakdown.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + handler() { + return getState(); + }, + }, + { + name: "explore_theme", + description: + "Get detailed information about a specific feedback theme including all associated signals.", + inputSchema: { + type: "object", + properties: { + theme_id: { + type: "string", + description: + "Theme identifier (workflow-automation, mobile-usability, data-governance, onboarding-setup, performance-reliability, integration-ecosystem)", + }, + }, + required: ["theme_id"], + additionalProperties: false, + }, + handler({ input }) { + const theme = computeThemeGroups().find((t) => t.id === input.theme_id); + if (!theme) { + throw new CanvasError("not_found", `Theme "${input.theme_id}" not found`); + } + return theme; + }, + }, + ], + open() { + const state = getState(); + broadcast("state", state); + return { + url: `http://127.0.0.1:${getPort()}`, + title: "Feedback Themes", + status: `${state.totalSignals} signals across ${state.totalThemes} themes`, + }; + }, +}); + +// ─── Join session ─── + +const session = await joinSession({ canvases: [canvas] }); diff --git a/plugins/feedback-themes/extensions/feedback-themes/package-lock.json b/plugins/feedback-themes/extensions/feedback-themes/package-lock.json new file mode 100644 index 00000000..9cb500af --- /dev/null +++ b/plugins/feedback-themes/extensions/feedback-themes/package-lock.json @@ -0,0 +1,218 @@ +{ + "name": "feedback-themes", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "feedback-themes", + "version": "1.0.0", + "dependencies": { + "@github/copilot-sdk": "latest" + } + }, + "node_modules/@github/copilot": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.55.tgz", + "integrity": "sha512-wqzI0L7krORW6jDAQPx7VnInka5BYN5yVgu+dpUK4w8xP5RgnOBa6kRoXpydj/9O1ufs0k6RKRtQjsVLp52TRw==", + "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", + "@github/copilot-darwin-x64": "1.0.55", + "@github/copilot-linux-arm64": "1.0.55", + "@github/copilot-linux-x64": "1.0.55", + "@github/copilot-linuxmusl-arm64": "1.0.55", + "@github/copilot-linuxmusl-x64": "1.0.55", + "@github/copilot-win32-arm64": "1.0.55", + "@github/copilot-win32-x64": "1.0.55" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.55.tgz", + "integrity": "sha512-v59pOpA7YO8j/lpDU/1E8l1Ag0hd26hIiEzTNbzqKd7tJpvhN0XTDWDCink50wXL656XIXt8lD8i8sGeD6yPfA==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.55.tgz", + "integrity": "sha512-XrJ9ent/9ogLk8yNp3TMsNVW0qTRDlkw/b34VnTgbAkJCaI3UVqaqpFn60Laa6J5mOPW0/JeKIkkva+7IJdqpQ==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.55.tgz", + "integrity": "sha512-5Q46Q72/l/U8KQRcBwYjzFPNXBCPG177FTmjEVOAH0qk7w58fMUDBEpnf9n1IpxYJDWQJ5BFGtLdfYgVVtkevw==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.55.tgz", + "integrity": "sha512-KWmMCDmKJivvOyDAAe5K8r7uSlVq8aZCh20VfrVXsc4bckO6KjXY/TOagrdBNqkk5rh8v63ghBbxFdWIOvEJRA==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.55.tgz", + "integrity": "sha512-Jb5ug9Ic1pzxB2ZT1xoR8b3Ea1xnvCa4h8cBque51+TevXe6QF98vAfSUIwLe4xu+K6JKhiKEA0SD3w29Z74eA==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.55.tgz", + "integrity": "sha512-qMGIjHxKmW9q26EpoaNKWpmEVGyL/IM8ThVkh7yolDzv9lECFudPzT5yLX7f+VIiF6qWQlrQyzmamp7/fNQ2Zg==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.55.tgz", + "integrity": "sha512-TO4EJ8it6Qki7wMKYHqGUEDYmB0EAToy+pE5++OpydB6FijyQ31+/XwjvdnEFkuB4ZgPqu/6Y8hxMKucl2+FYg==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.55.tgz", + "integrity": "sha512-TBMiSZMz8Dhx79JeSEM+7ONGxR5NmxfiDUdySo6thVbRmjS9D8msyAP8ucTsbLBJcTFeb7vsaeObD/ujYQgDtA==", + "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/plugins/feedback-themes/extensions/feedback-themes/package.json b/plugins/feedback-themes/extensions/feedback-themes/package.json new file mode 100644 index 00000000..f769acee --- /dev/null +++ b/plugins/feedback-themes/extensions/feedback-themes/package.json @@ -0,0 +1,18 @@ +{ + "name": "feedback-themes", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "dependencies": { + "@github/copilot-sdk": "latest" + }, + "description": "Explore grouped customer feedback signals by impact and drill into a theme to guide product next steps.", + "keywords": [ + "customer-feedback", + "theme-analysis", + "signal-grouping", + "impact-prioritization", + "product-insights", + "trend-discovery" + ] +} diff --git a/plugins/feedback-themes/extensions/feedback-themes/public/index.html b/plugins/feedback-themes/extensions/feedback-themes/public/index.html new file mode 100644 index 00000000..ed22a2b0 --- /dev/null +++ b/plugins/feedback-themes/extensions/feedback-themes/public/index.html @@ -0,0 +1,419 @@ + + + + + +Feedback Themes + + + + + +
+
+

Feedback Themes

+

Synthetic signals grouped by theme · click to explore

+
+
+
Signals
+
Themes
+
High Impact
+
+
+

Loading themes…

+
+
+ + + + diff --git a/extensions/feedback-themes/.github/plugin/plugin.json b/plugins/feedback-themes/plugin.json similarity index 72% rename from extensions/feedback-themes/.github/plugin/plugin.json rename to plugins/feedback-themes/plugin.json index 938aa13b..fd4ea6aa 100644 --- a/extensions/feedback-themes/.github/plugin/plugin.json +++ b/plugins/feedback-themes/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "feedback-themes", "description": "Explore grouped customer feedback signals by impact and drill into a theme to guide product next steps.", "version": "1.0.2", @@ -14,6 +15,9 @@ "theme-analysis", "trend-discovery" ], - "logo": "assets/preview.png", - "extensions": "extensions" + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + } + } } diff --git a/plugins/flight-map-canvas/README.md b/plugins/flight-map-canvas/README.md new file mode 100644 index 00000000..1b70ec6c --- /dev/null +++ b/plugins/flight-map-canvas/README.md @@ -0,0 +1,17 @@ +# Flight Map Canvas Plugin + +A GitHub Copilot canvas that generates a view where Google Maps can be explored using 3D controls, as if a flight simulator. Agents can send the flight anywhere and report what they are working on. + +## Installation + +``bash +copilot plugin install flight-map-canvas@awesome-copilot +`` + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). + +## License + +MIT diff --git a/plugins/flight-map-canvas/extensions/flight-map-canvas/README.md b/plugins/flight-map-canvas/extensions/flight-map-canvas/README.md new file mode 100644 index 00000000..3cf0861f --- /dev/null +++ b/plugins/flight-map-canvas/extensions/flight-map-canvas/README.md @@ -0,0 +1,116 @@ +# Flight Map Canvas + +A GitHub Copilot canvas that generates a view where Google Maps can be explored using 3D controls, as if a flight simulator. No 3D plane model as the focus is the aerial landscape. Fly around in 1st person for several minutes, starting from a capital city while Copilot works. + +It is a canvas port of the [Flight Map VSCode extension](https://github.com/isocialPractice/vscode-flight-map). Satellite imagery tiles stream in around the camera as it moves, unloaded gaps are filled with color sampled from the viewport, and drifting procedural clouds sit over the fill. The HUD reads heading, speed, altitude, pitch, and live coordinates, with the city, region, and country under the camera looked up as it flies. + +While an agent works in the session, it can send the flight somewhere new and report what it is doing on a status strip under the HUD. + +## Files + +- `extension.mjs` — canvas declaration, loopback game server, the host shim it injects into the page, destination resolution, and agent actions. +- `game/` — the simulator itself, byte for byte the VSCode extension's `media/` folder. `index.html` is its `flightMap.html`; the rest is the Three.js scene, tile loader, flight controls, terrain fill, HUD, and configuration panel. +- `game/vendor/three.min.js` — vendored Three.js r128, so the only remote requests are the ones the simulation makes for imagery and place names. +- `renderMap.json` — the render configuration the canvas starts on. +- `assets/` — 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. + +## Prerequisites + +- **Node.js 20.19 or newer**, because the Copilot SDK requires `node ^20.19.0 || >=22.12.0`. +- A WebGL-capable canvas surface; the scene is rendered through Three.js. +- Network access. Satellite tiles, the location label, and destination lookups are all fetched at runtime. +- The GitHub Copilot app canvas / UI-extensions experiment enabled. + +## Install + +Drop this folder at `~/.copilot/extensions/flight-map-canvas/` for user scope, or in a repository at `.github/extensions/flight-map-canvas/` for project scope. Then install dependencies from inside the copied folder: + +```sh +# User scope +cd ~/.copilot/extensions/flight-map-canvas + +# Or project scope, from the repository root +cd .github/extensions/flight-map-canvas + +npm install +``` + +Reload extensions in the GitHub Copilot app, then open the `flight-map-canvas` canvas. Pick a destination from the menu, click the view to start flying, and use the controls below. + +The canvas accepts optional open inputs, resolved the same way `fly_to` resolves them: + +| Input | Type | Description | +| --- | --- | --- | +| `capital` | string | A world capital by city or country name. Matched against the capitals the canvas ships with. | +| `city` | string | City name for anywhere that is not a shipped capital. Geocoded on the way in. | +| `state` | string | State, region, province, or district that narrows the city. | +| `country` | string | Country that narrows the city. | +| `lat` / `lng` | number | Raw coordinates, which skip the geocoder entirely. Latitude runs -85.0511 to 85.0511, the limit of the Web Mercator tile imagery; longitude runs -180 to 180. Give both or neither. | + +Opened with no input, the canvas starts on its own welcome screen and waits for a destination. + +## Controls + +| Input | Action | +| --- | --- | +| `W` / `S` or `Up` / `Down` | Throttle | +| Mouse (after clicking the view) | Pitch and yaw | +| `A` / `D` or `Left` / `Right` | Roll | +| `Q` / `E` | Rudder yaw | +| `R` / `F` | Climb / descend | +| `Space` | Auto-level | +| `Shift` | Boost | +| `P` | Pause / resume | +| `Esc` | Release the view | + +Mouse look prefers pointer lock. If the canvas host refuses it, the controls fall back to drag look: hold the left mouse button and move to steer. The on-screen help says which mode is active. + +The **Destination** menu lists the world capitals grouped by continent and opens with **Input Destination**, which takes a typed state/region and city plus a country from a menu, then lists what matched so one can be selected. **Config** opens live rendering sliders, and **Export JSON** downloads the current configuration as `renderMap.json`. + +## Agent actions + +- `fly_to { capital?, city?, state?, country?, lat?, lng? }` — send the flight to a destination. A capital is matched against the shipped list, a city is geocoded, and a lat/lng pair is flown to directly. Called with no input it picks a random world capital, which is the way to say "take me somewhere else". +- `report_job { status, tokens?, done? }` — report the current job step. The status shows on a strip under the HUD and the token count runs beside it. Call it when work starts, again on each new step, and once more with `done: true` when finished. + +To have the agent narrate itself automatically, reference these actions from a `.github/copilot-instructions.md` so it calls `report_job` on each step of a chat request. + +## How the port works + +The simulator under `game/` is unchanged from the VSCode extension. That page was already written to run in two places, and it reaches whatever is hosting it through one seam: an `acquireVsCodeApi()` object for messaging, and a `` placeholder in its `` that the host fills with a content security policy and its render configuration. + +`extension.mjs` fills that seam for the canvas: + +- A loopback HTTP server serves the page and its assets, replacing the placeholder with a policy, the render configuration read from `renderMap.json`, and a shim that defines `acquireVsCodeApi()`. +- Agent activity is streamed to the page over Server-Sent Events at `/events`. The shim translates those events into the exact `flyTo` and `jobStatus` commands the page already registers handlers for. +- The page's export button has no save dialog to reach here, so the shim falls back to the browser download the standalone page uses. +- Destinations are resolved server-side before broadcasting, against the page's own `capitals.js` for shipped capitals and OpenStreetMap Nominatim for everything else. + +This mirrors the [`backrooms-canvas`](../backrooms-canvas) and [`arcade-canvas`](../arcade-canvas) architecture: a static frontend served from a loopback server, with the agent driving it through canvas actions. + +## Development + +The port is a copy, not a fork. Everything under `game/` comes from the VSCode extension's `media/` folder, so edits belong there. + +In that repository the two are held together by symlinks, which means there is only ever one copy to edit and the port cannot go stale. Submission needs real files, though, because a link pointing at a sibling repository resolves to nothing anywhere else, so the stage command resolves every link on the way out: + +```sh +# In the vscode-flight-map repository +npm run canvas:link # create or repair the symlinks +npm run canvas:check # verify them; non-zero when stale +npm run canvas:stage -- # real-file copy for submission +``` + +`media/flightMap.html` is linked as `game/index.html`; nothing else is renamed and no file is rewritten. To run the simulator outside a canvas while working on it, open `media/flightMap.html` in a browser. + +## Credits + +- Source extension and canvas port: [vscode-flight-map](https://github.com/isocialPractice/vscode-flight-map) by John Haugabook. +- Satellite imagery: Google Maps tiles, with Esri World Imagery as the fallback provider. The imagery is served by, and remains the property of, those providers; this canvas only displays it, subject to their respective terms of service. +- Place names and destination lookups: [OpenStreetMap Nominatim](https://nominatim.openstreetmap.org/). Data © OpenStreetMap contributors, available under the [Open Database License](https://www.openstreetmap.org/copyright). +- 3D rendering: [Three.js](https://threejs.org/) r128. + +## License + +MIT diff --git a/plugins/flight-map-canvas/extensions/flight-map-canvas/assets/icon.png b/plugins/flight-map-canvas/extensions/flight-map-canvas/assets/icon.png new file mode 100644 index 00000000..9e76b2be Binary files /dev/null and b/plugins/flight-map-canvas/extensions/flight-map-canvas/assets/icon.png differ diff --git a/extensions/flight-map-canvas/extensions/flight-map-canvas/assets/preview.png b/plugins/flight-map-canvas/extensions/flight-map-canvas/assets/preview.png similarity index 100% rename from extensions/flight-map-canvas/extensions/flight-map-canvas/assets/preview.png rename to plugins/flight-map-canvas/extensions/flight-map-canvas/assets/preview.png diff --git a/plugins/flight-map-canvas/extensions/flight-map-canvas/copilot-extension.json b/plugins/flight-map-canvas/extensions/flight-map-canvas/copilot-extension.json new file mode 100644 index 00000000..30adeba5 --- /dev/null +++ b/plugins/flight-map-canvas/extensions/flight-map-canvas/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "flight-map-canvas", + "version": 1 +} diff --git a/plugins/flight-map-canvas/extensions/flight-map-canvas/extension.mjs b/plugins/flight-map-canvas/extensions/flight-map-canvas/extension.mjs new file mode 100644 index 00000000..dd8598f1 --- /dev/null +++ b/plugins/flight-map-canvas/extensions/flight-map-canvas/extension.mjs @@ -0,0 +1,632 @@ +/** + * Copilot canvas entry point for Flight Map. + * + * The simulator itself is the same page the VS Code extension ships: a + * plain Three.js document under game/ that loads satellite tiles, flies a + * first-person camera over them, and reads its render configuration off + * `window.__flightMapConfig`. This file is only the host. It runs a + * loopback HTTP server that serves that page, and registers a canvas so + * an agent can steer the flight and report what it is working on. + * + * The page carries a `` placeholder that each host + * fills with whatever only it can supply. The VS Code panel puts a + * content security policy, webview asset URIs, and the render config + * there. This server puts a policy, the render config, and a shim that + * stands in for `acquireVsCodeApi()`, so the page runs unchanged in both. + * + * Messages the shim relays, matching the commands the page already + * registers through media/hostBridge.js: + * + * host -> page : { command: 'flyTo', destination } + * { command: 'jobStatus', job } + * page -> host : { command: 'ready' } + * { command: 'exportConfig', json } // handled in the shim + */ +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 { randomBytes } from "node:crypto"; + +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"); + +/** Idle agent job; the status strip hides itself. */ +const IDLE_JOB = { working: false, status: "", tokens: 0 }; + +/** + * Nominatim asks every client to identify itself. The canvas makes at most + * one lookup per fly_to, well inside the one-request-per-second policy. + */ +const USER_AGENT = "flight-map-canvas (https://github.com/isocialPractice/vscode-flight-map)"; +const NOMINATIM = "https://nominatim.openstreetmap.org/search"; + +/** + * The tile imagery the page flies over is Web Mercator, which runs out of + * projection at about 85.0511 degrees. Past that there are no tiles to + * fetch, so a latitude beyond it is rejected rather than flown to a blank. + */ +const MAX_LATITUDE = 85.0511; + +const servers = new Map(); + +/** + * The capitals the page ships with, read out of its own data file rather + * than duplicated here. game/capitals.js is a plain script that assigns + * one `var`, so evaluating it and returning that binding keeps this + * server and the page reading from a single list. + */ +const CAPITALS = await (async () => { + const source = await readFile(path.join(gameRoot, "capitals.js"), "utf8"); + const regions = new Function(`${source}\nreturn CAPITALS_BY_REGION;`)(); + return regions.flatMap((region) => + region.capitals.map((capital) => ({ ...capital, region: region.region })), + ); +})(); + +/** The render configuration the page starts on, or defaults when absent. */ +async function readRenderConfig() { + try { + return JSON.parse(await readFile(path.join(__dirname, "renderMap.json"), "utf8")); + } catch { + // Missing or malformed: the page falls back to its own defaults + return {}; + } +} + +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 ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".svg": + return "image/svg+xml"; + case ".webp": + return "image/webp"; + default: + return "application/octet-stream"; + } +} + +/** Resolves a request path under a root, refusing anything that escapes it. */ +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 flight map assets."); + } + return resolved; +} + +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); + } +} + +/* ============================================================ + Destination resolution + ============================================================ */ + +function normalizeText(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function randomCapital() { + return CAPITALS[Math.floor(Math.random() * CAPITALS.length)]; +} + +function asDestination(capital) { + return { + capital: capital.capital, + country: capital.country, + lat: capital.lat, + lng: capital.lng, + label: `${capital.capital}, ${capital.country}`, + }; +} + +/** Matches a name against the shipped capitals, by city or by country. */ +function findCapital(name) { + const needle = name.toLowerCase(); + return ( + CAPITALS.find((c) => c.capital.toLowerCase() === needle) ?? + CAPITALS.find((c) => c.country.toLowerCase() === needle) ?? + CAPITALS.find((c) => c.capital.toLowerCase().includes(needle)) ?? + CAPITALS.find((c) => c.country.toLowerCase().includes(needle)) + ); +} + +/** + * Forward geocodes typed fields the same way the page's own destination + * dialog does: structured Nominatim parameters rather than one + * concatenated string, so a city that also exists elsewhere does not + * outrank the intended match. + */ +async function geocode(fields) { + const params = new URLSearchParams({ format: "jsonv2", addressdetails: "1", limit: "1" }); + for (const key of ["city", "state", "country"]) { + if (fields[key]) params.set(key, fields[key]); + } + + let response; + try { + response = await fetch(`${NOMINATIM}?${params}`, { + headers: { "user-agent": USER_AGENT, accept: "application/json" }, + }); + } catch (error) { + throw new CanvasError("geocode_unreachable", `Could not reach the geocoding service: ${error.message}`); + } + + if (!response.ok) { + throw new CanvasError("geocode_failed", `Geocoding failed with status ${response.status}.`); + } + + const [match] = await response.json(); + if (!match) { + return null; + } + + const address = match.address ?? {}; + const city = address.city || address.town || address.village || address.municipality || match.name; + const state = address.state || address.province || address.region || address.county; + const code = address.country_code ? address.country_code.toUpperCase() : ""; + const label = [state, city && city !== state ? city : null, code].filter(Boolean).join(", "); + + return { + capital: city || "Destination", + country: address.country || "", + lat: Number.parseFloat(match.lat), + lng: Number.parseFloat(match.lon), + label: label || match.display_name, + }; +} + +/** + * Turns fly_to / open input into a destination the page can load. + * + * lat + lng flown to directly + * capital matched against the shipped capitals + * city/state/country geocoded + * nothing a random capital, so "take me somewhere" works + */ +async function resolveDestination(input) { + const fields = { + capital: normalizeText(input?.capital), + city: normalizeText(input?.city), + state: normalizeText(input?.state), + country: normalizeText(input?.country), + }; + + const lat = typeof input?.lat === "number" ? input.lat : undefined; + const lng = typeof input?.lng === "number" ? input.lng : undefined; + + if (lat !== undefined || lng !== undefined) { + if (lat === undefined || lng === undefined) { + throw new CanvasError("incomplete_coordinates", "Give both lat and lng, or neither."); + } + if (!Number.isFinite(lat) || lat < -MAX_LATITUDE || lat > MAX_LATITUDE) { + throw new CanvasError( + "invalid_latitude", + `lat must be between -${MAX_LATITUDE} and ${MAX_LATITUDE}, the limit of the Web Mercator tile imagery.`, + ); + } + if (!Number.isFinite(lng) || lng < -180 || lng > 180) { + throw new CanvasError("invalid_longitude", "lng must be between -180 and 180."); + } + return { + capital: fields.city || fields.capital || "Waypoint", + country: fields.country, + lat, + lng, + label: fields.city || fields.capital + ? `${fields.city || fields.capital}${fields.country ? `, ${fields.country}` : ""}` + : `${Math.abs(lat).toFixed(4)}°${lat >= 0 ? "N" : "S"} ` + + `${Math.abs(lng).toFixed(4)}°${lng >= 0 ? "E" : "W"}`, + }; + } + + if (fields.capital) { + const capital = findCapital(fields.capital); + if (capital) return asDestination(capital); + // Not one of the shipped capitals: treat it as a place name + fields.city ||= fields.capital; + } + + if (fields.city || fields.state || fields.country) { + const resolved = await geocode(fields); + if (resolved) return resolved; + + // A country with no city still has a capital in the shipped list + const fallback = fields.country && findCapital(fields.country); + if (fallback) return asDestination(fallback); + + throw new CanvasError("destination_not_found", "No destination matched that description."); + } + + return asDestination(randomCapital()); +} + +/* ============================================================ + Page serving + ============================================================ */ + +/** + * Builds the head the page's placeholder expects: a policy, the render + * configuration, and the host shim. The shim must run before the page's + * own scripts, which is why it goes in the head rather than after them. + */ +function buildHead(entry, nonce) { + const csp = [ + "default-src 'self'", + // Satellite tiles are remote images; the terrain fill samples the + // canvas, so the tile textures are read back and need to be clean + "img-src 'self' https: data: blob:", + "style-src 'self' 'unsafe-inline'", + `script-src 'self' 'nonce-${nonce}'`, + // 'self' for the event stream below, https: for the geocoder the + // page's own destination dialog calls + "connect-src 'self' https:", + ].join("; "); + + const init = { + config: entry.config, + job: entry.job, + destination: entry.destination, + }; + + return ( + `\n` + + ` ` + ); +} + +/** + * Stands in for the VS Code webview host. The page reaches its host + * through `acquireVsCodeApi()` and a window `message` listener, so + * defining that API and translating the server's Server-Sent Events into + * those messages is the whole port. + */ +function hostShim(init) { + return ` (function () { + var init = ${JSON.stringify(init).replace(/ page channel for agent activity + try { + var events = new EventSource('/events'); + events.addEventListener('flyTo', function (event) { + try { dispatch({ command: 'flyTo', destination: JSON.parse(event.data).destination }); } catch (e) {} + }); + events.addEventListener('jobStatus', function (event) { + try { dispatch({ command: 'jobStatus', job: JSON.parse(event.data).job }); } catch (e) {} + }); + } catch (e) { + // No EventSource: the simulator still flies, just without agent activity + } + })();`; +} + +async function renderIndex(entry) { + const html = await readFile(indexPath, "utf8"); + const nonce = randomBytes(16).toString("base64url"); + return html.replace("", buildHead(entry, nonce)); +} + +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 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); + // Catch a fresh or reconnecting client up to the live state + sendSse(res, "jobStatus", { job: entry.job }); + if (entry.destination) { + sendSse(res, "flyTo", { destination: entry.destination }); + } + req.on("close", () => entry.clients.delete(res)); + return; + } + + if (url.pathname === "/favicon.ico") { + await streamFile(res, path.join(assetsRoot, "icon.png")); + return; + } + + try { + if (url.pathname === "/" || url.pathname === "/index.html") { + res.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-cache", + }); + res.end(await renderIndex(entry)); + return; + } + + const staticPath = url.pathname.startsWith("/assets/") + ? resolveUnder(assetsRoot, url.pathname.slice("/assets".length)) + : resolveUnder(gameRoot, 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, destination) { + const entry = { + clients: new Set(), + config: await readRenderConfig(), + job: { ...IDLE_JOB }, + destination, + 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 : "Flight map 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("flight_map_not_open", "Open the Flight Map canvas before invoking this action."); + } + return entry; +} + +/** + * Coerces report_job input into the job shape the status strip reads. + * A call that omits tokens keeps the count already on screen, so the + * final done=true call settles the job without wiping its total. + */ +function normalizeJob(input, previous) { + if (!input || typeof input !== "object") { + return { ...IDLE_JOB }; + } + return { + working: input.done !== true, + status: typeof input.status === "string" ? input.status.slice(0, 120) : "", + tokens: + typeof input.tokens === "number" && Number.isFinite(input.tokens) + ? Math.max(0, Math.floor(input.tokens)) + : (previous?.tokens ?? 0), + }; +} + +const DESTINATION_PROPERTIES = { + capital: { + type: "string", + description: + "A world capital to fly to, by city or country name (e.g. 'Wellington', 'New Zealand'). Matched against the capitals the canvas ships with.", + }, + city: { + type: "string", + description: "City name for anywhere that is not a shipped capital. Geocoded on the way in.", + }, + state: { + type: "string", + description: "State, region, province, or district that narrows the city.", + }, + country: { + type: "string", + description: "Country that narrows the city.", + }, + lat: { + type: "number", + description: `Latitude in degrees, -${MAX_LATITUDE} to ${MAX_LATITUDE} (the limit of the Web Mercator tile imagery). Give lng as well to skip the geocoder entirely.`, + minimum: -MAX_LATITUDE, + maximum: MAX_LATITUDE, + }, + lng: { + type: "number", + description: "Longitude in degrees, -180 to 180. Give lat as well.", + minimum: -180, + maximum: 180, + }, +}; + +await joinSession({ + canvases: [ + createCanvas({ + id: "flight-map-canvas", + displayName: "Flight Map", + description: + "Fly a first-person satellite terrain map of the world while agents work. The agent can send the flight anywhere and report what it is doing.", + inputSchema: { + type: "object", + properties: DESTINATION_PROPERTIES, + additionalProperties: false, + }, + actions: [ + { + name: "fly_to", + description: + "Send the flight to a destination. Give a capital by name, a city with an optional state and country, or a raw lat/lng. Called with no input it picks a random world capital, which is the way to say 'take me somewhere else'.", + inputSchema: { + type: "object", + properties: DESTINATION_PROPERTIES, + additionalProperties: false, + }, + handler: async (ctx) => { + const entry = getOpenEntry(ctx.instanceId); + const destination = await resolveDestination(ctx.input); + // Held so a reload, or a client that connects later, + // arrives at the same place the agent sent the flight + entry.destination = destination; + broadcast(entry, "flyTo", { destination }); + return destination; + }, + }, + { + name: "report_job", + description: + "Report your current job status to the flight map. Call it when you start a chat request, again on each new step, and once more with done=true when finished. The status shows on a strip under the HUD and the token count runs beside it.", + inputSchema: { + type: "object", + properties: { + status: { + type: "string", + description: + "Short status text to show under the HUD (e.g. 'reading the codebase', 'rewriting the parser').", + }, + tokens: { + type: "number", + description: "Approximate tokens consumed by the current job so far.", + }, + done: { + type: "boolean", + description: + "Set true when the job is finished; the activity indicator stills and the last status stays readable.", + }, + }, + required: ["status"], + additionalProperties: false, + }, + handler: (ctx) => { + const entry = getOpenEntry(ctx.instanceId); + entry.job = normalizeJob(ctx.input, entry.job); + broadcast(entry, "jobStatus", { job: entry.job }); + return { job: entry.job }; + }, + }, + ], + open: async (ctx) => { + const hasInput = ctx.input && Object.keys(ctx.input).length > 0; + // Only resolve when asked for somewhere; otherwise the page + // opens on its own welcome screen and the menu picks + const destination = hasInput ? await resolveDestination(ctx.input) : undefined; + + let entry = servers.get(ctx.instanceId); + if (!entry) { + entry = await startServer(ctx.instanceId, destination); + } else if (destination) { + entry.destination = destination; + broadcast(entry, "flyTo", { destination }); + } + + return { + title: "Flight Map", + status: entry.job.working && entry.job.status + ? entry.job.status + : (entry.destination?.label ?? "Pick a destination"), + 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/plugins/flight-map-canvas/extensions/flight-map-canvas/game/app.js b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/app.js new file mode 100644 index 00000000..c0ce7e15 --- /dev/null +++ b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/app.js @@ -0,0 +1,1057 @@ +/***************************************************************** + * Flight Map - Main Application * + * * + * Three.js scene with satellite imagery terrain tiles, * + * flight simulator controls, dynamic tile loading, and HUD. * + ****************************************************************/ + +/* ============================================================ + Configuration + ============================================================ */ + +var TILE_ZOOM = 15; +var TILE_SIZE = 100; // Three.js world units per tile +var LOAD_RADIUS = 6; // Tiles to load around camera +var UNLOAD_RADIUS = 9; // Tiles to unload beyond this distance +var START_ALTITUDE = 200; // Starting camera height +var FOG_DENSITY = 0.00018; // Exponential fog density +var RADIUS_FEATHER = 3; // Tiles to feather at load radius edge +var MAX_TILES = 600; // Maximum loaded tiles (memory cap) + +/* ============================================================ + State + ============================================================ */ + +var scene, camera, renderer, clock; +var controls; // FlightControls instance +var terrainFill; // TerrainFill instance + +var textureLoader; +var loadedTiles = {}; +var centerTileX = 0; +var centerTileY = 0; +var originLat = 0; +var originLng = 0; +var currentMetersPerTile = 1000; +var currentCapitalName = ''; + +// Sentinel value of the destination menu entry that opens the keyboard +// input dialog, and the capital the menu falls back to afterwards +var INPUT_DESTINATION_VALUE = '__input__'; +var lastCapitalValue = ''; + +// Group the menu collects typed and geocoded destinations under, so they +// read as somewhere the user went rather than a capital that ships +var ENTERED_GROUP_LABEL = 'Entered'; + +var tilesLoaded = 0; +var tilesRequested = 0; +var initialLoadDone = false; +var tileUpdateCounter = 0; +var isPaused = false; + +/* ============================================================ + Tile Math Utilities + ============================================================ */ + +function latLngToTile(lat, lng, zoom) { + var n = Math.pow(2, zoom); + var x = Math.floor((lng + 180) / 360 * n); + var latRad = lat * Math.PI / 180; + var y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n); + return { x: x, y: y }; +} + +function tileToLatLng(x, y, zoom) { + var n = Math.pow(2, zoom); + var lng = x / n * 360 - 180; + var latRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n))); + var lat = latRad * 180 / Math.PI; + return { lat: lat, lng: lng }; +} + +function metersPerTile(lat, zoom) { + return 156543.03392 * Math.cos(lat * Math.PI / 180) / Math.pow(2, zoom) * 256; +} + +/** + * Wraps a tile column into the [0, 2^zoom - 1] range the providers serve. + * + * Tile coordinates are kept unwrapped everywhere else: they double as the + * world grid, and a flight that crosses the antimeridian has to keep + * counting past the edge of the map for its tiles to stay put relative to + * each other. Only the URL needs the column the provider recognizes. + */ +function wrapTileX(x, zoom) { + var n = Math.pow(2, zoom); + return ((x % n) + n) % n; +} + +/** Wraps a longitude into [-180, 180) after a crossing carried it past. */ +function wrapLongitude(lng) { + return ((lng + 180) % 360 + 360) % 360 - 180; +} + +/** + * Formats a position for the coordinate readout. The two spaces between + * the latitude and the longitude are what separates them on screen; + * #hud-coords sets white-space: pre so they survive to the render. + */ +function formatCoords(lat, lng) { + return Math.abs(lat).toFixed(4) + '°' + (lat >= 0 ? 'N' : 'S') + ' ' + + Math.abs(lng).toFixed(4) + '°' + (lng >= 0 ? 'E' : 'W'); +} + +function positionToLatLng(worldX, worldZ) { + var metersPerUnit = currentMetersPerTile / TILE_SIZE; + var dMetersX = worldX * metersPerUnit; + var dMetersZ = worldZ * metersPerUnit; + var dLng = dMetersX / (111320 * Math.cos(originLat * Math.PI / 180)); + var dLat = -dMetersZ / 110574; + return { + lat: originLat + dLat, + lng: wrapLongitude(originLng + dLng) + }; +} + +/* ============================================================ + Tile URL Providers + ============================================================ */ + +// Esri World Imagery (reliable CORS, high quality satellite) +function getEsriTileUrl(x, y, z) { + return 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/' + z + '/' + y + '/' + x; +} + +// Google Maps satellite tiles (may have CORS restrictions) +function getGoogleTileUrl(x, y, z) { + var server = ['mt0', 'mt1', 'mt2', 'mt3'][Math.abs(x + y) % 4]; + return 'https://' + server + '.google.com/vt/lyrs=s&x=' + x + '&y=' + y + '&z=' + z; +} + +// Active tile provider - defaults to Google, falls back to Esri +var tileProvider = 'google'; + +function getTileUrl(x, y, z) { + if (tileProvider === 'google') { + return getGoogleTileUrl(x, y, z); + } + return getEsriTileUrl(x, y, z); +} + +/* ============================================================ + Tile Provider Detection + ============================================================ */ + +function detectTileProvider(callback) { + var testImg = new Image(); + testImg.crossOrigin = 'anonymous'; + var done = false; + + testImg.onload = function () { + if (done) return; + done = true; + // Test if we can actually read pixel data (CORS check) + try { + var canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + var ctx = canvas.getContext('2d'); + ctx.drawImage(testImg, 0, 0, 1, 1); + ctx.getImageData(0, 0, 1, 1); // Throws if CORS tainted + tileProvider = 'google'; + } catch (e) { + tileProvider = 'esri'; + } + callback(); + }; + + testImg.onerror = function () { + if (done) return; + done = true; + tileProvider = 'esri'; + callback(); + }; + + setTimeout(function () { + if (done) return; + done = true; + tileProvider = 'esri'; + callback(); + }, 4000); + + testImg.src = getGoogleTileUrl(0, 0, 1); +} + +/* ============================================================ + Scene Setup + ============================================================ */ + +function initScene() { + scene = new THREE.Scene(); + scene.background = new THREE.Color(0x87CEEB); + scene.fog = new THREE.FogExp2(0x87CEEB, FOG_DENSITY); + + camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 50000); + camera.position.set(0, START_ALTITUDE, 150); + camera.lookAt(0, 0, 0); + + renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + document.getElementById('canvas-container').appendChild(renderer.domElement); + + // Lighting + var hemiLight = new THREE.HemisphereLight(0x87CEEB, 0x8B6914, 0.4); + scene.add(hemiLight); + + var dirLight = new THREE.DirectionalLight(0xffffff, 0.8); + dirLight.position.set(200, 400, 100); + scene.add(dirLight); + + var ambLight = new THREE.AmbientLight(0x404040, 0.3); + scene.add(ambLight); + + // Water plane (visible below terrain tiles) + var waterGeo = new THREE.PlaneGeometry(80000, 80000); + var waterMat = new THREE.MeshBasicMaterial({ color: 0x1a4a7a }); + var waterPlane = new THREE.Mesh(waterGeo, waterMat); + waterPlane.rotation.x = -Math.PI / 2; + waterPlane.position.y = -2; + scene.add(waterPlane); + + // Texture loader + textureLoader = new THREE.TextureLoader(); + textureLoader.crossOrigin = 'anonymous'; + + // Clock + clock = new THREE.Clock(); + + // Flight controls + controls = new FlightControls(camera, renderer.domElement); + + // Terrain fill (samples viewport colors to fill unloaded tile gaps) + terrainFill = new TerrainFill(renderer, scene, camera); + + // Panel resizes are handled by initLayout, which reapplies the + // render resolution against the new panel shape +} + +/* ============================================================ + Terrain Tile System + ============================================================ */ + +function loadTile(tileX, tileY) { + var key = tileX + ',' + tileY; + if (loadedTiles[key]) return; + + var worldX = (tileX - centerTileX) * TILE_SIZE; + var worldZ = (tileY - centerTileY) * TILE_SIZE; + + var geometry = new THREE.PlaneGeometry(TILE_SIZE, TILE_SIZE); + var material = new THREE.MeshBasicMaterial({ color: 0x2a3a2a, transparent: true }); + var mesh = new THREE.Mesh(geometry, material); + mesh.rotation.x = -Math.PI / 2; + mesh.position.set(worldX, 0, worldZ); + scene.add(mesh); + + loadedTiles[key] = { mesh: mesh, tileX: tileX, tileY: tileY, loaded: false }; + tilesRequested++; + + // The mesh keeps its unwrapped world column; the request uses the + // column the provider serves, so a flight can cross the antimeridian + // without every tile after it 404ing + var urlX = wrapTileX(tileX, TILE_ZOOM); + var url = getTileUrl(urlX, tileY, TILE_ZOOM); + + textureLoader.load(url, function (texture) { + if (!loadedTiles[key]) return; // Already unloaded + texture.minFilter = THREE.LinearFilter; + texture.magFilter = THREE.LinearFilter; + mesh.material.map = texture; + mesh.material.color.set(0xffffff); + mesh.material.needsUpdate = true; + loadedTiles[key].loaded = true; + tilesLoaded++; + updateLoadingProgress(); + }, undefined, function () { + // Primary failed, try fallback provider + var fallbackUrl = (tileProvider === 'google') + ? getEsriTileUrl(urlX, tileY, TILE_ZOOM) + : getGoogleTileUrl(urlX, tileY, TILE_ZOOM); + + textureLoader.load(fallbackUrl, function (texture) { + if (!loadedTiles[key]) return; + texture.minFilter = THREE.LinearFilter; + texture.magFilter = THREE.LinearFilter; + mesh.material.map = texture; + mesh.material.color.set(0xffffff); + mesh.material.needsUpdate = true; + loadedTiles[key].loaded = true; + tilesLoaded++; + updateLoadingProgress(); + }, undefined, function () { + if (!loadedTiles[key]) return; + loadedTiles[key].loaded = true; + tilesLoaded++; + updateLoadingProgress(); + }); + }); +} + +function unloadTile(key) { + var tile = loadedTiles[key]; + if (!tile) return; + + scene.remove(tile.mesh); + tile.mesh.geometry.dispose(); + if (tile.mesh.material.map) tile.mesh.material.map.dispose(); + tile.mesh.material.dispose(); + + delete loadedTiles[key]; + + // A tile evicted before its texture arrived still counts as resolved, + // otherwise the loading progress can stall below its completion threshold + if (!tile.loaded) { + tilesLoaded++; + updateLoadingProgress(); + } +} + +function clearAllTiles() { + var keys = Object.keys(loadedTiles); + for (var i = 0; i < keys.length; i++) { + unloadTile(keys[i]); + } + loadedTiles = {}; + tilesLoaded = 0; + tilesRequested = 0; + initialLoadDone = false; +} + +/** + * How far the loader may actually reach, as a squared tile distance. + * + * loadRadius asks for a terrain disk of a given width and maxTiles caps + * how many tiles may exist at once. A disk of radius r holds about + * pi * r^2 tiles, so a radius asking for more than the cap allows is a + * radius the cap takes straight back: the reach is whichever is smaller. + * + * loadRadius keeps its other job either way. updateTileFeather() scales + * the opacity ramp against it, so a loadRadius wider than this reach + * stretches the fade across the terrain that does fit rather than + * widening the terrain itself. + */ +function tileReachSq() { + return Math.min(LOAD_RADIUS * LOAD_RADIUS, MAX_TILES / Math.PI); +} + +function updateTiles() { + if (!currentCapitalName) return; + + var camTileX = centerTileX + Math.round(controls.position.x / TILE_SIZE); + var camTileY = centerTileY + Math.round(controls.position.z / TILE_SIZE); + + var reachSq = tileReachSq(); + var reach = Math.ceil(Math.sqrt(reachSq)); + + // Everything in play this pass, gathered before anything is requested: + // the tiles the camera wants, plus the tiles already around it that it + // has not left behind yet. Ranking one pool by distance is what lets + // the cap be spent on the nearest tiles whether they are loaded yet or + // not, so no tile is requested only for the same pass to evict it. + var pool = []; + var wanted = {}; + + for (var dx = -reach; dx <= reach; dx++) { + for (var dy = -reach; dy <= reach; dy++) { + var distSq = dx * dx + dy * dy; + if (distSq > reachSq) continue; + var tx = camTileX + dx; + var ty = camTileY + dy; + var key = tx + ',' + ty; + wanted[key] = true; + pool.push({ key: key, tileX: tx, tileY: ty, distSq: distSq }); + } + } + + var loadedKeys = Object.keys(loadedTiles); + for (var i = 0; i < loadedKeys.length; i++) { + if (wanted[loadedKeys[i]]) continue; + var tile = loadedTiles[loadedKeys[i]]; + var ddx = tile.tileX - camTileX; + var ddy = tile.tileY - camTileY; + var loadedDistSq = ddx * ddx + ddy * ddy; + + // Out of range for good; the unload radius has the last word on + // what is too far behind to be worth ranking at all + if (loadedDistSq > UNLOAD_RADIUS * UNLOAD_RADIUS) { + unloadTile(loadedKeys[i]); + continue; + } + + pool.push({ + key: loadedKeys[i], + tileX: tile.tileX, + tileY: tile.tileY, + distSq: loadedDistSq + }); + } + + pool.sort(function (a, b) { return a.distSq - b.distSq; }); + + // Spend the cap nearest first: request what is missing while there is + // room, and evict whatever the room ran out on + for (var i = 0; i < pool.length; i++) { + var entry = pool[i]; + if (i < MAX_TILES) { + if (!loadedTiles[entry.key]) { + loadTile(entry.tileX, entry.tileY); + } + } else if (loadedTiles[entry.key]) { + unloadTile(entry.key); + } + } + + // Apply feather opacity to tiles near the load radius edge + updateTileFeather(camTileX, camTileY); +} + +/** + * Applies smooth opacity falloff to tiles near the load radius edge, + * creating a feathered border effect. Tiles within the feather zone + * transition from full opacity to transparent using smoothstep. + */ +function updateTileFeather(camTileX, camTileY) { + if (RADIUS_FEATHER <= 0) { + // No feather: ensure all tiles are fully opaque + var keys = Object.keys(loadedTiles); + for (var i = 0; i < keys.length; i++) { + var tile = loadedTiles[keys[i]]; + if (tile.mesh.material.opacity !== 1) { + tile.mesh.material.opacity = 1; + tile.mesh.material.needsUpdate = true; + } + } + return; + } + + // Clamp so a feather wider than the load radius still yields a valid zone + var featherStart = Math.max(0, LOAD_RADIUS - RADIUS_FEATHER); + var featherWidth = LOAD_RADIUS - featherStart; + var keys = Object.keys(loadedTiles); + for (var i = 0; i < keys.length; i++) { + var tile = loadedTiles[keys[i]]; + var ddx = tile.tileX - camTileX; + var ddy = tile.tileY - camTileY; + var dist = Math.sqrt(ddx * ddx + ddy * ddy); + + var opacity; + if (dist <= featherStart) { + opacity = 1; + } else if (dist >= LOAD_RADIUS) { + opacity = 0; + } else { + // Smoothstep falloff from 1 to 0 across the feather zone + var t = (dist - featherStart) / featherWidth; + opacity = 1 - t * t * (3 - 2 * t); + } + + if (tile.mesh.material.opacity !== opacity) { + tile.mesh.material.opacity = opacity; + tile.mesh.material.needsUpdate = true; + } + } +} + +/* ============================================================ + Capital Loading + ============================================================ */ + +function loadCapital(capitalData) { + clearAllTiles(); + + originLat = capitalData.lat; + originLng = capitalData.lng; + + // Typed destinations arrive with the label the geocoder built for + // them; capitals are named from their two known fields + currentCapitalName = capitalData.label || + (capitalData.capital + ', ' + capitalData.country); + currentMetersPerTile = metersPerTile(originLat, TILE_ZOOM); + + var tile = latLngToTile(originLat, originLng, TILE_ZOOM); + centerTileX = tile.x; + centerTileY = tile.y; + + // Show loading + document.getElementById('loading-overlay').classList.remove('hidden'); + document.getElementById('welcome').classList.add('hidden'); + document.getElementById('fly-prompt').classList.add('hidden'); + + // Reset terrain fill for new location + if (terrainFill) terrainFill.reset(); + + // Reset geocoder so the location label refreshes for the new area + Geocoder.reset(); + + // Load initial tiles + updateTiles(); + + // Position camera above the capital + controls.resetTo( + new THREE.Vector3(0, START_ALTITUDE, 150), + new THREE.Vector3(0, 0, 0) + ); + + // Update HUD capital name + document.getElementById('hud-capital').textContent = currentCapitalName; +} + +/* ============================================================ + Host-Driven Updates + + A host (the VS Code extension, or the Copilot canvas server) + can steer the flight and report what it is working on. Both + arrive as HostBridge commands, so a page with no host simply + never receives them. + ============================================================ */ + +/** + * Finds the menu entry for a destination, by position rather than by + * name: the same place reached from the capitals list, from the input + * dialog, or from a host command should resolve to one entry. Returns + * the option's value, or '' when the menu has no entry for it. + */ +function findDestinationOption(select, destination) { + for (var i = 0; i < select.options.length; i++) { + var value = select.options[i].value; + if (!value || value === INPUT_DESTINATION_VALUE) continue; + + var option; + try { + option = JSON.parse(value); + } catch (e) { + continue; // Not a destination entry + } + + if (option.lat === destination.lat && option.lng === destination.lng) { + return value; + } + } + + return ''; +} + +/** + * Adds a typed or geocoded destination to the menu so it can be flown + * back to without retyping it. + * + * The entry carries the whole destination as its value, in the shape the + * capitals use, so re-selecting it goes through the same change handler + * and lands in the same place. Entries collect under their own group, + * which is created the first time one is needed. + */ +function rememberDestination(select, destination) { + var group = null; + for (var i = 0; i < select.children.length; i++) { + if (select.children[i].label === ENTERED_GROUP_LABEL) { + group = select.children[i]; + break; + } + } + + if (!group) { + group = document.createElement('optgroup'); + group.label = ENTERED_GROUP_LABEL; + // Above the capitals, next to the entry that creates these + select.insertBefore(group, select.querySelector('optgroup')); + } + + // Geocoded places arrive with the label the HUD shows, so the menu + // names them the same way. A bare waypoint has only its own name. + var text = destination.label; + if (!text) { + text = destination.country + ? destination.capital + ' - ' + destination.country + : destination.capital; + } + + var option = document.createElement('option'); + option.value = JSON.stringify(destination); + option.textContent = text; + group.appendChild(option); + + return option.value; +} + +/** + * Flies to a destination and names it in the menu. Accepts the same + * shape loadCapital takes, so a place from the capitals list, the input + * dialog, or a host command works unchanged. + */ +function flyToDestination(destination) { + if (!destination || + typeof destination.lat !== 'number' || + typeof destination.lng !== 'number') return; + + loadCapital(destination); + + // Keep the menu honest about where the camera actually is, rather + // than leaving it on the capital that was left behind + var select = document.getElementById('capital-select'); + if (!select) return; + + var value = findDestinationOption(select, destination) || + rememberDestination(select, destination); + + lastCapitalValue = value; + select.value = value; +} + +/** + * Shows what the host's agent is working on. job is + * { working, status, tokens }; a job that is not working leaves the last + * status on screen with the activity indicator stilled. + */ +function showAgentStatus(job) { + var panel = document.getElementById('agent-status'); + if (!panel || !job) return; + + var status = typeof job.status === 'string' ? job.status : ''; + if (!status && !job.working) { + panel.classList.add('hidden'); + return; + } + + document.getElementById('agent-status-text').textContent = status; + document.getElementById('agent-status-tokens').textContent = + (typeof job.tokens === 'number' && job.tokens > 0) + ? job.tokens.toLocaleString() + ' tok' + : ''; + + panel.classList.toggle('idle', !job.working); + panel.classList.remove('hidden'); +} + +/* ============================================================ + Loading Progress + ============================================================ */ + +function updateLoadingProgress() { + var pct = Math.min(100, Math.round((tilesLoaded / Math.max(1, tilesRequested)) * 100)); + + document.getElementById('loading-bar').style.width = pct + '%'; + document.getElementById('loading-progress').textContent = pct + '%'; + + if (!initialLoadDone && pct >= 40) { + initialLoadDone = true; + document.getElementById('loading-overlay').classList.add('hidden'); + document.getElementById('fly-prompt').classList.remove('hidden'); + document.getElementById('hud').classList.remove('hidden'); + document.getElementById('hud-location').classList.remove('hidden'); + document.getElementById('controls-list').classList.remove('hidden'); + + // The readout joining the top bar can change its height + updateTopBarLayout(); + } +} + +/* ============================================================ + HUD Updates + ============================================================ */ + +function updateHUD() { + if (!controls || !controls.isEnabled) return; + + var heading = controls.getHeading(); + var pitch = controls.getPitch(); + var speed = controls.getSpeed(); + var altitude = controls.getAltitude(); + + // Convert speed from world units/frame to approximate km/h + var metersPerUnit = currentMetersPerTile / TILE_SIZE; + var speedKmh = Math.round(speed * 60 * metersPerUnit * 3.6); + var altMeters = Math.round(altitude * metersPerUnit); + + // Heading cardinal direction + var cardinals = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW', 'N']; + var cardinalIdx = Math.round(heading / 45); + var cardinal = cardinals[cardinalIdx]; + + document.getElementById('hud-heading-value').textContent = + cardinal + ' ' + Math.round(heading) + '°'; + + document.getElementById('hud-speed-value').textContent = speedKmh; + document.getElementById('hud-altitude-value').textContent = altMeters; + document.getElementById('hud-pitch-value').textContent = + (pitch >= 0 ? '+' : '') + Math.round(pitch) + '°'; + + // Update coordinates + var coords = positionToLatLng(controls.position.x, controls.position.z); + document.getElementById('hud-coords').textContent = + formatCoords(coords.lat, coords.lng); + + // Update the location label as the camera moves (throttled internally) + Geocoder.update(coords.lat, coords.lng, function (label) { + document.getElementById('hud-capital').textContent = label; + }); +} + +/* ============================================================ + Responsive Layout + ============================================================ */ + +// Mirrors the flex-basis of #hud-location in styles.css +var LOCATION_MIN_WIDTH = 240; + +// Mirrors the gap and padding of #top-bar in styles.css +var TOP_BAR_GAP = 12; + +var layoutFrame = null; + +/** + * Measures the top bar and publishes what the rest of the layout needs. + * + * The location readout shares a row with the capital selector, so the + * bar mirrors the selector's width as right padding to keep the readout + * centered on the panel instead of on the space beside the menu. Three + * arrangements, chosen by measured width: + * + * 1. Selector, readout, and a mirror of the selector all fit: the + * readout is centered on the panel. + * 2. Only the selector and readout fit: the mirror is dropped and the + * readout centers in the space beside the menu. + * 3. Neither fits: the readout takes its own row underneath. + * + * The arrangement is decided from measured widths rather than from an + * observed wrap, so the padding it toggles cannot feed back into the + * decision that produced it. + */ +function updateTopBarLayout() { + var topBar = document.getElementById('top-bar'); + var selector = document.getElementById('selector-panel'); + if (!topBar || !selector) return; + + var root = document.documentElement; + var available = window.innerWidth; + var selectorWidth = Math.ceil(selector.getBoundingClientRect().width); + + var mirrorFits = (2 * selectorWidth) + LOCATION_MIN_WIDTH + (3 * TOP_BAR_GAP) <= available; + var rowFits = selectorWidth + LOCATION_MIN_WIDTH + (3 * TOP_BAR_GAP) <= available; + + topBar.classList.toggle('stacked', !rowFits); + root.style.setProperty('--selector-width', (mirrorFits ? selectorWidth : 0) + 'px'); + + // Read back after the writes above so the heading readout and the + // config panel clear the bar at whatever height it settled on + root.style.setProperty( + '--top-bar-height', + Math.ceil(topBar.getBoundingClientRect().height) + 'px' + ); +} + +/** + * Coalesces panel resizes into one pass per frame. Dragging the editor + * layout fires a burst of resize events, and each one would otherwise + * reallocate the WebGL drawing buffer. + */ +function scheduleViewportLayout() { + if (layoutFrame !== null) return; + layoutFrame = requestAnimationFrame(function () { + layoutFrame = null; + updateTopBarLayout(); + RenderConfig.apply(); + }); +} + +/** + * Binds the layout to every source of a size change: the panel being + * resized, the editor layout shifting around it, and the selector panel + * reflowing its own contents. + */ +function initLayout() { + updateTopBarLayout(); + + window.addEventListener('resize', scheduleViewportLayout); + + if (typeof ResizeObserver === 'undefined') return; + + // Panel resizes that do not raise a window resize event, such as the + // file explorer being collapsed beside a docked panel + new ResizeObserver(scheduleViewportLayout).observe(document.body); + + // The selector panel reflows when the panel gets too narrow for its + // dropdown and buttons, which changes the width the bar mirrors + new ResizeObserver(updateTopBarLayout).observe( + document.getElementById('selector-panel') + ); +} + +/* ============================================================ + UI Setup + ============================================================ */ + +/** + * Rewrites the look-control wording for the mode the controls settled + * on, so the on-screen help never promises pointer lock the host + * refused to grant. + */ +function applyLookMode(mode) { + var lookText = (mode === 'drag') ? 'Drag: Look' : 'Mouse: Look'; + var lookHint = (mode === 'drag') ? 'Drag' : 'Mouse'; + + var helpLine = document.getElementById('controls-help-look'); + if (helpLine) helpLine.textContent = lookText; + + var listLine = document.getElementById('controls-list-look'); + if (listLine) listLine.textContent = lookHint; + + var promptHint = document.getElementById('fly-prompt-hint'); + if (promptHint) { + promptHint.textContent = (mode === 'drag') + ? 'Hold the left mouse button to look around' + : 'Press Escape to release the cursor'; + } +} + +function setupUI() { + var select = document.getElementById('capital-select'); + + // Populate dropdown + for (var i = 0; i < CAPITALS_BY_REGION.length; i++) { + var region = CAPITALS_BY_REGION[i]; + var optgroup = document.createElement('optgroup'); + optgroup.label = region.region; + + for (var j = 0; j < region.capitals.length; j++) { + var cap = region.capitals[j]; + var option = document.createElement('option'); + option.value = JSON.stringify(cap); + option.textContent = cap.capital + ' - ' + cap.country; + optgroup.appendChild(option); + } + + select.appendChild(optgroup); + } + + // Keyboard entry for anywhere the capitals list does not cover. + // Results go through flyToDestination so an entered place joins the + // menu, the same way one sent by a host does. + DestinationInput.init({ + onSelect: function (destination) { + flyToDestination(destination); + }, + onOpen: function () { + // Stop flying behind the dialog and give up the cursor + if (controls) controls.release(); + }, + onClose: function () { + select.focus(); + } + }); + + // Selection handler + select.addEventListener('change', function (e) { + var value = e.target.value; + if (!value) return; + + // The action entry is not a destination: reset the menu to the + // capital still loaded so re-picking it opens the dialog again + if (value === INPUT_DESTINATION_VALUE) { + e.target.value = lastCapitalValue; + DestinationInput.open(); + return; + } + + lastCapitalValue = value; + loadCapital(JSON.parse(value)); + }); + + // Pause toggle (P key): freezes flight, tile updates, HUD, and clouds + document.addEventListener('keydown', function (e) { + if (e.code !== 'KeyP') return; + // A "p" typed into a destination field is not a pause request + if (FlightControls.isTextEntryTarget(e.target)) return; + togglePause(); + }); + + // Flight controls lock/unlock + document.addEventListener('flightcontrols', function (e) { + applyLookMode(e.detail.mode); + + if (e.detail.locked) { + document.getElementById('fly-prompt').classList.add('hidden'); + document.getElementById('controls-help').classList.remove('hidden'); + + // Fade out controls help after 5 seconds + setTimeout(function () { + document.getElementById('controls-help').classList.add('fade-out'); + }, 5000); + } else { + document.getElementById('controls-help').classList.add('hidden'); + document.getElementById('controls-help').classList.remove('fade-out'); + if (initialLoadDone) { + document.getElementById('fly-prompt').classList.remove('hidden'); + } + } + }); +} + +/* ============================================================ + Animation Loop + ============================================================ */ + +function togglePause() { + isPaused = !isPaused; + var indicator = document.getElementById('pause-indicator'); + if (isPaused) { + indicator.classList.remove('hidden'); + } else { + indicator.classList.add('hidden'); + // Discard input and time accumulated while paused so the + // camera does not jump on resume + if (controls) { + controls.mouseMovementX = 0; + controls.mouseMovementY = 0; + } + if (clock) clock.getDelta(); + } +} + +function animate() { + requestAnimationFrame(animate); + + // Common pause state: keep rendering the frozen scene only + if (isPaused) { + renderer.render(scene, camera); + return; + } + + var delta = clock.getDelta(); + + // Update flight controls + controls.update(delta); + + // Periodically update tiles + tileUpdateCounter++; + if (tileUpdateCounter >= 30) { + tileUpdateCounter = 0; + updateTiles(); + } + + // Update HUD + updateHUD(); + + // Render + renderer.render(scene, camera); + + // Update terrain fill after main render (samples viewport, generates fill for next frame) + if (terrainFill && initialLoadDone) { + terrainFill.update(controls ? controls.getHeading() : 0); + } +} + +/* ============================================================ + Tile Zoom Reload + ============================================================ */ + +/** + * Recalculates tile coordinates and reloads all tiles at the current + * zoom level. Preserves the camera's geographic position so the view + * stays in place while tiles reload in the background. + */ +function reloadTilesAtCurrentZoom() { + if (!currentCapitalName) return; + + // Determine camera's current geographic position before clearing + var camGeo = positionToLatLng(controls.position.x, controls.position.z); + + clearAllTiles(); + + // Re-center on the camera's current geographic location at new zoom + originLat = camGeo.lat; + originLng = camGeo.lng; + currentMetersPerTile = metersPerTile(originLat, TILE_ZOOM); + + var tile = latLngToTile(originLat, originLng, TILE_ZOOM); + centerTileX = tile.x; + centerTileY = tile.y; + + // Camera is now at geographic center, so world position resets to origin + controls.position.x = 0; + controls.position.z = 0; + camera.position.x = 0; + camera.position.z = 0; + + if (terrainFill) terrainFill.reset(); + updateTiles(); + + // Update HUD with the new center coordinates + document.getElementById('hud-coords').textContent = + formatCoords(camGeo.lat, camGeo.lng); +} + +/* ============================================================ + Initialize + ============================================================ */ + +function init() { + detectTileProvider(function () { + RenderConfig.load(function (cfg) { + initScene(); + RenderConfig.apply(); + setupUI(); + RenderConfig.bindSliders(); + initLayout(); + + // React to config changes that require special handling. + // Debounce the zoom reload so dragging the slider does not + // trigger a full tile clear + refetch on every input tick. + var zoomReloadTimer = null; + RenderConfig.onChange(function (path) { + if (path === 'map.tileZoom') { + clearTimeout(zoomReloadTimer); + zoomReloadTimer = setTimeout(reloadTilesAtCurrentZoom, 400); + } + }); + + // Export JSON button + var exportBtn = document.getElementById('btn-export-json'); + if (exportBtn) { + exportBtn.addEventListener('click', function () { + RenderConfig.exportJSON(); + }); + } + + // Config panel toggle + var toggleBtn = document.getElementById('btn-toggle-config'); + var configPanel = document.getElementById('config-panel'); + if (toggleBtn && configPanel) { + toggleBtn.addEventListener('click', function () { + configPanel.classList.toggle('hidden'); + }); + } + + // Commands issued from the extension host + HostBridge.on('exportConfig', function () { + RenderConfig.exportJSON(); + }); + HostBridge.on('toggleConfigPanel', function () { + if (configPanel) configPanel.classList.toggle('hidden'); + }); + HostBridge.on('flyTo', function (message) { + flyToDestination(message.destination); + }); + HostBridge.on('jobStatus', function (message) { + showAgentStatus(message.job); + }); + + HostBridge.post({ command: 'ready' }); + + animate(); + }); + }); +} + +document.addEventListener('DOMContentLoaded', init); diff --git a/plugins/flight-map-canvas/extensions/flight-map-canvas/game/capitals.js b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/capitals.js new file mode 100644 index 00000000..26701637 --- /dev/null +++ b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/capitals.js @@ -0,0 +1,94 @@ +/************************************************************** + * World Capitals organized by continent/region. * + * Each capital includes country, city name, and coordinates. * + *************************************************************/ +var CAPITALS_BY_REGION = [ + { + region: "North America", + capitals: [ + { country: "United States", capital: "Washington, D.C.", lat: 38.8951, lng: -77.0364 }, + { country: "Canada", capital: "Ottawa", lat: 45.4215, lng: -75.6972 }, + { country: "Mexico", capital: "Mexico City", lat: 19.4326, lng: -99.1332 }, + { country: "Cuba", capital: "Havana", lat: 23.1136, lng: -82.3666 }, + { country: "Guatemala", capital: "Guatemala City", lat: 14.6349, lng: -90.5069 }, + { country: "Costa Rica", capital: "San Jose", lat: 9.9281, lng: -84.0907 }, + { country: "Panama", capital: "Panama City", lat: 8.9824, lng: -79.5199 }, + { country: "Jamaica", capital: "Kingston", lat: 18.0179, lng: -76.8099 } + ] + }, + { + region: "South America", + capitals: [ + { country: "Brazil", capital: "Brasilia", lat: -15.7975, lng: -47.8919 }, + { country: "Argentina", capital: "Buenos Aires", lat: -34.6037, lng: -58.3816 }, + { country: "Chile", capital: "Santiago", lat: -33.4489, lng: -70.6693 }, + { country: "Colombia", capital: "Bogota", lat: 4.7110, lng: -74.0721 }, + { country: "Peru", capital: "Lima", lat: -12.0464, lng: -77.0428 }, + { country: "Venezuela", capital: "Caracas", lat: 10.4806, lng: -66.9036 }, + { country: "Ecuador", capital: "Quito", lat: -0.1807, lng: -78.4678 }, + { country: "Uruguay", capital: "Montevideo", lat: -34.9011, lng: -56.1645 } + ] + }, + { + region: "Europe", + capitals: [ + { country: "United Kingdom", capital: "London", lat: 51.5074, lng: -0.1278 }, + { country: "France", capital: "Paris", lat: 48.8566, lng: 2.3522 }, + { country: "Germany", capital: "Berlin", lat: 52.5200, lng: 13.4050 }, + { country: "Italy", capital: "Rome", lat: 41.9028, lng: 12.4964 }, + { country: "Spain", capital: "Madrid", lat: 40.4168, lng: -3.7038 }, + { country: "Russia", capital: "Moscow", lat: 55.7558, lng: 37.6173 }, + { country: "Netherlands", capital: "Amsterdam", lat: 52.3676, lng: 4.9041 }, + { country: "Greece", capital: "Athens", lat: 37.9838, lng: 23.7275 }, + { country: "Sweden", capital: "Stockholm", lat: 59.3293, lng: 18.0686 }, + { country: "Norway", capital: "Oslo", lat: 59.9139, lng: 10.7522 }, + { country: "Portugal", capital: "Lisbon", lat: 38.7223, lng: -9.1393 }, + { country: "Czech Republic", capital: "Prague", lat: 50.0755, lng: 14.4378 }, + { country: "Austria", capital: "Vienna", lat: 48.2082, lng: 16.3738 }, + { country: "Switzerland", capital: "Bern", lat: 46.9480, lng: 7.4474 }, + { country: "Poland", capital: "Warsaw", lat: 52.2297, lng: 21.0122 }, + { country: "Turkey", capital: "Ankara", lat: 39.9334, lng: 32.8597 } + ] + }, + { + region: "Asia", + capitals: [ + { country: "Japan", capital: "Tokyo", lat: 35.6762, lng: 139.6503 }, + { country: "China", capital: "Beijing", lat: 39.9042, lng: 116.4074 }, + { country: "India", capital: "New Delhi", lat: 28.6139, lng: 77.2090 }, + { country: "South Korea", capital: "Seoul", lat: 37.5665, lng: 126.9780 }, + { country: "Thailand", capital: "Bangkok", lat: 13.7563, lng: 100.5018 }, + { country: "Vietnam", capital: "Hanoi", lat: 21.0285, lng: 105.8542 }, + { country: "Indonesia", capital: "Jakarta", lat: -6.2088, lng: 106.8456 }, + { country: "Philippines", capital: "Manila", lat: 14.5995, lng: 120.9842 }, + { country: "Singapore", capital: "Singapore", lat: 1.3521, lng: 103.8198 }, + { country: "Malaysia", capital: "Kuala Lumpur", lat: 3.1390, lng: 101.6869 }, + { country: "Pakistan", capital: "Islamabad", lat: 33.6844, lng: 73.0479 }, + { country: "Israel", capital: "Jerusalem", lat: 31.7683, lng: 35.2137 }, + { country: "UAE", capital: "Abu Dhabi", lat: 24.4539, lng: 54.3773 }, + { country: "Saudi Arabia", capital: "Riyadh", lat: 24.7136, lng: 46.6753 } + ] + }, + { + region: "Africa", + capitals: [ + { country: "Egypt", capital: "Cairo", lat: 30.0444, lng: 31.2357 }, + { country: "South Africa", capital: "Pretoria", lat: -25.7479, lng: 28.2293 }, + { country: "Kenya", capital: "Nairobi", lat: -1.2921, lng: 36.8219 }, + { country: "Nigeria", capital: "Abuja", lat: 9.0579, lng: 7.4951 }, + { country: "Morocco", capital: "Rabat", lat: 34.0209, lng: -6.8416 }, + { country: "Ethiopia", capital: "Addis Ababa", lat: 9.0250, lng: 38.7469 }, + { country: "Tanzania", capital: "Dodoma", lat: -6.1630, lng: 35.7516 }, + { country: "Ghana", capital: "Accra", lat: 5.6037, lng: -0.1870 } + ] + }, + { + region: "Oceania", + capitals: [ + { country: "Australia", capital: "Canberra", lat: -35.2809, lng: 149.1300 }, + { country: "New Zealand", capital: "Wellington", lat: -41.2865, lng: 174.7762 }, + { country: "Fiji", capital: "Suva", lat: -18.1416, lng: 178.4419 }, + { country: "Papua New Guinea", capital: "Port Moresby", lat: -6.3149, lng: 143.9556 } + ] + } +]; diff --git a/plugins/flight-map-canvas/extensions/flight-map-canvas/game/destinationInput.js b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/destinationInput.js new file mode 100644 index 00000000..b0bd013d --- /dev/null +++ b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/destinationInput.js @@ -0,0 +1,310 @@ +/*********************************************************************** + * DestinationInput - Keyboard entry for destinations that are not one * + * of the built-in capitals. * + * * + * Opened from the "Input Destination" entry at the top of the * + * destination menu. The dialog runs in two stages: * + * * + * 1. Form - state/region and city are typed, country is picked * + * from the menu. Any one of the three is enough. * + * 2. Results - the matches the geocoder returned, one per row. * + * Picking one and pressing Select flies there. * + * * + * The dialog owns the keyboard while it is open: flight input already * + * ignores events from form fields, and the shortcuts below stop at * + * the dialog so a typed letter never reaches the simulator. * + ***********************************************************************/ +var DestinationInput = (function () { + + var dialog, formStage, resultsStage; + var stateInput, cityInput, countrySelect; + var formMessage, resultsMessage, resultList; + var searchBtn, selectBtn; + + var onSelect = null; + var onOpen = null; + var onClose = null; + + var results = []; + var activeIndex = -1; + var searching = false; + var isOpen = false; + + /* ------------------------------------------------------------ + Country menu + ------------------------------------------------------------ */ + + /** + * Fills the country menu from the capitals dataset, which is the + * only list of country names the page ships with. Leaving the menu + * on its first entry searches without a country filter. + */ + function populateCountries() { + if (typeof CAPITALS_BY_REGION === 'undefined') return; + + var seen = {}; + var names = []; + + for (var i = 0; i < CAPITALS_BY_REGION.length; i++) { + var capitals = CAPITALS_BY_REGION[i].capitals; + for (var j = 0; j < capitals.length; j++) { + var country = capitals[j].country; + if (seen[country]) continue; + seen[country] = true; + names.push(country); + } + } + + names.sort(); + + for (var k = 0; k < names.length; k++) { + var option = document.createElement('option'); + option.value = names[k]; + option.textContent = names[k]; + countrySelect.appendChild(option); + } + } + + /* ------------------------------------------------------------ + Stage switching + ------------------------------------------------------------ */ + + function showForm() { + formStage.classList.remove('hidden'); + resultsStage.classList.add('hidden'); + resultsMessage.textContent = ''; + } + + function showResults() { + formStage.classList.add('hidden'); + resultsStage.classList.remove('hidden'); + formMessage.textContent = ''; + } + + function setSearching(active) { + searching = active; + searchBtn.disabled = active; + searchBtn.textContent = active ? 'Searching...' : 'Search'; + } + + /* ------------------------------------------------------------ + Results list + ------------------------------------------------------------ */ + + function renderResults() { + resultList.innerHTML = ''; + + for (var i = 0; i < results.length; i++) { + var row = document.createElement('li'); + row.className = 'dest-result'; + // Focus stays on the listbox, so each option needs an id for + // the list's aria-activedescendant to point a reader at it + row.id = 'dest-result-' + i; + row.setAttribute('role', 'option'); + row.setAttribute('aria-selected', 'false'); + row.dataset.index = String(i); + row.textContent = results[i].label; + resultList.appendChild(row); + } + + setActiveIndex(results.length ? 0 : -1); + } + + function setActiveIndex(index) { + activeIndex = index; + var rows = resultList.children; + + for (var i = 0; i < rows.length; i++) { + var active = i === index; + rows[i].classList.toggle('active', active); + rows[i].setAttribute('aria-selected', active ? 'true' : 'false'); + } + + selectBtn.disabled = index < 0; + + // Point the listbox at the option the arrow keys landed on, and + // drop the reference entirely when nothing is active + if (index >= 0 && rows[index]) { + resultList.setAttribute('aria-activedescendant', rows[index].id); + if (rows[index].scrollIntoView) { + rows[index].scrollIntoView({ block: 'nearest' }); + } + } else { + resultList.removeAttribute('aria-activedescendant'); + } + } + + function moveActive(step) { + if (!results.length) return; + var next = activeIndex + step; + if (next < 0) next = 0; + if (next > results.length - 1) next = results.length - 1; + setActiveIndex(next); + } + + /* ------------------------------------------------------------ + Actions + ------------------------------------------------------------ */ + + function runSearch() { + if (searching) return; + + formMessage.textContent = ''; + setSearching(true); + + Geocoder.search({ + state: stateInput.value, + city: cityInput.value, + country: countrySelect.value + }, function (error, found) { + setSearching(false); + + if (error) { + formMessage.textContent = error; + return; + } + + results = found; + renderResults(); + showResults(); + + resultsMessage.textContent = found.length + ? '' + : 'No matching destination was found. Cancel to adjust the search.'; + + if (found.length) resultList.focus(); + }); + } + + function commitSelection() { + if (activeIndex < 0 || !results[activeIndex]) return; + var chosen = results[activeIndex]; + close(); + if (onSelect) onSelect(chosen); + } + + /* ------------------------------------------------------------ + Open / close + ------------------------------------------------------------ */ + + function open() { + if (isOpen) return; + isOpen = true; + + results = []; + activeIndex = -1; + // Rows from the previous search are stale the moment the form + // reopens, so nothing should still be pointing at one of them + resultList.removeAttribute('aria-activedescendant'); + formMessage.textContent = ''; + setSearching(false); + showForm(); + + dialog.classList.remove('hidden'); + if (onOpen) onOpen(); + stateInput.focus(); + stateInput.select(); + } + + function close() { + if (!isOpen) return; + isOpen = false; + + dialog.classList.add('hidden'); + if (onClose) onClose(); + } + + /* ------------------------------------------------------------ + Wiring + ------------------------------------------------------------ */ + + /** + * Binds the dialog. options: + * onSelect(destination) - a result the user picked + * onOpen() - the dialog became visible + * onClose() - the dialog was dismissed or committed + */ + function init(options) { + options = options || {}; + onSelect = options.onSelect || null; + onOpen = options.onOpen || null; + onClose = options.onClose || null; + + dialog = document.getElementById('destination-dialog'); + if (!dialog) return; + + formStage = document.getElementById('destination-form'); + resultsStage = document.getElementById('destination-results'); + stateInput = document.getElementById('dest-state'); + cityInput = document.getElementById('dest-city'); + countrySelect = document.getElementById('dest-country'); + formMessage = document.getElementById('dest-form-message'); + resultsMessage = document.getElementById('dest-results-message'); + resultList = document.getElementById('dest-result-list'); + searchBtn = document.getElementById('dest-search'); + selectBtn = document.getElementById('dest-select'); + + populateCountries(); + + searchBtn.addEventListener('click', runSearch); + selectBtn.addEventListener('click', commitSelection); + document.getElementById('dest-cancel').addEventListener('click', close); + document.getElementById('dest-results-cancel').addEventListener('click', close); + + // Enter submits from any field in the form stage + formStage.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + runSearch(); + } + }); + + // Click a row to highlight it, double click to fly straight there + resultList.addEventListener('click', function (e) { + var row = e.target.closest ? e.target.closest('.dest-result') : null; + if (!row) return; + setActiveIndex(parseInt(row.dataset.index, 10)); + }); + + resultList.addEventListener('dblclick', function (e) { + var row = e.target.closest ? e.target.closest('.dest-result') : null; + if (!row) return; + setActiveIndex(parseInt(row.dataset.index, 10)); + commitSelection(); + }); + + resultList.addEventListener('keydown', function (e) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + moveActive(1); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + moveActive(-1); + } else if (e.key === 'Enter') { + e.preventDefault(); + commitSelection(); + } + }); + + // Escape closes from anywhere inside the dialog, and clicking the + // backdrop outside the card does the same + dialog.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + close(); + } + }); + + dialog.addEventListener('mousedown', function (e) { + if (e.target === dialog) close(); + }); + } + + return { + init: init, + open: open, + close: close, + isOpen: function () { return isOpen; } + }; +})(); diff --git a/plugins/flight-map-canvas/extensions/flight-map-canvas/game/flightControls.js b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/flightControls.js new file mode 100644 index 00000000..38cc6e44 --- /dev/null +++ b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/flightControls.js @@ -0,0 +1,385 @@ +/******************************************************************* + * FlightControls - Flight simulator camera controls for Three.js. * + * * + * Controls: * + * Mouse (pointer lock): Pitch and Yaw * + * W / Up Arrow: Increase speed * + * S / Down Arrow: Decrease speed * + * A / Left Arrow: Roll left * + * D / Right Arrow: Roll right * + * Q: Yaw left (rudder) * + * E: Yaw right (rudder) * + * R: Climb * + * F: Descend * + * Space: Auto-level (stabilize) * + * Shift: Boost speed * + * * + * Look modes: * + * pointer - pointer lock captures the cursor (preferred) * + * drag - hold the left mouse button and drag to look * + * * + * Drag mode is the fallback for hosts that deny pointer lock, * + * such as an embedded panel whose frame withholds the permission. * + * Escape releases drag mode the way it releases pointer lock. * + ******************************************************************/ + +/** + * True when a key event belongs to the page rather than to the + * simulator: the user is typing, choosing a value, or working inside a + * dialog. Flight input listens on the document, so without this guard a + * space typed into a destination field would trigger auto-level and + * have its default suppressed. + */ +function isTextEntryTarget(target) { + if (!target || !target.tagName) return false; + + var tag = target.tagName.toLowerCase(); + if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; + if (target.isContentEditable === true) return true; + + return !!(target.closest && target.closest('[role="dialog"]')); +} + +function FlightControls(camera, domElement) { + this.camera = camera; + this.domElement = domElement; + + // Position and orientation + this.position = camera.position.clone(); + this.quaternion = new THREE.Quaternion(); + + // Speed + this.speed = 0; + this.targetSpeed = 0; + this.minSpeed = 0; + this.maxSpeed = 12; + this.boostMultiplier = 3; + this.acceleration = 4.0; + this.deceleration = 3.0; + + // Rotation + this.rollSpeed = 1.8; + this.yawSpeed = 1.0; + this.mouseSensitivity = 0.002; + + // Altitude + this.verticalSpeed = 0; + this.verticalAccel = 4.0; + this.minAltitude = 8; + + // Input state + this.keys = {}; + this.mouseMovementX = 0; + this.mouseMovementY = 0; + this.isPointerLocked = false; + this.isEnabled = false; + + // Look mode: 'pointer' until a lock request is refused, then 'drag' + this.lookMode = 'pointer'; + this.isDragging = false; + this.lockTimeoutMs = 700; + this._lockTimer = null; + + // Auto-level + this.autoLevel = false; + this.autoLevelSpeed = 3.0; + + this._init(); +} + +FlightControls.prototype._init = function () { + var self = this; + + document.addEventListener('keydown', function (e) { + if (isTextEntryTarget(e.target)) return; + self.keys[e.code] = true; + if (e.code === 'Space') { + self.autoLevel = true; + e.preventDefault(); + } + // Pointer lock exits on Escape by itself; drag mode needs releasing + if (e.code === 'Escape' && self.lookMode === 'drag') { + self._release(); + } + }); + + document.addEventListener('keyup', function (e) { + if (isTextEntryTarget(e.target)) return; + self.keys[e.code] = false; + if (e.code === 'Space') { + self.autoLevel = false; + } + }); + + this.domElement.addEventListener('click', function () { + if (self.isEnabled) return; + if (self.lookMode === 'drag') { + self._engage(); + } else if (!self.isPointerLocked) { + self._requestLock(); + } + }); + + this.domElement.addEventListener('mousedown', function (e) { + if (self.lookMode !== 'drag' || !self.isEnabled) return; + if (e.button !== 0) return; + self.isDragging = true; + // Suppress the text selection drag that would otherwise start + e.preventDefault(); + }); + + document.addEventListener('mouseup', function () { + self.isDragging = false; + }); + + document.addEventListener('mousemove', function (e) { + var capturing = self.isPointerLocked || + (self.lookMode === 'drag' && self.isEnabled && self.isDragging); + if (!capturing) return; + self.mouseMovementX += e.movementX || 0; + self.mouseMovementY += e.movementY || 0; + }); + + document.addEventListener('pointerlockchange', function () { + self.isPointerLocked = document.pointerLockElement === self.domElement; + clearTimeout(self._lockTimer); + if (self.isPointerLocked) { + self.lookMode = 'pointer'; + self._engage(); + } else { + self._release(); + } + }); + + document.addEventListener('pointerlockerror', function () { + self._fallbackToDrag(); + }); + + this.domElement.addEventListener('contextmenu', function (e) { + e.preventDefault(); + }); +}; + +/** + * Requests pointer lock, falling back to drag look when the host + * refuses the request, throws, or ignores it without an error event. + */ +FlightControls.prototype._requestLock = function () { + var self = this; + + if (!this.domElement.requestPointerLock) { + this._fallbackToDrag(); + return; + } + + var request; + try { + request = this.domElement.requestPointerLock(); + } catch (e) { + this._fallbackToDrag(); + return; + } + + // Newer browsers resolve a promise; older ones return undefined and + // report failure through the pointerlockerror event + if (request && typeof request.catch === 'function') { + request.catch(function () { + self._fallbackToDrag(); + }); + } + + clearTimeout(this._lockTimer); + this._lockTimer = setTimeout(function () { + if (!self.isPointerLocked) self._fallbackToDrag(); + }, this.lockTimeoutMs); +}; + +/** + * Switches to drag look and engages immediately, so the click that + * asked for pointer lock still starts the flight. + */ +FlightControls.prototype._fallbackToDrag = function () { + clearTimeout(this._lockTimer); + if (this.lookMode !== 'drag') { + this.lookMode = 'drag'; + } + this._engage(); +}; + +/** + * Enables flight input and notifies the UI. + */ +FlightControls.prototype._engage = function () { + if (this.isEnabled) return; + this.isEnabled = true; + this._dispatch(true); +}; + +/** + * Disables flight input and clears any state that could read as stuck. + */ +FlightControls.prototype._release = function () { + if (!this.isEnabled) return; + this.isEnabled = false; + this.isDragging = false; + + // Keyup events can be missed while released; clear input state + // so no key reads as stuck on the next engage + this.keys = {}; + this.autoLevel = false; + this.mouseMovementX = 0; + this.mouseMovementY = 0; + + this._dispatch(false); +}; + +FlightControls.prototype._dispatch = function (locked) { + document.dispatchEvent(new CustomEvent('flightcontrols', { + detail: { locked: locked, mode: this.lookMode } + })); +}; + +FlightControls.prototype.update = function (delta) { + if (!this.isEnabled) return; + delta = Math.min(delta, 0.1); + + // --- Mouse look: pitch and yaw --- + if (this.mouseMovementX !== 0 || this.mouseMovementY !== 0) { + var pitchAngle = -this.mouseMovementY * this.mouseSensitivity; + var yawAngle = -this.mouseMovementX * this.mouseSensitivity; + + // Pitch around camera's local X axis + var right = new THREE.Vector3(1, 0, 0).applyQuaternion(this.quaternion); + var pitchQuat = new THREE.Quaternion().setFromAxisAngle(right, pitchAngle); + this.quaternion.premultiply(pitchQuat); + + // Yaw around world Y axis for stability + var worldUp = new THREE.Vector3(0, 1, 0); + var yawQuat = new THREE.Quaternion().setFromAxisAngle(worldUp, yawAngle); + this.quaternion.premultiply(yawQuat); + + this.quaternion.normalize(); + this.mouseMovementX = 0; + this.mouseMovementY = 0; + } + + // --- Keyboard roll (A/D, Left/Right) --- + var rollDelta = 0; + if (this.keys['KeyA'] || this.keys['ArrowLeft']) rollDelta += this.rollSpeed * delta; + if (this.keys['KeyD'] || this.keys['ArrowRight']) rollDelta -= this.rollSpeed * delta; + + if (rollDelta !== 0) { + var forward = new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion); + var rollQuat = new THREE.Quaternion().setFromAxisAngle(forward, rollDelta); + this.quaternion.premultiply(rollQuat); + this.quaternion.normalize(); + } + + // --- Keyboard yaw / rudder (Q/E) --- + var yawDelta = 0; + if (this.keys['KeyQ']) yawDelta += this.yawSpeed * delta; + if (this.keys['KeyE']) yawDelta -= this.yawSpeed * delta; + + if (yawDelta !== 0) { + var localUp = new THREE.Vector3(0, 1, 0).applyQuaternion(this.quaternion); + var rudderQuat = new THREE.Quaternion().setFromAxisAngle(localUp, yawDelta); + this.quaternion.premultiply(rudderQuat); + this.quaternion.normalize(); + } + + // --- Throttle (W/S, Up/Down) --- + var boost = (this.keys['ShiftLeft'] || this.keys['ShiftRight']) ? this.boostMultiplier : 1; + + if (this.keys['KeyW'] || this.keys['ArrowUp']) { + this.targetSpeed = Math.min(this.targetSpeed + this.acceleration * delta, this.maxSpeed * boost); + } else if (this.keys['KeyS'] || this.keys['ArrowDown']) { + this.targetSpeed = Math.max(this.targetSpeed - this.deceleration * delta, this.minSpeed); + } + + this.targetSpeed = Math.min(this.targetSpeed, this.maxSpeed * boost); + this.speed += (this.targetSpeed - this.speed) * Math.min(5 * delta, 1); + + // --- Altitude (R/F) --- + if (this.keys['KeyR']) this.verticalSpeed += this.verticalAccel * delta; + if (this.keys['KeyF']) this.verticalSpeed -= this.verticalAccel * delta; + this.verticalSpeed *= Math.pow(0.93, delta * 60); + + // --- Auto-level (Space held) --- + if (this.autoLevel) { + var currentUp = new THREE.Vector3(0, 1, 0).applyQuaternion(this.quaternion); + var targetUp = new THREE.Vector3(0, 1, 0); + var correction = new THREE.Quaternion().setFromUnitVectors(currentUp, targetUp); + var t = Math.min(this.autoLevelSpeed * delta, 1); + correction.slerp(new THREE.Quaternion(), 1 - t); + this.quaternion.premultiply(correction); + this.quaternion.normalize(); + } + + // --- Update position --- + var fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion); + this.position.addScaledVector(fwd, this.speed * delta * 60); + this.position.y += this.verticalSpeed * delta * 60; + + if (this.position.y < this.minAltitude) { + this.position.y = this.minAltitude; + this.verticalSpeed = Math.max(this.verticalSpeed, 0); + } + + // --- Apply to camera --- + this.camera.position.copy(this.position); + this.camera.quaternion.copy(this.quaternion); +}; + +FlightControls.prototype.getHeading = function () { + var fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion); + var heading = Math.atan2(fwd.x, -fwd.z) * 180 / Math.PI; + return ((heading % 360) + 360) % 360; +}; + +FlightControls.prototype.getPitch = function () { + var fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(this.quaternion); + return Math.asin(Math.max(-1, Math.min(1, fwd.y))) * 180 / Math.PI; +}; + +FlightControls.prototype.getRoll = function () { + var right = new THREE.Vector3(1, 0, 0).applyQuaternion(this.quaternion); + return Math.asin(Math.max(-1, Math.min(1, right.y))) * 180 / Math.PI; +}; + +FlightControls.prototype.getSpeed = function () { + return this.speed; +}; + +FlightControls.prototype.getAltitude = function () { + return this.position.y; +}; + +/** + * Hands control back to the page. Used when a dialog opens over the + * canvas so the aircraft is not still flying behind it. + */ +FlightControls.prototype.release = function () { + if (this.isPointerLocked && document.exitPointerLock) { + document.exitPointerLock(); + } + this._release(); +}; + +FlightControls.prototype.resetTo = function (position, lookAt) { + this.position.copy(position); + this.speed = 0; + this.targetSpeed = 0; + this.verticalSpeed = 0; + + var tempCam = new THREE.PerspectiveCamera(); + tempCam.position.copy(position); + tempCam.lookAt(lookAt); + this.quaternion.copy(tempCam.quaternion); + + this.camera.position.copy(this.position); + this.camera.quaternion.copy(this.quaternion); +}; + +// Shared with the rest of the page so every document-level shortcut +// applies the same rule about what counts as typing +FlightControls.isTextEntryTarget = isTextEntryTarget; diff --git a/plugins/flight-map-canvas/extensions/flight-map-canvas/game/geocoder.js b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/geocoder.js new file mode 100644 index 00000000..642afcce --- /dev/null +++ b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/geocoder.js @@ -0,0 +1,314 @@ +/************************************************************************ + * Geocoder - Talks to the OpenStreetMap Nominatim API in both * + * directions. * + * * + * update() reverse geocodes the camera's latitude/longitude into a * + * human-readable location label ("City, State, Country"), falling back * + * to "Rural , " when the position is not within a * + * city/town/village boundary. Requests are throttled by time and by * + * minimum movement so the HUD label costs as few lookups as possible. * + * * + * search() forward geocodes a typed state/city/country into a list of * + * candidate destinations for the destination input dialog. * + * * + * Both directions go out through one queue, so the module holds to the * + * Nominatim usage policy (absolute maximum of 1 request per second) * + * whichever direction is asking. * + ***********************************************************************/ +var Geocoder = (function () { + + var NOMINATIM = 'https://nominatim.openstreetmap.org/'; + + var MIN_REQUEST_INTERVAL_MS = 1000; // Nominatim policy: 1 request/second + var MIN_INTERVAL_MS = 5000; // Minimum time between reverse lookups + var MIN_MOVE_DEG = 0.005; // Minimum movement (~500m) before a new lookup + var SEARCH_LIMIT = 10; // Candidates shown in the results list + + var lastLat = null; + var lastLng = null; + var lastRequestTime = 0; + var lastLabel = ''; + var pending = false; + + // Bumped by reset(). A reverse lookup carries the value it was queued + // under, so a response that belongs to the location left behind is + // recognized as stale instead of overwriting the HUD with it. + var generation = 0; + + /* ------------------------------------------------------------ + Shared request queue + + Nominatim counts requests per client across its endpoints, so + reverse lookups and searches cannot each keep their own pace. + One request is in flight at a time and consecutive sends are + spaced by MIN_REQUEST_INTERVAL_MS. + ------------------------------------------------------------ */ + + var queue = []; + var inFlight = null; + var lastSendTime = 0; + var drainTimer = null; + + /** + * Queues one request. job is + * { url, success(data), failure(reason), stale() }, where reason is + * { status, malformed } and the optional stale() predicate lets a + * job whose result is no longer wanted be dropped before it is sent. + */ + function enqueue(job) { + queue.push(job); + drain(); + } + + function drain() { + if (inFlight) return; + + // Drop abandoned jobs before they spend a request slot + while (queue.length && queue[0].stale && queue[0].stale()) { + queue.shift(); + } + if (!queue.length) return; + + var wait = MIN_REQUEST_INTERVAL_MS - (performance.now() - lastSendTime); + if (wait > 0) { + if (drainTimer === null) { + drainTimer = setTimeout(function () { + drainTimer = null; + drain(); + }, wait); + } + return; + } + + send(queue.shift()); + } + + function send(job) { + var settled = false; + var xhr = new XMLHttpRequest(); + + inFlight = xhr; + lastSendTime = performance.now(); + + // A network failure raises both onerror and a readyState 4 with no + // status, so the first outcome to arrive is the one that counts + function finish(handler, argument) { + if (settled) return; + settled = true; + inFlight = null; + handler(argument); + drain(); + } + + xhr.open('GET', job.url, true); + xhr.onreadystatechange = function () { + if (xhr.readyState !== 4) return; + + if (xhr.status !== 200) { + finish(job.failure, { status: xhr.status, malformed: false }); + return; + } + + var data; + try { + data = JSON.parse(xhr.responseText); + } catch (e) { + finish(job.failure, { status: xhr.status, malformed: true }); + return; + } + + finish(job.success, data); + }; + xhr.onerror = function () { + finish(job.failure, { status: 0, malformed: false }); + }; + xhr.send(); + } + + /** + * Builds a display label from a Nominatim address object. + * Within a city boundary: "City, State, Country". + * Outside one: "Rural State, Country" (e.g. "Rural New York, United States"). + */ + function buildLabel(address) { + // Only city/town count as being "within a city"; villages, hamlets, + // and US civil townships ("Town of X" -> village) read as rural + var city = address.city || address.town; + var state = address.state || address.province || address.region || + address.county; + var country = address.country; + var parts = []; + + if (city) { + parts.push(city); + if (state) parts.push(state); + if (country) parts.push(country); + } else { + var area = state || country; + if (!area) return ''; + parts.push('Rural ' + area); + if (state && country) parts.push(country); + } + + return parts.join(', '); + } + + /** + * Requests a label for the given position. Throttled internally, + * so it is safe to call every frame. Invokes callback(label) only + * when the label has changed. + */ + function update(lat, lng, callback) { + var now = performance.now(); + if (pending || now - lastRequestTime < MIN_INTERVAL_MS) return; + if (lastLat !== null && + Math.abs(lat - lastLat) < MIN_MOVE_DEG && + Math.abs(lng - lastLng) < MIN_MOVE_DEG) return; + + pending = true; + lastRequestTime = now; + + var token = generation; + function abandoned() { + return token !== generation; + } + + enqueue({ + url: NOMINATIM + 'reverse?format=jsonv2&zoom=10' + + '&lat=' + encodeURIComponent(lat) + + '&lon=' + encodeURIComponent(lng), + stale: abandoned, + success: function (data) { + // Switched location while this was in flight: the label and + // the position it describes belong to somewhere else now + if (abandoned()) return; + pending = false; + + var label = buildLabel((data && data.address) || {}); + lastLat = lat; + lastLng = lng; + if (label && label !== lastLabel) { + lastLabel = label; + callback(label); + } + }, + failure: function () { + if (abandoned()) return; + pending = false; + // Keep the last label on failure or a malformed response + } + }); + } + + /** + * Clears cached state when switching capitals so the next lookup + * runs immediately for the new location. Lookups already queued or + * in flight for the old location are abandoned rather than allowed + * to report back over the new one. + */ + function reset() { + generation++; + lastLat = null; + lastLng = null; + lastRequestTime = 0; + lastLabel = ''; + pending = false; + } + + /** + * Builds the label shown for one search result: + * "State, City, CC" (e.g. "New York, New York City, US"). + * + * Nominatim omits whichever administrative levels a place does not + * have, so the parts that exist are joined and the raw display_name + * covers a result that carries no structured address at all. + */ + function buildResultLabel(item) { + var address = item.address || {}; + var city = address.city || address.town || address.village || + address.municipality || address.hamlet; + var state = address.state || address.province || address.region || + address.county; + var code = address.country_code ? address.country_code.toUpperCase() : ''; + var parts = []; + + if (state) parts.push(state); + if (city && city !== state) parts.push(city); + if (code) parts.push(code); + + if (!parts.length) return item.display_name || 'Unknown location'; + return parts.join(', '); + } + + /** + * Forward geocodes a destination typed into the input dialog. + * + * Fields are optional individually but at least one must be filled; + * they are sent as Nominatim's structured query parameters rather + * than concatenated into a free-text string, which keeps a city name + * that also exists in another country from outranking the match. + * + * Calls callback(error, results), where each result is + * { label, lat, lng, city, country } ready for loadCapital(). + */ + function search(fields, callback) { + var params = []; + var keys = ['state', 'city', 'country']; + + for (var i = 0; i < keys.length; i++) { + var value = (fields[keys[i]] || '').trim(); + if (value) params.push(keys[i] + '=' + encodeURIComponent(value)); + } + + if (!params.length) { + callback('Enter a state/region, a city, or a country to search.', []); + return; + } + + enqueue({ + url: NOMINATIM + 'search?format=jsonv2&addressdetails=1' + + '&limit=' + SEARCH_LIMIT + '&' + params.join('&'), + success: function (data) { + if (!data || !data.length) { + callback(null, []); + return; + } + + var results = []; + for (var i = 0; i < data.length; i++) { + var item = data[i]; + var lat = parseFloat(item.lat); + var lng = parseFloat(item.lon); + if (isNaN(lat) || isNaN(lng)) continue; + + var address = item.address || {}; + results.push({ + label: buildResultLabel(item), + lat: lat, + lng: lng, + city: address.city || address.town || address.village || + item.name || 'Destination', + country: address.country || '' + }); + } + + callback(null, results); + }, + failure: function (reason) { + if (reason.malformed) { + callback('Search returned a malformed response.', []); + } else if (!reason.status) { + callback('Search could not reach the geocoding service.', []); + } else { + callback('Search failed (' + reason.status + ').', []); + } + } + }); + } + + return { + update: update, + reset: reset, + search: search + }; +})(); diff --git a/plugins/flight-map-canvas/extensions/flight-map-canvas/game/hostBridge.js b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/hostBridge.js new file mode 100644 index 00000000..05e918a2 --- /dev/null +++ b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/hostBridge.js @@ -0,0 +1,73 @@ +/************************************************************************ + * HostBridge - Thin abstraction over the VS Code webview messaging API. * + * * + * The simulator runs in two places: inside a VS Code webview panel and * + * as a plain page in a browser. Everything that needs the extension * + * host (config injection, saving renderMap.json) goes through here so * + * the rest of the code stays identical in both environments. * + * * + * In the browser the API is absent and every call degrades to a no-op, * + * leaving the original web behavior in place. * + ***********************************************************************/ +var HostBridge = (function () { + + var api = null; + var handlers = {}; + + // acquireVsCodeApi exists only inside a webview, and may be called + // exactly once per page load + if (typeof acquireVsCodeApi === 'function') { + try { + api = acquireVsCodeApi(); + } catch (e) { + api = null; + } + } + + if (api) { + window.addEventListener('message', function (event) { + var message = event.data; + if (!message || !message.command) return; + var fn = handlers[message.command]; + if (fn) fn(message); + }); + } + + /** + * True when running inside the VS Code webview panel. + */ + function isHosted() { + return api !== null; + } + + /** + * Send a message to the extension host. No-op in the browser. + */ + function post(message) { + if (api) api.postMessage(message); + } + + /** + * Register a handler for a command sent by the extension host. + */ + function on(command, fn) { + handlers[command] = fn; + } + + /** + * Render configuration injected by the extension host, or null when + * running as a standalone page. + */ + function getInjectedConfig() { + return (window.__flightMapConfig && typeof window.__flightMapConfig === 'object') + ? window.__flightMapConfig + : null; + } + + return { + isHosted: isHosted, + post: post, + on: on, + getInjectedConfig: getInjectedConfig + }; +})(); diff --git a/plugins/flight-map-canvas/extensions/flight-map-canvas/game/index.html b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/index.html new file mode 100644 index 00000000..89eb2063 --- /dev/null +++ b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/index.html @@ -0,0 +1,284 @@ + + + + + + + Flight Map + + + + + +
+
+ + + + + +
+ + +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+

3D Flight Simulator

+

Satellite Terrain Viewer - World Capitals

+

Select a capital city to begin

+
+ + + + + + + + + + + + diff --git a/plugins/flight-map-canvas/extensions/flight-map-canvas/game/renderConfig.js b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/renderConfig.js new file mode 100644 index 00000000..e75012b1 --- /dev/null +++ b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/renderConfig.js @@ -0,0 +1,269 @@ +/*************************************************************************** + * RenderConfig - Loads, applies, and exports map rendering configuration. * + * * + * Inside the VS Code panel the extension host reads renderMap.json and * + * injects it before the page scripts run. As a standalone page the * + * config is fetched from renderMap.json in the project root. Either way * + * the values are merged over the defaults and applied to the live * + * simulation. * + * * + * Export hands the current configuration to the extension host, which * + * writes it through a save dialog. In the browser it falls back to a * + * plain file download. * + **************************************************************************/ +var RenderConfig = (function () { + + var defaults = { + map: { + width: 1920, + height: 1080, + tileZoom: 15, + tileSize: 100, + loadRadius: 6, + unloadRadius: 9, + startAltitude: 200, + fogDensity: 0.00018, + radiusFeather: 3, + maxTiles: 600 + }, + filler: { + sampleInterval: 45, + updateIntervalSec: 10, + perimeter: { patchCount: 10, patchSize: 10 }, + center: { patchCount: 5, patchSize: 10 }, + padding: { patchCount: 4, patchSize: 5 } + }, + cloud: { + enabled: true, + opacity: 0.35, + speed: 0.0004, + coverage: 0.5, + scale: 0.008 + } + }; + + var config = JSON.parse(JSON.stringify(defaults)); + var listeners = []; + + /** + * Deep merge source into target, preserving existing keys. + */ + function merge(target, source) { + for (var key in source) { + if (!source.hasOwnProperty(key)) continue; + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { + if (!target[key]) target[key] = {}; + merge(target[key], source[key]); + } else { + target[key] = source[key]; + } + } + return target; + } + + function freshDefaults() { + return JSON.parse(JSON.stringify(defaults)); + } + + /** + * Load configuration, merged over defaults. Prefers the config the + * extension host injected; otherwise reads renderMap.json from the + * project root relative to this page. + */ + function load(callback) { + var injected = HostBridge.getInjectedConfig(); + if (injected) { + config = merge(freshDefaults(), injected); + if (callback) callback(config); + return; + } + + var xhr = new XMLHttpRequest(); + xhr.open('GET', '../renderMap.json', true); + xhr.onreadystatechange = function () { + if (xhr.readyState !== 4) return; + if (xhr.status === 200) { + try { + var data = JSON.parse(xhr.responseText); + config = merge(freshDefaults(), data); + } catch (e) { + console.warn('RenderConfig: invalid JSON, using defaults'); + config = freshDefaults(); + } + } else { + console.warn('RenderConfig: could not load renderMap.json, using defaults'); + config = freshDefaults(); + } + if (callback) callback(config); + }; + xhr.send(); + } + + /** + * Get the full config object. + */ + function get() { + return config; + } + + /** + * Set a config value by dot-notation path (e.g. "cloud.opacity"). + */ + function set(path, value) { + var keys = path.split('.'); + var obj = config; + for (var i = 0; i < keys.length - 1; i++) { + if (!obj[keys[i]]) obj[keys[i]] = {}; + obj = obj[keys[i]]; + } + obj[keys[keys.length - 1]] = value; + apply(); + notify(path, value); + } + + /** + * Subscribe to config changes. + */ + function onChange(fn) { + listeners.push(fn); + } + + function notify(path, value) { + for (var i = 0; i < listeners.length; i++) { + listeners[i](path, value, config); + } + } + + /** + * Apply current config values to the live simulation globals. + */ + function apply() { + // Map globals + TILE_ZOOM = config.map.tileZoom; + TILE_SIZE = config.map.tileSize; + LOAD_RADIUS = config.map.loadRadius; + UNLOAD_RADIUS = Math.max(config.map.unloadRadius, LOAD_RADIUS + 3); + START_ALTITUDE = config.map.startAltitude; + FOG_DENSITY = config.map.fogDensity; + RADIUS_FEATHER = config.map.radiusFeather; + MAX_TILES = config.map.maxTiles; + + // Apply fog density to scene + if (typeof scene !== 'undefined' && scene && scene.fog) { + scene.fog.density = config.map.fogDensity; + } + + // Apply render resolution. CSS stretches the canvas buffer over + // the panel, so a buffer shaped differently from the panel would + // render distorted. Keep the configured width x height as the + // pixel budget and reshape it to the panel's aspect ratio, so + // resizing the panel changes how much is in frame rather than + // how the frame is squashed. + if (typeof renderer !== 'undefined' && renderer && + typeof camera !== 'undefined' && camera && + config.map.width > 0 && config.map.height > 0) { + + var aspect = (window.innerWidth > 0 && window.innerHeight > 0) + ? window.innerWidth / window.innerHeight + : config.map.width / config.map.height; + + var pixelBudget = config.map.width * config.map.height; + var height = Math.max(1, Math.round(Math.sqrt(pixelBudget / aspect))); + var width = Math.max(1, Math.round(height * aspect)); + + renderer.setSize(width, height, false); + camera.aspect = aspect; + camera.updateProjectionMatrix(); + } + + // Apply filler settings to TerrainFill instance + if (typeof terrainFill !== 'undefined' && terrainFill) { + terrainFill.sampleInterval = config.filler.sampleInterval; + terrainFill.updateIntervalSec = config.filler.updateIntervalSec; + terrainFill.perimeterPatchCount = config.filler.perimeter.patchCount; + terrainFill.perimeterPatchSize = config.filler.perimeter.patchSize; + terrainFill.centerPatchCount = config.filler.center.patchCount; + terrainFill.centerPatchSize = config.filler.center.patchSize; + terrainFill.paddingPatchCount = config.filler.padding.patchCount; + terrainFill.paddingPatchSize = config.filler.padding.patchSize; + } + + // Apply cloud settings + if (typeof terrainFill !== 'undefined' && terrainFill && terrainFill.cloudOverlay) { + terrainFill.cloudOverlay.enabled = config.cloud.enabled; + terrainFill.cloudOverlay.opacity = config.cloud.opacity; + terrainFill.cloudOverlay.speed = config.cloud.speed; + terrainFill.cloudOverlay.coverage = config.cloud.coverage; + terrainFill.cloudOverlay.scale = config.cloud.scale; + } + } + + /** + * Export the current configuration as renderMap.json. The extension + * host owns the file write so the save lands in the workspace; a + * standalone page falls back to a browser download. + */ + function exportJSON() { + var json = JSON.stringify(config, null, 4); + + if (HostBridge.isHosted()) { + HostBridge.post({ command: 'exportConfig', json: json }); + return; + } + + var blob = new Blob([json], { type: 'application/json' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'renderMap.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + /** + * Populate slider/input elements from the current config and bind change events. + */ + function bindSliders() { + var sliders = document.querySelectorAll('[data-config]'); + for (var i = 0; i < sliders.length; i++) { + (function (el) { + var path = el.getAttribute('data-config'); + var keys = path.split('.'); + var val = config; + for (var k = 0; k < keys.length; k++) { + val = val[keys[k]]; + } + el.value = val; + var display = document.getElementById(el.id + '-val'); + if (display) display.textContent = val; + + el.addEventListener('input', function () { + var v = parseFloat(el.value); + set(path, v); + if (display) display.textContent = v; + }); + })(sliders[i]); + } + + // Cloud enabled checkbox + var cloudToggle = document.getElementById('cfg-cloud-enabled'); + if (cloudToggle) { + cloudToggle.checked = config.cloud.enabled; + cloudToggle.addEventListener('change', function () { + set('cloud.enabled', cloudToggle.checked); + }); + } + } + + return { + load: load, + get: get, + set: set, + apply: apply, + exportJSON: exportJSON, + bindSliders: bindSliders, + onChange: onChange + }; +})(); diff --git a/plugins/flight-map-canvas/extensions/flight-map-canvas/game/styles.css b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/styles.css new file mode 100644 index 00000000..dc8439df --- /dev/null +++ b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/styles.css @@ -0,0 +1,846 @@ +/* ============================================================ + 3D Flight Simulator - Styles + ============================================================ */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + background: #000; + color: #e0e0e0; + overflow: hidden; + width: 100vw; + height: 100vh; +} + +/* ---- Canvas Container ---- */ +#canvas-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1; +} + +#canvas-container canvas { + display: block; + width: 100%; + height: 100%; +} + +/* ---- Top Bar ---- */ + +/* + The selector panel and the location readout share one row. The bar + mirrors the selector panel's measured width as right padding so the + readout reads as centered in the panel rather than in the space left + over beside the menu. When the panel is too narrow for both, app.js + adds .stacked and the readout takes its own row underneath. + + --selector-width and --top-bar-height are published by app.js. +*/ +#top-bar { + position: fixed; + top: 0; + left: 0; + width: auto; + z-index: 100; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + padding: 12px; + padding-right: calc(12px + var(--selector-width, 0px)); + pointer-events: none; +} + +#top-bar.stacked { + padding-right: 12px; +} + +/* ---- Capital Selector ---- */ +#selector-panel { + flex: 0 1 auto; + min-width: 0; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + background: rgba(0, 0, 0, 0.65); + padding: 8px 14px; + border-radius: 6px; + border: 1px solid rgba(100, 200, 255, 0.2); + backdrop-filter: blur(8px); + pointer-events: auto; +} + +#selector-panel label { + font-size: 0.75rem; + color: #88bbdd; + text-transform: uppercase; + letter-spacing: 1px; +} + +#capital-select { + padding: 5px 10px; + font-size: 0.85rem; + font-family: inherit; + border: 1px solid rgba(100, 200, 255, 0.3); + border-radius: 3px; + background: rgba(10, 20, 40, 0.9); + color: #c0e0f0; + cursor: pointer; + flex: 1 1 auto; + min-width: 140px; + max-width: 100%; + outline: none; +} + +#capital-select:focus { + border-color: rgba(100, 200, 255, 0.6); +} + +#capital-select optgroup { + color: #66aacc; + font-weight: bold; +} + +#capital-select option { + color: #c0e0f0; + background: #0a1428; +} + +/* ---- HUD Overlay ---- */ +#hud { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 50; + pointer-events: none; +} + +/* Location display (lives in #top-bar; the flex basis below is mirrored + by LOCATION_MIN_WIDTH in app.js, which decides when to stack) */ +#hud-location { + /* flex: 1 1 240px; */ + min-width: 0; + text-align: center; +} + +/* Both lines sit over bright satellite imagery, so they carry a hard + 1px drop shadow rather than a blurred glow. A blur radius spreads the + dark halo through the glyphs themselves and softens the strokes, which + is what made the coordinates unreadable against a pale sky. */ +#hud-capital { + font-size: calc(1rem + 2pt); + color: rgb(235, 246, 255); + font-weight: bold; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* white-space: pre keeps the two spaces that separate the latitude from + the longitude; normal whitespace handling would collapse them to one */ +#hud-coords { + font-size: calc(0.72rem + 2pt); + color: rgb(214, 234, 250); + margin-top: 2px; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85); + overflow: hidden; + text-overflow: ellipsis; + white-space: pre; +} + +/* Heading: sits under the top bar at whatever height it settled on */ +#hud-heading { + position: absolute; + top: calc(var(--top-bar-height, 56px) + 6px); + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.35); + padding: 3px 16px; + border-radius: 3px; + border: 1px solid rgba(100, 200, 255, 0.15); +} + +#hud-heading-value { + font-size: 0.9rem; + color: rgba(100, 220, 255, 0.85); + text-shadow: 0 0 6px rgba(0, 100, 200, 0.3); +} + +/* Speed (left side) */ +#hud-speed { + position: absolute; + left: 30px; + top: 50%; + transform: translateY(-50%); + text-align: center; + background: rgba(0, 0, 0, 0.35); + padding: 10px 14px; + border-radius: 4px; + border: 1px solid rgba(100, 200, 255, 0.15); +} + +#hud-speed .hud-label { + font-size: 0.65rem; + color: rgba(100, 200, 255, 0.5); + letter-spacing: 2px; + margin-bottom: 4px; +} + +#hud-speed-value { + font-size: 1.6rem; + color: rgba(100, 255, 180, 0.9); + font-weight: bold; + text-shadow: 0 0 10px rgba(0, 200, 100, 0.3); +} + +#hud-speed .hud-unit { + font-size: 0.6rem; + color: rgba(100, 200, 255, 0.4); + margin-top: 2px; +} + +/* Altitude (right side) */ +#hud-altitude { + position: absolute; + right: 30px; + top: 50%; + transform: translateY(-50%); + text-align: center; + background: rgba(0, 0, 0, 0.35); + padding: 10px 14px; + border-radius: 4px; + border: 1px solid rgba(100, 200, 255, 0.15); +} + +#hud-altitude .hud-label { + font-size: 0.65rem; + color: rgba(100, 200, 255, 0.5); + letter-spacing: 2px; + margin-bottom: 4px; +} + +#hud-altitude-value { + font-size: 1.6rem; + color: rgba(255, 200, 100, 0.9); + font-weight: bold; + text-shadow: 0 0 10px rgba(200, 150, 0, 0.3); +} + +#hud-altitude .hud-unit { + font-size: 0.6rem; + color: rgba(100, 200, 255, 0.4); + margin-top: 2px; +} + +/* Crosshair */ +#hud-crosshair { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 1.5rem; + color: rgba(200, 230, 255, 0.4); + text-shadow: 0 0 4px rgba(0, 0, 0, 0.5); + line-height: 1; +} + +/* Pitch indicator */ +#hud-pitch { + position: absolute; + right: 30px; + top: 50%; + transform: translateY(80px); + text-align: center; +} + +#hud-pitch-value { + font-size: 0.75rem; + color: rgba(180, 200, 255, 0.6); +} + +/* ---- Click to Fly Prompt ---- */ +#fly-prompt { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 80; + text-align: center; + pointer-events: none; +} + +#fly-prompt .prompt-text { + font-size: 1.8rem; + color: rgba(180, 220, 255, 0.9); + text-shadow: 0 0 20px rgba(50, 150, 255, 0.5); + animation: pulse 2s ease-in-out infinite; + letter-spacing: 2px; + text-transform: uppercase; +} + +#fly-prompt .prompt-hint { + margin-top: 8px; + font-size: 0.72rem; + letter-spacing: 1px; + color: rgba(120, 170, 210, 0.6); +} + +@keyframes pulse { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 1; } +} + +/* ---- Controls Help ---- */ +#controls-help { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 80; + text-align: center; + background: rgba(0, 0, 0, 0.5); + padding: 8px 20px; + border-radius: 6px; + border: 1px solid rgba(100, 200, 255, 0.15); + pointer-events: none; + font-size: 0.72rem; + color: rgba(160, 200, 230, 0.8); + line-height: 1.6; + transition: opacity 2s ease; +} + +#controls-help.fade-out { + opacity: 0; +} + +/* ---- Static Controls List ---- */ +#controls-list { + position: fixed; + left: 12px; + bottom: 20px; + z-index: 60; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(100, 200, 255, 0.1); + border-radius: 4px; + padding: 8px 12px; + pointer-events: none; + font-size: 0.62rem; + color: rgba(150, 190, 220, 0.6); +} + +#controls-list .controls-title { + font-size: 0.58rem; + color: rgba(120, 170, 210, 0.5); + text-transform: uppercase; + letter-spacing: 2px; + margin-bottom: 5px; +} + +#controls-list ul { + list-style: none; +} + +#controls-list li { + line-height: 1.55; + white-space: nowrap; +} + +#controls-list li span { + display: inline-block; + width: 52px; + color: rgba(120, 210, 255, 0.7); +} + +/* ---- Pause Indicator ---- */ +#pause-indicator { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 95; + background: rgba(0, 0, 0, 0.45); + padding: 12px 28px; + border-radius: 8px; + border: 1px solid rgba(255, 220, 120, 0.3); + pointer-events: none; + font-size: 1.6rem; + letter-spacing: 6px; + text-transform: uppercase; + color: rgba(255, 220, 120, 0.9); + text-shadow: 0 0 20px rgba(0, 0, 0, 0.8); +} + +/* ---- Loading Overlay ---- */ +#loading-overlay { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 90; + text-align: center; + background: rgba(0, 10, 30, 0.85); + padding: 30px 50px; + border-radius: 10px; + border: 1px solid rgba(100, 200, 255, 0.2); + backdrop-filter: blur(10px); +} + +#loading-text { + font-size: 1rem; + color: rgba(150, 200, 240, 0.9); + margin-bottom: 14px; + text-transform: uppercase; + letter-spacing: 2px; +} + +#loading-bar-container { + width: 240px; + height: 4px; + background: rgba(50, 80, 120, 0.5); + border-radius: 2px; + overflow: hidden; + margin: 0 auto; +} + +#loading-bar { + width: 0%; + height: 100%; + background: linear-gradient(90deg, #2288cc, #44ccff); + border-radius: 2px; + transition: width 0.3s ease; +} + +#loading-progress { + font-size: 0.8rem; + color: rgba(100, 180, 230, 0.7); + margin-top: 8px; +} + +/* ---- Welcome Screen ---- */ +#welcome { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 70; + text-align: center; + pointer-events: none; +} + +#welcome h1 { + font-size: 2.2rem; + color: rgba(180, 220, 255, 0.9); + font-weight: 300; + letter-spacing: 4px; + text-transform: uppercase; + text-shadow: 0 0 30px rgba(50, 150, 255, 0.3); + margin-bottom: 8px; +} + +#welcome p { + font-size: 0.85rem; + color: rgba(130, 170, 210, 0.6); + letter-spacing: 1px; +} + +#welcome .welcome-hint { + margin-top: 20px; + font-size: 0.75rem; + color: rgba(100, 150, 200, 0.5); + animation: pulse 3s ease-in-out infinite; +} + +/* ---- Agent Status ---- */ + +/* Clears the controls help that sits at the bottom edge, so both can be + on screen during the five seconds before the help fades */ +#agent-status { + position: fixed; + bottom: 64px; + left: 50%; + transform: translateX(-50%); + z-index: 90; + max-width: min(560px, calc(100vw - 24px)); + display: flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + border-radius: 20px; + background: rgba(0, 8, 24, 0.8); + border: 1px solid rgba(100, 200, 255, 0.25); + font-size: 0.75rem; + color: rgba(200, 230, 255, 0.9); + pointer-events: none; +} + +#agent-status-dot { + flex: 0 0 auto; + width: 8px; + height: 8px; + border-radius: 50%; + background: #66ddaa; + animation: pulse 1.6s ease-in-out infinite; +} + +/* A finished job leaves the last status readable but stops the pulse */ +#agent-status.idle #agent-status-dot { + background: rgba(120, 170, 210, 0.5); + animation: none; +} + +#agent-status-text { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#agent-status-tokens { + flex: 0 0 auto; + color: rgba(120, 200, 255, 0.65); +} + +#agent-status-tokens:empty { + display: none; +} + +/* ---- Utility ---- */ +.hidden { + display: none !important; +} + +/* ---- Destination Input Dialog ---- */ + +/* The "Input Destination" entry is the only action in the menu, so it + is set apart from the capitals listed under it. */ +#opt-input-destination { + background: #e8edf1; + color: #16283f; + font-weight: bold; +} + +/* Sits above the top bar so the dialog is never partly behind the menu, + and covers the canvas so a click cannot engage flight behind it. The + card hangs from just under the bar rather than centering, so it stays + next to the menu entry that opened it. */ +#destination-dialog { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 24px; + padding-top: calc(var(--top-bar-height, 56px) + 16px); + background: rgba(4, 12, 24, 0.45); +} + +.destination-card { + width: 100%; + max-width: 400px; + max-height: calc(100vh - var(--top-bar-height, 56px) - 40px); + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; + padding: 18px; + border-radius: 14px; + background: rgba(238, 242, 245, 0.97); + border: 1px solid rgba(120, 160, 200, 0.45); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45); +} + +#destination-form, +#destination-results { + display: flex; + flex-direction: column; + gap: 10px; +} + +.dest-row { + display: flex; + align-items: center; + gap: 10px; +} + +/* Fixed basis keeps the three fields on one left edge regardless of + how wide the individual label text is */ +.dest-label { + flex: 0 0 92px; + padding: 8px 10px; + border-radius: 8px; + background: #ccd4da; + color: #1c2b3d; + font-size: 0.7rem; + letter-spacing: 0.5px; + text-align: center; +} + +.dest-row input, +.dest-row select { + flex: 1 1 auto; + min-width: 0; + padding: 9px 12px; + font-family: inherit; + font-size: 0.9rem; + border-radius: 8px; + border: 1px solid rgba(80, 130, 180, 0.35); + background: #16283f; + color: #dceaf6; + outline: none; +} + +.dest-row input::placeholder { + color: rgba(190, 215, 235, 0.55); +} + +.dest-row input:focus, +.dest-row select:focus { + border-color: rgba(120, 200, 255, 0.85); +} + +.dest-row select option { + background: #16283f; + color: #dceaf6; +} + +#dest-result-list { + list-style: none; + max-height: 210px; + overflow-y: auto; + padding: 6px 4px; + border-radius: 10px; + border: 1px solid rgba(80, 130, 180, 0.35); + background: #16283f; + outline: none; +} + +#dest-result-list:focus { + border-color: rgba(120, 200, 255, 0.85); +} + +/* The dashed rule between rows is a bottom border on every row but the + last, so the list reads as separated entries rather than a paragraph */ +.dest-result { + padding: 9px 10px; + border-radius: 6px; + color: #dceaf6; + font-size: 0.85rem; + cursor: pointer; + border-bottom: 1px dashed rgba(140, 185, 225, 0.4); +} + +.dest-result:last-child { + border-bottom: none; +} + +.dest-result:hover { + background: rgba(90, 140, 190, 0.25); +} + +.dest-result.active { + background: rgba(110, 165, 215, 0.45); + color: #ffffff; +} + +.dest-message { + min-height: 1em; + font-size: 0.72rem; + color: #7a1f1f; +} + +#dest-results-message { + color: #33465c; +} + +.dest-actions { + display: flex; + gap: 12px; +} + +.dest-actions button { + flex: 1 1 0; + padding: 10px 12px; + font-family: inherit; + font-size: 0.85rem; + border-radius: 8px; + border: 1px solid rgba(80, 130, 180, 0.35); + background: #16283f; + color: #dceaf6; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} + +.dest-actions button:hover:not(:disabled) { + background: #21395a; + border-color: rgba(120, 200, 255, 0.7); +} + +.dest-actions button:disabled { + opacity: 0.5; + cursor: default; +} + +/* ---- Selector Panel Buttons ---- */ +#selector-panel button { + padding: 5px 12px; + font-size: 0.75rem; + font-family: inherit; + border: 1px solid rgba(100, 200, 255, 0.3); + border-radius: 3px; + background: rgba(10, 20, 40, 0.9); + color: #c0e0f0; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 1px; + transition: border-color 0.2s, background 0.2s; +} + +#selector-panel button:hover { + border-color: rgba(100, 200, 255, 0.6); + background: rgba(20, 40, 70, 0.9); +} + +#btn-export-json { + color: #88ddaa !important; + border-color: rgba(100, 255, 160, 0.3) !important; +} + +#btn-export-json:hover { + border-color: rgba(100, 255, 160, 0.6) !important; +} + +/* ---- Render Config Panel ---- */ +#config-panel { + position: fixed; + top: calc(var(--top-bar-height, 56px) + 12px); + left: 12px; + z-index: 100; + width: 320px; + max-width: calc(100vw - 24px); + max-height: calc(100vh - var(--top-bar-height, 56px) - 36px); + overflow-y: auto; + background: rgba(0, 8, 24, 0.88); + padding: 14px 16px; + border-radius: 6px; + border: 1px solid rgba(100, 200, 255, 0.2); + backdrop-filter: blur(10px); + font-size: 0.72rem; +} + +#config-panel h3 { + font-size: 0.85rem; + color: rgba(180, 220, 255, 0.9); + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; + margin-bottom: 4px; +} + +#config-panel .config-hint { + font-size: 0.65rem; + color: rgba(120, 170, 210, 0.6); + margin-bottom: 12px; + line-height: 1.4; +} + +#config-panel fieldset { + border: 1px solid rgba(100, 200, 255, 0.12); + border-radius: 4px; + padding: 8px 10px; + margin-bottom: 10px; +} + +#config-panel legend { + font-size: 0.7rem; + color: #66aacc; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 1px; + padding: 0 6px; +} + +.cfg-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.cfg-row label { + flex: 0 0 100px; + font-size: 0.68rem; + color: rgba(160, 200, 230, 0.8); + text-align: right; +} + +.cfg-row input[type="range"] { + flex: 1; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: rgba(50, 80, 120, 0.5); + border-radius: 2px; + outline: none; +} + +.cfg-row input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: #44ccff; + cursor: pointer; + border: 1px solid rgba(0, 0, 0, 0.3); +} + +.cfg-row input[type="range"]::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: #44ccff; + cursor: pointer; + border: 1px solid rgba(0, 0, 0, 0.3); +} + +.cfg-row input[type="checkbox"] { + width: 14px; + height: 14px; + cursor: pointer; + accent-color: #44ccff; +} + +.cfg-row span { + flex: 0 0 50px; + font-size: 0.65rem; + color: rgba(100, 220, 255, 0.85); + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* Scrollbar has styling for config panel */ +#config-panel::-webkit-scrollbar { + width: 4px; +} + +#config-panel::-webkit-scrollbar-track { + background: transparent; +} + +#config-panel::-webkit-scrollbar-thumb { + background: rgba(100, 200, 255, 0.2); + border-radius: 2px; +} diff --git a/plugins/flight-map-canvas/extensions/flight-map-canvas/game/terrainFill.js b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/terrainFill.js new file mode 100644 index 00000000..2fb4c9a7 --- /dev/null +++ b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/terrainFill.js @@ -0,0 +1,441 @@ +/************************************************************************************ + * TerrainFill - Fills unloaded terrain gaps using sampled colors * + * from the rendered viewport. * + * * + * Algorithm: * + * 1. Render scene (without fill) to a low-res offscreen target * + * 2. Sample 10x10px patches from each of the 4 viewport borders (40 samples) * + * 3. Sample 10x10px patches from the middle area (5 samples) * + * 4. Sample 5x5px patches from padding perimeter in flight direction (4 samples) * + * 5. Filter out placeholder/sky/water colors * + * 6. Generate a fill texture by blending sampled colors with value noise * + * 7. Apply the texture to a large fill plane between tiles and water * + ***********************************************************************************/ +function TerrainFill(renderer, scene, camera) { + this.renderer = renderer; + this.scene = scene; + this.camera = camera; + + // Low-res render target for viewport sampling + this.sampleW = 256; + this.sampleH = 256; + this.sampleTarget = new THREE.WebGLRenderTarget(this.sampleW, this.sampleH); + this.sampleBuffer = new Uint8Array(this.sampleW * this.sampleH * 4); + + // Fill texture canvas + this.texW = 256; + this.texH = 256; + this.fillCanvas = document.createElement('canvas'); + this.fillCanvas.width = this.texW; + this.fillCanvas.height = this.texH; + this.fillCtx = this.fillCanvas.getContext('2d'); + + // Initialize with placeholder color + this.fillCtx.fillStyle = '#2a3a2a'; + this.fillCtx.fillRect(0, 0, this.texW, this.texH); + + // Three.js canvas texture for the fill plane + this.fillTexture = new THREE.CanvasTexture(this.fillCanvas); + this.fillTexture.wrapS = THREE.RepeatWrapping; + this.fillTexture.wrapT = THREE.RepeatWrapping; + this.fillTexture.repeat.set(50, 50); + + // Fill plane mesh sitting between tile meshes (y=0) and water (y=-2) + var geo = new THREE.PlaneGeometry(30000, 30000); + var mat = new THREE.MeshBasicMaterial({ map: this.fillTexture }); + this.fillMesh = new THREE.Mesh(geo, mat); + this.fillMesh.rotation.x = -Math.PI / 2; + this.fillMesh.position.y = -0.5; + scene.add(this.fillMesh); + + // Collected sample colors (array of {r,g,b} objects) + this.sampleColors = []; + + // Timing + this.frameCount = 0; + this.sampleInterval = 45; // Sample every N frames + + // Time-based filler pattern update interval (seconds) + this.updateIntervalSec = 10; + this.lastUpdateTime = 0; + + // Configurable patch counts and sizes (overridden by RenderConfig) + this.perimeterPatchCount = 10; + this.perimeterPatchSize = 10; + this.centerPatchCount = 5; + this.centerPatchSize = 10; + this.paddingPatchCount = 4; + this.paddingPatchSize = 5; + + // Cloud overlay effect applied to the fill area + this.cloudOverlay = { + enabled: true, + opacity: 0.35, + speed: 0.0004, + coverage: 0.5, + scale: 0.008, + time: 0 + }; + + // Cloud canvas and texture (rendered on top of fill plane) + this.cloudCanvas = document.createElement('canvas'); + this.cloudCanvas.width = this.texW; + this.cloudCanvas.height = this.texH; + this.cloudCtx = this.cloudCanvas.getContext('2d'); + + this.cloudTexture = new THREE.CanvasTexture(this.cloudCanvas); + this.cloudTexture.wrapS = THREE.RepeatWrapping; + this.cloudTexture.wrapT = THREE.RepeatWrapping; + this.cloudTexture.repeat.set(50, 50); + + var cloudGeo = new THREE.PlaneGeometry(30000, 30000); + var cloudMat = new THREE.MeshBasicMaterial({ + map: this.cloudTexture, + transparent: true, + depthWrite: false, + opacity: 1.0 + }); + this.cloudMesh = new THREE.Mesh(cloudGeo, cloudMat); + this.cloudMesh.rotation.x = -Math.PI / 2; + this.cloudMesh.position.y = -0.3; + scene.add(this.cloudMesh); + + // Pre-compute noise permutation table + this.perm = new Array(512); + var p = new Array(256); + for (var i = 0; i < 256; i++) p[i] = i; + for (var i = 255; i > 0; i--) { + var j = Math.floor(Math.random() * (i + 1)); + var tmp = p[i]; p[i] = p[j]; p[j] = tmp; + } + for (var i = 0; i < 512; i++) this.perm[i] = p[i & 255]; + + // Second permutation for cloud noise (different seed) + this.cloudPerm = new Array(512); + var cp = new Array(256); + for (var i = 0; i < 256; i++) cp[i] = i; + for (var i = 255; i > 0; i--) { + var j = Math.floor(Math.random() * (i + 1)); + var tmp = cp[i]; cp[i] = cp[j]; cp[j] = tmp; + } + for (var i = 0; i < 512; i++) this.cloudPerm[i] = cp[i & 255]; + + // Pre-compute noise map (noise values won't change, only color mapping does) + this.noiseMap = new Float32Array(this.texW * this.texH); + for (var y = 0; y < this.texH; y++) { + for (var x = 0; x < this.texW; x++) { + var n1 = this._noise2d(x * 0.012, y * 0.012); + var n2 = this._noise2d(x * 0.035 + 97.3, y * 0.035 + 97.3) * 0.5; + var n3 = this._noise2d(x * 0.08 + 231.7, y * 0.08 + 231.7) * 0.25; + this.noiseMap[y * this.texW + x] = (n1 + n2 + n3) / 1.75; + } + } +} + +/** + * Called each frame after main render. Periodically samples the viewport + * and regenerates the fill texture. + */ +TerrainFill.prototype.update = function (flightHeading) { + // Keep fill plane centered on camera + this.fillMesh.position.x = this.camera.position.x; + this.fillMesh.position.z = this.camera.position.z; + + // Keep cloud mesh centered on camera + this.cloudMesh.position.x = this.camera.position.x; + this.cloudMesh.position.z = this.camera.position.z; + + this.frameCount++; + if (this.frameCount % this.sampleInterval !== 0) return; + + // Collect viewport samples every sampleInterval frames + this._collectSamples(flightHeading); + + // Regenerate fill texture on a time-based interval (seconds) + var now = performance.now() / 1000; + if (this.sampleColors.length > 20 && (now - this.lastUpdateTime) >= this.updateIntervalSec) { + this._generateFillTexture(); + this.lastUpdateTime = now; + } + + // Update cloud effect + this._updateCloud(); +}; + +/** + * Samples colors from the rendered viewport at border, middle, + * and padding perimeter positions. + */ +TerrainFill.prototype._collectSamples = function (flightHeading) { + // Hide generated overlays so we only sample actual terrain + sky + var cloudWasVisible = this.cloudMesh.visible; + this.fillMesh.visible = false; + this.cloudMesh.visible = false; + + // Render scene to low-res offscreen target + this.renderer.setRenderTarget(this.sampleTarget); + this.renderer.render(this.scene, this.camera); + this.renderer.setRenderTarget(null); + + this.fillMesh.visible = true; + this.cloudMesh.visible = cloudWasVisible; + + // Read pixels from the render target + this.renderer.readRenderTargetPixels( + this.sampleTarget, 0, 0, + this.sampleW, this.sampleH, + this.sampleBuffer + ); + + var w = this.sampleW; + var h = this.sampleH; + var buf = this.sampleBuffer; + var self = this; + this.sampleColors = []; + + // Helper: extract valid (non-placeholder/sky/water) colors from a patch + function extractPatch(px, py, size) { + var result = []; + for (var dy = 0; dy < size; dy++) { + for (var dx = 0; dx < size; dx++) { + var sx = Math.max(0, Math.min(px + dx, w - 1)); + var sy = Math.max(0, Math.min(py + dy, h - 1)); + var idx = (sy * w + sx) * 4; + var r = buf[idx], g = buf[idx + 1], b = buf[idx + 2]; + if (!self._isSkipColor(r, g, b)) { + result.push({ r: r, g: g, b: b }); + } + } + } + return result; + } + + var borderDepth = Math.max(Math.floor(h * 0.08), 8); + var patchSize = this.perimeterPatchSize; + var smallPatch = this.paddingPatchSize; + var perimCount = this.perimeterPatchCount; + var centerCount = this.centerPatchCount; + var centerSize = this.centerPatchSize; + var padCount = this.paddingPatchCount; + + // ---- Border samples: perimCount per border ---- + + // Top border + for (var i = 0; i < perimCount; i++) { + var x = Math.floor(Math.random() * (w - patchSize)); + var y = Math.floor(Math.random() * borderDepth); + this.sampleColors = this.sampleColors.concat(extractPatch(x, y, patchSize)); + } + // Bottom border + for (var i = 0; i < perimCount; i++) { + var x = Math.floor(Math.random() * (w - patchSize)); + var y = h - borderDepth + Math.floor(Math.random() * Math.max(1, borderDepth - patchSize)); + this.sampleColors = this.sampleColors.concat(extractPatch(x, y, patchSize)); + } + // Left border + for (var i = 0; i < perimCount; i++) { + var x = Math.floor(Math.random() * borderDepth); + var y = Math.floor(Math.random() * (h - patchSize)); + this.sampleColors = this.sampleColors.concat(extractPatch(x, y, patchSize)); + } + // Right border + for (var i = 0; i < perimCount; i++) { + var x = w - borderDepth + Math.floor(Math.random() * Math.max(1, borderDepth - patchSize)); + var y = Math.floor(Math.random() * (h - patchSize)); + this.sampleColors = this.sampleColors.concat(extractPatch(x, y, patchSize)); + } + + // ---- Middle area: centerCount samples ---- + var midRange = Math.min(w, h) / 4; + for (var i = 0; i < centerCount; i++) { + var x = Math.floor(w / 2 - midRange / 2 + Math.random() * midRange); + var y = Math.floor(h / 2 - midRange / 2 + Math.random() * midRange); + this.sampleColors = this.sampleColors.concat(extractPatch(x, y, centerSize)); + } + + // ---- Padding perimeter: padCount samples toward flight direction ---- + var headRad = ((flightHeading || 0)) * Math.PI / 180; + var dirX = Math.sin(headRad); + var dirY = -Math.cos(headRad); + for (var i = 0; i < padCount; i++) { + var dist = 0.25 + Math.random() * 0.35; + var spread = (Math.random() - 0.5) * 0.4; + var sx = Math.floor(w / 2 + (dirX * dist + dirY * spread) * w * 0.45); + var sy = Math.floor(h / 2 + (dirY * dist - dirX * spread) * h * 0.45); + sx = Math.max(0, Math.min(sx, w - smallPatch)); + sy = Math.max(0, Math.min(sy, h - smallPatch)); + this.sampleColors = this.sampleColors.concat(extractPatch(sx, sy, smallPatch)); + } +}; + +/** + * Checks whether a pixel color should be skipped during sampling + * (placeholder tiles, sky, water, or black). + */ +TerrainFill.prototype._isSkipColor = function (r, g, b) { + // Placeholder dark green: #2a3a2a (42, 58, 42) + if (Math.abs(r - 42) < 20 && Math.abs(g - 58) < 20 && Math.abs(b - 42) < 20) return true; + // Sky blue: #87CEEB (135, 206, 235) + if (Math.abs(r - 135) < 35 && Math.abs(g - 206) < 35 && Math.abs(b - 235) < 35) return true; + // Water blue: #1a4a7a (26, 74, 122) + if (Math.abs(r - 26) < 25 && Math.abs(g - 74) < 25 && Math.abs(b - 122) < 25) return true; + // Near-black + if (r < 8 && g < 8 && b < 8) return true; + // Fill plane color itself (dark placeholder) + if (r < 50 && g < 65 && b < 50 && Math.abs(r - b) < 10) return true; + return false; +}; + +/** + * Generates the fill texture by mapping sampled colors through + * a pre-computed noise map. Produces a natural-looking blend of + * terrain colors that fills gaps between loaded tiles. + */ +TerrainFill.prototype._generateFillTexture = function () { + var colors = this.sampleColors; + if (colors.length < 10) return; + + var ctx = this.fillCtx; + var w = this.texW; + var h = this.texH; + var imgData = ctx.createImageData(w, h); + var data = imgData.data; + var numColors = colors.length; + + for (var y = 0; y < h; y++) { + for (var x = 0; x < w; x++) { + // Look up pre-computed noise value (0-1) + var noise = this.noiseMap[y * w + x]; + + // Map noise to color index range and blend between neighbors + var fidx = noise * (numColors - 1); + var idx1 = Math.floor(fidx); + var idx2 = Math.min(idx1 + 1, numColors - 1); + var blend = fidx - idx1; + + var c1 = colors[idx1]; + var c2 = colors[idx2]; + + var px = (y * w + x) * 4; + data[px] = Math.round(c1.r + (c2.r - c1.r) * blend); + data[px + 1] = Math.round(c1.g + (c2.g - c1.g) * blend); + data[px + 2] = Math.round(c1.b + (c2.b - c1.b) * blend); + data[px + 3] = 255; + } + } + + ctx.putImageData(imgData, 0, 0); + this.fillTexture.needsUpdate = true; +}; + +/** + * 2D value noise using the pre-computed permutation table. + */ +TerrainFill.prototype._noise2d = function (x, y) { + var xi = Math.floor(x) & 255; + var yi = Math.floor(y) & 255; + var xf = x - Math.floor(x); + var yf = y - Math.floor(y); + + // Smoothstep fade curves + var u = xf * xf * (3 - 2 * xf); + var v = yf * yf * (3 - 2 * yf); + + // Hash four corners + var aa = this.perm[this.perm[xi] + yi] / 255; + var ab = this.perm[this.perm[xi] + yi + 1] / 255; + var ba = this.perm[this.perm[xi + 1] + yi] / 255; + var bb = this.perm[this.perm[xi + 1] + yi + 1] / 255; + + // Bilinear interpolation + var x1 = aa * (1 - u) + ba * u; + var x2 = ab * (1 - u) + bb * u; + return x1 * (1 - v) + x2 * v; +}; + +/** + * Generates animated cloud overlay texture using time-offset noise. + * The cloud effect applies a semi-transparent white layer over the + * fill area, simulating drifting cloud cover. + */ +TerrainFill.prototype._updateCloud = function () { + if (!this.cloudOverlay.enabled) { + this.cloudMesh.visible = false; + return; + } + this.cloudMesh.visible = true; + this.cloudOverlay.time += this.cloudOverlay.speed * this.sampleInterval; + + var ctx = this.cloudCtx; + var w = this.texW; + var h = this.texH; + var imgData = ctx.createImageData(w, h); + var data = imgData.data; + var t = this.cloudOverlay.time; + var scale = this.cloudOverlay.scale; + var coverage = this.cloudOverlay.coverage; + var opacity = this.cloudOverlay.opacity; + + for (var y = 0; y < h; y++) { + for (var x = 0; x < w; x++) { + // Multi-octave noise with time offset for drift + var n1 = this._cloudNoise2d(x * scale + t, y * scale + t * 0.7); + var n2 = this._cloudNoise2d(x * scale * 2.3 + 50 + t * 1.3, y * scale * 2.3 + 50 + t * 0.9) * 0.5; + var n3 = this._cloudNoise2d(x * scale * 5.1 + 120 - t * 0.5, y * scale * 5.1 + 120 + t * 0.3) * 0.25; + var noise = (n1 + n2 + n3) / 1.75; + + // Apply coverage threshold: lower coverage = fewer clouds + var cloudDensity = Math.max(0, noise - (1 - coverage)) / coverage; + cloudDensity = Math.min(1, cloudDensity); + + // Smooth edges + cloudDensity = cloudDensity * cloudDensity * (3 - 2 * cloudDensity); + + var alpha = Math.round(cloudDensity * opacity * 255); + var px = (y * w + x) * 4; + data[px] = 255; // White clouds + data[px + 1] = 255; + data[px + 2] = 255; + data[px + 3] = alpha; + } + } + + ctx.putImageData(imgData, 0, 0); + this.cloudTexture.needsUpdate = true; +}; + +/** + * 2D value noise using the cloud-specific permutation table. + */ +TerrainFill.prototype._cloudNoise2d = function (x, y) { + var xi = Math.floor(x) & 255; + var yi = Math.floor(y) & 255; + var xf = x - Math.floor(x); + var yf = y - Math.floor(y); + + var u = xf * xf * (3 - 2 * xf); + var v = yf * yf * (3 - 2 * yf); + + var aa = this.cloudPerm[this.cloudPerm[xi] + yi] / 255; + var ab = this.cloudPerm[this.cloudPerm[xi] + yi + 1] / 255; + var ba = this.cloudPerm[this.cloudPerm[xi + 1] + yi] / 255; + var bb = this.cloudPerm[this.cloudPerm[xi + 1] + yi + 1] / 255; + + var x1 = aa * (1 - u) + ba * u; + var x2 = ab * (1 - u) + bb * u; + return x1 * (1 - v) + x2 * v; +}; + +/** + * Reset fill state when switching capitals. + */ +TerrainFill.prototype.reset = function () { + this.sampleColors = []; + this.frameCount = 0; + this.fillCtx.fillStyle = '#2a3a2a'; + this.fillCtx.fillRect(0, 0, this.texW, this.texH); + this.fillTexture.needsUpdate = true; + this.cloudOverlay.time = 0; + this.cloudCtx.clearRect(0, 0, this.texW, this.texH); + this.cloudTexture.needsUpdate = true; +}; diff --git a/plugins/flight-map-canvas/extensions/flight-map-canvas/game/vendor/three.min.js b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/vendor/three.min.js new file mode 100644 index 00000000..b14f47c0 --- /dev/null +++ b/plugins/flight-map-canvas/extensions/flight-map-canvas/game/vendor/three.min.js @@ -0,0 +1,6 @@ +/** + * @license + * Copyright 2010-2021 Three.js Authors + * SPDX-License-Identifier: MIT + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).THREE={})}(this,(function(t){"use strict";const e="128",n=100,i=300,r=301,s=302,a=303,o=304,l=306,c=307,h=1e3,u=1001,d=1002,p=1003,m=1004,f=1005,g=1006,v=1007,y=1008,x=1009,_=1012,w=1014,b=1015,M=1016,S=1020,T=1022,E=1023,A=1026,L=1027,R=33776,C=33777,P=33778,D=33779,I=35840,N=35841,B=35842,z=35843,F=37492,O=37496,H=2300,G=2301,U=2302,k=2400,V=2401,W=2402,j=2500,q=2501,X=3e3,Y=3001,Z=3007,J=3002,Q=3004,K=3005,$=3006,tt=7680,et=35044,nt=35048,it="300 es";class rt{addEventListener(t,e){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[t]&&(n[t]=[]),-1===n[t].indexOf(e)&&n[t].push(e)}hasEventListener(t,e){if(void 0===this._listeners)return!1;const n=this._listeners;return void 0!==n[t]&&-1!==n[t].indexOf(e)}removeEventListener(t,e){if(void 0===this._listeners)return;const n=this._listeners[t];if(void 0!==n){const t=n.indexOf(e);-1!==t&&n.splice(t,1)}}dispatchEvent(t){if(void 0===this._listeners)return;const e=this._listeners[t.type];if(void 0!==e){t.target=this;const n=e.slice(0);for(let e=0,i=n.length;e>8&255]+st[t>>16&255]+st[t>>24&255]+"-"+st[255&e]+st[e>>8&255]+"-"+st[e>>16&15|64]+st[e>>24&255]+"-"+st[63&n|128]+st[n>>8&255]+"-"+st[n>>16&255]+st[n>>24&255]+st[255&i]+st[i>>8&255]+st[i>>16&255]+st[i>>24&255]).toUpperCase()}function ht(t,e,n){return Math.max(e,Math.min(n,t))}function ut(t,e){return(t%e+e)%e}function dt(t,e,n){return(1-n)*t+n*e}function pt(t){return 0==(t&t-1)&&0!==t}function mt(t){return Math.pow(2,Math.ceil(Math.log(t)/Math.LN2))}function ft(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}var gt=Object.freeze({__proto__:null,DEG2RAD:ot,RAD2DEG:lt,generateUUID:ct,clamp:ht,euclideanModulo:ut,mapLinear:function(t,e,n,i,r){return i+(t-e)*(r-i)/(n-e)},inverseLerp:function(t,e,n){return t!==e?(n-t)/(e-t):0},lerp:dt,damp:function(t,e,n,i){return dt(t,e,1-Math.exp(-n*i))},pingpong:function(t,e=1){return e-Math.abs(ut(t,2*e)-e)},smoothstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*(3-2*t)},smootherstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*t*(t*(6*t-15)+10)},randInt:function(t,e){return t+Math.floor(Math.random()*(e-t+1))},randFloat:function(t,e){return t+Math.random()*(e-t)},randFloatSpread:function(t){return t*(.5-Math.random())},seededRandom:function(t){return void 0!==t&&(at=t%2147483647),at=16807*at%2147483647,(at-1)/2147483646},degToRad:function(t){return t*ot},radToDeg:function(t){return t*lt},isPowerOfTwo:pt,ceilPowerOfTwo:mt,floorPowerOfTwo:ft,setQuaternionFromProperEuler:function(t,e,n,i,r){const s=Math.cos,a=Math.sin,o=s(n/2),l=a(n/2),c=s((e+i)/2),h=a((e+i)/2),u=s((e-i)/2),d=a((e-i)/2),p=s((i-e)/2),m=a((i-e)/2);switch(r){case"XYX":t.set(o*h,l*u,l*d,o*c);break;case"YZY":t.set(l*d,o*h,l*u,o*c);break;case"ZXZ":t.set(l*u,l*d,o*h,o*c);break;case"XZX":t.set(o*h,l*m,l*p,o*c);break;case"YXY":t.set(l*p,o*h,l*m,o*c);break;case"ZYZ":t.set(l*m,l*p,o*h,o*c);break;default:console.warn("THREE.MathUtils: .setQuaternionFromProperEuler() encountered an unknown order: "+r)}}});class vt{constructor(t=0,e=0){this.x=t,this.y=e}get width(){return this.x}set width(t){this.x=t}get height(){return this.y}set height(t){this.y=t}set(t,e){return this.x=t,this.y=e,this}setScalar(t){return this.x=t,this.y=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;default:throw new Error("index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;default:throw new Error("index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y)}copy(t){return this.x=t.x,this.y=t.y,this}add(t,e){return void 0!==e?(console.warn("THREE.Vector2: .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(t,e)):(this.x+=t.x,this.y+=t.y,this)}addScalar(t){return this.x+=t,this.y+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this}sub(t,e){return void 0!==e?(console.warn("THREE.Vector2: .sub() now only accepts one argument. Use .subVectors( a, b ) instead."),this.subVectors(t,e)):(this.x-=t.x,this.y-=t.y,this)}subScalar(t){return this.x-=t,this.y-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this}multiply(t){return this.x*=t.x,this.y*=t.y,this}multiplyScalar(t){return this.x*=t,this.y*=t,this}divide(t){return this.x/=t.x,this.y/=t.y,this}divideScalar(t){return this.multiplyScalar(1/t)}applyMatrix3(t){const e=this.x,n=this.y,i=t.elements;return this.x=i[0]*e+i[3]*n+i[6],this.y=i[1]*e+i[4]*n+i[7],this}min(t){return this.x=Math.min(this.x,t.x),this.y=Math.min(this.y,t.y),this}max(t){return this.x=Math.max(this.x,t.x),this.y=Math.max(this.y,t.y),this}clamp(t,e){return this.x=Math.max(t.x,Math.min(e.x,this.x)),this.y=Math.max(t.y,Math.min(e.y,this.y)),this}clampScalar(t,e){return this.x=Math.max(t,Math.min(e,this.x)),this.y=Math.max(t,Math.min(e,this.y)),this}clampLength(t,e){const n=this.length();return this.divideScalar(n||1).multiplyScalar(Math.max(t,Math.min(e,n)))}floor(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this}ceil(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this}round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this}roundToZero(){return this.x=this.x<0?Math.ceil(this.x):Math.floor(this.x),this.y=this.y<0?Math.ceil(this.y):Math.floor(this.y),this}negate(){return this.x=-this.x,this.y=-this.y,this}dot(t){return this.x*t.x+this.y*t.y}cross(t){return this.x*t.y-this.y*t.x}lengthSq(){return this.x*this.x+this.y*this.y}length(){return Math.sqrt(this.x*this.x+this.y*this.y)}manhattanLength(){return Math.abs(this.x)+Math.abs(this.y)}normalize(){return this.divideScalar(this.length()||1)}angle(){return Math.atan2(-this.y,-this.x)+Math.PI}distanceTo(t){return Math.sqrt(this.distanceToSquared(t))}distanceToSquared(t){const e=this.x-t.x,n=this.y-t.y;return e*e+n*n}manhattanDistanceTo(t){return Math.abs(this.x-t.x)+Math.abs(this.y-t.y)}setLength(t){return this.normalize().multiplyScalar(t)}lerp(t,e){return this.x+=(t.x-this.x)*e,this.y+=(t.y-this.y)*e,this}lerpVectors(t,e,n){return this.x=t.x+(e.x-t.x)*n,this.y=t.y+(e.y-t.y)*n,this}equals(t){return t.x===this.x&&t.y===this.y}fromArray(t,e=0){return this.x=t[e],this.y=t[e+1],this}toArray(t=[],e=0){return t[e]=this.x,t[e+1]=this.y,t}fromBufferAttribute(t,e,n){return void 0!==n&&console.warn("THREE.Vector2: offset has been removed from .fromBufferAttribute()."),this.x=t.getX(e),this.y=t.getY(e),this}rotateAround(t,e){const n=Math.cos(e),i=Math.sin(e),r=this.x-t.x,s=this.y-t.y;return this.x=r*n-s*i+t.x,this.y=r*i+s*n+t.y,this}random(){return this.x=Math.random(),this.y=Math.random(),this}}vt.prototype.isVector2=!0;class yt{constructor(){this.elements=[1,0,0,0,1,0,0,0,1],arguments.length>0&&console.error("THREE.Matrix3: the constructor no longer reads arguments. use .set() instead.")}set(t,e,n,i,r,s,a,o,l){const c=this.elements;return c[0]=t,c[1]=i,c[2]=a,c[3]=e,c[4]=r,c[5]=o,c[6]=n,c[7]=s,c[8]=l,this}identity(){return this.set(1,0,0,0,1,0,0,0,1),this}copy(t){const e=this.elements,n=t.elements;return e[0]=n[0],e[1]=n[1],e[2]=n[2],e[3]=n[3],e[4]=n[4],e[5]=n[5],e[6]=n[6],e[7]=n[7],e[8]=n[8],this}extractBasis(t,e,n){return t.setFromMatrix3Column(this,0),e.setFromMatrix3Column(this,1),n.setFromMatrix3Column(this,2),this}setFromMatrix4(t){const e=t.elements;return this.set(e[0],e[4],e[8],e[1],e[5],e[9],e[2],e[6],e[10]),this}multiply(t){return this.multiplyMatrices(this,t)}premultiply(t){return this.multiplyMatrices(t,this)}multiplyMatrices(t,e){const n=t.elements,i=e.elements,r=this.elements,s=n[0],a=n[3],o=n[6],l=n[1],c=n[4],h=n[7],u=n[2],d=n[5],p=n[8],m=i[0],f=i[3],g=i[6],v=i[1],y=i[4],x=i[7],_=i[2],w=i[5],b=i[8];return r[0]=s*m+a*v+o*_,r[3]=s*f+a*y+o*w,r[6]=s*g+a*x+o*b,r[1]=l*m+c*v+h*_,r[4]=l*f+c*y+h*w,r[7]=l*g+c*x+h*b,r[2]=u*m+d*v+p*_,r[5]=u*f+d*y+p*w,r[8]=u*g+d*x+p*b,this}multiplyScalar(t){const e=this.elements;return e[0]*=t,e[3]*=t,e[6]*=t,e[1]*=t,e[4]*=t,e[7]*=t,e[2]*=t,e[5]*=t,e[8]*=t,this}determinant(){const t=this.elements,e=t[0],n=t[1],i=t[2],r=t[3],s=t[4],a=t[5],o=t[6],l=t[7],c=t[8];return e*s*c-e*a*l-n*r*c+n*a*o+i*r*l-i*s*o}invert(){const t=this.elements,e=t[0],n=t[1],i=t[2],r=t[3],s=t[4],a=t[5],o=t[6],l=t[7],c=t[8],h=c*s-a*l,u=a*o-c*r,d=l*r-s*o,p=e*h+n*u+i*d;if(0===p)return this.set(0,0,0,0,0,0,0,0,0);const m=1/p;return t[0]=h*m,t[1]=(i*l-c*n)*m,t[2]=(a*n-i*s)*m,t[3]=u*m,t[4]=(c*e-i*o)*m,t[5]=(i*r-a*e)*m,t[6]=d*m,t[7]=(n*o-l*e)*m,t[8]=(s*e-n*r)*m,this}transpose(){let t;const e=this.elements;return t=e[1],e[1]=e[3],e[3]=t,t=e[2],e[2]=e[6],e[6]=t,t=e[5],e[5]=e[7],e[7]=t,this}getNormalMatrix(t){return this.setFromMatrix4(t).invert().transpose()}transposeIntoArray(t){const e=this.elements;return t[0]=e[0],t[1]=e[3],t[2]=e[6],t[3]=e[1],t[4]=e[4],t[5]=e[7],t[6]=e[2],t[7]=e[5],t[8]=e[8],this}setUvTransform(t,e,n,i,r,s,a){const o=Math.cos(r),l=Math.sin(r);return this.set(n*o,n*l,-n*(o*s+l*a)+s+t,-i*l,i*o,-i*(-l*s+o*a)+a+e,0,0,1),this}scale(t,e){const n=this.elements;return n[0]*=t,n[3]*=t,n[6]*=t,n[1]*=e,n[4]*=e,n[7]*=e,this}rotate(t){const e=Math.cos(t),n=Math.sin(t),i=this.elements,r=i[0],s=i[3],a=i[6],o=i[1],l=i[4],c=i[7];return i[0]=e*r+n*o,i[3]=e*s+n*l,i[6]=e*a+n*c,i[1]=-n*r+e*o,i[4]=-n*s+e*l,i[7]=-n*a+e*c,this}translate(t,e){const n=this.elements;return n[0]+=t*n[2],n[3]+=t*n[5],n[6]+=t*n[8],n[1]+=e*n[2],n[4]+=e*n[5],n[7]+=e*n[8],this}equals(t){const e=this.elements,n=t.elements;for(let t=0;t<9;t++)if(e[t]!==n[t])return!1;return!0}fromArray(t,e=0){for(let n=0;n<9;n++)this.elements[n]=t[n+e];return this}toArray(t=[],e=0){const n=this.elements;return t[e]=n[0],t[e+1]=n[1],t[e+2]=n[2],t[e+3]=n[3],t[e+4]=n[4],t[e+5]=n[5],t[e+6]=n[6],t[e+7]=n[7],t[e+8]=n[8],t}clone(){return(new this.constructor).fromArray(this.elements)}}let xt;yt.prototype.isMatrix3=!0;class _t{static getDataURL(t){if(/^data:/i.test(t.src))return t.src;if("undefined"==typeof HTMLCanvasElement)return t.src;let e;if(t instanceof HTMLCanvasElement)e=t;else{void 0===xt&&(xt=document.createElementNS("http://www.w3.org/1999/xhtml","canvas")),xt.width=t.width,xt.height=t.height;const n=xt.getContext("2d");t instanceof ImageData?n.putImageData(t,0,0):n.drawImage(t,0,0,t.width,t.height),e=xt}return e.width>2048||e.height>2048?(console.warn("THREE.ImageUtils.getDataURL: Image converted to jpg for performance reasons",t),e.toDataURL("image/jpeg",.6)):e.toDataURL("image/png")}}let wt=0;class bt extends rt{constructor(t=bt.DEFAULT_IMAGE,e=bt.DEFAULT_MAPPING,n=1001,i=1001,r=1006,s=1008,a=1023,o=1009,l=1,c=3e3){super(),Object.defineProperty(this,"id",{value:wt++}),this.uuid=ct(),this.name="",this.image=t,this.mipmaps=[],this.mapping=e,this.wrapS=n,this.wrapT=i,this.magFilter=r,this.minFilter=s,this.anisotropy=l,this.format=a,this.internalFormat=null,this.type=o,this.offset=new vt(0,0),this.repeat=new vt(1,1),this.center=new vt(0,0),this.rotation=0,this.matrixAutoUpdate=!0,this.matrix=new yt,this.generateMipmaps=!0,this.premultiplyAlpha=!1,this.flipY=!0,this.unpackAlignment=4,this.encoding=c,this.version=0,this.onUpdate=null}updateMatrix(){this.matrix.setUvTransform(this.offset.x,this.offset.y,this.repeat.x,this.repeat.y,this.rotation,this.center.x,this.center.y)}clone(){return(new this.constructor).copy(this)}copy(t){return this.name=t.name,this.image=t.image,this.mipmaps=t.mipmaps.slice(0),this.mapping=t.mapping,this.wrapS=t.wrapS,this.wrapT=t.wrapT,this.magFilter=t.magFilter,this.minFilter=t.minFilter,this.anisotropy=t.anisotropy,this.format=t.format,this.internalFormat=t.internalFormat,this.type=t.type,this.offset.copy(t.offset),this.repeat.copy(t.repeat),this.center.copy(t.center),this.rotation=t.rotation,this.matrixAutoUpdate=t.matrixAutoUpdate,this.matrix.copy(t.matrix),this.generateMipmaps=t.generateMipmaps,this.premultiplyAlpha=t.premultiplyAlpha,this.flipY=t.flipY,this.unpackAlignment=t.unpackAlignment,this.encoding=t.encoding,this}toJSON(t){const e=void 0===t||"string"==typeof t;if(!e&&void 0!==t.textures[this.uuid])return t.textures[this.uuid];const n={metadata:{version:4.5,type:"Texture",generator:"Texture.toJSON"},uuid:this.uuid,name:this.name,mapping:this.mapping,repeat:[this.repeat.x,this.repeat.y],offset:[this.offset.x,this.offset.y],center:[this.center.x,this.center.y],rotation:this.rotation,wrap:[this.wrapS,this.wrapT],format:this.format,type:this.type,encoding:this.encoding,minFilter:this.minFilter,magFilter:this.magFilter,anisotropy:this.anisotropy,flipY:this.flipY,premultiplyAlpha:this.premultiplyAlpha,unpackAlignment:this.unpackAlignment};if(void 0!==this.image){const i=this.image;if(void 0===i.uuid&&(i.uuid=ct()),!e&&void 0===t.images[i.uuid]){let e;if(Array.isArray(i)){e=[];for(let t=0,n=i.length;t1)switch(this.wrapS){case h:t.x=t.x-Math.floor(t.x);break;case u:t.x=t.x<0?0:1;break;case d:1===Math.abs(Math.floor(t.x)%2)?t.x=Math.ceil(t.x)-t.x:t.x=t.x-Math.floor(t.x)}if(t.y<0||t.y>1)switch(this.wrapT){case h:t.y=t.y-Math.floor(t.y);break;case u:t.y=t.y<0?0:1;break;case d:1===Math.abs(Math.floor(t.y)%2)?t.y=Math.ceil(t.y)-t.y:t.y=t.y-Math.floor(t.y)}return this.flipY&&(t.y=1-t.y),t}set needsUpdate(t){!0===t&&this.version++}}function Mt(t){return"undefined"!=typeof HTMLImageElement&&t instanceof HTMLImageElement||"undefined"!=typeof HTMLCanvasElement&&t instanceof HTMLCanvasElement||"undefined"!=typeof ImageBitmap&&t instanceof ImageBitmap?_t.getDataURL(t):t.data?{data:Array.prototype.slice.call(t.data),width:t.width,height:t.height,type:t.data.constructor.name}:(console.warn("THREE.Texture: Unable to serialize Texture."),{})}bt.DEFAULT_IMAGE=void 0,bt.DEFAULT_MAPPING=i,bt.prototype.isTexture=!0;class St{constructor(t=0,e=0,n=0,i=1){this.x=t,this.y=e,this.z=n,this.w=i}get width(){return this.z}set width(t){this.z=t}get height(){return this.w}set height(t){this.w=t}set(t,e,n,i){return this.x=t,this.y=e,this.z=n,this.w=i,this}setScalar(t){return this.x=t,this.y=t,this.z=t,this.w=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setZ(t){return this.z=t,this}setW(t){return this.w=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;case 2:this.z=e;break;case 3:this.w=e;break;default:throw new Error("index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;case 2:return this.z;case 3:return this.w;default:throw new Error("index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y,this.z,this.w)}copy(t){return this.x=t.x,this.y=t.y,this.z=t.z,this.w=void 0!==t.w?t.w:1,this}add(t,e){return void 0!==e?(console.warn("THREE.Vector4: .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(t,e)):(this.x+=t.x,this.y+=t.y,this.z+=t.z,this.w+=t.w,this)}addScalar(t){return this.x+=t,this.y+=t,this.z+=t,this.w+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this.z=t.z+e.z,this.w=t.w+e.w,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this.z+=t.z*e,this.w+=t.w*e,this}sub(t,e){return void 0!==e?(console.warn("THREE.Vector4: .sub() now only accepts one argument. Use .subVectors( a, b ) instead."),this.subVectors(t,e)):(this.x-=t.x,this.y-=t.y,this.z-=t.z,this.w-=t.w,this)}subScalar(t){return this.x-=t,this.y-=t,this.z-=t,this.w-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this.z=t.z-e.z,this.w=t.w-e.w,this}multiply(t){return this.x*=t.x,this.y*=t.y,this.z*=t.z,this.w*=t.w,this}multiplyScalar(t){return this.x*=t,this.y*=t,this.z*=t,this.w*=t,this}applyMatrix4(t){const e=this.x,n=this.y,i=this.z,r=this.w,s=t.elements;return this.x=s[0]*e+s[4]*n+s[8]*i+s[12]*r,this.y=s[1]*e+s[5]*n+s[9]*i+s[13]*r,this.z=s[2]*e+s[6]*n+s[10]*i+s[14]*r,this.w=s[3]*e+s[7]*n+s[11]*i+s[15]*r,this}divideScalar(t){return this.multiplyScalar(1/t)}setAxisAngleFromQuaternion(t){this.w=2*Math.acos(t.w);const e=Math.sqrt(1-t.w*t.w);return e<1e-4?(this.x=1,this.y=0,this.z=0):(this.x=t.x/e,this.y=t.y/e,this.z=t.z/e),this}setAxisAngleFromRotationMatrix(t){let e,n,i,r;const s=.01,a=.1,o=t.elements,l=o[0],c=o[4],h=o[8],u=o[1],d=o[5],p=o[9],m=o[2],f=o[6],g=o[10];if(Math.abs(c-u)o&&t>v?tv?o=0?1:-1,i=1-e*e;if(i>Number.EPSILON){const r=Math.sqrt(i),s=Math.atan2(r,e*n);t=Math.sin(t*s)/r,a=Math.sin(a*s)/r}const r=a*n;if(o=o*t+u*r,l=l*t+d*r,c=c*t+p*r,h=h*t+m*r,t===1-a){const t=1/Math.sqrt(o*o+l*l+c*c+h*h);o*=t,l*=t,c*=t,h*=t}}t[e]=o,t[e+1]=l,t[e+2]=c,t[e+3]=h}static multiplyQuaternionsFlat(t,e,n,i,r,s){const a=n[i],o=n[i+1],l=n[i+2],c=n[i+3],h=r[s],u=r[s+1],d=r[s+2],p=r[s+3];return t[e]=a*p+c*h+o*d-l*u,t[e+1]=o*p+c*u+l*h-a*d,t[e+2]=l*p+c*d+a*u-o*h,t[e+3]=c*p-a*h-o*u-l*d,t}get x(){return this._x}set x(t){this._x=t,this._onChangeCallback()}get y(){return this._y}set y(t){this._y=t,this._onChangeCallback()}get z(){return this._z}set z(t){this._z=t,this._onChangeCallback()}get w(){return this._w}set w(t){this._w=t,this._onChangeCallback()}set(t,e,n,i){return this._x=t,this._y=e,this._z=n,this._w=i,this._onChangeCallback(),this}clone(){return new this.constructor(this._x,this._y,this._z,this._w)}copy(t){return this._x=t.x,this._y=t.y,this._z=t.z,this._w=t.w,this._onChangeCallback(),this}setFromEuler(t,e){if(!t||!t.isEuler)throw new Error("THREE.Quaternion: .setFromEuler() now expects an Euler rotation rather than a Vector3 and order.");const n=t._x,i=t._y,r=t._z,s=t._order,a=Math.cos,o=Math.sin,l=a(n/2),c=a(i/2),h=a(r/2),u=o(n/2),d=o(i/2),p=o(r/2);switch(s){case"XYZ":this._x=u*c*h+l*d*p,this._y=l*d*h-u*c*p,this._z=l*c*p+u*d*h,this._w=l*c*h-u*d*p;break;case"YXZ":this._x=u*c*h+l*d*p,this._y=l*d*h-u*c*p,this._z=l*c*p-u*d*h,this._w=l*c*h+u*d*p;break;case"ZXY":this._x=u*c*h-l*d*p,this._y=l*d*h+u*c*p,this._z=l*c*p+u*d*h,this._w=l*c*h-u*d*p;break;case"ZYX":this._x=u*c*h-l*d*p,this._y=l*d*h+u*c*p,this._z=l*c*p-u*d*h,this._w=l*c*h+u*d*p;break;case"YZX":this._x=u*c*h+l*d*p,this._y=l*d*h+u*c*p,this._z=l*c*p-u*d*h,this._w=l*c*h-u*d*p;break;case"XZY":this._x=u*c*h-l*d*p,this._y=l*d*h-u*c*p,this._z=l*c*p+u*d*h,this._w=l*c*h+u*d*p;break;default:console.warn("THREE.Quaternion: .setFromEuler() encountered an unknown order: "+s)}return!1!==e&&this._onChangeCallback(),this}setFromAxisAngle(t,e){const n=e/2,i=Math.sin(n);return this._x=t.x*i,this._y=t.y*i,this._z=t.z*i,this._w=Math.cos(n),this._onChangeCallback(),this}setFromRotationMatrix(t){const e=t.elements,n=e[0],i=e[4],r=e[8],s=e[1],a=e[5],o=e[9],l=e[2],c=e[6],h=e[10],u=n+a+h;if(u>0){const t=.5/Math.sqrt(u+1);this._w=.25/t,this._x=(c-o)*t,this._y=(r-l)*t,this._z=(s-i)*t}else if(n>a&&n>h){const t=2*Math.sqrt(1+n-a-h);this._w=(c-o)/t,this._x=.25*t,this._y=(i+s)/t,this._z=(r+l)/t}else if(a>h){const t=2*Math.sqrt(1+a-n-h);this._w=(r-l)/t,this._x=(i+s)/t,this._y=.25*t,this._z=(o+c)/t}else{const t=2*Math.sqrt(1+h-n-a);this._w=(s-i)/t,this._x=(r+l)/t,this._y=(o+c)/t,this._z=.25*t}return this._onChangeCallback(),this}setFromUnitVectors(t,e){let n=t.dot(e)+1;return nMath.abs(t.z)?(this._x=-t.y,this._y=t.x,this._z=0,this._w=n):(this._x=0,this._y=-t.z,this._z=t.y,this._w=n)):(this._x=t.y*e.z-t.z*e.y,this._y=t.z*e.x-t.x*e.z,this._z=t.x*e.y-t.y*e.x,this._w=n),this.normalize()}angleTo(t){return 2*Math.acos(Math.abs(ht(this.dot(t),-1,1)))}rotateTowards(t,e){const n=this.angleTo(t);if(0===n)return this;const i=Math.min(1,e/n);return this.slerp(t,i),this}identity(){return this.set(0,0,0,1)}invert(){return this.conjugate()}conjugate(){return this._x*=-1,this._y*=-1,this._z*=-1,this._onChangeCallback(),this}dot(t){return this._x*t._x+this._y*t._y+this._z*t._z+this._w*t._w}lengthSq(){return this._x*this._x+this._y*this._y+this._z*this._z+this._w*this._w}length(){return Math.sqrt(this._x*this._x+this._y*this._y+this._z*this._z+this._w*this._w)}normalize(){let t=this.length();return 0===t?(this._x=0,this._y=0,this._z=0,this._w=1):(t=1/t,this._x=this._x*t,this._y=this._y*t,this._z=this._z*t,this._w=this._w*t),this._onChangeCallback(),this}multiply(t,e){return void 0!==e?(console.warn("THREE.Quaternion: .multiply() now only accepts one argument. Use .multiplyQuaternions( a, b ) instead."),this.multiplyQuaternions(t,e)):this.multiplyQuaternions(this,t)}premultiply(t){return this.multiplyQuaternions(t,this)}multiplyQuaternions(t,e){const n=t._x,i=t._y,r=t._z,s=t._w,a=e._x,o=e._y,l=e._z,c=e._w;return this._x=n*c+s*a+i*l-r*o,this._y=i*c+s*o+r*a-n*l,this._z=r*c+s*l+n*o-i*a,this._w=s*c-n*a-i*o-r*l,this._onChangeCallback(),this}slerp(t,e){if(0===e)return this;if(1===e)return this.copy(t);const n=this._x,i=this._y,r=this._z,s=this._w;let a=s*t._w+n*t._x+i*t._y+r*t._z;if(a<0?(this._w=-t._w,this._x=-t._x,this._y=-t._y,this._z=-t._z,a=-a):this.copy(t),a>=1)return this._w=s,this._x=n,this._y=i,this._z=r,this;const o=1-a*a;if(o<=Number.EPSILON){const t=1-e;return this._w=t*s+e*this._w,this._x=t*n+e*this._x,this._y=t*i+e*this._y,this._z=t*r+e*this._z,this.normalize(),this._onChangeCallback(),this}const l=Math.sqrt(o),c=Math.atan2(l,a),h=Math.sin((1-e)*c)/l,u=Math.sin(e*c)/l;return this._w=s*h+this._w*u,this._x=n*h+this._x*u,this._y=i*h+this._y*u,this._z=r*h+this._z*u,this._onChangeCallback(),this}slerpQuaternions(t,e,n){this.copy(t).slerp(e,n)}equals(t){return t._x===this._x&&t._y===this._y&&t._z===this._z&&t._w===this._w}fromArray(t,e=0){return this._x=t[e],this._y=t[e+1],this._z=t[e+2],this._w=t[e+3],this._onChangeCallback(),this}toArray(t=[],e=0){return t[e]=this._x,t[e+1]=this._y,t[e+2]=this._z,t[e+3]=this._w,t}fromBufferAttribute(t,e){return this._x=t.getX(e),this._y=t.getY(e),this._z=t.getZ(e),this._w=t.getW(e),this}_onChange(t){return this._onChangeCallback=t,this}_onChangeCallback(){}}At.prototype.isQuaternion=!0;class Lt{constructor(t=0,e=0,n=0){this.x=t,this.y=e,this.z=n}set(t,e,n){return void 0===n&&(n=this.z),this.x=t,this.y=e,this.z=n,this}setScalar(t){return this.x=t,this.y=t,this.z=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setZ(t){return this.z=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;case 2:this.z=e;break;default:throw new Error("index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;case 2:return this.z;default:throw new Error("index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y,this.z)}copy(t){return this.x=t.x,this.y=t.y,this.z=t.z,this}add(t,e){return void 0!==e?(console.warn("THREE.Vector3: .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(t,e)):(this.x+=t.x,this.y+=t.y,this.z+=t.z,this)}addScalar(t){return this.x+=t,this.y+=t,this.z+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this.z=t.z+e.z,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this.z+=t.z*e,this}sub(t,e){return void 0!==e?(console.warn("THREE.Vector3: .sub() now only accepts one argument. Use .subVectors( a, b ) instead."),this.subVectors(t,e)):(this.x-=t.x,this.y-=t.y,this.z-=t.z,this)}subScalar(t){return this.x-=t,this.y-=t,this.z-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this.z=t.z-e.z,this}multiply(t,e){return void 0!==e?(console.warn("THREE.Vector3: .multiply() now only accepts one argument. Use .multiplyVectors( a, b ) instead."),this.multiplyVectors(t,e)):(this.x*=t.x,this.y*=t.y,this.z*=t.z,this)}multiplyScalar(t){return this.x*=t,this.y*=t,this.z*=t,this}multiplyVectors(t,e){return this.x=t.x*e.x,this.y=t.y*e.y,this.z=t.z*e.z,this}applyEuler(t){return t&&t.isEuler||console.error("THREE.Vector3: .applyEuler() now expects an Euler rotation rather than a Vector3 and order."),this.applyQuaternion(Ct.setFromEuler(t))}applyAxisAngle(t,e){return this.applyQuaternion(Ct.setFromAxisAngle(t,e))}applyMatrix3(t){const e=this.x,n=this.y,i=this.z,r=t.elements;return this.x=r[0]*e+r[3]*n+r[6]*i,this.y=r[1]*e+r[4]*n+r[7]*i,this.z=r[2]*e+r[5]*n+r[8]*i,this}applyNormalMatrix(t){return this.applyMatrix3(t).normalize()}applyMatrix4(t){const e=this.x,n=this.y,i=this.z,r=t.elements,s=1/(r[3]*e+r[7]*n+r[11]*i+r[15]);return this.x=(r[0]*e+r[4]*n+r[8]*i+r[12])*s,this.y=(r[1]*e+r[5]*n+r[9]*i+r[13])*s,this.z=(r[2]*e+r[6]*n+r[10]*i+r[14])*s,this}applyQuaternion(t){const e=this.x,n=this.y,i=this.z,r=t.x,s=t.y,a=t.z,o=t.w,l=o*e+s*i-a*n,c=o*n+a*e-r*i,h=o*i+r*n-s*e,u=-r*e-s*n-a*i;return this.x=l*o+u*-r+c*-a-h*-s,this.y=c*o+u*-s+h*-r-l*-a,this.z=h*o+u*-a+l*-s-c*-r,this}project(t){return this.applyMatrix4(t.matrixWorldInverse).applyMatrix4(t.projectionMatrix)}unproject(t){return this.applyMatrix4(t.projectionMatrixInverse).applyMatrix4(t.matrixWorld)}transformDirection(t){const e=this.x,n=this.y,i=this.z,r=t.elements;return this.x=r[0]*e+r[4]*n+r[8]*i,this.y=r[1]*e+r[5]*n+r[9]*i,this.z=r[2]*e+r[6]*n+r[10]*i,this.normalize()}divide(t){return this.x/=t.x,this.y/=t.y,this.z/=t.z,this}divideScalar(t){return this.multiplyScalar(1/t)}min(t){return this.x=Math.min(this.x,t.x),this.y=Math.min(this.y,t.y),this.z=Math.min(this.z,t.z),this}max(t){return this.x=Math.max(this.x,t.x),this.y=Math.max(this.y,t.y),this.z=Math.max(this.z,t.z),this}clamp(t,e){return this.x=Math.max(t.x,Math.min(e.x,this.x)),this.y=Math.max(t.y,Math.min(e.y,this.y)),this.z=Math.max(t.z,Math.min(e.z,this.z)),this}clampScalar(t,e){return this.x=Math.max(t,Math.min(e,this.x)),this.y=Math.max(t,Math.min(e,this.y)),this.z=Math.max(t,Math.min(e,this.z)),this}clampLength(t,e){const n=this.length();return this.divideScalar(n||1).multiplyScalar(Math.max(t,Math.min(e,n)))}floor(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this.z=Math.floor(this.z),this}ceil(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this.z=Math.ceil(this.z),this}round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this.z=Math.round(this.z),this}roundToZero(){return this.x=this.x<0?Math.ceil(this.x):Math.floor(this.x),this.y=this.y<0?Math.ceil(this.y):Math.floor(this.y),this.z=this.z<0?Math.ceil(this.z):Math.floor(this.z),this}negate(){return this.x=-this.x,this.y=-this.y,this.z=-this.z,this}dot(t){return this.x*t.x+this.y*t.y+this.z*t.z}lengthSq(){return this.x*this.x+this.y*this.y+this.z*this.z}length(){return Math.sqrt(this.x*this.x+this.y*this.y+this.z*this.z)}manhattanLength(){return Math.abs(this.x)+Math.abs(this.y)+Math.abs(this.z)}normalize(){return this.divideScalar(this.length()||1)}setLength(t){return this.normalize().multiplyScalar(t)}lerp(t,e){return this.x+=(t.x-this.x)*e,this.y+=(t.y-this.y)*e,this.z+=(t.z-this.z)*e,this}lerpVectors(t,e,n){return this.x=t.x+(e.x-t.x)*n,this.y=t.y+(e.y-t.y)*n,this.z=t.z+(e.z-t.z)*n,this}cross(t,e){return void 0!==e?(console.warn("THREE.Vector3: .cross() now only accepts one argument. Use .crossVectors( a, b ) instead."),this.crossVectors(t,e)):this.crossVectors(this,t)}crossVectors(t,e){const n=t.x,i=t.y,r=t.z,s=e.x,a=e.y,o=e.z;return this.x=i*o-r*a,this.y=r*s-n*o,this.z=n*a-i*s,this}projectOnVector(t){const e=t.lengthSq();if(0===e)return this.set(0,0,0);const n=t.dot(this)/e;return this.copy(t).multiplyScalar(n)}projectOnPlane(t){return Rt.copy(this).projectOnVector(t),this.sub(Rt)}reflect(t){return this.sub(Rt.copy(t).multiplyScalar(2*this.dot(t)))}angleTo(t){const e=Math.sqrt(this.lengthSq()*t.lengthSq());if(0===e)return Math.PI/2;const n=this.dot(t)/e;return Math.acos(ht(n,-1,1))}distanceTo(t){return Math.sqrt(this.distanceToSquared(t))}distanceToSquared(t){const e=this.x-t.x,n=this.y-t.y,i=this.z-t.z;return e*e+n*n+i*i}manhattanDistanceTo(t){return Math.abs(this.x-t.x)+Math.abs(this.y-t.y)+Math.abs(this.z-t.z)}setFromSpherical(t){return this.setFromSphericalCoords(t.radius,t.phi,t.theta)}setFromSphericalCoords(t,e,n){const i=Math.sin(e)*t;return this.x=i*Math.sin(n),this.y=Math.cos(e)*t,this.z=i*Math.cos(n),this}setFromCylindrical(t){return this.setFromCylindricalCoords(t.radius,t.theta,t.y)}setFromCylindricalCoords(t,e,n){return this.x=t*Math.sin(e),this.y=n,this.z=t*Math.cos(e),this}setFromMatrixPosition(t){const e=t.elements;return this.x=e[12],this.y=e[13],this.z=e[14],this}setFromMatrixScale(t){const e=this.setFromMatrixColumn(t,0).length(),n=this.setFromMatrixColumn(t,1).length(),i=this.setFromMatrixColumn(t,2).length();return this.x=e,this.y=n,this.z=i,this}setFromMatrixColumn(t,e){return this.fromArray(t.elements,4*e)}setFromMatrix3Column(t,e){return this.fromArray(t.elements,3*e)}equals(t){return t.x===this.x&&t.y===this.y&&t.z===this.z}fromArray(t,e=0){return this.x=t[e],this.y=t[e+1],this.z=t[e+2],this}toArray(t=[],e=0){return t[e]=this.x,t[e+1]=this.y,t[e+2]=this.z,t}fromBufferAttribute(t,e,n){return void 0!==n&&console.warn("THREE.Vector3: offset has been removed from .fromBufferAttribute()."),this.x=t.getX(e),this.y=t.getY(e),this.z=t.getZ(e),this}random(){return this.x=Math.random(),this.y=Math.random(),this.z=Math.random(),this}}Lt.prototype.isVector3=!0;const Rt=new Lt,Ct=new At;class Pt{constructor(t=new Lt(1/0,1/0,1/0),e=new Lt(-1/0,-1/0,-1/0)){this.min=t,this.max=e}set(t,e){return this.min.copy(t),this.max.copy(e),this}setFromArray(t){let e=1/0,n=1/0,i=1/0,r=-1/0,s=-1/0,a=-1/0;for(let o=0,l=t.length;or&&(r=l),c>s&&(s=c),h>a&&(a=h)}return this.min.set(e,n,i),this.max.set(r,s,a),this}setFromBufferAttribute(t){let e=1/0,n=1/0,i=1/0,r=-1/0,s=-1/0,a=-1/0;for(let o=0,l=t.count;or&&(r=l),c>s&&(s=c),h>a&&(a=h)}return this.min.set(e,n,i),this.max.set(r,s,a),this}setFromPoints(t){this.makeEmpty();for(let e=0,n=t.length;ethis.max.x||t.ythis.max.y||t.zthis.max.z)}containsBox(t){return this.min.x<=t.min.x&&t.max.x<=this.max.x&&this.min.y<=t.min.y&&t.max.y<=this.max.y&&this.min.z<=t.min.z&&t.max.z<=this.max.z}getParameter(t,e){return void 0===e&&(console.warn("THREE.Box3: .getParameter() target is now required"),e=new Lt),e.set((t.x-this.min.x)/(this.max.x-this.min.x),(t.y-this.min.y)/(this.max.y-this.min.y),(t.z-this.min.z)/(this.max.z-this.min.z))}intersectsBox(t){return!(t.max.xthis.max.x||t.max.ythis.max.y||t.max.zthis.max.z)}intersectsSphere(t){return this.clampPoint(t.center,It),It.distanceToSquared(t.center)<=t.radius*t.radius}intersectsPlane(t){let e,n;return t.normal.x>0?(e=t.normal.x*this.min.x,n=t.normal.x*this.max.x):(e=t.normal.x*this.max.x,n=t.normal.x*this.min.x),t.normal.y>0?(e+=t.normal.y*this.min.y,n+=t.normal.y*this.max.y):(e+=t.normal.y*this.max.y,n+=t.normal.y*this.min.y),t.normal.z>0?(e+=t.normal.z*this.min.z,n+=t.normal.z*this.max.z):(e+=t.normal.z*this.max.z,n+=t.normal.z*this.min.z),e<=-t.constant&&n>=-t.constant}intersectsTriangle(t){if(this.isEmpty())return!1;this.getCenter(Ut),kt.subVectors(this.max,Ut),Bt.subVectors(t.a,Ut),zt.subVectors(t.b,Ut),Ft.subVectors(t.c,Ut),Ot.subVectors(zt,Bt),Ht.subVectors(Ft,zt),Gt.subVectors(Bt,Ft);let e=[0,-Ot.z,Ot.y,0,-Ht.z,Ht.y,0,-Gt.z,Gt.y,Ot.z,0,-Ot.x,Ht.z,0,-Ht.x,Gt.z,0,-Gt.x,-Ot.y,Ot.x,0,-Ht.y,Ht.x,0,-Gt.y,Gt.x,0];return!!jt(e,Bt,zt,Ft,kt)&&(e=[1,0,0,0,1,0,0,0,1],!!jt(e,Bt,zt,Ft,kt)&&(Vt.crossVectors(Ot,Ht),e=[Vt.x,Vt.y,Vt.z],jt(e,Bt,zt,Ft,kt)))}clampPoint(t,e){return void 0===e&&(console.warn("THREE.Box3: .clampPoint() target is now required"),e=new Lt),e.copy(t).clamp(this.min,this.max)}distanceToPoint(t){return It.copy(t).clamp(this.min,this.max).sub(t).length()}getBoundingSphere(t){return void 0===t&&console.error("THREE.Box3: .getBoundingSphere() target is now required"),this.getCenter(t.center),t.radius=.5*this.getSize(It).length(),t}intersect(t){return this.min.max(t.min),this.max.min(t.max),this.isEmpty()&&this.makeEmpty(),this}union(t){return this.min.min(t.min),this.max.max(t.max),this}applyMatrix4(t){return this.isEmpty()||(Dt[0].set(this.min.x,this.min.y,this.min.z).applyMatrix4(t),Dt[1].set(this.min.x,this.min.y,this.max.z).applyMatrix4(t),Dt[2].set(this.min.x,this.max.y,this.min.z).applyMatrix4(t),Dt[3].set(this.min.x,this.max.y,this.max.z).applyMatrix4(t),Dt[4].set(this.max.x,this.min.y,this.min.z).applyMatrix4(t),Dt[5].set(this.max.x,this.min.y,this.max.z).applyMatrix4(t),Dt[6].set(this.max.x,this.max.y,this.min.z).applyMatrix4(t),Dt[7].set(this.max.x,this.max.y,this.max.z).applyMatrix4(t),this.setFromPoints(Dt)),this}translate(t){return this.min.add(t),this.max.add(t),this}equals(t){return t.min.equals(this.min)&&t.max.equals(this.max)}}Pt.prototype.isBox3=!0;const Dt=[new Lt,new Lt,new Lt,new Lt,new Lt,new Lt,new Lt,new Lt],It=new Lt,Nt=new Pt,Bt=new Lt,zt=new Lt,Ft=new Lt,Ot=new Lt,Ht=new Lt,Gt=new Lt,Ut=new Lt,kt=new Lt,Vt=new Lt,Wt=new Lt;function jt(t,e,n,i,r){for(let s=0,a=t.length-3;s<=a;s+=3){Wt.fromArray(t,s);const a=r.x*Math.abs(Wt.x)+r.y*Math.abs(Wt.y)+r.z*Math.abs(Wt.z),o=e.dot(Wt),l=n.dot(Wt),c=i.dot(Wt);if(Math.max(-Math.max(o,l,c),Math.min(o,l,c))>a)return!1}return!0}const qt=new Pt,Xt=new Lt,Yt=new Lt,Zt=new Lt;class Jt{constructor(t=new Lt,e=-1){this.center=t,this.radius=e}set(t,e){return this.center.copy(t),this.radius=e,this}setFromPoints(t,e){const n=this.center;void 0!==e?n.copy(e):qt.setFromPoints(t).getCenter(n);let i=0;for(let e=0,r=t.length;ethis.radius*this.radius&&(e.sub(this.center).normalize(),e.multiplyScalar(this.radius).add(this.center)),e}getBoundingBox(t){return void 0===t&&(console.warn("THREE.Sphere: .getBoundingBox() target is now required"),t=new Pt),this.isEmpty()?(t.makeEmpty(),t):(t.set(this.center,this.center),t.expandByScalar(this.radius),t)}applyMatrix4(t){return this.center.applyMatrix4(t),this.radius=this.radius*t.getMaxScaleOnAxis(),this}translate(t){return this.center.add(t),this}expandByPoint(t){Zt.subVectors(t,this.center);const e=Zt.lengthSq();if(e>this.radius*this.radius){const t=Math.sqrt(e),n=.5*(t-this.radius);this.center.add(Zt.multiplyScalar(n/t)),this.radius+=n}return this}union(t){return Yt.subVectors(t.center,this.center).normalize().multiplyScalar(t.radius),this.expandByPoint(Xt.copy(t.center).add(Yt)),this.expandByPoint(Xt.copy(t.center).sub(Yt)),this}equals(t){return t.center.equals(this.center)&&t.radius===this.radius}clone(){return(new this.constructor).copy(this)}}const Qt=new Lt,Kt=new Lt,$t=new Lt,te=new Lt,ee=new Lt,ne=new Lt,ie=new Lt;class re{constructor(t=new Lt,e=new Lt(0,0,-1)){this.origin=t,this.direction=e}set(t,e){return this.origin.copy(t),this.direction.copy(e),this}copy(t){return this.origin.copy(t.origin),this.direction.copy(t.direction),this}at(t,e){return void 0===e&&(console.warn("THREE.Ray: .at() target is now required"),e=new Lt),e.copy(this.direction).multiplyScalar(t).add(this.origin)}lookAt(t){return this.direction.copy(t).sub(this.origin).normalize(),this}recast(t){return this.origin.copy(this.at(t,Qt)),this}closestPointToPoint(t,e){void 0===e&&(console.warn("THREE.Ray: .closestPointToPoint() target is now required"),e=new Lt),e.subVectors(t,this.origin);const n=e.dot(this.direction);return n<0?e.copy(this.origin):e.copy(this.direction).multiplyScalar(n).add(this.origin)}distanceToPoint(t){return Math.sqrt(this.distanceSqToPoint(t))}distanceSqToPoint(t){const e=Qt.subVectors(t,this.origin).dot(this.direction);return e<0?this.origin.distanceToSquared(t):(Qt.copy(this.direction).multiplyScalar(e).add(this.origin),Qt.distanceToSquared(t))}distanceSqToSegment(t,e,n,i){Kt.copy(t).add(e).multiplyScalar(.5),$t.copy(e).sub(t).normalize(),te.copy(this.origin).sub(Kt);const r=.5*t.distanceTo(e),s=-this.direction.dot($t),a=te.dot(this.direction),o=-te.dot($t),l=te.lengthSq(),c=Math.abs(1-s*s);let h,u,d,p;if(c>0)if(h=s*o-a,u=s*a-o,p=r*c,h>=0)if(u>=-p)if(u<=p){const t=1/c;h*=t,u*=t,d=h*(h+s*u+2*a)+u*(s*h+u+2*o)+l}else u=r,h=Math.max(0,-(s*u+a)),d=-h*h+u*(u+2*o)+l;else u=-r,h=Math.max(0,-(s*u+a)),d=-h*h+u*(u+2*o)+l;else u<=-p?(h=Math.max(0,-(-s*r+a)),u=h>0?-r:Math.min(Math.max(-r,-o),r),d=-h*h+u*(u+2*o)+l):u<=p?(h=0,u=Math.min(Math.max(-r,-o),r),d=u*(u+2*o)+l):(h=Math.max(0,-(s*r+a)),u=h>0?r:Math.min(Math.max(-r,-o),r),d=-h*h+u*(u+2*o)+l);else u=s>0?-r:r,h=Math.max(0,-(s*u+a)),d=-h*h+u*(u+2*o)+l;return n&&n.copy(this.direction).multiplyScalar(h).add(this.origin),i&&i.copy($t).multiplyScalar(u).add(Kt),d}intersectSphere(t,e){Qt.subVectors(t.center,this.origin);const n=Qt.dot(this.direction),i=Qt.dot(Qt)-n*n,r=t.radius*t.radius;if(i>r)return null;const s=Math.sqrt(r-i),a=n-s,o=n+s;return a<0&&o<0?null:a<0?this.at(o,e):this.at(a,e)}intersectsSphere(t){return this.distanceSqToPoint(t.center)<=t.radius*t.radius}distanceToPlane(t){const e=t.normal.dot(this.direction);if(0===e)return 0===t.distanceToPoint(this.origin)?0:null;const n=-(this.origin.dot(t.normal)+t.constant)/e;return n>=0?n:null}intersectPlane(t,e){const n=this.distanceToPlane(t);return null===n?null:this.at(n,e)}intersectsPlane(t){const e=t.distanceToPoint(this.origin);if(0===e)return!0;return t.normal.dot(this.direction)*e<0}intersectBox(t,e){let n,i,r,s,a,o;const l=1/this.direction.x,c=1/this.direction.y,h=1/this.direction.z,u=this.origin;return l>=0?(n=(t.min.x-u.x)*l,i=(t.max.x-u.x)*l):(n=(t.max.x-u.x)*l,i=(t.min.x-u.x)*l),c>=0?(r=(t.min.y-u.y)*c,s=(t.max.y-u.y)*c):(r=(t.max.y-u.y)*c,s=(t.min.y-u.y)*c),n>s||r>i?null:((r>n||n!=n)&&(n=r),(s=0?(a=(t.min.z-u.z)*h,o=(t.max.z-u.z)*h):(a=(t.max.z-u.z)*h,o=(t.min.z-u.z)*h),n>o||a>i?null:((a>n||n!=n)&&(n=a),(o=0?n:i,e)))}intersectsBox(t){return null!==this.intersectBox(t,Qt)}intersectTriangle(t,e,n,i,r){ee.subVectors(e,t),ne.subVectors(n,t),ie.crossVectors(ee,ne);let s,a=this.direction.dot(ie);if(a>0){if(i)return null;s=1}else{if(!(a<0))return null;s=-1,a=-a}te.subVectors(this.origin,t);const o=s*this.direction.dot(ne.crossVectors(te,ne));if(o<0)return null;const l=s*this.direction.dot(ee.cross(te));if(l<0)return null;if(o+l>a)return null;const c=-s*te.dot(ie);return c<0?null:this.at(c/a,r)}applyMatrix4(t){return this.origin.applyMatrix4(t),this.direction.transformDirection(t),this}equals(t){return t.origin.equals(this.origin)&&t.direction.equals(this.direction)}clone(){return(new this.constructor).copy(this)}}class se{constructor(){this.elements=[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1],arguments.length>0&&console.error("THREE.Matrix4: the constructor no longer reads arguments. use .set() instead.")}set(t,e,n,i,r,s,a,o,l,c,h,u,d,p,m,f){const g=this.elements;return g[0]=t,g[4]=e,g[8]=n,g[12]=i,g[1]=r,g[5]=s,g[9]=a,g[13]=o,g[2]=l,g[6]=c,g[10]=h,g[14]=u,g[3]=d,g[7]=p,g[11]=m,g[15]=f,this}identity(){return this.set(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1),this}clone(){return(new se).fromArray(this.elements)}copy(t){const e=this.elements,n=t.elements;return e[0]=n[0],e[1]=n[1],e[2]=n[2],e[3]=n[3],e[4]=n[4],e[5]=n[5],e[6]=n[6],e[7]=n[7],e[8]=n[8],e[9]=n[9],e[10]=n[10],e[11]=n[11],e[12]=n[12],e[13]=n[13],e[14]=n[14],e[15]=n[15],this}copyPosition(t){const e=this.elements,n=t.elements;return e[12]=n[12],e[13]=n[13],e[14]=n[14],this}setFromMatrix3(t){const e=t.elements;return this.set(e[0],e[3],e[6],0,e[1],e[4],e[7],0,e[2],e[5],e[8],0,0,0,0,1),this}extractBasis(t,e,n){return t.setFromMatrixColumn(this,0),e.setFromMatrixColumn(this,1),n.setFromMatrixColumn(this,2),this}makeBasis(t,e,n){return this.set(t.x,e.x,n.x,0,t.y,e.y,n.y,0,t.z,e.z,n.z,0,0,0,0,1),this}extractRotation(t){const e=this.elements,n=t.elements,i=1/ae.setFromMatrixColumn(t,0).length(),r=1/ae.setFromMatrixColumn(t,1).length(),s=1/ae.setFromMatrixColumn(t,2).length();return e[0]=n[0]*i,e[1]=n[1]*i,e[2]=n[2]*i,e[3]=0,e[4]=n[4]*r,e[5]=n[5]*r,e[6]=n[6]*r,e[7]=0,e[8]=n[8]*s,e[9]=n[9]*s,e[10]=n[10]*s,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,this}makeRotationFromEuler(t){t&&t.isEuler||console.error("THREE.Matrix4: .makeRotationFromEuler() now expects a Euler rotation rather than a Vector3 and order.");const e=this.elements,n=t.x,i=t.y,r=t.z,s=Math.cos(n),a=Math.sin(n),o=Math.cos(i),l=Math.sin(i),c=Math.cos(r),h=Math.sin(r);if("XYZ"===t.order){const t=s*c,n=s*h,i=a*c,r=a*h;e[0]=o*c,e[4]=-o*h,e[8]=l,e[1]=n+i*l,e[5]=t-r*l,e[9]=-a*o,e[2]=r-t*l,e[6]=i+n*l,e[10]=s*o}else if("YXZ"===t.order){const t=o*c,n=o*h,i=l*c,r=l*h;e[0]=t+r*a,e[4]=i*a-n,e[8]=s*l,e[1]=s*h,e[5]=s*c,e[9]=-a,e[2]=n*a-i,e[6]=r+t*a,e[10]=s*o}else if("ZXY"===t.order){const t=o*c,n=o*h,i=l*c,r=l*h;e[0]=t-r*a,e[4]=-s*h,e[8]=i+n*a,e[1]=n+i*a,e[5]=s*c,e[9]=r-t*a,e[2]=-s*l,e[6]=a,e[10]=s*o}else if("ZYX"===t.order){const t=s*c,n=s*h,i=a*c,r=a*h;e[0]=o*c,e[4]=i*l-n,e[8]=t*l+r,e[1]=o*h,e[5]=r*l+t,e[9]=n*l-i,e[2]=-l,e[6]=a*o,e[10]=s*o}else if("YZX"===t.order){const t=s*o,n=s*l,i=a*o,r=a*l;e[0]=o*c,e[4]=r-t*h,e[8]=i*h+n,e[1]=h,e[5]=s*c,e[9]=-a*c,e[2]=-l*c,e[6]=n*h+i,e[10]=t-r*h}else if("XZY"===t.order){const t=s*o,n=s*l,i=a*o,r=a*l;e[0]=o*c,e[4]=-h,e[8]=l*c,e[1]=t*h+r,e[5]=s*c,e[9]=n*h-i,e[2]=i*h-n,e[6]=a*c,e[10]=r*h+t}return e[3]=0,e[7]=0,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,this}makeRotationFromQuaternion(t){return this.compose(le,t,ce)}lookAt(t,e,n){const i=this.elements;return de.subVectors(t,e),0===de.lengthSq()&&(de.z=1),de.normalize(),he.crossVectors(n,de),0===he.lengthSq()&&(1===Math.abs(n.z)?de.x+=1e-4:de.z+=1e-4,de.normalize(),he.crossVectors(n,de)),he.normalize(),ue.crossVectors(de,he),i[0]=he.x,i[4]=ue.x,i[8]=de.x,i[1]=he.y,i[5]=ue.y,i[9]=de.y,i[2]=he.z,i[6]=ue.z,i[10]=de.z,this}multiply(t,e){return void 0!==e?(console.warn("THREE.Matrix4: .multiply() now only accepts one argument. Use .multiplyMatrices( a, b ) instead."),this.multiplyMatrices(t,e)):this.multiplyMatrices(this,t)}premultiply(t){return this.multiplyMatrices(t,this)}multiplyMatrices(t,e){const n=t.elements,i=e.elements,r=this.elements,s=n[0],a=n[4],o=n[8],l=n[12],c=n[1],h=n[5],u=n[9],d=n[13],p=n[2],m=n[6],f=n[10],g=n[14],v=n[3],y=n[7],x=n[11],_=n[15],w=i[0],b=i[4],M=i[8],S=i[12],T=i[1],E=i[5],A=i[9],L=i[13],R=i[2],C=i[6],P=i[10],D=i[14],I=i[3],N=i[7],B=i[11],z=i[15];return r[0]=s*w+a*T+o*R+l*I,r[4]=s*b+a*E+o*C+l*N,r[8]=s*M+a*A+o*P+l*B,r[12]=s*S+a*L+o*D+l*z,r[1]=c*w+h*T+u*R+d*I,r[5]=c*b+h*E+u*C+d*N,r[9]=c*M+h*A+u*P+d*B,r[13]=c*S+h*L+u*D+d*z,r[2]=p*w+m*T+f*R+g*I,r[6]=p*b+m*E+f*C+g*N,r[10]=p*M+m*A+f*P+g*B,r[14]=p*S+m*L+f*D+g*z,r[3]=v*w+y*T+x*R+_*I,r[7]=v*b+y*E+x*C+_*N,r[11]=v*M+y*A+x*P+_*B,r[15]=v*S+y*L+x*D+_*z,this}multiplyScalar(t){const e=this.elements;return e[0]*=t,e[4]*=t,e[8]*=t,e[12]*=t,e[1]*=t,e[5]*=t,e[9]*=t,e[13]*=t,e[2]*=t,e[6]*=t,e[10]*=t,e[14]*=t,e[3]*=t,e[7]*=t,e[11]*=t,e[15]*=t,this}determinant(){const t=this.elements,e=t[0],n=t[4],i=t[8],r=t[12],s=t[1],a=t[5],o=t[9],l=t[13],c=t[2],h=t[6],u=t[10],d=t[14];return t[3]*(+r*o*h-i*l*h-r*a*u+n*l*u+i*a*d-n*o*d)+t[7]*(+e*o*d-e*l*u+r*s*u-i*s*d+i*l*c-r*o*c)+t[11]*(+e*l*h-e*a*d-r*s*h+n*s*d+r*a*c-n*l*c)+t[15]*(-i*a*c-e*o*h+e*a*u+i*s*h-n*s*u+n*o*c)}transpose(){const t=this.elements;let e;return e=t[1],t[1]=t[4],t[4]=e,e=t[2],t[2]=t[8],t[8]=e,e=t[6],t[6]=t[9],t[9]=e,e=t[3],t[3]=t[12],t[12]=e,e=t[7],t[7]=t[13],t[13]=e,e=t[11],t[11]=t[14],t[14]=e,this}setPosition(t,e,n){const i=this.elements;return t.isVector3?(i[12]=t.x,i[13]=t.y,i[14]=t.z):(i[12]=t,i[13]=e,i[14]=n),this}invert(){const t=this.elements,e=t[0],n=t[1],i=t[2],r=t[3],s=t[4],a=t[5],o=t[6],l=t[7],c=t[8],h=t[9],u=t[10],d=t[11],p=t[12],m=t[13],f=t[14],g=t[15],v=h*f*l-m*u*l+m*o*d-a*f*d-h*o*g+a*u*g,y=p*u*l-c*f*l-p*o*d+s*f*d+c*o*g-s*u*g,x=c*m*l-p*h*l+p*a*d-s*m*d-c*a*g+s*h*g,_=p*h*o-c*m*o-p*a*u+s*m*u+c*a*f-s*h*f,w=e*v+n*y+i*x+r*_;if(0===w)return this.set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0);const b=1/w;return t[0]=v*b,t[1]=(m*u*r-h*f*r-m*i*d+n*f*d+h*i*g-n*u*g)*b,t[2]=(a*f*r-m*o*r+m*i*l-n*f*l-a*i*g+n*o*g)*b,t[3]=(h*o*r-a*u*r-h*i*l+n*u*l+a*i*d-n*o*d)*b,t[4]=y*b,t[5]=(c*f*r-p*u*r+p*i*d-e*f*d-c*i*g+e*u*g)*b,t[6]=(p*o*r-s*f*r-p*i*l+e*f*l+s*i*g-e*o*g)*b,t[7]=(s*u*r-c*o*r+c*i*l-e*u*l-s*i*d+e*o*d)*b,t[8]=x*b,t[9]=(p*h*r-c*m*r-p*n*d+e*m*d+c*n*g-e*h*g)*b,t[10]=(s*m*r-p*a*r+p*n*l-e*m*l-s*n*g+e*a*g)*b,t[11]=(c*a*r-s*h*r-c*n*l+e*h*l+s*n*d-e*a*d)*b,t[12]=_*b,t[13]=(c*m*i-p*h*i+p*n*u-e*m*u-c*n*f+e*h*f)*b,t[14]=(p*a*i-s*m*i-p*n*o+e*m*o+s*n*f-e*a*f)*b,t[15]=(s*h*i-c*a*i+c*n*o-e*h*o-s*n*u+e*a*u)*b,this}scale(t){const e=this.elements,n=t.x,i=t.y,r=t.z;return e[0]*=n,e[4]*=i,e[8]*=r,e[1]*=n,e[5]*=i,e[9]*=r,e[2]*=n,e[6]*=i,e[10]*=r,e[3]*=n,e[7]*=i,e[11]*=r,this}getMaxScaleOnAxis(){const t=this.elements,e=t[0]*t[0]+t[1]*t[1]+t[2]*t[2],n=t[4]*t[4]+t[5]*t[5]+t[6]*t[6],i=t[8]*t[8]+t[9]*t[9]+t[10]*t[10];return Math.sqrt(Math.max(e,n,i))}makeTranslation(t,e,n){return this.set(1,0,0,t,0,1,0,e,0,0,1,n,0,0,0,1),this}makeRotationX(t){const e=Math.cos(t),n=Math.sin(t);return this.set(1,0,0,0,0,e,-n,0,0,n,e,0,0,0,0,1),this}makeRotationY(t){const e=Math.cos(t),n=Math.sin(t);return this.set(e,0,n,0,0,1,0,0,-n,0,e,0,0,0,0,1),this}makeRotationZ(t){const e=Math.cos(t),n=Math.sin(t);return this.set(e,-n,0,0,n,e,0,0,0,0,1,0,0,0,0,1),this}makeRotationAxis(t,e){const n=Math.cos(e),i=Math.sin(e),r=1-n,s=t.x,a=t.y,o=t.z,l=r*s,c=r*a;return this.set(l*s+n,l*a-i*o,l*o+i*a,0,l*a+i*o,c*a+n,c*o-i*s,0,l*o-i*a,c*o+i*s,r*o*o+n,0,0,0,0,1),this}makeScale(t,e,n){return this.set(t,0,0,0,0,e,0,0,0,0,n,0,0,0,0,1),this}makeShear(t,e,n){return this.set(1,e,n,0,t,1,n,0,t,e,1,0,0,0,0,1),this}compose(t,e,n){const i=this.elements,r=e._x,s=e._y,a=e._z,o=e._w,l=r+r,c=s+s,h=a+a,u=r*l,d=r*c,p=r*h,m=s*c,f=s*h,g=a*h,v=o*l,y=o*c,x=o*h,_=n.x,w=n.y,b=n.z;return i[0]=(1-(m+g))*_,i[1]=(d+x)*_,i[2]=(p-y)*_,i[3]=0,i[4]=(d-x)*w,i[5]=(1-(u+g))*w,i[6]=(f+v)*w,i[7]=0,i[8]=(p+y)*b,i[9]=(f-v)*b,i[10]=(1-(u+m))*b,i[11]=0,i[12]=t.x,i[13]=t.y,i[14]=t.z,i[15]=1,this}decompose(t,e,n){const i=this.elements;let r=ae.set(i[0],i[1],i[2]).length();const s=ae.set(i[4],i[5],i[6]).length(),a=ae.set(i[8],i[9],i[10]).length();this.determinant()<0&&(r=-r),t.x=i[12],t.y=i[13],t.z=i[14],oe.copy(this);const o=1/r,l=1/s,c=1/a;return oe.elements[0]*=o,oe.elements[1]*=o,oe.elements[2]*=o,oe.elements[4]*=l,oe.elements[5]*=l,oe.elements[6]*=l,oe.elements[8]*=c,oe.elements[9]*=c,oe.elements[10]*=c,e.setFromRotationMatrix(oe),n.x=r,n.y=s,n.z=a,this}makePerspective(t,e,n,i,r,s){void 0===s&&console.warn("THREE.Matrix4: .makePerspective() has been redefined and has a new signature. Please check the docs.");const a=this.elements,o=2*r/(e-t),l=2*r/(n-i),c=(e+t)/(e-t),h=(n+i)/(n-i),u=-(s+r)/(s-r),d=-2*s*r/(s-r);return a[0]=o,a[4]=0,a[8]=c,a[12]=0,a[1]=0,a[5]=l,a[9]=h,a[13]=0,a[2]=0,a[6]=0,a[10]=u,a[14]=d,a[3]=0,a[7]=0,a[11]=-1,a[15]=0,this}makeOrthographic(t,e,n,i,r,s){const a=this.elements,o=1/(e-t),l=1/(n-i),c=1/(s-r),h=(e+t)*o,u=(n+i)*l,d=(s+r)*c;return a[0]=2*o,a[4]=0,a[8]=0,a[12]=-h,a[1]=0,a[5]=2*l,a[9]=0,a[13]=-u,a[2]=0,a[6]=0,a[10]=-2*c,a[14]=-d,a[3]=0,a[7]=0,a[11]=0,a[15]=1,this}equals(t){const e=this.elements,n=t.elements;for(let t=0;t<16;t++)if(e[t]!==n[t])return!1;return!0}fromArray(t,e=0){for(let n=0;n<16;n++)this.elements[n]=t[n+e];return this}toArray(t=[],e=0){const n=this.elements;return t[e]=n[0],t[e+1]=n[1],t[e+2]=n[2],t[e+3]=n[3],t[e+4]=n[4],t[e+5]=n[5],t[e+6]=n[6],t[e+7]=n[7],t[e+8]=n[8],t[e+9]=n[9],t[e+10]=n[10],t[e+11]=n[11],t[e+12]=n[12],t[e+13]=n[13],t[e+14]=n[14],t[e+15]=n[15],t}}se.prototype.isMatrix4=!0;const ae=new Lt,oe=new se,le=new Lt(0,0,0),ce=new Lt(1,1,1),he=new Lt,ue=new Lt,de=new Lt,pe=new se,me=new At;class fe{constructor(t=0,e=0,n=0,i=fe.DefaultOrder){this._x=t,this._y=e,this._z=n,this._order=i}get x(){return this._x}set x(t){this._x=t,this._onChangeCallback()}get y(){return this._y}set y(t){this._y=t,this._onChangeCallback()}get z(){return this._z}set z(t){this._z=t,this._onChangeCallback()}get order(){return this._order}set order(t){this._order=t,this._onChangeCallback()}set(t,e,n,i){return this._x=t,this._y=e,this._z=n,this._order=i||this._order,this._onChangeCallback(),this}clone(){return new this.constructor(this._x,this._y,this._z,this._order)}copy(t){return this._x=t._x,this._y=t._y,this._z=t._z,this._order=t._order,this._onChangeCallback(),this}setFromRotationMatrix(t,e,n){const i=t.elements,r=i[0],s=i[4],a=i[8],o=i[1],l=i[5],c=i[9],h=i[2],u=i[6],d=i[10];switch(e=e||this._order){case"XYZ":this._y=Math.asin(ht(a,-1,1)),Math.abs(a)<.9999999?(this._x=Math.atan2(-c,d),this._z=Math.atan2(-s,r)):(this._x=Math.atan2(u,l),this._z=0);break;case"YXZ":this._x=Math.asin(-ht(c,-1,1)),Math.abs(c)<.9999999?(this._y=Math.atan2(a,d),this._z=Math.atan2(o,l)):(this._y=Math.atan2(-h,r),this._z=0);break;case"ZXY":this._x=Math.asin(ht(u,-1,1)),Math.abs(u)<.9999999?(this._y=Math.atan2(-h,d),this._z=Math.atan2(-s,l)):(this._y=0,this._z=Math.atan2(o,r));break;case"ZYX":this._y=Math.asin(-ht(h,-1,1)),Math.abs(h)<.9999999?(this._x=Math.atan2(u,d),this._z=Math.atan2(o,r)):(this._x=0,this._z=Math.atan2(-s,l));break;case"YZX":this._z=Math.asin(ht(o,-1,1)),Math.abs(o)<.9999999?(this._x=Math.atan2(-c,l),this._y=Math.atan2(-h,r)):(this._x=0,this._y=Math.atan2(a,d));break;case"XZY":this._z=Math.asin(-ht(s,-1,1)),Math.abs(s)<.9999999?(this._x=Math.atan2(u,l),this._y=Math.atan2(a,r)):(this._x=Math.atan2(-c,d),this._y=0);break;default:console.warn("THREE.Euler: .setFromRotationMatrix() encountered an unknown order: "+e)}return this._order=e,!1!==n&&this._onChangeCallback(),this}setFromQuaternion(t,e,n){return pe.makeRotationFromQuaternion(t),this.setFromRotationMatrix(pe,e,n)}setFromVector3(t,e){return this.set(t.x,t.y,t.z,e||this._order)}reorder(t){return me.setFromEuler(this),this.setFromQuaternion(me,t)}equals(t){return t._x===this._x&&t._y===this._y&&t._z===this._z&&t._order===this._order}fromArray(t){return this._x=t[0],this._y=t[1],this._z=t[2],void 0!==t[3]&&(this._order=t[3]),this._onChangeCallback(),this}toArray(t=[],e=0){return t[e]=this._x,t[e+1]=this._y,t[e+2]=this._z,t[e+3]=this._order,t}toVector3(t){return t?t.set(this._x,this._y,this._z):new Lt(this._x,this._y,this._z)}_onChange(t){return this._onChangeCallback=t,this}_onChangeCallback(){}}fe.prototype.isEuler=!0,fe.DefaultOrder="XYZ",fe.RotationOrders=["XYZ","YZX","ZXY","XZY","YXZ","ZYX"];class ge{constructor(){this.mask=1}set(t){this.mask=1<1){for(let t=0;t1){for(let t=0;t0){i.children=[];for(let e=0;e0){i.animations=[];for(let e=0;e0&&(n.geometries=e),i.length>0&&(n.materials=i),r.length>0&&(n.textures=r),a.length>0&&(n.images=a),o.length>0&&(n.shapes=o),l.length>0&&(n.skeletons=l),c.length>0&&(n.animations=c)}return n.object=i,n;function s(t){const e=[];for(const n in t){const i=t[n];delete i.metadata,e.push(i)}return e}}clone(t){return(new this.constructor).copy(this,t)}copy(t,e=!0){if(this.name=t.name,this.up.copy(t.up),this.position.copy(t.position),this.rotation.order=t.rotation.order,this.quaternion.copy(t.quaternion),this.scale.copy(t.scale),this.matrix.copy(t.matrix),this.matrixWorld.copy(t.matrixWorld),this.matrixAutoUpdate=t.matrixAutoUpdate,this.matrixWorldNeedsUpdate=t.matrixWorldNeedsUpdate,this.layers.mask=t.layers.mask,this.visible=t.visible,this.castShadow=t.castShadow,this.receiveShadow=t.receiveShadow,this.frustumCulled=t.frustumCulled,this.renderOrder=t.renderOrder,this.userData=JSON.parse(JSON.stringify(t.userData)),!0===e)for(let e=0;e1?null:e.copy(n).multiplyScalar(r).add(t.start)}intersectsLine(t){const e=this.distanceToPoint(t.start),n=this.distanceToPoint(t.end);return e<0&&n>0||n<0&&e>0}intersectsBox(t){return t.intersectsPlane(this)}intersectsSphere(t){return t.intersectsPlane(this)}coplanarPoint(t){return void 0===t&&(console.warn("THREE.Plane: .coplanarPoint() target is now required"),t=new Lt),t.copy(this.normal).multiplyScalar(-this.constant)}applyMatrix4(t,e){const n=e||Ie.getNormalMatrix(t),i=this.coplanarPoint(Pe).applyMatrix4(t),r=this.normal.applyMatrix3(n).normalize();return this.constant=-i.dot(r),this}translate(t){return this.constant-=t.dot(this.normal),this}equals(t){return t.normal.equals(this.normal)&&t.constant===this.constant}clone(){return(new this.constructor).copy(this)}}Ne.prototype.isPlane=!0;const Be=new Lt,ze=new Lt,Fe=new Lt,Oe=new Lt,He=new Lt,Ge=new Lt,Ue=new Lt,ke=new Lt,Ve=new Lt,We=new Lt;class je{constructor(t=new Lt,e=new Lt,n=new Lt){this.a=t,this.b=e,this.c=n}static getNormal(t,e,n,i){void 0===i&&(console.warn("THREE.Triangle: .getNormal() target is now required"),i=new Lt),i.subVectors(n,e),Be.subVectors(t,e),i.cross(Be);const r=i.lengthSq();return r>0?i.multiplyScalar(1/Math.sqrt(r)):i.set(0,0,0)}static getBarycoord(t,e,n,i,r){Be.subVectors(i,e),ze.subVectors(n,e),Fe.subVectors(t,e);const s=Be.dot(Be),a=Be.dot(ze),o=Be.dot(Fe),l=ze.dot(ze),c=ze.dot(Fe),h=s*l-a*a;if(void 0===r&&(console.warn("THREE.Triangle: .getBarycoord() target is now required"),r=new Lt),0===h)return r.set(-2,-1,-1);const u=1/h,d=(l*o-a*c)*u,p=(s*c-a*o)*u;return r.set(1-d-p,p,d)}static containsPoint(t,e,n,i){return this.getBarycoord(t,e,n,i,Oe),Oe.x>=0&&Oe.y>=0&&Oe.x+Oe.y<=1}static getUV(t,e,n,i,r,s,a,o){return this.getBarycoord(t,e,n,i,Oe),o.set(0,0),o.addScaledVector(r,Oe.x),o.addScaledVector(s,Oe.y),o.addScaledVector(a,Oe.z),o}static isFrontFacing(t,e,n,i){return Be.subVectors(n,e),ze.subVectors(t,e),Be.cross(ze).dot(i)<0}set(t,e,n){return this.a.copy(t),this.b.copy(e),this.c.copy(n),this}setFromPointsAndIndices(t,e,n,i){return this.a.copy(t[e]),this.b.copy(t[n]),this.c.copy(t[i]),this}clone(){return(new this.constructor).copy(this)}copy(t){return this.a.copy(t.a),this.b.copy(t.b),this.c.copy(t.c),this}getArea(){return Be.subVectors(this.c,this.b),ze.subVectors(this.a,this.b),.5*Be.cross(ze).length()}getMidpoint(t){return void 0===t&&(console.warn("THREE.Triangle: .getMidpoint() target is now required"),t=new Lt),t.addVectors(this.a,this.b).add(this.c).multiplyScalar(1/3)}getNormal(t){return je.getNormal(this.a,this.b,this.c,t)}getPlane(t){return void 0===t&&(console.warn("THREE.Triangle: .getPlane() target is now required"),t=new Ne),t.setFromCoplanarPoints(this.a,this.b,this.c)}getBarycoord(t,e){return je.getBarycoord(t,this.a,this.b,this.c,e)}getUV(t,e,n,i,r){return je.getUV(t,this.a,this.b,this.c,e,n,i,r)}containsPoint(t){return je.containsPoint(t,this.a,this.b,this.c)}isFrontFacing(t){return je.isFrontFacing(this.a,this.b,this.c,t)}intersectsBox(t){return t.intersectsTriangle(this)}closestPointToPoint(t,e){void 0===e&&(console.warn("THREE.Triangle: .closestPointToPoint() target is now required"),e=new Lt);const n=this.a,i=this.b,r=this.c;let s,a;He.subVectors(i,n),Ge.subVectors(r,n),ke.subVectors(t,n);const o=He.dot(ke),l=Ge.dot(ke);if(o<=0&&l<=0)return e.copy(n);Ve.subVectors(t,i);const c=He.dot(Ve),h=Ge.dot(Ve);if(c>=0&&h<=c)return e.copy(i);const u=o*h-c*l;if(u<=0&&o>=0&&c<=0)return s=o/(o-c),e.copy(n).addScaledVector(He,s);We.subVectors(t,r);const d=He.dot(We),p=Ge.dot(We);if(p>=0&&d<=p)return e.copy(r);const m=d*l-o*p;if(m<=0&&l>=0&&p<=0)return a=l/(l-p),e.copy(n).addScaledVector(Ge,a);const f=c*p-d*h;if(f<=0&&h-c>=0&&d-p>=0)return Ue.subVectors(r,i),a=(h-c)/(h-c+(d-p)),e.copy(i).addScaledVector(Ue,a);const g=1/(f+m+u);return s=m*g,a=u*g,e.copy(n).addScaledVector(He,s).addScaledVector(Ge,a)}equals(t){return t.a.equals(this.a)&&t.b.equals(this.b)&&t.c.equals(this.c)}}let qe=0;function Xe(){Object.defineProperty(this,"id",{value:qe++}),this.uuid=ct(),this.name="",this.type="Material",this.fog=!0,this.blending=1,this.side=0,this.vertexColors=!1,this.opacity=1,this.transparent=!1,this.blendSrc=204,this.blendDst=205,this.blendEquation=n,this.blendSrcAlpha=null,this.blendDstAlpha=null,this.blendEquationAlpha=null,this.depthFunc=3,this.depthTest=!0,this.depthWrite=!0,this.stencilWriteMask=255,this.stencilFunc=519,this.stencilRef=0,this.stencilFuncMask=255,this.stencilFail=tt,this.stencilZFail=tt,this.stencilZPass=tt,this.stencilWrite=!1,this.clippingPlanes=null,this.clipIntersection=!1,this.clipShadows=!1,this.shadowSide=null,this.colorWrite=!0,this.precision=null,this.polygonOffset=!1,this.polygonOffsetFactor=0,this.polygonOffsetUnits=0,this.dithering=!1,this.alphaTest=0,this.alphaToCoverage=!1,this.premultipliedAlpha=!1,this.visible=!0,this.toneMapped=!0,this.userData={},this.version=0}Xe.prototype=Object.assign(Object.create(rt.prototype),{constructor:Xe,isMaterial:!0,onBuild:function(){},onBeforeCompile:function(){},customProgramCacheKey:function(){return this.onBeforeCompile.toString()},setValues:function(t){if(void 0!==t)for(const e in t){const n=t[e];if(void 0===n){console.warn("THREE.Material: '"+e+"' parameter is undefined.");continue}if("shading"===e){console.warn("THREE."+this.type+": .shading has been removed. Use the boolean .flatShading instead."),this.flatShading=1===n;continue}const i=this[e];void 0!==i?i&&i.isColor?i.set(n):i&&i.isVector3&&n&&n.isVector3?i.copy(n):this[e]=n:console.warn("THREE."+this.type+": '"+e+"' is not a property of this material.")}},toJSON:function(t){const e=void 0===t||"string"==typeof t;e&&(t={textures:{},images:{}});const n={metadata:{version:4.5,type:"Material",generator:"Material.toJSON"}};function i(t){const e=[];for(const n in t){const i=t[n];delete i.metadata,e.push(i)}return e}if(n.uuid=this.uuid,n.type=this.type,""!==this.name&&(n.name=this.name),this.color&&this.color.isColor&&(n.color=this.color.getHex()),void 0!==this.roughness&&(n.roughness=this.roughness),void 0!==this.metalness&&(n.metalness=this.metalness),this.sheen&&this.sheen.isColor&&(n.sheen=this.sheen.getHex()),this.emissive&&this.emissive.isColor&&(n.emissive=this.emissive.getHex()),this.emissiveIntensity&&1!==this.emissiveIntensity&&(n.emissiveIntensity=this.emissiveIntensity),this.specular&&this.specular.isColor&&(n.specular=this.specular.getHex()),void 0!==this.shininess&&(n.shininess=this.shininess),void 0!==this.clearcoat&&(n.clearcoat=this.clearcoat),void 0!==this.clearcoatRoughness&&(n.clearcoatRoughness=this.clearcoatRoughness),this.clearcoatMap&&this.clearcoatMap.isTexture&&(n.clearcoatMap=this.clearcoatMap.toJSON(t).uuid),this.clearcoatRoughnessMap&&this.clearcoatRoughnessMap.isTexture&&(n.clearcoatRoughnessMap=this.clearcoatRoughnessMap.toJSON(t).uuid),this.clearcoatNormalMap&&this.clearcoatNormalMap.isTexture&&(n.clearcoatNormalMap=this.clearcoatNormalMap.toJSON(t).uuid,n.clearcoatNormalScale=this.clearcoatNormalScale.toArray()),this.map&&this.map.isTexture&&(n.map=this.map.toJSON(t).uuid),this.matcap&&this.matcap.isTexture&&(n.matcap=this.matcap.toJSON(t).uuid),this.alphaMap&&this.alphaMap.isTexture&&(n.alphaMap=this.alphaMap.toJSON(t).uuid),this.lightMap&&this.lightMap.isTexture&&(n.lightMap=this.lightMap.toJSON(t).uuid,n.lightMapIntensity=this.lightMapIntensity),this.aoMap&&this.aoMap.isTexture&&(n.aoMap=this.aoMap.toJSON(t).uuid,n.aoMapIntensity=this.aoMapIntensity),this.bumpMap&&this.bumpMap.isTexture&&(n.bumpMap=this.bumpMap.toJSON(t).uuid,n.bumpScale=this.bumpScale),this.normalMap&&this.normalMap.isTexture&&(n.normalMap=this.normalMap.toJSON(t).uuid,n.normalMapType=this.normalMapType,n.normalScale=this.normalScale.toArray()),this.displacementMap&&this.displacementMap.isTexture&&(n.displacementMap=this.displacementMap.toJSON(t).uuid,n.displacementScale=this.displacementScale,n.displacementBias=this.displacementBias),this.roughnessMap&&this.roughnessMap.isTexture&&(n.roughnessMap=this.roughnessMap.toJSON(t).uuid),this.metalnessMap&&this.metalnessMap.isTexture&&(n.metalnessMap=this.metalnessMap.toJSON(t).uuid),this.emissiveMap&&this.emissiveMap.isTexture&&(n.emissiveMap=this.emissiveMap.toJSON(t).uuid),this.specularMap&&this.specularMap.isTexture&&(n.specularMap=this.specularMap.toJSON(t).uuid),this.envMap&&this.envMap.isTexture&&(n.envMap=this.envMap.toJSON(t).uuid,void 0!==this.combine&&(n.combine=this.combine)),void 0!==this.envMapIntensity&&(n.envMapIntensity=this.envMapIntensity),void 0!==this.reflectivity&&(n.reflectivity=this.reflectivity),void 0!==this.refractionRatio&&(n.refractionRatio=this.refractionRatio),this.gradientMap&&this.gradientMap.isTexture&&(n.gradientMap=this.gradientMap.toJSON(t).uuid),void 0!==this.size&&(n.size=this.size),null!==this.shadowSide&&(n.shadowSide=this.shadowSide),void 0!==this.sizeAttenuation&&(n.sizeAttenuation=this.sizeAttenuation),1!==this.blending&&(n.blending=this.blending),0!==this.side&&(n.side=this.side),this.vertexColors&&(n.vertexColors=!0),this.opacity<1&&(n.opacity=this.opacity),!0===this.transparent&&(n.transparent=this.transparent),n.depthFunc=this.depthFunc,n.depthTest=this.depthTest,n.depthWrite=this.depthWrite,n.colorWrite=this.colorWrite,n.stencilWrite=this.stencilWrite,n.stencilWriteMask=this.stencilWriteMask,n.stencilFunc=this.stencilFunc,n.stencilRef=this.stencilRef,n.stencilFuncMask=this.stencilFuncMask,n.stencilFail=this.stencilFail,n.stencilZFail=this.stencilZFail,n.stencilZPass=this.stencilZPass,this.rotation&&0!==this.rotation&&(n.rotation=this.rotation),!0===this.polygonOffset&&(n.polygonOffset=!0),0!==this.polygonOffsetFactor&&(n.polygonOffsetFactor=this.polygonOffsetFactor),0!==this.polygonOffsetUnits&&(n.polygonOffsetUnits=this.polygonOffsetUnits),this.linewidth&&1!==this.linewidth&&(n.linewidth=this.linewidth),void 0!==this.dashSize&&(n.dashSize=this.dashSize),void 0!==this.gapSize&&(n.gapSize=this.gapSize),void 0!==this.scale&&(n.scale=this.scale),!0===this.dithering&&(n.dithering=!0),this.alphaTest>0&&(n.alphaTest=this.alphaTest),!0===this.alphaToCoverage&&(n.alphaToCoverage=this.alphaToCoverage),!0===this.premultipliedAlpha&&(n.premultipliedAlpha=this.premultipliedAlpha),!0===this.wireframe&&(n.wireframe=this.wireframe),this.wireframeLinewidth>1&&(n.wireframeLinewidth=this.wireframeLinewidth),"round"!==this.wireframeLinecap&&(n.wireframeLinecap=this.wireframeLinecap),"round"!==this.wireframeLinejoin&&(n.wireframeLinejoin=this.wireframeLinejoin),!0===this.morphTargets&&(n.morphTargets=!0),!0===this.morphNormals&&(n.morphNormals=!0),!0===this.skinning&&(n.skinning=!0),!0===this.flatShading&&(n.flatShading=this.flatShading),!1===this.visible&&(n.visible=!1),!1===this.toneMapped&&(n.toneMapped=!1),"{}"!==JSON.stringify(this.userData)&&(n.userData=this.userData),e){const e=i(t.textures),r=i(t.images);e.length>0&&(n.textures=e),r.length>0&&(n.images=r)}return n},clone:function(){return(new this.constructor).copy(this)},copy:function(t){this.name=t.name,this.fog=t.fog,this.blending=t.blending,this.side=t.side,this.vertexColors=t.vertexColors,this.opacity=t.opacity,this.transparent=t.transparent,this.blendSrc=t.blendSrc,this.blendDst=t.blendDst,this.blendEquation=t.blendEquation,this.blendSrcAlpha=t.blendSrcAlpha,this.blendDstAlpha=t.blendDstAlpha,this.blendEquationAlpha=t.blendEquationAlpha,this.depthFunc=t.depthFunc,this.depthTest=t.depthTest,this.depthWrite=t.depthWrite,this.stencilWriteMask=t.stencilWriteMask,this.stencilFunc=t.stencilFunc,this.stencilRef=t.stencilRef,this.stencilFuncMask=t.stencilFuncMask,this.stencilFail=t.stencilFail,this.stencilZFail=t.stencilZFail,this.stencilZPass=t.stencilZPass,this.stencilWrite=t.stencilWrite;const e=t.clippingPlanes;let n=null;if(null!==e){const t=e.length;n=new Array(t);for(let i=0;i!==t;++i)n[i]=e[i].clone()}return this.clippingPlanes=n,this.clipIntersection=t.clipIntersection,this.clipShadows=t.clipShadows,this.shadowSide=t.shadowSide,this.colorWrite=t.colorWrite,this.precision=t.precision,this.polygonOffset=t.polygonOffset,this.polygonOffsetFactor=t.polygonOffsetFactor,this.polygonOffsetUnits=t.polygonOffsetUnits,this.dithering=t.dithering,this.alphaTest=t.alphaTest,this.alphaToCoverage=t.alphaToCoverage,this.premultipliedAlpha=t.premultipliedAlpha,this.visible=t.visible,this.toneMapped=t.toneMapped,this.userData=JSON.parse(JSON.stringify(t.userData)),this},dispose:function(){this.dispatchEvent({type:"dispose"})}}),Object.defineProperty(Xe.prototype,"needsUpdate",{set:function(t){!0===t&&this.version++}});const Ye={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074},Ze={h:0,s:0,l:0},Je={h:0,s:0,l:0};function Qe(t,e,n){return n<0&&(n+=1),n>1&&(n-=1),n<1/6?t+6*(e-t)*n:n<.5?e:n<2/3?t+6*(e-t)*(2/3-n):t}function Ke(t){return t<.04045?.0773993808*t:Math.pow(.9478672986*t+.0521327014,2.4)}function $e(t){return t<.0031308?12.92*t:1.055*Math.pow(t,.41666)-.055}class tn{constructor(t,e,n){return void 0===e&&void 0===n?this.set(t):this.setRGB(t,e,n)}set(t){return t&&t.isColor?this.copy(t):"number"==typeof t?this.setHex(t):"string"==typeof t&&this.setStyle(t),this}setScalar(t){return this.r=t,this.g=t,this.b=t,this}setHex(t){return t=Math.floor(t),this.r=(t>>16&255)/255,this.g=(t>>8&255)/255,this.b=(255&t)/255,this}setRGB(t,e,n){return this.r=t,this.g=e,this.b=n,this}setHSL(t,e,n){if(t=ut(t,1),e=ht(e,0,1),n=ht(n,0,1),0===e)this.r=this.g=this.b=n;else{const i=n<=.5?n*(1+e):n+e-n*e,r=2*n-i;this.r=Qe(r,i,t+1/3),this.g=Qe(r,i,t),this.b=Qe(r,i,t-1/3)}return this}setStyle(t){function e(e){void 0!==e&&parseFloat(e)<1&&console.warn("THREE.Color: Alpha component of "+t+" will be ignored.")}let n;if(n=/^((?:rgb|hsl)a?)\(([^\)]*)\)/.exec(t)){let t;const i=n[1],r=n[2];switch(i){case"rgb":case"rgba":if(t=/^\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec(r))return this.r=Math.min(255,parseInt(t[1],10))/255,this.g=Math.min(255,parseInt(t[2],10))/255,this.b=Math.min(255,parseInt(t[3],10))/255,e(t[4]),this;if(t=/^\s*(\d+)\%\s*,\s*(\d+)\%\s*,\s*(\d+)\%\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec(r))return this.r=Math.min(100,parseInt(t[1],10))/100,this.g=Math.min(100,parseInt(t[2],10))/100,this.b=Math.min(100,parseInt(t[3],10))/100,e(t[4]),this;break;case"hsl":case"hsla":if(t=/^\s*(\d*\.?\d+)\s*,\s*(\d+)\%\s*,\s*(\d+)\%\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec(r)){const n=parseFloat(t[1])/360,i=parseInt(t[2],10)/100,r=parseInt(t[3],10)/100;return e(t[4]),this.setHSL(n,i,r)}}}else if(n=/^\#([A-Fa-f\d]+)$/.exec(t)){const t=n[1],e=t.length;if(3===e)return this.r=parseInt(t.charAt(0)+t.charAt(0),16)/255,this.g=parseInt(t.charAt(1)+t.charAt(1),16)/255,this.b=parseInt(t.charAt(2)+t.charAt(2),16)/255,this;if(6===e)return this.r=parseInt(t.charAt(0)+t.charAt(1),16)/255,this.g=parseInt(t.charAt(2)+t.charAt(3),16)/255,this.b=parseInt(t.charAt(4)+t.charAt(5),16)/255,this}return t&&t.length>0?this.setColorName(t):this}setColorName(t){const e=Ye[t.toLowerCase()];return void 0!==e?this.setHex(e):console.warn("THREE.Color: Unknown color "+t),this}clone(){return new this.constructor(this.r,this.g,this.b)}copy(t){return this.r=t.r,this.g=t.g,this.b=t.b,this}copyGammaToLinear(t,e=2){return this.r=Math.pow(t.r,e),this.g=Math.pow(t.g,e),this.b=Math.pow(t.b,e),this}copyLinearToGamma(t,e=2){const n=e>0?1/e:1;return this.r=Math.pow(t.r,n),this.g=Math.pow(t.g,n),this.b=Math.pow(t.b,n),this}convertGammaToLinear(t){return this.copyGammaToLinear(this,t),this}convertLinearToGamma(t){return this.copyLinearToGamma(this,t),this}copySRGBToLinear(t){return this.r=Ke(t.r),this.g=Ke(t.g),this.b=Ke(t.b),this}copyLinearToSRGB(t){return this.r=$e(t.r),this.g=$e(t.g),this.b=$e(t.b),this}convertSRGBToLinear(){return this.copySRGBToLinear(this),this}convertLinearToSRGB(){return this.copyLinearToSRGB(this),this}getHex(){return 255*this.r<<16^255*this.g<<8^255*this.b<<0}getHexString(){return("000000"+this.getHex().toString(16)).slice(-6)}getHSL(t){void 0===t&&(console.warn("THREE.Color: .getHSL() target is now required"),t={h:0,s:0,l:0});const e=this.r,n=this.g,i=this.b,r=Math.max(e,n,i),s=Math.min(e,n,i);let a,o;const l=(s+r)/2;if(s===r)a=0,o=0;else{const t=r-s;switch(o=l<=.5?t/(r+s):t/(2-r-s),r){case e:a=(n-i)/t+(ne&&(e=t[n]);return e}const vn={Int8Array:Int8Array,Uint8Array:Uint8Array,Uint8ClampedArray:Uint8ClampedArray,Int16Array:Int16Array,Uint16Array:Uint16Array,Int32Array:Int32Array,Uint32Array:Uint32Array,Float32Array:Float32Array,Float64Array:Float64Array};function yn(t,e){return new vn[t](e)}let xn=0;const _n=new se,wn=new Ce,bn=new Lt,Mn=new Pt,Sn=new Pt,Tn=new Lt;class En extends rt{constructor(){super(),Object.defineProperty(this,"id",{value:xn++}),this.uuid=ct(),this.name="",this.type="BufferGeometry",this.index=null,this.attributes={},this.morphAttributes={},this.morphTargetsRelative=!1,this.groups=[],this.boundingBox=null,this.boundingSphere=null,this.drawRange={start:0,count:1/0},this.userData={}}getIndex(){return this.index}setIndex(t){return Array.isArray(t)?this.index=new(gn(t)>65535?dn:hn)(t,1):this.index=t,this}getAttribute(t){return this.attributes[t]}setAttribute(t,e){return this.attributes[t]=e,this}deleteAttribute(t){return delete this.attributes[t],this}hasAttribute(t){return void 0!==this.attributes[t]}addGroup(t,e,n=0){this.groups.push({start:t,count:e,materialIndex:n})}clearGroups(){this.groups=[]}setDrawRange(t,e){this.drawRange.start=t,this.drawRange.count=e}applyMatrix4(t){const e=this.attributes.position;void 0!==e&&(e.applyMatrix4(t),e.needsUpdate=!0);const n=this.attributes.normal;if(void 0!==n){const e=(new yt).getNormalMatrix(t);n.applyNormalMatrix(e),n.needsUpdate=!0}const i=this.attributes.tangent;return void 0!==i&&(i.transformDirection(t),i.needsUpdate=!0),null!==this.boundingBox&&this.computeBoundingBox(),null!==this.boundingSphere&&this.computeBoundingSphere(),this}rotateX(t){return _n.makeRotationX(t),this.applyMatrix4(_n),this}rotateY(t){return _n.makeRotationY(t),this.applyMatrix4(_n),this}rotateZ(t){return _n.makeRotationZ(t),this.applyMatrix4(_n),this}translate(t,e,n){return _n.makeTranslation(t,e,n),this.applyMatrix4(_n),this}scale(t,e,n){return _n.makeScale(t,e,n),this.applyMatrix4(_n),this}lookAt(t){return wn.lookAt(t),wn.updateMatrix(),this.applyMatrix4(wn.matrix),this}center(){return this.computeBoundingBox(),this.boundingBox.getCenter(bn).negate(),this.translate(bn.x,bn.y,bn.z),this}setFromPoints(t){const e=[];for(let n=0,i=t.length;n0&&(t.userData=this.userData),void 0!==this.parameters){const e=this.parameters;for(const n in e)void 0!==e[n]&&(t[n]=e[n]);return t}t.data={attributes:{}};const e=this.index;null!==e&&(t.data.index={type:e.array.constructor.name,array:Array.prototype.slice.call(e.array)});const n=this.attributes;for(const e in n){const i=n[e];t.data.attributes[e]=i.toJSON(t.data)}const i={};let r=!1;for(const e in this.morphAttributes){const n=this.morphAttributes[e],s=[];for(let e=0,i=n.length;e0&&(i[e]=s,r=!0)}r&&(t.data.morphAttributes=i,t.data.morphTargetsRelative=this.morphTargetsRelative);const s=this.groups;s.length>0&&(t.data.groups=JSON.parse(JSON.stringify(s)));const a=this.boundingSphere;return null!==a&&(t.data.boundingSphere={center:a.center.toArray(),radius:a.radius}),t}clone(){return(new En).copy(this)}copy(t){this.index=null,this.attributes={},this.morphAttributes={},this.groups=[],this.boundingBox=null,this.boundingSphere=null;const e={};this.name=t.name;const n=t.index;null!==n&&this.setIndex(n.clone(e));const i=t.attributes;for(const t in i){const n=i[t];this.setAttribute(t,n.clone(e))}const r=t.morphAttributes;for(const t in r){const n=[],i=r[t];for(let t=0,r=i.length;t0){const t=e[n[0]];if(void 0!==t){this.morphTargetInfluences=[],this.morphTargetDictionary={};for(let e=0,n=t.length;e0&&console.error("THREE.Mesh.updateMorphTargets() no longer supports THREE.Geometry. Use THREE.BufferGeometry instead.")}}raycast(t,e){const n=this.geometry,i=this.material,r=this.matrixWorld;if(void 0===i)return;if(null===n.boundingSphere&&n.computeBoundingSphere(),Rn.copy(n.boundingSphere),Rn.applyMatrix4(r),!1===t.ray.intersectsSphere(Rn))return;if(An.copy(r).invert(),Ln.copy(t.ray).applyMatrix4(An),null!==n.boundingBox&&!1===Ln.intersectsBox(n.boundingBox))return;let s;if(n.isBufferGeometry){const r=n.index,a=n.attributes.position,o=n.morphAttributes.position,l=n.morphTargetsRelative,c=n.attributes.uv,h=n.attributes.uv2,u=n.groups,d=n.drawRange;if(null!==r)if(Array.isArray(i))for(let n=0,p=u.length;nn.far?null:{distance:c,point:Vn.clone(),object:t}}(t,e,n,i,Cn,Pn,Dn,kn);if(p){o&&(Hn.fromBufferAttribute(o,c),Gn.fromBufferAttribute(o,h),Un.fromBufferAttribute(o,u),p.uv=je.getUV(kn,Cn,Pn,Dn,Hn,Gn,Un,new vt)),l&&(Hn.fromBufferAttribute(l,c),Gn.fromBufferAttribute(l,h),Un.fromBufferAttribute(l,u),p.uv2=je.getUV(kn,Cn,Pn,Dn,Hn,Gn,Un,new vt));const t={a:c,b:h,c:u,normal:new Lt,materialIndex:0};je.getNormal(Cn,Pn,Dn,t.normal),p.face=t}return p}Wn.prototype.isMesh=!0;class qn extends En{constructor(t=1,e=1,n=1,i=1,r=1,s=1){super(),this.type="BoxGeometry",this.parameters={width:t,height:e,depth:n,widthSegments:i,heightSegments:r,depthSegments:s};const a=this;i=Math.floor(i),r=Math.floor(r),s=Math.floor(s);const o=[],l=[],c=[],h=[];let u=0,d=0;function p(t,e,n,i,r,s,p,m,f,g,v){const y=s/f,x=p/g,_=s/2,w=p/2,b=m/2,M=f+1,S=g+1;let T=0,E=0;const A=new Lt;for(let s=0;s0?1:-1,c.push(A.x,A.y,A.z),h.push(o/f),h.push(1-s/g),T+=1}}for(let t=0;t0&&(e.defines=this.defines),e.vertexShader=this.vertexShader,e.fragmentShader=this.fragmentShader;const n={};for(const t in this.extensions)!0===this.extensions[t]&&(n[t]=!0);return Object.keys(n).length>0&&(e.extensions=n),e}}Jn.prototype.isShaderMaterial=!0;class Qn extends Ce{constructor(){super(),this.type="Camera",this.matrixWorldInverse=new se,this.projectionMatrix=new se,this.projectionMatrixInverse=new se}copy(t,e){return super.copy(t,e),this.matrixWorldInverse.copy(t.matrixWorldInverse),this.projectionMatrix.copy(t.projectionMatrix),this.projectionMatrixInverse.copy(t.projectionMatrixInverse),this}getWorldDirection(t){void 0===t&&(console.warn("THREE.Camera: .getWorldDirection() target is now required"),t=new Lt),this.updateWorldMatrix(!0,!1);const e=this.matrixWorld.elements;return t.set(-e[8],-e[9],-e[10]).normalize()}updateMatrixWorld(t){super.updateMatrixWorld(t),this.matrixWorldInverse.copy(this.matrixWorld).invert()}updateWorldMatrix(t,e){super.updateWorldMatrix(t,e),this.matrixWorldInverse.copy(this.matrixWorld).invert()}clone(){return(new this.constructor).copy(this)}}Qn.prototype.isCamera=!0;class Kn extends Qn{constructor(t=50,e=1,n=.1,i=2e3){super(),this.type="PerspectiveCamera",this.fov=t,this.zoom=1,this.near=n,this.far=i,this.focus=10,this.aspect=e,this.view=null,this.filmGauge=35,this.filmOffset=0,this.updateProjectionMatrix()}copy(t,e){return super.copy(t,e),this.fov=t.fov,this.zoom=t.zoom,this.near=t.near,this.far=t.far,this.focus=t.focus,this.aspect=t.aspect,this.view=null===t.view?null:Object.assign({},t.view),this.filmGauge=t.filmGauge,this.filmOffset=t.filmOffset,this}setFocalLength(t){const e=.5*this.getFilmHeight()/t;this.fov=2*lt*Math.atan(e),this.updateProjectionMatrix()}getFocalLength(){const t=Math.tan(.5*ot*this.fov);return.5*this.getFilmHeight()/t}getEffectiveFOV(){return 2*lt*Math.atan(Math.tan(.5*ot*this.fov)/this.zoom)}getFilmWidth(){return this.filmGauge*Math.min(this.aspect,1)}getFilmHeight(){return this.filmGauge/Math.max(this.aspect,1)}setViewOffset(t,e,n,i,r,s){this.aspect=t/e,null===this.view&&(this.view={enabled:!0,fullWidth:1,fullHeight:1,offsetX:0,offsetY:0,width:1,height:1}),this.view.enabled=!0,this.view.fullWidth=t,this.view.fullHeight=e,this.view.offsetX=n,this.view.offsetY=i,this.view.width=r,this.view.height=s,this.updateProjectionMatrix()}clearViewOffset(){null!==this.view&&(this.view.enabled=!1),this.updateProjectionMatrix()}updateProjectionMatrix(){const t=this.near;let e=t*Math.tan(.5*ot*this.fov)/this.zoom,n=2*e,i=this.aspect*n,r=-.5*i;const s=this.view;if(null!==this.view&&this.view.enabled){const t=s.fullWidth,a=s.fullHeight;r+=s.offsetX*i/t,e-=s.offsetY*n/a,i*=s.width/t,n*=s.height/a}const a=this.filmOffset;0!==a&&(r+=t*a/this.getFilmWidth()),this.projectionMatrix.makePerspective(r,r+i,e,e-n,t,this.far),this.projectionMatrixInverse.copy(this.projectionMatrix).invert()}toJSON(t){const e=super.toJSON(t);return e.object.fov=this.fov,e.object.zoom=this.zoom,e.object.near=this.near,e.object.far=this.far,e.object.focus=this.focus,e.object.aspect=this.aspect,null!==this.view&&(e.object.view=Object.assign({},this.view)),e.object.filmGauge=this.filmGauge,e.object.filmOffset=this.filmOffset,e}}Kn.prototype.isPerspectiveCamera=!0;const $n=90;class ti extends Ce{constructor(t,e,n){if(super(),this.type="CubeCamera",!0!==n.isWebGLCubeRenderTarget)return void console.error("THREE.CubeCamera: The constructor now expects an instance of WebGLCubeRenderTarget as third parameter.");this.renderTarget=n;const i=new Kn($n,1,t,e);i.layers=this.layers,i.up.set(0,-1,0),i.lookAt(new Lt(1,0,0)),this.add(i);const r=new Kn($n,1,t,e);r.layers=this.layers,r.up.set(0,-1,0),r.lookAt(new Lt(-1,0,0)),this.add(r);const s=new Kn($n,1,t,e);s.layers=this.layers,s.up.set(0,0,1),s.lookAt(new Lt(0,1,0)),this.add(s);const a=new Kn($n,1,t,e);a.layers=this.layers,a.up.set(0,0,-1),a.lookAt(new Lt(0,-1,0)),this.add(a);const o=new Kn($n,1,t,e);o.layers=this.layers,o.up.set(0,-1,0),o.lookAt(new Lt(0,0,1)),this.add(o);const l=new Kn($n,1,t,e);l.layers=this.layers,l.up.set(0,-1,0),l.lookAt(new Lt(0,0,-1)),this.add(l)}update(t,e){null===this.parent&&this.updateMatrixWorld();const n=this.renderTarget,[i,r,s,a,o,l]=this.children,c=t.xr.enabled,h=t.getRenderTarget();t.xr.enabled=!1;const u=n.texture.generateMipmaps;n.texture.generateMipmaps=!1,t.setRenderTarget(n,0),t.render(e,i),t.setRenderTarget(n,1),t.render(e,r),t.setRenderTarget(n,2),t.render(e,s),t.setRenderTarget(n,3),t.render(e,a),t.setRenderTarget(n,4),t.render(e,o),n.texture.generateMipmaps=u,t.setRenderTarget(n,5),t.render(e,l),t.setRenderTarget(h),t.xr.enabled=c}}class ei extends bt{constructor(t,e,n,i,s,a,o,l,c,h){super(t=void 0!==t?t:[],e=void 0!==e?e:r,n,i,s,a,o=void 0!==o?o:T,l,c,h),this._needsFlipEnvMap=!0,this.flipY=!1}get images(){return this.image}set images(t){this.image=t}}ei.prototype.isCubeTexture=!0;class ni extends Tt{constructor(t,e,n){Number.isInteger(e)&&(console.warn("THREE.WebGLCubeRenderTarget: constructor signature is now WebGLCubeRenderTarget( size, options )"),e=n),super(t,t,e),e=e||{},this.texture=new ei(void 0,e.mapping,e.wrapS,e.wrapT,e.magFilter,e.minFilter,e.format,e.type,e.anisotropy,e.encoding),this.texture.generateMipmaps=void 0!==e.generateMipmaps&&e.generateMipmaps,this.texture.minFilter=void 0!==e.minFilter?e.minFilter:g,this.texture._needsFlipEnvMap=!1}fromEquirectangularTexture(t,e){this.texture.type=e.type,this.texture.format=E,this.texture.encoding=e.encoding,this.texture.generateMipmaps=e.generateMipmaps,this.texture.minFilter=e.minFilter,this.texture.magFilter=e.magFilter;const n={uniforms:{tEquirect:{value:null}},vertexShader:"\n\n\t\t\t\tvarying vec3 vWorldDirection;\n\n\t\t\t\tvec3 transformDirection( in vec3 dir, in mat4 matrix ) {\n\n\t\t\t\t\treturn normalize( ( matrix * vec4( dir, 0.0 ) ).xyz );\n\n\t\t\t\t}\n\n\t\t\t\tvoid main() {\n\n\t\t\t\t\tvWorldDirection = transformDirection( position, modelMatrix );\n\n\t\t\t\t\t#include \n\t\t\t\t\t#include \n\n\t\t\t\t}\n\t\t\t",fragmentShader:"\n\n\t\t\t\tuniform sampler2D tEquirect;\n\n\t\t\t\tvarying vec3 vWorldDirection;\n\n\t\t\t\t#include \n\n\t\t\t\tvoid main() {\n\n\t\t\t\t\tvec3 direction = normalize( vWorldDirection );\n\n\t\t\t\t\tvec2 sampleUV = equirectUv( direction );\n\n\t\t\t\t\tgl_FragColor = texture2D( tEquirect, sampleUV );\n\n\t\t\t\t}\n\t\t\t"},i=new qn(5,5,5),r=new Jn({name:"CubemapFromEquirect",uniforms:Xn(n.uniforms),vertexShader:n.vertexShader,fragmentShader:n.fragmentShader,side:1,blending:0});r.uniforms.tEquirect.value=e;const s=new Wn(i,r),a=e.minFilter;e.minFilter===y&&(e.minFilter=g);return new ti(1,10,this).update(t,s),e.minFilter=a,s.geometry.dispose(),s.material.dispose(),this}clear(t,e,n,i){const r=t.getRenderTarget();for(let r=0;r<6;r++)t.setRenderTarget(this,r),t.clear(e,n,i);t.setRenderTarget(r)}}ni.prototype.isWebGLCubeRenderTarget=!0;class ii extends bt{constructor(t,e,n,i,r,s,a,o,l,c,h,u){super(null,s,a,o,l,c,i,r,h,u),this.image={data:t||null,width:e||1,height:n||1},this.magFilter=void 0!==l?l:p,this.minFilter=void 0!==c?c:p,this.generateMipmaps=!1,this.flipY=!1,this.unpackAlignment=1,this.needsUpdate=!0}}ii.prototype.isDataTexture=!0;const ri=new Jt,si=new Lt;class ai{constructor(t=new Ne,e=new Ne,n=new Ne,i=new Ne,r=new Ne,s=new Ne){this.planes=[t,e,n,i,r,s]}set(t,e,n,i,r,s){const a=this.planes;return a[0].copy(t),a[1].copy(e),a[2].copy(n),a[3].copy(i),a[4].copy(r),a[5].copy(s),this}copy(t){const e=this.planes;for(let n=0;n<6;n++)e[n].copy(t.planes[n]);return this}setFromProjectionMatrix(t){const e=this.planes,n=t.elements,i=n[0],r=n[1],s=n[2],a=n[3],o=n[4],l=n[5],c=n[6],h=n[7],u=n[8],d=n[9],p=n[10],m=n[11],f=n[12],g=n[13],v=n[14],y=n[15];return e[0].setComponents(a-i,h-o,m-u,y-f).normalize(),e[1].setComponents(a+i,h+o,m+u,y+f).normalize(),e[2].setComponents(a+r,h+l,m+d,y+g).normalize(),e[3].setComponents(a-r,h-l,m-d,y-g).normalize(),e[4].setComponents(a-s,h-c,m-p,y-v).normalize(),e[5].setComponents(a+s,h+c,m+p,y+v).normalize(),this}intersectsObject(t){const e=t.geometry;return null===e.boundingSphere&&e.computeBoundingSphere(),ri.copy(e.boundingSphere).applyMatrix4(t.matrixWorld),this.intersectsSphere(ri)}intersectsSprite(t){return ri.center.set(0,0,0),ri.radius=.7071067811865476,ri.applyMatrix4(t.matrixWorld),this.intersectsSphere(ri)}intersectsSphere(t){const e=this.planes,n=t.center,i=-t.radius;for(let t=0;t<6;t++){if(e[t].distanceToPoint(n)0?t.max.x:t.min.x,si.y=i.normal.y>0?t.max.y:t.min.y,si.z=i.normal.z>0?t.max.z:t.min.z,i.distanceToPoint(si)<0)return!1}return!0}containsPoint(t){const e=this.planes;for(let n=0;n<6;n++)if(e[n].distanceToPoint(t)<0)return!1;return!0}clone(){return(new this.constructor).copy(this)}}function oi(){let t=null,e=!1,n=null,i=null;function r(e,s){n(e,s),i=t.requestAnimationFrame(r)}return{start:function(){!0!==e&&null!==n&&(i=t.requestAnimationFrame(r),e=!0)},stop:function(){t.cancelAnimationFrame(i),e=!1},setAnimationLoop:function(t){n=t},setContext:function(e){t=e}}}function li(t,e){const n=e.isWebGL2,i=new WeakMap;return{get:function(t){return t.isInterleavedBufferAttribute&&(t=t.data),i.get(t)},remove:function(e){e.isInterleavedBufferAttribute&&(e=e.data);const n=i.get(e);n&&(t.deleteBuffer(n.buffer),i.delete(e))},update:function(e,r){if(e.isGLBufferAttribute){const t=i.get(e);return void((!t||t.version 0.0 ) {\n\t\tdistanceFalloff *= pow2( saturate( 1.0 - pow4( lightDistance / cutoffDistance ) ) );\n\t}\n\treturn distanceFalloff;\n#else\n\tif( cutoffDistance > 0.0 && decayExponent > 0.0 ) {\n\t\treturn pow( saturate( -lightDistance / cutoffDistance + 1.0 ), decayExponent );\n\t}\n\treturn 1.0;\n#endif\n}\nvec3 BRDF_Diffuse_Lambert( const in vec3 diffuseColor ) {\n\treturn RECIPROCAL_PI * diffuseColor;\n}\nvec3 F_Schlick( const in vec3 specularColor, const in float dotLH ) {\n\tfloat fresnel = exp2( ( -5.55473 * dotLH - 6.98316 ) * dotLH );\n\treturn ( 1.0 - specularColor ) * fresnel + specularColor;\n}\nvec3 F_Schlick_RoughnessDependent( const in vec3 F0, const in float dotNV, const in float roughness ) {\n\tfloat fresnel = exp2( ( -5.55473 * dotNV - 6.98316 ) * dotNV );\n\tvec3 Fr = max( vec3( 1.0 - roughness ), F0 ) - F0;\n\treturn Fr * fresnel + F0;\n}\nfloat G_GGX_Smith( const in float alpha, const in float dotNL, const in float dotNV ) {\n\tfloat a2 = pow2( alpha );\n\tfloat gl = dotNL + sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNL ) );\n\tfloat gv = dotNV + sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNV ) );\n\treturn 1.0 / ( gl * gv );\n}\nfloat G_GGX_SmithCorrelated( const in float alpha, const in float dotNL, const in float dotNV ) {\n\tfloat a2 = pow2( alpha );\n\tfloat gv = dotNL * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNV ) );\n\tfloat gl = dotNV * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNL ) );\n\treturn 0.5 / max( gv + gl, EPSILON );\n}\nfloat D_GGX( const in float alpha, const in float dotNH ) {\n\tfloat a2 = pow2( alpha );\n\tfloat denom = pow2( dotNH ) * ( a2 - 1.0 ) + 1.0;\n\treturn RECIPROCAL_PI * a2 / pow2( denom );\n}\nvec3 BRDF_Specular_GGX( const in IncidentLight incidentLight, const in vec3 viewDir, const in vec3 normal, const in vec3 specularColor, const in float roughness ) {\n\tfloat alpha = pow2( roughness );\n\tvec3 halfDir = normalize( incidentLight.direction + viewDir );\n\tfloat dotNL = saturate( dot( normal, incidentLight.direction ) );\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tfloat dotNH = saturate( dot( normal, halfDir ) );\n\tfloat dotLH = saturate( dot( incidentLight.direction, halfDir ) );\n\tvec3 F = F_Schlick( specularColor, dotLH );\n\tfloat G = G_GGX_SmithCorrelated( alpha, dotNL, dotNV );\n\tfloat D = D_GGX( alpha, dotNH );\n\treturn F * ( G * D );\n}\nvec2 LTC_Uv( const in vec3 N, const in vec3 V, const in float roughness ) {\n\tconst float LUT_SIZE = 64.0;\n\tconst float LUT_SCALE = ( LUT_SIZE - 1.0 ) / LUT_SIZE;\n\tconst float LUT_BIAS = 0.5 / LUT_SIZE;\n\tfloat dotNV = saturate( dot( N, V ) );\n\tvec2 uv = vec2( roughness, sqrt( 1.0 - dotNV ) );\n\tuv = uv * LUT_SCALE + LUT_BIAS;\n\treturn uv;\n}\nfloat LTC_ClippedSphereFormFactor( const in vec3 f ) {\n\tfloat l = length( f );\n\treturn max( ( l * l + f.z ) / ( l + 1.0 ), 0.0 );\n}\nvec3 LTC_EdgeVectorFormFactor( const in vec3 v1, const in vec3 v2 ) {\n\tfloat x = dot( v1, v2 );\n\tfloat y = abs( x );\n\tfloat a = 0.8543985 + ( 0.4965155 + 0.0145206 * y ) * y;\n\tfloat b = 3.4175940 + ( 4.1616724 + y ) * y;\n\tfloat v = a / b;\n\tfloat theta_sintheta = ( x > 0.0 ) ? v : 0.5 * inversesqrt( max( 1.0 - x * x, 1e-7 ) ) - v;\n\treturn cross( v1, v2 ) * theta_sintheta;\n}\nvec3 LTC_Evaluate( const in vec3 N, const in vec3 V, const in vec3 P, const in mat3 mInv, const in vec3 rectCoords[ 4 ] ) {\n\tvec3 v1 = rectCoords[ 1 ] - rectCoords[ 0 ];\n\tvec3 v2 = rectCoords[ 3 ] - rectCoords[ 0 ];\n\tvec3 lightNormal = cross( v1, v2 );\n\tif( dot( lightNormal, P - rectCoords[ 0 ] ) < 0.0 ) return vec3( 0.0 );\n\tvec3 T1, T2;\n\tT1 = normalize( V - N * dot( V, N ) );\n\tT2 = - cross( N, T1 );\n\tmat3 mat = mInv * transposeMat3( mat3( T1, T2, N ) );\n\tvec3 coords[ 4 ];\n\tcoords[ 0 ] = mat * ( rectCoords[ 0 ] - P );\n\tcoords[ 1 ] = mat * ( rectCoords[ 1 ] - P );\n\tcoords[ 2 ] = mat * ( rectCoords[ 2 ] - P );\n\tcoords[ 3 ] = mat * ( rectCoords[ 3 ] - P );\n\tcoords[ 0 ] = normalize( coords[ 0 ] );\n\tcoords[ 1 ] = normalize( coords[ 1 ] );\n\tcoords[ 2 ] = normalize( coords[ 2 ] );\n\tcoords[ 3 ] = normalize( coords[ 3 ] );\n\tvec3 vectorFormFactor = vec3( 0.0 );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 0 ], coords[ 1 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 1 ], coords[ 2 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 2 ], coords[ 3 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 3 ], coords[ 0 ] );\n\tfloat result = LTC_ClippedSphereFormFactor( vectorFormFactor );\n\treturn vec3( result );\n}\nvec3 BRDF_Specular_GGX_Environment( const in vec3 viewDir, const in vec3 normal, const in vec3 specularColor, const in float roughness ) {\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tvec2 brdf = integrateSpecularBRDF( dotNV, roughness );\n\treturn specularColor * brdf.x + brdf.y;\n}\nvoid BRDF_Specular_Multiscattering_Environment( const in GeometricContext geometry, const in vec3 specularColor, const in float roughness, inout vec3 singleScatter, inout vec3 multiScatter ) {\n\tfloat dotNV = saturate( dot( geometry.normal, geometry.viewDir ) );\n\tvec3 F = F_Schlick_RoughnessDependent( specularColor, dotNV, roughness );\n\tvec2 brdf = integrateSpecularBRDF( dotNV, roughness );\n\tvec3 FssEss = F * brdf.x + brdf.y;\n\tfloat Ess = brdf.x + brdf.y;\n\tfloat Ems = 1.0 - Ess;\n\tvec3 Favg = specularColor + ( 1.0 - specularColor ) * 0.047619;\tvec3 Fms = FssEss * Favg / ( 1.0 - Ems * Favg );\n\tsingleScatter += FssEss;\n\tmultiScatter += Fms * Ems;\n}\nfloat G_BlinnPhong_Implicit( ) {\n\treturn 0.25;\n}\nfloat D_BlinnPhong( const in float shininess, const in float dotNH ) {\n\treturn RECIPROCAL_PI * ( shininess * 0.5 + 1.0 ) * pow( dotNH, shininess );\n}\nvec3 BRDF_Specular_BlinnPhong( const in IncidentLight incidentLight, const in GeometricContext geometry, const in vec3 specularColor, const in float shininess ) {\n\tvec3 halfDir = normalize( incidentLight.direction + geometry.viewDir );\n\tfloat dotNH = saturate( dot( geometry.normal, halfDir ) );\n\tfloat dotLH = saturate( dot( incidentLight.direction, halfDir ) );\n\tvec3 F = F_Schlick( specularColor, dotLH );\n\tfloat G = G_BlinnPhong_Implicit( );\n\tfloat D = D_BlinnPhong( shininess, dotNH );\n\treturn F * ( G * D );\n}\nfloat GGXRoughnessToBlinnExponent( const in float ggxRoughness ) {\n\treturn ( 2.0 / pow2( ggxRoughness + 0.0001 ) - 2.0 );\n}\nfloat BlinnExponentToGGXRoughness( const in float blinnExponent ) {\n\treturn sqrt( 2.0 / ( blinnExponent + 2.0 ) );\n}\n#if defined( USE_SHEEN )\nfloat D_Charlie(float roughness, float NoH) {\n\tfloat invAlpha = 1.0 / roughness;\n\tfloat cos2h = NoH * NoH;\n\tfloat sin2h = max(1.0 - cos2h, 0.0078125);\treturn (2.0 + invAlpha) * pow(sin2h, invAlpha * 0.5) / (2.0 * PI);\n}\nfloat V_Neubelt(float NoV, float NoL) {\n\treturn saturate(1.0 / (4.0 * (NoL + NoV - NoL * NoV)));\n}\nvec3 BRDF_Specular_Sheen( const in float roughness, const in vec3 L, const in GeometricContext geometry, vec3 specularColor ) {\n\tvec3 N = geometry.normal;\n\tvec3 V = geometry.viewDir;\n\tvec3 H = normalize( V + L );\n\tfloat dotNH = saturate( dot( N, H ) );\n\treturn specularColor * D_Charlie( roughness, dotNH ) * V_Neubelt( dot(N, V), dot(N, L) );\n}\n#endif",bumpmap_pars_fragment:"#ifdef USE_BUMPMAP\n\tuniform sampler2D bumpMap;\n\tuniform float bumpScale;\n\tvec2 dHdxy_fwd() {\n\t\tvec2 dSTdx = dFdx( vUv );\n\t\tvec2 dSTdy = dFdy( vUv );\n\t\tfloat Hll = bumpScale * texture2D( bumpMap, vUv ).x;\n\t\tfloat dBx = bumpScale * texture2D( bumpMap, vUv + dSTdx ).x - Hll;\n\t\tfloat dBy = bumpScale * texture2D( bumpMap, vUv + dSTdy ).x - Hll;\n\t\treturn vec2( dBx, dBy );\n\t}\n\tvec3 perturbNormalArb( vec3 surf_pos, vec3 surf_norm, vec2 dHdxy, float faceDirection ) {\n\t\tvec3 vSigmaX = vec3( dFdx( surf_pos.x ), dFdx( surf_pos.y ), dFdx( surf_pos.z ) );\n\t\tvec3 vSigmaY = vec3( dFdy( surf_pos.x ), dFdy( surf_pos.y ), dFdy( surf_pos.z ) );\n\t\tvec3 vN = surf_norm;\n\t\tvec3 R1 = cross( vSigmaY, vN );\n\t\tvec3 R2 = cross( vN, vSigmaX );\n\t\tfloat fDet = dot( vSigmaX, R1 ) * faceDirection;\n\t\tvec3 vGrad = sign( fDet ) * ( dHdxy.x * R1 + dHdxy.y * R2 );\n\t\treturn normalize( abs( fDet ) * surf_norm - vGrad );\n\t}\n#endif",clipping_planes_fragment:"#if NUM_CLIPPING_PLANES > 0\n\tvec4 plane;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < UNION_CLIPPING_PLANES; i ++ ) {\n\t\tplane = clippingPlanes[ i ];\n\t\tif ( dot( vClipPosition, plane.xyz ) > plane.w ) discard;\n\t}\n\t#pragma unroll_loop_end\n\t#if UNION_CLIPPING_PLANES < NUM_CLIPPING_PLANES\n\t\tbool clipped = true;\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = UNION_CLIPPING_PLANES; i < NUM_CLIPPING_PLANES; i ++ ) {\n\t\t\tplane = clippingPlanes[ i ];\n\t\t\tclipped = ( dot( vClipPosition, plane.xyz ) > plane.w ) && clipped;\n\t\t}\n\t\t#pragma unroll_loop_end\n\t\tif ( clipped ) discard;\n\t#endif\n#endif",clipping_planes_pars_fragment:"#if NUM_CLIPPING_PLANES > 0\n\tvarying vec3 vClipPosition;\n\tuniform vec4 clippingPlanes[ NUM_CLIPPING_PLANES ];\n#endif",clipping_planes_pars_vertex:"#if NUM_CLIPPING_PLANES > 0\n\tvarying vec3 vClipPosition;\n#endif",clipping_planes_vertex:"#if NUM_CLIPPING_PLANES > 0\n\tvClipPosition = - mvPosition.xyz;\n#endif",color_fragment:"#if defined( USE_COLOR_ALPHA )\n\tdiffuseColor *= vColor;\n#elif defined( USE_COLOR )\n\tdiffuseColor.rgb *= vColor;\n#endif",color_pars_fragment:"#if defined( USE_COLOR_ALPHA )\n\tvarying vec4 vColor;\n#elif defined( USE_COLOR )\n\tvarying vec3 vColor;\n#endif",color_pars_vertex:"#if defined( USE_COLOR_ALPHA )\n\tvarying vec4 vColor;\n#elif defined( USE_COLOR ) || defined( USE_INSTANCING_COLOR )\n\tvarying vec3 vColor;\n#endif",color_vertex:"#if defined( USE_COLOR_ALPHA )\n\tvColor = vec4( 1.0 );\n#elif defined( USE_COLOR ) || defined( USE_INSTANCING_COLOR )\n\tvColor = vec3( 1.0 );\n#endif\n#ifdef USE_COLOR\n\tvColor *= color;\n#endif\n#ifdef USE_INSTANCING_COLOR\n\tvColor.xyz *= instanceColor.xyz;\n#endif",common:"#define PI 3.141592653589793\n#define PI2 6.283185307179586\n#define PI_HALF 1.5707963267948966\n#define RECIPROCAL_PI 0.3183098861837907\n#define RECIPROCAL_PI2 0.15915494309189535\n#define EPSILON 1e-6\n#ifndef saturate\n#define saturate(a) clamp( a, 0.0, 1.0 )\n#endif\n#define whiteComplement(a) ( 1.0 - saturate( a ) )\nfloat pow2( const in float x ) { return x*x; }\nfloat pow3( const in float x ) { return x*x*x; }\nfloat pow4( const in float x ) { float x2 = x*x; return x2*x2; }\nfloat average( const in vec3 color ) { return dot( color, vec3( 0.3333 ) ); }\nhighp float rand( const in vec2 uv ) {\n\tconst highp float a = 12.9898, b = 78.233, c = 43758.5453;\n\thighp float dt = dot( uv.xy, vec2( a,b ) ), sn = mod( dt, PI );\n\treturn fract(sin(sn) * c);\n}\n#ifdef HIGH_PRECISION\n\tfloat precisionSafeLength( vec3 v ) { return length( v ); }\n#else\n\tfloat max3( vec3 v ) { return max( max( v.x, v.y ), v.z ); }\n\tfloat precisionSafeLength( vec3 v ) {\n\t\tfloat maxComponent = max3( abs( v ) );\n\t\treturn length( v / maxComponent ) * maxComponent;\n\t}\n#endif\nstruct IncidentLight {\n\tvec3 color;\n\tvec3 direction;\n\tbool visible;\n};\nstruct ReflectedLight {\n\tvec3 directDiffuse;\n\tvec3 directSpecular;\n\tvec3 indirectDiffuse;\n\tvec3 indirectSpecular;\n};\nstruct GeometricContext {\n\tvec3 position;\n\tvec3 normal;\n\tvec3 viewDir;\n#ifdef CLEARCOAT\n\tvec3 clearcoatNormal;\n#endif\n};\nvec3 transformDirection( in vec3 dir, in mat4 matrix ) {\n\treturn normalize( ( matrix * vec4( dir, 0.0 ) ).xyz );\n}\nvec3 inverseTransformDirection( in vec3 dir, in mat4 matrix ) {\n\treturn normalize( ( vec4( dir, 0.0 ) * matrix ).xyz );\n}\nvec3 projectOnPlane(in vec3 point, in vec3 pointOnPlane, in vec3 planeNormal ) {\n\tfloat distance = dot( planeNormal, point - pointOnPlane );\n\treturn - distance * planeNormal + point;\n}\nfloat sideOfPlane( in vec3 point, in vec3 pointOnPlane, in vec3 planeNormal ) {\n\treturn sign( dot( point - pointOnPlane, planeNormal ) );\n}\nvec3 linePlaneIntersect( in vec3 pointOnLine, in vec3 lineDirection, in vec3 pointOnPlane, in vec3 planeNormal ) {\n\treturn lineDirection * ( dot( planeNormal, pointOnPlane - pointOnLine ) / dot( planeNormal, lineDirection ) ) + pointOnLine;\n}\nmat3 transposeMat3( const in mat3 m ) {\n\tmat3 tmp;\n\ttmp[ 0 ] = vec3( m[ 0 ].x, m[ 1 ].x, m[ 2 ].x );\n\ttmp[ 1 ] = vec3( m[ 0 ].y, m[ 1 ].y, m[ 2 ].y );\n\ttmp[ 2 ] = vec3( m[ 0 ].z, m[ 1 ].z, m[ 2 ].z );\n\treturn tmp;\n}\nfloat linearToRelativeLuminance( const in vec3 color ) {\n\tvec3 weights = vec3( 0.2126, 0.7152, 0.0722 );\n\treturn dot( weights, color.rgb );\n}\nbool isPerspectiveMatrix( mat4 m ) {\n\treturn m[ 2 ][ 3 ] == - 1.0;\n}\nvec2 equirectUv( in vec3 dir ) {\n\tfloat u = atan( dir.z, dir.x ) * RECIPROCAL_PI2 + 0.5;\n\tfloat v = asin( clamp( dir.y, - 1.0, 1.0 ) ) * RECIPROCAL_PI + 0.5;\n\treturn vec2( u, v );\n}",cube_uv_reflection_fragment:"#ifdef ENVMAP_TYPE_CUBE_UV\n\t#define cubeUV_maxMipLevel 8.0\n\t#define cubeUV_minMipLevel 4.0\n\t#define cubeUV_maxTileSize 256.0\n\t#define cubeUV_minTileSize 16.0\n\tfloat getFace( vec3 direction ) {\n\t\tvec3 absDirection = abs( direction );\n\t\tfloat face = - 1.0;\n\t\tif ( absDirection.x > absDirection.z ) {\n\t\t\tif ( absDirection.x > absDirection.y )\n\t\t\t\tface = direction.x > 0.0 ? 0.0 : 3.0;\n\t\t\telse\n\t\t\t\tface = direction.y > 0.0 ? 1.0 : 4.0;\n\t\t} else {\n\t\t\tif ( absDirection.z > absDirection.y )\n\t\t\t\tface = direction.z > 0.0 ? 2.0 : 5.0;\n\t\t\telse\n\t\t\t\tface = direction.y > 0.0 ? 1.0 : 4.0;\n\t\t}\n\t\treturn face;\n\t}\n\tvec2 getUV( vec3 direction, float face ) {\n\t\tvec2 uv;\n\t\tif ( face == 0.0 ) {\n\t\t\tuv = vec2( direction.z, direction.y ) / abs( direction.x );\n\t\t} else if ( face == 1.0 ) {\n\t\t\tuv = vec2( - direction.x, - direction.z ) / abs( direction.y );\n\t\t} else if ( face == 2.0 ) {\n\t\t\tuv = vec2( - direction.x, direction.y ) / abs( direction.z );\n\t\t} else if ( face == 3.0 ) {\n\t\t\tuv = vec2( - direction.z, direction.y ) / abs( direction.x );\n\t\t} else if ( face == 4.0 ) {\n\t\t\tuv = vec2( - direction.x, direction.z ) / abs( direction.y );\n\t\t} else {\n\t\t\tuv = vec2( direction.x, direction.y ) / abs( direction.z );\n\t\t}\n\t\treturn 0.5 * ( uv + 1.0 );\n\t}\n\tvec3 bilinearCubeUV( sampler2D envMap, vec3 direction, float mipInt ) {\n\t\tfloat face = getFace( direction );\n\t\tfloat filterInt = max( cubeUV_minMipLevel - mipInt, 0.0 );\n\t\tmipInt = max( mipInt, cubeUV_minMipLevel );\n\t\tfloat faceSize = exp2( mipInt );\n\t\tfloat texelSize = 1.0 / ( 3.0 * cubeUV_maxTileSize );\n\t\tvec2 uv = getUV( direction, face ) * ( faceSize - 1.0 );\n\t\tvec2 f = fract( uv );\n\t\tuv += 0.5 - f;\n\t\tif ( face > 2.0 ) {\n\t\t\tuv.y += faceSize;\n\t\t\tface -= 3.0;\n\t\t}\n\t\tuv.x += face * faceSize;\n\t\tif ( mipInt < cubeUV_maxMipLevel ) {\n\t\t\tuv.y += 2.0 * cubeUV_maxTileSize;\n\t\t}\n\t\tuv.y += filterInt * 2.0 * cubeUV_minTileSize;\n\t\tuv.x += 3.0 * max( 0.0, cubeUV_maxTileSize - 2.0 * faceSize );\n\t\tuv *= texelSize;\n\t\tvec3 tl = envMapTexelToLinear( texture2D( envMap, uv ) ).rgb;\n\t\tuv.x += texelSize;\n\t\tvec3 tr = envMapTexelToLinear( texture2D( envMap, uv ) ).rgb;\n\t\tuv.y += texelSize;\n\t\tvec3 br = envMapTexelToLinear( texture2D( envMap, uv ) ).rgb;\n\t\tuv.x -= texelSize;\n\t\tvec3 bl = envMapTexelToLinear( texture2D( envMap, uv ) ).rgb;\n\t\tvec3 tm = mix( tl, tr, f.x );\n\t\tvec3 bm = mix( bl, br, f.x );\n\t\treturn mix( tm, bm, f.y );\n\t}\n\t#define r0 1.0\n\t#define v0 0.339\n\t#define m0 - 2.0\n\t#define r1 0.8\n\t#define v1 0.276\n\t#define m1 - 1.0\n\t#define r4 0.4\n\t#define v4 0.046\n\t#define m4 2.0\n\t#define r5 0.305\n\t#define v5 0.016\n\t#define m5 3.0\n\t#define r6 0.21\n\t#define v6 0.0038\n\t#define m6 4.0\n\tfloat roughnessToMip( float roughness ) {\n\t\tfloat mip = 0.0;\n\t\tif ( roughness >= r1 ) {\n\t\t\tmip = ( r0 - roughness ) * ( m1 - m0 ) / ( r0 - r1 ) + m0;\n\t\t} else if ( roughness >= r4 ) {\n\t\t\tmip = ( r1 - roughness ) * ( m4 - m1 ) / ( r1 - r4 ) + m1;\n\t\t} else if ( roughness >= r5 ) {\n\t\t\tmip = ( r4 - roughness ) * ( m5 - m4 ) / ( r4 - r5 ) + m4;\n\t\t} else if ( roughness >= r6 ) {\n\t\t\tmip = ( r5 - roughness ) * ( m6 - m5 ) / ( r5 - r6 ) + m5;\n\t\t} else {\n\t\t\tmip = - 2.0 * log2( 1.16 * roughness );\t\t}\n\t\treturn mip;\n\t}\n\tvec4 textureCubeUV( sampler2D envMap, vec3 sampleDir, float roughness ) {\n\t\tfloat mip = clamp( roughnessToMip( roughness ), m0, cubeUV_maxMipLevel );\n\t\tfloat mipF = fract( mip );\n\t\tfloat mipInt = floor( mip );\n\t\tvec3 color0 = bilinearCubeUV( envMap, sampleDir, mipInt );\n\t\tif ( mipF == 0.0 ) {\n\t\t\treturn vec4( color0, 1.0 );\n\t\t} else {\n\t\t\tvec3 color1 = bilinearCubeUV( envMap, sampleDir, mipInt + 1.0 );\n\t\t\treturn vec4( mix( color0, color1, mipF ), 1.0 );\n\t\t}\n\t}\n#endif",defaultnormal_vertex:"vec3 transformedNormal = objectNormal;\n#ifdef USE_INSTANCING\n\tmat3 m = mat3( instanceMatrix );\n\ttransformedNormal /= vec3( dot( m[ 0 ], m[ 0 ] ), dot( m[ 1 ], m[ 1 ] ), dot( m[ 2 ], m[ 2 ] ) );\n\ttransformedNormal = m * transformedNormal;\n#endif\ntransformedNormal = normalMatrix * transformedNormal;\n#ifdef FLIP_SIDED\n\ttransformedNormal = - transformedNormal;\n#endif\n#ifdef USE_TANGENT\n\tvec3 transformedTangent = ( modelViewMatrix * vec4( objectTangent, 0.0 ) ).xyz;\n\t#ifdef FLIP_SIDED\n\t\ttransformedTangent = - transformedTangent;\n\t#endif\n#endif",displacementmap_pars_vertex:"#ifdef USE_DISPLACEMENTMAP\n\tuniform sampler2D displacementMap;\n\tuniform float displacementScale;\n\tuniform float displacementBias;\n#endif",displacementmap_vertex:"#ifdef USE_DISPLACEMENTMAP\n\ttransformed += normalize( objectNormal ) * ( texture2D( displacementMap, vUv ).x * displacementScale + displacementBias );\n#endif",emissivemap_fragment:"#ifdef USE_EMISSIVEMAP\n\tvec4 emissiveColor = texture2D( emissiveMap, vUv );\n\temissiveColor.rgb = emissiveMapTexelToLinear( emissiveColor ).rgb;\n\ttotalEmissiveRadiance *= emissiveColor.rgb;\n#endif",emissivemap_pars_fragment:"#ifdef USE_EMISSIVEMAP\n\tuniform sampler2D emissiveMap;\n#endif",encodings_fragment:"gl_FragColor = linearToOutputTexel( gl_FragColor );",encodings_pars_fragment:"\nvec4 LinearToLinear( in vec4 value ) {\n\treturn value;\n}\nvec4 GammaToLinear( in vec4 value, in float gammaFactor ) {\n\treturn vec4( pow( value.rgb, vec3( gammaFactor ) ), value.a );\n}\nvec4 LinearToGamma( in vec4 value, in float gammaFactor ) {\n\treturn vec4( pow( value.rgb, vec3( 1.0 / gammaFactor ) ), value.a );\n}\nvec4 sRGBToLinear( in vec4 value ) {\n\treturn vec4( mix( pow( value.rgb * 0.9478672986 + vec3( 0.0521327014 ), vec3( 2.4 ) ), value.rgb * 0.0773993808, vec3( lessThanEqual( value.rgb, vec3( 0.04045 ) ) ) ), value.a );\n}\nvec4 LinearTosRGB( in vec4 value ) {\n\treturn vec4( mix( pow( value.rgb, vec3( 0.41666 ) ) * 1.055 - vec3( 0.055 ), value.rgb * 12.92, vec3( lessThanEqual( value.rgb, vec3( 0.0031308 ) ) ) ), value.a );\n}\nvec4 RGBEToLinear( in vec4 value ) {\n\treturn vec4( value.rgb * exp2( value.a * 255.0 - 128.0 ), 1.0 );\n}\nvec4 LinearToRGBE( in vec4 value ) {\n\tfloat maxComponent = max( max( value.r, value.g ), value.b );\n\tfloat fExp = clamp( ceil( log2( maxComponent ) ), -128.0, 127.0 );\n\treturn vec4( value.rgb / exp2( fExp ), ( fExp + 128.0 ) / 255.0 );\n}\nvec4 RGBMToLinear( in vec4 value, in float maxRange ) {\n\treturn vec4( value.rgb * value.a * maxRange, 1.0 );\n}\nvec4 LinearToRGBM( in vec4 value, in float maxRange ) {\n\tfloat maxRGB = max( value.r, max( value.g, value.b ) );\n\tfloat M = clamp( maxRGB / maxRange, 0.0, 1.0 );\n\tM = ceil( M * 255.0 ) / 255.0;\n\treturn vec4( value.rgb / ( M * maxRange ), M );\n}\nvec4 RGBDToLinear( in vec4 value, in float maxRange ) {\n\treturn vec4( value.rgb * ( ( maxRange / 255.0 ) / value.a ), 1.0 );\n}\nvec4 LinearToRGBD( in vec4 value, in float maxRange ) {\n\tfloat maxRGB = max( value.r, max( value.g, value.b ) );\n\tfloat D = max( maxRange / maxRGB, 1.0 );\n\tD = clamp( floor( D ) / 255.0, 0.0, 1.0 );\n\treturn vec4( value.rgb * ( D * ( 255.0 / maxRange ) ), D );\n}\nconst mat3 cLogLuvM = mat3( 0.2209, 0.3390, 0.4184, 0.1138, 0.6780, 0.7319, 0.0102, 0.1130, 0.2969 );\nvec4 LinearToLogLuv( in vec4 value ) {\n\tvec3 Xp_Y_XYZp = cLogLuvM * value.rgb;\n\tXp_Y_XYZp = max( Xp_Y_XYZp, vec3( 1e-6, 1e-6, 1e-6 ) );\n\tvec4 vResult;\n\tvResult.xy = Xp_Y_XYZp.xy / Xp_Y_XYZp.z;\n\tfloat Le = 2.0 * log2(Xp_Y_XYZp.y) + 127.0;\n\tvResult.w = fract( Le );\n\tvResult.z = ( Le - ( floor( vResult.w * 255.0 ) ) / 255.0 ) / 255.0;\n\treturn vResult;\n}\nconst mat3 cLogLuvInverseM = mat3( 6.0014, -2.7008, -1.7996, -1.3320, 3.1029, -5.7721, 0.3008, -1.0882, 5.6268 );\nvec4 LogLuvToLinear( in vec4 value ) {\n\tfloat Le = value.z * 255.0 + value.w;\n\tvec3 Xp_Y_XYZp;\n\tXp_Y_XYZp.y = exp2( ( Le - 127.0 ) / 2.0 );\n\tXp_Y_XYZp.z = Xp_Y_XYZp.y / value.y;\n\tXp_Y_XYZp.x = value.x * Xp_Y_XYZp.z;\n\tvec3 vRGB = cLogLuvInverseM * Xp_Y_XYZp.rgb;\n\treturn vec4( max( vRGB, 0.0 ), 1.0 );\n}",envmap_fragment:"#ifdef USE_ENVMAP\n\t#ifdef ENV_WORLDPOS\n\t\tvec3 cameraToFrag;\n\t\tif ( isOrthographic ) {\n\t\t\tcameraToFrag = normalize( vec3( - viewMatrix[ 0 ][ 2 ], - viewMatrix[ 1 ][ 2 ], - viewMatrix[ 2 ][ 2 ] ) );\n\t\t} else {\n\t\t\tcameraToFrag = normalize( vWorldPosition - cameraPosition );\n\t\t}\n\t\tvec3 worldNormal = inverseTransformDirection( normal, viewMatrix );\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvec3 reflectVec = reflect( cameraToFrag, worldNormal );\n\t\t#else\n\t\t\tvec3 reflectVec = refract( cameraToFrag, worldNormal, refractionRatio );\n\t\t#endif\n\t#else\n\t\tvec3 reflectVec = vReflect;\n\t#endif\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tvec4 envColor = textureCube( envMap, vec3( flipEnvMap * reflectVec.x, reflectVec.yz ) );\n\t#elif defined( ENVMAP_TYPE_CUBE_UV )\n\t\tvec4 envColor = textureCubeUV( envMap, reflectVec, 0.0 );\n\t#else\n\t\tvec4 envColor = vec4( 0.0 );\n\t#endif\n\t#ifndef ENVMAP_TYPE_CUBE_UV\n\t\tenvColor = envMapTexelToLinear( envColor );\n\t#endif\n\t#ifdef ENVMAP_BLENDING_MULTIPLY\n\t\toutgoingLight = mix( outgoingLight, outgoingLight * envColor.xyz, specularStrength * reflectivity );\n\t#elif defined( ENVMAP_BLENDING_MIX )\n\t\toutgoingLight = mix( outgoingLight, envColor.xyz, specularStrength * reflectivity );\n\t#elif defined( ENVMAP_BLENDING_ADD )\n\t\toutgoingLight += envColor.xyz * specularStrength * reflectivity;\n\t#endif\n#endif",envmap_common_pars_fragment:"#ifdef USE_ENVMAP\n\tuniform float envMapIntensity;\n\tuniform float flipEnvMap;\n\tuniform int maxMipLevel;\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tuniform samplerCube envMap;\n\t#else\n\t\tuniform sampler2D envMap;\n\t#endif\n\t\n#endif",envmap_pars_fragment:"#ifdef USE_ENVMAP\n\tuniform float reflectivity;\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG )\n\t\t#define ENV_WORLDPOS\n\t#endif\n\t#ifdef ENV_WORLDPOS\n\t\tvarying vec3 vWorldPosition;\n\t\tuniform float refractionRatio;\n\t#else\n\t\tvarying vec3 vReflect;\n\t#endif\n#endif",envmap_pars_vertex:"#ifdef USE_ENVMAP\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) ||defined( PHONG )\n\t\t#define ENV_WORLDPOS\n\t#endif\n\t#ifdef ENV_WORLDPOS\n\t\t\n\t\tvarying vec3 vWorldPosition;\n\t#else\n\t\tvarying vec3 vReflect;\n\t\tuniform float refractionRatio;\n\t#endif\n#endif",envmap_physical_pars_fragment:"#if defined( USE_ENVMAP )\n\t#ifdef ENVMAP_MODE_REFRACTION\n\t\tuniform float refractionRatio;\n\t#endif\n\tvec3 getLightProbeIndirectIrradiance( const in GeometricContext geometry, const in int maxMIPLevel ) {\n\t\tvec3 worldNormal = inverseTransformDirection( geometry.normal, viewMatrix );\n\t\t#ifdef ENVMAP_TYPE_CUBE\n\t\t\tvec3 queryVec = vec3( flipEnvMap * worldNormal.x, worldNormal.yz );\n\t\t\t#ifdef TEXTURE_LOD_EXT\n\t\t\t\tvec4 envMapColor = textureCubeLodEXT( envMap, queryVec, float( maxMIPLevel ) );\n\t\t\t#else\n\t\t\t\tvec4 envMapColor = textureCube( envMap, queryVec, float( maxMIPLevel ) );\n\t\t\t#endif\n\t\t\tenvMapColor.rgb = envMapTexelToLinear( envMapColor ).rgb;\n\t\t#elif defined( ENVMAP_TYPE_CUBE_UV )\n\t\t\tvec4 envMapColor = textureCubeUV( envMap, worldNormal, 1.0 );\n\t\t#else\n\t\t\tvec4 envMapColor = vec4( 0.0 );\n\t\t#endif\n\t\treturn PI * envMapColor.rgb * envMapIntensity;\n\t}\n\tfloat getSpecularMIPLevel( const in float roughness, const in int maxMIPLevel ) {\n\t\tfloat maxMIPLevelScalar = float( maxMIPLevel );\n\t\tfloat sigma = PI * roughness * roughness / ( 1.0 + roughness );\n\t\tfloat desiredMIPLevel = maxMIPLevelScalar + log2( sigma );\n\t\treturn clamp( desiredMIPLevel, 0.0, maxMIPLevelScalar );\n\t}\n\tvec3 getLightProbeIndirectRadiance( const in vec3 viewDir, const in vec3 normal, const in float roughness, const in int maxMIPLevel ) {\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvec3 reflectVec = reflect( -viewDir, normal );\n\t\t\treflectVec = normalize( mix( reflectVec, normal, roughness * roughness) );\n\t\t#else\n\t\t\tvec3 reflectVec = refract( -viewDir, normal, refractionRatio );\n\t\t#endif\n\t\treflectVec = inverseTransformDirection( reflectVec, viewMatrix );\n\t\tfloat specularMIPLevel = getSpecularMIPLevel( roughness, maxMIPLevel );\n\t\t#ifdef ENVMAP_TYPE_CUBE\n\t\t\tvec3 queryReflectVec = vec3( flipEnvMap * reflectVec.x, reflectVec.yz );\n\t\t\t#ifdef TEXTURE_LOD_EXT\n\t\t\t\tvec4 envMapColor = textureCubeLodEXT( envMap, queryReflectVec, specularMIPLevel );\n\t\t\t#else\n\t\t\t\tvec4 envMapColor = textureCube( envMap, queryReflectVec, specularMIPLevel );\n\t\t\t#endif\n\t\t\tenvMapColor.rgb = envMapTexelToLinear( envMapColor ).rgb;\n\t\t#elif defined( ENVMAP_TYPE_CUBE_UV )\n\t\t\tvec4 envMapColor = textureCubeUV( envMap, reflectVec, roughness );\n\t\t#endif\n\t\treturn envMapColor.rgb * envMapIntensity;\n\t}\n#endif",envmap_vertex:"#ifdef USE_ENVMAP\n\t#ifdef ENV_WORLDPOS\n\t\tvWorldPosition = worldPosition.xyz;\n\t#else\n\t\tvec3 cameraToVertex;\n\t\tif ( isOrthographic ) {\n\t\t\tcameraToVertex = normalize( vec3( - viewMatrix[ 0 ][ 2 ], - viewMatrix[ 1 ][ 2 ], - viewMatrix[ 2 ][ 2 ] ) );\n\t\t} else {\n\t\t\tcameraToVertex = normalize( worldPosition.xyz - cameraPosition );\n\t\t}\n\t\tvec3 worldNormal = inverseTransformDirection( transformedNormal, viewMatrix );\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvReflect = reflect( cameraToVertex, worldNormal );\n\t\t#else\n\t\t\tvReflect = refract( cameraToVertex, worldNormal, refractionRatio );\n\t\t#endif\n\t#endif\n#endif",fog_vertex:"#ifdef USE_FOG\n\tfogDepth = - mvPosition.z;\n#endif",fog_pars_vertex:"#ifdef USE_FOG\n\tvarying float fogDepth;\n#endif",fog_fragment:"#ifdef USE_FOG\n\t#ifdef FOG_EXP2\n\t\tfloat fogFactor = 1.0 - exp( - fogDensity * fogDensity * fogDepth * fogDepth );\n\t#else\n\t\tfloat fogFactor = smoothstep( fogNear, fogFar, fogDepth );\n\t#endif\n\tgl_FragColor.rgb = mix( gl_FragColor.rgb, fogColor, fogFactor );\n#endif",fog_pars_fragment:"#ifdef USE_FOG\n\tuniform vec3 fogColor;\n\tvarying float fogDepth;\n\t#ifdef FOG_EXP2\n\t\tuniform float fogDensity;\n\t#else\n\t\tuniform float fogNear;\n\t\tuniform float fogFar;\n\t#endif\n#endif",gradientmap_pars_fragment:"#ifdef USE_GRADIENTMAP\n\tuniform sampler2D gradientMap;\n#endif\nvec3 getGradientIrradiance( vec3 normal, vec3 lightDirection ) {\n\tfloat dotNL = dot( normal, lightDirection );\n\tvec2 coord = vec2( dotNL * 0.5 + 0.5, 0.0 );\n\t#ifdef USE_GRADIENTMAP\n\t\treturn texture2D( gradientMap, coord ).rgb;\n\t#else\n\t\treturn ( coord.x < 0.7 ) ? vec3( 0.7 ) : vec3( 1.0 );\n\t#endif\n}",lightmap_fragment:"#ifdef USE_LIGHTMAP\n\tvec4 lightMapTexel= texture2D( lightMap, vUv2 );\n\treflectedLight.indirectDiffuse += PI * lightMapTexelToLinear( lightMapTexel ).rgb * lightMapIntensity;\n#endif",lightmap_pars_fragment:"#ifdef USE_LIGHTMAP\n\tuniform sampler2D lightMap;\n\tuniform float lightMapIntensity;\n#endif",lights_lambert_vertex:"vec3 diffuse = vec3( 1.0 );\nGeometricContext geometry;\ngeometry.position = mvPosition.xyz;\ngeometry.normal = normalize( transformedNormal );\ngeometry.viewDir = ( isOrthographic ) ? vec3( 0, 0, 1 ) : normalize( -mvPosition.xyz );\nGeometricContext backGeometry;\nbackGeometry.position = geometry.position;\nbackGeometry.normal = -geometry.normal;\nbackGeometry.viewDir = geometry.viewDir;\nvLightFront = vec3( 0.0 );\nvIndirectFront = vec3( 0.0 );\n#ifdef DOUBLE_SIDED\n\tvLightBack = vec3( 0.0 );\n\tvIndirectBack = vec3( 0.0 );\n#endif\nIncidentLight directLight;\nfloat dotNL;\nvec3 directLightColor_Diffuse;\nvIndirectFront += getAmbientLightIrradiance( ambientLightColor );\nvIndirectFront += getLightProbeIrradiance( lightProbe, geometry );\n#ifdef DOUBLE_SIDED\n\tvIndirectBack += getAmbientLightIrradiance( ambientLightColor );\n\tvIndirectBack += getLightProbeIrradiance( lightProbe, backGeometry );\n#endif\n#if NUM_POINT_LIGHTS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) {\n\t\tgetPointDirectLightIrradiance( pointLights[ i ], geometry, directLight );\n\t\tdotNL = dot( geometry.normal, directLight.direction );\n\t\tdirectLightColor_Diffuse = PI * directLight.color;\n\t\tvLightFront += saturate( dotNL ) * directLightColor_Diffuse;\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvLightBack += saturate( -dotNL ) * directLightColor_Diffuse;\n\t\t#endif\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if NUM_SPOT_LIGHTS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) {\n\t\tgetSpotDirectLightIrradiance( spotLights[ i ], geometry, directLight );\n\t\tdotNL = dot( geometry.normal, directLight.direction );\n\t\tdirectLightColor_Diffuse = PI * directLight.color;\n\t\tvLightFront += saturate( dotNL ) * directLightColor_Diffuse;\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvLightBack += saturate( -dotNL ) * directLightColor_Diffuse;\n\t\t#endif\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if NUM_DIR_LIGHTS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\t\tgetDirectionalDirectLightIrradiance( directionalLights[ i ], geometry, directLight );\n\t\tdotNL = dot( geometry.normal, directLight.direction );\n\t\tdirectLightColor_Diffuse = PI * directLight.color;\n\t\tvLightFront += saturate( dotNL ) * directLightColor_Diffuse;\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvLightBack += saturate( -dotNL ) * directLightColor_Diffuse;\n\t\t#endif\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if NUM_HEMI_LIGHTS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_HEMI_LIGHTS; i ++ ) {\n\t\tvIndirectFront += getHemisphereLightIrradiance( hemisphereLights[ i ], geometry );\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvIndirectBack += getHemisphereLightIrradiance( hemisphereLights[ i ], backGeometry );\n\t\t#endif\n\t}\n\t#pragma unroll_loop_end\n#endif",lights_pars_begin:"uniform bool receiveShadow;\nuniform vec3 ambientLightColor;\nuniform vec3 lightProbe[ 9 ];\nvec3 shGetIrradianceAt( in vec3 normal, in vec3 shCoefficients[ 9 ] ) {\n\tfloat x = normal.x, y = normal.y, z = normal.z;\n\tvec3 result = shCoefficients[ 0 ] * 0.886227;\n\tresult += shCoefficients[ 1 ] * 2.0 * 0.511664 * y;\n\tresult += shCoefficients[ 2 ] * 2.0 * 0.511664 * z;\n\tresult += shCoefficients[ 3 ] * 2.0 * 0.511664 * x;\n\tresult += shCoefficients[ 4 ] * 2.0 * 0.429043 * x * y;\n\tresult += shCoefficients[ 5 ] * 2.0 * 0.429043 * y * z;\n\tresult += shCoefficients[ 6 ] * ( 0.743125 * z * z - 0.247708 );\n\tresult += shCoefficients[ 7 ] * 2.0 * 0.429043 * x * z;\n\tresult += shCoefficients[ 8 ] * 0.429043 * ( x * x - y * y );\n\treturn result;\n}\nvec3 getLightProbeIrradiance( const in vec3 lightProbe[ 9 ], const in GeometricContext geometry ) {\n\tvec3 worldNormal = inverseTransformDirection( geometry.normal, viewMatrix );\n\tvec3 irradiance = shGetIrradianceAt( worldNormal, lightProbe );\n\treturn irradiance;\n}\nvec3 getAmbientLightIrradiance( const in vec3 ambientLightColor ) {\n\tvec3 irradiance = ambientLightColor;\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\treturn irradiance;\n}\n#if NUM_DIR_LIGHTS > 0\n\tstruct DirectionalLight {\n\t\tvec3 direction;\n\t\tvec3 color;\n\t};\n\tuniform DirectionalLight directionalLights[ NUM_DIR_LIGHTS ];\n\tvoid getDirectionalDirectLightIrradiance( const in DirectionalLight directionalLight, const in GeometricContext geometry, out IncidentLight directLight ) {\n\t\tdirectLight.color = directionalLight.color;\n\t\tdirectLight.direction = directionalLight.direction;\n\t\tdirectLight.visible = true;\n\t}\n#endif\n#if NUM_POINT_LIGHTS > 0\n\tstruct PointLight {\n\t\tvec3 position;\n\t\tvec3 color;\n\t\tfloat distance;\n\t\tfloat decay;\n\t};\n\tuniform PointLight pointLights[ NUM_POINT_LIGHTS ];\n\tvoid getPointDirectLightIrradiance( const in PointLight pointLight, const in GeometricContext geometry, out IncidentLight directLight ) {\n\t\tvec3 lVector = pointLight.position - geometry.position;\n\t\tdirectLight.direction = normalize( lVector );\n\t\tfloat lightDistance = length( lVector );\n\t\tdirectLight.color = pointLight.color;\n\t\tdirectLight.color *= punctualLightIntensityToIrradianceFactor( lightDistance, pointLight.distance, pointLight.decay );\n\t\tdirectLight.visible = ( directLight.color != vec3( 0.0 ) );\n\t}\n#endif\n#if NUM_SPOT_LIGHTS > 0\n\tstruct SpotLight {\n\t\tvec3 position;\n\t\tvec3 direction;\n\t\tvec3 color;\n\t\tfloat distance;\n\t\tfloat decay;\n\t\tfloat coneCos;\n\t\tfloat penumbraCos;\n\t};\n\tuniform SpotLight spotLights[ NUM_SPOT_LIGHTS ];\n\tvoid getSpotDirectLightIrradiance( const in SpotLight spotLight, const in GeometricContext geometry, out IncidentLight directLight ) {\n\t\tvec3 lVector = spotLight.position - geometry.position;\n\t\tdirectLight.direction = normalize( lVector );\n\t\tfloat lightDistance = length( lVector );\n\t\tfloat angleCos = dot( directLight.direction, spotLight.direction );\n\t\tif ( angleCos > spotLight.coneCos ) {\n\t\t\tfloat spotEffect = smoothstep( spotLight.coneCos, spotLight.penumbraCos, angleCos );\n\t\t\tdirectLight.color = spotLight.color;\n\t\t\tdirectLight.color *= spotEffect * punctualLightIntensityToIrradianceFactor( lightDistance, spotLight.distance, spotLight.decay );\n\t\t\tdirectLight.visible = true;\n\t\t} else {\n\t\t\tdirectLight.color = vec3( 0.0 );\n\t\t\tdirectLight.visible = false;\n\t\t}\n\t}\n#endif\n#if NUM_RECT_AREA_LIGHTS > 0\n\tstruct RectAreaLight {\n\t\tvec3 color;\n\t\tvec3 position;\n\t\tvec3 halfWidth;\n\t\tvec3 halfHeight;\n\t};\n\tuniform sampler2D ltc_1;\tuniform sampler2D ltc_2;\n\tuniform RectAreaLight rectAreaLights[ NUM_RECT_AREA_LIGHTS ];\n#endif\n#if NUM_HEMI_LIGHTS > 0\n\tstruct HemisphereLight {\n\t\tvec3 direction;\n\t\tvec3 skyColor;\n\t\tvec3 groundColor;\n\t};\n\tuniform HemisphereLight hemisphereLights[ NUM_HEMI_LIGHTS ];\n\tvec3 getHemisphereLightIrradiance( const in HemisphereLight hemiLight, const in GeometricContext geometry ) {\n\t\tfloat dotNL = dot( geometry.normal, hemiLight.direction );\n\t\tfloat hemiDiffuseWeight = 0.5 * dotNL + 0.5;\n\t\tvec3 irradiance = mix( hemiLight.groundColor, hemiLight.skyColor, hemiDiffuseWeight );\n\t\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\t\tirradiance *= PI;\n\t\t#endif\n\t\treturn irradiance;\n\t}\n#endif",lights_toon_fragment:"ToonMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb;",lights_toon_pars_fragment:"varying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\nstruct ToonMaterial {\n\tvec3 diffuseColor;\n};\nvoid RE_Direct_Toon( const in IncidentLight directLight, const in GeometricContext geometry, const in ToonMaterial material, inout ReflectedLight reflectedLight ) {\n\tvec3 irradiance = getGradientIrradiance( geometry.normal, directLight.direction ) * directLight.color;\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\treflectedLight.directDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectDiffuse_Toon( const in vec3 irradiance, const in GeometricContext geometry, const in ToonMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\n#define RE_Direct\t\t\t\tRE_Direct_Toon\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_Toon\n#define Material_LightProbeLOD( material )\t(0)",lights_phong_fragment:"BlinnPhongMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb;\nmaterial.specularColor = specular;\nmaterial.specularShininess = shininess;\nmaterial.specularStrength = specularStrength;",lights_phong_pars_fragment:"varying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\nstruct BlinnPhongMaterial {\n\tvec3 diffuseColor;\n\tvec3 specularColor;\n\tfloat specularShininess;\n\tfloat specularStrength;\n};\nvoid RE_Direct_BlinnPhong( const in IncidentLight directLight, const in GeometricContext geometry, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) {\n\tfloat dotNL = saturate( dot( geometry.normal, directLight.direction ) );\n\tvec3 irradiance = dotNL * directLight.color;\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\treflectedLight.directDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n\treflectedLight.directSpecular += irradiance * BRDF_Specular_BlinnPhong( directLight, geometry, material.specularColor, material.specularShininess ) * material.specularStrength;\n}\nvoid RE_IndirectDiffuse_BlinnPhong( const in vec3 irradiance, const in GeometricContext geometry, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\n#define RE_Direct\t\t\t\tRE_Direct_BlinnPhong\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_BlinnPhong\n#define Material_LightProbeLOD( material )\t(0)",lights_physical_fragment:"PhysicalMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb * ( 1.0 - metalnessFactor );\nvec3 dxy = max( abs( dFdx( geometryNormal ) ), abs( dFdy( geometryNormal ) ) );\nfloat geometryRoughness = max( max( dxy.x, dxy.y ), dxy.z );\nmaterial.specularRoughness = max( roughnessFactor, 0.0525 );material.specularRoughness += geometryRoughness;\nmaterial.specularRoughness = min( material.specularRoughness, 1.0 );\n#ifdef REFLECTIVITY\n\tmaterial.specularColor = mix( vec3( MAXIMUM_SPECULAR_COEFFICIENT * pow2( reflectivity ) ), diffuseColor.rgb, metalnessFactor );\n#else\n\tmaterial.specularColor = mix( vec3( DEFAULT_SPECULAR_COEFFICIENT ), diffuseColor.rgb, metalnessFactor );\n#endif\n#ifdef CLEARCOAT\n\tmaterial.clearcoat = clearcoat;\n\tmaterial.clearcoatRoughness = clearcoatRoughness;\n\t#ifdef USE_CLEARCOATMAP\n\t\tmaterial.clearcoat *= texture2D( clearcoatMap, vUv ).x;\n\t#endif\n\t#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\t\tmaterial.clearcoatRoughness *= texture2D( clearcoatRoughnessMap, vUv ).y;\n\t#endif\n\tmaterial.clearcoat = saturate( material.clearcoat );\tmaterial.clearcoatRoughness = max( material.clearcoatRoughness, 0.0525 );\n\tmaterial.clearcoatRoughness += geometryRoughness;\n\tmaterial.clearcoatRoughness = min( material.clearcoatRoughness, 1.0 );\n#endif\n#ifdef USE_SHEEN\n\tmaterial.sheenColor = sheen;\n#endif",lights_physical_pars_fragment:"struct PhysicalMaterial {\n\tvec3 diffuseColor;\n\tfloat specularRoughness;\n\tvec3 specularColor;\n#ifdef CLEARCOAT\n\tfloat clearcoat;\n\tfloat clearcoatRoughness;\n#endif\n#ifdef USE_SHEEN\n\tvec3 sheenColor;\n#endif\n};\n#define MAXIMUM_SPECULAR_COEFFICIENT 0.16\n#define DEFAULT_SPECULAR_COEFFICIENT 0.04\nfloat clearcoatDHRApprox( const in float roughness, const in float dotNL ) {\n\treturn DEFAULT_SPECULAR_COEFFICIENT + ( 1.0 - DEFAULT_SPECULAR_COEFFICIENT ) * ( pow( 1.0 - dotNL, 5.0 ) * pow( 1.0 - roughness, 2.0 ) );\n}\n#if NUM_RECT_AREA_LIGHTS > 0\n\tvoid RE_Direct_RectArea_Physical( const in RectAreaLight rectAreaLight, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\t\tvec3 normal = geometry.normal;\n\t\tvec3 viewDir = geometry.viewDir;\n\t\tvec3 position = geometry.position;\n\t\tvec3 lightPos = rectAreaLight.position;\n\t\tvec3 halfWidth = rectAreaLight.halfWidth;\n\t\tvec3 halfHeight = rectAreaLight.halfHeight;\n\t\tvec3 lightColor = rectAreaLight.color;\n\t\tfloat roughness = material.specularRoughness;\n\t\tvec3 rectCoords[ 4 ];\n\t\trectCoords[ 0 ] = lightPos + halfWidth - halfHeight;\t\trectCoords[ 1 ] = lightPos - halfWidth - halfHeight;\n\t\trectCoords[ 2 ] = lightPos - halfWidth + halfHeight;\n\t\trectCoords[ 3 ] = lightPos + halfWidth + halfHeight;\n\t\tvec2 uv = LTC_Uv( normal, viewDir, roughness );\n\t\tvec4 t1 = texture2D( ltc_1, uv );\n\t\tvec4 t2 = texture2D( ltc_2, uv );\n\t\tmat3 mInv = mat3(\n\t\t\tvec3( t1.x, 0, t1.y ),\n\t\t\tvec3(\t\t0, 1,\t\t0 ),\n\t\t\tvec3( t1.z, 0, t1.w )\n\t\t);\n\t\tvec3 fresnel = ( material.specularColor * t2.x + ( vec3( 1.0 ) - material.specularColor ) * t2.y );\n\t\treflectedLight.directSpecular += lightColor * fresnel * LTC_Evaluate( normal, viewDir, position, mInv, rectCoords );\n\t\treflectedLight.directDiffuse += lightColor * material.diffuseColor * LTC_Evaluate( normal, viewDir, position, mat3( 1.0 ), rectCoords );\n\t}\n#endif\nvoid RE_Direct_Physical( const in IncidentLight directLight, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\tfloat dotNL = saturate( dot( geometry.normal, directLight.direction ) );\n\tvec3 irradiance = dotNL * directLight.color;\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\t#ifdef CLEARCOAT\n\t\tfloat ccDotNL = saturate( dot( geometry.clearcoatNormal, directLight.direction ) );\n\t\tvec3 ccIrradiance = ccDotNL * directLight.color;\n\t\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\t\tccIrradiance *= PI;\n\t\t#endif\n\t\tfloat clearcoatDHR = material.clearcoat * clearcoatDHRApprox( material.clearcoatRoughness, ccDotNL );\n\t\treflectedLight.directSpecular += ccIrradiance * material.clearcoat * BRDF_Specular_GGX( directLight, geometry.viewDir, geometry.clearcoatNormal, vec3( DEFAULT_SPECULAR_COEFFICIENT ), material.clearcoatRoughness );\n\t#else\n\t\tfloat clearcoatDHR = 0.0;\n\t#endif\n\t#ifdef USE_SHEEN\n\t\treflectedLight.directSpecular += ( 1.0 - clearcoatDHR ) * irradiance * BRDF_Specular_Sheen(\n\t\t\tmaterial.specularRoughness,\n\t\t\tdirectLight.direction,\n\t\t\tgeometry,\n\t\t\tmaterial.sheenColor\n\t\t);\n\t#else\n\t\treflectedLight.directSpecular += ( 1.0 - clearcoatDHR ) * irradiance * BRDF_Specular_GGX( directLight, geometry.viewDir, geometry.normal, material.specularColor, material.specularRoughness);\n\t#endif\n\treflectedLight.directDiffuse += ( 1.0 - clearcoatDHR ) * irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectDiffuse_Physical( const in vec3 irradiance, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectSpecular_Physical( const in vec3 radiance, const in vec3 irradiance, const in vec3 clearcoatRadiance, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight) {\n\t#ifdef CLEARCOAT\n\t\tfloat ccDotNV = saturate( dot( geometry.clearcoatNormal, geometry.viewDir ) );\n\t\treflectedLight.indirectSpecular += clearcoatRadiance * material.clearcoat * BRDF_Specular_GGX_Environment( geometry.viewDir, geometry.clearcoatNormal, vec3( DEFAULT_SPECULAR_COEFFICIENT ), material.clearcoatRoughness );\n\t\tfloat ccDotNL = ccDotNV;\n\t\tfloat clearcoatDHR = material.clearcoat * clearcoatDHRApprox( material.clearcoatRoughness, ccDotNL );\n\t#else\n\t\tfloat clearcoatDHR = 0.0;\n\t#endif\n\tfloat clearcoatInv = 1.0 - clearcoatDHR;\n\tvec3 singleScattering = vec3( 0.0 );\n\tvec3 multiScattering = vec3( 0.0 );\n\tvec3 cosineWeightedIrradiance = irradiance * RECIPROCAL_PI;\n\tBRDF_Specular_Multiscattering_Environment( geometry, material.specularColor, material.specularRoughness, singleScattering, multiScattering );\n\tvec3 diffuse = material.diffuseColor * ( 1.0 - ( singleScattering + multiScattering ) );\n\treflectedLight.indirectSpecular += clearcoatInv * radiance * singleScattering;\n\treflectedLight.indirectSpecular += multiScattering * cosineWeightedIrradiance;\n\treflectedLight.indirectDiffuse += diffuse * cosineWeightedIrradiance;\n}\n#define RE_Direct\t\t\t\tRE_Direct_Physical\n#define RE_Direct_RectArea\t\tRE_Direct_RectArea_Physical\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_Physical\n#define RE_IndirectSpecular\t\tRE_IndirectSpecular_Physical\nfloat computeSpecularOcclusion( const in float dotNV, const in float ambientOcclusion, const in float roughness ) {\n\treturn saturate( pow( dotNV + ambientOcclusion, exp2( - 16.0 * roughness - 1.0 ) ) - 1.0 + ambientOcclusion );\n}",lights_fragment_begin:"\nGeometricContext geometry;\ngeometry.position = - vViewPosition;\ngeometry.normal = normal;\ngeometry.viewDir = ( isOrthographic ) ? vec3( 0, 0, 1 ) : normalize( vViewPosition );\n#ifdef CLEARCOAT\n\tgeometry.clearcoatNormal = clearcoatNormal;\n#endif\nIncidentLight directLight;\n#if ( NUM_POINT_LIGHTS > 0 ) && defined( RE_Direct )\n\tPointLight pointLight;\n\t#if defined( USE_SHADOWMAP ) && NUM_POINT_LIGHT_SHADOWS > 0\n\tPointLightShadow pointLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) {\n\t\tpointLight = pointLights[ i ];\n\t\tgetPointDirectLightIrradiance( pointLight, geometry, directLight );\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_POINT_LIGHT_SHADOWS )\n\t\tpointLightShadow = pointLightShadows[ i ];\n\t\tdirectLight.color *= all( bvec2( directLight.visible, receiveShadow ) ) ? getPointShadow( pointShadowMap[ i ], pointLightShadow.shadowMapSize, pointLightShadow.shadowBias, pointLightShadow.shadowRadius, vPointShadowCoord[ i ], pointLightShadow.shadowCameraNear, pointLightShadow.shadowCameraFar ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometry, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_SPOT_LIGHTS > 0 ) && defined( RE_Direct )\n\tSpotLight spotLight;\n\t#if defined( USE_SHADOWMAP ) && NUM_SPOT_LIGHT_SHADOWS > 0\n\tSpotLightShadow spotLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) {\n\t\tspotLight = spotLights[ i ];\n\t\tgetSpotDirectLightIrradiance( spotLight, geometry, directLight );\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS )\n\t\tspotLightShadow = spotLightShadows[ i ];\n\t\tdirectLight.color *= all( bvec2( directLight.visible, receiveShadow ) ) ? getShadow( spotShadowMap[ i ], spotLightShadow.shadowMapSize, spotLightShadow.shadowBias, spotLightShadow.shadowRadius, vSpotShadowCoord[ i ] ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometry, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_DIR_LIGHTS > 0 ) && defined( RE_Direct )\n\tDirectionalLight directionalLight;\n\t#if defined( USE_SHADOWMAP ) && NUM_DIR_LIGHT_SHADOWS > 0\n\tDirectionalLightShadow directionalLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\t\tdirectionalLight = directionalLights[ i ];\n\t\tgetDirectionalDirectLightIrradiance( directionalLight, geometry, directLight );\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_DIR_LIGHT_SHADOWS )\n\t\tdirectionalLightShadow = directionalLightShadows[ i ];\n\t\tdirectLight.color *= all( bvec2( directLight.visible, receiveShadow ) ) ? getShadow( directionalShadowMap[ i ], directionalLightShadow.shadowMapSize, directionalLightShadow.shadowBias, directionalLightShadow.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometry, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_RECT_AREA_LIGHTS > 0 ) && defined( RE_Direct_RectArea )\n\tRectAreaLight rectAreaLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_RECT_AREA_LIGHTS; i ++ ) {\n\t\trectAreaLight = rectAreaLights[ i ];\n\t\tRE_Direct_RectArea( rectAreaLight, geometry, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if defined( RE_IndirectDiffuse )\n\tvec3 iblIrradiance = vec3( 0.0 );\n\tvec3 irradiance = getAmbientLightIrradiance( ambientLightColor );\n\tirradiance += getLightProbeIrradiance( lightProbe, geometry );\n\t#if ( NUM_HEMI_LIGHTS > 0 )\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = 0; i < NUM_HEMI_LIGHTS; i ++ ) {\n\t\t\tirradiance += getHemisphereLightIrradiance( hemisphereLights[ i ], geometry );\n\t\t}\n\t\t#pragma unroll_loop_end\n\t#endif\n#endif\n#if defined( RE_IndirectSpecular )\n\tvec3 radiance = vec3( 0.0 );\n\tvec3 clearcoatRadiance = vec3( 0.0 );\n#endif",lights_fragment_maps:"#if defined( RE_IndirectDiffuse )\n\t#ifdef USE_LIGHTMAP\n\t\tvec4 lightMapTexel= texture2D( lightMap, vUv2 );\n\t\tvec3 lightMapIrradiance = lightMapTexelToLinear( lightMapTexel ).rgb * lightMapIntensity;\n\t\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\t\tlightMapIrradiance *= PI;\n\t\t#endif\n\t\tirradiance += lightMapIrradiance;\n\t#endif\n\t#if defined( USE_ENVMAP ) && defined( STANDARD ) && defined( ENVMAP_TYPE_CUBE_UV )\n\t\tiblIrradiance += getLightProbeIndirectIrradiance( geometry, maxMipLevel );\n\t#endif\n#endif\n#if defined( USE_ENVMAP ) && defined( RE_IndirectSpecular )\n\tradiance += getLightProbeIndirectRadiance( geometry.viewDir, geometry.normal, material.specularRoughness, maxMipLevel );\n\t#ifdef CLEARCOAT\n\t\tclearcoatRadiance += getLightProbeIndirectRadiance( geometry.viewDir, geometry.clearcoatNormal, material.clearcoatRoughness, maxMipLevel );\n\t#endif\n#endif",lights_fragment_end:"#if defined( RE_IndirectDiffuse )\n\tRE_IndirectDiffuse( irradiance, geometry, material, reflectedLight );\n#endif\n#if defined( RE_IndirectSpecular )\n\tRE_IndirectSpecular( radiance, iblIrradiance, clearcoatRadiance, geometry, material, reflectedLight );\n#endif",logdepthbuf_fragment:"#if defined( USE_LOGDEPTHBUF ) && defined( USE_LOGDEPTHBUF_EXT )\n\tgl_FragDepthEXT = vIsPerspective == 0.0 ? gl_FragCoord.z : log2( vFragDepth ) * logDepthBufFC * 0.5;\n#endif",logdepthbuf_pars_fragment:"#if defined( USE_LOGDEPTHBUF ) && defined( USE_LOGDEPTHBUF_EXT )\n\tuniform float logDepthBufFC;\n\tvarying float vFragDepth;\n\tvarying float vIsPerspective;\n#endif",logdepthbuf_pars_vertex:"#ifdef USE_LOGDEPTHBUF\n\t#ifdef USE_LOGDEPTHBUF_EXT\n\t\tvarying float vFragDepth;\n\t\tvarying float vIsPerspective;\n\t#else\n\t\tuniform float logDepthBufFC;\n\t#endif\n#endif",logdepthbuf_vertex:"#ifdef USE_LOGDEPTHBUF\n\t#ifdef USE_LOGDEPTHBUF_EXT\n\t\tvFragDepth = 1.0 + gl_Position.w;\n\t\tvIsPerspective = float( isPerspectiveMatrix( projectionMatrix ) );\n\t#else\n\t\tif ( isPerspectiveMatrix( projectionMatrix ) ) {\n\t\t\tgl_Position.z = log2( max( EPSILON, gl_Position.w + 1.0 ) ) * logDepthBufFC - 1.0;\n\t\t\tgl_Position.z *= gl_Position.w;\n\t\t}\n\t#endif\n#endif",map_fragment:"#ifdef USE_MAP\n\tvec4 texelColor = texture2D( map, vUv );\n\ttexelColor = mapTexelToLinear( texelColor );\n\tdiffuseColor *= texelColor;\n#endif",map_pars_fragment:"#ifdef USE_MAP\n\tuniform sampler2D map;\n#endif",map_particle_fragment:"#if defined( USE_MAP ) || defined( USE_ALPHAMAP )\n\tvec2 uv = ( uvTransform * vec3( gl_PointCoord.x, 1.0 - gl_PointCoord.y, 1 ) ).xy;\n#endif\n#ifdef USE_MAP\n\tvec4 mapTexel = texture2D( map, uv );\n\tdiffuseColor *= mapTexelToLinear( mapTexel );\n#endif\n#ifdef USE_ALPHAMAP\n\tdiffuseColor.a *= texture2D( alphaMap, uv ).g;\n#endif",map_particle_pars_fragment:"#if defined( USE_MAP ) || defined( USE_ALPHAMAP )\n\tuniform mat3 uvTransform;\n#endif\n#ifdef USE_MAP\n\tuniform sampler2D map;\n#endif\n#ifdef USE_ALPHAMAP\n\tuniform sampler2D alphaMap;\n#endif",metalnessmap_fragment:"float metalnessFactor = metalness;\n#ifdef USE_METALNESSMAP\n\tvec4 texelMetalness = texture2D( metalnessMap, vUv );\n\tmetalnessFactor *= texelMetalness.b;\n#endif",metalnessmap_pars_fragment:"#ifdef USE_METALNESSMAP\n\tuniform sampler2D metalnessMap;\n#endif",morphnormal_vertex:"#ifdef USE_MORPHNORMALS\n\tobjectNormal *= morphTargetBaseInfluence;\n\tobjectNormal += morphNormal0 * morphTargetInfluences[ 0 ];\n\tobjectNormal += morphNormal1 * morphTargetInfluences[ 1 ];\n\tobjectNormal += morphNormal2 * morphTargetInfluences[ 2 ];\n\tobjectNormal += morphNormal3 * morphTargetInfluences[ 3 ];\n#endif",morphtarget_pars_vertex:"#ifdef USE_MORPHTARGETS\n\tuniform float morphTargetBaseInfluence;\n\t#ifndef USE_MORPHNORMALS\n\t\tuniform float morphTargetInfluences[ 8 ];\n\t#else\n\t\tuniform float morphTargetInfluences[ 4 ];\n\t#endif\n#endif",morphtarget_vertex:"#ifdef USE_MORPHTARGETS\n\ttransformed *= morphTargetBaseInfluence;\n\ttransformed += morphTarget0 * morphTargetInfluences[ 0 ];\n\ttransformed += morphTarget1 * morphTargetInfluences[ 1 ];\n\ttransformed += morphTarget2 * morphTargetInfluences[ 2 ];\n\ttransformed += morphTarget3 * morphTargetInfluences[ 3 ];\n\t#ifndef USE_MORPHNORMALS\n\t\ttransformed += morphTarget4 * morphTargetInfluences[ 4 ];\n\t\ttransformed += morphTarget5 * morphTargetInfluences[ 5 ];\n\t\ttransformed += morphTarget6 * morphTargetInfluences[ 6 ];\n\t\ttransformed += morphTarget7 * morphTargetInfluences[ 7 ];\n\t#endif\n#endif",normal_fragment_begin:"float faceDirection = gl_FrontFacing ? 1.0 : - 1.0;\n#ifdef FLAT_SHADED\n\tvec3 fdx = vec3( dFdx( vViewPosition.x ), dFdx( vViewPosition.y ), dFdx( vViewPosition.z ) );\n\tvec3 fdy = vec3( dFdy( vViewPosition.x ), dFdy( vViewPosition.y ), dFdy( vViewPosition.z ) );\n\tvec3 normal = normalize( cross( fdx, fdy ) );\n#else\n\tvec3 normal = normalize( vNormal );\n\t#ifdef DOUBLE_SIDED\n\t\tnormal = normal * faceDirection;\n\t#endif\n\t#ifdef USE_TANGENT\n\t\tvec3 tangent = normalize( vTangent );\n\t\tvec3 bitangent = normalize( vBitangent );\n\t\t#ifdef DOUBLE_SIDED\n\t\t\ttangent = tangent * faceDirection;\n\t\t\tbitangent = bitangent * faceDirection;\n\t\t#endif\n\t\t#if defined( TANGENTSPACE_NORMALMAP ) || defined( USE_CLEARCOAT_NORMALMAP )\n\t\t\tmat3 vTBN = mat3( tangent, bitangent, normal );\n\t\t#endif\n\t#endif\n#endif\nvec3 geometryNormal = normal;",normal_fragment_maps:"#ifdef OBJECTSPACE_NORMALMAP\n\tnormal = texture2D( normalMap, vUv ).xyz * 2.0 - 1.0;\n\t#ifdef FLIP_SIDED\n\t\tnormal = - normal;\n\t#endif\n\t#ifdef DOUBLE_SIDED\n\t\tnormal = normal * faceDirection;\n\t#endif\n\tnormal = normalize( normalMatrix * normal );\n#elif defined( TANGENTSPACE_NORMALMAP )\n\tvec3 mapN = texture2D( normalMap, vUv ).xyz * 2.0 - 1.0;\n\tmapN.xy *= normalScale;\n\t#ifdef USE_TANGENT\n\t\tnormal = normalize( vTBN * mapN );\n\t#else\n\t\tnormal = perturbNormal2Arb( -vViewPosition, normal, mapN, faceDirection );\n\t#endif\n#elif defined( USE_BUMPMAP )\n\tnormal = perturbNormalArb( -vViewPosition, normal, dHdxy_fwd(), faceDirection );\n#endif",normalmap_pars_fragment:"#ifdef USE_NORMALMAP\n\tuniform sampler2D normalMap;\n\tuniform vec2 normalScale;\n#endif\n#ifdef OBJECTSPACE_NORMALMAP\n\tuniform mat3 normalMatrix;\n#endif\n#if ! defined ( USE_TANGENT ) && ( defined ( TANGENTSPACE_NORMALMAP ) || defined ( USE_CLEARCOAT_NORMALMAP ) )\n\tvec3 perturbNormal2Arb( vec3 eye_pos, vec3 surf_norm, vec3 mapN, float faceDirection ) {\n\t\tvec3 q0 = vec3( dFdx( eye_pos.x ), dFdx( eye_pos.y ), dFdx( eye_pos.z ) );\n\t\tvec3 q1 = vec3( dFdy( eye_pos.x ), dFdy( eye_pos.y ), dFdy( eye_pos.z ) );\n\t\tvec2 st0 = dFdx( vUv.st );\n\t\tvec2 st1 = dFdy( vUv.st );\n\t\tvec3 N = surf_norm;\n\t\tvec3 q1perp = cross( q1, N );\n\t\tvec3 q0perp = cross( N, q0 );\n\t\tvec3 T = q1perp * st0.x + q0perp * st1.x;\n\t\tvec3 B = q1perp * st0.y + q0perp * st1.y;\n\t\tfloat det = max( dot( T, T ), dot( B, B ) );\n\t\tfloat scale = ( det == 0.0 ) ? 0.0 : faceDirection * inversesqrt( det );\n\t\treturn normalize( T * ( mapN.x * scale ) + B * ( mapN.y * scale ) + N * mapN.z );\n\t}\n#endif",clearcoat_normal_fragment_begin:"#ifdef CLEARCOAT\n\tvec3 clearcoatNormal = geometryNormal;\n#endif",clearcoat_normal_fragment_maps:"#ifdef USE_CLEARCOAT_NORMALMAP\n\tvec3 clearcoatMapN = texture2D( clearcoatNormalMap, vUv ).xyz * 2.0 - 1.0;\n\tclearcoatMapN.xy *= clearcoatNormalScale;\n\t#ifdef USE_TANGENT\n\t\tclearcoatNormal = normalize( vTBN * clearcoatMapN );\n\t#else\n\t\tclearcoatNormal = perturbNormal2Arb( - vViewPosition, clearcoatNormal, clearcoatMapN, faceDirection );\n\t#endif\n#endif",clearcoat_pars_fragment:"#ifdef USE_CLEARCOATMAP\n\tuniform sampler2D clearcoatMap;\n#endif\n#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\tuniform sampler2D clearcoatRoughnessMap;\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\tuniform sampler2D clearcoatNormalMap;\n\tuniform vec2 clearcoatNormalScale;\n#endif",packing:"vec3 packNormalToRGB( const in vec3 normal ) {\n\treturn normalize( normal ) * 0.5 + 0.5;\n}\nvec3 unpackRGBToNormal( const in vec3 rgb ) {\n\treturn 2.0 * rgb.xyz - 1.0;\n}\nconst float PackUpscale = 256. / 255.;const float UnpackDownscale = 255. / 256.;\nconst vec3 PackFactors = vec3( 256. * 256. * 256., 256. * 256., 256. );\nconst vec4 UnpackFactors = UnpackDownscale / vec4( PackFactors, 1. );\nconst float ShiftRight8 = 1. / 256.;\nvec4 packDepthToRGBA( const in float v ) {\n\tvec4 r = vec4( fract( v * PackFactors ), v );\n\tr.yzw -= r.xyz * ShiftRight8;\treturn r * PackUpscale;\n}\nfloat unpackRGBAToDepth( const in vec4 v ) {\n\treturn dot( v, UnpackFactors );\n}\nvec4 pack2HalfToRGBA( vec2 v ) {\n\tvec4 r = vec4( v.x, fract( v.x * 255.0 ), v.y, fract( v.y * 255.0 ));\n\treturn vec4( r.x - r.y / 255.0, r.y, r.z - r.w / 255.0, r.w);\n}\nvec2 unpackRGBATo2Half( vec4 v ) {\n\treturn vec2( v.x + ( v.y / 255.0 ), v.z + ( v.w / 255.0 ) );\n}\nfloat viewZToOrthographicDepth( const in float viewZ, const in float near, const in float far ) {\n\treturn ( viewZ + near ) / ( near - far );\n}\nfloat orthographicDepthToViewZ( const in float linearClipZ, const in float near, const in float far ) {\n\treturn linearClipZ * ( near - far ) - near;\n}\nfloat viewZToPerspectiveDepth( const in float viewZ, const in float near, const in float far ) {\n\treturn (( near + viewZ ) * far ) / (( far - near ) * viewZ );\n}\nfloat perspectiveDepthToViewZ( const in float invClipZ, const in float near, const in float far ) {\n\treturn ( near * far ) / ( ( far - near ) * invClipZ - far );\n}",premultiplied_alpha_fragment:"#ifdef PREMULTIPLIED_ALPHA\n\tgl_FragColor.rgb *= gl_FragColor.a;\n#endif",project_vertex:"vec4 mvPosition = vec4( transformed, 1.0 );\n#ifdef USE_INSTANCING\n\tmvPosition = instanceMatrix * mvPosition;\n#endif\nmvPosition = modelViewMatrix * mvPosition;\ngl_Position = projectionMatrix * mvPosition;",dithering_fragment:"#ifdef DITHERING\n\tgl_FragColor.rgb = dithering( gl_FragColor.rgb );\n#endif",dithering_pars_fragment:"#ifdef DITHERING\n\tvec3 dithering( vec3 color ) {\n\t\tfloat grid_position = rand( gl_FragCoord.xy );\n\t\tvec3 dither_shift_RGB = vec3( 0.25 / 255.0, -0.25 / 255.0, 0.25 / 255.0 );\n\t\tdither_shift_RGB = mix( 2.0 * dither_shift_RGB, -2.0 * dither_shift_RGB, grid_position );\n\t\treturn color + dither_shift_RGB;\n\t}\n#endif",roughnessmap_fragment:"float roughnessFactor = roughness;\n#ifdef USE_ROUGHNESSMAP\n\tvec4 texelRoughness = texture2D( roughnessMap, vUv );\n\troughnessFactor *= texelRoughness.g;\n#endif",roughnessmap_pars_fragment:"#ifdef USE_ROUGHNESSMAP\n\tuniform sampler2D roughnessMap;\n#endif",shadowmap_pars_fragment:"#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D directionalShadowMap[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tvarying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tstruct DirectionalLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform DirectionalLightShadow directionalLightShadows[ NUM_DIR_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D spotShadowMap[ NUM_SPOT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vSpotShadowCoord[ NUM_SPOT_LIGHT_SHADOWS ];\n\t\tstruct SpotLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform SpotLightShadow spotLightShadows[ NUM_SPOT_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D pointShadowMap[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vPointShadowCoord[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tstruct PointLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t\tfloat shadowCameraNear;\n\t\t\tfloat shadowCameraFar;\n\t\t};\n\t\tuniform PointLightShadow pointLightShadows[ NUM_POINT_LIGHT_SHADOWS ];\n\t#endif\n\tfloat texture2DCompare( sampler2D depths, vec2 uv, float compare ) {\n\t\treturn step( compare, unpackRGBAToDepth( texture2D( depths, uv ) ) );\n\t}\n\tvec2 texture2DDistribution( sampler2D shadow, vec2 uv ) {\n\t\treturn unpackRGBATo2Half( texture2D( shadow, uv ) );\n\t}\n\tfloat VSMShadow (sampler2D shadow, vec2 uv, float compare ){\n\t\tfloat occlusion = 1.0;\n\t\tvec2 distribution = texture2DDistribution( shadow, uv );\n\t\tfloat hard_shadow = step( compare , distribution.x );\n\t\tif (hard_shadow != 1.0 ) {\n\t\t\tfloat distance = compare - distribution.x ;\n\t\t\tfloat variance = max( 0.00000, distribution.y * distribution.y );\n\t\t\tfloat softness_probability = variance / (variance + distance * distance );\t\t\tsoftness_probability = clamp( ( softness_probability - 0.3 ) / ( 0.95 - 0.3 ), 0.0, 1.0 );\t\t\tocclusion = clamp( max( hard_shadow, softness_probability ), 0.0, 1.0 );\n\t\t}\n\t\treturn occlusion;\n\t}\n\tfloat getShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowBias, float shadowRadius, vec4 shadowCoord ) {\n\t\tfloat shadow = 1.0;\n\t\tshadowCoord.xyz /= shadowCoord.w;\n\t\tshadowCoord.z += shadowBias;\n\t\tbvec4 inFrustumVec = bvec4 ( shadowCoord.x >= 0.0, shadowCoord.x <= 1.0, shadowCoord.y >= 0.0, shadowCoord.y <= 1.0 );\n\t\tbool inFrustum = all( inFrustumVec );\n\t\tbvec2 frustumTestVec = bvec2( inFrustum, shadowCoord.z <= 1.0 );\n\t\tbool frustumTest = all( frustumTestVec );\n\t\tif ( frustumTest ) {\n\t\t#if defined( SHADOWMAP_TYPE_PCF )\n\t\t\tvec2 texelSize = vec2( 1.0 ) / shadowMapSize;\n\t\t\tfloat dx0 = - texelSize.x * shadowRadius;\n\t\t\tfloat dy0 = - texelSize.y * shadowRadius;\n\t\t\tfloat dx1 = + texelSize.x * shadowRadius;\n\t\t\tfloat dy1 = + texelSize.y * shadowRadius;\n\t\t\tfloat dx2 = dx0 / 2.0;\n\t\t\tfloat dy2 = dy0 / 2.0;\n\t\t\tfloat dx3 = dx1 / 2.0;\n\t\t\tfloat dy3 = dy1 / 2.0;\n\t\t\tshadow = (\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy, shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, dy1 ), shadowCoord.z )\n\t\t\t) * ( 1.0 / 17.0 );\n\t\t#elif defined( SHADOWMAP_TYPE_PCF_SOFT )\n\t\t\tvec2 texelSize = vec2( 1.0 ) / shadowMapSize;\n\t\t\tfloat dx = texelSize.x;\n\t\t\tfloat dy = texelSize.y;\n\t\t\tvec2 uv = shadowCoord.xy;\n\t\t\tvec2 f = fract( uv * shadowMapSize + 0.5 );\n\t\t\tuv -= f * texelSize;\n\t\t\tshadow = (\n\t\t\t\ttexture2DCompare( shadowMap, uv, shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( dx, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( 0.0, dy ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + texelSize, shadowCoord.z ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( -dx, 0.0 ), shadowCoord.z ), \n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, 0.0 ), shadowCoord.z ),\n\t\t\t\t\t f.x ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( -dx, dy ), shadowCoord.z ), \n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, dy ), shadowCoord.z ),\n\t\t\t\t\t f.x ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( 0.0, -dy ), shadowCoord.z ), \n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 0.0, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t f.y ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( dx, -dy ), shadowCoord.z ), \n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( dx, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t f.y ) +\n\t\t\t\tmix( mix( texture2DCompare( shadowMap, uv + vec2( -dx, -dy ), shadowCoord.z ), \n\t\t\t\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( 2.0 * dx, -dy ), shadowCoord.z ),\n\t\t\t\t\t\t\tf.x ),\n\t\t\t\t\t mix( texture2DCompare( shadowMap, uv + vec2( -dx, 2.0 * dy ), shadowCoord.z ), \n\t\t\t\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( 2.0 * dx, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t\t\tf.x ),\n\t\t\t\t\t f.y )\n\t\t\t) * ( 1.0 / 9.0 );\n\t\t#elif defined( SHADOWMAP_TYPE_VSM )\n\t\t\tshadow = VSMShadow( shadowMap, shadowCoord.xy, shadowCoord.z );\n\t\t#else\n\t\t\tshadow = texture2DCompare( shadowMap, shadowCoord.xy, shadowCoord.z );\n\t\t#endif\n\t\t}\n\t\treturn shadow;\n\t}\n\tvec2 cubeToUV( vec3 v, float texelSizeY ) {\n\t\tvec3 absV = abs( v );\n\t\tfloat scaleToCube = 1.0 / max( absV.x, max( absV.y, absV.z ) );\n\t\tabsV *= scaleToCube;\n\t\tv *= scaleToCube * ( 1.0 - 2.0 * texelSizeY );\n\t\tvec2 planar = v.xy;\n\t\tfloat almostATexel = 1.5 * texelSizeY;\n\t\tfloat almostOne = 1.0 - almostATexel;\n\t\tif ( absV.z >= almostOne ) {\n\t\t\tif ( v.z > 0.0 )\n\t\t\t\tplanar.x = 4.0 - v.x;\n\t\t} else if ( absV.x >= almostOne ) {\n\t\t\tfloat signX = sign( v.x );\n\t\t\tplanar.x = v.z * signX + 2.0 * signX;\n\t\t} else if ( absV.y >= almostOne ) {\n\t\t\tfloat signY = sign( v.y );\n\t\t\tplanar.x = v.x + 2.0 * signY + 2.0;\n\t\t\tplanar.y = v.z * signY - 2.0;\n\t\t}\n\t\treturn vec2( 0.125, 0.25 ) * planar + vec2( 0.375, 0.75 );\n\t}\n\tfloat getPointShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowBias, float shadowRadius, vec4 shadowCoord, float shadowCameraNear, float shadowCameraFar ) {\n\t\tvec2 texelSize = vec2( 1.0 ) / ( shadowMapSize * vec2( 4.0, 2.0 ) );\n\t\tvec3 lightToPosition = shadowCoord.xyz;\n\t\tfloat dp = ( length( lightToPosition ) - shadowCameraNear ) / ( shadowCameraFar - shadowCameraNear );\t\tdp += shadowBias;\n\t\tvec3 bd3D = normalize( lightToPosition );\n\t\t#if defined( SHADOWMAP_TYPE_PCF ) || defined( SHADOWMAP_TYPE_PCF_SOFT ) || defined( SHADOWMAP_TYPE_VSM )\n\t\t\tvec2 offset = vec2( - 1, 1 ) * shadowRadius * texelSize.y;\n\t\t\treturn (\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xyy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yyy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xyx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yyx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xxy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yxy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xxx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yxx, texelSize.y ), dp )\n\t\t\t) * ( 1.0 / 9.0 );\n\t\t#else\n\t\t\treturn texture2DCompare( shadowMap, cubeToUV( bd3D, texelSize.y ), dp );\n\t\t#endif\n\t}\n#endif",shadowmap_pars_vertex:"#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t\tuniform mat4 directionalShadowMatrix[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tvarying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tstruct DirectionalLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform DirectionalLightShadow directionalLightShadows[ NUM_DIR_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\t\tuniform mat4 spotShadowMatrix[ NUM_SPOT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vSpotShadowCoord[ NUM_SPOT_LIGHT_SHADOWS ];\n\t\tstruct SpotLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform SpotLightShadow spotLightShadows[ NUM_SPOT_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t\tuniform mat4 pointShadowMatrix[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vPointShadowCoord[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tstruct PointLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t\tfloat shadowCameraNear;\n\t\t\tfloat shadowCameraFar;\n\t\t};\n\t\tuniform PointLightShadow pointLightShadows[ NUM_POINT_LIGHT_SHADOWS ];\n\t#endif\n#endif",shadowmap_vertex:"#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0 || NUM_SPOT_LIGHT_SHADOWS > 0 || NUM_POINT_LIGHT_SHADOWS > 0\n\t\tvec3 shadowWorldNormal = inverseTransformDirection( transformedNormal, viewMatrix );\n\t\tvec4 shadowWorldPosition;\n\t#endif\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) {\n\t\tshadowWorldPosition = worldPosition + vec4( shadowWorldNormal * directionalLightShadows[ i ].shadowNormalBias, 0 );\n\t\tvDirectionalShadowCoord[ i ] = directionalShadowMatrix[ i ] * shadowWorldPosition;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHT_SHADOWS; i ++ ) {\n\t\tshadowWorldPosition = worldPosition + vec4( shadowWorldNormal * spotLightShadows[ i ].shadowNormalBias, 0 );\n\t\tvSpotShadowCoord[ i ] = spotShadowMatrix[ i ] * shadowWorldPosition;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHT_SHADOWS; i ++ ) {\n\t\tshadowWorldPosition = worldPosition + vec4( shadowWorldNormal * pointLightShadows[ i ].shadowNormalBias, 0 );\n\t\tvPointShadowCoord[ i ] = pointShadowMatrix[ i ] * shadowWorldPosition;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n#endif",shadowmask_pars_fragment:"float getShadowMask() {\n\tfloat shadow = 1.0;\n\t#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\tDirectionalLightShadow directionalLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) {\n\t\tdirectionalLight = directionalLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getShadow( directionalShadowMap[ i ], directionalLight.shadowMapSize, directionalLight.shadowBias, directionalLight.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\tSpotLightShadow spotLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHT_SHADOWS; i ++ ) {\n\t\tspotLight = spotLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getShadow( spotShadowMap[ i ], spotLight.shadowMapSize, spotLight.shadowBias, spotLight.shadowRadius, vSpotShadowCoord[ i ] ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\tPointLightShadow pointLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHT_SHADOWS; i ++ ) {\n\t\tpointLight = pointLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getPointShadow( pointShadowMap[ i ], pointLight.shadowMapSize, pointLight.shadowBias, pointLight.shadowRadius, vPointShadowCoord[ i ], pointLight.shadowCameraNear, pointLight.shadowCameraFar ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#endif\n\treturn shadow;\n}",skinbase_vertex:"#ifdef USE_SKINNING\n\tmat4 boneMatX = getBoneMatrix( skinIndex.x );\n\tmat4 boneMatY = getBoneMatrix( skinIndex.y );\n\tmat4 boneMatZ = getBoneMatrix( skinIndex.z );\n\tmat4 boneMatW = getBoneMatrix( skinIndex.w );\n#endif",skinning_pars_vertex:"#ifdef USE_SKINNING\n\tuniform mat4 bindMatrix;\n\tuniform mat4 bindMatrixInverse;\n\t#ifdef BONE_TEXTURE\n\t\tuniform highp sampler2D boneTexture;\n\t\tuniform int boneTextureSize;\n\t\tmat4 getBoneMatrix( const in float i ) {\n\t\t\tfloat j = i * 4.0;\n\t\t\tfloat x = mod( j, float( boneTextureSize ) );\n\t\t\tfloat y = floor( j / float( boneTextureSize ) );\n\t\t\tfloat dx = 1.0 / float( boneTextureSize );\n\t\t\tfloat dy = 1.0 / float( boneTextureSize );\n\t\t\ty = dy * ( y + 0.5 );\n\t\t\tvec4 v1 = texture2D( boneTexture, vec2( dx * ( x + 0.5 ), y ) );\n\t\t\tvec4 v2 = texture2D( boneTexture, vec2( dx * ( x + 1.5 ), y ) );\n\t\t\tvec4 v3 = texture2D( boneTexture, vec2( dx * ( x + 2.5 ), y ) );\n\t\t\tvec4 v4 = texture2D( boneTexture, vec2( dx * ( x + 3.5 ), y ) );\n\t\t\tmat4 bone = mat4( v1, v2, v3, v4 );\n\t\t\treturn bone;\n\t\t}\n\t#else\n\t\tuniform mat4 boneMatrices[ MAX_BONES ];\n\t\tmat4 getBoneMatrix( const in float i ) {\n\t\t\tmat4 bone = boneMatrices[ int(i) ];\n\t\t\treturn bone;\n\t\t}\n\t#endif\n#endif",skinning_vertex:"#ifdef USE_SKINNING\n\tvec4 skinVertex = bindMatrix * vec4( transformed, 1.0 );\n\tvec4 skinned = vec4( 0.0 );\n\tskinned += boneMatX * skinVertex * skinWeight.x;\n\tskinned += boneMatY * skinVertex * skinWeight.y;\n\tskinned += boneMatZ * skinVertex * skinWeight.z;\n\tskinned += boneMatW * skinVertex * skinWeight.w;\n\ttransformed = ( bindMatrixInverse * skinned ).xyz;\n#endif",skinnormal_vertex:"#ifdef USE_SKINNING\n\tmat4 skinMatrix = mat4( 0.0 );\n\tskinMatrix += skinWeight.x * boneMatX;\n\tskinMatrix += skinWeight.y * boneMatY;\n\tskinMatrix += skinWeight.z * boneMatZ;\n\tskinMatrix += skinWeight.w * boneMatW;\n\tskinMatrix = bindMatrixInverse * skinMatrix * bindMatrix;\n\tobjectNormal = vec4( skinMatrix * vec4( objectNormal, 0.0 ) ).xyz;\n\t#ifdef USE_TANGENT\n\t\tobjectTangent = vec4( skinMatrix * vec4( objectTangent, 0.0 ) ).xyz;\n\t#endif\n#endif",specularmap_fragment:"float specularStrength;\n#ifdef USE_SPECULARMAP\n\tvec4 texelSpecular = texture2D( specularMap, vUv );\n\tspecularStrength = texelSpecular.r;\n#else\n\tspecularStrength = 1.0;\n#endif",specularmap_pars_fragment:"#ifdef USE_SPECULARMAP\n\tuniform sampler2D specularMap;\n#endif",tonemapping_fragment:"#if defined( TONE_MAPPING )\n\tgl_FragColor.rgb = toneMapping( gl_FragColor.rgb );\n#endif",tonemapping_pars_fragment:"#ifndef saturate\n#define saturate(a) clamp( a, 0.0, 1.0 )\n#endif\nuniform float toneMappingExposure;\nvec3 LinearToneMapping( vec3 color ) {\n\treturn toneMappingExposure * color;\n}\nvec3 ReinhardToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\treturn saturate( color / ( vec3( 1.0 ) + color ) );\n}\nvec3 OptimizedCineonToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\tcolor = max( vec3( 0.0 ), color - 0.004 );\n\treturn pow( ( color * ( 6.2 * color + 0.5 ) ) / ( color * ( 6.2 * color + 1.7 ) + 0.06 ), vec3( 2.2 ) );\n}\nvec3 RRTAndODTFit( vec3 v ) {\n\tvec3 a = v * ( v + 0.0245786 ) - 0.000090537;\n\tvec3 b = v * ( 0.983729 * v + 0.4329510 ) + 0.238081;\n\treturn a / b;\n}\nvec3 ACESFilmicToneMapping( vec3 color ) {\n\tconst mat3 ACESInputMat = mat3(\n\t\tvec3( 0.59719, 0.07600, 0.02840 ),\t\tvec3( 0.35458, 0.90834, 0.13383 ),\n\t\tvec3( 0.04823, 0.01566, 0.83777 )\n\t);\n\tconst mat3 ACESOutputMat = mat3(\n\t\tvec3(\t1.60475, -0.10208, -0.00327 ),\t\tvec3( -0.53108,\t1.10813, -0.07276 ),\n\t\tvec3( -0.07367, -0.00605,\t1.07602 )\n\t);\n\tcolor *= toneMappingExposure / 0.6;\n\tcolor = ACESInputMat * color;\n\tcolor = RRTAndODTFit( color );\n\tcolor = ACESOutputMat * color;\n\treturn saturate( color );\n}\nvec3 CustomToneMapping( vec3 color ) { return color; }",transmissionmap_fragment:"#ifdef USE_TRANSMISSIONMAP\n\ttotalTransmission *= texture2D( transmissionMap, vUv ).r;\n#endif",transmissionmap_pars_fragment:"#ifdef USE_TRANSMISSIONMAP\n\tuniform sampler2D transmissionMap;\n#endif",uv_pars_fragment:"#if ( defined( USE_UV ) && ! defined( UVS_VERTEX_ONLY ) )\n\tvarying vec2 vUv;\n#endif",uv_pars_vertex:"#ifdef USE_UV\n\t#ifdef UVS_VERTEX_ONLY\n\t\tvec2 vUv;\n\t#else\n\t\tvarying vec2 vUv;\n\t#endif\n\tuniform mat3 uvTransform;\n#endif",uv_vertex:"#ifdef USE_UV\n\tvUv = ( uvTransform * vec3( uv, 1 ) ).xy;\n#endif",uv2_pars_fragment:"#if defined( USE_LIGHTMAP ) || defined( USE_AOMAP )\n\tvarying vec2 vUv2;\n#endif",uv2_pars_vertex:"#if defined( USE_LIGHTMAP ) || defined( USE_AOMAP )\n\tattribute vec2 uv2;\n\tvarying vec2 vUv2;\n\tuniform mat3 uv2Transform;\n#endif",uv2_vertex:"#if defined( USE_LIGHTMAP ) || defined( USE_AOMAP )\n\tvUv2 = ( uv2Transform * vec3( uv2, 1 ) ).xy;\n#endif",worldpos_vertex:"#if defined( USE_ENVMAP ) || defined( DISTANCE ) || defined ( USE_SHADOWMAP )\n\tvec4 worldPosition = vec4( transformed, 1.0 );\n\t#ifdef USE_INSTANCING\n\t\tworldPosition = instanceMatrix * worldPosition;\n\t#endif\n\tworldPosition = modelMatrix * worldPosition;\n#endif",background_frag:"uniform sampler2D t2D;\nvarying vec2 vUv;\nvoid main() {\n\tvec4 texColor = texture2D( t2D, vUv );\n\tgl_FragColor = mapTexelToLinear( texColor );\n\t#include \n\t#include \n}",background_vert:"varying vec2 vUv;\nuniform mat3 uvTransform;\nvoid main() {\n\tvUv = ( uvTransform * vec3( uv, 1 ) ).xy;\n\tgl_Position = vec4( position.xy, 1.0, 1.0 );\n}",cube_frag:"#include \nuniform float opacity;\nvarying vec3 vWorldDirection;\n#include \nvoid main() {\n\tvec3 vReflect = vWorldDirection;\n\t#include \n\tgl_FragColor = envColor;\n\tgl_FragColor.a *= opacity;\n\t#include \n\t#include \n}",cube_vert:"varying vec3 vWorldDirection;\n#include \nvoid main() {\n\tvWorldDirection = transformDirection( position, modelMatrix );\n\t#include \n\t#include \n\tgl_Position.z = gl_Position.w;\n}",depth_frag:"#if DEPTH_PACKING == 3200\n\tuniform float opacity;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvarying vec2 vHighPrecisionZW;\nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( 1.0 );\n\t#if DEPTH_PACKING == 3200\n\t\tdiffuseColor.a = opacity;\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\tfloat fragCoordZ = 0.5 * vHighPrecisionZW[0] / vHighPrecisionZW[1] + 0.5;\n\t#if DEPTH_PACKING == 3200\n\t\tgl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );\n\t#elif DEPTH_PACKING == 3201\n\t\tgl_FragColor = packDepthToRGBA( fragCoordZ );\n\t#endif\n}",depth_vert:"#include \n#include \n#include \n#include \n#include \n#include \n#include \nvarying vec2 vHighPrecisionZW;\nvoid main() {\n\t#include \n\t#include \n\t#ifdef USE_DISPLACEMENTMAP\n\t\t#include \n\t\t#include \n\t\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvHighPrecisionZW = gl_Position.zw;\n}",distanceRGBA_frag:"#define DISTANCE\nuniform vec3 referencePosition;\nuniform float nearDistance;\nuniform float farDistance;\nvarying vec3 vWorldPosition;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main () {\n\t#include \n\tvec4 diffuseColor = vec4( 1.0 );\n\t#include \n\t#include \n\t#include \n\tfloat dist = length( vWorldPosition - referencePosition );\n\tdist = ( dist - nearDistance ) / ( farDistance - nearDistance );\n\tdist = saturate( dist );\n\tgl_FragColor = packDepthToRGBA( dist );\n}",distanceRGBA_vert:"#define DISTANCE\nvarying vec3 vWorldPosition;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#ifdef USE_DISPLACEMENTMAP\n\t\t#include \n\t\t#include \n\t\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvWorldPosition = worldPosition.xyz;\n}",equirect_frag:"uniform sampler2D tEquirect;\nvarying vec3 vWorldDirection;\n#include \nvoid main() {\n\tvec3 direction = normalize( vWorldDirection );\n\tvec2 sampleUV = equirectUv( direction );\n\tvec4 texColor = texture2D( tEquirect, sampleUV );\n\tgl_FragColor = mapTexelToLinear( texColor );\n\t#include \n\t#include \n}",equirect_vert:"varying vec3 vWorldDirection;\n#include \nvoid main() {\n\tvWorldDirection = transformDirection( position, modelMatrix );\n\t#include \n\t#include \n}",linedashed_frag:"uniform vec3 diffuse;\nuniform float opacity;\nuniform float dashSize;\nuniform float totalSize;\nvarying float vLineDistance;\n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tif ( mod( vLineDistance, totalSize ) > dashSize ) {\n\t\tdiscard;\n\t}\n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\toutgoingLight = diffuseColor.rgb;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n}",linedashed_vert:"uniform float scale;\nattribute float lineDistance;\nvarying float vLineDistance;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\tvLineDistance = scale * lineDistance;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshbasic_frag:"uniform vec3 diffuse;\nuniform float opacity;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\t#ifdef USE_LIGHTMAP\n\t\n\t\tvec4 lightMapTexel= texture2D( lightMap, vUv2 );\n\t\treflectedLight.indirectDiffuse += lightMapTexelToLinear( lightMapTexel ).rgb * lightMapIntensity;\n\t#else\n\t\treflectedLight.indirectDiffuse += vec3( 1.0 );\n\t#endif\n\t#include \n\treflectedLight.indirectDiffuse *= diffuseColor.rgb;\n\tvec3 outgoingLight = reflectedLight.indirectDiffuse;\n\t#include \n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshbasic_vert:"#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#ifdef USE_ENVMAP\n\t#include \n\t#include \n\t#include \n\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshlambert_frag:"uniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float opacity;\nvarying vec3 vLightFront;\nvarying vec3 vIndirectFront;\n#ifdef DOUBLE_SIDED\n\tvarying vec3 vLightBack;\n\tvarying vec3 vIndirectBack;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#ifdef DOUBLE_SIDED\n\t\treflectedLight.indirectDiffuse += ( gl_FrontFacing ) ? vIndirectFront : vIndirectBack;\n\t#else\n\t\treflectedLight.indirectDiffuse += vIndirectFront;\n\t#endif\n\t#include \n\treflectedLight.indirectDiffuse *= BRDF_Diffuse_Lambert( diffuseColor.rgb );\n\t#ifdef DOUBLE_SIDED\n\t\treflectedLight.directDiffuse = ( gl_FrontFacing ) ? vLightFront : vLightBack;\n\t#else\n\t\treflectedLight.directDiffuse = vLightFront;\n\t#endif\n\treflectedLight.directDiffuse *= BRDF_Diffuse_Lambert( diffuseColor.rgb ) * getShadowMask();\n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + totalEmissiveRadiance;\n\t#include \n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshlambert_vert:"#define LAMBERT\nvarying vec3 vLightFront;\nvarying vec3 vIndirectFront;\n#ifdef DOUBLE_SIDED\n\tvarying vec3 vLightBack;\n\tvarying vec3 vIndirectBack;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshmatcap_frag:"#define MATCAP\nuniform vec3 diffuse;\nuniform float opacity;\nuniform sampler2D matcap;\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 viewDir = normalize( vViewPosition );\n\tvec3 x = normalize( vec3( viewDir.z, 0.0, - viewDir.x ) );\n\tvec3 y = cross( viewDir, x );\n\tvec2 uv = vec2( dot( x, normal ), dot( y, normal ) ) * 0.495 + 0.5;\n\t#ifdef USE_MATCAP\n\t\tvec4 matcapColor = texture2D( matcap, uv );\n\t\tmatcapColor = matcapTexelToLinear( matcapColor );\n\t#else\n\t\tvec4 matcapColor = vec4( 1.0 );\n\t#endif\n\tvec3 outgoingLight = diffuseColor.rgb * matcapColor.rgb;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshmatcap_vert:"#define MATCAP\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#ifndef FLAT_SHADED\n\t\tvNormal = normalize( transformedNormal );\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n}",meshtoon_frag:"#define TOON\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + totalEmissiveRadiance;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshtoon_vert:"#define TOON\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n\t#include \n\t#include \n\t#include \n}",meshphong_frag:"#define PHONG\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform vec3 specular;\nuniform float shininess;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance;\n\t#include \n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshphong_vert:"#define PHONG\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n\t#include \n\t#include \n\t#include \n\t#include \n}",meshphysical_frag:"#define STANDARD\n#ifdef PHYSICAL\n\t#define REFLECTIVITY\n\t#define CLEARCOAT\n\t#define TRANSMISSION\n#endif\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float roughness;\nuniform float metalness;\nuniform float opacity;\n#ifdef TRANSMISSION\n\tuniform float transmission;\n#endif\n#ifdef REFLECTIVITY\n\tuniform float reflectivity;\n#endif\n#ifdef CLEARCOAT\n\tuniform float clearcoat;\n\tuniform float clearcoatRoughness;\n#endif\n#ifdef USE_SHEEN\n\tuniform vec3 sheen;\n#endif\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#ifdef TRANSMISSION\n\t\tfloat totalTransmission = transmission;\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance;\n\t#ifdef TRANSMISSION\n\t\tdiffuseColor.a *= mix( saturate( 1. - totalTransmission + linearToRelativeLuminance( reflectedLight.directSpecular + reflectedLight.indirectSpecular ) ), 1.0, metalness );\n\t#endif\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshphysical_vert:"#define STANDARD\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n\t#ifdef USE_TANGENT\n\t\tvTangent = normalize( transformedTangent );\n\t\tvBitangent = normalize( cross( vNormal, vTangent ) * tangent.w );\n\t#endif\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n\t#include \n\t#include \n\t#include \n}",normal_frag:"#define NORMAL\nuniform float opacity;\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( TANGENTSPACE_NORMALMAP )\n\tvarying vec3 vViewPosition;\n#endif\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\tgl_FragColor = vec4( packNormalToRGB( normal ), opacity );\n}",normal_vert:"#define NORMAL\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( TANGENTSPACE_NORMALMAP )\n\tvarying vec3 vViewPosition;\n#endif\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n\t#ifdef USE_TANGENT\n\t\tvTangent = normalize( transformedTangent );\n\t\tvBitangent = normalize( cross( vNormal, vTangent ) * tangent.w );\n\t#endif\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( TANGENTSPACE_NORMALMAP )\n\tvViewPosition = - mvPosition.xyz;\n#endif\n}",points_frag:"uniform vec3 diffuse;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\toutgoingLight = diffuseColor.rgb;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n}",points_vert:"uniform float size;\nuniform float scale;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\tgl_PointSize = size;\n\t#ifdef USE_SIZEATTENUATION\n\t\tbool isPerspective = isPerspectiveMatrix( projectionMatrix );\n\t\tif ( isPerspective ) gl_PointSize *= ( scale / - mvPosition.z );\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n}",shadow_frag:"uniform vec3 color;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\tgl_FragColor = vec4( color, opacity * ( 1.0 - getShadowMask() ) );\n\t#include \n\t#include \n\t#include \n}",shadow_vert:"#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",sprite_frag:"uniform vec3 diffuse;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\toutgoingLight = diffuseColor.rgb;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n}",sprite_vert:"uniform float rotation;\nuniform vec2 center;\n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 mvPosition = modelViewMatrix * vec4( 0.0, 0.0, 0.0, 1.0 );\n\tvec2 scale;\n\tscale.x = length( vec3( modelMatrix[ 0 ].x, modelMatrix[ 0 ].y, modelMatrix[ 0 ].z ) );\n\tscale.y = length( vec3( modelMatrix[ 1 ].x, modelMatrix[ 1 ].y, modelMatrix[ 1 ].z ) );\n\t#ifndef USE_SIZEATTENUATION\n\t\tbool isPerspective = isPerspectiveMatrix( projectionMatrix );\n\t\tif ( isPerspective ) scale *= - mvPosition.z;\n\t#endif\n\tvec2 alignedPosition = ( position.xy - ( center - vec2( 0.5 ) ) ) * scale;\n\tvec2 rotatedPosition;\n\trotatedPosition.x = cos( rotation ) * alignedPosition.x - sin( rotation ) * alignedPosition.y;\n\trotatedPosition.y = sin( rotation ) * alignedPosition.x + cos( rotation ) * alignedPosition.y;\n\tmvPosition.xy += rotatedPosition;\n\tgl_Position = projectionMatrix * mvPosition;\n\t#include \n\t#include \n\t#include \n}"},ui={common:{diffuse:{value:new tn(15658734)},opacity:{value:1},map:{value:null},uvTransform:{value:new yt},uv2Transform:{value:new yt},alphaMap:{value:null}},specularmap:{specularMap:{value:null}},envmap:{envMap:{value:null},flipEnvMap:{value:-1},reflectivity:{value:1},refractionRatio:{value:.98},maxMipLevel:{value:0}},aomap:{aoMap:{value:null},aoMapIntensity:{value:1}},lightmap:{lightMap:{value:null},lightMapIntensity:{value:1}},emissivemap:{emissiveMap:{value:null}},bumpmap:{bumpMap:{value:null},bumpScale:{value:1}},normalmap:{normalMap:{value:null},normalScale:{value:new vt(1,1)}},displacementmap:{displacementMap:{value:null},displacementScale:{value:1},displacementBias:{value:0}},roughnessmap:{roughnessMap:{value:null}},metalnessmap:{metalnessMap:{value:null}},gradientmap:{gradientMap:{value:null}},fog:{fogDensity:{value:25e-5},fogNear:{value:1},fogFar:{value:2e3},fogColor:{value:new tn(16777215)}},lights:{ambientLightColor:{value:[]},lightProbe:{value:[]},directionalLights:{value:[],properties:{direction:{},color:{}}},directionalLightShadows:{value:[],properties:{shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{}}},directionalShadowMap:{value:[]},directionalShadowMatrix:{value:[]},spotLights:{value:[],properties:{color:{},position:{},direction:{},distance:{},coneCos:{},penumbraCos:{},decay:{}}},spotLightShadows:{value:[],properties:{shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{}}},spotShadowMap:{value:[]},spotShadowMatrix:{value:[]},pointLights:{value:[],properties:{color:{},position:{},decay:{},distance:{}}},pointLightShadows:{value:[],properties:{shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{},shadowCameraNear:{},shadowCameraFar:{}}},pointShadowMap:{value:[]},pointShadowMatrix:{value:[]},hemisphereLights:{value:[],properties:{direction:{},skyColor:{},groundColor:{}}},rectAreaLights:{value:[],properties:{color:{},position:{},width:{},height:{}}},ltc_1:{value:null},ltc_2:{value:null}},points:{diffuse:{value:new tn(15658734)},opacity:{value:1},size:{value:1},scale:{value:1},map:{value:null},alphaMap:{value:null},uvTransform:{value:new yt}},sprite:{diffuse:{value:new tn(15658734)},opacity:{value:1},center:{value:new vt(.5,.5)},rotation:{value:0},map:{value:null},alphaMap:{value:null},uvTransform:{value:new yt}}},di={basic:{uniforms:Yn([ui.common,ui.specularmap,ui.envmap,ui.aomap,ui.lightmap,ui.fog]),vertexShader:hi.meshbasic_vert,fragmentShader:hi.meshbasic_frag},lambert:{uniforms:Yn([ui.common,ui.specularmap,ui.envmap,ui.aomap,ui.lightmap,ui.emissivemap,ui.fog,ui.lights,{emissive:{value:new tn(0)}}]),vertexShader:hi.meshlambert_vert,fragmentShader:hi.meshlambert_frag},phong:{uniforms:Yn([ui.common,ui.specularmap,ui.envmap,ui.aomap,ui.lightmap,ui.emissivemap,ui.bumpmap,ui.normalmap,ui.displacementmap,ui.fog,ui.lights,{emissive:{value:new tn(0)},specular:{value:new tn(1118481)},shininess:{value:30}}]),vertexShader:hi.meshphong_vert,fragmentShader:hi.meshphong_frag},standard:{uniforms:Yn([ui.common,ui.envmap,ui.aomap,ui.lightmap,ui.emissivemap,ui.bumpmap,ui.normalmap,ui.displacementmap,ui.roughnessmap,ui.metalnessmap,ui.fog,ui.lights,{emissive:{value:new tn(0)},roughness:{value:1},metalness:{value:0},envMapIntensity:{value:1}}]),vertexShader:hi.meshphysical_vert,fragmentShader:hi.meshphysical_frag},toon:{uniforms:Yn([ui.common,ui.aomap,ui.lightmap,ui.emissivemap,ui.bumpmap,ui.normalmap,ui.displacementmap,ui.gradientmap,ui.fog,ui.lights,{emissive:{value:new tn(0)}}]),vertexShader:hi.meshtoon_vert,fragmentShader:hi.meshtoon_frag},matcap:{uniforms:Yn([ui.common,ui.bumpmap,ui.normalmap,ui.displacementmap,ui.fog,{matcap:{value:null}}]),vertexShader:hi.meshmatcap_vert,fragmentShader:hi.meshmatcap_frag},points:{uniforms:Yn([ui.points,ui.fog]),vertexShader:hi.points_vert,fragmentShader:hi.points_frag},dashed:{uniforms:Yn([ui.common,ui.fog,{scale:{value:1},dashSize:{value:1},totalSize:{value:2}}]),vertexShader:hi.linedashed_vert,fragmentShader:hi.linedashed_frag},depth:{uniforms:Yn([ui.common,ui.displacementmap]),vertexShader:hi.depth_vert,fragmentShader:hi.depth_frag},normal:{uniforms:Yn([ui.common,ui.bumpmap,ui.normalmap,ui.displacementmap,{opacity:{value:1}}]),vertexShader:hi.normal_vert,fragmentShader:hi.normal_frag},sprite:{uniforms:Yn([ui.sprite,ui.fog]),vertexShader:hi.sprite_vert,fragmentShader:hi.sprite_frag},background:{uniforms:{uvTransform:{value:new yt},t2D:{value:null}},vertexShader:hi.background_vert,fragmentShader:hi.background_frag},cube:{uniforms:Yn([ui.envmap,{opacity:{value:1}}]),vertexShader:hi.cube_vert,fragmentShader:hi.cube_frag},equirect:{uniforms:{tEquirect:{value:null}},vertexShader:hi.equirect_vert,fragmentShader:hi.equirect_frag},distanceRGBA:{uniforms:Yn([ui.common,ui.displacementmap,{referencePosition:{value:new Lt},nearDistance:{value:1},farDistance:{value:1e3}}]),vertexShader:hi.distanceRGBA_vert,fragmentShader:hi.distanceRGBA_frag},shadow:{uniforms:Yn([ui.lights,ui.fog,{color:{value:new tn(0)},opacity:{value:1}}]),vertexShader:hi.shadow_vert,fragmentShader:hi.shadow_frag}};function pi(t,e,n,i,r){const s=new tn(0);let a,o,c=0,h=null,u=0,d=null;function p(t,e){n.buffers.color.setClear(t.r,t.g,t.b,e,r)}return{getClearColor:function(){return s},setClearColor:function(t,e=1){s.set(t),c=e,p(s,c)},getClearAlpha:function(){return c},setClearAlpha:function(t){c=t,p(s,c)},render:function(n,r,m,f){let g=!0===r.isScene?r.background:null;g&&g.isTexture&&(g=e.get(g));const v=t.xr,y=v.getSession&&v.getSession();y&&"additive"===y.environmentBlendMode&&(g=null),null===g?p(s,c):g&&g.isColor&&(p(g,1),f=!0),(t.autoClear||f)&&t.clear(t.autoClearColor,t.autoClearDepth,t.autoClearStencil),g&&(g.isCubeTexture||g.mapping===l)?(void 0===o&&(o=new Wn(new qn(1,1,1),new Jn({name:"BackgroundCubeMaterial",uniforms:Xn(di.cube.uniforms),vertexShader:di.cube.vertexShader,fragmentShader:di.cube.fragmentShader,side:1,depthTest:!1,depthWrite:!1,fog:!1})),o.geometry.deleteAttribute("normal"),o.geometry.deleteAttribute("uv"),o.onBeforeRender=function(t,e,n){this.matrixWorld.copyPosition(n.matrixWorld)},Object.defineProperty(o.material,"envMap",{get:function(){return this.uniforms.envMap.value}}),i.update(o)),o.material.uniforms.envMap.value=g,o.material.uniforms.flipEnvMap.value=g.isCubeTexture&&g._needsFlipEnvMap?-1:1,h===g&&u===g.version&&d===t.toneMapping||(o.material.needsUpdate=!0,h=g,u=g.version,d=t.toneMapping),n.unshift(o,o.geometry,o.material,0,0,null)):g&&g.isTexture&&(void 0===a&&(a=new Wn(new ci(2,2),new Jn({name:"BackgroundMaterial",uniforms:Xn(di.background.uniforms),vertexShader:di.background.vertexShader,fragmentShader:di.background.fragmentShader,side:0,depthTest:!1,depthWrite:!1,fog:!1})),a.geometry.deleteAttribute("normal"),Object.defineProperty(a.material,"map",{get:function(){return this.uniforms.t2D.value}}),i.update(a)),a.material.uniforms.t2D.value=g,!0===g.matrixAutoUpdate&&g.updateMatrix(),a.material.uniforms.uvTransform.value.copy(g.matrix),h===g&&u===g.version&&d===t.toneMapping||(a.material.needsUpdate=!0,h=g,u=g.version,d=t.toneMapping),n.unshift(a,a.geometry,a.material,0,0,null))}}}function mi(t,e,n,i){const r=t.getParameter(34921),s=i.isWebGL2?null:e.get("OES_vertex_array_object"),a=i.isWebGL2||null!==s,o={},l=d(null);let c=l;function h(e){return i.isWebGL2?t.bindVertexArray(e):s.bindVertexArrayOES(e)}function u(e){return i.isWebGL2?t.deleteVertexArray(e):s.deleteVertexArrayOES(e)}function d(t){const e=[],n=[],i=[];for(let t=0;t=0){const s=l[e];if(void 0!==s){const e=s.normalized,r=s.itemSize,a=n.get(s);if(void 0===a)continue;const l=a.buffer,c=a.type,h=a.bytesPerElement;if(s.isInterleavedBufferAttribute){const n=s.data,a=n.stride,u=s.offset;n&&n.isInstancedInterleavedBuffer?(f(i,n.meshPerAttribute),void 0===o._maxInstanceCount&&(o._maxInstanceCount=n.meshPerAttribute*n.count)):m(i),t.bindBuffer(34962,l),v(i,r,c,e,a*h,u*h)}else s.isInstancedBufferAttribute?(f(i,s.meshPerAttribute),void 0===o._maxInstanceCount&&(o._maxInstanceCount=s.meshPerAttribute*s.count)):m(i),t.bindBuffer(34962,l),v(i,r,c,e,0,0)}else if("instanceMatrix"===e){const e=n.get(r.instanceMatrix);if(void 0===e)continue;const s=e.buffer,a=e.type;f(i+0,1),f(i+1,1),f(i+2,1),f(i+3,1),t.bindBuffer(34962,s),t.vertexAttribPointer(i+0,4,a,!1,64,0),t.vertexAttribPointer(i+1,4,a,!1,64,16),t.vertexAttribPointer(i+2,4,a,!1,64,32),t.vertexAttribPointer(i+3,4,a,!1,64,48)}else if("instanceColor"===e){const e=n.get(r.instanceColor);if(void 0===e)continue;const s=e.buffer,a=e.type;f(i,1),t.bindBuffer(34962,s),t.vertexAttribPointer(i,3,a,!1,12,0)}else if(void 0!==h){const n=h[e];if(void 0!==n)switch(n.length){case 2:t.vertexAttrib2fv(i,n);break;case 3:t.vertexAttrib3fv(i,n);break;case 4:t.vertexAttrib4fv(i,n);break;default:t.vertexAttrib1fv(i,n)}}}}g()}(r,l,u,y),null!==x&&t.bindBuffer(34963,n.get(x).buffer))},reset:y,resetDefaultState:x,dispose:function(){y();for(const t in o){const e=o[t];for(const t in e){const n=e[t];for(const t in n)u(n[t].object),delete n[t];delete e[t]}delete o[t]}},releaseStatesOfGeometry:function(t){if(void 0===o[t.id])return;const e=o[t.id];for(const t in e){const n=e[t];for(const t in n)u(n[t].object),delete n[t];delete e[t]}delete o[t.id]},releaseStatesOfProgram:function(t){for(const e in o){const n=o[e];if(void 0===n[t.id])continue;const i=n[t.id];for(const t in i)u(i[t].object),delete i[t];delete n[t.id]}},initAttributes:p,enableAttribute:m,disableUnusedAttributes:g}}function fi(t,e,n,i){const r=i.isWebGL2;let s;this.setMode=function(t){s=t},this.render=function(e,i){t.drawArrays(s,e,i),n.update(i,s,1)},this.renderInstances=function(i,a,o){if(0===o)return;let l,c;if(r)l=t,c="drawArraysInstanced";else if(l=e.get("ANGLE_instanced_arrays"),c="drawArraysInstancedANGLE",null===l)return void console.error("THREE.WebGLBufferRenderer: using THREE.InstancedBufferGeometry but hardware does not support extension ANGLE_instanced_arrays.");l[c](s,i,a,o),n.update(a,s,o)}}function gi(t,e,n){let i;function r(e){if("highp"===e){if(t.getShaderPrecisionFormat(35633,36338).precision>0&&t.getShaderPrecisionFormat(35632,36338).precision>0)return"highp";e="mediump"}return"mediump"===e&&t.getShaderPrecisionFormat(35633,36337).precision>0&&t.getShaderPrecisionFormat(35632,36337).precision>0?"mediump":"lowp"}const s="undefined"!=typeof WebGL2RenderingContext&&t instanceof WebGL2RenderingContext||"undefined"!=typeof WebGL2ComputeRenderingContext&&t instanceof WebGL2ComputeRenderingContext;let a=void 0!==n.precision?n.precision:"highp";const o=r(a);o!==a&&(console.warn("THREE.WebGLRenderer:",a,"not supported, using",o,"instead."),a=o);const l=!0===n.logarithmicDepthBuffer,c=t.getParameter(34930),h=t.getParameter(35660),u=t.getParameter(3379),d=t.getParameter(34076),p=t.getParameter(34921),m=t.getParameter(36347),f=t.getParameter(36348),g=t.getParameter(36349),v=h>0,y=s||e.has("OES_texture_float");return{isWebGL2:s,getMaxAnisotropy:function(){if(void 0!==i)return i;if(!0===e.has("EXT_texture_filter_anisotropic")){const n=e.get("EXT_texture_filter_anisotropic");i=t.getParameter(n.MAX_TEXTURE_MAX_ANISOTROPY_EXT)}else i=0;return i},getMaxPrecision:r,precision:a,logarithmicDepthBuffer:l,maxTextures:c,maxVertexTextures:h,maxTextureSize:u,maxCubemapSize:d,maxAttributes:p,maxVertexUniforms:m,maxVaryings:f,maxFragmentUniforms:g,vertexTextures:v,floatFragmentTextures:y,floatVertexTextures:v&&y,maxSamples:s?t.getParameter(36183):0}}function vi(t){const e=this;let n=null,i=0,r=!1,s=!1;const a=new Ne,o=new yt,l={value:null,needsUpdate:!1};function c(){l.value!==n&&(l.value=n,l.needsUpdate=i>0),e.numPlanes=i,e.numIntersection=0}function h(t,n,i,r){const s=null!==t?t.length:0;let c=null;if(0!==s){if(c=l.value,!0!==r||null===c){const e=i+4*s,r=n.matrixWorldInverse;o.getNormalMatrix(r),(null===c||c.length0){const a=t.getRenderTarget(),o=new ni(s.height/2);return o.fromEquirectangularTexture(t,r),e.set(r,o),t.setRenderTarget(a),r.addEventListener("dispose",i),n(o.texture,r.mapping)}return null}}}return r},dispose:function(){e=new WeakMap}}}function xi(t){const e={};function n(n){if(void 0!==e[n])return e[n];let i;switch(n){case"WEBGL_depth_texture":i=t.getExtension("WEBGL_depth_texture")||t.getExtension("MOZ_WEBGL_depth_texture")||t.getExtension("WEBKIT_WEBGL_depth_texture");break;case"EXT_texture_filter_anisotropic":i=t.getExtension("EXT_texture_filter_anisotropic")||t.getExtension("MOZ_EXT_texture_filter_anisotropic")||t.getExtension("WEBKIT_EXT_texture_filter_anisotropic");break;case"WEBGL_compressed_texture_s3tc":i=t.getExtension("WEBGL_compressed_texture_s3tc")||t.getExtension("MOZ_WEBGL_compressed_texture_s3tc")||t.getExtension("WEBKIT_WEBGL_compressed_texture_s3tc");break;case"WEBGL_compressed_texture_pvrtc":i=t.getExtension("WEBGL_compressed_texture_pvrtc")||t.getExtension("WEBKIT_WEBGL_compressed_texture_pvrtc");break;default:i=t.getExtension(n)}return e[n]=i,i}return{has:function(t){return null!==n(t)},init:function(t){t.isWebGL2?n("EXT_color_buffer_float"):(n("WEBGL_depth_texture"),n("OES_texture_float"),n("OES_texture_half_float"),n("OES_texture_half_float_linear"),n("OES_standard_derivatives"),n("OES_element_index_uint"),n("OES_vertex_array_object"),n("ANGLE_instanced_arrays")),n("OES_texture_float_linear"),n("EXT_color_buffer_half_float")},get:function(t){const e=n(t);return null===e&&console.warn("THREE.WebGLRenderer: "+t+" extension not supported."),e}}}function _i(t,e,n,i){const r={},s=new WeakMap;function a(t){const o=t.target;null!==o.index&&e.remove(o.index);for(const t in o.attributes)e.remove(o.attributes[t]);o.removeEventListener("dispose",a),delete r[o.id];const l=s.get(o);l&&(e.remove(l),s.delete(o)),i.releaseStatesOfGeometry(o),!0===o.isInstancedBufferGeometry&&delete o._maxInstanceCount,n.memory.geometries--}function o(t){const n=[],i=t.index,r=t.attributes.position;let a=0;if(null!==i){const t=i.array;a=i.version;for(let e=0,i=t.length;e65535?dn:hn)(n,1);o.version=a;const l=s.get(t);l&&e.remove(l),s.set(t,o)}return{get:function(t,e){return!0===r[e.id]||(e.addEventListener("dispose",a),r[e.id]=!0,n.memory.geometries++),e},update:function(t){const n=t.attributes;for(const t in n)e.update(n[t],34962);const i=t.morphAttributes;for(const t in i){const n=i[t];for(let t=0,i=n.length;t0)return t;const r=e*n;let s=Ii[r];if(void 0===s&&(s=new Float32Array(r),Ii[r]=s),0!==e){i.toArray(s,0);for(let i=1,r=0;i!==e;++i)r+=n,t[i].toArray(s,r)}return s}function Hi(t,e){if(t.length!==e.length)return!1;for(let n=0,i=t.length;n/gm;function kr(t){return t.replace(Ur,Vr)}function Vr(t,e){const n=hi[e];if(void 0===n)throw new Error("Can not resolve #include <"+e+">");return kr(n)}const Wr=/#pragma unroll_loop[\s]+?for \( int i \= (\d+)\; i < (\d+)\; i \+\+ \) \{([\s\S]+?)(?=\})\}/g,jr=/#pragma unroll_loop_start\s+for\s*\(\s*int\s+i\s*=\s*(\d+)\s*;\s*i\s*<\s*(\d+)\s*;\s*i\s*\+\+\s*\)\s*{([\s\S]+?)}\s+#pragma unroll_loop_end/g;function qr(t){return t.replace(jr,Yr).replace(Wr,Xr)}function Xr(t,e,n,i){return console.warn("WebGLProgram: #pragma unroll_loop shader syntax is deprecated. Please use #pragma unroll_loop_start syntax instead."),Yr(t,e,n,i)}function Yr(t,e,n,i){let r="";for(let t=parseInt(e);t0?t.gammaFactor:1,v=n.isWebGL2?"":function(t){return[t.extensionDerivatives||t.envMapCubeUV||t.bumpMap||t.tangentSpaceNormalMap||t.clearcoatNormalMap||t.flatShading||"physical"===t.shaderID?"#extension GL_OES_standard_derivatives : enable":"",(t.extensionFragDepth||t.logarithmicDepthBuffer)&&t.rendererExtensionFragDepth?"#extension GL_EXT_frag_depth : enable":"",t.extensionDrawBuffers&&t.rendererExtensionDrawBuffers?"#extension GL_EXT_draw_buffers : require":"",(t.extensionShaderTextureLOD||t.envMap)&&t.rendererExtensionShaderTextureLod?"#extension GL_EXT_shader_texture_lod : enable":""].filter(Or).join("\n")}(n),y=function(t){const e=[];for(const n in t){const i=t[n];!1!==i&&e.push("#define "+n+" "+i)}return e.join("\n")}(o),x=a.createProgram();let _,w,b=n.glslVersion?"#version "+n.glslVersion+"\n":"";n.isRawShaderMaterial?(_=[y].filter(Or).join("\n"),_.length>0&&(_+="\n"),w=[v,y].filter(Or).join("\n"),w.length>0&&(w+="\n")):(_=[Zr(n),"#define SHADER_NAME "+n.shaderName,y,n.instancing?"#define USE_INSTANCING":"",n.instancingColor?"#define USE_INSTANCING_COLOR":"",n.supportsVertexTextures?"#define VERTEX_TEXTURES":"","#define GAMMA_FACTOR "+g,"#define MAX_BONES "+n.maxBones,n.useFog&&n.fog?"#define USE_FOG":"",n.useFog&&n.fogExp2?"#define FOG_EXP2":"",n.map?"#define USE_MAP":"",n.envMap?"#define USE_ENVMAP":"",n.envMap?"#define "+m:"",n.lightMap?"#define USE_LIGHTMAP":"",n.aoMap?"#define USE_AOMAP":"",n.emissiveMap?"#define USE_EMISSIVEMAP":"",n.bumpMap?"#define USE_BUMPMAP":"",n.normalMap?"#define USE_NORMALMAP":"",n.normalMap&&n.objectSpaceNormalMap?"#define OBJECTSPACE_NORMALMAP":"",n.normalMap&&n.tangentSpaceNormalMap?"#define TANGENTSPACE_NORMALMAP":"",n.clearcoatMap?"#define USE_CLEARCOATMAP":"",n.clearcoatRoughnessMap?"#define USE_CLEARCOAT_ROUGHNESSMAP":"",n.clearcoatNormalMap?"#define USE_CLEARCOAT_NORMALMAP":"",n.displacementMap&&n.supportsVertexTextures?"#define USE_DISPLACEMENTMAP":"",n.specularMap?"#define USE_SPECULARMAP":"",n.roughnessMap?"#define USE_ROUGHNESSMAP":"",n.metalnessMap?"#define USE_METALNESSMAP":"",n.alphaMap?"#define USE_ALPHAMAP":"",n.transmissionMap?"#define USE_TRANSMISSIONMAP":"",n.vertexTangents?"#define USE_TANGENT":"",n.vertexColors?"#define USE_COLOR":"",n.vertexAlphas?"#define USE_COLOR_ALPHA":"",n.vertexUvs?"#define USE_UV":"",n.uvsVertexOnly?"#define UVS_VERTEX_ONLY":"",n.flatShading?"#define FLAT_SHADED":"",n.skinning?"#define USE_SKINNING":"",n.useVertexTexture?"#define BONE_TEXTURE":"",n.morphTargets?"#define USE_MORPHTARGETS":"",n.morphNormals&&!1===n.flatShading?"#define USE_MORPHNORMALS":"",n.doubleSided?"#define DOUBLE_SIDED":"",n.flipSided?"#define FLIP_SIDED":"",n.shadowMapEnabled?"#define USE_SHADOWMAP":"",n.shadowMapEnabled?"#define "+d:"",n.sizeAttenuation?"#define USE_SIZEATTENUATION":"",n.logarithmicDepthBuffer?"#define USE_LOGDEPTHBUF":"",n.logarithmicDepthBuffer&&n.rendererExtensionFragDepth?"#define USE_LOGDEPTHBUF_EXT":"","uniform mat4 modelMatrix;","uniform mat4 modelViewMatrix;","uniform mat4 projectionMatrix;","uniform mat4 viewMatrix;","uniform mat3 normalMatrix;","uniform vec3 cameraPosition;","uniform bool isOrthographic;","#ifdef USE_INSTANCING","\tattribute mat4 instanceMatrix;","#endif","#ifdef USE_INSTANCING_COLOR","\tattribute vec3 instanceColor;","#endif","attribute vec3 position;","attribute vec3 normal;","attribute vec2 uv;","#ifdef USE_TANGENT","\tattribute vec4 tangent;","#endif","#if defined( USE_COLOR_ALPHA )","\tattribute vec4 color;","#elif defined( USE_COLOR )","\tattribute vec3 color;","#endif","#ifdef USE_MORPHTARGETS","\tattribute vec3 morphTarget0;","\tattribute vec3 morphTarget1;","\tattribute vec3 morphTarget2;","\tattribute vec3 morphTarget3;","\t#ifdef USE_MORPHNORMALS","\t\tattribute vec3 morphNormal0;","\t\tattribute vec3 morphNormal1;","\t\tattribute vec3 morphNormal2;","\t\tattribute vec3 morphNormal3;","\t#else","\t\tattribute vec3 morphTarget4;","\t\tattribute vec3 morphTarget5;","\t\tattribute vec3 morphTarget6;","\t\tattribute vec3 morphTarget7;","\t#endif","#endif","#ifdef USE_SKINNING","\tattribute vec4 skinIndex;","\tattribute vec4 skinWeight;","#endif","\n"].filter(Or).join("\n"),w=[v,Zr(n),"#define SHADER_NAME "+n.shaderName,y,n.alphaTest?"#define ALPHATEST "+n.alphaTest+(n.alphaTest%1?"":".0"):"","#define GAMMA_FACTOR "+g,n.useFog&&n.fog?"#define USE_FOG":"",n.useFog&&n.fogExp2?"#define FOG_EXP2":"",n.map?"#define USE_MAP":"",n.matcap?"#define USE_MATCAP":"",n.envMap?"#define USE_ENVMAP":"",n.envMap?"#define "+p:"",n.envMap?"#define "+m:"",n.envMap?"#define "+f:"",n.lightMap?"#define USE_LIGHTMAP":"",n.aoMap?"#define USE_AOMAP":"",n.emissiveMap?"#define USE_EMISSIVEMAP":"",n.bumpMap?"#define USE_BUMPMAP":"",n.normalMap?"#define USE_NORMALMAP":"",n.normalMap&&n.objectSpaceNormalMap?"#define OBJECTSPACE_NORMALMAP":"",n.normalMap&&n.tangentSpaceNormalMap?"#define TANGENTSPACE_NORMALMAP":"",n.clearcoatMap?"#define USE_CLEARCOATMAP":"",n.clearcoatRoughnessMap?"#define USE_CLEARCOAT_ROUGHNESSMAP":"",n.clearcoatNormalMap?"#define USE_CLEARCOAT_NORMALMAP":"",n.specularMap?"#define USE_SPECULARMAP":"",n.roughnessMap?"#define USE_ROUGHNESSMAP":"",n.metalnessMap?"#define USE_METALNESSMAP":"",n.alphaMap?"#define USE_ALPHAMAP":"",n.sheen?"#define USE_SHEEN":"",n.transmissionMap?"#define USE_TRANSMISSIONMAP":"",n.vertexTangents?"#define USE_TANGENT":"",n.vertexColors||n.instancingColor?"#define USE_COLOR":"",n.vertexAlphas?"#define USE_COLOR_ALPHA":"",n.vertexUvs?"#define USE_UV":"",n.uvsVertexOnly?"#define UVS_VERTEX_ONLY":"",n.gradientMap?"#define USE_GRADIENTMAP":"",n.flatShading?"#define FLAT_SHADED":"",n.doubleSided?"#define DOUBLE_SIDED":"",n.flipSided?"#define FLIP_SIDED":"",n.shadowMapEnabled?"#define USE_SHADOWMAP":"",n.shadowMapEnabled?"#define "+d:"",n.premultipliedAlpha?"#define PREMULTIPLIED_ALPHA":"",n.physicallyCorrectLights?"#define PHYSICALLY_CORRECT_LIGHTS":"",n.logarithmicDepthBuffer?"#define USE_LOGDEPTHBUF":"",n.logarithmicDepthBuffer&&n.rendererExtensionFragDepth?"#define USE_LOGDEPTHBUF_EXT":"",(n.extensionShaderTextureLOD||n.envMap)&&n.rendererExtensionShaderTextureLod?"#define TEXTURE_LOD_EXT":"","uniform mat4 viewMatrix;","uniform vec3 cameraPosition;","uniform bool isOrthographic;",0!==n.toneMapping?"#define TONE_MAPPING":"",0!==n.toneMapping?hi.tonemapping_pars_fragment:"",0!==n.toneMapping?Fr("toneMapping",n.toneMapping):"",n.dithering?"#define DITHERING":"",hi.encodings_pars_fragment,n.map?Br("mapTexelToLinear",n.mapEncoding):"",n.matcap?Br("matcapTexelToLinear",n.matcapEncoding):"",n.envMap?Br("envMapTexelToLinear",n.envMapEncoding):"",n.emissiveMap?Br("emissiveMapTexelToLinear",n.emissiveMapEncoding):"",n.lightMap?Br("lightMapTexelToLinear",n.lightMapEncoding):"",zr("linearToOutputTexel",n.outputEncoding),n.depthPacking?"#define DEPTH_PACKING "+n.depthPacking:"","\n"].filter(Or).join("\n")),h=kr(h),h=Hr(h,n),h=Gr(h,n),u=kr(u),u=Hr(u,n),u=Gr(u,n),h=qr(h),u=qr(u),n.isWebGL2&&!0!==n.isRawShaderMaterial&&(b="#version 300 es\n",_=["#define attribute in","#define varying out","#define texture2D texture"].join("\n")+"\n"+_,w=["#define varying in",n.glslVersion===it?"":"out highp vec4 pc_fragColor;",n.glslVersion===it?"":"#define gl_FragColor pc_fragColor","#define gl_FragDepthEXT gl_FragDepth","#define texture2D texture","#define textureCube texture","#define texture2DProj textureProj","#define texture2DLodEXT textureLod","#define texture2DProjLodEXT textureProjLod","#define textureCubeLodEXT textureLod","#define texture2DGradEXT textureGrad","#define texture2DProjGradEXT textureProjGrad","#define textureCubeGradEXT textureGrad"].join("\n")+"\n"+w);const M=b+w+u,S=Pr(a,35633,b+_+h),T=Pr(a,35632,M);if(a.attachShader(x,S),a.attachShader(x,T),void 0!==n.index0AttributeName?a.bindAttribLocation(x,0,n.index0AttributeName):!0===n.morphTargets&&a.bindAttribLocation(x,0,"position"),a.linkProgram(x),t.debug.checkShaderErrors){const t=a.getProgramInfoLog(x).trim(),e=a.getShaderInfoLog(S).trim(),n=a.getShaderInfoLog(T).trim();let i=!0,r=!0;if(!1===a.getProgramParameter(x,35714)){i=!1;const e=Nr(a,S,"vertex"),n=Nr(a,T,"fragment");console.error("THREE.WebGLProgram: shader error: ",a.getError(),"35715",a.getProgramParameter(x,35715),"gl.getProgramInfoLog",t,e,n)}else""!==t?console.warn("THREE.WebGLProgram: gl.getProgramInfoLog()",t):""!==e&&""!==n||(r=!1);r&&(this.diagnostics={runnable:i,programLog:t,vertexShader:{log:e,prefix:_},fragmentShader:{log:n,prefix:w}})}let E,A;return a.deleteShader(S),a.deleteShader(T),this.getUniforms=function(){return void 0===E&&(E=new Cr(a,x)),E},this.getAttributes=function(){return void 0===A&&(A=function(t,e){const n={},i=t.getProgramParameter(e,35721);for(let r=0;r0,maxBones:S,useVertexTexture:u,morphTargets:r.morphTargets,morphNormals:r.morphNormals,numDirLights:a.directional.length,numPointLights:a.point.length,numSpotLights:a.spot.length,numRectAreaLights:a.rectArea.length,numHemiLights:a.hemi.length,numDirLightShadows:a.directionalShadowMap.length,numPointLightShadows:a.pointShadowMap.length,numSpotLightShadows:a.spotShadowMap.length,numClippingPlanes:s.numPlanes,numClipIntersection:s.numIntersection,dithering:r.dithering,shadowMapEnabled:t.shadowMap.enabled&&g.length>0,shadowMapType:t.shadowMap.type,toneMapping:r.toneMapped?t.toneMapping:0,physicallyCorrectLights:t.physicallyCorrectLights,premultipliedAlpha:r.premultipliedAlpha,alphaTest:r.alphaTest,doubleSided:2===r.side,flipSided:1===r.side,depthPacking:void 0!==r.depthPacking&&r.depthPacking,index0AttributeName:r.index0AttributeName,extensionDerivatives:r.extensions&&r.extensions.derivatives,extensionFragDepth:r.extensions&&r.extensions.fragDepth,extensionDrawBuffers:r.extensions&&r.extensions.drawBuffers,extensionShaderTextureLOD:r.extensions&&r.extensions.shaderTextureLOD,rendererExtensionFragDepth:o||n.has("EXT_frag_depth"),rendererExtensionDrawBuffers:o||n.has("WEBGL_draw_buffers"),rendererExtensionShaderTextureLod:o||n.has("EXT_shader_texture_lod"),customProgramCacheKey:r.customProgramCacheKey()}},getProgramCacheKey:function(e){const n=[];if(e.shaderID?n.push(e.shaderID):(n.push(e.fragmentShader),n.push(e.vertexShader)),void 0!==e.defines)for(const t in e.defines)n.push(t),n.push(e.defines[t]);if(!1===e.isRawShaderMaterial){for(let t=0;t1&&i.sort(t||$r),r.length>1&&r.sort(e||ts)}}}function ns(t){let e=new WeakMap;return{get:function(n,i){let r;return!1===e.has(n)?(r=new es(t),e.set(n,[r])):i>=e.get(n).length?(r=new es(t),e.get(n).push(r)):r=e.get(n)[i],r},dispose:function(){e=new WeakMap}}}function is(){const t={};return{get:function(e){if(void 0!==t[e.id])return t[e.id];let n;switch(e.type){case"DirectionalLight":n={direction:new Lt,color:new tn};break;case"SpotLight":n={position:new Lt,direction:new Lt,color:new tn,distance:0,coneCos:0,penumbraCos:0,decay:0};break;case"PointLight":n={position:new Lt,color:new tn,distance:0,decay:0};break;case"HemisphereLight":n={direction:new Lt,skyColor:new tn,groundColor:new tn};break;case"RectAreaLight":n={color:new tn,position:new Lt,halfWidth:new Lt,halfHeight:new Lt}}return t[e.id]=n,n}}}let rs=0;function ss(t,e){return(e.castShadow?1:0)-(t.castShadow?1:0)}function as(t,e){const n=new is,i=function(){const t={};return{get:function(e){if(void 0!==t[e.id])return t[e.id];let n;switch(e.type){case"DirectionalLight":case"SpotLight":n={shadowBias:0,shadowNormalBias:0,shadowRadius:1,shadowMapSize:new vt};break;case"PointLight":n={shadowBias:0,shadowNormalBias:0,shadowRadius:1,shadowMapSize:new vt,shadowCameraNear:1,shadowCameraFar:1e3}}return t[e.id]=n,n}}}(),r={version:0,hash:{directionalLength:-1,pointLength:-1,spotLength:-1,rectAreaLength:-1,hemiLength:-1,numDirectionalShadows:-1,numPointShadows:-1,numSpotShadows:-1},ambient:[0,0,0],probe:[],directional:[],directionalShadow:[],directionalShadowMap:[],directionalShadowMatrix:[],spot:[],spotShadow:[],spotShadowMap:[],spotShadowMatrix:[],rectArea:[],rectAreaLTC1:null,rectAreaLTC2:null,point:[],pointShadow:[],pointShadowMap:[],pointShadowMatrix:[],hemi:[]};for(let t=0;t<9;t++)r.probe.push(new Lt);const s=new Lt,a=new se,o=new se;return{setup:function(s){let a=0,o=0,l=0;for(let t=0;t<9;t++)r.probe[t].set(0,0,0);let c=0,h=0,u=0,d=0,p=0,m=0,f=0,g=0;s.sort(ss);for(let t=0,e=s.length;t0&&(e.isWebGL2||!0===t.has("OES_texture_float_linear")?(r.rectAreaLTC1=ui.LTC_FLOAT_1,r.rectAreaLTC2=ui.LTC_FLOAT_2):!0===t.has("OES_texture_half_float_linear")?(r.rectAreaLTC1=ui.LTC_HALF_1,r.rectAreaLTC2=ui.LTC_HALF_2):console.error("THREE.WebGLRenderer: Unable to use RectAreaLight. Missing WebGL extensions.")),r.ambient[0]=a,r.ambient[1]=o,r.ambient[2]=l;const v=r.hash;v.directionalLength===c&&v.pointLength===h&&v.spotLength===u&&v.rectAreaLength===d&&v.hemiLength===p&&v.numDirectionalShadows===m&&v.numPointShadows===f&&v.numSpotShadows===g||(r.directional.length=c,r.spot.length=u,r.rectArea.length=d,r.point.length=h,r.hemi.length=p,r.directionalShadow.length=m,r.directionalShadowMap.length=m,r.pointShadow.length=f,r.pointShadowMap.length=f,r.spotShadow.length=g,r.spotShadowMap.length=g,r.directionalShadowMatrix.length=m,r.pointShadowMatrix.length=f,r.spotShadowMatrix.length=g,v.directionalLength=c,v.pointLength=h,v.spotLength=u,v.rectAreaLength=d,v.hemiLength=p,v.numDirectionalShadows=m,v.numPointShadows=f,v.numSpotShadows=g,r.version=rs++)},setupView:function(t,e){let n=0,i=0,l=0,c=0,h=0;const u=e.matrixWorldInverse;for(let e=0,d=t.length;e=n.get(i).length?(s=new os(t,e),n.get(i).push(s)):s=n.get(i)[r],s},dispose:function(){n=new WeakMap}}}class cs extends Xe{constructor(t){super(),this.type="MeshDepthMaterial",this.depthPacking=3200,this.skinning=!1,this.morphTargets=!1,this.map=null,this.alphaMap=null,this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.wireframe=!1,this.wireframeLinewidth=1,this.fog=!1,this.setValues(t)}copy(t){return super.copy(t),this.depthPacking=t.depthPacking,this.skinning=t.skinning,this.morphTargets=t.morphTargets,this.map=t.map,this.alphaMap=t.alphaMap,this.displacementMap=t.displacementMap,this.displacementScale=t.displacementScale,this.displacementBias=t.displacementBias,this.wireframe=t.wireframe,this.wireframeLinewidth=t.wireframeLinewidth,this}}cs.prototype.isMeshDepthMaterial=!0;class hs extends Xe{constructor(t){super(),this.type="MeshDistanceMaterial",this.referencePosition=new Lt,this.nearDistance=1,this.farDistance=1e3,this.skinning=!1,this.morphTargets=!1,this.map=null,this.alphaMap=null,this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.fog=!1,this.setValues(t)}copy(t){return super.copy(t),this.referencePosition.copy(t.referencePosition),this.nearDistance=t.nearDistance,this.farDistance=t.farDistance,this.skinning=t.skinning,this.morphTargets=t.morphTargets,this.map=t.map,this.alphaMap=t.alphaMap,this.displacementMap=t.displacementMap,this.displacementScale=t.displacementScale,this.displacementBias=t.displacementBias,this}}hs.prototype.isMeshDistanceMaterial=!0;function us(t,e,n){let i=new ai;const r=new vt,s=new vt,a=new St,o=[],l=[],c={},h=n.maxTextureSize,u={0:1,1:0,2:2},d=new Jn({defines:{SAMPLE_RATE:2/8,HALF_SAMPLE_RATE:1/8},uniforms:{shadow_pass:{value:null},resolution:{value:new vt},radius:{value:4}},vertexShader:"void main() {\n\tgl_Position = vec4( position, 1.0 );\n}",fragmentShader:"uniform sampler2D shadow_pass;\nuniform vec2 resolution;\nuniform float radius;\n#include \nvoid main() {\n\tfloat mean = 0.0;\n\tfloat squared_mean = 0.0;\n\tfloat depth = unpackRGBAToDepth( texture2D( shadow_pass, ( gl_FragCoord.xy ) / resolution ) );\n\tfor ( float i = -1.0; i < 1.0 ; i += SAMPLE_RATE) {\n\t\t#ifdef HORIZONTAL_PASS\n\t\t\tvec2 distribution = unpackRGBATo2Half( texture2D( shadow_pass, ( gl_FragCoord.xy + vec2( i, 0.0 ) * radius ) / resolution ) );\n\t\t\tmean += distribution.x;\n\t\t\tsquared_mean += distribution.y * distribution.y + distribution.x * distribution.x;\n\t\t#else\n\t\t\tfloat depth = unpackRGBAToDepth( texture2D( shadow_pass, ( gl_FragCoord.xy + vec2( 0.0, i ) * radius ) / resolution ) );\n\t\t\tmean += depth;\n\t\t\tsquared_mean += depth * depth;\n\t\t#endif\n\t}\n\tmean = mean * HALF_SAMPLE_RATE;\n\tsquared_mean = squared_mean * HALF_SAMPLE_RATE;\n\tfloat std_dev = sqrt( squared_mean - mean * mean );\n\tgl_FragColor = pack2HalfToRGBA( vec2( mean, std_dev ) );\n}"}),m=d.clone();m.defines.HORIZONTAL_PASS=1;const f=new En;f.setAttribute("position",new sn(new Float32Array([-1,-1,.5,3,-1,.5,-1,3,.5]),3));const v=new Wn(f,d),y=this;function x(n,i){const r=e.update(v);d.uniforms.shadow_pass.value=n.map.texture,d.uniforms.resolution.value=n.mapSize,d.uniforms.radius.value=n.radius,t.setRenderTarget(n.mapPass),t.clear(),t.renderBufferDirect(i,null,r,d,v,null),m.uniforms.shadow_pass.value=n.mapPass.texture,m.uniforms.resolution.value=n.mapSize,m.uniforms.radius.value=n.radius,t.setRenderTarget(n.map),t.clear(),t.renderBufferDirect(i,null,r,m,v,null)}function _(t,e,n){const i=t<<0|e<<1|n<<2;let r=o[i];return void 0===r&&(r=new cs({depthPacking:3201,morphTargets:t,skinning:e}),o[i]=r),r}function w(t,e,n){const i=t<<0|e<<1|n<<2;let r=l[i];return void 0===r&&(r=new hs({morphTargets:t,skinning:e}),l[i]=r),r}function b(e,n,i,r,s,a,o){let l=null,h=_,d=e.customDepthMaterial;if(!0===r.isPointLight&&(h=w,d=e.customDistanceMaterial),void 0===d){let t=!1;!0===i.morphTargets&&(t=n.morphAttributes&&n.morphAttributes.position&&n.morphAttributes.position.length>0);let r=!1;!0===e.isSkinnedMesh&&(!0===i.skinning?r=!0:console.warn("THREE.WebGLShadowMap: THREE.SkinnedMesh with material.skinning set to false:",e));l=h(t,r,!0===e.isInstancedMesh)}else l=d;if(t.localClippingEnabled&&!0===i.clipShadows&&0!==i.clippingPlanes.length){const t=l.uuid,e=i.uuid;let n=c[t];void 0===n&&(n={},c[t]=n);let r=n[e];void 0===r&&(r=l.clone(),n[e]=r),l=r}return l.visible=i.visible,l.wireframe=i.wireframe,l.side=3===o?null!==i.shadowSide?i.shadowSide:i.side:null!==i.shadowSide?i.shadowSide:u[i.side],l.clipShadows=i.clipShadows,l.clippingPlanes=i.clippingPlanes,l.clipIntersection=i.clipIntersection,l.wireframeLinewidth=i.wireframeLinewidth,l.linewidth=i.linewidth,!0===r.isPointLight&&!0===l.isMeshDistanceMaterial&&(l.referencePosition.setFromMatrixPosition(r.matrixWorld),l.nearDistance=s,l.farDistance=a),l}function M(n,r,s,a,o){if(!1===n.visible)return;if(n.layers.test(r.layers)&&(n.isMesh||n.isLine||n.isPoints)&&(n.castShadow||n.receiveShadow&&3===o)&&(!n.frustumCulled||i.intersectsObject(n))){n.modelViewMatrix.multiplyMatrices(s.matrixWorldInverse,n.matrixWorld);const i=e.update(n),r=n.material;if(Array.isArray(r)){const e=i.groups;for(let l=0,c=e.length;lh||r.y>h)&&(r.x>h&&(s.x=Math.floor(h/m.x),r.x=s.x*m.x,u.mapSize.x=s.x),r.y>h&&(s.y=Math.floor(h/m.y),r.y=s.y*m.y,u.mapSize.y=s.y)),null===u.map&&!u.isPointLightShadow&&3===this.type){const t={minFilter:g,magFilter:g,format:E};u.map=new Tt(r.x,r.y,t),u.map.texture.name=c.name+".shadowMap",u.mapPass=new Tt(r.x,r.y,t),u.camera.updateProjectionMatrix()}if(null===u.map){const t={minFilter:p,magFilter:p,format:E};u.map=new Tt(r.x,r.y,t),u.map.texture.name=c.name+".shadowMap",u.camera.updateProjectionMatrix()}t.setRenderTarget(u.map),t.clear();const f=u.getViewportCount();for(let t=0;t=1):-1!==R.indexOf("OpenGL ES")&&(L=parseFloat(/^OpenGL ES (\d)/.exec(R)[1]),A=L>=2);let C=null,P={};const D=new St(0,0,t.canvas.width,t.canvas.height),I=new St(0,0,t.canvas.width,t.canvas.height);function N(e,n,i){const r=new Uint8Array(4),s=t.createTexture();t.bindTexture(e,s),t.texParameteri(e,10241,9728),t.texParameteri(e,10240,9728);for(let e=0;ei||t.height>i)&&(r=i/Math.max(t.width,t.height)),r<1||!0===e){if("undefined"!=typeof HTMLImageElement&&t instanceof HTMLImageElement||"undefined"!=typeof HTMLCanvasElement&&t instanceof HTMLCanvasElement||"undefined"!=typeof ImageBitmap&&t instanceof ImageBitmap){const i=e?ft:Math.floor,s=i(r*t.width),a=i(r*t.height);void 0===P&&(P=I(s,a));const o=n?I(s,a):P;o.width=s,o.height=a;return o.getContext("2d").drawImage(t,0,0,s,a),console.warn("THREE.WebGLRenderer: Texture has been resized from ("+t.width+"x"+t.height+") to ("+s+"x"+a+")."),o}return"data"in t&&console.warn("THREE.WebGLRenderer: Image in DataTexture is too big ("+t.width+"x"+t.height+")."),t}return t}function B(t){return pt(t.width)&&pt(t.height)}function z(t,e){return t.generateMipmaps&&e&&t.minFilter!==p&&t.minFilter!==g}function F(e,n,r,s){t.generateMipmap(e);i.get(n).__maxMipLevel=Math.log2(Math.max(r,s))}function O(n,i,r){if(!1===o)return i;if(null!==n){if(void 0!==t[n])return t[n];console.warn("THREE.WebGLRenderer: Attempt to use non-existing WebGL internal format '"+n+"'")}let s=i;return 6403===i&&(5126===r&&(s=33326),5131===r&&(s=33325),5121===r&&(s=33321)),6407===i&&(5126===r&&(s=34837),5131===r&&(s=34843),5121===r&&(s=32849)),6408===i&&(5126===r&&(s=34836),5131===r&&(s=34842),5121===r&&(s=32856)),33325!==s&&33326!==s&&34842!==s&&34836!==s||e.get("EXT_color_buffer_float"),s}function H(t){return t===p||t===m||t===f?9728:9729}function G(e){const n=e.target;n.removeEventListener("dispose",G),function(e){const n=i.get(e);if(void 0===n.__webglInit)return;t.deleteTexture(n.__webglTexture),i.remove(e)}(n),n.isVideoTexture&&C.delete(n),a.memory.textures--}function U(e){const n=e.target;n.removeEventListener("dispose",U),function(e){const n=e.texture,r=i.get(e),s=i.get(n);if(!e)return;void 0!==s.__webglTexture&&t.deleteTexture(s.__webglTexture);e.depthTexture&&e.depthTexture.dispose();if(e.isWebGLCubeRenderTarget)for(let e=0;e<6;e++)t.deleteFramebuffer(r.__webglFramebuffer[e]),r.__webglDepthbuffer&&t.deleteRenderbuffer(r.__webglDepthbuffer[e]);else t.deleteFramebuffer(r.__webglFramebuffer),r.__webglDepthbuffer&&t.deleteRenderbuffer(r.__webglDepthbuffer),r.__webglMultisampledFramebuffer&&t.deleteFramebuffer(r.__webglMultisampledFramebuffer),r.__webglColorRenderbuffer&&t.deleteRenderbuffer(r.__webglColorRenderbuffer),r.__webglDepthRenderbuffer&&t.deleteRenderbuffer(r.__webglDepthRenderbuffer);i.remove(n),i.remove(e)}(n),a.memory.textures--}let k=0;function V(t,e){const r=i.get(t);if(t.isVideoTexture&&function(t){const e=a.render.frame;C.get(t)!==e&&(C.set(t,e),t.update())}(t),t.version>0&&r.__version!==t.version){const n=t.image;if(void 0===n)console.warn("THREE.WebGLRenderer: Texture marked for update but image is undefined");else{if(!1!==n.complete)return void Z(r,t,e);console.warn("THREE.WebGLRenderer: Texture marked for update but image is incomplete")}}n.activeTexture(33984+e),n.bindTexture(3553,r.__webglTexture)}function W(e,r){const a=i.get(e);e.version>0&&a.__version!==e.version?function(e,i,r){if(6!==i.image.length)return;Y(e,i),n.activeTexture(33984+r),n.bindTexture(34067,e.__webglTexture),t.pixelStorei(37440,i.flipY),t.pixelStorei(37441,i.premultiplyAlpha),t.pixelStorei(3317,i.unpackAlignment),t.pixelStorei(37443,0);const a=i&&(i.isCompressedTexture||i.image[0].isCompressedTexture),l=i.image[0]&&i.image[0].isDataTexture,h=[];for(let t=0;t<6;t++)h[t]=a||l?l?i.image[t].image:i.image[t]:N(i.image[t],!1,!0,c);const u=h[0],d=B(u)||o,p=s.convert(i.format),m=s.convert(i.type),f=O(i.internalFormat,p,m);let g;if(X(34067,i,d),a){for(let t=0;t<6;t++){g=h[t].mipmaps;for(let e=0;e1||i.get(s).__currentAnisotropy)&&(t.texParameterf(n,a.TEXTURE_MAX_ANISOTROPY_EXT,Math.min(s.anisotropy,r.getMaxAnisotropy())),i.get(s).__currentAnisotropy=s.anisotropy)}}function Y(e,n){void 0===e.__webglInit&&(e.__webglInit=!0,n.addEventListener("dispose",G),e.__webglTexture=t.createTexture(),a.memory.textures++)}function Z(e,i,r){let a=3553;i.isDataTexture2DArray&&(a=35866),i.isDataTexture3D&&(a=32879),Y(e,i),n.activeTexture(33984+r),n.bindTexture(a,e.__webglTexture),t.pixelStorei(37440,i.flipY),t.pixelStorei(37441,i.premultiplyAlpha),t.pixelStorei(3317,i.unpackAlignment),t.pixelStorei(37443,0);const l=function(t){return!o&&(t.wrapS!==u||t.wrapT!==u||t.minFilter!==p&&t.minFilter!==g)}(i)&&!1===B(i.image),c=N(i.image,l,!1,x),h=B(c)||o,d=s.convert(i.format);let m,f=s.convert(i.type),v=O(i.internalFormat,d,f);X(a,i,h);const y=i.mipmaps;if(i.isDepthTexture)v=6402,o?v=i.type===b?36012:i.type===w?33190:i.type===S?35056:33189:i.type===b&&console.error("WebGLRenderer: Floating point depth texture requires WebGL2."),i.format===A&&6402===v&&i.type!==_&&i.type!==w&&(console.warn("THREE.WebGLRenderer: Use UnsignedShortType or UnsignedIntType for DepthFormat DepthTexture."),i.type=_,f=s.convert(i.type)),i.format===L&&6402===v&&(v=34041,i.type!==S&&(console.warn("THREE.WebGLRenderer: Use UnsignedInt248Type for DepthStencilFormat DepthTexture."),i.type=S,f=s.convert(i.type))),n.texImage2D(3553,0,v,c.width,c.height,0,d,f,null);else if(i.isDataTexture)if(y.length>0&&h){for(let t=0,e=y.length;t0&&h){for(let t=0,e=y.length;t=l&&console.warn("THREE.WebGLTextures: Trying to use "+t+" texture units while this GPU supports only "+l),k+=1,t},this.resetTextureUnits=function(){k=0},this.setTexture2D=V,this.setTexture2DArray=function(t,e){const r=i.get(t);t.version>0&&r.__version!==t.version?Z(r,t,e):(n.activeTexture(33984+e),n.bindTexture(35866,r.__webglTexture))},this.setTexture3D=function(t,e){const r=i.get(t);t.version>0&&r.__version!==t.version?Z(r,t,e):(n.activeTexture(33984+e),n.bindTexture(32879,r.__webglTexture))},this.setTextureCube=W,this.setupRenderTarget=function(e){const r=e.texture,l=i.get(e),c=i.get(r);e.addEventListener("dispose",U),c.__webglTexture=t.createTexture(),c.__version=r.version,a.memory.textures++;const h=!0===e.isWebGLCubeRenderTarget,u=!0===e.isWebGLMultisampleRenderTarget,d=r.isDataTexture3D||r.isDataTexture2DArray,p=B(e)||o;if(!o||r.format!==T||r.type!==b&&r.type!==M||(r.format=E,console.warn("THREE.WebGLRenderer: Rendering to textures with RGB format is not supported. Using RGBA format instead.")),h){l.__webglFramebuffer=[];for(let e=0;e<6;e++)l.__webglFramebuffer[e]=t.createFramebuffer()}else if(l.__webglFramebuffer=t.createFramebuffer(),u)if(o){l.__webglMultisampledFramebuffer=t.createFramebuffer(),l.__webglColorRenderbuffer=t.createRenderbuffer(),t.bindRenderbuffer(36161,l.__webglColorRenderbuffer);const i=s.convert(r.format),a=s.convert(r.type),o=O(r.internalFormat,i,a),c=$(e);t.renderbufferStorageMultisample(36161,c,o,e.width,e.height),n.bindFramebuffer(36160,l.__webglMultisampledFramebuffer),t.framebufferRenderbuffer(36160,36064,36161,l.__webglColorRenderbuffer),t.bindRenderbuffer(36161,null),e.depthBuffer&&(l.__webglDepthRenderbuffer=t.createRenderbuffer(),Q(l.__webglDepthRenderbuffer,e,!0)),n.bindFramebuffer(36160,null)}else console.warn("THREE.WebGLRenderer: WebGLMultisampleRenderTarget can only be used with WebGL2.");if(h){n.bindTexture(34067,c.__webglTexture),X(34067,r,p);for(let t=0;t<6;t++)J(l.__webglFramebuffer[t],e,36064,34069+t);z(r,p)&&F(34067,r,e.width,e.height),n.bindTexture(34067,null)}else{let t=3553;if(d)if(o){t=r.isDataTexture3D?32879:35866}else console.warn("THREE.DataTexture3D and THREE.DataTexture2DArray only supported with WebGL2.");n.bindTexture(t,c.__webglTexture),X(t,r,p),J(l.__webglFramebuffer,e,36064,t),z(r,p)&&F(3553,r,e.width,e.height),n.bindTexture(3553,null)}e.depthBuffer&&K(e)},this.updateRenderTargetMipmap=function(t){const e=t.texture;if(z(e,B(t)||o)){const r=t.isWebGLCubeRenderTarget?34067:3553,s=i.get(e).__webglTexture;n.bindTexture(r,s),F(r,e,t.width,t.height),n.bindTexture(r,null)}},this.updateMultisampleRenderTarget=function(e){if(e.isWebGLMultisampleRenderTarget)if(o){const r=e.width,s=e.height;let a=16384;e.depthBuffer&&(a|=256),e.stencilBuffer&&(a|=1024);const o=i.get(e);n.bindFramebuffer(36008,o.__webglMultisampledFramebuffer),n.bindFramebuffer(36009,o.__webglFramebuffer),t.blitFramebuffer(0,0,r,s,0,0,r,s,a,9728),n.bindFramebuffer(36008,null),n.bindFramebuffer(36009,o.__webglMultisampledFramebuffer)}else console.warn("THREE.WebGLRenderer: WebGLMultisampleRenderTarget can only be used with WebGL2.")},this.safeSetTexture2D=function(t,e){t&&t.isWebGLRenderTarget&&(!1===tt&&(console.warn("THREE.WebGLTextures.safeSetTexture2D: don't use render targets as textures. Use their .texture property instead."),tt=!0),t=t.texture),V(t,e)},this.safeSetTextureCube=function(t,e){t&&t.isWebGLCubeRenderTarget&&(!1===et&&(console.warn("THREE.WebGLTextures.safeSetTextureCube: don't use cube render targets as textures. Use their .texture property instead."),et=!0),t=t.texture),W(t,e)}}function ms(t,e,n){const i=n.isWebGL2;return{convert:function(t){let n;if(t===x)return 5121;if(1017===t)return 32819;if(1018===t)return 32820;if(1019===t)return 33635;if(1010===t)return 5120;if(1011===t)return 5122;if(t===_)return 5123;if(1013===t)return 5124;if(t===w)return 5125;if(t===b)return 5126;if(t===M)return i?5131:(n=e.get("OES_texture_half_float"),null!==n?n.HALF_FLOAT_OES:null);if(1021===t)return 6406;if(t===T)return 6407;if(t===E)return 6408;if(1024===t)return 6409;if(1025===t)return 6410;if(t===A)return 6402;if(t===L)return 34041;if(1028===t)return 6403;if(1029===t)return 36244;if(1030===t)return 33319;if(1031===t)return 33320;if(1032===t)return 36248;if(1033===t)return 36249;if(t===R||t===C||t===P||t===D){if(n=e.get("WEBGL_compressed_texture_s3tc"),null===n)return null;if(t===R)return n.COMPRESSED_RGB_S3TC_DXT1_EXT;if(t===C)return n.COMPRESSED_RGBA_S3TC_DXT1_EXT;if(t===P)return n.COMPRESSED_RGBA_S3TC_DXT3_EXT;if(t===D)return n.COMPRESSED_RGBA_S3TC_DXT5_EXT}if(t===I||t===N||t===B||t===z){if(n=e.get("WEBGL_compressed_texture_pvrtc"),null===n)return null;if(t===I)return n.COMPRESSED_RGB_PVRTC_4BPPV1_IMG;if(t===N)return n.COMPRESSED_RGB_PVRTC_2BPPV1_IMG;if(t===B)return n.COMPRESSED_RGBA_PVRTC_4BPPV1_IMG;if(t===z)return n.COMPRESSED_RGBA_PVRTC_2BPPV1_IMG}if(36196===t)return n=e.get("WEBGL_compressed_texture_etc1"),null!==n?n.COMPRESSED_RGB_ETC1_WEBGL:null;if((t===F||t===O)&&(n=e.get("WEBGL_compressed_texture_etc"),null!==n)){if(t===F)return n.COMPRESSED_RGB8_ETC2;if(t===O)return n.COMPRESSED_RGBA8_ETC2_EAC}return 37808===t||37809===t||37810===t||37811===t||37812===t||37813===t||37814===t||37815===t||37816===t||37817===t||37818===t||37819===t||37820===t||37821===t||37840===t||37841===t||37842===t||37843===t||37844===t||37845===t||37846===t||37847===t||37848===t||37849===t||37850===t||37851===t||37852===t||37853===t?(n=e.get("WEBGL_compressed_texture_astc"),null!==n?t:null):36492===t?(n=e.get("EXT_texture_compression_bptc"),null!==n?t:null):t===S?i?34042:(n=e.get("WEBGL_depth_texture"),null!==n?n.UNSIGNED_INT_24_8_WEBGL:null):void 0}}}class fs extends Kn{constructor(t=[]){super(),this.cameras=t}}fs.prototype.isArrayCamera=!0;class gs extends Ce{constructor(){super(),this.type="Group"}}gs.prototype.isGroup=!0;const vs={type:"move"};class ys{constructor(){this._targetRay=null,this._grip=null,this._hand=null}getHandSpace(){return null===this._hand&&(this._hand=new gs,this._hand.matrixAutoUpdate=!1,this._hand.visible=!1,this._hand.joints={},this._hand.inputState={pinching:!1}),this._hand}getTargetRaySpace(){return null===this._targetRay&&(this._targetRay=new gs,this._targetRay.matrixAutoUpdate=!1,this._targetRay.visible=!1,this._targetRay.hasLinearVelocity=!1,this._targetRay.linearVelocity=new Lt,this._targetRay.hasAngularVelocity=!1,this._targetRay.angularVelocity=new Lt),this._targetRay}getGripSpace(){return null===this._grip&&(this._grip=new gs,this._grip.matrixAutoUpdate=!1,this._grip.visible=!1,this._grip.hasLinearVelocity=!1,this._grip.linearVelocity=new Lt,this._grip.hasAngularVelocity=!1,this._grip.angularVelocity=new Lt),this._grip}dispatchEvent(t){return null!==this._targetRay&&this._targetRay.dispatchEvent(t),null!==this._grip&&this._grip.dispatchEvent(t),null!==this._hand&&this._hand.dispatchEvent(t),this}disconnect(t){return this.dispatchEvent({type:"disconnected",data:t}),null!==this._targetRay&&(this._targetRay.visible=!1),null!==this._grip&&(this._grip.visible=!1),null!==this._hand&&(this._hand.visible=!1),this}update(t,e,n){let i=null,r=null,s=null;const a=this._targetRay,o=this._grip,l=this._hand;if(t&&"visible-blurred"!==e.session.visibilityState)if(null!==a&&(i=e.getPose(t.targetRaySpace,n),null!==i&&(a.matrix.fromArray(i.transform.matrix),a.matrix.decompose(a.position,a.rotation,a.scale),i.linearVelocity?(a.hasLinearVelocity=!0,a.linearVelocity.copy(i.linearVelocity)):a.hasLinearVelocity=!1,i.angularVelocity?(a.hasAngularVelocity=!0,a.angularVelocity.copy(i.angularVelocity)):a.hasAngularVelocity=!1,this.dispatchEvent(vs))),l&&t.hand){s=!0;for(const i of t.hand.values()){const t=e.getJointPose(i,n);if(void 0===l.joints[i.jointName]){const t=new gs;t.matrixAutoUpdate=!1,t.visible=!1,l.joints[i.jointName]=t,l.add(t)}const r=l.joints[i.jointName];null!==t&&(r.matrix.fromArray(t.transform.matrix),r.matrix.decompose(r.position,r.rotation,r.scale),r.jointRadius=t.radius),r.visible=null!==t}const i=l.joints["index-finger-tip"],r=l.joints["thumb-tip"],a=i.position.distanceTo(r.position),o=.02,c=.005;l.inputState.pinching&&a>o+c?(l.inputState.pinching=!1,this.dispatchEvent({type:"pinchend",handedness:t.handedness,target:this})):!l.inputState.pinching&&a<=o-c&&(l.inputState.pinching=!0,this.dispatchEvent({type:"pinchstart",handedness:t.handedness,target:this}))}else null!==o&&t.gripSpace&&(r=e.getPose(t.gripSpace,n),null!==r&&(o.matrix.fromArray(r.transform.matrix),o.matrix.decompose(o.position,o.rotation,o.scale),r.linearVelocity?(o.hasLinearVelocity=!0,o.linearVelocity.copy(r.linearVelocity)):o.hasLinearVelocity=!1,r.angularVelocity?(o.hasAngularVelocity=!0,o.angularVelocity.copy(r.angularVelocity)):o.hasAngularVelocity=!1));return null!==a&&(a.visible=null!==i),null!==o&&(o.visible=null!==r),null!==l&&(l.visible=null!==s),this}}class xs extends rt{constructor(t,e){super();const n=this,i=t.state;let r=null,s=1,a=null,o="local-floor",l=null;const c=[],h=new Map,u=new Kn;u.layers.enable(1),u.viewport=new St;const d=new Kn;d.layers.enable(2),d.viewport=new St;const p=[u,d],m=new fs;m.layers.enable(1),m.layers.enable(2);let f=null,g=null;function v(t){const e=h.get(t.inputSource);e&&e.dispatchEvent({type:t.type,data:t.inputSource})}function y(){h.forEach((function(t,e){t.disconnect(e)})),h.clear(),f=null,g=null,i.bindXRFramebuffer(null),t.setRenderTarget(t.getRenderTarget()),S.stop(),n.isPresenting=!1,n.dispatchEvent({type:"sessionend"})}function x(t){const e=r.inputSources;for(let t=0;t0&&Rt(s,t,e),a.length>0&&Rt(a,t,e),null!==_&&(J.updateRenderTargetMipmap(_),J.updateMultisampleRenderTarget(_)),!0===t.isScene&&t.onAfterRender(f,t,e),q.buffers.depth.setTest(!0),q.buffers.depth.setMask(!0),q.buffers.color.setMask(!0),q.setPolygonOffset(!1),dt.resetDefaultState(),w=-1,S=null,m.pop(),d=m.length>0?m[m.length-1]:null,p.pop(),u=p.length>0?p[p.length-1]:null},this.getActiveCubeFace=function(){return v},this.getActiveMipmapLevel=function(){return y},this.getRenderTarget=function(){return _},this.setRenderTarget=function(t,e=0,n=0){_=t,v=e,y=n,t&&void 0===Z.get(t).__webglFramebuffer&&J.setupRenderTarget(t);let i=null,r=!1,s=!1;if(t){const n=t.texture;(n.isDataTexture3D||n.isDataTexture2DArray)&&(s=!0);const a=Z.get(t).__webglFramebuffer;t.isWebGLCubeRenderTarget?(i=a[e],r=!0):i=t.isWebGLMultisampleRenderTarget?Z.get(t).__webglMultisampledFramebuffer:a,T.copy(t.viewport),A.copy(t.scissor),L=t.scissorTest}else T.copy(N).multiplyScalar(P).floor(),A.copy(B).multiplyScalar(P).floor(),L=z;if(q.bindFramebuffer(36160,i),q.viewport(T),q.scissor(A),q.setScissorTest(L),r){const i=Z.get(t.texture);pt.framebufferTexture2D(36160,36064,34069+e,i.__webglTexture,n)}else if(s){const i=Z.get(t.texture),r=e||0;pt.framebufferTextureLayer(36160,36064,i.__webglTexture,n||0,r)}},this.readRenderTargetPixels=function(t,e,n,i,r,s,a){if(!t||!t.isWebGLRenderTarget)return void console.error("THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not THREE.WebGLRenderTarget.");let o=Z.get(t).__webglFramebuffer;if(t.isWebGLCubeRenderTarget&&void 0!==a&&(o=o[a]),o){q.bindFramebuffer(36160,o);try{const a=t.texture,o=a.format,l=a.type;if(o!==E&&ut.convert(o)!==pt.getParameter(35739))return void console.error("THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not in RGBA or implementation defined format.");const c=l===M&&(W.has("EXT_color_buffer_half_float")||j.isWebGL2&&W.has("EXT_color_buffer_float"));if(!(l===x||ut.convert(l)===pt.getParameter(35738)||l===b&&(j.isWebGL2||W.has("OES_texture_float")||W.has("WEBGL_color_buffer_float"))||c))return void console.error("THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not in UnsignedByteType or implementation defined type.");36053===pt.checkFramebufferStatus(36160)?e>=0&&e<=t.width-i&&n>=0&&n<=t.height-r&&pt.readPixels(e,n,i,r,ut.convert(o),ut.convert(l),s):console.error("THREE.WebGLRenderer.readRenderTargetPixels: readPixels from renderTarget failed. Framebuffer not complete.")}finally{const t=null!==_?Z.get(_).__webglFramebuffer:null;q.bindFramebuffer(36160,t)}}},this.copyFramebufferToTexture=function(t,e,n=0){const i=Math.pow(2,-n),r=Math.floor(e.image.width*i),s=Math.floor(e.image.height*i),a=ut.convert(e.format);J.setTexture2D(e,0),pt.copyTexImage2D(3553,n,a,t.x,t.y,r,s,0),q.unbindTexture()},this.copyTextureToTexture=function(t,e,n,i=0){const r=e.image.width,s=e.image.height,a=ut.convert(n.format),o=ut.convert(n.type);J.setTexture2D(n,0),pt.pixelStorei(37440,n.flipY),pt.pixelStorei(37441,n.premultiplyAlpha),pt.pixelStorei(3317,n.unpackAlignment),e.isDataTexture?pt.texSubImage2D(3553,i,t.x,t.y,r,s,a,o,e.image.data):e.isCompressedTexture?pt.compressedTexSubImage2D(3553,i,t.x,t.y,e.mipmaps[0].width,e.mipmaps[0].height,a,e.mipmaps[0].data):pt.texSubImage2D(3553,i,t.x,t.y,a,o,e.image),0===i&&n.generateMipmaps&&pt.generateMipmap(3553),q.unbindTexture()},this.copyTextureToTexture3D=function(t,e,n,i,r=0){if(f.isWebGL1Renderer)return void console.warn("THREE.WebGLRenderer.copyTextureToTexture3D: can only be used with WebGL2.");const{width:s,height:a,data:o}=n.image,l=ut.convert(i.format),c=ut.convert(i.type);let h;if(i.isDataTexture3D)J.setTexture3D(i,0),h=32879;else{if(!i.isDataTexture2DArray)return void console.warn("THREE.WebGLRenderer.copyTextureToTexture3D: only supports THREE.DataTexture3D and THREE.DataTexture2DArray.");J.setTexture2DArray(i,0),h=35866}pt.pixelStorei(37440,i.flipY),pt.pixelStorei(37441,i.premultiplyAlpha),pt.pixelStorei(3317,i.unpackAlignment);const u=pt.getParameter(3314),d=pt.getParameter(32878),p=pt.getParameter(3316),m=pt.getParameter(3315),g=pt.getParameter(32877);pt.pixelStorei(3314,s),pt.pixelStorei(32878,a),pt.pixelStorei(3316,t.min.x),pt.pixelStorei(3315,t.min.y),pt.pixelStorei(32877,t.min.z),pt.texSubImage3D(h,r,e.x,e.y,e.z,t.max.x-t.min.x+1,t.max.y-t.min.y+1,t.max.z-t.min.z+1,l,c,o),pt.pixelStorei(3314,u),pt.pixelStorei(32878,d),pt.pixelStorei(3316,p),pt.pixelStorei(3315,m),pt.pixelStorei(32877,g),0===r&&i.generateMipmaps&&pt.generateMipmap(h),q.unbindTexture()},this.initTexture=function(t){J.setTexture2D(t,0),q.unbindTexture()},this.resetState=function(){v=0,y=0,_=null,q.reset(),dt.reset()},"undefined"!=typeof __THREE_DEVTOOLS__&&__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent("observe",{detail:this}))}class bs extends ws{}bs.prototype.isWebGL1Renderer=!0;class Ms{constructor(t,e=25e-5){this.name="",this.color=new tn(t),this.density=e}clone(){return new Ms(this.color,this.density)}toJSON(){return{type:"FogExp2",color:this.color.getHex(),density:this.density}}}Ms.prototype.isFogExp2=!0;class Ss{constructor(t,e=1,n=1e3){this.name="",this.color=new tn(t),this.near=e,this.far=n}clone(){return new Ss(this.color,this.near,this.far)}toJSON(){return{type:"Fog",color:this.color.getHex(),near:this.near,far:this.far}}}Ss.prototype.isFog=!0;class Ts extends Ce{constructor(){super(),this.type="Scene",this.background=null,this.environment=null,this.fog=null,this.overrideMaterial=null,this.autoUpdate=!0,"undefined"!=typeof __THREE_DEVTOOLS__&&__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent("observe",{detail:this}))}copy(t,e){return super.copy(t,e),null!==t.background&&(this.background=t.background.clone()),null!==t.environment&&(this.environment=t.environment.clone()),null!==t.fog&&(this.fog=t.fog.clone()),null!==t.overrideMaterial&&(this.overrideMaterial=t.overrideMaterial.clone()),this.autoUpdate=t.autoUpdate,this.matrixAutoUpdate=t.matrixAutoUpdate,this}toJSON(t){const e=super.toJSON(t);return null!==this.background&&(e.object.background=this.background.toJSON(t)),null!==this.environment&&(e.object.environment=this.environment.toJSON(t)),null!==this.fog&&(e.object.fog=this.fog.toJSON()),e}}Ts.prototype.isScene=!0;class Es{constructor(t,e){this.array=t,this.stride=e,this.count=void 0!==t?t.length/e:0,this.usage=et,this.updateRange={offset:0,count:-1},this.version=0,this.uuid=ct(),this.onUploadCallback=function(){}}set needsUpdate(t){!0===t&&this.version++}setUsage(t){return this.usage=t,this}copy(t){return this.array=new t.array.constructor(t.array),this.count=t.count,this.stride=t.stride,this.usage=t.usage,this}copyAt(t,e,n){t*=this.stride,n*=e.stride;for(let i=0,r=this.stride;it.far||e.push({distance:o,point:Ps.clone(),uv:je.getUV(Ps,Fs,Os,Hs,Gs,Us,ks,new vt),face:null,object:this})}copy(t){return super.copy(t),void 0!==t.center&&this.center.copy(t.center),this.material=t.material,this}}function Ws(t,e,n,i,r,s){Ns.subVectors(t,n).addScalar(.5).multiply(i),void 0!==r?(Bs.x=s*Ns.x-r*Ns.y,Bs.y=r*Ns.x+s*Ns.y):Bs.copy(Ns),t.copy(e),t.x+=Bs.x,t.y+=Bs.y,t.applyMatrix4(zs)}Vs.prototype.isSprite=!0;const js=new Lt,qs=new Lt;class Xs extends Ce{constructor(){super(),this._currentLevel=0,this.type="LOD",Object.defineProperties(this,{levels:{enumerable:!0,value:[]},isLOD:{value:!0}}),this.autoUpdate=!0}copy(t){super.copy(t,!1);const e=t.levels;for(let t=0,n=e.length;t0){let n,i;for(n=1,i=e.length;n0){js.setFromMatrixPosition(this.matrixWorld);const n=t.ray.origin.distanceTo(js);this.getObjectForDistance(n).raycast(t,e)}}update(t){const e=this.levels;if(e.length>1){js.setFromMatrixPosition(t.matrixWorld),qs.setFromMatrixPosition(this.matrixWorld);const n=js.distanceTo(qs)/t.zoom;let i,r;for(e[0].object.visible=!0,i=1,r=e.length;i=e[i].distance;i++)e[i-1].object.visible=!1,e[i].object.visible=!0;for(this._currentLevel=i-1;io)continue;u.applyMatrix4(this.matrixWorld);const d=t.ray.origin.distanceTo(u);dt.far||e.push({distance:d,point:h.clone().applyMatrix4(this.matrixWorld),index:n,face:null,faceIndex:null,object:this})}}else{for(let n=Math.max(0,s.start),i=Math.min(r.count,s.start+s.count)-1;no)continue;u.applyMatrix4(this.matrixWorld);const i=t.ray.origin.distanceTo(u);it.far||e.push({distance:i,point:h.clone().applyMatrix4(this.matrixWorld),index:n,face:null,faceIndex:null,object:this})}}}else n.isGeometry&&console.error("THREE.Line.raycast() no longer supports THREE.Geometry. Use THREE.BufferGeometry instead.")}updateMorphTargets(){const t=this.geometry;if(t.isBufferGeometry){const e=t.morphAttributes,n=Object.keys(e);if(n.length>0){const t=e[n[0]];if(void 0!==t){this.morphTargetInfluences=[],this.morphTargetDictionary={};for(let e=0,n=t.length;e0&&console.error("THREE.Line.updateMorphTargets() does not support THREE.Geometry. Use THREE.BufferGeometry instead.")}}}fa.prototype.isLine=!0;const ga=new Lt,va=new Lt;class ya extends fa{constructor(t,e){super(t,e),this.type="LineSegments"}computeLineDistances(){const t=this.geometry;if(t.isBufferGeometry)if(null===t.index){const e=t.attributes.position,n=[];for(let t=0,i=e.count;t0){const t=e[n[0]];if(void 0!==t){this.morphTargetInfluences=[],this.morphTargetDictionary={};for(let e=0,n=t.length;e0&&console.error("THREE.Points.updateMorphTargets() does not support THREE.Geometry. Use THREE.BufferGeometry instead.")}}}function Ea(t,e,n,i,r,s,a){const o=ba.distanceSqToPoint(t);if(or.far)return;s.push({distance:l,distanceToRay:Math.sqrt(o),point:n,index:e,face:null,object:a})}}Ta.prototype.isPoints=!0;class Aa extends bt{constructor(t,e,n,i,r,s,a,o,l){super(t,e,n,i,r,s,a,o,l),this.format=void 0!==a?a:T,this.minFilter=void 0!==s?s:g,this.magFilter=void 0!==r?r:g,this.generateMipmaps=!1;const c=this;"requestVideoFrameCallback"in t&&t.requestVideoFrameCallback((function e(){c.needsUpdate=!0,t.requestVideoFrameCallback(e)}))}clone(){return new this.constructor(this.image).copy(this)}update(){const t=this.image;!1==="requestVideoFrameCallback"in t&&t.readyState>=t.HAVE_CURRENT_DATA&&(this.needsUpdate=!0)}}Aa.prototype.isVideoTexture=!0;class La extends bt{constructor(t,e,n,i,r,s,a,o,l,c,h,u){super(null,s,a,o,l,c,i,r,h,u),this.image={width:e,height:n},this.mipmaps=t,this.flipY=!1,this.generateMipmaps=!1}}La.prototype.isCompressedTexture=!0;class Ra extends bt{constructor(t,e,n,i,r,s,a,o,l){super(t,e,n,i,r,s,a,o,l),this.needsUpdate=!0}}Ra.prototype.isCanvasTexture=!0;class Ca extends bt{constructor(t,e,n,i,r,s,a,o,l,c){if((c=void 0!==c?c:A)!==A&&c!==L)throw new Error("DepthTexture format must be either THREE.DepthFormat or THREE.DepthStencilFormat");void 0===n&&c===A&&(n=_),void 0===n&&c===L&&(n=S),super(null,i,r,s,a,o,c,n,l),this.image={width:t,height:e},this.magFilter=void 0!==a?a:p,this.minFilter=void 0!==o?o:p,this.flipY=!1,this.generateMipmaps=!1}}Ca.prototype.isDepthTexture=!0;class Pa extends En{constructor(t=1,e=8,n=0,i=2*Math.PI){super(),this.type="CircleGeometry",this.parameters={radius:t,segments:e,thetaStart:n,thetaLength:i},e=Math.max(3,e);const r=[],s=[],a=[],o=[],l=new Lt,c=new vt;s.push(0,0,0),a.push(0,0,1),o.push(.5,.5);for(let r=0,h=3;r<=e;r++,h+=3){const u=n+r/e*i;l.x=t*Math.cos(u),l.y=t*Math.sin(u),s.push(l.x,l.y,l.z),a.push(0,0,1),c.x=(s[h]/t+1)/2,c.y=(s[h+1]/t+1)/2,o.push(c.x,c.y)}for(let t=1;t<=e;t++)r.push(t,t+1,0);this.setIndex(r),this.setAttribute("position",new mn(s,3)),this.setAttribute("normal",new mn(a,3)),this.setAttribute("uv",new mn(o,2))}}class Da extends En{constructor(t=1,e=1,n=1,i=8,r=1,s=!1,a=0,o=2*Math.PI){super(),this.type="CylinderGeometry",this.parameters={radiusTop:t,radiusBottom:e,height:n,radialSegments:i,heightSegments:r,openEnded:s,thetaStart:a,thetaLength:o};const l=this;i=Math.floor(i),r=Math.floor(r);const c=[],h=[],u=[],d=[];let p=0;const m=[],f=n/2;let g=0;function v(n){const r=p,s=new vt,m=new Lt;let v=0;const y=!0===n?t:e,x=!0===n?1:-1;for(let t=1;t<=i;t++)h.push(0,f*x,0),u.push(0,x,0),d.push(.5,.5),p++;const _=p;for(let t=0;t<=i;t++){const e=t/i*o+a,n=Math.cos(e),r=Math.sin(e);m.x=y*r,m.y=f*x,m.z=y*n,h.push(m.x,m.y,m.z),u.push(0,x,0),s.x=.5*n+.5,s.y=.5*r*x+.5,d.push(s.x,s.y),p++}for(let t=0;t0&&v(!0),e>0&&v(!1)),this.setIndex(c),this.setAttribute("position",new mn(h,3)),this.setAttribute("normal",new mn(u,3)),this.setAttribute("uv",new mn(d,2))}}class Ia extends Da{constructor(t=1,e=1,n=8,i=1,r=!1,s=0,a=2*Math.PI){super(0,t,e,n,i,r,s,a),this.type="ConeGeometry",this.parameters={radius:t,height:e,radialSegments:n,heightSegments:i,openEnded:r,thetaStart:s,thetaLength:a}}}class Na extends En{constructor(t,e,n=1,i=0){super(),this.type="PolyhedronGeometry",this.parameters={vertices:t,indices:e,radius:n,detail:i};const r=[],s=[];function a(t,e,n,i){const r=i+1,s=[];for(let i=0;i<=r;i++){s[i]=[];const a=t.clone().lerp(n,i/r),o=e.clone().lerp(n,i/r),l=r-i;for(let t=0;t<=l;t++)s[i][t]=0===t&&i===r?a:a.clone().lerp(o,t/l)}for(let t=0;t.9&&a<.1&&(e<.2&&(s[t+0]+=1),n<.2&&(s[t+2]+=1),i<.2&&(s[t+4]+=1))}}()}(),this.setAttribute("position",new mn(r,3)),this.setAttribute("normal",new mn(r.slice(),3)),this.setAttribute("uv",new mn(s,2)),0===i?this.computeVertexNormals():this.normalizeNormals()}}class Ba extends Na{constructor(t=1,e=0){const n=(1+Math.sqrt(5))/2,i=1/n;super([-1,-1,-1,-1,-1,1,-1,1,-1,-1,1,1,1,-1,-1,1,-1,1,1,1,-1,1,1,1,0,-i,-n,0,-i,n,0,i,-n,0,i,n,-i,-n,0,-i,n,0,i,-n,0,i,n,0,-n,0,-i,n,0,-i,-n,0,i,n,0,i],[3,11,7,3,7,15,3,15,13,7,19,17,7,17,6,7,6,15,17,4,8,17,8,10,17,10,6,8,0,16,8,16,2,8,2,10,0,12,1,0,1,18,0,18,16,6,10,2,6,2,13,6,13,15,2,16,18,2,18,3,2,3,13,18,1,9,18,9,11,18,11,3,4,14,12,4,12,0,4,0,8,11,9,5,11,5,19,11,19,7,19,5,14,19,14,4,19,4,17,1,12,14,1,14,5,1,5,9],t,e),this.type="DodecahedronGeometry",this.parameters={radius:t,detail:e}}}const za=new Lt,Fa=new Lt,Oa=new Lt,Ha=new je;class Ga extends En{constructor(t,e){if(super(),this.type="EdgesGeometry",this.parameters={thresholdAngle:e},e=void 0!==e?e:1,!0===t.isGeometry)return void console.error("THREE.EdgesGeometry no longer supports THREE.Geometry. Use THREE.BufferGeometry instead.");const n=Math.pow(10,4),i=Math.cos(ot*e),r=t.getIndex(),s=t.getAttribute("position"),a=r?r.count:s.count,o=[0,0,0],l=["a","b","c"],c=new Array(3),h={},u=[];for(let t=0;t80*n){o=c=t[0],l=h=t[1];for(let e=n;ec&&(c=u),d>h&&(h=d);p=Math.max(c-o,h-l),p=0!==p?1/p:0}return Wa(s,a,n,o,l,p),a};function ka(t,e,n,i,r){let s,a;if(r===function(t,e,n,i){let r=0;for(let s=e,a=n-i;s0)for(s=e;s=e;s-=i)a=co(s,t[s],t[s+1],a);return a&&io(a,a.next)&&(ho(a),a=a.next),a}function Va(t,e){if(!t)return t;e||(e=t);let n,i=t;do{if(n=!1,i.steiner||!io(i,i.next)&&0!==no(i.prev,i,i.next))i=i.next;else{if(ho(i),i=e=i.prev,i===i.next)break;n=!0}}while(n||i!==e);return e}function Wa(t,e,n,i,r,s,a){if(!t)return;!a&&s&&function(t,e,n,i){let r=t;do{null===r.z&&(r.z=Ka(r.x,r.y,e,n,i)),r.prevZ=r.prev,r.nextZ=r.next,r=r.next}while(r!==t);r.prevZ.nextZ=null,r.prevZ=null,function(t){let e,n,i,r,s,a,o,l,c=1;do{for(n=t,t=null,s=null,a=0;n;){for(a++,i=n,o=0,e=0;e0||l>0&&i;)0!==o&&(0===l||!i||n.z<=i.z)?(r=n,n=n.nextZ,o--):(r=i,i=i.nextZ,l--),s?s.nextZ=r:t=r,r.prevZ=s,s=r;n=i}s.nextZ=null,c*=2}while(a>1)}(r)}(t,i,r,s);let o,l,c=t;for(;t.prev!==t.next;)if(o=t.prev,l=t.next,s?qa(t,i,r,s):ja(t))e.push(o.i/n),e.push(t.i/n),e.push(l.i/n),ho(t),t=l.next,c=l.next;else if((t=l)===c){a?1===a?Wa(t=Xa(Va(t),e,n),e,n,i,r,s,2):2===a&&Ya(t,e,n,i,r,s):Wa(Va(t),e,n,i,r,s,1);break}}function ja(t){const e=t.prev,n=t,i=t.next;if(no(e,n,i)>=0)return!1;let r=t.next.next;for(;r!==t.prev;){if(to(e.x,e.y,n.x,n.y,i.x,i.y,r.x,r.y)&&no(r.prev,r,r.next)>=0)return!1;r=r.next}return!0}function qa(t,e,n,i){const r=t.prev,s=t,a=t.next;if(no(r,s,a)>=0)return!1;const o=r.xs.x?r.x>a.x?r.x:a.x:s.x>a.x?s.x:a.x,h=r.y>s.y?r.y>a.y?r.y:a.y:s.y>a.y?s.y:a.y,u=Ka(o,l,e,n,i),d=Ka(c,h,e,n,i);let p=t.prevZ,m=t.nextZ;for(;p&&p.z>=u&&m&&m.z<=d;){if(p!==t.prev&&p!==t.next&&to(r.x,r.y,s.x,s.y,a.x,a.y,p.x,p.y)&&no(p.prev,p,p.next)>=0)return!1;if(p=p.prevZ,m!==t.prev&&m!==t.next&&to(r.x,r.y,s.x,s.y,a.x,a.y,m.x,m.y)&&no(m.prev,m,m.next)>=0)return!1;m=m.nextZ}for(;p&&p.z>=u;){if(p!==t.prev&&p!==t.next&&to(r.x,r.y,s.x,s.y,a.x,a.y,p.x,p.y)&&no(p.prev,p,p.next)>=0)return!1;p=p.prevZ}for(;m&&m.z<=d;){if(m!==t.prev&&m!==t.next&&to(r.x,r.y,s.x,s.y,a.x,a.y,m.x,m.y)&&no(m.prev,m,m.next)>=0)return!1;m=m.nextZ}return!0}function Xa(t,e,n){let i=t;do{const r=i.prev,s=i.next.next;!io(r,s)&&ro(r,i,i.next,s)&&oo(r,s)&&oo(s,r)&&(e.push(r.i/n),e.push(i.i/n),e.push(s.i/n),ho(i),ho(i.next),i=t=s),i=i.next}while(i!==t);return Va(i)}function Ya(t,e,n,i,r,s){let a=t;do{let t=a.next.next;for(;t!==a.prev;){if(a.i!==t.i&&eo(a,t)){let o=lo(a,t);return a=Va(a,a.next),o=Va(o,o.next),Wa(a,e,n,i,r,s),void Wa(o,e,n,i,r,s)}t=t.next}a=a.next}while(a!==t)}function Za(t,e){return t.x-e.x}function Ja(t,e){if(e=function(t,e){let n=e;const i=t.x,r=t.y;let s,a=-1/0;do{if(r<=n.y&&r>=n.next.y&&n.next.y!==n.y){const t=n.x+(r-n.y)*(n.next.x-n.x)/(n.next.y-n.y);if(t<=i&&t>a){if(a=t,t===i){if(r===n.y)return n;if(r===n.next.y)return n.next}s=n.x=n.x&&n.x>=l&&i!==n.x&&to(rs.x||n.x===s.x&&Qa(s,n)))&&(s=n,u=h)),n=n.next}while(n!==o);return s}(t,e)){const n=lo(e,t);Va(e,e.next),Va(n,n.next)}}function Qa(t,e){return no(t.prev,t,e.prev)<0&&no(e.next,t,t.next)<0}function Ka(t,e,n,i,r){return(t=1431655765&((t=858993459&((t=252645135&((t=16711935&((t=32767*(t-n)*r)|t<<8))|t<<4))|t<<2))|t<<1))|(e=1431655765&((e=858993459&((e=252645135&((e=16711935&((e=32767*(e-i)*r)|e<<8))|e<<4))|e<<2))|e<<1))<<1}function $a(t){let e=t,n=t;do{(e.x=0&&(t-a)*(i-o)-(n-a)*(e-o)>=0&&(n-a)*(s-o)-(r-a)*(i-o)>=0}function eo(t,e){return t.next.i!==e.i&&t.prev.i!==e.i&&!function(t,e){let n=t;do{if(n.i!==t.i&&n.next.i!==t.i&&n.i!==e.i&&n.next.i!==e.i&&ro(n,n.next,t,e))return!0;n=n.next}while(n!==t);return!1}(t,e)&&(oo(t,e)&&oo(e,t)&&function(t,e){let n=t,i=!1;const r=(t.x+e.x)/2,s=(t.y+e.y)/2;do{n.y>s!=n.next.y>s&&n.next.y!==n.y&&r<(n.next.x-n.x)*(s-n.y)/(n.next.y-n.y)+n.x&&(i=!i),n=n.next}while(n!==t);return i}(t,e)&&(no(t.prev,t,e.prev)||no(t,e.prev,e))||io(t,e)&&no(t.prev,t,t.next)>0&&no(e.prev,e,e.next)>0)}function no(t,e,n){return(e.y-t.y)*(n.x-e.x)-(e.x-t.x)*(n.y-e.y)}function io(t,e){return t.x===e.x&&t.y===e.y}function ro(t,e,n,i){const r=ao(no(t,e,n)),s=ao(no(t,e,i)),a=ao(no(n,i,t)),o=ao(no(n,i,e));return r!==s&&a!==o||(!(0!==r||!so(t,n,e))||(!(0!==s||!so(t,i,e))||(!(0!==a||!so(n,t,i))||!(0!==o||!so(n,e,i)))))}function so(t,e,n){return e.x<=Math.max(t.x,n.x)&&e.x>=Math.min(t.x,n.x)&&e.y<=Math.max(t.y,n.y)&&e.y>=Math.min(t.y,n.y)}function ao(t){return t>0?1:t<0?-1:0}function oo(t,e){return no(t.prev,t,t.next)<0?no(t,e,t.next)>=0&&no(t,t.prev,e)>=0:no(t,e,t.prev)<0||no(t,t.next,e)<0}function lo(t,e){const n=new uo(t.i,t.x,t.y),i=new uo(e.i,e.x,e.y),r=t.next,s=e.prev;return t.next=e,e.prev=t,n.next=r,r.prev=n,i.next=n,n.prev=i,s.next=i,i.prev=s,i}function co(t,e,n,i){const r=new uo(t,e,n);return i?(r.next=i.next,r.prev=i,i.next.prev=r,i.next=r):(r.prev=r,r.next=r),r}function ho(t){t.next.prev=t.prev,t.prev.next=t.next,t.prevZ&&(t.prevZ.nextZ=t.nextZ),t.nextZ&&(t.nextZ.prevZ=t.prevZ)}function uo(t,e,n){this.i=t,this.x=e,this.y=n,this.prev=null,this.next=null,this.z=null,this.prevZ=null,this.nextZ=null,this.steiner=!1}class po{static area(t){const e=t.length;let n=0;for(let i=e-1,r=0;r2&&t[e-1].equals(t[0])&&t.pop()}function fo(t,e){for(let n=0;nNumber.EPSILON){const u=Math.sqrt(h),d=Math.sqrt(l*l+c*c),p=e.x-o/u,m=e.y+a/u,f=((n.x-c/d-p)*c-(n.y+l/d-m)*l)/(a*c-o*l);i=p+a*f-t.x,r=m+o*f-t.y;const g=i*i+r*r;if(g<=2)return new vt(i,r);s=Math.sqrt(g/2)}else{let t=!1;a>Number.EPSILON?l>Number.EPSILON&&(t=!0):a<-Number.EPSILON?l<-Number.EPSILON&&(t=!0):Math.sign(o)===Math.sign(c)&&(t=!0),t?(i=-o,r=a,s=Math.sqrt(h)):(i=a,r=o,s=Math.sqrt(h/2))}return new vt(i/s,r/s)}const P=[];for(let t=0,e=E.length,n=e-1,i=t+1;t=0;t--){const e=t/p,n=h*Math.cos(e*Math.PI/2),i=u*Math.sin(e*Math.PI/2)+d;for(let t=0,e=E.length;t=0;){const i=n;let r=n-1;r<0&&(r=t.length-1);for(let t=0,n=o+2*p;t=0?(t(i-o,p,h),u.subVectors(c,h)):(t(i+o,p,h),u.subVectors(h,c)),p-o>=0?(t(i,p-o,h),d.subVectors(c,h)):(t(i,p+o,h),d.subVectors(h,c)),l.crossVectors(u,d).normalize(),s.push(l.x,l.y,l.z),a.push(i,p)}}for(let t=0;t0)&&d.push(e,r,l),(t!==n-1||o=i)){l.push(e.times[t]);for(let n=0;ns.tracks[t].times[0]&&(o=s.tracks[t].times[0]);for(let t=0;t=i.times[u]){const t=u*l+o,e=t+l-o;d=Vo.arraySlice(i.values,t,e)}else{const t=i.createInterpolant(),e=o,n=l-o;t.evaluate(s),d=Vo.arraySlice(t.resultBuffer,e,n)}if("quaternion"===r){(new At).fromArray(d).normalize().conjugate().toArray(d)}const p=a.times.length;for(let t=0;t=r)break t;{const a=e[1];t=r)break e}s=n,n=0}}for(;n>>1;te;)--s;if(++s,0!==r||s!==i){r>=s&&(s=Math.max(s,1),r=s-1);const t=this.getValueSize();this.times=Vo.arraySlice(n,r,s),this.values=Vo.arraySlice(this.values,r*t,s*t)}return this}validate(){let t=!0;const e=this.getValueSize();e-Math.floor(e)!=0&&(console.error("THREE.KeyframeTrack: Invalid value size in track.",this),t=!1);const n=this.times,i=this.values,r=n.length;0===r&&(console.error("THREE.KeyframeTrack: Track is empty.",this),t=!1);let s=null;for(let e=0;e!==r;e++){const i=n[e];if("number"==typeof i&&isNaN(i)){console.error("THREE.KeyframeTrack: Time is not a valid number.",this,e,i),t=!1;break}if(null!==s&&s>i){console.error("THREE.KeyframeTrack: Out of order keys.",this,e,i,s),t=!1;break}s=i}if(void 0!==i&&Vo.isTypedArray(i))for(let e=0,n=i.length;e!==n;++e){const n=i[e];if(isNaN(n)){console.error("THREE.KeyframeTrack: Value is not a valid number.",this,e,n),t=!1;break}}return t}optimize(){const t=Vo.arraySlice(this.times),e=Vo.arraySlice(this.values),n=this.getValueSize(),i=this.getInterpolation()===U,r=t.length-1;let s=1;for(let a=1;a0){t[s]=t[r];for(let t=r*n,i=s*n,a=0;a!==n;++a)e[i+a]=e[t+a];++s}return s!==t.length?(this.times=Vo.arraySlice(t,0,s),this.values=Vo.arraySlice(e,0,s*n)):(this.times=t,this.values=e),this}clone(){const t=Vo.arraySlice(this.times,0),e=Vo.arraySlice(this.values,0),n=new(0,this.constructor)(this.name,t,e);return n.createInterpolant=this.createInterpolant,n}}Yo.prototype.TimeBufferType=Float32Array,Yo.prototype.ValueBufferType=Float32Array,Yo.prototype.DefaultInterpolation=G;class Zo extends Yo{}Zo.prototype.ValueTypeName="bool",Zo.prototype.ValueBufferType=Array,Zo.prototype.DefaultInterpolation=H,Zo.prototype.InterpolantFactoryMethodLinear=void 0,Zo.prototype.InterpolantFactoryMethodSmooth=void 0;class Jo extends Yo{}Jo.prototype.ValueTypeName="color";class Qo extends Yo{}Qo.prototype.ValueTypeName="number";class Ko extends Wo{constructor(t,e,n,i){super(t,e,n,i)}interpolate_(t,e,n,i){const r=this.resultBuffer,s=this.sampleValues,a=this.valueSize,o=(n-e)/(i-e);let l=t*a;for(let t=l+a;l!==t;l+=4)At.slerpFlat(r,0,s,l-a,s,l,o);return r}}class $o extends Yo{InterpolantFactoryMethodLinear(t){return new Ko(this.times,this.values,this.getValueSize(),t)}}$o.prototype.ValueTypeName="quaternion",$o.prototype.DefaultInterpolation=G,$o.prototype.InterpolantFactoryMethodSmooth=void 0;class tl extends Yo{}tl.prototype.ValueTypeName="string",tl.prototype.ValueBufferType=Array,tl.prototype.DefaultInterpolation=H,tl.prototype.InterpolantFactoryMethodLinear=void 0,tl.prototype.InterpolantFactoryMethodSmooth=void 0;class el extends Yo{}el.prototype.ValueTypeName="vector";class nl{constructor(t,e=-1,n,i=2500){this.name=t,this.tracks=n,this.duration=e,this.blendMode=i,this.uuid=ct(),this.duration<0&&this.resetDuration()}static parse(t){const e=[],n=t.tracks,i=1/(t.fps||1);for(let t=0,r=n.length;t!==r;++t)e.push(il(n[t]).scale(i));const r=new this(t.name,t.duration,e,t.blendMode);return r.uuid=t.uuid,r}static toJSON(t){const e=[],n=t.tracks,i={name:t.name,duration:t.duration,tracks:e,uuid:t.uuid,blendMode:t.blendMode};for(let t=0,i=n.length;t!==i;++t)e.push(Yo.toJSON(n[t]));return i}static CreateFromMorphTargetSequence(t,e,n,i){const r=e.length,s=[];for(let t=0;t1){const t=s[1];let e=i[t];e||(i[t]=e=[]),e.push(n)}}const s=[];for(const t in i)s.push(this.CreateFromMorphTargetSequence(t,i[t],e,n));return s}static parseAnimation(t,e){if(!t)return console.error("THREE.AnimationClip: No animation in JSONLoader data."),null;const n=function(t,e,n,i,r){if(0!==n.length){const s=[],a=[];Vo.flattenJSON(n,s,a,i),0!==s.length&&r.push(new t(e,s,a))}},i=[],r=t.name||"default",s=t.fps||30,a=t.blendMode;let o=t.length||-1;const l=t.hierarchy||[];for(let t=0;t0||0===t.search(/^data\:image\/jpeg/);r.format=i?T:E,r.needsUpdate=!0,void 0!==e&&e(r)}),n,i),r}}class ml{constructor(){this.type="Curve",this.arcLengthDivisions=200}getPoint(){return console.warn("THREE.Curve: .getPoint() not implemented."),null}getPointAt(t,e){const n=this.getUtoTmapping(t);return this.getPoint(n,e)}getPoints(t=5){const e=[];for(let n=0;n<=t;n++)e.push(this.getPoint(n/t));return e}getSpacedPoints(t=5){const e=[];for(let n=0;n<=t;n++)e.push(this.getPointAt(n/t));return e}getLength(){const t=this.getLengths();return t[t.length-1]}getLengths(t=this.arcLengthDivisions){if(this.cacheArcLengths&&this.cacheArcLengths.length===t+1&&!this.needsUpdate)return this.cacheArcLengths;this.needsUpdate=!1;const e=[];let n,i=this.getPoint(0),r=0;e.push(0);for(let s=1;s<=t;s++)n=this.getPoint(s/t),r+=n.distanceTo(i),e.push(r),i=n;return this.cacheArcLengths=e,e}updateArcLengths(){this.needsUpdate=!0,this.getLengths()}getUtoTmapping(t,e){const n=this.getLengths();let i=0;const r=n.length;let s;s=e||t*n[r-1];let a,o=0,l=r-1;for(;o<=l;)if(i=Math.floor(o+(l-o)/2),a=n[i]-s,a<0)o=i+1;else{if(!(a>0)){l=i;break}l=i-1}if(i=l,n[i]===s)return i/(r-1);const c=n[i];return(i+(s-c)/(n[i+1]-c))/(r-1)}getTangent(t,e){const n=1e-4;let i=t-n,r=t+n;i<0&&(i=0),r>1&&(r=1);const s=this.getPoint(i),a=this.getPoint(r),o=e||(s.isVector2?new vt:new Lt);return o.copy(a).sub(s).normalize(),o}getTangentAt(t,e){const n=this.getUtoTmapping(t);return this.getTangent(n,e)}computeFrenetFrames(t,e){const n=new Lt,i=[],r=[],s=[],a=new Lt,o=new se;for(let e=0;e<=t;e++){const n=e/t;i[e]=this.getTangentAt(n,new Lt),i[e].normalize()}r[0]=new Lt,s[0]=new Lt;let l=Number.MAX_VALUE;const c=Math.abs(i[0].x),h=Math.abs(i[0].y),u=Math.abs(i[0].z);c<=l&&(l=c,n.set(1,0,0)),h<=l&&(l=h,n.set(0,1,0)),u<=l&&n.set(0,0,1),a.crossVectors(i[0],n).normalize(),r[0].crossVectors(i[0],a),s[0].crossVectors(i[0],r[0]);for(let e=1;e<=t;e++){if(r[e]=r[e-1].clone(),s[e]=s[e-1].clone(),a.crossVectors(i[e-1],i[e]),a.length()>Number.EPSILON){a.normalize();const t=Math.acos(ht(i[e-1].dot(i[e]),-1,1));r[e].applyMatrix4(o.makeRotationAxis(a,t))}s[e].crossVectors(i[e],r[e])}if(!0===e){let e=Math.acos(ht(r[0].dot(r[t]),-1,1));e/=t,i[0].dot(a.crossVectors(r[0],r[t]))>0&&(e=-e);for(let n=1;n<=t;n++)r[n].applyMatrix4(o.makeRotationAxis(i[n],e*n)),s[n].crossVectors(i[n],r[n])}return{tangents:i,normals:r,binormals:s}}clone(){return(new this.constructor).copy(this)}copy(t){return this.arcLengthDivisions=t.arcLengthDivisions,this}toJSON(){const t={metadata:{version:4.5,type:"Curve",generator:"Curve.toJSON"}};return t.arcLengthDivisions=this.arcLengthDivisions,t.type=this.type,t}fromJSON(t){return this.arcLengthDivisions=t.arcLengthDivisions,this}}class fl extends ml{constructor(t=0,e=0,n=1,i=1,r=0,s=2*Math.PI,a=!1,o=0){super(),this.type="EllipseCurve",this.aX=t,this.aY=e,this.xRadius=n,this.yRadius=i,this.aStartAngle=r,this.aEndAngle=s,this.aClockwise=a,this.aRotation=o}getPoint(t,e){const n=e||new vt,i=2*Math.PI;let r=this.aEndAngle-this.aStartAngle;const s=Math.abs(r)i;)r-=i;r0?0:(Math.floor(Math.abs(l)/r)+1)*r:0===c&&l===r-1&&(l=r-2,c=1),this.closed||l>0?a=i[(l-1)%r]:(yl.subVectors(i[0],i[1]).add(i[0]),a=yl);const h=i[l%r],u=i[(l+1)%r];if(this.closed||l+2i.length-2?i.length-1:s+1],h=i[s>i.length-3?i.length-1:s+2];return n.set(Ml(a,o.x,l.x,c.x,h.x),Ml(a,o.y,l.y,c.y,h.y)),n}copy(t){super.copy(t),this.points=[];for(let e=0,n=t.points.length;e=e){const t=n[i]-e,r=this.curves[i],s=r.getLength(),a=0===s?0:1-t/s;return r.getPointAt(a)}i++}return null}getLength(){const t=this.getCurveLengths();return t[t.length-1]}updateArcLengths(){this.needsUpdate=!0,this.cacheLengths=null,this.getCurveLengths()}getCurveLengths(){if(this.cacheLengths&&this.cacheLengths.length===this.curves.length)return this.cacheLengths;const t=[];let e=0;for(let n=0,i=this.curves.length;n1&&!e[e.length-1].equals(e[0])&&e.push(e[0]),e}copy(t){super.copy(t),this.curves=[];for(let e=0,n=t.curves.length;e0){const t=l.getPoint(0);t.equals(this.currentPoint)||this.lineTo(t.x,t.y)}this.curves.push(l);const c=l.getPoint(1);return this.currentPoint.copy(c),this}copy(t){return super.copy(t),this.currentPoint.copy(t.currentPoint),this}toJSON(){const t=super.toJSON();return t.currentPoint=this.currentPoint.toArray(),t}fromJSON(t){return super.fromJSON(t),this.currentPoint.fromArray(t.currentPoint),this}}class zl extends Bl{constructor(t){super(t),this.uuid=ct(),this.type="Shape",this.holes=[]}getPointsHoles(t){const e=[];for(let n=0,i=this.holes.length;n0:i.vertexColors=t.vertexColors),void 0!==t.uniforms)for(const e in t.uniforms){const r=t.uniforms[e];switch(i.uniforms[e]={},r.type){case"t":i.uniforms[e].value=n(r.value);break;case"c":i.uniforms[e].value=(new tn).setHex(r.value);break;case"v2":i.uniforms[e].value=(new vt).fromArray(r.value);break;case"v3":i.uniforms[e].value=(new Lt).fromArray(r.value);break;case"v4":i.uniforms[e].value=(new St).fromArray(r.value);break;case"m3":i.uniforms[e].value=(new yt).fromArray(r.value);break;case"m4":i.uniforms[e].value=(new se).fromArray(r.value);break;default:i.uniforms[e].value=r.value}}if(void 0!==t.defines&&(i.defines=t.defines),void 0!==t.vertexShader&&(i.vertexShader=t.vertexShader),void 0!==t.fragmentShader&&(i.fragmentShader=t.fragmentShader),void 0!==t.extensions)for(const e in t.extensions)i.extensions[e]=t.extensions[e];if(void 0!==t.shading&&(i.flatShading=1===t.shading),void 0!==t.size&&(i.size=t.size),void 0!==t.sizeAttenuation&&(i.sizeAttenuation=t.sizeAttenuation),void 0!==t.map&&(i.map=n(t.map)),void 0!==t.matcap&&(i.matcap=n(t.matcap)),void 0!==t.alphaMap&&(i.alphaMap=n(t.alphaMap)),void 0!==t.bumpMap&&(i.bumpMap=n(t.bumpMap)),void 0!==t.bumpScale&&(i.bumpScale=t.bumpScale),void 0!==t.normalMap&&(i.normalMap=n(t.normalMap)),void 0!==t.normalMapType&&(i.normalMapType=t.normalMapType),void 0!==t.normalScale){let e=t.normalScale;!1===Array.isArray(e)&&(e=[e,e]),i.normalScale=(new vt).fromArray(e)}return void 0!==t.displacementMap&&(i.displacementMap=n(t.displacementMap)),void 0!==t.displacementScale&&(i.displacementScale=t.displacementScale),void 0!==t.displacementBias&&(i.displacementBias=t.displacementBias),void 0!==t.roughnessMap&&(i.roughnessMap=n(t.roughnessMap)),void 0!==t.metalnessMap&&(i.metalnessMap=n(t.metalnessMap)),void 0!==t.emissiveMap&&(i.emissiveMap=n(t.emissiveMap)),void 0!==t.emissiveIntensity&&(i.emissiveIntensity=t.emissiveIntensity),void 0!==t.specularMap&&(i.specularMap=n(t.specularMap)),void 0!==t.envMap&&(i.envMap=n(t.envMap)),void 0!==t.envMapIntensity&&(i.envMapIntensity=t.envMapIntensity),void 0!==t.reflectivity&&(i.reflectivity=t.reflectivity),void 0!==t.refractionRatio&&(i.refractionRatio=t.refractionRatio),void 0!==t.lightMap&&(i.lightMap=n(t.lightMap)),void 0!==t.lightMapIntensity&&(i.lightMapIntensity=t.lightMapIntensity),void 0!==t.aoMap&&(i.aoMap=n(t.aoMap)),void 0!==t.aoMapIntensity&&(i.aoMapIntensity=t.aoMapIntensity),void 0!==t.gradientMap&&(i.gradientMap=n(t.gradientMap)),void 0!==t.clearcoatMap&&(i.clearcoatMap=n(t.clearcoatMap)),void 0!==t.clearcoatRoughnessMap&&(i.clearcoatRoughnessMap=n(t.clearcoatRoughnessMap)),void 0!==t.clearcoatNormalMap&&(i.clearcoatNormalMap=n(t.clearcoatNormalMap)),void 0!==t.clearcoatNormalScale&&(i.clearcoatNormalScale=(new vt).fromArray(t.clearcoatNormalScale)),void 0!==t.transmission&&(i.transmission=t.transmission),void 0!==t.transmissionMap&&(i.transmissionMap=n(t.transmissionMap)),i}setTextures(t){return this.textures=t,this}}class rc{static decodeText(t){if("undefined"!=typeof TextDecoder)return(new TextDecoder).decode(t);let e="";for(let n=0,i=t.length;nNumber.EPSILON){if(l<0&&(n=e[s],o=-o,a=e[r],l=-l),t.ya.y)continue;if(t.y===n.y){if(t.x===n.x)return!0}else{const e=l*(t.x-n.x)-o*(t.y-n.y);if(0===e)return!0;if(e<0)continue;i=!i}}else{if(t.y!==n.y)continue;if(a.x<=t.x&&t.x<=n.x||n.x<=t.x&&t.x<=a.x)return!0}}return i}const r=po.isClockWise,s=this.subPaths;if(0===s.length)return[];if(!0===e)return n(s);let a,o,l;const c=[];if(1===s.length)return o=s[0],l=new zl,l.curves=o.curves,c.push(l),c;let h=!r(s[0].getPoints());h=t?!h:h;const u=[],d=[];let p,m,f=[],g=0;d[g]=void 0,f[g]=[];for(let e=0,n=s.length;e1){let t=!1;const e=[];for(let t=0,e=d.length;t0&&(t||(f=u))}for(let t=0,e=d.length;t0){this.source.connect(this.filters[0]);for(let t=1,e=this.filters.length;t0){this.source.disconnect(this.filters[0]);for(let t=1,e=this.filters.length;t0&&this._mixBufferRegionAdditive(n,i,this._addIndex*e,1,e);for(let t=e,r=e+e;t!==r;++t)if(n[t]!==n[t+e]){a.setValue(n,i);break}}saveOriginalState(){const t=this.binding,e=this.buffer,n=this.valueSize,i=n*this._origIndex;t.getValue(e,i);for(let t=n,r=i;t!==r;++t)e[t]=e[i+t%n];this._setIdentity(),this.cumulativeWeight=0,this.cumulativeWeightAdditive=0}restoreOriginalState(){const t=3*this.valueSize;this.binding.setValue(this.buffer,t)}_setAdditiveIdentityNumeric(){const t=this._addIndex*this.valueSize,e=t+this.valueSize;for(let n=t;n=.5)for(let i=0;i!==r;++i)t[e+i]=t[n+i]}_slerp(t,e,n,i){At.slerpFlat(t,e,t,e,t,n,i)}_slerpAdditive(t,e,n,i,r){const s=this._workIndex*r;At.multiplyQuaternionsFlat(t,s,t,e,t,n),At.slerpFlat(t,e,t,e,t,s,i)}_lerp(t,e,n,i,r){const s=1-i;for(let a=0;a!==r;++a){const r=e+a;t[r]=t[r]*s+t[n+a]*i}}_lerpAdditive(t,e,n,i,r){for(let s=0;s!==r;++s){const r=e+s;t[r]=t[r]+t[n+s]*i}}}const Bc="\\[\\]\\.:\\/",zc=new RegExp("[\\[\\]\\.:\\/]","g"),Fc="[^\\[\\]\\.:\\/]",Oc="[^"+Bc.replace("\\.","")+"]",Hc=/((?:WC+[\/:])*)/.source.replace("WC",Fc),Gc=/(WCOD+)?/.source.replace("WCOD",Oc),Uc=/(?:\.(WC+)(?:\[(.+)\])?)?/.source.replace("WC",Fc),kc=/\.(WC+)(?:\[(.+)\])?/.source.replace("WC",Fc),Vc=new RegExp("^"+Hc+Gc+Uc+kc+"$"),Wc=["material","materials","bones"];class jc{constructor(t,e,n){this.path=e,this.parsedPath=n||jc.parseTrackName(e),this.node=jc.findNode(t,this.parsedPath.nodeName)||t,this.rootNode=t,this.getValue=this._getValue_unbound,this.setValue=this._setValue_unbound}static create(t,e,n){return t&&t.isAnimationObjectGroup?new jc.Composite(t,e,n):new jc(t,e,n)}static sanitizeNodeName(t){return t.replace(/\s/g,"_").replace(zc,"")}static parseTrackName(t){const e=Vc.exec(t);if(!e)throw new Error("PropertyBinding: Cannot parse trackName: "+t);const n={nodeName:e[2],objectName:e[3],objectIndex:e[4],propertyName:e[5],propertyIndex:e[6]},i=n.nodeName&&n.nodeName.lastIndexOf(".");if(void 0!==i&&-1!==i){const t=n.nodeName.substring(i+1);-1!==Wc.indexOf(t)&&(n.nodeName=n.nodeName.substring(0,i),n.objectName=t)}if(null===n.propertyName||0===n.propertyName.length)throw new Error("PropertyBinding: can not parse propertyName from trackName: "+t);return n}static findNode(t,e){if(!e||""===e||"."===e||-1===e||e===t.name||e===t.uuid)return t;if(t.skeleton){const n=t.skeleton.getBoneByName(e);if(void 0!==n)return n}if(t.children){const n=function(t){for(let i=0;i=r){const s=r++,c=t[s];e[c.uuid]=l,t[l]=c,e[o]=s,t[s]=a;for(let t=0,e=i;t!==e;++t){const e=n[t],i=e[s],r=e[l];e[l]=i,e[s]=r}}}this.nCachedObjects_=r}uncache(){const t=this._objects,e=this._indicesByUUID,n=this._bindings,i=n.length;let r=this.nCachedObjects_,s=t.length;for(let a=0,o=arguments.length;a!==o;++a){const o=arguments[a].uuid,l=e[o];if(void 0!==l)if(delete e[o],l0&&(e[a.uuid]=l),t[l]=a,t.pop();for(let t=0,e=i;t!==e;++t){const e=n[t];e[l]=e[r],e.pop()}}}this.nCachedObjects_=r}subscribe_(t,e){const n=this._bindingsIndicesByPath;let i=n[t];const r=this._bindings;if(void 0!==i)return r[i];const s=this._paths,a=this._parsedPaths,o=this._objects,l=o.length,c=this.nCachedObjects_,h=new Array(l);i=r.length,n[t]=i,s.push(t),a.push(e),r.push(h);for(let n=c,i=o.length;n!==i;++n){const i=o[n];h[n]=new jc(i,t,e)}return h}unsubscribe_(t){const e=this._bindingsIndicesByPath,n=e[t];if(void 0!==n){const i=this._paths,r=this._parsedPaths,s=this._bindings,a=s.length-1,o=s[a];e[t[a]]=n,s[n]=o,s.pop(),r[n]=r[a],r.pop(),i[n]=i[a],i.pop()}}}qc.prototype.isAnimationObjectGroup=!0;class Xc{constructor(t,e,n=null,i=e.blendMode){this._mixer=t,this._clip=e,this._localRoot=n,this.blendMode=i;const r=e.tracks,s=r.length,a=new Array(s),o={endingStart:k,endingEnd:k};for(let t=0;t!==s;++t){const e=r[t].createInterpolant(null);a[t]=e,e.settings=o}this._interpolantSettings=o,this._interpolants=a,this._propertyBindings=new Array(s),this._cacheIndex=null,this._byClipCacheIndex=null,this._timeScaleInterpolant=null,this._weightInterpolant=null,this.loop=2201,this._loopCount=-1,this._startTime=null,this.time=0,this.timeScale=1,this._effectiveTimeScale=1,this.weight=1,this._effectiveWeight=1,this.repetitions=1/0,this.paused=!1,this.enabled=!0,this.clampWhenFinished=!1,this.zeroSlopeAtStart=!0,this.zeroSlopeAtEnd=!0}play(){return this._mixer._activateAction(this),this}stop(){return this._mixer._deactivateAction(this),this.reset()}reset(){return this.paused=!1,this.enabled=!0,this.time=0,this._loopCount=-1,this._startTime=null,this.stopFading().stopWarping()}isRunning(){return this.enabled&&!this.paused&&0!==this.timeScale&&null===this._startTime&&this._mixer._isActiveAction(this)}isScheduled(){return this._mixer._isActiveAction(this)}startAt(t){return this._startTime=t,this}setLoop(t,e){return this.loop=t,this.repetitions=e,this}setEffectiveWeight(t){return this.weight=t,this._effectiveWeight=this.enabled?t:0,this.stopFading()}getEffectiveWeight(){return this._effectiveWeight}fadeIn(t){return this._scheduleFading(t,0,1)}fadeOut(t){return this._scheduleFading(t,1,0)}crossFadeFrom(t,e,n){if(t.fadeOut(e),this.fadeIn(e),n){const n=this._clip.duration,i=t._clip.duration,r=i/n,s=n/i;t.warp(1,r,e),this.warp(s,1,e)}return this}crossFadeTo(t,e,n){return t.crossFadeFrom(this,e,n)}stopFading(){const t=this._weightInterpolant;return null!==t&&(this._weightInterpolant=null,this._mixer._takeBackControlInterpolant(t)),this}setEffectiveTimeScale(t){return this.timeScale=t,this._effectiveTimeScale=this.paused?0:t,this.stopWarping()}getEffectiveTimeScale(){return this._effectiveTimeScale}setDuration(t){return this.timeScale=this._clip.duration/t,this.stopWarping()}syncWith(t){return this.time=t.time,this.timeScale=t.timeScale,this.stopWarping()}halt(t){return this.warp(this._effectiveTimeScale,0,t)}warp(t,e,n){const i=this._mixer,r=i.time,s=this.timeScale;let a=this._timeScaleInterpolant;null===a&&(a=i._lendControlInterpolant(),this._timeScaleInterpolant=a);const o=a.parameterPositions,l=a.sampleValues;return o[0]=r,o[1]=r+n,l[0]=t/s,l[1]=e/s,this}stopWarping(){const t=this._timeScaleInterpolant;return null!==t&&(this._timeScaleInterpolant=null,this._mixer._takeBackControlInterpolant(t)),this}getMixer(){return this._mixer}getClip(){return this._clip}getRoot(){return this._localRoot||this._mixer._root}_update(t,e,n,i){if(!this.enabled)return void this._updateWeight(t);const r=this._startTime;if(null!==r){const i=(t-r)*n;if(i<0||0===n)return;this._startTime=null,e=n*i}e*=this._updateTimeScale(t);const s=this._updateTime(e),a=this._updateWeight(t);if(a>0){const t=this._interpolants,e=this._propertyBindings;switch(this.blendMode){case q:for(let n=0,i=t.length;n!==i;++n)t[n].evaluate(s),e[n].accumulateAdditive(a);break;case j:default:for(let n=0,r=t.length;n!==r;++n)t[n].evaluate(s),e[n].accumulate(i,a)}}}_updateWeight(t){let e=0;if(this.enabled){e=this.weight;const n=this._weightInterpolant;if(null!==n){const i=n.evaluate(t)[0];e*=i,t>n.parameterPositions[1]&&(this.stopFading(),0===i&&(this.enabled=!1))}}return this._effectiveWeight=e,e}_updateTimeScale(t){let e=0;if(!this.paused){e=this.timeScale;const n=this._timeScaleInterpolant;if(null!==n){e*=n.evaluate(t)[0],t>n.parameterPositions[1]&&(this.stopWarping(),0===e?this.paused=!0:this.timeScale=e)}}return this._effectiveTimeScale=e,e}_updateTime(t){const e=this._clip.duration,n=this.loop;let i=this.time+t,r=this._loopCount;const s=2202===n;if(0===t)return-1===r?i:s&&1==(1&r)?e-i:i;if(2200===n){-1===r&&(this._loopCount=0,this._setEndings(!0,!0,!1));t:{if(i>=e)i=e;else{if(!(i<0)){this.time=i;break t}i=0}this.clampWhenFinished?this.paused=!0:this.enabled=!1,this.time=i,this._mixer.dispatchEvent({type:"finished",action:this,direction:t<0?-1:1})}}else{if(-1===r&&(t>=0?(r=0,this._setEndings(!0,0===this.repetitions,s)):this._setEndings(0===this.repetitions,!0,s)),i>=e||i<0){const n=Math.floor(i/e);i-=e*n,r+=Math.abs(n);const a=this.repetitions-r;if(a<=0)this.clampWhenFinished?this.paused=!0:this.enabled=!1,i=t>0?e:0,this.time=i,this._mixer.dispatchEvent({type:"finished",action:this,direction:t>0?1:-1});else{if(1===a){const e=t<0;this._setEndings(e,!e,s)}else this._setEndings(!1,!1,s);this._loopCount=r,this.time=i,this._mixer.dispatchEvent({type:"loop",action:this,loopDelta:n})}}else this.time=i;if(s&&1==(1&r))return e-i}return i}_setEndings(t,e,n){const i=this._interpolantSettings;n?(i.endingStart=V,i.endingEnd=V):(i.endingStart=t?this.zeroSlopeAtStart?V:k:W,i.endingEnd=e?this.zeroSlopeAtEnd?V:k:W)}_scheduleFading(t,e,n){const i=this._mixer,r=i.time;let s=this._weightInterpolant;null===s&&(s=i._lendControlInterpolant(),this._weightInterpolant=s);const a=s.parameterPositions,o=s.sampleValues;return a[0]=r,o[0]=e,a[1]=r+t,o[1]=n,this}}class Yc extends rt{constructor(t){super(),this._root=t,this._initMemoryManager(),this._accuIndex=0,this.time=0,this.timeScale=1}_bindAction(t,e){const n=t._localRoot||this._root,i=t._clip.tracks,r=i.length,s=t._propertyBindings,a=t._interpolants,o=n.uuid,l=this._bindingsByRootAndName;let c=l[o];void 0===c&&(c={},l[o]=c);for(let t=0;t!==r;++t){const r=i[t],l=r.name;let h=c[l];if(void 0!==h)s[t]=h;else{if(h=s[t],void 0!==h){null===h._cacheIndex&&(++h.referenceCount,this._addInactiveBinding(h,o,l));continue}const i=e&&e._propertyBindings[t].binding.parsedPath;h=new Nc(jc.create(n,l,i),r.ValueTypeName,r.getValueSize()),++h.referenceCount,this._addInactiveBinding(h,o,l),s[t]=h}a[t].resultBuffer=h.buffer}}_activateAction(t){if(!this._isActiveAction(t)){if(null===t._cacheIndex){const e=(t._localRoot||this._root).uuid,n=t._clip.uuid,i=this._actionsByClip[n];this._bindAction(t,i&&i.knownActions[0]),this._addInactiveAction(t,n,e)}const e=t._propertyBindings;for(let t=0,n=e.length;t!==n;++t){const n=e[t];0==n.useCount++&&(this._lendBinding(n),n.saveOriginalState())}this._lendAction(t)}}_deactivateAction(t){if(this._isActiveAction(t)){const e=t._propertyBindings;for(let t=0,n=e.length;t!==n;++t){const n=e[t];0==--n.useCount&&(n.restoreOriginalState(),this._takeBackBinding(n))}this._takeBackAction(t)}}_initMemoryManager(){this._actions=[],this._nActiveActions=0,this._actionsByClip={},this._bindings=[],this._nActiveBindings=0,this._bindingsByRootAndName={},this._controlInterpolants=[],this._nActiveControlInterpolants=0;const t=this;this.stats={actions:{get total(){return t._actions.length},get inUse(){return t._nActiveActions}},bindings:{get total(){return t._bindings.length},get inUse(){return t._nActiveBindings}},controlInterpolants:{get total(){return t._controlInterpolants.length},get inUse(){return t._nActiveControlInterpolants}}}}_isActiveAction(t){const e=t._cacheIndex;return null!==e&&e=0;--e)t[e].stop();return this}update(t){t*=this.timeScale;const e=this._actions,n=this._nActiveActions,i=this.time+=t,r=Math.sign(t),s=this._accuIndex^=1;for(let a=0;a!==n;++a){e[a]._update(i,t,r,s)}const a=this._bindings,o=this._nActiveBindings;for(let t=0;t!==o;++t)a[t].apply(s);return this}setTime(t){this.time=0;for(let t=0;tthis.max.x||t.ythis.max.y)}containsBox(t){return this.min.x<=t.min.x&&t.max.x<=this.max.x&&this.min.y<=t.min.y&&t.max.y<=this.max.y}getParameter(t,e){return void 0===e&&(console.warn("THREE.Box2: .getParameter() target is now required"),e=new vt),e.set((t.x-this.min.x)/(this.max.x-this.min.x),(t.y-this.min.y)/(this.max.y-this.min.y))}intersectsBox(t){return!(t.max.xthis.max.x||t.max.ythis.max.y)}clampPoint(t,e){return void 0===e&&(console.warn("THREE.Box2: .clampPoint() target is now required"),e=new vt),e.copy(t).clamp(this.min,this.max)}distanceToPoint(t){return th.copy(t).clamp(this.min,this.max).sub(t).length()}intersect(t){return this.min.max(t.min),this.max.min(t.max),this}union(t){return this.min.min(t.min),this.max.max(t.max),this}translate(t){return this.min.add(t),this.max.add(t),this}equals(t){return t.min.equals(this.min)&&t.max.equals(this.max)}}eh.prototype.isBox2=!0;const nh=new Lt,ih=new Lt;class rh{constructor(t=new Lt,e=new Lt){this.start=t,this.end=e}set(t,e){return this.start.copy(t),this.end.copy(e),this}copy(t){return this.start.copy(t.start),this.end.copy(t.end),this}getCenter(t){return void 0===t&&(console.warn("THREE.Line3: .getCenter() target is now required"),t=new Lt),t.addVectors(this.start,this.end).multiplyScalar(.5)}delta(t){return void 0===t&&(console.warn("THREE.Line3: .delta() target is now required"),t=new Lt),t.subVectors(this.end,this.start)}distanceSq(){return this.start.distanceToSquared(this.end)}distance(){return this.start.distanceTo(this.end)}at(t,e){return void 0===e&&(console.warn("THREE.Line3: .at() target is now required"),e=new Lt),this.delta(e).multiplyScalar(t).add(this.start)}closestPointToPointParameter(t,e){nh.subVectors(t,this.start),ih.subVectors(this.end,this.start);const n=ih.dot(ih);let i=ih.dot(nh)/n;return e&&(i=ht(i,0,1)),i}closestPointToPoint(t,e,n){const i=this.closestPointToPointParameter(t,e);return void 0===n&&(console.warn("THREE.Line3: .closestPointToPoint() target is now required"),n=new Lt),this.delta(n).multiplyScalar(i).add(this.start)}applyMatrix4(t){return this.start.applyMatrix4(t),this.end.applyMatrix4(t),this}equals(t){return t.start.equals(this.start)&&t.end.equals(this.end)}clone(){return(new this.constructor).copy(this)}}class sh extends Ce{constructor(t){super(),this.material=t,this.render=function(){},this.hasPositions=!1,this.hasNormals=!1,this.hasColors=!1,this.hasUvs=!1,this.positionArray=null,this.normalArray=null,this.colorArray=null,this.uvArray=null,this.count=0}}sh.prototype.isImmediateRenderObject=!0;const ah=new Lt;const oh=new Lt,lh=new se,ch=new se;class hh extends ya{constructor(t){const e=uh(t),n=new En,i=[],r=[],s=new tn(0,0,1),a=new tn(0,1,0);for(let t=0;t4?a=Ph[r-8+4-1]:0==r&&(a=0),n.push(a);const o=1/(s-1),l=-o/2,c=1+o/2,h=[l,l,c,l,c,c,l,l,c,c,l,c],u=6,d=6,p=3,m=2,f=1,g=new Float32Array(p*d*u),v=new Float32Array(m*d*u),y=new Float32Array(f*d*u);for(let t=0;t2?0:-1,i=[e,n,0,e+2/3,n,0,e+2/3,n+1,0,e,n,0,e+2/3,n+1,0,e,n+1,0];g.set(i,p*d*t),v.set(h,m*d*t);const r=[t,t,t,t,t,t];y.set(r,f*d*t)}const x=new En;x.setAttribute("position",new sn(g,p)),x.setAttribute("uv",new sn(v,m)),x.setAttribute("faceIndex",new sn(y,f)),t.push(x),i>4&&i--}return{_lodPlanes:t,_sizeLods:e,_sigmas:n}}function Zh(t){const e=new Tt(3*Ch,3*Ch,t);return e.texture.mapping=l,e.texture.name="PMREM.cubeUv",e.scissorTest=!0,e}function Jh(t,e,n,i,r){t.viewport.set(e,n,i,r),t.scissor.set(e,n,i,r)}function Qh(){const t=new vt(1,1);return new Io({name:"EquirectangularToCubeUV",uniforms:{envMap:{value:null},texelSize:{value:t},inputEncoding:{value:Nh[3e3]},outputEncoding:{value:Nh[3e3]}},vertexShader:$h(),fragmentShader:`\n\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump int;\n\n\t\t\tvarying vec3 vOutputDirection;\n\n\t\t\tuniform sampler2D envMap;\n\t\t\tuniform vec2 texelSize;\n\n\t\t\t${tu()}\n\n\t\t\t#include \n\n\t\t\tvoid main() {\n\n\t\t\t\tgl_FragColor = vec4( 0.0, 0.0, 0.0, 1.0 );\n\n\t\t\t\tvec3 outputDirection = normalize( vOutputDirection );\n\t\t\t\tvec2 uv = equirectUv( outputDirection );\n\n\t\t\t\tvec2 f = fract( uv / texelSize - 0.5 );\n\t\t\t\tuv -= f * texelSize;\n\t\t\t\tvec3 tl = envMapTexelToLinear( texture2D ( envMap, uv ) ).rgb;\n\t\t\t\tuv.x += texelSize.x;\n\t\t\t\tvec3 tr = envMapTexelToLinear( texture2D ( envMap, uv ) ).rgb;\n\t\t\t\tuv.y += texelSize.y;\n\t\t\t\tvec3 br = envMapTexelToLinear( texture2D ( envMap, uv ) ).rgb;\n\t\t\t\tuv.x -= texelSize.x;\n\t\t\t\tvec3 bl = envMapTexelToLinear( texture2D ( envMap, uv ) ).rgb;\n\n\t\t\t\tvec3 tm = mix( tl, tr, f.x );\n\t\t\t\tvec3 bm = mix( bl, br, f.x );\n\t\t\t\tgl_FragColor.rgb = mix( tm, bm, f.y );\n\n\t\t\t\tgl_FragColor = linearToOutputTexel( gl_FragColor );\n\n\t\t\t}\n\t\t`,blending:0,depthTest:!1,depthWrite:!1})}function Kh(){return new Io({name:"CubemapToCubeUV",uniforms:{envMap:{value:null},inputEncoding:{value:Nh[3e3]},outputEncoding:{value:Nh[3e3]}},vertexShader:$h(),fragmentShader:`\n\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump int;\n\n\t\t\tvarying vec3 vOutputDirection;\n\n\t\t\tuniform samplerCube envMap;\n\n\t\t\t${tu()}\n\n\t\t\tvoid main() {\n\n\t\t\t\tgl_FragColor = vec4( 0.0, 0.0, 0.0, 1.0 );\n\t\t\t\tgl_FragColor.rgb = envMapTexelToLinear( textureCube( envMap, vec3( - vOutputDirection.x, vOutputDirection.yz ) ) ).rgb;\n\t\t\t\tgl_FragColor = linearToOutputTexel( gl_FragColor );\n\n\t\t\t}\n\t\t`,blending:0,depthTest:!1,depthWrite:!1})}function $h(){return"\n\n\t\tprecision mediump float;\n\t\tprecision mediump int;\n\n\t\tattribute vec3 position;\n\t\tattribute vec2 uv;\n\t\tattribute float faceIndex;\n\n\t\tvarying vec3 vOutputDirection;\n\n\t\t// RH coordinate system; PMREM face-indexing convention\n\t\tvec3 getDirection( vec2 uv, float face ) {\n\n\t\t\tuv = 2.0 * uv - 1.0;\n\n\t\t\tvec3 direction = vec3( uv, 1.0 );\n\n\t\t\tif ( face == 0.0 ) {\n\n\t\t\t\tdirection = direction.zyx; // ( 1, v, u ) pos x\n\n\t\t\t} else if ( face == 1.0 ) {\n\n\t\t\t\tdirection = direction.xzy;\n\t\t\t\tdirection.xz *= -1.0; // ( -u, 1, -v ) pos y\n\n\t\t\t} else if ( face == 2.0 ) {\n\n\t\t\t\tdirection.x *= -1.0; // ( -u, v, 1 ) pos z\n\n\t\t\t} else if ( face == 3.0 ) {\n\n\t\t\t\tdirection = direction.zyx;\n\t\t\t\tdirection.xz *= -1.0; // ( -1, v, -u ) neg x\n\n\t\t\t} else if ( face == 4.0 ) {\n\n\t\t\t\tdirection = direction.xzy;\n\t\t\t\tdirection.xy *= -1.0; // ( -u, -1, v ) neg y\n\n\t\t\t} else if ( face == 5.0 ) {\n\n\t\t\t\tdirection.z *= -1.0; // ( u, v, -1 ) neg z\n\n\t\t\t}\n\n\t\t\treturn direction;\n\n\t\t}\n\n\t\tvoid main() {\n\n\t\t\tvOutputDirection = getDirection( uv, faceIndex );\n\t\t\tgl_Position = vec4( position, 1.0 );\n\n\t\t}\n\t"}function tu(){return"\n\n\t\tuniform int inputEncoding;\n\t\tuniform int outputEncoding;\n\n\t\t#include \n\n\t\tvec4 inputTexelToLinear( vec4 value ) {\n\n\t\t\tif ( inputEncoding == 0 ) {\n\n\t\t\t\treturn value;\n\n\t\t\t} else if ( inputEncoding == 1 ) {\n\n\t\t\t\treturn sRGBToLinear( value );\n\n\t\t\t} else if ( inputEncoding == 2 ) {\n\n\t\t\t\treturn RGBEToLinear( value );\n\n\t\t\t} else if ( inputEncoding == 3 ) {\n\n\t\t\t\treturn RGBMToLinear( value, 7.0 );\n\n\t\t\t} else if ( inputEncoding == 4 ) {\n\n\t\t\t\treturn RGBMToLinear( value, 16.0 );\n\n\t\t\t} else if ( inputEncoding == 5 ) {\n\n\t\t\t\treturn RGBDToLinear( value, 256.0 );\n\n\t\t\t} else {\n\n\t\t\t\treturn GammaToLinear( value, 2.2 );\n\n\t\t\t}\n\n\t\t}\n\n\t\tvec4 linearToOutputTexel( vec4 value ) {\n\n\t\t\tif ( outputEncoding == 0 ) {\n\n\t\t\t\treturn value;\n\n\t\t\t} else if ( outputEncoding == 1 ) {\n\n\t\t\t\treturn LinearTosRGB( value );\n\n\t\t\t} else if ( outputEncoding == 2 ) {\n\n\t\t\t\treturn LinearToRGBE( value );\n\n\t\t\t} else if ( outputEncoding == 3 ) {\n\n\t\t\t\treturn LinearToRGBM( value, 7.0 );\n\n\t\t\t} else if ( outputEncoding == 4 ) {\n\n\t\t\t\treturn LinearToRGBM( value, 16.0 );\n\n\t\t\t} else if ( outputEncoding == 5 ) {\n\n\t\t\t\treturn LinearToRGBD( value, 256.0 );\n\n\t\t\t} else {\n\n\t\t\t\treturn LinearToGamma( value, 2.2 );\n\n\t\t\t}\n\n\t\t}\n\n\t\tvec4 envMapTexelToLinear( vec4 color ) {\n\n\t\t\treturn inputTexelToLinear( color );\n\n\t\t}\n\t"}ml.create=function(t,e){return console.log("THREE.Curve.create() has been deprecated"),t.prototype=Object.create(ml.prototype),t.prototype.constructor=t,t.prototype.getPoint=e,t},Bl.prototype.fromPoints=function(t){return console.warn("THREE.Path: .fromPoints() has been renamed to .setFromPoints()."),this.setFromPoints(t)},fh.prototype.setColors=function(){console.error("THREE.GridHelper: setColors() has been deprecated, pass them in the constructor instead.")},hh.prototype.update=function(){console.error("THREE.SkeletonHelper: update() no longer needs to be called.")},ol.prototype.extractUrlBase=function(t){return console.warn("THREE.Loader: .extractUrlBase() has been deprecated. Use THREE.LoaderUtils.extractUrlBase() instead."),rc.extractUrlBase(t)},ol.Handlers={add:function(){console.error("THREE.Loader: Handlers.add() has been removed. Use LoadingManager.addHandler() instead.")},get:function(){console.error("THREE.Loader: Handlers.get() has been removed. Use LoadingManager.getHandler() instead.")}},eh.prototype.center=function(t){return console.warn("THREE.Box2: .center() has been renamed to .getCenter()."),this.getCenter(t)},eh.prototype.empty=function(){return console.warn("THREE.Box2: .empty() has been renamed to .isEmpty()."),this.isEmpty()},eh.prototype.isIntersectionBox=function(t){return console.warn("THREE.Box2: .isIntersectionBox() has been renamed to .intersectsBox()."),this.intersectsBox(t)},eh.prototype.size=function(t){return console.warn("THREE.Box2: .size() has been renamed to .getSize()."),this.getSize(t)},Pt.prototype.center=function(t){return console.warn("THREE.Box3: .center() has been renamed to .getCenter()."),this.getCenter(t)},Pt.prototype.empty=function(){return console.warn("THREE.Box3: .empty() has been renamed to .isEmpty()."),this.isEmpty()},Pt.prototype.isIntersectionBox=function(t){return console.warn("THREE.Box3: .isIntersectionBox() has been renamed to .intersectsBox()."),this.intersectsBox(t)},Pt.prototype.isIntersectionSphere=function(t){return console.warn("THREE.Box3: .isIntersectionSphere() has been renamed to .intersectsSphere()."),this.intersectsSphere(t)},Pt.prototype.size=function(t){return console.warn("THREE.Box3: .size() has been renamed to .getSize()."),this.getSize(t)},Jt.prototype.empty=function(){return console.warn("THREE.Sphere: .empty() has been renamed to .isEmpty()."),this.isEmpty()},ai.prototype.setFromMatrix=function(t){return console.warn("THREE.Frustum: .setFromMatrix() has been renamed to .setFromProjectionMatrix()."),this.setFromProjectionMatrix(t)},rh.prototype.center=function(t){return console.warn("THREE.Line3: .center() has been renamed to .getCenter()."),this.getCenter(t)},yt.prototype.flattenToArrayOffset=function(t,e){return console.warn("THREE.Matrix3: .flattenToArrayOffset() has been deprecated. Use .toArray() instead."),this.toArray(t,e)},yt.prototype.multiplyVector3=function(t){return console.warn("THREE.Matrix3: .multiplyVector3() has been removed. Use vector.applyMatrix3( matrix ) instead."),t.applyMatrix3(this)},yt.prototype.multiplyVector3Array=function(){console.error("THREE.Matrix3: .multiplyVector3Array() has been removed.")},yt.prototype.applyToBufferAttribute=function(t){return console.warn("THREE.Matrix3: .applyToBufferAttribute() has been removed. Use attribute.applyMatrix3( matrix ) instead."),t.applyMatrix3(this)},yt.prototype.applyToVector3Array=function(){console.error("THREE.Matrix3: .applyToVector3Array() has been removed.")},yt.prototype.getInverse=function(t){return console.warn("THREE.Matrix3: .getInverse() has been removed. Use matrixInv.copy( matrix ).invert(); instead."),this.copy(t).invert()},se.prototype.extractPosition=function(t){return console.warn("THREE.Matrix4: .extractPosition() has been renamed to .copyPosition()."),this.copyPosition(t)},se.prototype.flattenToArrayOffset=function(t,e){return console.warn("THREE.Matrix4: .flattenToArrayOffset() has been deprecated. Use .toArray() instead."),this.toArray(t,e)},se.prototype.getPosition=function(){return console.warn("THREE.Matrix4: .getPosition() has been removed. Use Vector3.setFromMatrixPosition( matrix ) instead."),(new Lt).setFromMatrixColumn(this,3)},se.prototype.setRotationFromQuaternion=function(t){return console.warn("THREE.Matrix4: .setRotationFromQuaternion() has been renamed to .makeRotationFromQuaternion()."),this.makeRotationFromQuaternion(t)},se.prototype.multiplyToArray=function(){console.warn("THREE.Matrix4: .multiplyToArray() has been removed.")},se.prototype.multiplyVector3=function(t){return console.warn("THREE.Matrix4: .multiplyVector3() has been removed. Use vector.applyMatrix4( matrix ) instead."),t.applyMatrix4(this)},se.prototype.multiplyVector4=function(t){return console.warn("THREE.Matrix4: .multiplyVector4() has been removed. Use vector.applyMatrix4( matrix ) instead."),t.applyMatrix4(this)},se.prototype.multiplyVector3Array=function(){console.error("THREE.Matrix4: .multiplyVector3Array() has been removed.")},se.prototype.rotateAxis=function(t){console.warn("THREE.Matrix4: .rotateAxis() has been removed. Use Vector3.transformDirection( matrix ) instead."),t.transformDirection(this)},se.prototype.crossVector=function(t){return console.warn("THREE.Matrix4: .crossVector() has been removed. Use vector.applyMatrix4( matrix ) instead."),t.applyMatrix4(this)},se.prototype.translate=function(){console.error("THREE.Matrix4: .translate() has been removed.")},se.prototype.rotateX=function(){console.error("THREE.Matrix4: .rotateX() has been removed.")},se.prototype.rotateY=function(){console.error("THREE.Matrix4: .rotateY() has been removed.")},se.prototype.rotateZ=function(){console.error("THREE.Matrix4: .rotateZ() has been removed.")},se.prototype.rotateByAxis=function(){console.error("THREE.Matrix4: .rotateByAxis() has been removed.")},se.prototype.applyToBufferAttribute=function(t){return console.warn("THREE.Matrix4: .applyToBufferAttribute() has been removed. Use attribute.applyMatrix4( matrix ) instead."),t.applyMatrix4(this)},se.prototype.applyToVector3Array=function(){console.error("THREE.Matrix4: .applyToVector3Array() has been removed.")},se.prototype.makeFrustum=function(t,e,n,i,r,s){return console.warn("THREE.Matrix4: .makeFrustum() has been removed. Use .makePerspective( left, right, top, bottom, near, far ) instead."),this.makePerspective(t,e,i,n,r,s)},se.prototype.getInverse=function(t){return console.warn("THREE.Matrix4: .getInverse() has been removed. Use matrixInv.copy( matrix ).invert(); instead."),this.copy(t).invert()},Ne.prototype.isIntersectionLine=function(t){return console.warn("THREE.Plane: .isIntersectionLine() has been renamed to .intersectsLine()."),this.intersectsLine(t)},At.prototype.multiplyVector3=function(t){return console.warn("THREE.Quaternion: .multiplyVector3() has been removed. Use is now vector.applyQuaternion( quaternion ) instead."),t.applyQuaternion(this)},At.prototype.inverse=function(){return console.warn("THREE.Quaternion: .inverse() has been renamed to invert()."),this.invert()},re.prototype.isIntersectionBox=function(t){return console.warn("THREE.Ray: .isIntersectionBox() has been renamed to .intersectsBox()."),this.intersectsBox(t)},re.prototype.isIntersectionPlane=function(t){return console.warn("THREE.Ray: .isIntersectionPlane() has been renamed to .intersectsPlane()."),this.intersectsPlane(t)},re.prototype.isIntersectionSphere=function(t){return console.warn("THREE.Ray: .isIntersectionSphere() has been renamed to .intersectsSphere()."),this.intersectsSphere(t)},je.prototype.area=function(){return console.warn("THREE.Triangle: .area() has been renamed to .getArea()."),this.getArea()},je.prototype.barycoordFromPoint=function(t,e){return console.warn("THREE.Triangle: .barycoordFromPoint() has been renamed to .getBarycoord()."),this.getBarycoord(t,e)},je.prototype.midpoint=function(t){return console.warn("THREE.Triangle: .midpoint() has been renamed to .getMidpoint()."),this.getMidpoint(t)},je.prototypenormal=function(t){return console.warn("THREE.Triangle: .normal() has been renamed to .getNormal()."),this.getNormal(t)},je.prototype.plane=function(t){return console.warn("THREE.Triangle: .plane() has been renamed to .getPlane()."),this.getPlane(t)},je.barycoordFromPoint=function(t,e,n,i,r){return console.warn("THREE.Triangle: .barycoordFromPoint() has been renamed to .getBarycoord()."),je.getBarycoord(t,e,n,i,r)},je.normal=function(t,e,n,i){return console.warn("THREE.Triangle: .normal() has been renamed to .getNormal()."),je.getNormal(t,e,n,i)},zl.prototype.extractAllPoints=function(t){return console.warn("THREE.Shape: .extractAllPoints() has been removed. Use .extractPoints() instead."),this.extractPoints(t)},zl.prototype.extrude=function(t){return console.warn("THREE.Shape: .extrude() has been removed. Use ExtrudeGeometry() instead."),new go(this,t)},zl.prototype.makeGeometry=function(t){return console.warn("THREE.Shape: .makeGeometry() has been removed. Use ShapeGeometry() instead."),new Mo(this,t)},vt.prototype.fromAttribute=function(t,e,n){return console.warn("THREE.Vector2: .fromAttribute() has been renamed to .fromBufferAttribute()."),this.fromBufferAttribute(t,e,n)},vt.prototype.distanceToManhattan=function(t){return console.warn("THREE.Vector2: .distanceToManhattan() has been renamed to .manhattanDistanceTo()."),this.manhattanDistanceTo(t)},vt.prototype.lengthManhattan=function(){return console.warn("THREE.Vector2: .lengthManhattan() has been renamed to .manhattanLength()."),this.manhattanLength()},Lt.prototype.setEulerFromRotationMatrix=function(){console.error("THREE.Vector3: .setEulerFromRotationMatrix() has been removed. Use Euler.setFromRotationMatrix() instead.")},Lt.prototype.setEulerFromQuaternion=function(){console.error("THREE.Vector3: .setEulerFromQuaternion() has been removed. Use Euler.setFromQuaternion() instead.")},Lt.prototype.getPositionFromMatrix=function(t){return console.warn("THREE.Vector3: .getPositionFromMatrix() has been renamed to .setFromMatrixPosition()."),this.setFromMatrixPosition(t)},Lt.prototype.getScaleFromMatrix=function(t){return console.warn("THREE.Vector3: .getScaleFromMatrix() has been renamed to .setFromMatrixScale()."),this.setFromMatrixScale(t)},Lt.prototype.getColumnFromMatrix=function(t,e){return console.warn("THREE.Vector3: .getColumnFromMatrix() has been renamed to .setFromMatrixColumn()."),this.setFromMatrixColumn(e,t)},Lt.prototype.applyProjection=function(t){return console.warn("THREE.Vector3: .applyProjection() has been removed. Use .applyMatrix4( m ) instead."),this.applyMatrix4(t)},Lt.prototype.fromAttribute=function(t,e,n){return console.warn("THREE.Vector3: .fromAttribute() has been renamed to .fromBufferAttribute()."),this.fromBufferAttribute(t,e,n)},Lt.prototype.distanceToManhattan=function(t){return console.warn("THREE.Vector3: .distanceToManhattan() has been renamed to .manhattanDistanceTo()."),this.manhattanDistanceTo(t)},Lt.prototype.lengthManhattan=function(){return console.warn("THREE.Vector3: .lengthManhattan() has been renamed to .manhattanLength()."),this.manhattanLength()},St.prototype.fromAttribute=function(t,e,n){return console.warn("THREE.Vector4: .fromAttribute() has been renamed to .fromBufferAttribute()."),this.fromBufferAttribute(t,e,n)},St.prototype.lengthManhattan=function(){return console.warn("THREE.Vector4: .lengthManhattan() has been renamed to .manhattanLength()."),this.manhattanLength()},Ce.prototype.getChildByName=function(t){return console.warn("THREE.Object3D: .getChildByName() has been renamed to .getObjectByName()."),this.getObjectByName(t)},Ce.prototype.renderDepth=function(){console.warn("THREE.Object3D: .renderDepth has been removed. Use .renderOrder, instead.")},Ce.prototype.translate=function(t,e){return console.warn("THREE.Object3D: .translate() has been removed. Use .translateOnAxis( axis, distance ) instead."),this.translateOnAxis(e,t)},Ce.prototype.getWorldRotation=function(){console.error("THREE.Object3D: .getWorldRotation() has been removed. Use THREE.Object3D.getWorldQuaternion( target ) instead.")},Ce.prototype.applyMatrix=function(t){return console.warn("THREE.Object3D: .applyMatrix() has been renamed to .applyMatrix4()."),this.applyMatrix4(t)},Object.defineProperties(Ce.prototype,{eulerOrder:{get:function(){return console.warn("THREE.Object3D: .eulerOrder is now .rotation.order."),this.rotation.order},set:function(t){console.warn("THREE.Object3D: .eulerOrder is now .rotation.order."),this.rotation.order=t}},useQuaternion:{get:function(){console.warn("THREE.Object3D: .useQuaternion has been removed. The library now uses quaternions by default.")},set:function(){console.warn("THREE.Object3D: .useQuaternion has been removed. The library now uses quaternions by default.")}}}),Wn.prototype.setDrawMode=function(){console.error("THREE.Mesh: .setDrawMode() has been removed. The renderer now always assumes THREE.TrianglesDrawMode. Transform your geometry via BufferGeometryUtils.toTrianglesDrawMode() if necessary.")},Object.defineProperties(Wn.prototype,{drawMode:{get:function(){return console.error("THREE.Mesh: .drawMode has been removed. The renderer now always assumes THREE.TrianglesDrawMode."),0},set:function(){console.error("THREE.Mesh: .drawMode has been removed. The renderer now always assumes THREE.TrianglesDrawMode. Transform your geometry via BufferGeometryUtils.toTrianglesDrawMode() if necessary.")}}}),$s.prototype.initBones=function(){console.error("THREE.SkinnedMesh: initBones() has been removed.")},Kn.prototype.setLens=function(t,e){console.warn("THREE.PerspectiveCamera.setLens is deprecated. Use .setFocalLength and .filmGauge for a photographic setup."),void 0!==e&&(this.filmGauge=e),this.setFocalLength(t)},Object.defineProperties(Fl.prototype,{onlyShadow:{set:function(){console.warn("THREE.Light: .onlyShadow has been removed.")}},shadowCameraFov:{set:function(t){console.warn("THREE.Light: .shadowCameraFov is now .shadow.camera.fov."),this.shadow.camera.fov=t}},shadowCameraLeft:{set:function(t){console.warn("THREE.Light: .shadowCameraLeft is now .shadow.camera.left."),this.shadow.camera.left=t}},shadowCameraRight:{set:function(t){console.warn("THREE.Light: .shadowCameraRight is now .shadow.camera.right."),this.shadow.camera.right=t}},shadowCameraTop:{set:function(t){console.warn("THREE.Light: .shadowCameraTop is now .shadow.camera.top."),this.shadow.camera.top=t}},shadowCameraBottom:{set:function(t){console.warn("THREE.Light: .shadowCameraBottom is now .shadow.camera.bottom."),this.shadow.camera.bottom=t}},shadowCameraNear:{set:function(t){console.warn("THREE.Light: .shadowCameraNear is now .shadow.camera.near."),this.shadow.camera.near=t}},shadowCameraFar:{set:function(t){console.warn("THREE.Light: .shadowCameraFar is now .shadow.camera.far."),this.shadow.camera.far=t}},shadowCameraVisible:{set:function(){console.warn("THREE.Light: .shadowCameraVisible has been removed. Use new THREE.CameraHelper( light.shadow.camera ) instead.")}},shadowBias:{set:function(t){console.warn("THREE.Light: .shadowBias is now .shadow.bias."),this.shadow.bias=t}},shadowDarkness:{set:function(){console.warn("THREE.Light: .shadowDarkness has been removed.")}},shadowMapWidth:{set:function(t){console.warn("THREE.Light: .shadowMapWidth is now .shadow.mapSize.width."),this.shadow.mapSize.width=t}},shadowMapHeight:{set:function(t){console.warn("THREE.Light: .shadowMapHeight is now .shadow.mapSize.height."),this.shadow.mapSize.height=t}}}),Object.defineProperties(sn.prototype,{length:{get:function(){return console.warn("THREE.BufferAttribute: .length has been deprecated. Use .count instead."),this.array.length}},dynamic:{get:function(){return console.warn("THREE.BufferAttribute: .dynamic has been deprecated. Use .usage instead."),this.usage===nt},set:function(){console.warn("THREE.BufferAttribute: .dynamic has been deprecated. Use .usage instead."),this.setUsage(nt)}}}),sn.prototype.setDynamic=function(t){return console.warn("THREE.BufferAttribute: .setDynamic() has been deprecated. Use .setUsage() instead."),this.setUsage(!0===t?nt:et),this},sn.prototype.copyIndicesArray=function(){console.error("THREE.BufferAttribute: .copyIndicesArray() has been removed.")},sn.prototype.setArray=function(){console.error("THREE.BufferAttribute: .setArray has been removed. Use BufferGeometry .setAttribute to replace/resize attribute buffers")},En.prototype.addIndex=function(t){console.warn("THREE.BufferGeometry: .addIndex() has been renamed to .setIndex()."),this.setIndex(t)},En.prototype.addAttribute=function(t,e){return console.warn("THREE.BufferGeometry: .addAttribute() has been renamed to .setAttribute()."),e&&e.isBufferAttribute||e&&e.isInterleavedBufferAttribute?"index"===t?(console.warn("THREE.BufferGeometry.addAttribute: Use .setIndex() for index attribute."),this.setIndex(e),this):this.setAttribute(t,e):(console.warn("THREE.BufferGeometry: .addAttribute() now expects ( name, attribute )."),this.setAttribute(t,new sn(arguments[1],arguments[2])))},En.prototype.addDrawCall=function(t,e,n){void 0!==n&&console.warn("THREE.BufferGeometry: .addDrawCall() no longer supports indexOffset."),console.warn("THREE.BufferGeometry: .addDrawCall() is now .addGroup()."),this.addGroup(t,e)},En.prototype.clearDrawCalls=function(){console.warn("THREE.BufferGeometry: .clearDrawCalls() is now .clearGroups()."),this.clearGroups()},En.prototype.computeOffsets=function(){console.warn("THREE.BufferGeometry: .computeOffsets() has been removed.")},En.prototype.removeAttribute=function(t){return console.warn("THREE.BufferGeometry: .removeAttribute() has been renamed to .deleteAttribute()."),this.deleteAttribute(t)},En.prototype.applyMatrix=function(t){return console.warn("THREE.BufferGeometry: .applyMatrix() has been renamed to .applyMatrix4()."),this.applyMatrix4(t)},Object.defineProperties(En.prototype,{drawcalls:{get:function(){return console.error("THREE.BufferGeometry: .drawcalls has been renamed to .groups."),this.groups}},offsets:{get:function(){return console.warn("THREE.BufferGeometry: .offsets has been renamed to .groups."),this.groups}}}),Es.prototype.setDynamic=function(t){return console.warn("THREE.InterleavedBuffer: .setDynamic() has been deprecated. Use .setUsage() instead."),this.setUsage(!0===t?nt:et),this},Es.prototype.setArray=function(){console.error("THREE.InterleavedBuffer: .setArray has been removed. Use BufferGeometry .setAttribute to replace/resize attribute buffers")},go.prototype.getArrays=function(){console.error("THREE.ExtrudeGeometry: .getArrays() has been removed.")},go.prototype.addShapeList=function(){console.error("THREE.ExtrudeGeometry: .addShapeList() has been removed.")},go.prototype.addShape=function(){console.error("THREE.ExtrudeGeometry: .addShape() has been removed.")},Ts.prototype.dispose=function(){console.error("THREE.Scene: .dispose() has been removed.")},Zc.prototype.onUpdate=function(){return console.warn("THREE.Uniform: .onUpdate() has been removed. Use object.onBeforeRender() instead."),this},Object.defineProperties(Xe.prototype,{wrapAround:{get:function(){console.warn("THREE.Material: .wrapAround has been removed.")},set:function(){console.warn("THREE.Material: .wrapAround has been removed.")}},overdraw:{get:function(){console.warn("THREE.Material: .overdraw has been removed.")},set:function(){console.warn("THREE.Material: .overdraw has been removed.")}},wrapRGB:{get:function(){return console.warn("THREE.Material: .wrapRGB has been removed."),new tn}},shading:{get:function(){console.error("THREE."+this.type+": .shading has been removed. Use the boolean .flatShading instead.")},set:function(t){console.warn("THREE."+this.type+": .shading has been removed. Use the boolean .flatShading instead."),this.flatShading=1===t}},stencilMask:{get:function(){return console.warn("THREE."+this.type+": .stencilMask has been removed. Use .stencilFuncMask instead."),this.stencilFuncMask},set:function(t){console.warn("THREE."+this.type+": .stencilMask has been removed. Use .stencilFuncMask instead."),this.stencilFuncMask=t}}}),Object.defineProperties(Jn.prototype,{derivatives:{get:function(){return console.warn("THREE.ShaderMaterial: .derivatives has been moved to .extensions.derivatives."),this.extensions.derivatives},set:function(t){console.warn("THREE. ShaderMaterial: .derivatives has been moved to .extensions.derivatives."),this.extensions.derivatives=t}}}),ws.prototype.clearTarget=function(t,e,n,i){console.warn("THREE.WebGLRenderer: .clearTarget() has been deprecated. Use .setRenderTarget() and .clear() instead."),this.setRenderTarget(t),this.clear(e,n,i)},ws.prototype.animate=function(t){console.warn("THREE.WebGLRenderer: .animate() is now .setAnimationLoop()."),this.setAnimationLoop(t)},ws.prototype.getCurrentRenderTarget=function(){return console.warn("THREE.WebGLRenderer: .getCurrentRenderTarget() is now .getRenderTarget()."),this.getRenderTarget()},ws.prototype.getMaxAnisotropy=function(){return console.warn("THREE.WebGLRenderer: .getMaxAnisotropy() is now .capabilities.getMaxAnisotropy()."),this.capabilities.getMaxAnisotropy()},ws.prototype.getPrecision=function(){return console.warn("THREE.WebGLRenderer: .getPrecision() is now .capabilities.precision."),this.capabilities.precision},ws.prototype.resetGLState=function(){return console.warn("THREE.WebGLRenderer: .resetGLState() is now .state.reset()."),this.state.reset()},ws.prototype.supportsFloatTextures=function(){return console.warn("THREE.WebGLRenderer: .supportsFloatTextures() is now .extensions.get( 'OES_texture_float' )."),this.extensions.get("OES_texture_float")},ws.prototype.supportsHalfFloatTextures=function(){return console.warn("THREE.WebGLRenderer: .supportsHalfFloatTextures() is now .extensions.get( 'OES_texture_half_float' )."),this.extensions.get("OES_texture_half_float")},ws.prototype.supportsStandardDerivatives=function(){return console.warn("THREE.WebGLRenderer: .supportsStandardDerivatives() is now .extensions.get( 'OES_standard_derivatives' )."),this.extensions.get("OES_standard_derivatives")},ws.prototype.supportsCompressedTextureS3TC=function(){return console.warn("THREE.WebGLRenderer: .supportsCompressedTextureS3TC() is now .extensions.get( 'WEBGL_compressed_texture_s3tc' )."),this.extensions.get("WEBGL_compressed_texture_s3tc")},ws.prototype.supportsCompressedTexturePVRTC=function(){return console.warn("THREE.WebGLRenderer: .supportsCompressedTexturePVRTC() is now .extensions.get( 'WEBGL_compressed_texture_pvrtc' )."),this.extensions.get("WEBGL_compressed_texture_pvrtc")},ws.prototype.supportsBlendMinMax=function(){return console.warn("THREE.WebGLRenderer: .supportsBlendMinMax() is now .extensions.get( 'EXT_blend_minmax' )."),this.extensions.get("EXT_blend_minmax")},ws.prototype.supportsVertexTextures=function(){return console.warn("THREE.WebGLRenderer: .supportsVertexTextures() is now .capabilities.vertexTextures."),this.capabilities.vertexTextures},ws.prototype.supportsInstancedArrays=function(){return console.warn("THREE.WebGLRenderer: .supportsInstancedArrays() is now .extensions.get( 'ANGLE_instanced_arrays' )."),this.extensions.get("ANGLE_instanced_arrays")},ws.prototype.enableScissorTest=function(t){console.warn("THREE.WebGLRenderer: .enableScissorTest() is now .setScissorTest()."),this.setScissorTest(t)},ws.prototype.initMaterial=function(){console.warn("THREE.WebGLRenderer: .initMaterial() has been removed.")},ws.prototype.addPrePlugin=function(){console.warn("THREE.WebGLRenderer: .addPrePlugin() has been removed.")},ws.prototype.addPostPlugin=function(){console.warn("THREE.WebGLRenderer: .addPostPlugin() has been removed.")},ws.prototype.updateShadowMap=function(){console.warn("THREE.WebGLRenderer: .updateShadowMap() has been removed.")},ws.prototype.setFaceCulling=function(){console.warn("THREE.WebGLRenderer: .setFaceCulling() has been removed.")},ws.prototype.allocTextureUnit=function(){console.warn("THREE.WebGLRenderer: .allocTextureUnit() has been removed.")},ws.prototype.setTexture=function(){console.warn("THREE.WebGLRenderer: .setTexture() has been removed.")},ws.prototype.setTexture2D=function(){console.warn("THREE.WebGLRenderer: .setTexture2D() has been removed.")},ws.prototype.setTextureCube=function(){console.warn("THREE.WebGLRenderer: .setTextureCube() has been removed.")},ws.prototype.getActiveMipMapLevel=function(){return console.warn("THREE.WebGLRenderer: .getActiveMipMapLevel() is now .getActiveMipmapLevel()."),this.getActiveMipmapLevel()},Object.defineProperties(ws.prototype,{shadowMapEnabled:{get:function(){return this.shadowMap.enabled},set:function(t){console.warn("THREE.WebGLRenderer: .shadowMapEnabled is now .shadowMap.enabled."),this.shadowMap.enabled=t}},shadowMapType:{get:function(){return this.shadowMap.type},set:function(t){console.warn("THREE.WebGLRenderer: .shadowMapType is now .shadowMap.type."),this.shadowMap.type=t}},shadowMapCullFace:{get:function(){console.warn("THREE.WebGLRenderer: .shadowMapCullFace has been removed. Set Material.shadowSide instead.")},set:function(){console.warn("THREE.WebGLRenderer: .shadowMapCullFace has been removed. Set Material.shadowSide instead.")}},context:{get:function(){return console.warn("THREE.WebGLRenderer: .context has been removed. Use .getContext() instead."),this.getContext()}},vr:{get:function(){return console.warn("THREE.WebGLRenderer: .vr has been renamed to .xr"),this.xr}},gammaInput:{get:function(){return console.warn("THREE.WebGLRenderer: .gammaInput has been removed. Set the encoding for textures via Texture.encoding instead."),!1},set:function(){console.warn("THREE.WebGLRenderer: .gammaInput has been removed. Set the encoding for textures via Texture.encoding instead.")}},gammaOutput:{get:function(){return console.warn("THREE.WebGLRenderer: .gammaOutput has been removed. Set WebGLRenderer.outputEncoding instead."),!1},set:function(t){console.warn("THREE.WebGLRenderer: .gammaOutput has been removed. Set WebGLRenderer.outputEncoding instead."),this.outputEncoding=!0===t?Y:X}},toneMappingWhitePoint:{get:function(){return console.warn("THREE.WebGLRenderer: .toneMappingWhitePoint has been removed."),1},set:function(){console.warn("THREE.WebGLRenderer: .toneMappingWhitePoint has been removed.")}}}),Object.defineProperties(us.prototype,{cullFace:{get:function(){console.warn("THREE.WebGLRenderer: .shadowMap.cullFace has been removed. Set Material.shadowSide instead.")},set:function(){console.warn("THREE.WebGLRenderer: .shadowMap.cullFace has been removed. Set Material.shadowSide instead.")}},renderReverseSided:{get:function(){console.warn("THREE.WebGLRenderer: .shadowMap.renderReverseSided has been removed. Set Material.shadowSide instead.")},set:function(){console.warn("THREE.WebGLRenderer: .shadowMap.renderReverseSided has been removed. Set Material.shadowSide instead.")}},renderSingleSided:{get:function(){console.warn("THREE.WebGLRenderer: .shadowMap.renderSingleSided has been removed. Set Material.shadowSide instead.")},set:function(){console.warn("THREE.WebGLRenderer: .shadowMap.renderSingleSided has been removed. Set Material.shadowSide instead.")}}}),Object.defineProperties(Tt.prototype,{wrapS:{get:function(){return console.warn("THREE.WebGLRenderTarget: .wrapS is now .texture.wrapS."),this.texture.wrapS},set:function(t){console.warn("THREE.WebGLRenderTarget: .wrapS is now .texture.wrapS."),this.texture.wrapS=t}},wrapT:{get:function(){return console.warn("THREE.WebGLRenderTarget: .wrapT is now .texture.wrapT."),this.texture.wrapT},set:function(t){console.warn("THREE.WebGLRenderTarget: .wrapT is now .texture.wrapT."),this.texture.wrapT=t}},magFilter:{get:function(){return console.warn("THREE.WebGLRenderTarget: .magFilter is now .texture.magFilter."),this.texture.magFilter},set:function(t){console.warn("THREE.WebGLRenderTarget: .magFilter is now .texture.magFilter."),this.texture.magFilter=t}},minFilter:{get:function(){return console.warn("THREE.WebGLRenderTarget: .minFilter is now .texture.minFilter."),this.texture.minFilter},set:function(t){console.warn("THREE.WebGLRenderTarget: .minFilter is now .texture.minFilter."),this.texture.minFilter=t}},anisotropy:{get:function(){return console.warn("THREE.WebGLRenderTarget: .anisotropy is now .texture.anisotropy."),this.texture.anisotropy},set:function(t){console.warn("THREE.WebGLRenderTarget: .anisotropy is now .texture.anisotropy."),this.texture.anisotropy=t}},offset:{get:function(){return console.warn("THREE.WebGLRenderTarget: .offset is now .texture.offset."),this.texture.offset},set:function(t){console.warn("THREE.WebGLRenderTarget: .offset is now .texture.offset."),this.texture.offset=t}},repeat:{get:function(){return console.warn("THREE.WebGLRenderTarget: .repeat is now .texture.repeat."),this.texture.repeat},set:function(t){console.warn("THREE.WebGLRenderTarget: .repeat is now .texture.repeat."),this.texture.repeat=t}},format:{get:function(){return console.warn("THREE.WebGLRenderTarget: .format is now .texture.format."),this.texture.format},set:function(t){console.warn("THREE.WebGLRenderTarget: .format is now .texture.format."),this.texture.format=t}},type:{get:function(){return console.warn("THREE.WebGLRenderTarget: .type is now .texture.type."),this.texture.type},set:function(t){console.warn("THREE.WebGLRenderTarget: .type is now .texture.type."),this.texture.type=t}},generateMipmaps:{get:function(){return console.warn("THREE.WebGLRenderTarget: .generateMipmaps is now .texture.generateMipmaps."),this.texture.generateMipmaps},set:function(t){console.warn("THREE.WebGLRenderTarget: .generateMipmaps is now .texture.generateMipmaps."),this.texture.generateMipmaps=t}}}),Lc.prototype.load=function(t){console.warn("THREE.Audio: .load has been deprecated. Use THREE.AudioLoader instead.");const e=this;return(new vc).load(t,(function(t){e.setBuffer(t)})),this},Ic.prototype.getData=function(){return console.warn("THREE.AudioAnalyser: .getData() is now .getFrequencyData()."),this.getFrequencyData()},ti.prototype.updateCubeMap=function(t,e){return console.warn("THREE.CubeCamera: .updateCubeMap() is now .update()."),this.update(t,e)},ti.prototype.clear=function(t,e,n,i){return console.warn("THREE.CubeCamera: .clear() is now .renderTarget.clear()."),this.renderTarget.clear(t,e,n,i)},_t.crossOrigin=void 0,_t.loadTexture=function(t,e,n,i){console.warn("THREE.ImageUtils.loadTexture has been deprecated. Use THREE.TextureLoader() instead.");const r=new pl;r.setCrossOrigin(this.crossOrigin);const s=r.load(t,n,void 0,i);return e&&(s.mapping=e),s},_t.loadTextureCube=function(t,e,n,i){console.warn("THREE.ImageUtils.loadTextureCube has been deprecated. Use THREE.CubeTextureLoader() instead.");const r=new ul;r.setCrossOrigin(this.crossOrigin);const s=r.load(t,n,void 0,i);return e&&(s.mapping=e),s},_t.loadCompressedTexture=function(){console.error("THREE.ImageUtils.loadCompressedTexture has been removed. Use THREE.DDSLoader instead.")},_t.loadCompressedTextureCube=function(){console.error("THREE.ImageUtils.loadCompressedTextureCube has been removed. Use THREE.DDSLoader instead.")};const eu={createMultiMaterialObject:function(){console.error("THREE.SceneUtils has been moved to /examples/jsm/utils/SceneUtils.js")},detach:function(){console.error("THREE.SceneUtils has been moved to /examples/jsm/utils/SceneUtils.js")},attach:function(){console.error("THREE.SceneUtils has been moved to /examples/jsm/utils/SceneUtils.js")}};"undefined"!=typeof __THREE_DEVTOOLS__&&__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent("register",{detail:{revision:e}})),"undefined"!=typeof window&&(window.__THREE__?console.warn("WARNING: Multiple instances of Three.js being imported."):window.__THREE__=e),t.ACESFilmicToneMapping=4,t.AddEquation=n,t.AddOperation=2,t.AdditiveAnimationBlendMode=q,t.AdditiveBlending=2,t.AlphaFormat=1021,t.AlwaysDepth=1,t.AlwaysStencilFunc=519,t.AmbientLight=$l,t.AmbientLightProbe=xc,t.AnimationClip=nl,t.AnimationLoader=class extends ol{constructor(t){super(t)}load(t,e,n,i){const r=this,s=new cl(this.manager);s.setPath(this.path),s.setRequestHeader(this.requestHeader),s.setWithCredentials(this.withCredentials),s.load(t,(function(n){try{e(r.parse(JSON.parse(n)))}catch(e){i?i(e):console.error(e),r.manager.itemError(t)}}),n,i)}parse(t){const e=[];for(let n=0;n.99999)this.quaternion.set(0,0,0,1);else if(t.y<-.99999)this.quaternion.set(1,0,0,0);else{Sh.set(t.z,0,-t.x).normalize();const e=Math.acos(t.y);this.quaternion.setFromAxisAngle(Sh,e)}}setLength(t,e=.2*t,n=.2*e){this.line.scale.set(1,Math.max(1e-4,t-e),1),this.line.updateMatrix(),this.cone.scale.set(n,e,n),this.cone.position.y=t,this.cone.updateMatrix()}setColor(t){this.line.material.color.set(t),this.cone.material.color.set(t)}copy(t){return super.copy(t,!1),this.line.copy(t.line),this.cone.copy(t.cone),this}},t.Audio=Lc,t.AudioAnalyser=Ic,t.AudioContext=gc,t.AudioListener=class extends Ce{constructor(){super(),this.type="AudioListener",this.context=gc.getContext(),this.gain=this.context.createGain(),this.gain.connect(this.context.destination),this.filter=null,this.timeDelta=0,this._clock=new bc}getInput(){return this.gain}removeFilter(){return null!==this.filter&&(this.gain.disconnect(this.filter),this.filter.disconnect(this.context.destination),this.gain.connect(this.context.destination),this.filter=null),this}getFilter(){return this.filter}setFilter(t){return null!==this.filter?(this.gain.disconnect(this.filter),this.filter.disconnect(this.context.destination)):this.gain.disconnect(this.context.destination),this.filter=t,this.gain.connect(this.filter),this.filter.connect(this.context.destination),this}getMasterVolume(){return this.gain.gain.value}setMasterVolume(t){return this.gain.gain.setTargetAtTime(t,this.context.currentTime,.01),this}updateMatrixWorld(t){super.updateMatrixWorld(t);const e=this.context.listener,n=this.up;if(this.timeDelta=this._clock.getDelta(),this.matrixWorld.decompose(Sc,Tc,Ec),Ac.set(0,0,-1).applyQuaternion(Tc),e.positionX){const t=this.context.currentTime+this.timeDelta;e.positionX.linearRampToValueAtTime(Sc.x,t),e.positionY.linearRampToValueAtTime(Sc.y,t),e.positionZ.linearRampToValueAtTime(Sc.z,t),e.forwardX.linearRampToValueAtTime(Ac.x,t),e.forwardY.linearRampToValueAtTime(Ac.y,t),e.forwardZ.linearRampToValueAtTime(Ac.z,t),e.upX.linearRampToValueAtTime(n.x,t),e.upY.linearRampToValueAtTime(n.y,t),e.upZ.linearRampToValueAtTime(n.z,t)}else e.setPosition(Sc.x,Sc.y,Sc.z),e.setOrientation(Ac.x,Ac.y,Ac.z,n.x,n.y,n.z)}},t.AudioLoader=vc,t.AxesHelper=Ah,t.AxisHelper=function(t){return console.warn("THREE.AxisHelper has been renamed to THREE.AxesHelper."),new Ah(t)},t.BackSide=1,t.BasicDepthPacking=3200,t.BasicShadowMap=0,t.BinaryTextureLoader=function(t){return console.warn("THREE.BinaryTextureLoader has been renamed to THREE.DataTextureLoader."),new dl(t)},t.Bone=ta,t.BooleanKeyframeTrack=Zo,t.BoundingBoxHelper=function(t,e){return console.warn("THREE.BoundingBoxHelper has been deprecated. Creating a THREE.BoxHelper instead."),new Mh(t,e)},t.Box2=eh,t.Box3=Pt,t.Box3Helper=class extends ya{constructor(t,e=16776960){const n=new Uint16Array([0,1,1,2,2,3,3,0,4,5,5,6,6,7,7,4,0,4,1,5,2,6,3,7]),i=new En;i.setIndex(new sn(n,1)),i.setAttribute("position",new mn([1,1,1,-1,1,1,-1,-1,1,1,-1,1,1,1,-1,-1,1,-1,-1,-1,-1,1,-1,-1],3)),super(i,new ca({color:e,toneMapped:!1})),this.box=t,this.type="Box3Helper",this.geometry.computeBoundingSphere()}updateMatrixWorld(t){const e=this.box;e.isEmpty()||(e.getCenter(this.position),e.getSize(this.scale),this.scale.multiplyScalar(.5),super.updateMatrixWorld(t))}},t.BoxBufferGeometry=qn,t.BoxGeometry=qn,t.BoxHelper=Mh,t.BufferAttribute=sn,t.BufferGeometry=En,t.BufferGeometryLoader=oc,t.ByteType=1010,t.Cache=rl,t.Camera=Qn,t.CameraHelper=class extends ya{constructor(t){const e=new En,n=new ca({color:16777215,vertexColors:!0,toneMapped:!1}),i=[],r=[],s={},a=new tn(16755200),o=new tn(16711680),l=new tn(43775),c=new tn(16777215),h=new tn(3355443);function u(t,e,n){d(t,n),d(e,n)}function d(t,e){i.push(0,0,0),r.push(e.r,e.g,e.b),void 0===s[t]&&(s[t]=[]),s[t].push(i.length/3-1)}u("n1","n2",a),u("n2","n4",a),u("n4","n3",a),u("n3","n1",a),u("f1","f2",a),u("f2","f4",a),u("f4","f3",a),u("f3","f1",a),u("n1","f1",a),u("n2","f2",a),u("n3","f3",a),u("n4","f4",a),u("p","n1",o),u("p","n2",o),u("p","n3",o),u("p","n4",o),u("u1","u2",l),u("u2","u3",l),u("u3","u1",l),u("c","t",c),u("p","c",h),u("cn1","cn2",h),u("cn3","cn4",h),u("cf1","cf2",h),u("cf3","cf4",h),e.setAttribute("position",new mn(i,3)),e.setAttribute("color",new mn(r,3)),super(e,n),this.type="CameraHelper",this.camera=t,this.camera.updateProjectionMatrix&&this.camera.updateProjectionMatrix(),this.matrix=t.matrixWorld,this.matrixAutoUpdate=!1,this.pointMap=s,this.update()}update(){const t=this.geometry,e=this.pointMap;_h.projectionMatrixInverse.copy(this.camera.projectionMatrixInverse),wh("c",e,t,_h,0,0,-1),wh("t",e,t,_h,0,0,1),wh("n1",e,t,_h,-1,-1,-1),wh("n2",e,t,_h,1,-1,-1),wh("n3",e,t,_h,-1,1,-1),wh("n4",e,t,_h,1,1,-1),wh("f1",e,t,_h,-1,-1,1),wh("f2",e,t,_h,1,-1,1),wh("f3",e,t,_h,-1,1,1),wh("f4",e,t,_h,1,1,1),wh("u1",e,t,_h,.7,1.1,-1),wh("u2",e,t,_h,-.7,1.1,-1),wh("u3",e,t,_h,0,2,-1),wh("cf1",e,t,_h,-1,0,1),wh("cf2",e,t,_h,1,0,1),wh("cf3",e,t,_h,0,-1,1),wh("cf4",e,t,_h,0,1,1),wh("cn1",e,t,_h,-1,0,-1),wh("cn2",e,t,_h,1,0,-1),wh("cn3",e,t,_h,0,-1,-1),wh("cn4",e,t,_h,0,1,-1),t.getAttribute("position").needsUpdate=!0}dispose(){this.geometry.dispose(),this.material.dispose()}},t.CanvasRenderer=function(){console.error("THREE.CanvasRenderer has been removed")},t.CanvasTexture=Ra,t.CatmullRomCurve3=bl,t.CineonToneMapping=3,t.CircleBufferGeometry=Pa,t.CircleGeometry=Pa,t.ClampToEdgeWrapping=u,t.Clock=bc,t.Color=tn,t.ColorKeyframeTrack=Jo,t.CompressedTexture=La,t.CompressedTextureLoader=class extends ol{constructor(t){super(t)}load(t,e,n,i){const r=this,s=[],a=new La,o=new cl(this.manager);o.setPath(this.path),o.setResponseType("arraybuffer"),o.setRequestHeader(this.requestHeader),o.setWithCredentials(r.withCredentials);let l=0;function c(c){o.load(t[c],(function(t){const n=r.parse(t,!0);s[c]={width:n.width,height:n.height,format:n.format,mipmaps:n.mipmaps},l+=1,6===l&&(1===n.mipmapCount&&(a.minFilter=g),a.image=s,a.format=n.format,a.needsUpdate=!0,e&&e(a))}),n,i)}if(Array.isArray(t))for(let e=0,n=t.length;e>16&32768,i=e>>12&2047;const r=e>>23&255;return r<103?n:r>142?(n|=31744,n|=(255==r?0:1)&&8388607&e,n):r<113?(i|=2048,n|=(i>>114-r)+(i>>113-r&1),n):(n|=r-112<<10|i>>1,n+=1&i,n)}},t.DecrementStencilOp=7683,t.DecrementWrapStencilOp=34056,t.DefaultLoadingManager=al,t.DepthFormat=A,t.DepthStencilFormat=L,t.DepthTexture=Ca,t.DirectionalLight=Kl,t.DirectionalLightHelper=class extends Ce{constructor(t,e,n){super(),this.light=t,this.light.updateMatrixWorld(),this.matrix=t.matrixWorld,this.matrixAutoUpdate=!1,this.color=n,void 0===e&&(e=1);let i=new En;i.setAttribute("position",new mn([-e,e,0,e,e,0,e,-e,0,-e,-e,0,-e,e,0],3));const r=new ca({fog:!1,toneMapped:!1});this.lightPlane=new fa(i,r),this.add(this.lightPlane),i=new En,i.setAttribute("position",new mn([0,0,0,0,0,1],3)),this.targetLine=new fa(i,r),this.add(this.targetLine),this.update()}dispose(){this.lightPlane.geometry.dispose(),this.lightPlane.material.dispose(),this.targetLine.geometry.dispose(),this.targetLine.material.dispose()}update(){gh.setFromMatrixPosition(this.light.matrixWorld),vh.setFromMatrixPosition(this.light.target.matrixWorld),yh.subVectors(vh,gh),this.lightPlane.lookAt(vh),void 0!==this.color?(this.lightPlane.material.color.set(this.color),this.targetLine.material.color.set(this.color)):(this.lightPlane.material.color.copy(this.light.color),this.targetLine.material.color.copy(this.light.color)),this.targetLine.lookAt(vh),this.targetLine.scale.z=yh.length()}},t.DiscreteInterpolant=Xo,t.DodecahedronBufferGeometry=Ba,t.DodecahedronGeometry=Ba,t.DoubleSide=2,t.DstAlphaFactor=206,t.DstColorFactor=208,t.DynamicBufferAttribute=function(t,e){return console.warn("THREE.DynamicBufferAttribute has been removed. Use new THREE.BufferAttribute().setUsage( THREE.DynamicDrawUsage ) instead."),new sn(t,e).setUsage(nt)},t.DynamicCopyUsage=35050,t.DynamicDrawUsage=nt,t.DynamicReadUsage=35049,t.EdgesGeometry=Ga,t.EdgesHelper=function(t,e){return console.warn("THREE.EdgesHelper has been removed. Use THREE.EdgesGeometry instead."),new ya(new Ga(t.geometry),new ca({color:void 0!==e?e:16777215}))},t.EllipseCurve=fl,t.EqualDepth=4,t.EqualStencilFunc=514,t.EquirectangularReflectionMapping=a,t.EquirectangularRefractionMapping=o,t.Euler=fe,t.EventDispatcher=rt,t.ExtrudeBufferGeometry=go,t.ExtrudeGeometry=go,t.FaceColors=1,t.FileLoader=cl,t.FlatShading=1,t.Float16BufferAttribute=pn,t.Float32Attribute=function(t,e){return console.warn("THREE.Float32Attribute has been removed. Use new THREE.Float32BufferAttribute() instead."),new mn(t,e)},t.Float32BufferAttribute=mn,t.Float64Attribute=function(t,e){return console.warn("THREE.Float64Attribute has been removed. Use new THREE.Float64BufferAttribute() instead."),new fn(t,e)},t.Float64BufferAttribute=fn,t.FloatType=b,t.Fog=Ss,t.FogExp2=Ms,t.Font=pc,t.FontLoader=class extends ol{constructor(t){super(t)}load(t,e,n,i){const r=this,s=new cl(this.manager);s.setPath(this.path),s.setRequestHeader(this.requestHeader),s.setWithCredentials(r.withCredentials),s.load(t,(function(t){let n;try{n=JSON.parse(t)}catch(e){console.warn("THREE.FontLoader: typeface.js support is being deprecated. Use typeface.json instead."),n=JSON.parse(t.substring(65,t.length-2))}const i=r.parse(n);e&&e(i)}),n,i)}parse(t){return new pc(t)}},t.FrontSide=0,t.Frustum=ai,t.GLBufferAttribute=Qc,t.GLSL1="100",t.GLSL3=it,t.GammaEncoding=Z,t.GreaterDepth=6,t.GreaterEqualDepth=5,t.GreaterEqualStencilFunc=518,t.GreaterStencilFunc=516,t.GridHelper=fh,t.Group=gs,t.HalfFloatType=M,t.HemisphereLight=Ol,t.HemisphereLightHelper=class extends Ce{constructor(t,e,n){super(),this.light=t,this.light.updateMatrixWorld(),this.matrix=t.matrixWorld,this.matrixAutoUpdate=!1,this.color=n;const i=new _o(e);i.rotateY(.5*Math.PI),this.material=new en({wireframe:!0,fog:!1,toneMapped:!1}),void 0===this.color&&(this.material.vertexColors=!0);const r=i.getAttribute("position"),s=new Float32Array(3*r.count);i.setAttribute("color",new sn(s,3)),this.add(new Wn(i,this.material)),this.update()}dispose(){this.children[0].geometry.dispose(),this.children[0].material.dispose()}update(){const t=this.children[0];if(void 0!==this.color)this.material.color.set(this.color);else{const e=t.geometry.getAttribute("color");ph.copy(this.light.color),mh.copy(this.light.groundColor);for(let t=0,n=e.count;t0){const n=new sl(e);r=new hl(n),r.setCrossOrigin(this.crossOrigin);for(let e=0,n=t.length;e\n\n\t\t\tvec3 getSample( float theta, vec3 axis ) {\n\n\t\t\t\tfloat cosTheta = cos( theta );\n\t\t\t\t// Rodrigues' axis-angle rotation\n\t\t\t\tvec3 sampleDirection = vOutputDirection * cosTheta\n\t\t\t\t\t+ cross( axis, vOutputDirection ) * sin( theta )\n\t\t\t\t\t+ axis * dot( axis, vOutputDirection ) * ( 1.0 - cosTheta );\n\n\t\t\t\treturn bilinearCubeUV( envMap, sampleDirection, mipInt );\n\n\t\t\t}\n\n\t\t\tvoid main() {\n\n\t\t\t\tvec3 axis = latitudinal ? poleAxis : cross( poleAxis, vOutputDirection );\n\n\t\t\t\tif ( all( equal( axis, vec3( 0.0 ) ) ) ) {\n\n\t\t\t\t\taxis = vec3( vOutputDirection.z, 0.0, - vOutputDirection.x );\n\n\t\t\t\t}\n\n\t\t\t\taxis = normalize( axis );\n\n\t\t\t\tgl_FragColor = vec4( 0.0, 0.0, 0.0, 1.0 );\n\t\t\t\tgl_FragColor.rgb += weights[ 0 ] * getSample( 0.0, axis );\n\n\t\t\t\tfor ( int i = 1; i < n; i++ ) {\n\n\t\t\t\t\tif ( i >= samples ) {\n\n\t\t\t\t\t\tbreak;\n\n\t\t\t\t\t}\n\n\t\t\t\t\tfloat theta = dTheta * float( i );\n\t\t\t\t\tgl_FragColor.rgb += weights[ i ] * getSample( -1.0 * theta, axis );\n\t\t\t\t\tgl_FragColor.rgb += weights[ i ] * getSample( theta, axis );\n\n\t\t\t\t}\n\n\t\t\t\tgl_FragColor = linearToOutputTexel( gl_FragColor );\n\n\t\t\t}\n\t\t`,blending:0,depthTest:!1,depthWrite:!1})}(Ih),this._equirectShader=null,this._cubemapShader=null,this._compileMaterial(this._blurMaterial)}fromScene(t,e=0,n=.1,i=100){kh=this._renderer.getRenderTarget();const r=this._allocateTargets();return this._sceneToCubeUV(t,n,i,r),e>0&&this._blur(r,0,0,e),this._applyPMREM(r),this._cleanup(r),r}fromEquirectangular(t){return this._fromTexture(t)}fromCubemap(t){return this._fromTexture(t)}compileCubemapShader(){null===this._cubemapShader&&(this._cubemapShader=Kh(),this._compileMaterial(this._cubemapShader))}compileEquirectangularShader(){null===this._equirectShader&&(this._equirectShader=Qh(),this._compileMaterial(this._equirectShader))}dispose(){this._blurMaterial.dispose(),null!==this._cubemapShader&&this._cubemapShader.dispose(),null!==this._equirectShader&&this._equirectShader.dispose();for(let t=0;t2?Ch:0,Ch,Ch),o.setRenderTarget(i),u&&o.render(zh,r),o.render(t,r)}o.toneMapping=h,o.outputEncoding=c,o.autoClear=l}_textureToCubeUV(t,e){const n=this._renderer;t.isCubeTexture?null==this._cubemapShader&&(this._cubemapShader=Kh()):null==this._equirectShader&&(this._equirectShader=Qh());const i=t.isCubeTexture?this._cubemapShader:this._equirectShader,r=new Wn(Oh[0],i),s=i.uniforms;s.envMap.value=t,t.isCubeTexture||s.texelSize.value.set(1/t.image.width,1/t.image.height),s.inputEncoding.value=Nh[t.encoding],s.outputEncoding.value=Nh[e.texture.encoding],Jh(e,0,0,3*Ch,2*Ch),n.setRenderTarget(e),n.render(r,Fh)}_applyPMREM(t){const e=this._renderer,n=e.autoClear;e.autoClear=!1;for(let e=1;eIh&&console.warn(`sigmaRadians, ${r}, is too large and will clip, as it requested ${m} samples when the maximum is set to 20`);const f=[];let g=0;for(let t=0;t4?i-8+4:0),3*v,2*v),o.setRenderTarget(e),o.render(c,Fh)}},t.ParametricBufferGeometry=wo,t.ParametricGeometry=wo,t.Particle=function(t){return console.warn("THREE.Particle has been renamed to THREE.Sprite."),new Vs(t)},t.ParticleBasicMaterial=function(t){return console.warn("THREE.ParticleBasicMaterial has been renamed to THREE.PointsMaterial."),new _a(t)},t.ParticleSystem=function(t,e){return console.warn("THREE.ParticleSystem has been renamed to THREE.Points."),new Ta(t,e)},t.ParticleSystemMaterial=function(t){return console.warn("THREE.ParticleSystemMaterial has been renamed to THREE.PointsMaterial."),new _a(t)},t.Path=Bl,t.PerspectiveCamera=Kn,t.Plane=Ne,t.PlaneBufferGeometry=ci,t.PlaneGeometry=ci,t.PlaneHelper=class extends fa{constructor(t,e=1,n=16776960){const i=n,r=new En;r.setAttribute("position",new mn([1,-1,1,-1,1,1,-1,-1,1,1,1,1,-1,1,1,-1,-1,1,1,-1,1,1,1,1,0,0,1,0,0,0],3)),r.computeBoundingSphere(),super(r,new ca({color:i,toneMapped:!1})),this.type="PlaneHelper",this.plane=t,this.size=e;const s=new En;s.setAttribute("position",new mn([1,1,1,-1,1,1,-1,-1,1,1,1,1,-1,-1,1,1,-1,1],3)),s.computeBoundingSphere(),this.add(new Wn(s,new en({color:i,opacity:.2,transparent:!0,depthWrite:!1,toneMapped:!1})))}updateMatrixWorld(t){let e=-this.plane.constant;Math.abs(e)<1e-8&&(e=1e-8),this.scale.set(.5*this.size,.5*this.size,e),this.children[0].material.side=e<0?1:0,this.lookAt(this.plane.normal),super.updateMatrixWorld(t)}},t.PointCloud=function(t,e){return console.warn("THREE.PointCloud has been renamed to THREE.Points."),new Ta(t,e)},t.PointCloudMaterial=function(t){return console.warn("THREE.PointCloudMaterial has been renamed to THREE.PointsMaterial."),new _a(t)},t.PointLight=Zl,t.PointLightHelper=class extends Wn{constructor(t,e,n){super(new So(e,4,2),new en({wireframe:!0,fog:!1,toneMapped:!1})),this.light=t,this.light.updateMatrixWorld(),this.color=n,this.type="PointLightHelper",this.matrix=this.light.matrixWorld,this.matrixAutoUpdate=!1,this.update()}dispose(){this.geometry.dispose(),this.material.dispose()}update(){void 0!==this.color?this.material.color.set(this.color):this.material.color.copy(this.light.color)}},t.Points=Ta,t.PointsMaterial=_a,t.PolarGridHelper=class extends ya{constructor(t=10,e=16,n=8,i=64,r=4473924,s=8947848){r=new tn(r),s=new tn(s);const a=[],o=[];for(let n=0;n<=e;n++){const i=n/e*(2*Math.PI),l=Math.sin(i)*t,c=Math.cos(i)*t;a.push(0,0,0),a.push(l,0,c);const h=1&n?r:s;o.push(h.r,h.g,h.b),o.push(h.r,h.g,h.b)}for(let e=0;e<=n;e++){const l=1&e?r:s,c=t-t/n*e;for(let t=0;t=22.12.0" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "1.0.75", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.75.tgz", + "integrity": "sha512-wlLDuor+Qm1H43VwZNi03QI4H9FSyZoQzE/YHpGhUay7sKA+4cDJF8ZZNGrtYkyPskfH5HCdNygUn7EEt6lRyQ==", + "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.75", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.75.tgz", + "integrity": "sha512-TKISaaILkgcOi7ThaTnln1XoasbUIx+HKSb+pf678RvGucIfLohQyLceuq+rTykoynX54KQ0pf9TEUbMVkUNAA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } + }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.2.tgz", + "integrity": "sha512-32pU4pNZABIz+l9DNJl51Y+jur4vv+SF4Ip2CSF4OUg1xUyefoLpX0NttDmzGITIrneUEVSEN+dT22524ESKBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.2.tgz", + "integrity": "sha512-S+H6LQgUoMj77BqDegwlRaxwLXDfwvSJGuceOqtH0I5V8rzKLmu/hC7NBlxOoAlvKlcV63FtdNiE2E9YSltffg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.2.tgz", + "integrity": "sha512-fD0ow2PBE60nw7K6xcbala6qwXxfcYeU62tduNeIPvx0KoWhU2rMKZiDNe+iI5TQb3rxYYjjP+aF2Sdm9y6EXQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.2.tgz", + "integrity": "sha512-t8OmL+hoJGDLZDnuLjgLemSYrXX99M7Md+zJX8bMHOtiNbFtkGXn/mV21Pb1ik9JhBXjwK1r4hvBPNlqTMGrHg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.2.tgz", + "integrity": "sha512-axbLgiM4Y2vyDOTqlXCI8vkg9wqjwSRsmoWXSKreA5YFJwnYA6Sc4aHMz+qZgUSfFei52Qrv1RGhDyo4kHvqhA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.2.tgz", + "integrity": "sha512-f0hqAIlFcL9wlRGJ/uCfyfspqnGaASk2gLx1UAP3RBgMQl68D1e+fiHNdXa7g9d76ttmpA8/PGNAqc1X4Byy1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.2.tgz", + "integrity": "sha512-UGLPuqeOV/UArsK6oeB5yI/XjSWkFqFlBTC9rUbezBuHJhSibk1EMv7QC0cvtDMu18bo+ucqXWPzh42oT5yYlw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.2.tgz", + "integrity": "sha512-jI0+gM2oDsJ7reOt3XPyO7lyQtZ1CT6NR2uqGQcQVM43cyXBAVYYCUxEH3LHCbgumFaZ+LueIUgbMSwb9pHBxQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.2.tgz", + "integrity": "sha512-yB99adXBRd5T+xXG+f6nnUkC3jCI0iXvPU6RqD9Kx7aZP4Y4NNUWJ5Q4FaP9jb1XmZLY4pGBUiHt8u03Yl7NyA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.2.tgz", + "integrity": "sha512-Oxvo6F3Edzy/Jm2EtbHWkJ2xRB0mXDAe63k5+USL5uiGE5xZjwEUDOBKIhv2BpCZSOAJrfoojFFogj6+ICKQhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.2.tgz", + "integrity": "sha512-SSWzUhL8Ex84JTsO67+MdWZrdwgOzoOrQ0+ZbB+UsivHoAxmWLHKWZaSafNqyBZtxGY1EgtR8AIPouWE9U+Zfw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.2.tgz", + "integrity": "sha512-0ZuI4St7chq3M0d3VivvKIqacZ7RhgohdR476V3HpJkaNdfIywsJIw+GBvqkQahu+4A2Rpu6yQJpWSrfk/Z+Jw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.1.2.tgz", + "integrity": "sha512-8Wn6phw7y53uI52+aBPAqEfZ5pj/HCjg/YtdthqSWYHy+d0MhyASKlcmuP0B5raxQnnA1Bm9LC8UO3M3RojeBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.2.tgz", + "integrity": "sha512-FkKaPBMawgHMNnp1FwLldXMNvEa139GXkxPi9JD9xU71Kh/ZmuEYHGSD6JwZDmDr4jekVrBrr+eGZ+j6C2mkXg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.2.tgz", + "integrity": "sha512-FeFC59UU1XX4J3ZaqKrsrEzczzB5qksMJo7/R45vIg8mGNVSLMVE85JRiZpjcp9i5Lbav5Vw47QvwFzBgIfvlw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "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/koffi": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.2.tgz", + "integrity": "sha512-wVwuE21TBl8/si6E0hPorKR2PJ2q33mEWVETANrtSp3kFM8fi2FcD/J5wmxu0T4TBcqmMQ4xKuF1X1ayFmphzw==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-darwin-arm64": "3.1.2", + "@koromix/koffi-darwin-x64": "3.1.2", + "@koromix/koffi-freebsd-arm64": "3.1.2", + "@koromix/koffi-freebsd-ia32": "3.1.2", + "@koromix/koffi-freebsd-x64": "3.1.2", + "@koromix/koffi-linux-arm64": "3.1.2", + "@koromix/koffi-linux-ia32": "3.1.2", + "@koromix/koffi-linux-loong64": "3.1.2", + "@koromix/koffi-linux-riscv64": "3.1.2", + "@koromix/koffi-linux-x64": "3.1.2", + "@koromix/koffi-openbsd-ia32": "3.1.2", + "@koromix/koffi-openbsd-x64": "3.1.2", + "@koromix/koffi-win32-arm64": "3.1.2", + "@koromix/koffi-win32-ia32": "3.1.2", + "@koromix/koffi-win32-x64": "3.1.2" + } + }, + "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/plugins/flight-map-canvas/extensions/flight-map-canvas/package.json b/plugins/flight-map-canvas/extensions/flight-map-canvas/package.json new file mode 100644 index 00000000..8f637900 --- /dev/null +++ b/plugins/flight-map-canvas/extensions/flight-map-canvas/package.json @@ -0,0 +1,21 @@ +{ + "name": "flight-map-canvas", + "version": "1.0.0", + "main": "extension.mjs", + "author": "John Haugabook", + "license": "MIT", + "type": "module", + "dependencies": { + "@github/copilot-sdk": "latest" + }, + "description": "A GitHub Copilot canvas that generates a view where Google Maps can be explored using 3D controls, as if a flight simulator. Agents can send the flight anywhere and report what they are working on.", + "keywords": [ + "copilot-canvas", + "flight-simulator", + "geography", + "google-maps", + "interactive-canvas", + "session-breaks", + "threejs" + ] +} diff --git a/plugins/flight-map-canvas/extensions/flight-map-canvas/renderMap.json b/plugins/flight-map-canvas/extensions/flight-map-canvas/renderMap.json new file mode 100644 index 00000000..4a67e867 --- /dev/null +++ b/plugins/flight-map-canvas/extensions/flight-map-canvas/renderMap.json @@ -0,0 +1,37 @@ +{ + "map": { + "width": 2210, + "height": 1290, + "tileZoom": 15, + "tileSize": 100, + "loadRadius": 25, + "unloadRadius": 9, + "startAltitude": 200, + "fogDensity": 0.00092, + "radiusFeather": 28.5, + "maxTiles": 600 + }, + "filler": { + "sampleInterval": 55, + "updateIntervalSec": 15, + "perimeter": { + "patchCount": 15, + "patchSize": 15 + }, + "center": { + "patchCount": 4, + "patchSize": 7 + }, + "padding": { + "patchCount": 6, + "patchSize": 9 + } + }, + "cloud": { + "enabled": true, + "opacity": 0.35, + "speed": 0.0004, + "coverage": 0.5, + "scale": 0.008 + } +} diff --git a/extensions/flight-map-canvas/.github/plugin/plugin.json b/plugins/flight-map-canvas/plugin.json similarity index 76% rename from extensions/flight-map-canvas/.github/plugin/plugin.json rename to plugins/flight-map-canvas/plugin.json index ff27a092..152079fe 100644 --- a/extensions/flight-map-canvas/.github/plugin/plugin.json +++ b/plugins/flight-map-canvas/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "flight-map-canvas", "description": "A GitHub Copilot canvas that generates a view where Google Maps can be explored using 3D controls, as if a flight simulator. Agents can send the flight anywhere and report what they are working on.", "version": "1.0.0", @@ -15,6 +16,9 @@ "session-breaks", "threejs" ], - "logo": "assets/preview.png", - "extensions": "extensions" + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + } + } } diff --git a/plugins/flowstudio-power-automate/.github/plugin/plugin.json b/plugins/flowstudio-power-automate/plugin.json similarity index 71% rename from plugins/flowstudio-power-automate/.github/plugin/plugin.json rename to plugins/flowstudio-power-automate/plugin.json index b32894e8..a9b76ccb 100644 --- a/plugins/flowstudio-power-automate/.github/plugin/plugin.json +++ b/plugins/flowstudio-power-automate/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "flowstudio-power-automate", "description": "Give your AI agent full visibility into Power Automate cloud flows via the FlowStudio MCP server. Connect, debug, build, monitor health, and govern flows at scale — action-level inputs and outputs, not just status codes.", "version": "2.0.0", @@ -17,12 +18,5 @@ "workflow-automation", "monitoring", "governance" - ], - "skills": [ - "./skills/flowstudio-power-automate-build", - "./skills/flowstudio-power-automate-debug", - "./skills/flowstudio-power-automate-governance", - "./skills/flowstudio-power-automate-mcp", - "./skills/flowstudio-power-automate-monitoring" ] } diff --git a/plugins/frontend-web-dev/.github/plugin/plugin.json b/plugins/frontend-web-dev/plugin.json similarity index 78% rename from plugins/frontend-web-dev/.github/plugin/plugin.json rename to plugins/frontend-web-dev/plugin.json index 8efc3030..53a00f4d 100644 --- a/plugins/frontend-web-dev/.github/plugin/plugin.json +++ b/plugins/frontend-web-dev/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "frontend-web-dev", "description": "Essential prompts, instructions, and chat modes for modern frontend web development including React, Angular, Vue, TypeScript, and CSS frameworks.", "version": "1.0.0", @@ -17,12 +18,5 @@ "html", "angular", "vue" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/playwright-explore-website", - "./skills/playwright-generate-test" ] } diff --git a/plugins/gem-team/.github/plugin/plugin.json b/plugins/gem-team/plugin.json similarity index 78% rename from plugins/gem-team/.github/plugin/plugin.json rename to plugins/gem-team/plugin.json index 0f00aeff..1a674cda 100644 --- a/plugins/gem-team/.github/plugin/plugin.json +++ b/plugins/gem-team/plugin.json @@ -1,10 +1,13 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "gem-team", + "version": "1.99.0", + "description": "Self-Learning Multi-agent orchestration framework for spec-driven development and automated verification. With smarter tool calling and leaner context.", "author": { "email": "mubaidr@gmail.com", "name": "mubaidr", "url": "https://github.com/mubaidr" }, - "description": "Self-Learning Multi-agent orchestration framework for spec-driven development and automated verification. With smarter tool calling and leaner context.", "homepage": "https://github.com/mubaidr/gem-team", "keywords": [ "multi-agent", @@ -19,7 +22,5 @@ "mobile" ], "license": "Apache-2.0", - "name": "gem-team", - "repository": "https://github.com/mubaidr/gem-team", - "version": "1.99.0" + "repository": "https://github.com/mubaidr/gem-team" } diff --git a/plugins/gesture-review/README.md b/plugins/gesture-review/README.md new file mode 100644 index 00000000..ec501329 --- /dev/null +++ b/plugins/gesture-review/README.md @@ -0,0 +1,17 @@ +# Gesture Review Plugin + +Review pull requests with a live camera feed and approve or reject using thumbs-up/thumbs-down gestures. + +## Installation + +``bash +copilot plugin install gesture-review@awesome-copilot +`` + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). + +## License + +MIT diff --git a/extensions/gesture-review/extensions/gesture-review/assets/preview.png b/plugins/gesture-review/extensions/gesture-review/assets/preview.png similarity index 100% rename from extensions/gesture-review/extensions/gesture-review/assets/preview.png rename to plugins/gesture-review/extensions/gesture-review/assets/preview.png diff --git a/plugins/gesture-review/extensions/gesture-review/extension.mjs b/plugins/gesture-review/extensions/gesture-review/extension.mjs new file mode 100644 index 00000000..eb0bc5b8 --- /dev/null +++ b/plugins/gesture-review/extensions/gesture-review/extension.mjs @@ -0,0 +1,1284 @@ +import http from "node:http"; +import { execFile } from "node:child_process"; +import { createCanvas, joinSession } from "@github/copilot-sdk/extension"; + +// The extension should query PRs from the active workspace repository. + +// In-memory state +let currentPR = null; +let prList = []; +let gestureState = "idle"; // idle | detecting | approved | rejected +let lastDecision = null; +let lastLoadError = null; +const sseClients = new Set(); +let loadPRsPromise = null; // in-flight guard for loadOpenPRs +let cachedHTML = null; // cached HTML string + +function broadcast(event, data) { + for (const res of sseClients) { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + } +} + +function normalizeErrorMessage(error) { + if (!error) return "Unknown error loading pull requests."; + const message = typeof error === "string" ? error : error.message || String(error); + const singleLine = message.split(/\r?\n/)[0].trim(); + return singleLine || "Unknown error loading pull requests."; +} + +// --- Load open PRs from the repo via the gh CLI --- +function shortDescription(body) { + if (!body) return ""; + // First non-empty, non-heading line, trimmed to a reasonable length. + const line = body + .split(/\r?\n/) + .map((l) => l.trim()) + .find((l) => l && !l.startsWith("#")); + if (!line) return ""; + return line.length > 140 ? line.slice(0, 137) + "..." : line; +} + +function loadOpenPRs() { + // De-dupe: return existing in-flight promise if one is running + if (loadPRsPromise) return loadPRsPromise; + + loadPRsPromise = new Promise((resolve) => { + const repoCwd = process.cwd(); + execFile( + "gh", + [ + "pr", + "list", + "--state", + "open", + "--limit", + "20", + "--json", + "number,title,author,additions,deletions,body", + ], + { cwd: repoCwd, maxBuffer: 1024 * 1024 }, + (err, stdout) => { + loadPRsPromise = null; + if (err) { + lastLoadError = normalizeErrorMessage(err); + prList = []; + currentPR = null; + console.error("gesture-review: failed to load PRs:", lastLoadError); + broadcast("prlist", prList); + broadcast("load_error", { message: lastLoadError }); + resolve(false); + return; + } + try { + const raw = JSON.parse(stdout); + lastLoadError = null; + prList = raw.map((pr) => ({ + title: pr.title, + number: pr.number, + author: pr.author?.login || "unknown", + description: shortDescription(pr.body), + additions: pr.additions || 0, + deletions: pr.deletions || 0, + })); + // Keep currentPR pointing at a still-open PR if possible. + if (currentPR) { + currentPR = prList.find((p) => p.number === currentPR.number) || null; + } + broadcast("prlist", prList); + if (currentPR) broadcast("pr", currentPR); + broadcast("load_error", null); + resolve(true); + } catch (e) { + lastLoadError = normalizeErrorMessage(e); + console.error("gesture-review: failed to parse PRs:", lastLoadError); + broadcast("load_error", { message: lastLoadError }); + resolve(false); + } + }, + ); + }); + + return loadPRsPromise; +} + +// --- Loopback HTTP server for the iframe --- +const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/") { + if (!cachedHTML) cachedHTML = getHTML(); + res.writeHead(200, { + "Content-Type": "text/html", + "Cache-Control": "no-cache", + }); + res.end(cachedHTML); + 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: prlist\ndata: ${JSON.stringify(prList)}\n\n`); + if (currentPR) { + res.write(`event: pr\ndata: ${JSON.stringify(currentPR)}\n\n`); + } + res.write(`event: state\ndata: ${JSON.stringify({ state: gestureState })}\n\n`); + if (lastLoadError) { + res.write( + `event: load_error\ndata: ${JSON.stringify({ message: lastLoadError })}\n\n`, + ); + } + sseClients.add(res); + req.on("close", () => sseClients.delete(res)); + return; + } + + if (req.method === "POST" && req.url === "/select-pr") { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + const { number } = JSON.parse(body); + const pr = prList.find((p) => p.number === number); + if (pr) { + currentPR = pr; + gestureState = "idle"; + broadcast("pr", currentPR); + broadcast("state", { state: "idle" }); + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + return; + } + + if (req.method === "POST" && req.url === "/gesture-decision") { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + const { decision } = JSON.parse(body); + gestureState = decision; // "approved" or "rejected" + lastDecision = { decision, pr: currentPR, timestamp: Date.now() }; + broadcast("state", { state: gestureState }); + + if (session && currentPR) { + const action = decision === "approved" ? "approve" : "reject"; + session.send({ + prompt: `The user gave a thumbs ${decision === "approved" ? "up" : "down"} gesture to ${action} PR #${currentPR.number} ("${currentPR.title}" by ${currentPR.author}). Please ${action} this pull request accordingly.`, + }); + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, decision })); + }); + return; + } + + if (req.method === "POST" && req.url === "/refresh") { + loadOpenPRs().then(() => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, count: prList.length })); + }); + 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: "gesture-review", + displayName: "Gesture PR Review", + description: + "Users review pull requests with a live camera feed and approve or reject using thumbs-up/thumbs-down gestures.", + actions: [ + { + name: "show_pr", + description: + "Display a PR for the user to gesture-review. Shows PR info and activates gesture detection.", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "PR title" }, + number: { type: "number", description: "PR number" }, + author: { type: "string", description: "PR author username" }, + description: { + type: "string", + description: "Short PR description", + }, + additions: { + type: "number", + description: "Lines added", + }, + deletions: { + type: "number", + description: "Lines deleted", + }, + }, + required: ["title", "number", "author"], + }, + handler({ input }) { + currentPR = { + title: input.title, + number: input.number, + author: input.author, + description: input.description || "", + additions: input.additions || 0, + deletions: input.deletions || 0, + }; + // Add to list if not already there + if (!prList.find((p) => p.number === currentPR.number)) { + prList.push(currentPR); + broadcast("prlist", prList); + } + gestureState = "idle"; + broadcast("pr", currentPR); + broadcast("state", { state: "idle" }); + return { ok: true, pr: currentPR }; + }, + }, + { + name: "get_status", + description: + "Returns current gesture detection state and last decision made.", + inputSchema: { type: "object", properties: {} }, + handler() { + return { + gestureState, + currentPR, + lastDecision, + }; + }, + }, + ], + open({ instanceId }) { + // Refresh open PRs each time the canvas is opened so the drawer is current. + loadOpenPRs(); + return { + url: `http://127.0.0.1:${port}`, + title: "Gesture PR Review", + status: "ready", + }; + }, +}); + +session = await joinSession({ canvases: [canvas] }); + +// Populate the drawer with open PRs as soon as the extension starts. +loadOpenPRs(); + +function getHTML() { + return ` + + + + + + + + + + + + +
+ + +
+ + +
+
+
Initializing camera...
+
+
+ + + + + +
+ 👋 + Waiting for a PR to review... + Ask the agent to show a PR +
+ + +
+ +
+ +
Initializing camera...
+
+ + + +`; +} diff --git a/plugins/gesture-review/extensions/gesture-review/package-lock.json b/plugins/gesture-review/extensions/gesture-review/package-lock.json new file mode 100644 index 00000000..de10bc66 --- /dev/null +++ b/plugins/gesture-review/extensions/gesture-review/package-lock.json @@ -0,0 +1,218 @@ +{ + "name": "gesture-review", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gesture-review", + "version": "1.0.0", + "dependencies": { + "@github/copilot-sdk": "latest" + } + }, + "node_modules/@github/copilot": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.55.tgz", + "integrity": "sha512-wqzI0L7krORW6jDAQPx7VnInka5BYN5yVgu+dpUK4w8xP5RgnOBa6kRoXpydj/9O1ufs0k6RKRtQjsVLp52TRw==", + "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", + "@github/copilot-darwin-x64": "1.0.55", + "@github/copilot-linux-arm64": "1.0.55", + "@github/copilot-linux-x64": "1.0.55", + "@github/copilot-linuxmusl-arm64": "1.0.55", + "@github/copilot-linuxmusl-x64": "1.0.55", + "@github/copilot-win32-arm64": "1.0.55", + "@github/copilot-win32-x64": "1.0.55" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.55.tgz", + "integrity": "sha512-v59pOpA7YO8j/lpDU/1E8l1Ag0hd26hIiEzTNbzqKd7tJpvhN0XTDWDCink50wXL656XIXt8lD8i8sGeD6yPfA==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.55.tgz", + "integrity": "sha512-XrJ9ent/9ogLk8yNp3TMsNVW0qTRDlkw/b34VnTgbAkJCaI3UVqaqpFn60Laa6J5mOPW0/JeKIkkva+7IJdqpQ==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.55.tgz", + "integrity": "sha512-5Q46Q72/l/U8KQRcBwYjzFPNXBCPG177FTmjEVOAH0qk7w58fMUDBEpnf9n1IpxYJDWQJ5BFGtLdfYgVVtkevw==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.55.tgz", + "integrity": "sha512-KWmMCDmKJivvOyDAAe5K8r7uSlVq8aZCh20VfrVXsc4bckO6KjXY/TOagrdBNqkk5rh8v63ghBbxFdWIOvEJRA==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.55.tgz", + "integrity": "sha512-Jb5ug9Ic1pzxB2ZT1xoR8b3Ea1xnvCa4h8cBque51+TevXe6QF98vAfSUIwLe4xu+K6JKhiKEA0SD3w29Z74eA==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.55.tgz", + "integrity": "sha512-qMGIjHxKmW9q26EpoaNKWpmEVGyL/IM8ThVkh7yolDzv9lECFudPzT5yLX7f+VIiF6qWQlrQyzmamp7/fNQ2Zg==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.55.tgz", + "integrity": "sha512-TO4EJ8it6Qki7wMKYHqGUEDYmB0EAToy+pE5++OpydB6FijyQ31+/XwjvdnEFkuB4ZgPqu/6Y8hxMKucl2+FYg==", + "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", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.55.tgz", + "integrity": "sha512-TBMiSZMz8Dhx79JeSEM+7ONGxR5NmxfiDUdySo6thVbRmjS9D8msyAP8ucTsbLBJcTFeb7vsaeObD/ujYQgDtA==", + "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/plugins/gesture-review/extensions/gesture-review/package.json b/plugins/gesture-review/extensions/gesture-review/package.json new file mode 100644 index 00000000..1f5a911f --- /dev/null +++ b/plugins/gesture-review/extensions/gesture-review/package.json @@ -0,0 +1,18 @@ +{ + "name": "gesture-review", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "dependencies": { + "@github/copilot-sdk": "latest" + }, + "description": "Users review pull requests with a live camera feed and approve or reject using thumbs-up/thumbs-down gestures.", + "keywords": [ + "pull-request-review", + "gesture-control", + "camera-input", + "hands-free", + "github-prs", + "mediapipe" + ] +} diff --git a/extensions/gesture-review/.github/plugin/plugin.json b/plugins/gesture-review/plugin.json similarity index 71% rename from extensions/gesture-review/.github/plugin/plugin.json rename to plugins/gesture-review/plugin.json index 9c376eae..c7bd4c32 100644 --- a/extensions/gesture-review/.github/plugin/plugin.json +++ b/plugins/gesture-review/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "gesture-review", "description": "Review pull requests with a live camera feed and approve or reject using thumbs-up/thumbs-down gestures.", "version": "1.0.2", @@ -14,6 +15,9 @@ "mediapipe", "pull-request-review" ], - "logo": "assets/preview.png", - "extensions": "extensions" + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + } + } } diff --git a/plugins/go-mcp-development/.github/plugin/plugin.json b/plugins/go-mcp-development/plugin.json similarity index 86% rename from plugins/go-mcp-development/.github/plugin/plugin.json rename to plugins/go-mcp-development/plugin.json index f012e481..6f33dc9d 100644 --- a/plugins/go-mcp-development/.github/plugin/plugin.json +++ b/plugins/go-mcp-development/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "go-mcp-development", "description": "Complete toolkit for building Model Context Protocol (MCP) servers in Go using the official github.com/modelcontextprotocol/go-sdk. Includes instructions for best practices, a prompt for generating servers, and an expert chat mode for guidance.", "version": "1.0.0", @@ -14,11 +15,5 @@ "model-context-protocol", "server-development", "sdk" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/go-mcp-server-generator" ] } diff --git a/plugins/java-development/.github/plugin/plugin.json b/plugins/java-development/plugin.json similarity index 75% rename from plugins/java-development/.github/plugin/plugin.json rename to plugins/java-development/plugin.json index 8b2f2556..604aafa8 100644 --- a/plugins/java-development/.github/plugin/plugin.json +++ b/plugins/java-development/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "java-development", "description": "Comprehensive collection of prompts and instructions for Java development including Spring Boot, Quarkus, testing, documentation, and best practices.", "version": "1.0.0", @@ -14,11 +15,5 @@ "jpa", "junit", "javadoc" - ], - "skills": [ - "./skills/create-spring-boot-java-project", - "./skills/java-docs", - "./skills/java-junit", - "./skills/java-springboot" ] } diff --git a/plugins/java-mcp-development/.github/plugin/plugin.json b/plugins/java-mcp-development/plugin.json similarity index 85% rename from plugins/java-mcp-development/.github/plugin/plugin.json rename to plugins/java-mcp-development/plugin.json index 799b1f76..e46ddc7a 100644 --- a/plugins/java-mcp-development/.github/plugin/plugin.json +++ b/plugins/java-mcp-development/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "java-mcp-development", "description": "Complete toolkit for building Model Context Protocol servers in Java using the official MCP Java SDK with reactive streams and Spring Boot integration.", "version": "1.0.0", @@ -16,11 +17,5 @@ "reactive-streams", "spring-boot", "reactor" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/java-mcp-server-generator" ] } diff --git a/plugins/java-modernization-studio/README.md b/plugins/java-modernization-studio/README.md new file mode 100644 index 00000000..76d0b042 --- /dev/null +++ b/plugins/java-modernization-studio/README.md @@ -0,0 +1,17 @@ +# Java Modernization Studio Plugin + +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. + +## Installation + +``bash +copilot plugin install java-modernization-studio@awesome-copilot +`` + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). + +## License + +MIT diff --git a/plugins/java-modernization-studio/extensions/java-modernization-studio/.gitignore b/plugins/java-modernization-studio/extensions/java-modernization-studio/.gitignore new file mode 100644 index 00000000..46c7eb98 --- /dev/null +++ b/plugins/java-modernization-studio/extensions/java-modernization-studio/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.log +.DS_Store +artifacts/ diff --git a/plugins/java-modernization-studio/extensions/java-modernization-studio/README.md b/plugins/java-modernization-studio/extensions/java-modernization-studio/README.md new file mode 100644 index 00000000..8677964a --- /dev/null +++ b/plugins/java-modernization-studio/extensions/java-modernization-studio/README.md @@ -0,0 +1,92 @@ +# Java Modernization Studio + +An interactive GitHub Copilot **canvas** that turns the [GitHub Copilot App Modernization for Java](https://learn.microsoft.com/en-us/azure/developer/java/migration/migrate-github-copilot-app-modernization-for-java) CLI workflow into a visible, steerable dashboard — assess a legacy Java app, drive a prioritized remediation plan, run validation gates, and dispatch Microsoft predefined tasks, all grounded in the repo's real artifacts. + +The Copilot App Modernization for Java tooling stays the engine. This canvas is the cockpit on top of it: it reads what the workflow produces and turns each step into an agent-driving button. + +## What it does + +- **Overview** — at-a-glance modernization status (phase, % complete, finding counts) scanned from the repo. +- **Readiness (Environment Doctor)** — checks JDK, Maven (or `./mvnw`), Git, Docker, and Azure CLI on PATH and flags what's missing before you start. +- **Assessment** — renders structured findings from `.appmod/assessment.json` (stack summary, severity-ordered findings, strengths), each with a one-click action. +- **Plan & Progress** — renders `plan.md` / `progress.md` as live checklists (`- [ ]` / `- [x]`). +- **Validation** — runs the workflow's quality gates (CVE validation, test generation) before and after changes. +- **Tasks** — dispatches Microsoft predefined modernization tasks (managed identity for DB, secrets → Key Vault, message-broker → Service Bus, S3 → Blob, cache → Redis, Entra ID auth, and more) relevant to the detected stack. +- **Summary** — surfaces `summary.md` when the run is complete. +- **Autopilot** — an optional phase-ordered, hands-free loop that advances assessment → remediation → validation and updates the dashboard as the agent makes progress. + +Buttons don't execute logic in the canvas — they dispatch a grounded prompt to your Copilot agent (action kinds: `run_task`, `generate_plan`, `run_cve`, `generate_tests`, `fix_finding`). The agent does the work; the canvas reflects the result. + +## Prerequisites + +- **GitHub Copilot app** (the canvas host). +- **GitHub Copilot App Modernization for Java** tooling — the underlying workflow this canvas drives. +- **JDK 17+** and **Maven** (or a `./mvnw` wrapper) for the Java project you're modernizing. +- *Optional:* **Azure CLI** (`az`) for cloud-readiness and Azure migration tasks; **Docker** for container checks. + +## Install + +This is an in-repo canvas extension. Copy the `java-modernization-studio/` folder into one of: + +- `~/.copilot/extensions/` — **user scope** (just you), or +- `.github/extensions/` — **project scope** (shared with your repo's team). + +Then reload extensions (or restart the app) so Copilot discovers it. No build step is required — the Copilot CLI resolves `@github/copilot-sdk` automatically. + +## Usage + +Point the canvas at a Java repository and let the agent drive it: + +```text +Open the Java Modernization Studio canvas for /path/to/my-java-app and run a readiness check. +``` + +The canvas resolves the target repo from its `repoPath` input, falling back to the session's working directory. From there: + +1. **Readiness first** — resolve any missing JDK/Maven/Azure CLI the Doctor flags. +2. **Assess** — generate `.appmod/assessment.json` + a prioritized `plan.md` / `progress.md`. +3. **Remediate** — work findings in severity order (P0 first), using task buttons and "Help me fix this". +4. **Validate** — run the CVE and test-generation gates. +5. **Ship** — when the work is genuinely complete, write `summary.md`. + +### Suggested agent instructions + +```text +When a user modernizes a Java project with the Java Modernization Studio canvas: +1) Open the canvas pointed at the repo (repoPath) and run the Environment Doctor first. +2) Run an assessment; write findings to .appmod/assessment.json and a prioritized plan.md / progress.md. + Start plan.md, progress.md, and summary.md with the exact first line so the + canvas recognizes them as modernization artifacts. +3) Work findings in severity order (P0 first); run the validation gates (CVE scan, test generation) + before and after code changes. +4) Keep plan.md / progress.md updated as - [ ] / - [x] checklists. Only write summary.md when the + work is truly complete. +``` + +## How it stays grounded + +The canvas renders **real repo state**, never invented status: + +- Structured findings come from `.appmod/assessment.json`. +- Plan/progress/summary come from root `plan.md` / `progress.md` / `summary.md`. +- To avoid mistaking an unrelated repo's `plan.md` for modernization output, root markdown is trusted **only** when `.appmod/` exists **or** the file's first line is the provenance marker ``. +- Stack detection (build tool, Java version, framework, container) is parsed from `pom.xml` / Gradle / Dockerfile and drives which tasks are shown. + +## Agent-callable actions + +| Action | Description | +|---|---| +| `get_state` | Return the current modernization snapshot scanned from the repo (assessment, plan/progress, gates, tasks). | +| `refresh` | Re-scan the repo and push a fresh snapshot to the open canvas. | + +## Development + +The grounding/parsing logic is pure and unit-tested independently of the canvas runtime: + +```bash +node --test test/cockpit.test.mjs +``` + +## License + +MIT diff --git a/extensions/java-modernization-studio/extensions/java-modernization-studio/assets/preview.png b/plugins/java-modernization-studio/extensions/java-modernization-studio/assets/preview.png similarity index 100% rename from extensions/java-modernization-studio/extensions/java-modernization-studio/assets/preview.png rename to plugins/java-modernization-studio/extensions/java-modernization-studio/assets/preview.png diff --git a/plugins/java-modernization-studio/extensions/java-modernization-studio/autopilot.mjs b/plugins/java-modernization-studio/extensions/java-modernization-studio/autopilot.mjs new file mode 100644 index 00000000..60287a6e --- /dev/null +++ b/plugins/java-modernization-studio/extensions/java-modernization-studio/autopilot.mjs @@ -0,0 +1,162 @@ +// autopilot.mjs — Sequencing engine for "Run on autopilot". +// +// Given the live plan, Autopilot drives the agent through the checklist one +// step at a time: pick the next eligible step (respecting phase ordering), +// hand it to the agent, wait for the turn to finish, re-scan, and repeat — +// streaming progress to the canvas after every step. +// +// All I/O is injected (`snapshot`, `runTurn`, `buildStepPrompt`, `onProgress`) +// so the loop is unit-testable without a live session or HTTP server. The pure +// helpers below are the same selection logic the renderer uses for "Continue +// here", which keeps the visible recommendation and the automated run in sync. + +// How long to wait for a single step's turn to go idle. Generous: one +// modernization step can involve edits, a build, and tests. The wait does not +// abort in-flight agent work; it only bounds how long Autopilot blocks before +// treating the step as failed. +export const AUTOPILOT_TURN_TIMEOUT_MS = 30 * 60 * 1000; + +export const AUTOPILOT_MAX_STEPS = 25; + +/** The checklist Autopilot follows: progress.md when present, else plan.md. */ +export function currentSteps(state) { + if (!state) return []; + if (state.progress && state.progress.steps && state.progress.steps.length) return state.progress.steps; + if (state.plan && state.plan.steps) return state.plan.steps; + return []; +} + +/** Stable identity for a step across re-scans (phase + title). */ +export function stepKey(step) { + if (!step) return ""; + return (step.section || "") + "::" + (step.title || ""); +} + +/** + * The next step Autopilot should run: the first not-done step in the active + * phase, falling back to the first not-done step overall. Mirrors the renderer's + * "Continue here" so the automated run never jumps ahead of the safe next step. + * @returns {object|null} + */ +export function selectNextStep(state) { + const steps = currentSteps(state); + if (!steps.length) return null; + const ord = (state && state.ordering) || { activeRank: null }; + const inPhase = steps.find( + (x) => x.status !== "done" && (ord.activeRank == null || x.rank === ord.activeRank) + ); + return inPhase || steps.find((x) => x.status !== "done") || null; +} + +/** Whether the given step is now checked off in a freshly scanned state. */ +export function isStepDone(state, step) { + const k = stepKey(step); + const match = currentSteps(state).find((s) => stepKey(s) === k); + return !!(match && match.status === "done"); +} + +/** Construct the mutable, serializable run record broadcast to the canvas. */ +export function makeRun({ scope, maxSteps, startRank } = {}) { + return { + running: true, + cancelled: false, + scope: scope === "all" ? "all" : "phase", + maxSteps: maxSteps || AUTOPILOT_MAX_STEPS, + startRank: startRank == null ? null : startRank, + status: "running", + current: null, + completed: [], + startedAt: Date.now(), + finishedAt: null, + }; +} + +/** + * Drive the run to completion. Mutates `run` in place and calls + * `deps.onProgress(run, state|null)` whenever something changes so the caller + * can broadcast. Resolves with the final `run`. + * + * @param {object} run from makeRun() + * @param {{ + * snapshot: () => Promise, + * runTurn: (prompt: string) => Promise, + * buildStepPrompt: (step: object) => string, + * onProgress: (run: object, state: object|null) => void, + * log?: Function, + * }} deps + */ +export async function runAutopilot(run, deps) { + const onProgress = deps.onProgress || (() => {}); + let lastKey = null; + try { + while (true) { + if (run.cancelled) { + run.status = "cancelled"; + break; + } + if (run.completed.length >= run.maxSteps) { + run.status = "capped"; + break; + } + const state = await deps.snapshot(); + const step = selectNextStep(state); + if (!step) { + run.status = "completed"; + break; + } + // Phase scope: stop once the active phase advances past where we began. + if (run.scope === "phase" && run.startRank != null && step.rank != null && step.rank > run.startRank) { + run.status = "phase_done"; + break; + } + const key = stepKey(step); + // Selecting the same step twice running means the previous attempt did + // not check it off — the agent is stuck or waiting on a decision. Stop + // and hand control back rather than loop on it. + if (key === lastKey) { + run.status = "stuck"; + run.stuck = step.title; + break; + } + lastKey = key; + + run.current = { title: step.title, section: step.section || null }; + onProgress(run, state); + if (deps.log) deps.log("Autopilot → " + step.title, { ephemeral: true }); + + let stepError = null; + try { + await deps.runTurn(deps.buildStepPrompt(step)); + } catch (e) { + stepError = (e && e.message) || String(e); + } + + const after = await deps.snapshot(); + const done = isStepDone(after, step); + run.completed.push({ + title: step.title, + section: step.section || null, + done, + error: stepError, + at: Date.now(), + }); + run.current = null; + onProgress(run, after); + + if (stepError) { + run.status = "error"; + run.error = stepError; + break; + } + } + } catch (e) { + run.status = "error"; + run.error = (e && e.message) || String(e); + } finally { + if (run.status === "running") run.status = "completed"; + run.running = false; + run.finishedAt = Date.now(); + onProgress(run, null); + } + return run; +} diff --git a/plugins/java-modernization-studio/extensions/java-modernization-studio/catalog.mjs b/plugins/java-modernization-studio/extensions/java-modernization-studio/catalog.mjs new file mode 100644 index 00000000..da2fa6b3 --- /dev/null +++ b/plugins/java-modernization-studio/extensions/java-modernization-studio/catalog.mjs @@ -0,0 +1,175 @@ +// catalog.mjs — Static catalog of GitHub Copilot App Modernization for Java +// predefined tasks and supported Java upgrade paths. Sourced from Microsoft Learn: +// /azure/developer/java/migration/migrate-github-copilot-app-modernization-for-java-predefined-tasks +// /azure/developer/github-copilot-app-modernization/tools +// +// `detect` lists dependency-signature keys (see scan.mjs DEP_SIGNATURES). When a +// repo's build files match one of those keys, the task is flagged "relevant". + +export const PREDEFINED_TASKS = [ + { + id: "rabbitmq-to-servicebus", + name: "RabbitMQ to Azure Service Bus", + category: "Messaging", + summary: + "Convert RabbitMQ usage (Spring AMQP, Spring JMS, or Jakarta EE over AMQP) to Azure Service Bus, preserving messaging semantics with secure auth by default.", + detect: ["rabbitmq"], + }, + { + id: "activemq-to-servicebus", + name: "ActiveMQ to Azure Service Bus", + category: "Messaging", + summary: + "Convert ActiveMQ producers, consumers, connection factories, and queue/topic interactions to Azure Service Bus equivalents.", + detect: ["activemq"], + }, + { + id: "aws-sqs-to-servicebus", + name: "AWS SQS to Azure Service Bus", + category: "Messaging", + summary: + "Translate AWS SQS queue operations and message handling to Azure Service Bus, preserving at-least-once delivery, batching, and visibility-timeout behaviors.", + detect: ["awsSqs"], + }, + { + id: "managed-identity-db", + name: "Managed Identities for Database migration", + category: "Identity & Secrets", + summary: + "Prepare your codebase for Managed Identity authentication when moving from a local database to Azure SQL, MySQL, PostgreSQL, or Cosmos DB.", + detect: ["jdbc"], + }, + { + id: "managed-identity-credentials", + name: "Managed Identities for Credential Migration", + category: "Identity & Secrets", + summary: + "Replace connection strings / shared access signatures for messaging services (Event Hubs, Service Bus) with Azure Managed Identity authentication.", + detect: ["rabbitmq", "activemq", "jms"], + }, + { + id: "secrets-to-keyvault", + name: "Secrets & Certificate Management to Azure Key Vault", + category: "Identity & Secrets", + summary: + "Move hardcoded secrets and local TLS/mTLS certificates (Java KeyStores) to Azure Key Vault, retrieving them at runtime via the JCA provider.", + detect: ["keystore"], + }, + { + id: "crypto-to-keyvault", + name: "Cryptography operations to Azure Key Vault", + category: "Identity & Secrets", + summary: + "Migrate local signing, verification, encryption, and decryption to Azure Key Vault so keys never leave the vault.", + detect: ["crypto"], + }, + { + id: "aws-secrets-to-keyvault", + name: "AWS Secret Manager to Azure Key Vault", + category: "Identity & Secrets", + summary: + "Reconfigure secret creation, retrieval, update, and deletion from AWS Secret Manager to Azure Key Vault.", + detect: ["awsSecrets"], + }, + { + id: "entra-id-auth", + name: "User authentication to Microsoft Entra ID", + category: "Identity & Secrets", + summary: + "Transition LDAP-based user authentication to Microsoft Entra ID authentication.", + detect: ["ldap"], + }, + { + id: "aws-s3-to-blob", + name: "AWS S3 to Azure Storage Blob", + category: "Storage", + summary: + "Convert code that interacts with AWS S3 into Azure Storage Blob logic while maintaining the same semantics.", + detect: ["awsS3"], + }, + { + id: "file-io-to-fileshare", + name: "Local file I/O to Azure Storage File share mounts", + category: "Storage", + summary: + "Convert local file reads/writes to unified mount-path access so an Azure Storage File share can persist data across replicas.", + detect: [], + }, + { + id: "logging-to-console", + name: "Logging to local file → console (Azure Monitor)", + category: "Observability", + summary: + "Convert file-based logging to console-based logging so Azure hosting integrates it with Azure Monitor automatically.", + detect: ["filelog"], + }, + { + id: "javamail-to-acs", + name: "Java Mail to Azure Communication Services", + category: "Email", + summary: + "Convert SMTP-based mail sending to Azure Communication Services, which works in Azure hosting environments that block port 25.", + detect: ["javamail"], + }, + { + id: "databases-to-azure", + name: "On-prem databases to Azure database offerings", + category: "Database", + summary: + "Migrate Oracle, IBM Db2, Informix, or Sybase ASE to Azure Database for PostgreSQL or Azure SQL with passwordless Entra ID auth, reconciling SQL dialect differences.", + detect: ["oracle", "db2", "sybase", "informix"], + }, + { + id: "cache-to-redis", + name: "Cache solutions to Azure Managed Redis", + category: "Cache", + summary: + "Migrate in-memory or distributed caches (Infinispan, SwarmCache, Memcached, etc.) to Azure Managed Redis with passwordless Entra ID auth.", + detect: ["cache"], + }, + { + id: "ant-eclipse-to-maven", + name: "Ant / Eclipse project to Maven", + category: "Build", + summary: + "Convert Ant or Eclipse IDE projects to Maven so the project builds consistently from any environment.", + detect: ["ant"], + }, +]; + +// Java runtime upgrade ladder targeted by App Modernization (8 → 11 → 17 → 21 → 25), +// with particular focus on Spring Boot apps. +export const JAVA_UPGRADE_TARGETS = [11, 17, 21, 25]; + +// Day-to-day Java utilities you can invoke in Copilot Chat with the `#` prefix. +export const APPMOD_TOOLS = { + cve: { + id: "appmod-validate-cves-for-java", + label: "Validate CVEs", + summary: + "Scan the project for known Java CVEs and validate that critical vulnerabilities are addressed.", + }, + tests: { + id: "appmod-generate-tests-for-java", + label: "Generate unit tests", + summary: "Use AI code understanding to generate unit tests for the Java code.", + }, +}; + +// The five validation gates App Modernization runs after code transformation. +export const VALIDATION_GATES = [ + { key: "build", label: "Build" }, + { key: "tests", label: "Unit Tests" }, + { key: "cve", label: "CVE Check" }, + { key: "consistency", label: "Consistency" }, + { key: "completeness", label: "Completeness" }, +]; + +/** Return the catalog with a `relevant` flag set per task based on detected deps. */ +export function catalogWithRelevance(detectedKeys) { + const set = new Set(detectedKeys || []); + return PREDEFINED_TASKS.map((t) => ({ + ...t, + relevant: t.detect.some((d) => set.has(d)), + })); +} diff --git a/plugins/java-modernization-studio/extensions/java-modernization-studio/doctor.mjs b/plugins/java-modernization-studio/extensions/java-modernization-studio/doctor.mjs new file mode 100644 index 00000000..d121dc86 --- /dev/null +++ b/plugins/java-modernization-studio/extensions/java-modernization-studio/doctor.mjs @@ -0,0 +1,412 @@ +// doctor.mjs — Environment & workflow readiness checks for the App Modernization +// Cockpit. Probes the local CLI toolchain (JDK, Maven/Gradle, git, Docker, Azure +// CLI / azd, Node) and combines those results with repo facts from scan.mjs so a +// user can confirm their machine is ready *before* they start migrating — and get +// clear, actionable remediation when something is missing. +// +// Split into a pure builder (`buildDoctorReport`) and an impure runner +// (`runDoctor`) so the decision logic is unit-testable without shelling out. + +import { execFile } from "node:child_process"; +import { platform } from "node:os"; +import { readdirSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +// One probe per tool. Many CLIs print their version to stderr (notably `java`), +// so probes capture both streams. +export const TOOL_PROBES = [ + { key: "java", cmd: "java", args: ["-version"] }, + { key: "mvn", cmd: "mvn", args: ["-v"] }, + { key: "gradle", cmd: "gradle", args: ["-v"] }, + { key: "git", cmd: "git", args: ["--version"] }, + { key: "docker", cmd: "docker", args: ["--version"] }, + { key: "az", cmd: "az", args: ["--version"] }, + { key: "azd", cmd: "azd", args: ["version"] }, + { key: "node", cmd: "node", args: ["--version"] }, +]; + +/** Friendly OS label for remediation hints. */ +export function osLabel() { + const p = platform(); + if (p === "darwin") return "macOS"; + if (p === "win32") return "Windows"; + if (p === "linux") return "Linux"; + return p; +} + +/** Extract a short version string from a tool's --version output. Pure. */ +export function parseToolVersion(key, text) { + if (!text) return null; + const t = String(text); + if (key === "java") { + const m = t.match(/version\s+"?([\d._]+)"?/i); + if (m) return m[1]; + } + if (key === "node") { + const m = t.match(/v?(\d+\.\d+\.\d+)/); + if (m) return m[1]; + } + const g = t.match(/(\d+\.\d+(?:\.\d+)?)/); + if (g) return g[1]; + const first = t.split(/\r?\n/)[0].trim(); + return first ? first.slice(0, 40) : null; +} + +function defaultExec(cmd, args) { + return makeExec(augmentedEnv(process.env, platform()))(cmd, args); +} + +/** + * Build a child-process env whose PATH also covers the usual places dev tools + * live. A GUI-launched app (the Copilot app) inherits a minimal launchd PATH + * (/usr/bin:/bin:/usr/sbin:/sbin), so tools installed via Homebrew, SDKMAN, + * asdf, or a keg-only JDK aren't found even though the user's terminal sees + * them fine. Prepending these locations makes the probes match reality without + * touching the user's actual environment. Pure given an injected directory + * lister (so it's unit-testable without hitting the filesystem). + */ +export function augmentedEnv(env, plat, lister) { + const base = Object.assign({}, env); + if (plat === "win32") return base; // Windows PATH semantics differ; leave as-is. + const ls = lister || ((d) => { try { return readdirSync(d); } catch (e) { return []; } }); + const extra = ["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "/usr/local/sbin"]; + if (base.HOME) { + extra.push(base.HOME + "/.sdkman/candidates/java/current/bin"); + extra.push(base.HOME + "/.asdf/shims"); + } + if (base.JAVA_HOME) extra.push(base.JAVA_HOME + "/bin"); + // Keg-only Homebrew JDKs aren't symlinked into .../bin, so enumerate them. + for (const root of ["/opt/homebrew/opt", "/usr/local/opt"]) { + for (const name of ls(root)) { + if (/^openjdk(@\d+)?$/.test(name)) extra.push(root + "/" + name + "/libexec/openjdk.jdk/Contents/Home/bin"); + } + } + const seen = {}; + const prefix = extra.filter((p) => { if (seen[p]) return false; seen[p] = 1; return true; }); + base.PATH = prefix.join(":") + (base.PATH ? ":" + base.PATH : ""); + return base; +} + +/** Build an exec function bound to a specific environment. */ +export function makeExec(env) { + return (cmd, args) => + new Promise((resolve) => { + execFile(cmd, args, { timeout: 3500, windowsHide: true, env }, (error, stdout, stderr) => { + resolve({ error: error || null, stdout: String(stdout || ""), stderr: String(stderr || "") }); + }); + }); +} + +/** + * Expand a shell-style JAVA_HOME value from an rc assignment into a concrete path, + * WITHOUT executing anything. Handles surrounding quotes, an inline comment on + * unquoted values, and `~` / `$HOME` / `${HOME}` expansion. Returns null when the + * value still contains something only a shell could resolve (command substitution, + * other variables) — we'd rather report nothing than a bogus path. + */ +export function expandJavaHomeValue(raw, env) { + if (typeof raw !== "string") return null; + let v = raw.trim(); + if (!v) return null; + if (v[0] !== '"' && v[0] !== "'") { + const c = v.indexOf(" #"); + if (c >= 0) v = v.slice(0, c).trim(); + } + if (v.length >= 2 && ((v[0] === '"' && v[v.length - 1] === '"') || (v[0] === "'" && v[v.length - 1] === "'"))) { + v = v.slice(1, -1); + } + const home = (env && env.HOME) || ""; + if (v === "~") v = home; + else if (v.startsWith("~/")) v = home + v.slice(1); + v = v.replace(/\$\{HOME\}/g, home).replace(/\$HOME(?=\/|$)/g, home); + if (!v || /[`]|\$\(|\$\w/.test(v)) return null; // unresolved shell expansion + return v; +} + +/** + * Statically parse a shell rc file's text for the last `JAVA_HOME=` assignment. + * Pure and side-effect-free. Returns the expanded path or null. + */ +export function parseJavaHomeFromRc(text, env) { + if (typeof text !== "string" || !text) return null; + const re = /^[ \t]*(?:export[ \t]+)?JAVA_HOME[ \t]*=[ \t]*(.+?)[ \t]*$/gm; + let m; + let last = null; + while ((m = re.exec(text)) !== null) { + const val = expandJavaHomeValue(m[1], env); + if (val) last = val; // later assignments win, mirroring shell evaluation order + } + return last; +} + +/** + * Best-effort discovery of the JAVA_HOME the user's interactive shell is + * configured with, so the readiness report reflects the JDK their terminal + * actually uses (e.g. a pinned LTS) rather than whichever JDK happens to sort + * first on the augmented PATH. + * + * IMPORTANT: this only *reads and statically parses* the shell rc files — it never + * sources or executes them. That keeps the readiness probe honest with the UI + * promise ("only reads version numbers; nothing is installed or changed") and + * avoids running arbitrary user startup code. A JAVA_HOME defined via command + * substitution (e.g. `$(/usr/libexec/java_home)`) can't be resolved statically, so + * we simply fall back to PATH-based probing in that case. The rc reader is + * injectable for tests. + */ +export async function discoverJavaHome(env, plat, readRc) { + const e = env || {}; + if (e.JAVA_HOME) return e.JAVA_HOME; // already explicit in this process + if (plat === "win32") return null; // not a $JAVA_HOME/rc world + const home = e.HOME || ""; + if (!home) return null; + const shell = e.SHELL || (plat === "darwin" ? "/bin/zsh" : "/bin/bash"); + const names = /zsh/.test(shell) + ? [".zshenv", ".zprofile", ".zshrc"] + : /bash/.test(shell) + ? [".bash_profile", ".bashrc", ".profile"] + : [".profile"]; + const read = + readRc || + (async (p) => { + try { + return await readFile(p, "utf8"); + } catch { + return null; + } + }); + let found = null; + for (const name of names) { + let text = null; + try { + text = await read(join(home, name)); + } catch { + text = null; + } + const val = parseJavaHomeFromRc(text, e); + if (val) found = val; // later rc files (e.g. .zshrc) win + } + return found || null; +} + +/** Probe a single tool. Returns { key, found, version }. */ +export async function probeOne(def, exec) { + const run = exec || defaultExec; + let r; + try { + r = await run(def.cmd, def.args); + } catch (e) { + r = { error: e, stdout: "", stderr: "" }; + } + const text = ((r.stdout || "") + "\n" + (r.stderr || "")).trim(); + // ENOENT means the binary isn't on PATH at all. + if (r.error && r.error.code === "ENOENT") return { key: def.key, found: false, version: null }; + if (r.error && !text) return { key: def.key, found: false, version: null }; + const version = parseToolVersion(def.key, text); + if (r.error) { + // Non-zero exit. Genuine `--version` calls exit 0, so only trust this if a + // real version number came back. This rejects the macOS `java` stub, which + // exits 1 with "Unable to locate a Java Runtime" when no JDK is installed. + if (!(version && /\d/.test(version))) return { key: def.key, found: false, version: null }; + } + return { key: def.key, found: true, version }; +} + +/** Probe every tool and build the readiness report. Impure (spawns processes). */ +export async function runDoctor(scan, opts = {}) { + let exec = opts.exec; + if (!exec) { + const env = augmentedEnv(process.env, platform()); + // Prefer the JDK the user's shell is configured with so the report matches + // their terminal (and so a dependency-pulled JDK doesn't shadow their pin). + // Static rc parse — no shell sourcing. + const jh = await discoverJavaHome(process.env, platform()); + if (jh && !env.JAVA_HOME) { + env.JAVA_HOME = jh; + env.PATH = jh + "/bin:" + env.PATH; + } + exec = makeExec(env); + } + const probesArr = await Promise.all(TOOL_PROBES.map((d) => probeOne(d, exec))); + const probes = {}; + for (const p of probesArr) probes[p.key] = p; + return buildDoctorReport({ probes, scan: scan || {} }); +} + +function envAction(tool, detail, fix) { + return { kind: "fix_env", payload: { tool, detail, fix }, label: "Help me set up " + tool }; +} + +/** + * Turn probe results + scan facts into grouped readiness checks. Pure. + * @returns {{ overall:"ready"|"caution"|"blocked", generatedAt:string, groups:Array }} + */ +export function buildDoctorReport({ probes, scan }) { + const get = (k) => (probes && probes[k]) || { found: false, version: null }; + const a = (scan && scan.assessment) || {}; + const buildTool = a.buildTool || null; + + const buildRun = []; + + // --- JDK ----------------------------------------------------------------- + const java = get("java"); + buildRun.push( + java.found + ? { id: "jdk", label: "Java Development Kit", status: "ok", detail: "Java " + (java.version || "detected") } + : { + id: "jdk", + label: "Java Development Kit", + status: "fail", + detail: "java not found on PATH", + fix: "Install a JDK 17 or newer (Microsoft Build of OpenJDK or Eclipse Temurin) and make sure `java` is on your PATH.", + action: envAction("a JDK", "java not found on PATH", "Install JDK 17+ (Microsoft OpenJDK / Temurin) and add it to PATH."), + } + ); + + // --- Build tool ---------------------------------------------------------- + if (buildTool === "Maven") { + const mvn = get("mvn"); + const ok = mvn.found || a.hasMavenWrapper; + buildRun.push( + ok + ? { + id: "build", + label: "Maven", + status: "ok", + detail: mvn.found ? "Maven " + (mvn.version || "detected") : "Using the project Maven wrapper (./mvnw)", + } + : { + id: "build", + label: "Maven", + status: "fail", + detail: "mvn not found and no ./mvnw wrapper in the repo", + fix: "Install Apache Maven, or add the Maven wrapper (`mvn -N wrapper:wrapper`) so the build runs anywhere.", + action: envAction("Maven", "mvn not found and no ./mvnw wrapper", "Install Apache Maven or add the ./mvnw wrapper."), + } + ); + } else if (buildTool === "Gradle") { + const gr = get("gradle"); + const ok = gr.found || a.hasGradleWrapper; + buildRun.push( + ok + ? { + id: "build", + label: "Gradle", + status: "ok", + detail: gr.found ? "Gradle " + (gr.version || "detected") : "Using the project Gradle wrapper (./gradlew)", + } + : { + id: "build", + label: "Gradle", + status: "fail", + detail: "gradle not found and no ./gradlew wrapper in the repo", + fix: "Install Gradle, or add the Gradle wrapper (`gradle wrapper`) so the build runs anywhere.", + action: envAction("Gradle", "gradle not found and no ./gradlew wrapper", "Install Gradle or add the ./gradlew wrapper."), + } + ); + } else { + buildRun.push({ + id: "build", + label: "Build tool", + status: "info", + detail: "No Maven or Gradle build file detected yet.", + }); + } + + // --- git ----------------------------------------------------------------- + const git = get("git"); + buildRun.push( + git.found + ? { id: "git", label: "Git", status: "ok", detail: git.version ? "git " + git.version : "installed" } + : { + id: "git", + label: "Git", + status: "fail", + detail: "git not found on PATH", + fix: "Install Git — the modernization workflow uses a branch and a pull request.", + action: envAction("Git", "git not found on PATH", "Install Git and ensure it is on PATH."), + } + ); + + // --- containerize / deploy (optional) ------------------------------------ + const deploy = []; + const docker = get("docker"); + if (docker.found) { + deploy.push({ id: "docker", label: "Docker", status: "ok", detail: docker.version ? "Docker " + docker.version : "installed" }); + } else if (a.hasDockerfile) { + deploy.push({ + id: "docker", + label: "Docker", + status: "warn", + detail: "A Dockerfile is present but Docker isn't installed.", + fix: "Install Docker Desktop (or the Docker engine) to build and run the container image locally.", + action: envAction("Docker", "Dockerfile present but Docker not installed", "Install Docker Desktop / engine."), + }); + } else { + deploy.push({ id: "docker", label: "Docker", status: "info", detail: "Not installed — only needed to build container images." }); + } + const az = get("az"); + deploy.push( + az.found + ? { id: "az", label: "Azure CLI", status: "ok", detail: az.version ? "az " + az.version : "installed" } + : { id: "az", label: "Azure CLI", status: "info", detail: "Optional — needed to provision Azure resources and set up passwordless auth." } + ); + const azd = get("azd"); + deploy.push( + azd.found + ? { id: "azd", label: "Azure Developer CLI", status: "ok", detail: azd.version ? "azd " + azd.version : "installed" } + : { id: "azd", label: "Azure Developer CLI", status: "info", detail: "Optional — `azd` deploys the app to Azure once it's modernized." } + ); + + // --- workflow readiness (from scan, no probes) --------------------------- + const flow = []; + const g = scan && scan.git; + if (!g) { + flow.push({ id: "branch", label: "Source control", status: "info", detail: "Git branch state unavailable." }); + } else if (g.isMigrationBranch) { + flow.push({ id: "branch", label: "Working branch", status: "ok", detail: "On modernization branch '" + g.branch + "'." }); + } else { + flow.push({ + id: "branch", + label: "Working branch", + status: "info", + detail: "On '" + (g.branch || "unknown") + "' — consider a dedicated modernization branch before making changes.", + }); + } + const hasAssessment = !!( + (scan && scan.report && scan.report.findings && scan.report.findings.length) || + (scan && scan.plan && scan.plan.exists) || + (scan && scan.progress && scan.progress.exists) + ); + flow.push( + hasAssessment + ? { id: "assessed", label: "Assessment", status: "ok", detail: "Assessment artifacts found — you're underway." } + : { + id: "assessed", + label: "Assessment", + status: "warn", + detail: "No assessment yet. Start here to map your modernization work.", + action: { kind: "start_assessment", payload: {}, label: "Run assessment" }, + } + ); + const nSkills = (scan && scan.skills && scan.skills.length) || 0; + flow.push({ + id: "skills", + label: "Custom skills", + status: "info", + detail: nSkills ? nSkills + " custom skill" + (nSkills > 1 ? "s" : "") + " discovered." : "No custom skills yet (optional).", + }); + + const groups = [ + { id: "buildRun", name: "Build & run", checks: buildRun }, + { id: "deploy", name: "Containerize & deploy to Azure (optional)", checks: deploy }, + { id: "flow", name: "Workflow readiness", checks: flow }, + ]; + + let overall = "ready"; + const all = groups.reduce((acc, grp) => acc.concat(grp.checks), []); + if (all.some((c) => c.status === "fail")) overall = "blocked"; + else if (all.some((c) => c.status === "warn")) overall = "caution"; + + return { overall, generatedAt: new Date().toISOString(), groups }; +} diff --git a/plugins/java-modernization-studio/extensions/java-modernization-studio/extension.mjs b/plugins/java-modernization-studio/extensions/java-modernization-studio/extension.mjs new file mode 100644 index 00000000..0f05774c --- /dev/null +++ b/plugins/java-modernization-studio/extensions/java-modernization-studio/extension.mjs @@ -0,0 +1,122 @@ +// extension.mjs — Java Modernization Studio (user-scoped canvas). Wiring only. +// +// Declares the canvas and bridges the SDK to the testable modules: +// scan.mjs repo + markdown parsing -> grounded state +// catalog.mjs Microsoft predefined task catalog +// prompts.mjs action -> crafted agent prompt +// server.mjs per-instance loopback HTTP server + action dispatch +// renderer.mjs cockpit UI +// +// Button clicks POST /action; agent actions are forwarded to session.send. When a +// turn finishes (session.idle) every open cockpit re-scans and pushes fresh state. + +import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; +import { basename } from "node:path"; +import { scanRepo } from "./scan.mjs"; +import { createInstanceServer, pushState, resolveRepoPath } from "./server.mjs"; +import { AUTOPILOT_TURN_TIMEOUT_MS } from "./autopilot.mjs"; + +const instances = new Map(); // instanceId -> rec { server, url, repoPath, sseClients } +let sessionRef = null; +let lastWorkingDir = null; + +const session = await joinSession({ + canvases: [ + createCanvas({ + id: "appmod-cockpit", + displayName: "Java Modernization Studio", + description: + "Drive the GitHub Copilot App Modernization for Java workflow: assessment, plan/progress, validation gates, and task/skill runs grounded in the repo's real artifacts.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + repoPath: { type: "string", description: "Absolute path to the Java repo to inspect." }, + initialTab: { type: "string", enum: ["overview", "readiness", "assessment", "plan", "validation", "tasks", "summary"] }, + }, + }, + actions: [ + { + name: "get_state", + description: + "Return the current modernization state snapshot scanned from the repo (assessment, plan/progress, gates, skills, tasks).", + handler: async (ctx) => { + const rec = instances.get(ctx.instanceId); + const repoPath = rec ? rec.repoPath : resolveRepoPath(ctx, lastWorkingDir); + return await scanRepo(repoPath); + }, + }, + { + name: "refresh", + description: "Re-scan the repo and push a fresh snapshot to the open cockpit.", + handler: async (ctx) => { + const rec = instances.get(ctx.instanceId); + if (!rec) throw new CanvasError("not_open", "Cockpit instance is not open."); + await pushState(rec, logFn); + return { ok: true, repoPath: rec.repoPath }; + }, + }, + ], + open: async (ctx) => { + const repoPath = resolveRepoPath(ctx, lastWorkingDir); + if (repoPath) lastWorkingDir = repoPath; // never clobber a good target with null + const initialTab = ctx.input && ctx.input.initialTab ? ctx.input.initialTab : "overview"; + let rec = instances.get(ctx.instanceId); + if (!rec) { + rec = await createInstanceServer({ + instanceId: ctx.instanceId, + repoPath, + initialTab, + sendPrompt: (prompt) => sessionRef.send({ prompt }), + runTurn: (prompt) => sessionRef.sendAndWait({ prompt }, AUTOPILOT_TURN_TIMEOUT_MS), + log: logFn, + }); + instances.set(ctx.instanceId, rec); + } else if (rec.repoPath !== repoPath) { + rec.repoPath = repoPath; // refresh target on re-open + rec.doctor = null; // env readiness depends on repo facts — recompute for the new target + } + return { + title: "Java Modernization Studio", + status: repoPath ? basename(repoPath) : "no repo", + url: rec.url, + }; + }, + onClose: async (ctx) => { + const rec = instances.get(ctx.instanceId); + if (rec) { + instances.delete(ctx.instanceId); + for (const res of rec.sseClients) { + try { + res.end(); + } catch { + /* ignore */ + } + } + await new Promise((resolve) => rec.server.close(() => resolve())); + } + }, + }), + ], +}); + +sessionRef = session; +function logFn(message, opts) { + try { + return sessionRef.log(message, opts); + } catch { + /* logging is best-effort */ + } +} + +if (session.workspacePath && !lastWorkingDir) lastWorkingDir = session.workspacePath; + +// A finished turn may have changed plan.md/progress.md/summary.md or the working +// tree — re-scan and push fresh state to every open cockpit. +session.on("session.idle", async () => { + for (const rec of instances.values()) { + await pushState(rec, logFn); + } +}); + +await session.log("Java Modernization Studio ready", { ephemeral: true }); diff --git a/plugins/java-modernization-studio/extensions/java-modernization-studio/package.json b/plugins/java-modernization-studio/extensions/java-modernization-studio/package.json new file mode 100644 index 00000000..4924f078 --- /dev/null +++ b/plugins/java-modernization-studio/extensions/java-modernization-studio/package.json @@ -0,0 +1,23 @@ +{ + "name": "java-modernization-studio", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "author": { + "name": "Ayan Gupta", + "url": "https://github.com/ayangupt" + }, + "dependencies": { + "@github/copilot-sdk": "1.0.4" + }, + "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.", + "keywords": [ + "java-modernization", + "app-modernization", + "azure-migration", + "legacy-java", + "assessment-dashboard", + "validation-gates", + "modernization-cockpit" + ] +} diff --git a/plugins/java-modernization-studio/extensions/java-modernization-studio/prompts.mjs b/plugins/java-modernization-studio/extensions/java-modernization-studio/prompts.mjs new file mode 100644 index 00000000..ffd5a4f5 --- /dev/null +++ b/plugins/java-modernization-studio/extensions/java-modernization-studio/prompts.mjs @@ -0,0 +1,155 @@ +// prompts.mjs — Maps each cockpit button action to a crafted agent instruction. +// Pure (no I/O, no session) so it can be unit-tested in isolation. + +import { PREDEFINED_TASKS } from "./catalog.mjs"; + +export const APPMOD_PREAMBLE = + "You are running the GitHub Copilot App Modernization for Java workflow. " + + "Keep plan.md and progress.md up to date as a checklist (use - [ ] / - [x]) and, when the work is complete, write summary.md. " + + "Start each of these files (plan.md, progress.md, summary.md) with the exact line as the very first line so the cockpit recognizes them as modernization artifacts."; + +export const ACTION_LABELS = { + start_assessment: "Assessment", + generate_plan: "Generate plan", + run_task: "Run task", + run_skill: "Run skill", + run_cve: "CVE scan", + generate_tests: "Generate tests", + run_build_tests: "Build & tests", + run_consistency: "Consistency check", + run_completeness: "Completeness check", + create_skill: "Create skill", + open_pr: "Open PR", + fix_finding: "Resolve finding", + work_step: "Work on step", + fix_env: "Environment setup", +}; + +/** + * Build the agent prompt for a cockpit action. + * @returns {string|null} prompt text, or null for unknown actions / unknown task ids. + */ +export function buildPrompt(kind, payload, repoPath) { + const p = payload || {}; + switch (kind) { + case "start_assessment": + return ( + APPMOD_PREAMBLE + + "\n\nRun an App Modernization assessment of this Java project at " + repoPath + ". " + + "Report the current Java runtime, build tool, framework versions, outdated/vulnerable dependencies, " + + "and cloud-readiness issues for Azure. Summarize findings and write a prioritized plan.md of remediation steps. Do not change code yet." + + "\n\nAlso write the findings as structured JSON to .appmod/assessment.json so the cockpit can render them. Use this shape:\n" + + "{\n" + + ' "generatedAt": "",\n' + + ' "headline": "",\n' + + ' "summary": "<2-3 sentence plain-language verdict>",\n' + + ' "stack": { "buildTool": "", "java": "", "framework": "", "database": "", "container": "" },\n' + + ' "findings": [\n' + + ' { "id": "kebab-id", "severity": "P0|P1|P2|P3", "title": "", "detail": "", "files": ["path"],\n' + + ' "action": { "kind": "run_task|generate_plan|run_cve|generate_tests|fix_finding", "payload": {}, "label": "" } }\n' + + " ],\n" + + ' "strengths": [""]\n' + + "}\n" + + "Order findings by severity (P0 first). For action.kind, use run_task with payload {\"taskId\":\"...\"} when a Microsoft predefined task fits, " + + "generate_plan/run_cve/generate_tests when those fit, otherwise use fix_finding (the cockpit turns it into a 'Help me fix this' button). Omit action only if there is genuinely nothing to do." + ); + case "generate_plan": + return ( + APPMOD_PREAMBLE + + "\n\nCreate a step-by-step modernization plan to upgrade this project to Java " + (p.targetJava || 21) + ". " + + "Cover build-file changes, removed/replaced APIs, dependency upgrades, and required test updates. " + + "Write the plan as a checklist in plan.md and create an empty progress.md mirroring those steps. Do not change code yet." + ); + case "run_task": { + const t = PREDEFINED_TASKS.find((x) => x.id === p.taskId); + if (!t) return null; + return ( + APPMOD_PREAMBLE + + "\n\nApply the App Modernization predefined task \"" + t.name + "\": " + t.summary + "\n" + + "Steps: (1) record the plan in plan.md and progress.md; (2) check out a migration branch; " + + "(3) make the code changes for this task; (4) validate by building, running unit tests, and a CVE check; " + + "(5) write a summary.md of what changed. Pause for my confirmation before committing." + ); + } + case "run_skill": + if (!p.folder) return null; + return ( + APPMOD_PREAMBLE + + "\n\nRun the custom modernization skill in .github/skills/" + p.folder + "/SKILL.md. " + + "Follow its instructions and referenced resources exactly, updating plan.md and progress.md as you go, " + + "and write summary.md when finished." + ); + case "run_cve": + return "Check this Java project for known CVE issues using #appmod-validate-cves-for-java and report which dependencies need upgrading, then update progress.md."; + case "generate_tests": + return "Generate unit tests for this Java project using #appmod-generate-tests-for-java, then run them and record the result in progress.md."; + case "run_build_tests": + return "Build this project and run its unit tests. Report failures with root-cause analysis and update the Build and Unit Tests entries in progress.md."; + case "run_consistency": + return "Run a consistency check comparing the modernized code against the original behavior (APIs, SQL, wire format). List any behavioral differences and update progress.md."; + case "run_completeness": + return "Run a completeness check: verify every step in plan.md has been implemented. List anything missing and update progress.md."; + case "create_skill": + return ( + "Help me create a custom App Modernization skill. Create .github/skills//SKILL.md with Skill Name, " + + "Description, and Content sections following the Agent Skills specification, plus any resource files it references. Ask me what migration this skill should capture." + ); + case "open_pr": + return "Summarize the modernization changes on the current branch and open a pull request with a clear title and a body that lists the plan steps completed and validation results."; + case "work_step": { + const title = (p.title || "").trim(); + if (!title) return null; + const phase = p.section ? " (phase: " + p.section + ")" : ""; + return ( + APPMOD_PREAMBLE + + "\n\nWork on this single step from the modernization checklist" + phase + ":\n\"" + title + "\"\n\n" + + "Do just this step: tell me what you'll change, make the code/config changes, then check it off in progress.md " + + "(change its - [ ] to - [x]) and add a one-line note of what changed. If the step needs a decision from me or is " + + "ambiguous, ask before proceeding. Pause for my confirmation before committing." + ); + } + case "auto_step": { + const title = (p.title || "").trim(); + if (!title) return null; + const phase = p.section ? " (phase: " + p.section + ")" : ""; + return ( + APPMOD_PREAMBLE + + "\n\nYou are running in AUTOPILOT, working the modernization checklist on your own. " + + "Do exactly ONE step now" + phase + ":\n\"" + title + "\"\n\n" + + "For this single step: briefly state what you will change, make the necessary code/config changes, " + + "then check it off in progress.md (change its - [ ] to - [x]) and add a one-line note of what changed. " + + "Do NOT commit, and do NOT start any other step. " + + "If this step genuinely needs a decision from me, or is blocked or ambiguous, do NOT guess: leave it unchecked, " + + "add a short note in progress.md describing exactly what you need, and stop so I can take over." + ); + } + case "fix_finding": { + const title = (p.title || "").trim(); + if (!title) return null; + const detail = (p.detail || "").trim(); + const files = Array.isArray(p.files) && p.files.length ? "\nAffected files: " + p.files.join(", ") + "." : ""; + const sev = p.severity ? " (" + p.severity + ")" : ""; + return ( + APPMOD_PREAMBLE + + "\n\nHelp me resolve this App Modernization assessment finding" + sev + ":\n" + + title + (detail ? " — " + detail : "") + "." + files + + "\n\nExplain the fix, make the change, and update the matching checklist items in plan.md and progress.md. " + + "Pause for my confirmation before committing." + ); + } + case "fix_env": { + const tool = (p.tool || "").trim(); + if (!tool) return null; + const detail = p.detail ? "\nWhat the readiness check found: " + p.detail + "." : ""; + const fix = p.fix ? "\nSuggested fix: " + p.fix : ""; + return ( + "I'm preparing my local environment for the GitHub Copilot App Modernization for Java workflow.\n" + + "The readiness check flagged a missing or misconfigured tool: " + tool + "." + detail + fix + + "\n\nWalk me through installing and configuring " + tool + " on my machine, including the exact commands for my OS " + + "and how to verify it's on PATH at the right version for this project. This is environment setup only — do not modify my application code." + ); + } + default: + return null; + } +} diff --git a/plugins/java-modernization-studio/extensions/java-modernization-studio/renderer.mjs b/plugins/java-modernization-studio/extensions/java-modernization-studio/renderer.mjs new file mode 100644 index 00000000..56fd94e4 --- /dev/null +++ b/plugins/java-modernization-studio/extensions/java-modernization-studio/renderer.mjs @@ -0,0 +1,1038 @@ +// renderer.mjs — Single-page cockpit UI served by each instance's loopback server. +// The client script is authored as a real function (clientMain) and serialized to +// a string at render time, so its own template literals / ${} are NOT evaluated by +// this module's template literal. Only CSS and a boot blob are interpolated. + +const CSS = ` +:root { color-scheme: light dark; } +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--background-color-default, #ffffff); + color: var(--text-color-default, #1f2328); + 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); +} +a { color: var(--true-color-blue, #0969da); } +.muted { color: var(--text-color-muted, #656d76); } +header.top { + position: sticky; top: 0; z-index: 5; + background: var(--background-color-default, #fff); + border-bottom: 1px solid var(--border-color-default, #d0d7de); + padding: 14px 18px 0; +} +.title-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +h1 { + font-size: var(--text-title-medium, 18px); + font-weight: var(--font-weight-semibold, 600); + margin: 0; letter-spacing: .2px; +} +.spacer { flex: 1; } +.repo { font-family: var(--font-mono, monospace); font-size: 12px; } +.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 12px; } +.chip { + display: inline-flex; align-items: center; gap: 5px; + border: 1px solid var(--border-color-default, #d0d7de); + border-radius: 999px; padding: 3px 10px; font-size: 12px; + background: var(--background-color-muted, #f6f8fa); +} +.chip b { font-weight: 600; } +.badge { border-radius: 999px; padding: 2px 9px; font-size: 11px; font-weight: 600; border: 1px solid transparent; white-space: nowrap; } +.b-green { color: #fff; background: var(--true-color-green, #1a7f37); } +.b-amber { color: #fff; background: var(--true-color-orange, #9a6700); } +.b-red { color: #fff; background: var(--true-color-red, #cf222e); } +.b-gray { color: var(--text-color-default,#1f2328); background: var(--background-color-muted,#eaeef2); border-color: var(--border-color-default,#d0d7de); } +.b-blue { color: #fff; background: var(--true-color-blue, #0969da); } +nav.tabs { display: flex; gap: 2px; margin-top: 6px; flex-wrap: wrap; } +nav.tabs button { + background: none; border: none; border-bottom: 2px solid transparent; + padding: 8px 12px; font-size: 13px; cursor: pointer; color: var(--text-color-muted,#656d76); + font-family: inherit; +} +nav.tabs button.active { color: var(--text-color-default,#1f2328); border-bottom-color: var(--true-color-blue,#0969da); font-weight: 600; } +nav.tabs button:hover { color: var(--text-color-default,#1f2328); border-bottom-color: var(--border-color-default,#d0d7de); } +nav.tabs button.active:hover { border-bottom-color: var(--true-color-blue,#0969da); } +nav.tabs button:focus-visible { outline: 2px solid var(--color-focus-outline, var(--true-color-blue,#0969da)); outline-offset: -2px; border-radius: 4px; } +nav.tabs button .count { font-size: 11px; color: var(--text-color-muted,#656d76); } +main { padding: 16px 18px 40px; max-width: 980px; } +.card { + border: 1px solid var(--border-color-default, #d0d7de); + border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; + background: var(--background-color-default, #fff); +} +.card h2 { font-size: 14px; margin: 0 0 10px; font-weight: 600; display: flex; align-items: center; gap: 8px; } +.card.relevant { border-color: var(--true-color-blue, #0969da); box-shadow: 0 0 0 1px var(--true-color-blue-muted, rgba(9,105,218,.25)); } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px,1fr)); gap: 10px; } +.kv { display: flex; flex-direction: column; gap: 2px; } +.kv .k { font-size: 11px; text-transform: uppercase; letter-spacing: .4px; color: var(--text-color-muted,#656d76); } +.kv .v { font-size: 14px; font-weight: 600; } +button.btn { + font-family: inherit; font-size: 12.5px; cursor: pointer; + border: 1px solid var(--border-color-default, #d0d7de); + background: var(--background-color-muted, #f6f8fa); + color: var(--text-color-default, #1f2328); + border-radius: 7px; padding: 6px 12px; +} +button.btn:not(.primary):not(:disabled):hover { + background: var(--background-color-muted, #eaeef2); + border-color: var(--border-color-muted, #afb8c1); + box-shadow: inset 0 0 0 999px rgba(140,149,159,.14); +} +button.btn.primary { background: var(--true-color-blue, #0969da); color: #fff; border-color: transparent; } +button.btn.primary:not(:disabled):hover { filter: brightness(1.08); box-shadow: 0 1px 2px rgba(31,35,40,.18); } +button.btn:focus-visible { outline: 2px solid var(--color-focus-outline, var(--true-color-blue, #0969da)); outline-offset: 1px; } +button.btn:disabled { opacity: .55; cursor: default; } +.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; } +.progress { height: 8px; border-radius: 999px; background: var(--background-color-muted,#eaeef2); overflow: hidden; } +.progress > span { display: block; height: 100%; background: var(--true-color-green,#1a7f37); } +ul.steps { list-style: none; margin: 0; padding: 0; } +ul.steps li { display: flex; align-items: flex-start; gap: 9px; padding: 6px 0; border-bottom: 1px solid var(--border-color-muted, #eaeef2); } +ul.steps li:last-child { border-bottom: none; } +.dot { width: 16px; height: 16px; border-radius: 50%; flex: 0 0 auto; margin-top: 2px; border: 2px solid; } +.dot.done { background: var(--true-color-green,#1a7f37); border-color: var(--true-color-green,#1a7f37); } +.dot.in_progress { background: var(--true-color-orange,#9a6700); border-color: var(--true-color-orange,#9a6700); } +.dot.failed { background: var(--true-color-red,#cf222e); border-color: var(--true-color-red,#cf222e); } +.dot.pending { background: transparent; border-color: var(--border-color-default,#8c959f); } +.task { display: flex; align-items: flex-start; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--border-color-muted,#eaeef2); } +.task:last-child { border-bottom: none; } +.task .body { flex: 1; } +.task .name { font-weight: 600; font-size: 13px; } +.task .sum { font-size: 12.5px; color: var(--text-color-muted,#656d76); margin-top: 2px; } +.cat { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-color-muted,#656d76); margin: 14px 0 4px; font-weight: 600; } +.empty { text-align: center; padding: 26px 16px; color: var(--text-color-muted,#656d76); } +.empty .big { font-size: 15px; color: var(--text-color-default,#1f2328); margin-bottom: 6px; font-weight: 600; } +.toast { + position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); + background: var(--true-color-blue,#0969da); color: #fff; padding: 9px 16px; + border-radius: 8px; font-size: 13px; opacity: 0; transition: opacity .2s; pointer-events: none; z-index: 20; + max-width: 90%; +} +.toast.show { opacity: 1; } +.banner { background: var(--true-color-blue-muted, #ddf4ff); border: 1px solid var(--true-color-blue,#0969da); color: var(--text-color-default,#1f2328); padding: 8px 12px; border-radius: 8px; font-size: 12.5px; margin-bottom: 12px; display: flex; gap: 8px; align-items: center; } +.md { font-size: 13px; line-height: 1.55; } +.md h1,.md h2,.md h3 { font-size: 15px; margin: 14px 0 6px; } +.md code { font-family: var(--font-mono,monospace); background: var(--background-color-muted,#f6f8fa); padding: 1px 4px; border-radius: 4px; font-size: 12px; } +.md ul { padding-left: 20px; } +.spin { width:13px; height:13px; border:2px solid currentColor; border-right-color:transparent; border-radius:50%; display:inline-block; animation: sp .7s linear infinite; } +@keyframes sp { to { transform: rotate(360deg); } } + +/* journey stepper */ +.stepper { display: flex; align-items: center; flex-wrap: wrap; gap: 2px; } +.stepper .step { display: flex; align-items: center; gap: 7px; } +.stepper .ix { width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--border-color-default,#8c959f); color: var(--text-color-muted,#656d76); display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; flex: 0 0 auto; } +.stepper .lbl { font-size: 12.5px; color: var(--text-color-muted,#656d76); } +.stepper .step.done .ix { background: var(--true-color-green,#1a7f37); border-color: var(--true-color-green,#1a7f37); color: #fff; } +.stepper .step.done .lbl { color: var(--text-color-default,#1f2328); } +.stepper .step.current .ix { border-color: var(--true-color-blue,#0969da); color: var(--true-color-blue,#0969da); } +.stepper .step.current .lbl { color: var(--text-color-default,#1f2328); font-weight: 600; } +.stepper .bar { width: 24px; height: 2px; background: var(--border-color-default,#d0d7de); margin: 0 5px; flex: 0 0 auto; } +.stepper .bar.done { background: var(--true-color-green,#1a7f37); } + +/* hero next-step */ +.hero { border: 1px solid var(--true-color-blue,#0969da); background: var(--true-color-blue-muted,#ddf4ff); border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; } +.hero .eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; font-weight: 700; color: var(--true-color-blue,#0969da); } +.hero .htitle { font-size: 16px; font-weight: 700; margin: 3px 0 4px; } +.hero .hbody { font-size: 13px; color: var(--text-color-default,#1f2328); margin: 0 0 10px; } + +/* findings */ +.sevrow { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } +.finding { border: 1px solid var(--border-color-default,#d0d7de); border-left-width: 4px; border-radius: 8px; padding: 11px 13px; margin-bottom: 9px; } +.finding.sev-P0 { border-left-color: var(--true-color-red,#cf222e); } +.finding.sev-P1 { border-left-color: var(--true-color-orange,#9a6700); } +.finding.sev-P2 { border-left-color: var(--true-color-blue,#0969da); } +.finding.sev-P3 { border-left-color: var(--border-color-default,#8c959f); } +.finding .ftitle { font-weight: 600; font-size: 13.5px; display: flex; align-items: center; gap: 8px; } +.finding .fdetail { font-size: 12.5px; color: var(--text-color-default,#1f2328); margin: 6px 0 0; } +.finding .ffiles { font-size: 11.5px; color: var(--text-color-muted,#656d76); margin-top: 7px; display: flex; flex-wrap: wrap; gap: 4px; } +.finding .ffiles code { font-family: var(--font-mono,monospace); background: var(--background-color-muted,#f6f8fa); padding: 1px 5px; border-radius: 4px; word-break: break-all; } + +/* plan & progress steps */ +.legend { display: flex; gap: 16px; margin-top: 11px; font-size: 11.5px; color: var(--text-color-muted,#656d76); } +.legend > span { display: inline-flex; align-items: center; } +.legend .dot { width: 12px; height: 12px; margin: 0 5px 0 0; } +ul.steps li { align-items: center; } +ul.steps li .stext { flex: 1; } +ul.steps li .srhs { flex: 0 0 auto; margin-left: 10px; } +.smuted { font-size: 11.5px; color: var(--text-color-muted,#656d76); white-space: nowrap; } +ul.steps li.locked .stext { opacity: .55; } +ul.steps li.locked .dot { opacity: .5; } +.card.locked { border-style: dashed; } +.card.locked > h2 { color: var(--text-color-muted,#656d76); } +.finding.locked { opacity: .72; } +.finding.locked .ftitle { opacity: .8; } +/* environment doctor / readiness checks */ +ul.checks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 9px; } +ul.checks li { display: flex; gap: 10px; align-items: flex-start; } +ul.checks .ci { width: 18px; height: 18px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; flex: 0 0 auto; margin-top: 1px; } +.chk.ok .ci { background: rgba(26,127,55,.15); color: var(--true-color-green,#1a7f37); } +.chk.warn .ci { background: rgba(154,103,0,.18); color: #9a6700; } +.chk.fail .ci { background: rgba(207,34,46,.15); color: var(--true-color-red,#cf222e); } +.chk.info .ci { background: var(--background-color-muted,#eaeef2); color: var(--text-color-muted,#656d76); } +ul.checks .cbody { display: flex; flex-direction: column; gap: 1px; flex: 1 1 auto; min-width: 0; } +ul.checks .clabel { font-weight: 600; } +ul.checks .cdetail { font-size: 12px; color: var(--text-color-muted,#656d76); } +ul.checks .cfix { font-size: 12px; color: #9a6700; margin-top: 2px; } +ul.checks .crhs { flex: 0 0 auto; } +.chk.fail .clabel { color: var(--true-color-red,#cf222e); } +.banner-red { border-color: var(--true-color-red,#cf222e); background: rgba(207,34,46,.08); } +nav.tabs button .count.c-red { color: var(--true-color-red,#cf222e); font-weight: 700; } +nav.tabs button .count.c-amber { color: #9a6700; font-weight: 700; } +.lockwhy { margin-right: 8px; } +/* recommended-approach guide */ +.card.guide { background: var(--background-color-muted, #f6f8fa); } +.flow { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; } +.flow .fstep { font-size: 12px; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--border-color-default,#d0d7de); background: var(--background-color-default,#fff); color: var(--text-color-muted,#656d76); } +.flow .fstep.on { border-color: var(--true-color-blue,#0969da); color: var(--true-color-blue,#0969da); font-weight: 600; } +.flow .fstep.past { color: var(--true-color-green,#1a7f37); border-color: var(--true-color-green,#1a7f37); } +.flow .farrow { color: var(--text-color-muted,#8c959f); font-size: 12px; } +/* autopilot */ +.banner-auto { border-color: var(--true-color-blue,#0969da); background: var(--true-color-blue-muted,#ddf4ff); align-items: center; } +.card.autopilot { border-color: var(--true-color-blue,#0969da); } +.badge.b-blue { background: var(--true-color-blue-muted,#ddf4ff); color: var(--true-color-blue,#0969da); } +.apc { font-size: 11.5px; color: var(--text-color-muted,#656d76); margin-left: 6px; } +ul.aplog { list-style: none; margin: 8px 0 12px; padding: 8px 12px; border: 1px solid var(--border-color-default,#d0d7de); border-radius: 8px; background: var(--background-color-muted,#f6f8fa); display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; } +ul.aplog li { display: block; } +.apok { color: var(--true-color-green,#1a7f37); font-weight: 700; } +.apno { color: var(--true-color-red,#cf222e); font-weight: 700; } +`; + +function clientMain() { + const boot = window.__APPMOD__ || {}; + // The host embeds a per-instance token in the iframe URL; echo it back on every + // request so the loopback server accepts us. Empty in unit harnesses (no guard). + const apiToken = boot.token || ""; + const tokenQuery = apiToken ? "?token=" + encodeURIComponent(apiToken) : ""; + let state = null; + let activeTab = boot.initialTab || "overview"; + let pending = null; + let autopilotRunning = false; + + const $ = (sel) => document.querySelector(sel); + const esc = (s) => + String(s == null ? "" : s).replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]) + ); + + function toast(msg) { + const t = $("#toast"); + t.textContent = msg; + t.classList.add("show"); + clearTimeout(toast._t); + toast._t = setTimeout(() => t.classList.remove("show"), 2600); + } + + function statusBadge(status) { + const map = { + completed: ["b-green", "Completed"], + in_progress: ["b-amber", "In progress"], + not_started: ["b-gray", "Not started"], + done: ["b-green", "Done"], + pending: ["b-gray", "Pending"], + failed: ["b-red", "Failed"], + not_run: ["b-gray", "Not run"], + pass: ["b-green", "Pass"], + }; + const [cls, label] = map[status] || ["b-gray", status || "—"]; + return '' + esc(label) + ""; + } + + function btn(label, kind, payload, opts) { + opts = opts || {}; + const cls = "btn" + (opts.primary ? " primary" : ""); + // While Autopilot runs, freeze manual actions so a stray click can't inject + // a competing prompt into the same session — except Stop and tab navigation. + const frozen = autopilotRunning && kind !== "autopilot_stop" && kind !== "autopilot_dismiss" && String(kind).indexOf("goto:") !== 0; + const dis = pending || frozen ? " disabled" : ""; + return ( + '" + ); + } + + // ---- tab renderers ------------------------------------------------------- + + function overviewTab(s) { + let html = ""; + + // Environment readiness nudge — surfaces a blocker before the user starts. + const d = s.doctor; + if (d && d.overall === "blocked") { + html += '"; + } else if (d && d.overall === "caution") { + html += '"; + } + + // Journey stepper — always shows where you are in the workflow. + html += '

Your modernization journey

' + journeyHtml(s) + "
"; + + // One adaptive "do this next" hero so the next click is never ambiguous. + const hero = heroNext(s); + html += + '
' + esc(hero.eyebrow) + "
" + + '
' + esc(hero.title) + "
" + + '
' + hero.body + "
" + + '
' + hero.actions + "
"; + + // Autopilot nudge — once there's a checklist with work left, offer to run it + // hands-free. Hidden while a run is already active (the strip covers that). + const planSteps = (s.progress && s.progress.steps.length ? s.progress.steps : (s.plan ? s.plan.steps : [])) || []; + const pendingSteps = planSteps.filter((x) => x.status !== "done").length; + const envBlocked = d && d.overall === "blocked"; + if (pendingSteps > 0 && !(s.autopilot && s.autopilot.running)) { + const cta = envBlocked + ? btn("Open Readiness →", "goto:readiness") + : btn("▶ Run on autopilot", "autopilot_start", { scope: "phase" }, { primary: true }) + btn("See in Plan →", "goto:plan"); + html += + '

⚡ Run on autopilot beta

' + + '

Instead of clicking each step, let Copilot work the ' + pendingSteps + + " remaining step(s) in order and update this dashboard live. It pauses at the end of the phase and does not commit.

" + + '
' + cta + "
"; + } + + // Findings snapshot (only once an assessment has been run). + const rep = s.report; + if (rep && rep.findings && rep.findings.length) { + const by = sevCounts(rep.findings); + html += '

Assessment findings ' + btn("Open Assessment →", "goto:assessment") + "

"; + if (rep.summary) html += '

' + esc(rep.summary) + "

"; + html += '
' + sevChip("P0", by.P0) + sevChip("P1", by.P1) + sevChip("P2", by.P2) + sevChip("P3", by.P3) + "
"; + } + + // Stack facts. + const a = s.assessment || {}; + const jv = a.javaVersion ? "Java " + a.javaVersion : "Java (unknown)"; + html += + '

Stack

' + + kv("Build tool", a.buildTool || "—") + + kv("Java version", esc(jv)) + + kv("Spring Boot", a.springBoot ? "Yes" + (a.springVersion ? " " + esc(a.springVersion) : "") : "No") + + kv("Container", a.hasDockerfile ? "Dockerfile ✓" : "None") + + kv("Git branch", s.git && s.git.branch ? esc(s.git.branch) : "—") + + kv("Working tree", s.git ? (s.git.dirty ? s.git.changedFiles + " changed" : "clean") : "—") + + "
"; + + const rel = (s.tasks || []).filter((t) => t.relevant); + if (rel.length) { + html += '

Detected in this repo ' + rel.length + "

"; + rel.forEach((t) => (html += taskRow(t))); + html += "
"; + } + return html; + } + + function assessmentTab(s) { + const rep = s.report; + if (!rep || !(rep.findings && rep.findings.length)) { + return emptyState( + "No assessment yet", + "Run an assessment to scan this repo for its Java runtime, dependencies, vulnerabilities, and Azure cloud-readiness gaps. Findings land here as a prioritized, clickable checklist — nothing in your code changes.", + btn("Run assessment", "start_assessment", {}, { primary: true }) + ); + } + const by = sevCounts(rep.findings); + let html = '

Assessment results'; + if (rep.generatedAt) html += ' ' + esc(fmtDate(rep.generatedAt)) + ""; + html += '' + btn("Re-run", "start_assessment") + "

"; + if (rep.headline) html += '

' + esc(rep.headline) + "

"; + if (rep.summary) html += '

' + esc(rep.summary) + "

"; + if (rep.stack) { + const st = rep.stack; + html += '
' + + (st.buildTool ? kv("Build", esc(st.buildTool)) : "") + + (st.java ? kv("Java", esc(st.java)) : "") + + (st.framework ? kv("Framework", esc(st.framework)) : "") + + (st.database ? kv("Database", esc(st.database)) : "") + + (st.container ? kv("Container", esc(st.container)) : "") + + "
"; + } + html += '
' + sevChip("P0", by.P0) + sevChip("P1", by.P1) + sevChip("P2", by.P2) + sevChip("P3", by.P3) + "
"; + + ["P0", "P1", "P2", "P3"].forEach((sev) => { + const items = rep.findings.filter((x) => x.severity === sev); + if (!items.length) return; + html += '
' + esc(sevName(sev)) + "
"; + items.forEach((x) => (html += findingCard(x, s.ordering))); + }); + + if (rep.strengths && rep.strengths.length) { + html += '

Already done well ' + rep.strengths.length + "

    "; + rep.strengths.forEach((x) => (html += '
  • ' + esc(x) + "
  • ")); + html += "
"; + } + return html; + } + + // ---- assessment helpers -------------------------------------------------- + + function journeyHtml(s) { + const labels = ["Assess", "Remediate", "Validate", "Ship"]; + const hasPlan = !!((s.report && s.report.findings) || (s.plan && s.plan.exists) || (s.progress && s.progress.exists)); + const gates = s.gates || {}; + const anyGate = Object.keys(gates).some((k) => gates[k] && gates[k] !== "not_run"); + const done = s.status === "completed"; + let cur; + if (done) cur = 4; + else if (anyGate) cur = 2; + else if (hasPlan) cur = 1; + else cur = 0; + let h = '
'; + labels.forEach((label, i) => { + if (i > 0) h += ''; + const cls = i < cur ? "done" : i === cur ? "current" : ""; + const mark = i < cur ? "✓" : String(i + 1); + h += '
' + mark + '' + esc(label) + "
"; + }); + return h + "
"; + } + + function heroNext(s) { + const rep = s.report; + const findings = rep && rep.findings ? rep.findings : []; + const hasReport = findings.length > 0; + const p0 = findings.filter((f) => f.severity === "P0"); + const p1 = findings.filter((f) => f.severity === "P1"); + const gates = s.gates || {}; + const anyGate = Object.keys(gates).some((k) => gates[k] && gates[k] !== "not_run"); + const hasPlan = !!((s.plan && s.plan.exists) || (s.progress && s.progress.exists)); + + if (s.status === "completed") { + return { + eyebrow: "Final step", + title: "Ship your changes", + body: "Validations are complete. Open a pull request to hand off the modernized service.", + actions: btn("Open a pull request", "open_pr", {}, { primary: true }) + btn("View summary", "goto:summary"), + }; + } + if (!hasReport && !hasPlan) { + return { + eyebrow: "Start here", + title: "Assess the project", + body: "Scan the repo for its Java runtime, dependencies, known CVEs, and Azure cloud-readiness gaps. Nothing changes in your code — you get a prioritized list of findings to work through.", + actions: btn("Run assessment", "start_assessment", {}, { primary: true }), + }; + } + if (p0.length) { + return { + eyebrow: "Blocker — fix this first", + title: p0[0].title, + body: esc(p0[0].detail || "A P0 issue is blocking modernization. Resolve it before moving on."), + actions: heroAction(p0[0]) + btn("See all findings", "goto:assessment"), + }; + } + if (hasReport && !anyGate) { + if (p1.length) { + return { + eyebrow: "Recommended next", + title: "Start remediation: " + p1[0].title, + body: esc(p1[0].detail || "Work through the high-priority findings first."), + actions: heroAction(p1[0]) + btn("See all findings", "goto:assessment"), + }; + } + return { + eyebrow: "Recommended next", + title: "Work through the findings", + body: "Open the Assessment tab and resolve findings by priority. Each one has a button that hands the fix to the agent.", + actions: btn("Open Assessment", "goto:assessment", {}, { primary: true }), + }; + } + return { + eyebrow: "Recommended next", + title: "Validate your changes", + body: "Build the project, run unit tests, and re-check CVEs to confirm the migration holds.", + actions: btn("Build & run tests", "run_build_tests", {}, { primary: true }) + btn("Open Validation", "goto:validation"), + }; + } + + function heroAction(f) { + const act = f.action; + if (act && act.kind && act.kind !== "fix_finding") { + return btn(act.label || "Run this step", act.kind, act.payload || {}, { primary: true }); + } + return btn((act && act.label) || "Help me fix this", "fix_finding", findingCtx(f), { primary: true }); + } + + function findingCard(x, ord) { + const locked = isFindingLocked(x, ord); + let h = '
'; + h += '
' + esc(x.severity || "—") + "" + esc(x.title) + "
"; + if (x.detail) h += '
' + esc(x.detail) + "
"; + if (x.files && x.files.length) { + h += '
' + x.files.map((p) => "" + esc(p) + "").join("") + "
"; + } + h += '
' + findingActions(x, ord) + "
"; + return h + "
"; + } + + function sevRank(sev) { + const m = { P0: 1, P1: 2, P2: 3, P3: 4 }; + return m[sev] != null ? m[sev] : null; + } + function isFindingLocked(x, ord) { + if (!ord || ord.activeRank == null) return false; + const r = sevRank(x.severity); + return r != null && r > ord.activeRank; + } + + function findingActions(x, ord) { + const act = x.action; + if (isFindingLocked(x, ord)) { + const why = '🔒 fix ' + esc(shortPhase(ord.activePhase)) + " issues first"; + if (act && act.kind && act.kind !== "fix_finding") { + return why + btn(act.label || "Run this step", act.kind, act.payload || {}) + btn("Help me fix this", "fix_finding", findingCtx(x)); + } + return why + btn((act && act.label) || "Help me fix this", "fix_finding", findingCtx(x)); + } + if (act && act.kind && act.kind !== "fix_finding") { + return btn(act.label || "Run this step", act.kind, act.payload || {}, { primary: true }) + btn("Help me fix this", "fix_finding", findingCtx(x)); + } + return btn((act && act.label) || "Help me fix this", "fix_finding", findingCtx(x), { primary: true }); + } + + function findingCtx(x) { + return { title: x.title, detail: x.detail, files: x.files, severity: x.severity }; + } + function sevCounts(f) { + const c = { P0: 0, P1: 0, P2: 0, P3: 0 }; + (f || []).forEach((x) => { if (c[x.severity] != null) c[x.severity]++; }); + return c; + } + function sevBadgeClass(sev) { + return { P0: "b-red", P1: "b-amber", P2: "b-blue", P3: "b-gray" }[sev] || "b-gray"; + } + function sevChip(sev, n) { + return '' + esc(sev) + " · " + (n || 0) + ""; + } + function sevName(sev) { + return { P0: "P0 · Blockers", P1: "P1 · High priority", P2: "P2 · Medium priority", P3: "P3 · Low / platform" }[sev] || sev; + } + function fmtDate(iso) { + try { + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + " " + d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + } catch (e) { + return iso; + } + } + + // ---- autopilot ----------------------------------------------------------- + + // Live status strip for an Autopilot run, shown above the active tab body. + function autopilotStrip(s) { + const a = s && s.autopilot; + if (!a) return ""; + if (a.running) { + const scopeLabel = a.scope === "all" ? "all phases" : "this phase"; + const now = a.current + ? "Working: " + esc(a.current.title) + (a.current.section ? ' ' + esc(a.current.section) + "" : "") + : "Selecting the next step…"; + return ( + '" + autopilotLog(a) + ); + } + const labels = { + completed: "finished — every eligible step is done", + phase_done: "finished this phase", + stuck: "paused — a step needs your input", + cancelled: "stopped", + capped: "reached its step limit", + error: "stopped on an error", + }; + const label = labels[a.status] || "finished"; + const bad = a.status === "error" || a.status === "stuck"; + let note = "Review the changes (nothing was committed), then continue."; + if (a.stuck) note = "Stuck on: " + esc(a.stuck) + ". Open it in Plan & Progress to finish it yourself, then resume."; + if (a.error) note = esc(a.error); + const more = + btn("▶ Continue (next phase)", "autopilot_start", { scope: "phase" }, { primary: true }) + + btn("Dismiss", "autopilot_dismiss", {}); + return ( + '" + autopilotLog(a) + ); + } + + function autopilotLog(a) { + if (!a || !a.completed || !a.completed.length) return ""; + let h = '
    '; + a.completed.forEach((c) => { + h += + "
  • " + (c.done ? '' : '') + + " " + esc(c.title) + + (c.section ? ' ' + esc(c.section) + "" : "") + + (c.error ? ' ' + esc(c.error) + "" : "") + + "
  • "; + }); + return h + "
"; + } + + // Autopilot launch card on the Plan tab. + function autopilotCard(s) { + const running = !!(s.autopilot && s.autopilot.running); + const blocked = s.doctor && s.doctor.overall === "blocked"; + let body = + '

Let Copilot work the checklist for you. Autopilot runs each eligible step in order, ' + + "updates progress.md, and streams results here live. It pauses at the end of the phase, stops if a step needs a decision, " + + "and does not commit — so you stay in control and can review the diff.

"; + let actions; + if (running) { + actions = btn("■ Stop autopilot", "autopilot_stop", {}); + } else if (blocked) { + actions = 'Environment is not ready. ' + btn("Open Readiness →", "goto:readiness"); + } else { + actions = + btn("▶ Run this phase", "autopilot_start", { scope: "phase" }, { primary: true }) + + btn("Run all phases", "autopilot_start", { scope: "all" }); + } + return '

⚡ Autopilot beta

' + body + '
' + actions + "
"; + } + + function planTab(s) { + const usingProgress = !!(s.progress && s.progress.steps.length); + const steps = (usingProgress ? s.progress.steps : (s.plan ? s.plan.steps : [])) || []; + if (!steps.length) { + return emptyState( + "No plan yet", + "Run an assessment or generate an upgrade plan. App Modernization writes plan.md and a progress.md checklist, and your steps land here — each unchecked one becomes a button that hands that exact step to the agent.", + btn("Run assessment", "start_assessment", {}, { primary: true }) + btn("Generate upgrade plan", "generate_plan", { targetJava: 21 }) + ); + } + const src = usingProgress ? "progress.md" : "plan.md"; + const total = steps.length; + const done = steps.filter((x) => x.status === "done").length; + const ord = s.ordering || { activeRank: null, activePhase: null }; + + // Intro + overall progress + legend. + let html = '

Plan & Progress ' + esc(src) + "" + statusBadge(s.status) + "

"; + html += '

Your live modernization checklist. Each unchecked step has a Work on this button that hands just that step to the agent in this session — it checks the box here when the step is done.

'; + html += '
'; + html += '

' + done + " of " + total + " steps done · " + (s.percent || 0) + "% complete

"; + html += '
DoneIn progressTo do🔒 Do later
'; + html += "
"; + + // Recommended-order guide so users don't work steps out of sequence. + html += approachGuide(ord); + + // Autopilot: hand the whole phase to the agent instead of clicking each step. + html += autopilotCard(s); + + // "Continue here" — the recommended next step: first not-done step in the + // active phase (falls back to the first not-done step overall). + const next = steps.find((x) => x.status !== "done" && (ord.activeRank == null || x.rank === ord.activeRank)) || steps.find((x) => x.status !== "done"); + if (next) { + html += + '
Continue here · recommended next step
' + + '
' + esc(next.title) + "
" + + (next.section ? '
Phase: ' + esc(next.section) + "
" : "") + + '
' + stepAction(next) + (next.section && /validation|gates/i.test(next.section) ? btn("Open Validation", "goto:validation") : "") + "
"; + } else { + html += ''; + } + + // Steps grouped by their phase/section. + const groups = []; + const idx = {}; + steps.forEach((st) => { + const key = st.section || "Steps"; + if (idx[key] == null) { idx[key] = groups.length; groups.push({ name: key, items: [], rank: st.rank }); } + groups[idx[key]].items.push(st); + }); + + groups.forEach((g) => { + const gdone = g.items.filter((x) => x.status === "done").length; + const isValidation = /validation|gates/i.test(g.name); + const gLocked = ord.activeRank != null && g.rank != null && g.rank > ord.activeRank; + html += '

' + (gLocked ? "🔒 " : "") + esc(g.name) + ' ' + gdone + "/" + g.items.length + ""; + if (isValidation) html += '' + btn("Open Validation →", "goto:validation"); + else if (gLocked) html += 'after ' + esc(shortPhase(ord.activePhase)) + ""; + html += '

    '; + g.items.forEach((st) => { + const locked = ord.activeRank != null && st.rank != null && st.rank > ord.activeRank; + html += + '' + + '' + esc(st.title) + "" + + '' + stepRowAction(st, isValidation, locked, ord) + ""; + }); + html += "
"; + }); + + return html; + } + + function approachGuide(ord) { + const order = ["Assessment", "P0 Build", "P1 Security", "P2 Runtime", "P3 Observability", "Validate"]; + const activeIdx = ord && ord.activeRank != null ? ord.activeRank : -1; + const flow = order + .map((name, i) => '' + esc(name) + "") + .join(''); + let body = "Work top-down and finish each phase before the next — e.g. don't upgrade the Java runtime (P2) while the build is still broken (P0). Steps in later phases are marked 🔒 until the current phase is done; Continue here always points to the safe next step."; + if (ord && ord.activePhase) body += " You're in " + esc(ord.activePhase) + "."; + return '

Recommended approach

' + flow + "

" + body + "

"; + } + function shortPhase(name) { + if (!name) return "the current phase"; + const m = String(name).match(/\bP[0-3]\b/i); + return m ? m[0].toUpperCase() : name; + } + + function stepAction(st) { + const label = st.status === "in_progress" ? "Resume this step" : "Work on this step"; + return btn(label, "work_step", { title: st.title, section: st.section }, { primary: true }); + } + function stepRowAction(st, isValidation, locked, ord) { + if (st.status === "done") return '✓ Done'; + if (isValidation) return 'run from Validation →'; + if (locked) { + return '🔒 after ' + esc(shortPhase(ord && ord.activePhase)) + "" + btn("Do anyway", "work_step", { title: st.title, section: st.section }); + } + const label = st.status === "in_progress" ? "Resume" : "Work on this"; + return btn(label, "work_step", { title: st.title, section: st.section }); + } + + function validationTab(s) { + const gates = s.gates || {}; + const labels = { build: "Build", tests: "Unit Tests", cve: "CVE Check", consistency: "Consistency", completeness: "Completeness" }; + let html = '

Validation gates

'; + Object.keys(labels).forEach((k) => { + html += '
' + esc(labels[k]) + "" + statusBadge(gates[k] || "not_run") + "
"; + }); + html += "
"; + html += + '

Run validations

' + + btn("Build & unit tests", "run_build_tests", {}, { primary: true }) + + btn("Scan CVEs", "run_cve") + + btn("Generate unit tests", "generate_tests") + + btn("Consistency check", "run_consistency") + + btn("Completeness check", "run_completeness") + + "

CVE scan uses #appmod-validate-cves-for-java; test generation uses #appmod-generate-tests-for-java.

"; + return html; + } + + function doctorTab(s) { + const d = s.doctor; + if (!d) { + return emptyState( + "Environment readiness", + "Check that your machine has the tools the App Modernization workflow needs — a JDK, your build tool, git, and (optionally) Docker and the Azure CLI. This only reads version numbers; nothing is installed or changed.", + btn("Check my environment", "recheck_env", {}, { primary: true }) + ); + } + const tone = d.overall === "ready" ? "b-green" : d.overall === "caution" ? "b-amber" : "b-red"; + const head = + d.overall === "ready" ? "Your environment is ready" : + d.overall === "caution" ? "Almost ready — a couple of things to check" : + "Not ready yet — resolve the blockers below"; + const sub = + d.overall === "ready" ? "All required tools are installed. You're clear to start modernizing." : + d.overall === "caution" ? "The required tools are present; some optional or recommended items need attention." : + "One or more required tools are missing. Fix these before building the project or running tasks."; + + const notAssessed = d.groups.some((g) => g.checks.some((c) => c.id === "assessed" && c.status !== "ok")); + let heroActions = btn("Re-check environment", "recheck_env", {}, { primary: true }); + if (notAssessed) heroActions += btn("Run assessment", "start_assessment", {}); + + let html = + '
Environment readiness
' + + '
' + esc(head) + ' ' + esc(d.overall) + "
" + + '
' + esc(sub) + "
" + + '
' + heroActions + "
"; + + d.groups.forEach((g) => { + html += '

' + esc(g.name) + "

    "; + g.checks.forEach((c) => { + html += + '
  • ' + + '' + checkIcon(c.status) + "" + + '' + esc(c.label) + "" + + '' + esc(c.detail || "") + "" + + (c.fix ? 'Fix: ' + esc(c.fix) + "" : "") + + "" + + '' + checkAction(c) + "
  • "; + }); + html += "
"; + }); + + html += '

Probed locally on your machine — version numbers only. Nothing was installed or modified.

'; + return html; + } + + function checkIcon(status) { + if (status === "ok") return "✓"; + if (status === "warn") return "!"; + if (status === "fail") return "✕"; + return "•"; + } + function checkAction(c) { + if (!c.action) return ""; + const opts = c.status === "fail" ? { primary: true } : {}; + return btn(c.action.label || "Fix", c.action.kind, c.action.payload || {}, opts); + } + + function tasksTab(s) { + let html = ""; + // Custom skills + html += '

Custom skills .github/skills

'; + if (!s.skills || !s.skills.length) { + html += '

No custom skills found. Capture an org-specific migration as a reusable skill.

' + + '
' + btn("Create a custom skill", "create_skill") + "
"; + } else { + s.skills.forEach((sk) => { + html += + '
' + esc(sk.name) + "
" + + '
' + esc(sk.description || sk.folder) + "
" + + '
' + btn("Run", "run_skill", { folder: sk.folder }, { primary: true }) + "
"; + }); + } + html += "
"; + + // Predefined catalog grouped by category, relevant first + const tasks = (s.tasks || []).slice().sort((a, b) => (b.relevant ? 1 : 0) - (a.relevant ? 1 : 0)); + const cats = {}; + tasks.forEach((t) => { + (cats[t.category] = cats[t.category] || []).push(t); + }); + html += '

Microsoft predefined tasks

'; + Object.keys(cats).forEach((cat) => { + html += '
' + esc(cat) + "
"; + cats[cat].forEach((t) => (html += taskRow(t))); + }); + html += "
"; + return html; + } + + function summaryTab(s) { + let html = ""; + if (s.summary && s.summary.exists && s.summary.markdown) { + html += '

summary.md

' + miniMd(s.summary.markdown) + "
"; + } else { + html += emptyState("No summary yet", "App Modernization writes summary.md after validations pass.", ""); + } + const g = s.git || {}; + html += + '

Branch & PR

' + + kv("Branch", g.branch ? esc(g.branch) : "—") + + kv("Migration branch", g.isMigrationBranch ? "Yes" : "No") + + kv("Changed files", g.changedFiles != null ? String(g.changedFiles) : "—") + + "
" + + btn("Open a pull request", "open_pr", {}, { primary: true }) + + btn("Refresh", "refresh") + + "
"; + return html; + } + + // ---- helpers ------------------------------------------------------------- + + function nextTarget(v) { + const ladder = [11, 17, 21, 25]; + for (const t of ladder) if (t > v) return t; + return 25; + } + function kv(k, v) { + return '
' + esc(k) + '' + v + "
"; + } + function taskRow(t) { + return ( + '
' + esc(t.name) + + (t.relevant ? ' relevant' : "") + + '
' + esc(t.summary) + "
" + + "
" + btn("Run", "run_task", { taskId: t.id }) + "
" + ); + } + function emptyState(big, sub, action) { + return '
' + esc(big) + "
" + sub + "
" + (action ? '
' + action + "
" : "") + "
"; + } + function miniMd(md) { + const lines = esc(md).split(/\r?\n/); + let out = ""; + let inList = false; + for (let line of lines) { + if (/^\s*[-*]\s+/.test(line)) { + if (!inList) { out += "
    "; inList = true; } + out += "
  • " + line.replace(/^\s*[-*]\s+/, "") + "
  • "; + continue; + } + if (inList) { out += "
"; inList = false; } + const h = line.match(/^(#{1,3})\s+(.*)/); + if (h) { out += "" + h[2] + ""; continue; } + if (line.trim() === "") { out += "
"; continue; } + out += "

" + line + "

"; + } + if (inList) out += ""; + return out.replace(/`([^`]+)`/g, "$1"); + } + + // ---- shell --------------------------------------------------------------- + + function render() { + if (!state) { + $("#app").innerHTML = '
Loading…
'; + return; + } + if (!state.ok) { + $("#app").innerHTML = emptyState("Repo not available", esc(state.error || "Could not read the repository."), btn("Retry", "refresh", {}, { primary: true })); + renderHeader(); + return; + } + renderHeader(); + autopilotRunning = !!(state.autopilot && state.autopilot.running); + const tabs = { overview: overviewTab, readiness: doctorTab, assessment: assessmentTab, plan: planTab, validation: validationTab, tasks: tasksTab, summary: summaryTab }; + let body = ""; + try { + body = autopilotStrip(state); + body += pending ? '" : ""; + body += (tabs[activeTab] || overviewTab)(state); + } catch (e) { + // A tab renderer threw — show the error instead of silently leaving the + // previous tab's content on screen (which looks like a dead click). + body = '"; + } + $("#app").innerHTML = body; + } + + function renderHeader() { + const s = state || {}; + const a = s.assessment || {}; + $("#status").innerHTML = s.status ? statusBadge(s.status) : ""; + $("#repo").textContent = s.repoPath ? s.repoPath.split(/[\\/]/).slice(-2).join("/") : ""; + const chips = $("#chips"); + if (!s.ok) { chips.innerHTML = ""; } + else { + chips.innerHTML = [ + a.buildTool ? '' + esc(a.buildTool) + "" : "", + a.javaVersion ? 'Java ' + esc(a.javaVersion) + "" : "", + a.springBoot ? 'Spring Boot' : "", + a.hasDockerfile ? 'Docker' : "", + s.git && s.git.branch ? '' + esc(s.git.branch) + "" : "", + ].join(""); + } + const counts = { + assessment: s.report && s.report.findings ? s.report.findings.length : 0, + plan: s.progress && s.progress.steps.length ? s.progress.steps.length : (s.plan ? s.plan.steps.length : 0), + tasks: (s.tasks ? s.tasks.length : 0) + (s.skills ? s.skills.length : 0), + }; + document.querySelectorAll("nav.tabs button").forEach((b) => { + b.classList.toggle("active", b.dataset.tab === activeTab); + const c = b.querySelector(".count"); + if (!c) return; + if (b.dataset.tab === "readiness") { + const ov = s.doctor && s.doctor.overall; + c.textContent = ov === "blocked" ? "⚠" : ov === "caution" ? "!" : ""; + c.className = "count " + (ov === "blocked" ? "c-red" : ov === "caution" ? "c-amber" : ""); + } else { + c.textContent = counts[b.dataset.tab] ? "(" + counts[b.dataset.tab] + ")" : ""; + } + }); + } + + async function loadState() { + try { + const r = await fetch("/state" + tokenQuery); + state = await r.json(); + render(); + } catch (e) { + toast("Failed to load state"); + } + } + + async function doAction(kind, payload) { + if (kind === "refresh") { toast("Refreshing…"); await loadState(); return; } + if (kind.indexOf("goto:") === 0) { activeTab = kind.slice(5); render(); window.scrollTo(0, 0); return; } + // Autopilot controls stream their results over SSE, so they must not set the + // blocking "pending" banner that freezes the whole view for a one-shot send. + const isAutopilot = kind.indexOf("autopilot_") === 0; + try { + const r = await fetch("/action" + tokenQuery, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind, payload }), + }); + const res = await r.json(); + if (res.ok) { + if (res.state) { + // Local, synchronous actions (refresh, recheck_env) hand back a + // fresh snapshot inline. Apply it now and clear any spinner instead + // of waiting on an SSE broadcast that can be missed if the provider + // restarts or the connection drops — which would spin forever. + state = res.state; + pending = null; + } else if (!isAutopilot) { + pending = res.label || kind; + } + toast(res.message || "Sent to the agent"); + render(); + } else { + toast(res.error || "Action failed"); + } + } catch (e) { + toast("Action failed"); + } + } + + // event delegation for all action buttons + tabs + document.addEventListener("click", (e) => { + const tabBtn = e.target.closest("nav.tabs button"); + if (tabBtn) { activeTab = tabBtn.dataset.tab; render(); return; } + const b = e.target.closest("[data-kind]"); + if (b && !b.disabled) { + let payload = {}; + try { payload = JSON.parse(b.dataset.payload || "{}"); } catch {} + doAction(b.dataset.kind, payload); + } + }); + + // live updates: server pushes a fresh snapshot when the agent finishes a turn + try { + const ev = new EventSource("/events" + tokenQuery); + // Resync whenever the stream (re)connects. If the provider restarted while an + // action was in flight, its "done" broadcast was lost; re-fetching state here + // clears any stuck spinner and shows the real result. + ev.onopen = () => { loadState(); }; + ev.onmessage = (m) => { + try { + const data = JSON.parse(m.data); + if (data && data.type === "state") { state = data.state; pending = null; render(); } + else if (data && data.type === "autopilot" && state) { state.autopilot = data.autopilot; render(); } + } catch {} + }; + } catch {} + + render(); + loadState(); +} + +export function renderHtml({ instanceId, initialTab, token } = {}) { + // Escape `<` so a stray "" in any field can't break out of the inline + // " + + "" + ); +} diff --git a/plugins/java-modernization-studio/extensions/java-modernization-studio/scan.mjs b/plugins/java-modernization-studio/extensions/java-modernization-studio/scan.mjs new file mode 100644 index 00000000..60f8b1a4 --- /dev/null +++ b/plugins/java-modernization-studio/extensions/java-modernization-studio/scan.mjs @@ -0,0 +1,571 @@ +// scan.mjs — Grounds the cockpit in the real repository. Reads App Modernization +// artifacts (plan.md / progress.md / summary.md), discovers custom skills under +// .github/skills/, extracts assessment facts from Maven/Gradle build files, and +// reports git branch state. Everything degrades gracefully when files are absent. + +import { readFile, readdir, stat, open } from "node:fs/promises"; +import { join } from "node:path"; +import { execFile } from "node:child_process"; +import { catalogWithRelevance, VALIDATION_GATES } from "./catalog.mjs"; + +// Dependency signatures: substring(s) searched in build files -> catalog `detect` key. +const DEP_SIGNATURES = { + rabbitmq: ["rabbitmq", "spring-rabbit", "amqp-client", "spring-amqp", "starter-amqp", "amqp"], + activemq: ["activemq"], + jms: ["javax.jms", "jakarta.jms", "spring-jms"], + awsS3: ["aws-java-sdk-s3", "s3", "software.amazon.awssdk"], + awsSqs: ["aws-java-sdk-sqs", "sqs"], + awsSecrets: ["secretsmanager"], + javamail: ["javax.mail", "jakarta.mail", "com.sun.mail", "spring-boot-starter-mail"], + ldap: ["spring-ldap", "spring-security-ldap", "unboundid-ldapsdk", "ldaptive"], + cache: ["infinispan", "swarmcache", "memcached", "spymemcached", "ehcache"], + oracle: ["ojdbc", "oracle"], + db2: ["db2jcc", "com.ibm.db2"], + sybase: ["jconn", "sybase"], + informix: ["informix"], + jdbc: ["jdbc", "hikari", "mysql", "postgresql", "mssql-jdbc", "sqlserver"], + keystore: ["keystore", ".jks", "javax.net.ssl"], + crypto: ["javax.crypto", "java.security", "bouncycastle", "bcprov"], + filelog: ["fileappender", "rollingfileappender", "logback", "log4j"], +}; + +// Cap how much of any single file we read. Build files and modernization docs are +// tiny; an unbounded read on a pathological repo (a giant generated XML, a vendored +// blob) would waste memory for no parsing benefit. Truncating is safe — every +// parser here scans for early markers/coordinates. +const MAX_READ_BYTES = 2 * 1024 * 1024; + +function execGit(args, cwd) { + return new Promise((resolve) => { + execFile("git", args, { cwd, timeout: 4000 }, (err, stdout) => { + if (err) resolve(null); + else resolve(String(stdout).trim()); + }); + }); +} + +async function readText(path) { + try { + const st = await stat(path); + if (!st.isFile()) return null; + if (st.size > MAX_READ_BYTES) { + const fh = await open(path, "r"); + try { + const buf = Buffer.alloc(MAX_READ_BYTES); + const { bytesRead } = await fh.read(buf, 0, MAX_READ_BYTES, 0); + return buf.toString("utf8", 0, bytesRead); + } finally { + await fh.close(); + } + } + return await readFile(path, "utf8"); + } catch { + return null; + } +} + +async function exists(path) { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +async function isDirectory(path) { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +// Explicit provenance marker the cockpit writes at the top of artifacts it owns, +// so a root plan/progress/summary self-identifies as modernization state even +// when there is no .appmod/ directory yet. +const APPMOD_MARKER_RE = / marker. This stops an unrelated summary.md from + // flipping a foreign repo to "completed", or a stray plan.md from injecting + // fake steps into the progress/ordering/autopilot machinery. + const hasAppmodArtifact = + reportRaw != null || apPlan != null || apProgress != null || apSummary != null; + const trusted = (root) => hasAppmodArtifact || hasMarker(root); + const planMd = apPlan || (trusted(rootPlan) ? rootPlan : null); + const progressMd = apProgress || (trusted(rootProgress) ? rootProgress : null); + const summaryMd = apSummary || (trusted(rootSummary) ? rootSummary : null); + + const assessment = await buildAssessment(repoPath); + const skills = await discoverSkills(repoPath); + + const planSteps = parseSteps(planMd); + const progressSteps = parseSteps(progressMd); + // Tag each step with its canonical phase rank for ordering guidance. + for (const arr of [planSteps, progressSteps]) { + for (const st of arr) st.rank = phaseRankFromName(st.section); + } + // Progress is the source of truth for status; fall back to plan steps. + const steps = progressSteps.length ? progressSteps : planSteps; + const percent = percentDone(steps); + const ordering = computeOrdering(steps); + // Prefer an explicit "Validation"/"gates" section for gate status; fall back + // to scanning all steps when no such section exists. + const gateSteps = + sectionSteps(progressMd, /validation|gates/i) || + sectionSteps(summaryMd, /validation|gates/i) || + steps; + const gates = deriveGates(gateSteps, summaryMd); + + let status = "not_started"; + if (summaryMd) status = "completed"; + else if (planMd || progressMd) status = "in_progress"; + + let git = null; + if (includeGit) { + const branch = await execGit(["rev-parse", "--abbrev-ref", "HEAD"], repoPath); + const dirty = await execGit(["status", "--porcelain"], repoPath); + git = { + branch, + isMigrationBranch: !!branch && /moderniz|migrat|upgrade|appmod/i.test(branch), + dirty: dirty === null ? null : dirty.length > 0, + changedFiles: dirty ? dirty.split(/\r?\n/).filter(Boolean).length : 0, + }; + } + + return { + ok: true, + repoPath, + scannedAt: new Date().toISOString(), + status, + percent, + assessment, + tasks: catalogWithRelevance(assessment.detectedKeys), + skills, + git, + plan: { exists: !!planMd, steps: planSteps }, + progress: { exists: !!progressMd, steps: progressSteps }, + summary: { exists: !!summaryMd, markdown: summaryMd || "" }, + report, + ordering, + gates, + }; +} diff --git a/plugins/java-modernization-studio/extensions/java-modernization-studio/server.mjs b/plugins/java-modernization-studio/extensions/java-modernization-studio/server.mjs new file mode 100644 index 00000000..af2e2275 --- /dev/null +++ b/plugins/java-modernization-studio/extensions/java-modernization-studio/server.mjs @@ -0,0 +1,334 @@ +// server.mjs — Per-instance loopback HTTP server + action dispatch for the cockpit. +// Decoupled from the SDK: agent messaging is injected as `sendPrompt`, so this +// module can be exercised by tests without a live session. + +import { createServer } from "node:http"; +import { randomBytes } from "node:crypto"; +import { scanRepo } from "./scan.mjs"; +import { renderHtml } from "./renderer.mjs"; +import { buildPrompt, ACTION_LABELS } from "./prompts.mjs"; +import { runDoctor } from "./doctor.mjs"; +import { makeRun, runAutopilot, AUTOPILOT_MAX_STEPS } from "./autopilot.mjs"; + +/** Resolve which repo path an open/action context refers to, by precedence. */ +export function resolveRepoPath(ctx, lastWorkingDir) { + const fromInput = + ctx && ctx.input && typeof ctx.input.repoPath === "string" ? ctx.input.repoPath : null; + const fromSession = + ctx && ctx.session && ctx.session.workingDirectory ? ctx.session.workingDirectory : null; + // No process.cwd() fallback: that resolves to the extension's own directory, + // not the user's repo, and would make the cockpit scan unrelated files. When + // nothing resolves we return null and the UI shows a "repo not available" + // state instead of plausible-but-wrong data. + return fromInput || fromSession || lastWorkingDir || null; +} + +/** + * Scan the repo and attach the environment readiness report. Tool probing is + * expensive (spawns several processes), so the doctor result is cached on the + * instance record and only recomputed on first build or an explicit recheck — + * not on every per-turn refresh. Probing only runs when `rec.runDoctor` is wired + * (real instances), so unit tests stay fast and deterministic. + */ +export async function buildState(rec, { recheckEnv = false } = {}) { + const state = await scanRepo(rec.repoPath); + if (state && state.ok && typeof rec.runDoctor === "function") { + if (recheckEnv || rec.doctor == null) { + try { + rec.doctor = await rec.runDoctor(state); + } catch { + rec.doctor = rec.doctor || null; + } + } + state.doctor = rec.doctor; + } else if (state && state.ok) { + state.doctor = rec.doctor || null; + } + if (state && state.ok) state.autopilot = rec.autopilot || null; + return state; +} + +/** Write an SSE payload to every connected client of an instance. */ +export function broadcast(rec, payload) { + const data = "data: " + JSON.stringify(payload) + "\n\n"; + for (const res of rec.sseClients) { + try { + res.write(data); + } catch { + // Client disconnected uncleanly: drop it so we don't keep throwing on + // every future broadcast (dead clients would otherwise leak in the Set). + rec.sseClients.delete(res); + } + } +} + +/** Re-scan the repo and push the fresh snapshot to connected clients. */ +export async function pushState(rec, log, opts) { + let state; + try { + state = await buildState(rec, opts); + } catch (e) { + if (log) log("appmod-cockpit scan failed: " + e.message, { level: "error" }); + return null; + } + broadcast(rec, { type: "state", state }); + return state; +} + +/** Clamp a requested step budget into a sane range. */ +function clampSteps(n) { + const v = Number(n); + if (!Number.isFinite(v)) return AUTOPILOT_MAX_STEPS; + return Math.max(1, Math.min(50, Math.round(v))); +} + +/** + * Kick off an Autopilot run for this instance. Returns immediately; the run loop + * streams progress to connected clients over SSE. The run promise is parked on + * the record so it is not garbage-collected mid-flight. + * @param {object} rec instance record + * @param {object} payload { scope?: "phase"|"all", maxSteps?, force? } + * @param {{ runTurn:(p:string)=>Promise, log?:Function }} deps + */ +export async function startAutopilot(rec, payload, deps) { + if (rec.autopilot && rec.autopilot.running) { + return { ok: false, error: "Autopilot is already running." }; + } + if (!deps || typeof deps.runTurn !== "function") { + return { ok: false, error: "This session can't drive Autopilot." }; + } + const p = payload || {}; + const start = await buildState(rec); + if (!start || !start.ok) { + return { ok: false, error: "Can't read the repo to start Autopilot." }; + } + if (start.doctor && start.doctor.overall === "blocked" && !p.force) { + return { ok: false, error: "Your environment isn't ready. Open Readiness and install the missing tools first." }; + } + const scope = p.scope === "all" ? "all" : "phase"; + const startRank = start.ordering ? start.ordering.activeRank : null; + const run = makeRun({ scope, maxSteps: clampSteps(p.maxSteps), startRank }); + rec.autopilot = run; + broadcast(rec, { type: "autopilot", autopilot: run }); + + const runDeps = { + snapshot: () => buildState(rec), + runTurn: deps.runTurn, + buildStepPrompt: (step) => buildPrompt("auto_step", { title: step.title, section: step.section }, rec.repoPath), + onProgress: (r, state) => { + if (state) broadcast(rec, { type: "state", state }); + else broadcast(rec, { type: "autopilot", autopilot: r }); + }, + log: deps.log, + }; + rec.autopilotPromise = runAutopilot(run, runDeps) + .then(() => pushState(rec, deps.log)) + .catch((e) => { + if (deps.log) deps.log("Autopilot run failed: " + e.message, { level: "error" }); + }); + return { ok: true, message: "Autopilot started", autopilot: run }; +} + +/** + * Handle a cockpit action. + * @param {object} rec instance record ({ repoPath, sseClients, ... }) + * @param {string} kind action kind + * @param {object} payload action payload + * @param {{ sendPrompt:(p:string)=>Promise, runTurn?:Function, log?:Function }} deps + */ +export async function dispatchAction(rec, kind, payload, deps) { + if (kind === "refresh") { + const state = await pushState(rec, deps && deps.log); + return { ok: true, message: "Refreshed", state }; + } + if (kind === "recheck_env") { + const state = await pushState(rec, deps && deps.log, { recheckEnv: true }); + return { ok: true, message: "Re-checked environment", state }; + } + if (kind === "autopilot_start") { + return await startAutopilot(rec, payload, deps); + } + if (kind === "autopilot_stop") { + if (rec.autopilot && rec.autopilot.running) { + rec.autopilot.cancelled = true; + broadcast(rec, { type: "autopilot", autopilot: rec.autopilot }); + return { ok: true, message: "Stopping after the current step finishes…" }; + } + return { ok: false, error: "Autopilot isn't running." }; + } + if (kind === "autopilot_dismiss") { + if (rec.autopilot && !rec.autopilot.running) { + rec.autopilot = null; + await pushState(rec, deps && deps.log); + } + return { ok: true, message: "Dismissed" }; + } + const prompt = buildPrompt(kind, payload, rec.repoPath); + if (!prompt) return { ok: false, error: "Unknown action: " + kind }; + if (!deps || typeof deps.sendPrompt !== "function") { + return { ok: false, error: "Session not ready" }; + } + const label = ACTION_LABELS[kind] || kind; + try { + await deps.sendPrompt(prompt); + if (deps.log) deps.log("Java Modernization Studio → " + label, { ephemeral: true }); + return { ok: true, label, message: "Sent to the agent: " + label, prompt }; + } catch (e) { + return { ok: false, error: e.message }; + } +} + +// Cap the request body the /action endpoint will buffer. Actions carry tiny JSON +// payloads; anything large is a bug or abuse, so we reject rather than buffer it. +const MAX_BODY_BYTES = 256 * 1024; + +function readBody(req, max = MAX_BODY_BYTES) { + return new Promise((resolve) => { + let data = ""; + let size = 0; + let done = false; + req.on("data", (c) => { + if (done) return; + size += c.length; + if (size > max) { + done = true; + resolve({ body: "", tooLarge: true }); + if (typeof req.destroy === "function") req.destroy(); + return; + } + data += c; + }); + req.on("end", () => { + if (!done) { + done = true; + resolve({ body: data, tooLarge: false }); + } + }); + req.on("error", () => { + if (!done) { + done = true; + resolve({ body: "", tooLarge: false }); + } + }); + }); +} + +/** + * Build the request handler for an instance. Exposed for tests so routes can be + * exercised without binding a socket. + */ +export function makeHandler(rec, { instanceId, initialTab, sendPrompt, runTurn, log }) { + return async function handler(req, res) { + const url = new URL(req.url, "http://127.0.0.1"); + try { + // Per-instance secret: real instances (createInstanceServer) mint a token + // that the host embeds in the iframe URL. Reject any loopback request that + // doesn't present it, so other local processes can't read repo state or + // dispatch agent actions just by guessing the random port. When no token is + // set (direct makeHandler unit tests) the guard is a no-op. + if (rec.token) { + const provided = url.searchParams.get("token"); + if (provided !== rec.token) { + res.statusCode = 403; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: false, error: "forbidden" })); + return; + } + } + if (req.method === "GET" && url.pathname === "/") { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderHtml({ instanceId, initialTab, token: rec.token })); + return; + } + if (req.method === "GET" && url.pathname === "/state") { + const state = await buildState(rec); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(state)); + return; + } + if (req.method === "GET" && url.pathname === "/events") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + res.write("retry: 3000\n\n"); + rec.sseClients.add(res); + req.on("close", () => rec.sseClients.delete(res)); + return; + } + if (req.method === "POST" && url.pathname === "/action") { + const { body, tooLarge } = await readBody(req); + if (tooLarge) { + res.statusCode = 413; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: false, error: "request body too large" })); + return; + } + let parsed; + try { + parsed = JSON.parse(body || "{}"); + } catch { + res.statusCode = 400; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: false, error: "invalid JSON body" })); + return; + } + if (!parsed || typeof parsed.kind !== "string" || !parsed.kind) { + res.statusCode = 400; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: false, error: "missing or invalid 'kind'" })); + return; + } + const result = await dispatchAction(rec, parsed.kind, parsed.payload, { sendPrompt, runTurn, log }); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(result)); + return; + } + res.statusCode = 404; + res.end("not found"); + } catch (e) { + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + } + res.end(JSON.stringify({ ok: false, error: e.message })); + } + }; +} + +/** + * Start a loopback server for one canvas instance. + * @returns {Promise} rec with { server, url, repoPath, sseClients } + */ +export async function createInstanceServer({ instanceId, repoPath, initialTab, sendPrompt, runTurn, log, runDoctor: runDoctorImpl }) { + const rec = { server: null, url: "", repoPath, sseClients: new Set(), doctor: null, autopilot: null, token: randomBytes(16).toString("hex") }; + // Wire the environment doctor (injectable for tests); real instances probe the + // local toolchain, cached and only recomputed on explicit recheck. + rec.runDoctor = typeof runDoctorImpl === "function" ? runDoctorImpl : (scan) => runDoctor(scan, {}); + const handler = makeHandler(rec, { instanceId, initialTab, sendPrompt, runTurn, log }); + const server = createServer((req, res) => { + // The handler is async: if it rejects, surface a 500 and log it rather than + // letting it become an unhandled rejection that can crash the extension. + handler(req, res).catch((err) => { + if (log) log("Java Modernization Studio server error: " + (err && err.message ? err.message : err), { level: "error" }); + try { + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: false, error: "internal error" })); + } else { + res.end(); + } + } catch { + /* response already torn down */ + } + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + rec.server = server; + rec.url = "http://127.0.0.1:" + port + "/?token=" + rec.token; + return rec; +} diff --git a/plugins/java-modernization-studio/extensions/java-modernization-studio/test/cockpit.test.mjs b/plugins/java-modernization-studio/extensions/java-modernization-studio/test/cockpit.test.mjs new file mode 100644 index 00000000..c85207d4 --- /dev/null +++ b/plugins/java-modernization-studio/extensions/java-modernization-studio/test/cockpit.test.mjs @@ -0,0 +1,1662 @@ +// cockpit.test.mjs — Test suite for the Java Modernization Studio canvas. +// Run with: node --test test/cockpit.test.mjs +// +// Covers the testable modules (scan, catalog, prompts, renderer, server). It does +// NOT import extension.mjs, which calls joinSession() at module load and needs a +// live CLI session. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { execFileSync } from "node:child_process"; +import { EventEmitter } from "node:events"; + +import { scanRepo } from "../scan.mjs"; +import { PREDEFINED_TASKS, catalogWithRelevance, VALIDATION_GATES } from "../catalog.mjs"; +import { buildPrompt, ACTION_LABELS } from "../prompts.mjs"; +import { renderHtml } from "../renderer.mjs"; +import { + buildDoctorReport, + parseToolVersion, + probeOne, + runDoctor, + augmentedEnv, + discoverJavaHome, + parseJavaHomeFromRc, + TOOL_PROBES, +} from "../doctor.mjs"; +import { + resolveRepoPath, + broadcast, + pushState, + dispatchAction, + makeHandler, + createInstanceServer, +} from "../server.mjs"; +import { + selectNextStep, + stepKey, + isStepDone, + makeRun, + runAutopilot, +} from "../autopilot.mjs"; + +// ---- helpers ---------------------------------------------------------------- + +// Minimal provenance: a .appmod/ artifact tells the scanner this repo is actively +// running the modernization workflow, so root plan/progress/summary are trusted as +// workflow state (vs. unrelated files that merely share those names). +const PROV = { ".appmod/assessment.json": "{}" }; + +async function makeRepo(files = {}) { + const dir = await mkdtemp(join(tmpdir(), "appmod-test-")); + for (const [rel, content] of Object.entries(files)) { + const full = join(dir, rel); + await mkdir(dirname(full), { recursive: true }); + await writeFile(full, content); + } + return dir; +} + +async function withRepo(files, fn) { + const dir = await makeRepo(files); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function git(dir, ...args) { + execFileSync("git", args, { cwd: dir, stdio: "pipe" }); +} + +function fakeRes() { + return { + statusCode: 200, + headers: {}, + body: "", + ended: false, + setHeader(k, v) { + this.headers[String(k).toLowerCase()] = v; + }, + writeHead(code, hdrs) { + this.statusCode = code; + if (hdrs) for (const k of Object.keys(hdrs)) this.headers[k.toLowerCase()] = hdrs[k]; + }, + write(s) { + this.body += s; + }, + end(s) { + if (s !== undefined) this.body += s; + this.ended = true; + }, + }; +} + +// Drive makeHandler with a fake POST request stream. +async function invokePost(handler, path, payloadObj) { + const req = new EventEmitter(); + req.method = "POST"; + req.url = path; + const res = fakeRes(); + const pending = handler(req, res); + req.emit("data", JSON.stringify(payloadObj)); + req.emit("end"); + await pending; + return res; +} + +async function invokeGet(handler, path) { + const req = new EventEmitter(); + req.method = "GET"; + req.url = path; + const res = fakeRes(); + await handler(req, res); + return { req, res }; +} + +// =========================================================================== +// scan.mjs +// =========================================================================== + +test("scan: nonexistent path returns ok:false", async () => { + const s = await scanRepo("/no/such/path/at/all/xyz"); + assert.equal(s.ok, false); +}); + +test("scan: empty repo is not_started with all gates not_run", async () => { + await withRepo({}, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.ok, true); + assert.equal(s.status, "not_started"); + assert.equal(s.percent, 0); + assert.equal(s.plan.exists, false); + assert.equal(s.progress.exists, false); + assert.equal(s.summary.exists, false); + for (const g of VALIDATION_GATES) assert.equal(s.gates[g.key], "not_run"); + }); +}); + +test("scan: Maven Java version from properties", async () => { + await withRepo( + { "pom.xml": "8" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.buildTool, "Maven"); + assert.equal(s.assessment.javaVersion, "8"); + } + ); +}); + +test("scan: Maven compiler release strips 1.x and detects", async () => { + await withRepo( + { "pom.xml": "17" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.javaVersion, "17"); + } + ); +}); + +test("scan: Gradle build tool + sourceCompatibility", async () => { + await withRepo( + { "build.gradle": "sourceCompatibility = '11'\napply plugin: 'java'" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.buildTool, "Gradle"); + assert.equal(s.assessment.javaVersion, "11"); + } + ); +}); + +test("scan: nested module pom is discovered", async () => { + await withRepo( + { "service/pom.xml": "8" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.buildTool, "Maven"); + assert.equal(s.assessment.javaVersion, "8"); + } + ); +}); + +test("scan: Spring Boot detection", async () => { + await withRepo( + { "pom.xml": "spring-boot-starter-web" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.springBoot, true); + } + ); +}); + +test("scan: Dockerfile detected at root and one level down", async () => { + await withRepo({ Dockerfile: "FROM tomcat:9" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.hasDockerfile, true); + }); + await withRepo({ "svc/Dockerfile": "FROM tomcat:9" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.hasDockerfile, true); + }); +}); + +test("scan: dependency relevance flags the right catalog tasks", async () => { + const cases = [ + ["spring-boot-starter-amqp", "rabbitmq-to-servicebus"], + ["activemq-client", "activemq-to-servicebus"], + ["aws-java-sdk-s3", "aws-s3-to-blob"], + ["ojdbc8", "databases-to-azure"], + ["spring-ldap-core", "entra-id-auth"], + ]; + for (const [dep, taskId] of cases) { + await withRepo( + { "pom.xml": `${dep}` }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + const task = s.tasks.find((t) => t.id === taskId); + assert.ok(task, `task ${taskId} exists`); + assert.equal(task.relevant, true, `${dep} -> ${taskId} relevant`); + } + ); + } +}); + +test("scan: checklist statuses (done / in_progress / pending)", async () => { + await withRepo( + { ...PROV, "progress.md": "- [x] Done step\n- [/] Working step\n- [ ] Todo step\n" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + const byTitle = Object.fromEntries(s.progress.steps.map((x) => [x.title, x.status])); + assert.equal(byTitle["Done step"], "done"); + assert.equal(byTitle["Working step"], "in_progress"); + assert.equal(byTitle["Todo step"], "pending"); + } + ); +}); + +test("scan: emoji status markers override", async () => { + await withRepo( + { ...PROV, "progress.md": "- [ ] Build ✅\n- [ ] Tests ❌\n" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + const byTitle = Object.fromEntries( + s.progress.steps.map((x) => [x.title.replace(/[^A-Za-z ].*/, "").trim(), x.status]) + ); + assert.equal(byTitle["Build"], "done"); + assert.equal(byTitle["Tests"], "failed"); + } + ); +}); + +test("scan: numbered-list fallback when no checkboxes", async () => { + await withRepo({ ...PROV, "plan.md": "# Plan\n1. First thing\n2. Second thing\n" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.plan.steps.length, 2); + assert.equal(s.plan.steps[0].title, "First thing"); + }); +}); + +test("scan: heading fallback when no checkboxes or numbers", async () => { + await withRepo({ ...PROV, "plan.md": "# Title\n## Phase one\n## Phase two\n" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + const titles = s.plan.steps.map((x) => x.title); + assert.deepEqual(titles, ["Phase one", "Phase two"]); + }); +}); + +test("scan: percent counts in_progress as half", async () => { + await withRepo( + { ...PROV, "progress.md": "- [x] a\n- [/] b\n- [ ] c\n- [ ] d\n" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + // (1 + 0.5) / 4 = 37.5 -> 38 + assert.equal(s.percent, 38); + } + ); +}); + +test("scan: gates derived from matching step titles", async () => { + await withRepo( + { ...PROV, "progress.md": "- [x] Unit Tests pass\n- [/] Build app\n- [ ] CVE scan\n" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.gates.tests, "done"); + assert.equal(s.gates.build, "in_progress"); + assert.equal(s.gates.cve, "pending"); // matched but not run + assert.equal(s.gates.consistency, "not_run"); // unmatched + } + ); +}); + +test("scan: gates scope to an explicit Validation section, not assessment text", async () => { + // Assessment rows mention "build" and "vulnerable" (done); they must NOT + // satisfy the Build/CVE gates, which live in the Validation section (pending). + const md = [ + "## Assessment", + "- [x] Inventory runtime, build tool, dependencies", + "- [x] Identify vulnerable dependencies", + "", + "## Validation gates", + "- [ ] Build — mvn clean package succeeds", + "- [ ] CVE Check — no critical CVEs", + "", + "## Next", + "- [x] something else with the word build in it", + ].join("\n"); + await withRepo({ ...PROV, "progress.md": md }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.gates.build, "pending"); + assert.equal(s.gates.cve, "pending"); + }); +}); + +test("scan: status is completed when summary.md present", async () => { + await withRepo({ ...PROV, "summary.md": "# done" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.status, "completed"); + }); +}); + +test("scan: status is in_progress when only plan present", async () => { + await withRepo({ ...PROV, "plan.md": "- [ ] a" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.status, "in_progress"); + }); +}); + +test("scan: reads .appmod/assessment.json into report", async () => { + const report = { + generatedAt: "2026-01-01T00:00:00Z", + headline: "h", + findings: [{ id: "x", severity: "P0", title: "t", detail: "d", files: ["a"], action: { kind: "fix_finding" } }], + }; + await withRepo({ ".appmod/assessment.json": JSON.stringify(report) }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.ok(s.report, "report should be present"); + assert.equal(s.report.findings.length, 1); + assert.equal(s.report.findings[0].severity, "P0"); + }); +}); + +test("scan: report is null when assessment.json absent or malformed", async () => { + await withRepo({ "pom.xml": "" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.report, null); + }); + await withRepo({ ".appmod/assessment.json": "{ not valid json" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.report, null, "malformed JSON should not throw, just yield null"); + }); +}); + +// ---- provenance gate (cross-repo safety) ------------------------------------ + +test("scan: an unrelated summary.md without provenance does NOT mark completed", async () => { + await withRepo({ "summary.md": "# My library\nUsage and changelog notes." }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.notEqual(s.status, "completed", "a stray summary.md must not flip status to completed"); + assert.equal(s.status, "not_started"); + assert.equal(s.summary.exists, false, "untrusted summary is treated as absent"); + }); +}); + +test("scan: an unrelated plan.md without provenance is not treated as workflow steps", async () => { + await withRepo({ "plan.md": "# Roadmap\n- [ ] ship feature x\n- [ ] write docs\n" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.plan.steps.length, 0, "stray checkboxes must not become modernization steps"); + assert.equal(s.status, "not_started"); + }); +}); + +test("scan: assessment.json provenance makes a root progress.md trusted", async () => { + await withRepo( + { ".appmod/assessment.json": "{}", "progress.md": "## P0\n- [x] a\n- [ ] b\n" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.progress.steps.length, 2); + assert.equal(s.status, "in_progress"); + } + ); +}); + +test("scan: an explicit appmod marker makes a root plan trusted without .appmod/", async () => { + await withRepo( + { "plan.md": "\n## P0 Build\n- [ ] Fix build\n" }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.plan.steps.length, 1, "marked file is trusted"); + assert.equal(s.status, "in_progress"); + } + ); +}); + +test("scan: .appmod/ namespaced docs are trusted and take precedence over root", async () => { + await withRepo( + { + ".appmod/plan.md": "## P0\n- [ ] from appmod\n", + "plan.md": "## P0\n- [ ] from root\n- [ ] root two\n", + }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.plan.steps.length, 1, "uses .appmod/plan.md, not the root copy"); + assert.equal(s.plan.steps[0].title, "from appmod"); + } + ); +}); + +test("scan: a non-directory path is reported unavailable (not scanned)", async () => { + await withRepo({ "pom.xml": "" }, async (dir) => { + const s = await scanRepo(join(dir, "pom.xml"), { includeGit: false }); + assert.equal(s.ok, false, "a file path is not a repo"); + }); +}); + +test("scan: steps carry their section heading", async () => { + const md = "# Plan\n## P0 — Build\n- [x] a\n- [ ] b\n## P1 — Secrets\n- [ ] c\n"; + await withRepo({ ...PROV, "progress.md": md }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + const byTitle = Object.fromEntries(s.progress.steps.map((x) => [x.title, x.section])); + assert.equal(byTitle["a"], "P0 — Build"); + assert.equal(byTitle["b"], "P0 — Build"); + assert.equal(byTitle["c"], "P1 — Secrets"); + }); +}); + +test("scan: ordering computes the active phase, ranks, and per-phase counts", async () => { + const md = + "# Plan\n## Assessment\n- [x] a1\n- [x] a2\n" + + "## P0 — Build consistency\n- [x] b1\n- [ ] b2\n" + + "## P1 — Secrets & identity\n- [ ] c1\n- [ ] c2\n" + + "## Validation gates\n- [ ] v1\n"; + await withRepo({ ...PROV, "progress.md": md }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.ordering.activeRank, 1, "P0 is the active rank"); + assert.match(s.ordering.activePhase, /P0/); + // Per-phase roll-up. + const byName = Object.fromEntries(s.ordering.phases.map((p) => [p.name, p])); + assert.equal(byName["Assessment"].done, 2); + assert.equal(byName["Assessment"].total, 2); + assert.equal(byName["P0 — Build consistency"].done, 1); + assert.equal(byName["P0 — Build consistency"].total, 2); + assert.equal(byName["P1 — Secrets & identity"].done, 0); + // Each step carries a phase rank; P1 steps sit ahead of the active P0. + const byTitle = Object.fromEntries(s.progress.steps.map((x) => [x.title, x.rank])); + assert.equal(byTitle["a1"], 0); + assert.equal(byTitle["b2"], 1); + assert.equal(byTitle["c1"], 2); + assert.ok(byTitle["c1"] > s.ordering.activeRank, "P1 step is ahead of sequence (locked)"); + }); +}); + +test("scan: ordering activeRank is null once every ranked step is done", async () => { + const md = "# Plan\n## Assessment\n- [x] a\n## P0 — Build\n- [x] b\n"; + await withRepo({ ...PROV, "progress.md": md }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.ordering.activeRank, null); + assert.equal(s.ordering.activePhase, null); + }); +}); + + +test("scan: discovers custom skills from .github/skills", async () => { + await withRepo( + { + ".github/skills/my-skill/SKILL.md": "name: My Skill\ndescription: Does a thing\n", + ".github/skills/other/SKILL.md": "# Other Skill\n", + }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.skills.length, 2); + const mine = s.skills.find((x) => x.folder === "my-skill"); + assert.equal(mine.name, "My Skill"); + assert.equal(mine.description, "Does a thing"); + } + ); +}); + +test("scan: git branch + migration detection + dirty flag", async () => { + await withRepo({ "pom.xml": "", "README.md": "x" }, async (dir) => { + git(dir, "init", "-b", "modernize-java21"); + git(dir, "config", "user.email", "t@example.com"); + git(dir, "config", "user.name", "Test"); + git(dir, "add", "."); + git(dir, "commit", "-m", "init", "--no-gpg-sign"); + + let s = await scanRepo(dir); + assert.equal(s.git.branch, "modernize-java21"); + assert.equal(s.git.isMigrationBranch, true); + assert.equal(s.git.dirty, false); + + await writeFile(join(dir, "new.txt"), "hi"); + s = await scanRepo(dir); + assert.equal(s.git.dirty, true); + assert.equal(s.git.changedFiles, 1); + }); +}); + +// =========================================================================== +// doctor.mjs (environment readiness) +// =========================================================================== + +function fakeExec(map) { + // map: cmd -> { stdout?, stderr?, error? } (missing cmd => ENOENT) + return async (cmd) => { + const r = map[cmd]; + if (!r) return { error: Object.assign(new Error("not found"), { code: "ENOENT" }), stdout: "", stderr: "" }; + return { error: r.error || null, stdout: r.stdout || "", stderr: r.stderr || "" }; + }; +} + +test("doctor: parseToolVersion handles java (stderr), node, and generic output", () => { + assert.equal(parseToolVersion("java", 'openjdk version "17.0.2" 2022-01-18'), "17.0.2"); + assert.equal(parseToolVersion("node", "v22.3.0"), "22.3.0"); + assert.equal(parseToolVersion("git", "git version 2.39.5"), "2.39.5"); + assert.equal(parseToolVersion("x", ""), null); +}); + +test("doctor: probeOne reports not-found on ENOENT and found otherwise", async () => { + const exec = fakeExec({ java: { stderr: 'openjdk version "21.0.1"' } }); + const found = await probeOne({ key: "java", cmd: "java", args: ["-version"] }, exec); + assert.deepEqual(found, { key: "java", found: true, version: "21.0.1" }); + const missing = await probeOne({ key: "mvn", cmd: "mvn", args: ["-v"] }, exec); + assert.equal(missing.found, false); +}); + +test("doctor: probeOne rejects the macOS java stub (non-zero exit, no version)", async () => { + const stub = async () => ({ + error: Object.assign(new Error("exit 1"), { code: 1 }), + stdout: "", + stderr: "The operation couldn't be completed. Unable to locate a Java Runtime.", + }); + const r = await probeOne({ key: "java", cmd: "java", args: ["-version"] }, stub); + assert.equal(r.found, false, "stub with no real version is treated as not installed"); +}); + +test("doctor: missing JDK + missing Maven (no wrapper) blocks readiness", () => { + const rep = buildDoctorReport({ + probes: { git: { found: true, version: "2.39" } }, + scan: { ok: true, assessment: { buildTool: "Maven", hasMavenWrapper: false }, git: { branch: "main" }, plan: {}, progress: {} }, + }); + assert.equal(rep.overall, "blocked"); + const checks = rep.groups.flatMap((g) => g.checks); + const jdk = checks.find((c) => c.id === "jdk"); + assert.equal(jdk.status, "fail"); + assert.ok(jdk.action && jdk.action.kind === "fix_env", "JDK failure offers a fix_env action"); + const build = checks.find((c) => c.id === "build"); + assert.equal(build.status, "fail"); +}); + +test("doctor: a Maven wrapper satisfies the build-tool check without mvn installed", () => { + const rep = buildDoctorReport({ + probes: { java: { found: true, version: "17" }, git: { found: true } }, + scan: { ok: true, assessment: { buildTool: "Maven", hasMavenWrapper: true }, git: { branch: "x" }, progress: { exists: true } }, + }); + const build = rep.groups.flatMap((g) => g.checks).find((c) => c.id === "build"); + assert.equal(build.status, "ok"); + assert.match(build.detail, /wrapper/i); +}); + +test("doctor: a Dockerfile without Docker is a caution (warn), not a blocker", () => { + const rep = buildDoctorReport({ + probes: { java: { found: true }, mvn: { found: true }, git: { found: true } }, + scan: { ok: true, assessment: { buildTool: "Maven", hasDockerfile: true }, git: { branch: "x" }, progress: { exists: true } }, + }); + const docker = rep.groups.flatMap((g) => g.checks).find((c) => c.id === "docker"); + assert.equal(docker.status, "warn"); + assert.equal(rep.overall, "caution"); +}); + +test("doctor: no assessment yet surfaces a start_assessment action", () => { + const rep = buildDoctorReport({ + probes: { java: { found: true }, mvn: { found: true }, git: { found: true } }, + scan: { ok: true, assessment: { buildTool: "Maven" }, git: { branch: "x" }, plan: { exists: false }, progress: { exists: false } }, + }); + const assessed = rep.groups.flatMap((g) => g.checks).find((c) => c.id === "assessed"); + assert.equal(assessed.status, "warn"); + assert.equal(assessed.action.kind, "start_assessment"); +}); + +test("doctor: everything present is ready", () => { + const rep = buildDoctorReport({ + probes: { + java: { found: true, version: "21" }, mvn: { found: true }, git: { found: true }, + docker: { found: true }, az: { found: true }, azd: { found: true }, node: { found: true }, + }, + scan: { ok: true, assessment: { buildTool: "Maven" }, git: { branch: "modernize", isMigrationBranch: true }, progress: { exists: true }, skills: [{ folder: "a" }] }, + }); + assert.equal(rep.overall, "ready"); +}); + +test("doctor: runDoctor probes every tool via the injected exec", async () => { + const exec = fakeExec({ + java: { stderr: 'openjdk version "17.0.9"' }, + git: { stdout: "git version 2.40.0" }, + }); + const rep = await runDoctor( + { ok: true, assessment: { buildTool: "Maven" }, git: { branch: "x" }, progress: { exists: true } }, + { exec } + ); + assert.ok(rep.groups.length >= 3); + assert.equal(TOOL_PROBES.length, 8); +}); + +test("doctor: augmentedEnv covers Homebrew, keg JDKs, SDKMAN, and JAVA_HOME (macOS)", () => { + // GUI apps inherit a minimal PATH; the doctor must still find tools the user's + // terminal sees. Inject a directory lister so the test doesn't hit the filesystem. + const lister = (d) => (d === "/opt/homebrew/opt" ? ["openjdk@21", "openjdk", "maven", "node@20"] : []); + const env = augmentedEnv({ PATH: "/usr/bin:/bin", HOME: "/Users/x", JAVA_HOME: "/jh" }, "darwin", lister); + const parts = env.PATH.split(":"); + assert.ok(parts.includes("/opt/homebrew/bin"), "Homebrew bin"); + assert.ok(parts.includes("/jh/bin"), "JAVA_HOME/bin"); + assert.ok(parts.includes("/Users/x/.sdkman/candidates/java/current/bin"), "SDKMAN"); + assert.ok(parts.includes("/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home/bin"), "versioned keg"); + assert.ok(parts.includes("/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home/bin"), "unversioned keg"); + assert.ok(!parts.some((p) => /\/maven\/libexec\/openjdk/.test(p)), "non-JDK kegs are skipped"); + assert.ok(env.PATH.endsWith("/usr/bin:/bin"), "original PATH preserved at the tail"); +}); + +test("doctor: augmentedEnv is a no-op on Windows and tolerates a bare env", () => { + assert.equal(augmentedEnv({ PATH: "C:\\Windows" }, "win32", () => []).PATH, "C:\\Windows"); + // No HOME/JAVA_HOME/PATH provided — must not throw and still prepend brew bins. + const env = augmentedEnv({}, "darwin", () => []); + assert.ok(env.PATH.split(":").includes("/opt/homebrew/bin")); +}); + +test("doctor: discoverJavaHome respects an explicit JAVA_HOME and skips rc files", async () => { + let read = false; + const readRc = async () => { read = true; return ""; }; + const jh = await discoverJavaHome({ JAVA_HOME: "/explicit/jdk" }, "darwin", readRc); + assert.equal(jh, "/explicit/jdk"); + assert.equal(read, false, "must not read rc files when JAVA_HOME is already set"); +}); + +test("doctor: discoverJavaHome statically parses a literal JAVA_HOME from the rc (no shell)", async () => { + const reads = []; + const readRc = async (p) => { + reads.push(p); + if (/\.zshrc$/.test(p)) { + return 'export PATH="$HOME/bin:$PATH"\nexport JAVA_HOME="$HOME/.sdkman/candidates/java/current"\n'; + } + return null; + }; + const jh = await discoverJavaHome({ HOME: "/Users/x", SHELL: "/bin/zsh" }, "darwin", readRc); + assert.equal(jh, "/Users/x/.sdkman/candidates/java/current", "expands $HOME, no execution"); + assert.ok(reads.some((p) => /\.zshrc$/.test(p)), "reads the zsh rc files"); +}); + +test("doctor: discoverJavaHome returns null on Windows, no match, unresolved subst, or read error", async () => { + assert.equal(await discoverJavaHome({ HOME: "/h" }, "win32", async () => "export JAVA_HOME=/x"), null); + assert.equal( + await discoverJavaHome({ HOME: "/h", SHELL: "/bin/zsh" }, "darwin", async () => "# nothing here"), + null + ); + // Command substitution can't be resolved statically -> null (falls back to PATH probing). + assert.equal( + await discoverJavaHome({ HOME: "/h", SHELL: "/bin/zsh" }, "darwin", async () => + "export JAVA_HOME=$(/usr/libexec/java_home -v 21)" + ), + null + ); + assert.equal( + await discoverJavaHome({ HOME: "/h", SHELL: "/bin/zsh" }, "darwin", async () => { throw new Error("boom"); }), + null + ); +}); + +test("doctor: parseJavaHomeFromRc handles quotes, comments, ~ and last-wins", () => { + assert.equal(parseJavaHomeFromRc("export JAVA_HOME=/opt/jdk17 # pinned", {}), "/opt/jdk17"); + assert.equal(parseJavaHomeFromRc("JAVA_HOME='/opt/jdk21'", {}), "/opt/jdk21"); + assert.equal(parseJavaHomeFromRc("export JAVA_HOME=~/jdks/21", { HOME: "/Users/x" }), "/Users/x/jdks/21"); + assert.equal( + parseJavaHomeFromRc("export JAVA_HOME=/a\nexport JAVA_HOME=/b", {}), + "/b", + "later assignment wins" + ); + assert.equal(parseJavaHomeFromRc("export NOTJAVA=/x", {}), null); +}); + +test("prompts: fix_env requires a tool and embeds the readiness detail", () => { + const p = buildPrompt("fix_env", { tool: "a JDK", detail: "java not found", fix: "Install Temurin" }, "/r"); + assert.match(p, /a JDK/); + assert.match(p, /java not found/); + assert.match(p, /do not modify my application code/i); + assert.equal(buildPrompt("fix_env", {}, "/r"), null); +}); + +test("scan: detects Maven/Gradle wrappers for the doctor", async () => { + await withRepo({ "pom.xml": "", "mvnw": "#!/bin/sh\n" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.hasMavenWrapper, true); + assert.equal(s.assessment.hasGradleWrapper, false); + }); +}); + +test("scan: detects Windows wrapper scripts (mvnw.cmd / gradlew.bat)", async () => { + await withRepo({ "pom.xml": "", "mvnw.cmd": "@echo off\n" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.hasMavenWrapper, true, "mvnw.cmd counts as a wrapper"); + }); + await withRepo({ "build.gradle": "plugins {}", "gradlew.bat": "@echo off\n" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.hasGradleWrapper, true, "gradlew.bat counts as a wrapper"); + }); +}); + +test("scan: Dockerfile detection is case-insensitive and accepts Containerfile", async () => { + await withRepo({ "dockerfile": "FROM tomcat:9" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.hasDockerfile, true, "lowercase dockerfile"); + }); + await withRepo({ "Containerfile": "FROM tomcat:9" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.hasDockerfile, true, "Podman Containerfile"); + }); +}); + +test("scan: multi-module repo unions Java version + deps across modules (not just the first)", async () => { + await withRepo( + { + "pom.xml": "pomapiworker", + "api/pom.xml": "17", + "worker/pom.xml": + "com.rabbitmqamqp-client", + }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.buildTool, "Maven"); + assert.equal(s.assessment.javaVersion, "17", "Java version comes from a module, not the aggregator"); + assert.ok( + s.assessment.detectedKeys.includes("rabbitmq"), + "a dependency declared only in a second module is still detected" + ); + } + ); +}); + +test("scan: dependency matching no longer fires on English words containing a token", async () => { + // "important"/"constant" contain "ant"; a plain substring match used to flag Ant. + await withRepo( + { + "pom.xml": + "An important and constant service", + }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.ok(!s.assessment.detectedKeys.includes("ant"), "no false Ant detection"); + } + ); + // A real Ant build is still flagged, via the build tool. + await withRepo({ "build.xml": "" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.assessment.buildTool, "Ant"); + assert.ok(s.assessment.detectedKeys.includes("ant"), "genuine Ant build is detected"); + }); +}); + +test("scan: a real coordinate token is still matched with boundary-aware logic", async () => { + await withRepo( + { + "pom.xml": + "aws-java-sdk-s3", + }, + async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.ok(s.assessment.detectedKeys.includes("awsS3")); + } + ); +}); + +test("scan: assessment.json is normalized (severity aliases, files string, bad shapes)", async () => { + const raw = { + findings: [ + { id: "a", severity: "critical", title: "x", files: "only/one.txt" }, + { id: "b", severity: "high", title: "y" }, + { id: "c", severity: "weird", title: "z", files: ["k.txt", 5] }, + "not-an-object", + { severity: "low" }, + ], + strengths: "single strength", + }; + await withRepo({ ".appmod/assessment.json": JSON.stringify(raw) }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + const f = s.report.findings; + assert.equal(f.length, 4, "non-object findings are dropped, valid ones kept"); + assert.equal(f[0].severity, "P0", "critical -> P0"); + assert.deepEqual(f[0].files, ["only/one.txt"], "string files -> array"); + assert.equal(f[1].severity, "P1", "high -> P1"); + assert.equal(f[2].severity, "P3", "unknown severity -> P3 (kept, not dropped)"); + assert.deepEqual(f[2].files, ["k.txt"], "non-string file entries filtered out"); + assert.equal(f[3].title, "Untitled finding", "missing title gets a fallback"); + assert.deepEqual(s.report.strengths, ["single strength"], "string strengths -> array"); + }); +}); + +test("scan: a non-object assessment.json yields a null report (no crash)", async () => { + await withRepo({ ".appmod/assessment.json": "42" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.report, null); + }); + await withRepo({ ".appmod/assessment.json": "[1,2,3]" }, async (dir) => { + const s = await scanRepo(dir, { includeGit: false }); + assert.equal(s.report, null, "a top-level array is not a valid report"); + }); +}); + + + +test("catalog: has 16 well-formed tasks", () => { + assert.equal(PREDEFINED_TASKS.length, 16); + for (const t of PREDEFINED_TASKS) { + assert.ok(t.id && t.name && t.category && t.summary, "task fields present: " + t.id); + assert.ok(Array.isArray(t.detect)); + } + const ids = new Set(PREDEFINED_TASKS.map((t) => t.id)); + assert.equal(ids.size, PREDEFINED_TASKS.length, "ids are unique"); +}); + +test("catalog: catalogWithRelevance flags only matching detect keys", () => { + const withRel = catalogWithRelevance(["rabbitmq"]); + assert.equal(withRel.find((t) => t.id === "rabbitmq-to-servicebus").relevant, true); + assert.equal(withRel.find((t) => t.id === "aws-s3-to-blob").relevant, false); +}); + +// =========================================================================== +// prompts.mjs +// =========================================================================== + +test("prompts: every labelled action builds a non-empty prompt", () => { + for (const kind of Object.keys(ACTION_LABELS)) { + const payload = + kind === "run_task" + ? { taskId: PREDEFINED_TASKS[0].id } + : kind === "run_skill" + ? { folder: "x" } + : kind === "fix_finding" + ? { title: "Some finding" } + : kind === "work_step" + ? { title: "Some step" } + : kind === "fix_env" + ? { tool: "a JDK" } + : {}; + const p = buildPrompt(kind, payload, "/repo"); + assert.ok(typeof p === "string" && p.length > 0, "prompt for " + kind); + } +}); + +test("prompts: run_task unknown id returns null", () => { + assert.equal(buildPrompt("run_task", { taskId: "nope" }, "/repo"), null); +}); + +test("prompts: run_task known id embeds the task name", () => { + const p = buildPrompt("run_task", { taskId: "aws-s3-to-blob" }, "/repo"); + assert.match(p, /AWS S3 to Azure Storage Blob/); +}); + +test("prompts: assessment embeds repo path; generate_plan embeds target", () => { + assert.match(buildPrompt("start_assessment", {}, "/my/repo"), /\/my\/repo/); + assert.match(buildPrompt("generate_plan", { targetJava: 21 }, "/r"), /Java 21/); +}); + +test("prompts: appmod tools referenced by hash command", () => { + assert.match(buildPrompt("run_cve", {}, "/r"), /#appmod-validate-cves-for-java/); + assert.match(buildPrompt("generate_tests", {}, "/r"), /#appmod-generate-tests-for-java/); +}); + +test("prompts: unknown kind returns null", () => { + assert.equal(buildPrompt("bogus", {}, "/r"), null); +}); + +test("prompts: fix_finding embeds the finding title/detail and needs a title", () => { + const p = buildPrompt("fix_finding", { title: "TLS off", detail: "encrypt=false", files: ["db.properties"], severity: "P1" }, "/r"); + assert.match(p, /TLS off/); + assert.match(p, /encrypt=false/); + assert.match(p, /db\.properties/); + assert.equal(buildPrompt("fix_finding", {}, "/r"), null, "no title -> null"); +}); + +test("prompts: start_assessment instructs writing structured assessment.json", () => { + const p = buildPrompt("start_assessment", {}, "/my/repo"); + assert.match(p, /\.appmod\/assessment\.json/); + assert.match(p, /findings/); + assert.match(p, /P0\|P1\|P2\|P3/); +}); + +test("prompts: fix_finding has a label in ACTION_LABELS", () => { + assert.ok(ACTION_LABELS.fix_finding, "fix_finding should be labelled"); +}); + +test("prompts: work_step embeds the step title + phase and requires a title", () => { + const p = buildPrompt("work_step", { title: "Add ingress TLS", section: "P1 — Secrets & identity" }, "/r"); + assert.match(p, /Add ingress TLS/); + assert.match(p, /P1 — Secrets & identity/); + assert.match(p, /progress\.md/); + assert.equal(buildPrompt("work_step", {}, "/r"), null, "no title -> null"); +}); + +// =========================================================================== +// renderer.mjs +// =========================================================================== + +test("renderer: returns full HTML doc with app root and boot blob", () => { + const html = renderHtml({ instanceId: "abc", initialTab: "plan" }); + assert.match(html, /^/); + assert.match(html, /id="app"/); + assert.match(html, /Java Modernization Studio/); + assert.match(html, /"initialTab":"plan"/); + assert.match(html, /"instanceId":"abc"/); +}); + +test("renderer: defaults initialTab to overview", () => { + const html = renderHtml({ instanceId: "x" }); + assert.match(html, /"initialTab":"overview"/); +}); + +test("renderer: button hover is contrast-safe (no inverted emphasis background)", () => { + const html = renderHtml({ instanceId: "css" }); + const css = html.slice(html.indexOf("")); + // Regression: a `:hover` must never swap a dark-text button onto the dark + // `--background-color-emphasis` surface (that made hovered text unreadable). + assert.ok(!/:hover[^}]*background-color-emphasis/.test(css), "no emphasis background on hover"); + // The default (non-primary) button keeps a readable surface on hover. + assert.match(css, /button\.btn:not\(\.primary\):not\(:disabled\):hover/); + // Keyboard users get a visible focus ring. + assert.match(css, /button\.btn:focus-visible/); +}); + +test("renderer: boot blob escapes < so it cannot break out of ")[0]; + const slots = {}; + const make = (id) => ({ + _html: "", id, + set innerHTML(v) { this._html = v; }, + get innerHTML() { return this._html; }, + addEventListener() {}, querySelectorAll() { return []; }, querySelector() { return null; }, + classList: { add() {}, remove() {}, toggle() {} }, setAttribute() {}, style: {}, dataset: {}, textContent: "", + }); + const win = { __APPMOD__: { instanceId: "exec", initialTab }, addEventListener() {}, location: {}, scrollTo() {} }; + const ES = class { constructor() {} addEventListener() {} close() {} }; + const doc = { + getElementById: (id) => { if (!slots[id]) slots[id] = make(id); return slots[id]; }, + querySelectorAll: () => [], + querySelector: (s) => { if (typeof s === "string" && s[0] === "#") { const id = s.slice(1); if (!slots[id]) slots[id] = make(id); return slots[id]; } return null; }, + addEventListener() {}, createElement: () => make("x"), body: make("body"), + }; + const fetchShim = async () => ({ ok: true, json: async () => state }); + // Inject our shims as the only free identifiers the client uses, so the body + // runs against them rather than this module's lexical scope. + const fn = new Function("window", "document", "EventSource", "fetch", "setTimeout", "clearTimeout", "console", script); + fn(win, doc, ES, fetchShim, setTimeout, clearTimeout, console); + // The client renders once synchronously (Loading), then loadState() fetches + // and re-renders async — let those microtasks settle before reading #app. + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + return { app: () => (slots.app ? slots.app._html : "") }; +} + +// A realistic snapshot with a multi-phase plan, an assessment report, gates, an +// environment report, and no autopilot run — exercises the common render paths. +function sampleState(extra = {}) { + const steps = [ + { title: "Fix the build", section: "P0 Build", status: "in_progress", rank: 1 }, + { title: "Patch CVEs", section: "P1 Security", status: "pending", rank: 2 }, + { title: "Upgrade Java", section: "P2 Runtime", status: "pending", rank: 3 }, + ]; + return { + ok: true, repoPath: "/demo/app", scannedAt: new Date().toISOString(), + status: "in_progress", percent: 10, + assessment: { buildTool: "Maven", javaVersion: "8", springBoot: true, hasDockerfile: true, detectedKeys: [] }, + tasks: [], skills: [], + git: { branch: "modernize/java21", isMigrationBranch: true, dirty: true, changedFiles: 3 }, + plan: { exists: true, steps }, progress: { exists: true, steps }, + summary: { exists: false, markdown: "" }, + report: { + generatedAt: new Date().toISOString(), headline: "Java 8 Spring app", summary: "Outdated runtime and deps.", + stack: { buildTool: "Maven", java: "8", framework: "Spring Boot", database: "MSSQL", container: "Docker" }, + findings: [ + { id: "java8", severity: "P0", title: "Java 8 is EOL", detail: "Upgrade the runtime.", files: ["pom.xml"], action: { kind: "generate_plan", payload: { targetJava: 21 }, label: "Plan upgrade" } }, + { id: "cve", severity: "P1", title: "Vulnerable dependency", detail: "Bump it.", files: [], action: { kind: "fix_finding", payload: {}, label: "Fix" } }, + ], + strengths: ["Has a Dockerfile"], + }, + ordering: { activeRank: 1, activePhase: "P0 Build", phases: [{ name: "P0 Build", rank: 1, done: 0, total: 1 }] }, + gates: { build: "not_run", tests: "not_run", cve: "not_run", consistency: "not_run", completeness: "not_run" }, + doctor: { overall: "ready", generatedAt: new Date().toISOString(), groups: [ + { id: "build", name: "Build & run", checks: [{ id: "jdk", label: "JDK", status: "ok", detail: "Java 21" }] }, + ] }, + autopilot: null, + ...extra, + }; +} + +test("renderer: every tab renders without throwing against a realistic state", async () => { + const tabs = ["overview", "readiness", "assessment", "plan", "validation", "tasks", "summary"]; + for (const tab of tabs) { + const r = await renderClientTab(sampleState(), tab); + const out = r.app(); + assert.ok(out && out.length > 40, "tab '" + tab + "' produced no body"); + assert.ok(!/hit an error/.test(out), "tab '" + tab + "' fell back to the error banner"); + } +}); + +test("renderer: Plan tab actually renders its checklist when executed (regression)", async () => { + const r = await renderClientTab(sampleState(), "plan"); + const out = r.app(); + assert.match(out, /Plan & Progress/); + assert.match(out, /Fix the build/); + assert.match(out, /data-kind="work_step"/); + assert.match(out, /Autopilot/); + assert.ok(!/hit an error/.test(out)); +}); + +test("renderer: a running autopilot shows the live strip and freezes manual actions", async () => { + const state = sampleState({ + autopilot: { running: true, scope: "phase", status: "running", current: { title: "Fix the build", section: "P0 Build" }, completed: [{ title: "Earlier", section: "P0 Build", done: true }], maxSteps: 25 }, + }); + const r = await renderClientTab(state, "plan"); + const out = r.app(); + assert.match(out, /Autopilot running/); + assert.match(out, /autopilot_stop/); + assert.match(out, /Earlier/); // step log + assert.match(out, /data-kind="work_step"[^>]*disabled/); // manual buttons frozen +}); + +// Drive the live client: capture its click handler + EventSource so a test can +// fire a real button click and simulate an SSE (re)connect, then read #app. +// fetch routes GET /state -> current snapshot, POST /action -> a scripted result. +function driveClient(initialState, opts = {}) { + const initialTab = opts.initialTab || "readiness"; + const html = renderHtml({ instanceId: "drive", initialTab }); + const script = html.split("")[0]; + const slots = {}; + const make = (id) => ({ + _html: "", id, + set innerHTML(v) { this._html = v; }, get innerHTML() { return this._html; }, + addEventListener() {}, querySelectorAll() { return []; }, querySelector() { return null; }, + classList: { add() {}, remove() {}, toggle() {} }, setAttribute() {}, style: {}, dataset: {}, textContent: "", + }); + let clickHandler = null; + const es = { onopen: null, onmessage: null, onerror: null, addEventListener() {}, close() {} }; + const win = { __APPMOD__: { instanceId: "drive", initialTab }, addEventListener() {}, location: {}, scrollTo() {} }; + const ES = function () { return es; }; // `new ES()` yields our singleton + const doc = { + getElementById: (id) => { if (!slots[id]) slots[id] = make(id); return slots[id]; }, + querySelectorAll: () => [], + querySelector: (s) => { if (typeof s === "string" && s[0] === "#") { const id = s.slice(1); if (!slots[id]) slots[id] = make(id); return slots[id]; } return null; }, + addEventListener: (type, fn) => { if (type === "click") clickHandler = fn; }, + createElement: () => make("x"), body: make("body"), + }; + let getState = initialState; + let getStateCalls = 0; + const fetchShim = async (url, init) => { + if (init && init.method === "POST") return { ok: true, json: async () => (opts.actionResponse || { ok: true, message: "ok" }) }; + getStateCalls++; + return { ok: true, json: async () => getState }; + }; + new Function("window", "document", "EventSource", "fetch", "setTimeout", "clearTimeout", "console", script)(win, doc, ES, fetchShim, setTimeout, clearTimeout, console); + const settle = async () => { await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0)); }; + return { + async ready() { await settle(); return this; }, + app: () => (slots.app ? slots.app._html : ""), + getStateCalls: () => getStateCalls, + setGetState(s) { getState = s; }, + async clickKind(kind, payload) { + const btnEl = { disabled: false, dataset: { kind, payload: JSON.stringify(payload || {}) } }; + const ev = { target: { closest: (sel) => (sel.indexOf("nav.tabs") === 0 ? null : btnEl) } }; + clickHandler(ev); + await settle(); + }, + async reconnect() { if (es.onopen) es.onopen(); await settle(); }, + }; +} + +test("renderer: recheck_env applies the inline result and never strands the spinner", async () => { + // Backend returns a fresh, now-ready snapshot inline (res.state) — the client + // must apply it directly, not fall back to the agent-style 'pending' banner + // that only clears on an SSE broadcast (which can be lost on a provider restart). + const blocked = sampleState({ doctor: { overall: "blocked", generatedAt: "t", groups: [{ id: "build", name: "Build", checks: [{ id: "jdk", label: "JDK", status: "fail", detail: "java not found" }] }] } }); + const ready = sampleState({ doctor: { overall: "ready", generatedAt: "t2", groups: [{ id: "build", name: "Build", checks: [{ id: "jdk", label: "JDK", status: "ok", detail: "Java 21" }] }] } }); + const c = await driveClient(blocked, { initialTab: "readiness", actionResponse: { ok: true, message: "Re-checked environment", state: ready } }).ready(); + assert.match(c.app(), /Not ready yet/); // starts blocked + await c.clickKind("recheck_env", {}); + assert.match(c.app(), /Your environment is ready/); // inline state applied + assert.ok(!/Watch the chat/.test(c.app()), "must not show the agent 'pending' spinner banner"); + assert.equal(c.getStateCalls(), 1, "recheck must not need an extra /state fetch (uses inline state)"); +}); + +test("renderer: an SSE reconnect resyncs state (recovers a missed broadcast)", async () => { + const blocked = sampleState({ doctor: { overall: "blocked", generatedAt: "t", groups: [{ id: "build", name: "Build", checks: [{ id: "jdk", label: "JDK", status: "fail", detail: "x" }] }] } }); + const ready = sampleState({ doctor: { overall: "ready", generatedAt: "t2", groups: [{ id: "build", name: "Build", checks: [{ id: "jdk", label: "JDK", status: "ok", detail: "Java 21" }] }] } }); + const c = await driveClient(blocked, { initialTab: "readiness" }).ready(); + const before = c.getStateCalls(); + assert.match(c.app(), /Not ready yet/); + // Provider finished the recheck after a restart that dropped the broadcast; + // backend state is now ready. The stream reconnects -> client must re-fetch. + c.setGetState(ready); + await c.reconnect(); + assert.equal(c.getStateCalls(), before + 1, "onopen must re-fetch /state"); + assert.match(c.app(), /Your environment is ready/); +}); + +// =========================================================================== +// server.mjs +// =========================================================================== + +test("server: resolveRepoPath precedence input > session > last, else null", () => { + assert.equal( + resolveRepoPath({ input: { repoPath: "/in" }, session: { workingDirectory: "/s" } }, "/last"), + "/in" + ); + assert.equal(resolveRepoPath({ session: { workingDirectory: "/s" } }, "/last"), "/s"); + assert.equal(resolveRepoPath({}, "/last"), "/last"); + // No process.cwd() fallback — unresolved means null (UI shows "repo not available"). + assert.equal(resolveRepoPath({}, null), null); + assert.equal(resolveRepoPath({}, undefined), null); +}); + +test("server: broadcast writes SSE frame to every client", () => { + const chunks = []; + const rec = { sseClients: new Set([{ write: (s) => chunks.push(s) }]) }; + broadcast(rec, { type: "state", state: { ok: true } }); + assert.equal(chunks.length, 1); + assert.match(chunks[0], /^data: /); + assert.match(chunks[0], /"type":"state"/); +}); + +test("server: pushState scans repo and broadcasts snapshot", async () => { + await withRepo({ "pom.xml": "" }, async (dir) => { + const chunks = []; + const rec = { repoPath: dir, sseClients: new Set([{ write: (s) => chunks.push(s) }]) }; + const state = await pushState(rec); + assert.equal(state.ok, true); + assert.equal(chunks.length, 1); + assert.match(chunks[0], /"buildTool":"Maven"/); + }); +}); + +test("server: dispatch refresh re-scans without sending a prompt", async () => { + await withRepo({ "pom.xml": "" }, async (dir) => { + let sent = 0; + const rec = { repoPath: dir, sseClients: new Set() }; + const res = await dispatchAction(rec, "refresh", {}, { sendPrompt: async () => sent++ }); + assert.equal(res.ok, true); + assert.equal(sent, 0); + }); +}); + +test("server: dispatch agent action forwards crafted prompt to sendPrompt", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + const sentPrompts = []; + const res = await dispatchAction(rec, "run_cve", {}, { sendPrompt: async (p) => sentPrompts.push(p) }); + assert.equal(res.ok, true); + assert.equal(res.label, "CVE scan"); + assert.equal(sentPrompts.length, 1); + assert.match(sentPrompts[0], /#appmod-validate-cves-for-java/); +}); + +test("server: dispatch unknown action returns error, no send", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + let sent = 0; + const res = await dispatchAction(rec, "bogus", {}, { sendPrompt: async () => sent++ }); + assert.equal(res.ok, false); + assert.match(res.error, /Unknown action/); + assert.equal(sent, 0); +}); + +test("server: dispatch without sendPrompt reports session not ready", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + const res = await dispatchAction(rec, "run_cve", {}, {}); + assert.equal(res.ok, false); + assert.match(res.error, /not ready/); +}); + +test("server: handler GET / serves HTML", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + const handler = makeHandler(rec, { instanceId: "i", initialTab: "overview", sendPrompt: async () => {} }); + const { res } = await invokeGet(handler, "/"); + assert.match(res.headers["content-type"], /text\/html/); + assert.match(res.body, /Java Modernization Studio/); +}); + +test("server: handler GET /state serves scanned JSON", async () => { + await withRepo({ "pom.xml": "" }, async (dir) => { + const rec = { repoPath: dir, sseClients: new Set() }; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => {} }); + const { res } = await invokeGet(handler, "/state"); + const json = JSON.parse(res.body); + assert.equal(json.ok, true); + assert.equal(json.repoPath, dir); + }); +}); + +test("server: handler GET /events registers and cleans up SSE client", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => {} }); + const { req } = await invokeGet(handler, "/events"); + assert.equal(rec.sseClients.size, 1); + req.emit("close"); + assert.equal(rec.sseClients.size, 0); +}); + +test("server: handler POST /action runs dispatch", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + const sent = []; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async (p) => sent.push(p) }); + const res = await invokePost(handler, "/action", { kind: "run_cve" }); + const json = JSON.parse(res.body); + assert.equal(json.ok, true); + assert.equal(sent.length, 1); +}); + +test("server: token guard forbids requests without the per-instance token", async () => { + const rec = { repoPath: "/repo", sseClients: new Set(), token: "s3cret" }; + const sent = []; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async (p) => sent.push(p) }); + for (const path of ["/", "/state", "/events"]) { + const { res } = await invokeGet(handler, path); + assert.equal(res.statusCode, 403, path + " without a token must be forbidden"); + } + const post = await invokePost(handler, "/action", { kind: "run_cve" }); + assert.equal(post.statusCode, 403, "/action without a token must be forbidden"); + assert.equal(sent.length, 0, "no action is dispatched for an unauthenticated request"); + assert.equal(rec.sseClients.size, 0, "no SSE client is registered for an unauthenticated request"); +}); + +test("server: token guard allows requests carrying the correct token", async () => { + const rec = { repoPath: "/repo", sseClients: new Set(), token: "s3cret" }; + const sent = []; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async (p) => sent.push(p) }); + const { req } = await invokeGet(handler, "/events?token=s3cret"); + assert.equal(rec.sseClients.size, 1, "a valid token registers the SSE client"); + req.emit("close"); + const post = await invokePost(handler, "/action?token=s3cret", { kind: "run_cve" }); + const json = JSON.parse(post.body); + assert.equal(json.ok, true); + assert.equal(sent.length, 1); +}); + +test("server: POST /action rejects an oversized request body (413)", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + const sent = []; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async (p) => sent.push(p) }); + const req = new EventEmitter(); + req.method = "POST"; + req.url = "/action"; + const res = fakeRes(); + const pending = handler(req, res); + req.emit("data", "x".repeat(300 * 1024)); // > 256 KB cap + req.emit("end"); + await pending; + assert.equal(res.statusCode, 413); + const json = JSON.parse(res.body); + assert.equal(json.ok, false); + assert.match(json.error, /too large/i); + assert.equal(sent.length, 0, "no action is dispatched for a rejected body"); +}); + +test("server: handler unknown route 404s", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => {} }); + const { res } = await invokeGet(handler, "/nope"); + assert.equal(res.statusCode, 404); +}); + +test("server: broadcast drops a client whose write fails (no leak, no repeat throw)", () => { + const live = []; + const dead = { + write() { + throw new Error("EPIPE: client gone"); + }, + }; + const rec = { sseClients: new Set([dead, { write: (s) => live.push(s) }]) }; + broadcast(rec, { type: "state", state: { ok: true } }); + assert.ok(!rec.sseClients.has(dead), "a client whose write throws is removed from the Set"); + assert.equal(rec.sseClients.size, 1, "the healthy client is retained"); + assert.equal(live.length, 1, "the healthy client still receives the frame"); + // A later broadcast must not keep throwing now that the dead client is gone. + assert.doesNotThrow(() => broadcast(rec, { type: "state", state: { ok: true } })); + assert.equal(live.length, 2); +}); + +test("server: POST /action rejects malformed JSON with 400 and dispatches nothing", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + let sent = 0; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => sent++ }); + const req = new EventEmitter(); + req.method = "POST"; + req.url = "/action"; + const res = fakeRes(); + const pending = handler(req, res); + req.emit("data", "{ not valid json "); + req.emit("end"); + await pending; + assert.equal(res.statusCode, 400); + assert.match(res.headers["content-type"], /application\/json/); + const json = JSON.parse(res.body); + assert.equal(json.ok, false); + assert.match(json.error, /invalid JSON/i); + assert.equal(sent, 0, "a malformed body dispatches no action"); +}); + +test("server: POST /action rejects a missing/invalid kind with 400", async () => { + const rec = { repoPath: "/repo", sseClients: new Set() }; + let sent = 0; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => sent++ }); + const res = await invokePost(handler, "/action", { payload: {} }); // no kind + assert.equal(res.statusCode, 400); + assert.match(res.headers["content-type"], /application\/json/); + const json = JSON.parse(res.body); + assert.equal(json.ok, false); + assert.match(json.error, /kind/i); + assert.equal(sent, 0, "a kindless request dispatches no action"); +}); + +test("server: a handler exception returns 500 with a JSON content-type", async () => { + // A throwing repoPath getter makes buildState (GET /state) reject, exercising + // the handler's outer catch — which must still label the error body as JSON. + const rec = { + sseClients: new Set(), + get repoPath() { + throw new Error("boom"); + }, + }; + const handler = makeHandler(rec, { instanceId: "i", sendPrompt: async () => {} }); + const { res } = await invokeGet(handler, "/state"); + assert.equal(res.statusCode, 500); + assert.match(res.headers["content-type"], /application\/json/); + const json = JSON.parse(res.body); + assert.equal(json.ok, false); +}); + +test("server: recheck_env runs the doctor, caches it, and broadcasts (no agent prompt)", async () => { + await withRepo({ "pom.xml": "" }, async (dir) => { + let runs = 0; + const chunks = []; + const rec = { + repoPath: dir, + sseClients: new Set([{ write: (s) => chunks.push(s) }]), + doctor: null, + runDoctor: async () => ({ overall: "blocked", groups: [], runs: ++runs }), + }; + let sent = 0; + const res = await dispatchAction(rec, "recheck_env", {}, { sendPrompt: async () => sent++ }); + assert.equal(res.ok, true); + assert.equal(sent, 0, "recheck_env does not message the agent"); + assert.equal(rec.doctor.overall, "blocked"); + assert.equal(runs, 1); + assert.match(chunks[0], /"overall":"blocked"/); + }); +}); + +test("server: createInstanceServer binds a loopback socket end-to-end", async () => { + await withRepo({ "pom.xml": "" }, async (dir) => { + const sent = []; + const rec = await createInstanceServer({ + instanceId: "live", + repoPath: dir, + initialTab: "overview", + sendPrompt: async (p) => sent.push(p), + runDoctor: async () => ({ overall: "ready", groups: [] }), + }); + try { + assert.match(rec.url, /^http:\/\/127\.0\.0\.1:\d+\/\?token=[a-f0-9]+$/); + const u = new URL(rec.url); + const base = u.origin; + const q = "?token=" + u.searchParams.get("token"); + + const home = await fetch(rec.url); + assert.equal(home.status, 200); + assert.match(await home.text(), /Java Modernization Studio/); + + const state = await (await fetch(base + "/state" + q)).json(); + assert.equal(state.ok, true); + assert.equal(state.assessment.buildTool, "Maven"); + assert.equal(state.doctor.overall, "ready", "doctor report is attached to state"); + + const action = await fetch(base + "/action" + q, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "generate_tests" }), + }); + const aj = await action.json(); + assert.equal(aj.ok, true); + assert.equal(sent.length, 1); + + // A request that omits the per-instance token must be refused, even though + // it reached the right loopback port. + const unauth = await fetch(base + "/state"); + assert.equal(unauth.status, 403, "missing token is rejected at the socket"); + } finally { + await new Promise((r) => rec.server.close(() => r())); + } + }); +}); + +// ---- autopilot -------------------------------------------------------------- + +// Build an evolving plan model: snapshot() reflects current statuses and a fresh +// activeRank; markNextDone() simulates the agent checking a step off. +function planSim(steps) { + const model = steps.map((s) => ({ section: null, rank: null, status: "pending", ...s })); + const activeRank = () => { + const notDone = model.filter((s) => s.status !== "done" && s.rank != null); + return notDone.length ? Math.min(...notDone.map((s) => s.rank)) : null; + }; + return { + model, + snapshot: async () => ({ + ok: true, + progress: { steps: model.map((s) => ({ ...s })) }, + plan: { steps: model.map((s) => ({ ...s })) }, + ordering: { activeRank: activeRank() }, + }), + markNextDone() { + const notDone = model.find((s) => s.status !== "done"); + if (notDone) notDone.status = "done"; + }, + }; +} + +test("autopilot: selectNextStep follows phase order then falls back", () => { + const s = { + progress: { steps: [ + { title: "A", status: "done", rank: 1 }, + { title: "B", status: "pending", rank: 1 }, + { title: "C", status: "pending", rank: 2 }, + ] }, + ordering: { activeRank: 1 }, + }; + assert.equal(selectNextStep(s).title, "B", "picks the active-phase pending step"); + const s2 = { progress: { steps: [{ title: "X", status: "done", rank: 1 }] }, ordering: { activeRank: null } }; + assert.equal(selectNextStep(s2), null, "null when nothing is pending"); + assert.equal(selectNextStep({}), null, "null when there are no steps"); +}); + +test("autopilot: stepKey is stable and isStepDone reads status", () => { + const step = { title: "Upgrade", section: "P0 Build" }; + assert.equal(stepKey(step), "P0 Build::Upgrade"); + const state = { progress: { steps: [{ title: "Upgrade", section: "P0 Build", status: "done" }] } }; + assert.equal(isStepDone(state, step), true); + assert.equal(isStepDone({ progress: { steps: [] } }, step), false); +}); + +test("autopilot: auto_step prompt is autonomous and never commits", () => { + assert.equal(buildPrompt("auto_step", {}, "/repo"), null, "no title -> no prompt"); + const p = buildPrompt("auto_step", { title: "Bump Spring", section: "P1 Security" }, "/repo"); + assert.match(p, /AUTOPILOT/); + assert.match(p, /Bump Spring/); + assert.match(p, /Do NOT commit/); + assert.match(p, /one step/i); +}); + +test("autopilot: runs every step to completion (happy path)", async () => { + const sim = planSim([ + { title: "A", section: "P0 Build", rank: 1 }, + { title: "B", section: "P0 Build", rank: 1 }, + ]); + const run = makeRun({ scope: "all", maxSteps: 10, startRank: 1 }); + const prompts = []; + await runAutopilot(run, { + snapshot: sim.snapshot, + runTurn: async (p) => { prompts.push(p); sim.markNextDone(); }, + buildStepPrompt: (step) => step.title, + onProgress: () => {}, + }); + assert.equal(run.status, "completed"); + assert.equal(run.completed.length, 2); + assert.ok(run.completed.every((c) => c.done), "each step ended up checked off"); + assert.deepEqual(prompts, ["A", "B"]); + assert.equal(run.running, false); +}); + +test("autopilot: phase scope stops at the phase boundary", async () => { + const sim = planSim([ + { title: "A", section: "P0 Build", rank: 1 }, + { title: "B", section: "P1 Security", rank: 2 }, + ]); + const run = makeRun({ scope: "phase", maxSteps: 10, startRank: 1 }); + await runAutopilot(run, { + snapshot: sim.snapshot, + runTurn: async () => sim.markNextDone(), + buildStepPrompt: (step) => step.title, + onProgress: () => {}, + }); + assert.equal(run.status, "phase_done"); + assert.equal(run.completed.length, 1, "only the P0 step ran; it paused before P1"); + assert.equal(run.completed[0].title, "A"); +}); + +test("autopilot: stops (stuck) when a step does not get checked off", async () => { + const sim = planSim([{ title: "Tricky", section: "P0 Build", rank: 1 }]); + const run = makeRun({ scope: "all", maxSteps: 10, startRank: 1 }); + await runAutopilot(run, { + snapshot: sim.snapshot, + runTurn: async () => { /* agent fails to complete the step */ }, + buildStepPrompt: (step) => step.title, + onProgress: () => {}, + }); + assert.equal(run.status, "stuck"); + assert.equal(run.stuck, "Tricky"); + assert.equal(run.completed.length, 1); + assert.equal(run.completed[0].done, false); +}); + +test("autopilot: honors cancellation between steps", async () => { + const sim = planSim([ + { title: "A", section: "P0 Build", rank: 1 }, + { title: "B", section: "P0 Build", rank: 1 }, + ]); + const run = makeRun({ scope: "all", maxSteps: 10, startRank: 1 }); + await runAutopilot(run, { + snapshot: sim.snapshot, + runTurn: async () => sim.markNextDone(), + buildStepPrompt: (step) => step.title, + onProgress: (r) => { if (r.current) r.cancelled = true; }, + }); + assert.equal(run.status, "cancelled"); + assert.equal(run.completed.length, 1, "the in-flight step finished, then it stopped"); +}); + +test("server: autopilot_start refuses without a runTurn driver", async () => { + const rec = { repoPath: "/nope", sseClients: new Set(), doctor: null, autopilot: null }; + const res = await dispatchAction(rec, "autopilot_start", {}, { sendPrompt: async () => {}, log: () => {} }); + assert.equal(res.ok, false); + assert.match(res.error, /drive/i); + assert.equal(rec.autopilot, null); +}); + +test("server: autopilot_start is blocked when the environment is not ready", async () => { + await withRepo({ ...PROV, "pom.xml": "", "progress.md": "## P0 Build\n- [ ] Fix build\n" }, async (dir) => { + const rec = { + repoPath: dir, + sseClients: new Set(), + doctor: null, + autopilot: null, + runDoctor: async () => ({ overall: "blocked", groups: [] }), + }; + const res = await dispatchAction(rec, "autopilot_start", {}, { runTurn: async () => {}, log: () => {} }); + assert.equal(res.ok, false); + assert.match(res.error, /environment/i); + assert.equal(rec.autopilot, null, "no run starts while blocked"); + }); +}); + +test("server: autopilot_start drives the loop and broadcasts progress", async () => { + await withRepo({ ...PROV, "pom.xml": "", "progress.md": "## P0 Build\n- [ ] Fix the build\n" }, async (dir) => { + const chunks = []; + const rec = { + repoPath: dir, + sseClients: new Set([{ write: (s) => chunks.push(s) }]), + doctor: null, + autopilot: null, + runDoctor: async () => ({ overall: "ready", groups: [] }), + }; + let turns = 0; + const res = await dispatchAction(rec, "autopilot_start", { scope: "phase" }, { runTurn: async () => { turns++; }, log: () => {} }); + assert.equal(res.ok, true); + assert.ok(rec.autopilot, "a run record is attached"); + await rec.autopilotPromise; // let the loop settle + assert.equal(rec.autopilot.running, false); + assert.ok(turns >= 1, "the agent was driven at least once"); + // The step never gets checked off (runTurn is a stub), so it pauses as stuck. + assert.equal(rec.autopilot.status, "stuck"); + assert.ok(chunks.some((c) => /autopilot|"type":"state"/.test(c)), "progress was broadcast"); + }); +}); + +test("server: autopilot_stop and autopilot_dismiss behave by run state", async () => { + const rec = { repoPath: "/x", sseClients: new Set(), doctor: null, autopilot: null }; + const stopIdle = await dispatchAction(rec, "autopilot_stop", {}, {}); + assert.equal(stopIdle.ok, false, "nothing to stop when idle"); + + rec.autopilot = { running: true, cancelled: false, completed: [] }; + const stopRunning = await dispatchAction(rec, "autopilot_stop", {}, {}); + assert.equal(stopRunning.ok, true); + assert.equal(rec.autopilot.cancelled, true, "stop flips the cancel flag"); + + rec.autopilot = { running: false, status: "completed", completed: [] }; + await withRepo({ "pom.xml": "" }, async (dir) => { + rec.repoPath = dir; + const res = await dispatchAction(rec, "autopilot_dismiss", {}, {}); + assert.equal(res.ok, true); + assert.equal(rec.autopilot, null, "dismiss clears a finished run"); + }); +}); + +test("renderer: ships the Autopilot controls (start/stop, card, live strip)", () => { + const html = renderHtml({ instanceId: "x" }); + assert.match(html, /autopilot_start/); + assert.match(html, /autopilot_stop/); + assert.match(html, /Autopilot/); + assert.match(html, /autopilotStrip/); +}); diff --git a/extensions/java-modernization-studio/.github/plugin/plugin.json b/plugins/java-modernization-studio/plugin.json similarity index 78% rename from extensions/java-modernization-studio/.github/plugin/plugin.json rename to plugins/java-modernization-studio/plugin.json index 615221af..7085eb7c 100644 --- a/extensions/java-modernization-studio/.github/plugin/plugin.json +++ b/plugins/java-modernization-studio/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "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.2", @@ -15,6 +16,9 @@ "modernization-cockpit", "validation-gates" ], - "logo": "assets/preview.png", - "extensions": "extensions" + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + } + } } diff --git a/plugins/kotlin-mcp-development/.github/plugin/plugin.json b/plugins/kotlin-mcp-development/plugin.json similarity index 86% rename from plugins/kotlin-mcp-development/.github/plugin/plugin.json rename to plugins/kotlin-mcp-development/plugin.json index 2aa608f6..2d77283d 100644 --- a/plugins/kotlin-mcp-development/.github/plugin/plugin.json +++ b/plugins/kotlin-mcp-development/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "kotlin-mcp-development", "description": "Complete toolkit for building Model Context Protocol (MCP) servers in Kotlin using the official io.modelcontextprotocol:kotlin-sdk library. Includes instructions for best practices, a prompt for generating servers, and an expert chat mode for guidance.", "version": "1.0.0", @@ -14,11 +15,5 @@ "kotlin-multiplatform", "server-development", "ktor" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/kotlin-mcp-server-generator" ] } diff --git a/plugins/mcp-m365-copilot/.github/plugin/plugin.json b/plugins/mcp-m365-copilot/plugin.json similarity index 72% rename from plugins/mcp-m365-copilot/.github/plugin/plugin.json rename to plugins/mcp-m365-copilot/plugin.json index 9f504810..a1a1b270 100644 --- a/plugins/mcp-m365-copilot/.github/plugin/plugin.json +++ b/plugins/mcp-m365-copilot/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "mcp-m365-copilot", "description": "Comprehensive collection for building declarative agents with Model Context Protocol integration for Microsoft 365 Copilot", "version": "1.0.0", @@ -14,13 +15,5 @@ "api-plugins", "model-context-protocol", "adaptive-cards" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/mcp-create-adaptive-cards", - "./skills/mcp-create-declarative-agent", - "./skills/mcp-deploy-manage-agents" ] } diff --git a/plugins/napkin/.github/plugin/plugin.json b/plugins/napkin/plugin.json similarity index 90% rename from plugins/napkin/.github/plugin/plugin.json rename to plugins/napkin/plugin.json index 17be1ddf..a838c4de 100644 --- a/plugins/napkin/.github/plugin/plugin.json +++ b/plugins/napkin/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "napkin", "description": "Visual whiteboard collaboration for Copilot CLI. Opens an interactive whiteboard in your browser where you can draw, sketch, and add sticky notes — then share everything back with Copilot. Copilot sees your drawings and responds with analysis, suggestions, and ideas.", "version": "1.0.0", @@ -18,8 +19,5 @@ "accessibility", "copilot-cli", "ux" - ], - "skills": [ - "./skills/napkin" ] } diff --git a/plugins/noob-mode/.github/plugin/plugin.json b/plugins/noob-mode/plugin.json similarity index 88% rename from plugins/noob-mode/.github/plugin/plugin.json rename to plugins/noob-mode/plugin.json index 899e47a5..62dd0830 100644 --- a/plugins/noob-mode/.github/plugin/plugin.json +++ b/plugins/noob-mode/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "noob-mode", "description": "Plain-English translation layer for non-technical Copilot CLI users. Translates every approval prompt, error message, and technical output into clear, jargon-free English with color-coded risk indicators.", "version": "1.0.0", @@ -15,8 +16,5 @@ "translation", "copilot-cli", "ux" - ], - "skills": [ - "./skills/noob-mode" ] } diff --git a/plugins/openapi-to-application-csharp-dotnet/.github/plugin/plugin.json b/plugins/openapi-to-application-csharp-dotnet/plugin.json similarity index 84% rename from plugins/openapi-to-application-csharp-dotnet/.github/plugin/plugin.json rename to plugins/openapi-to-application-csharp-dotnet/plugin.json index 16930355..35c6f6af 100644 --- a/plugins/openapi-to-application-csharp-dotnet/.github/plugin/plugin.json +++ b/plugins/openapi-to-application-csharp-dotnet/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "openapi-to-application-csharp-dotnet", "description": "Generate production-ready .NET applications from OpenAPI specifications. Includes ASP.NET Core project scaffolding, controller generation, entity framework integration, and C# best practices.", "version": "1.0.0", @@ -14,11 +15,5 @@ "csharp", "dotnet", "aspnet" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/openapi-to-application-code" ] } diff --git a/plugins/openapi-to-application-go/.github/plugin/plugin.json b/plugins/openapi-to-application-go/plugin.json similarity index 83% rename from plugins/openapi-to-application-go/.github/plugin/plugin.json rename to plugins/openapi-to-application-go/plugin.json index 22e2c62e..189d90e2 100644 --- a/plugins/openapi-to-application-go/.github/plugin/plugin.json +++ b/plugins/openapi-to-application-go/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "openapi-to-application-go", "description": "Generate production-ready Go applications from OpenAPI specifications. Includes project scaffolding, handler generation, middleware setup, and Go best practices for REST APIs.", "version": "1.0.0", @@ -13,11 +14,5 @@ "api", "go", "golang" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/openapi-to-application-code" ] } diff --git a/plugins/openapi-to-application-java-spring-boot/.github/plugin/plugin.json b/plugins/openapi-to-application-java-spring-boot/plugin.json similarity index 84% rename from plugins/openapi-to-application-java-spring-boot/.github/plugin/plugin.json rename to plugins/openapi-to-application-java-spring-boot/plugin.json index 82aa5d21..201845e7 100644 --- a/plugins/openapi-to-application-java-spring-boot/.github/plugin/plugin.json +++ b/plugins/openapi-to-application-java-spring-boot/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "openapi-to-application-java-spring-boot", "description": "Generate production-ready Spring Boot applications from OpenAPI specifications. Includes project scaffolding, REST controller generation, service layer organization, and Spring Boot best practices.", "version": "1.0.0", @@ -13,11 +14,5 @@ "api", "java", "spring-boot" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/openapi-to-application-code" ] } diff --git a/plugins/openapi-to-application-nodejs-nestjs/.github/plugin/plugin.json b/plugins/openapi-to-application-nodejs-nestjs/plugin.json similarity index 84% rename from plugins/openapi-to-application-nodejs-nestjs/.github/plugin/plugin.json rename to plugins/openapi-to-application-nodejs-nestjs/plugin.json index ddd2e850..1a489142 100644 --- a/plugins/openapi-to-application-nodejs-nestjs/.github/plugin/plugin.json +++ b/plugins/openapi-to-application-nodejs-nestjs/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "openapi-to-application-nodejs-nestjs", "description": "Generate production-ready NestJS applications from OpenAPI specifications. Includes project scaffolding, controller and service generation, TypeScript best practices, and enterprise patterns.", "version": "1.0.0", @@ -14,11 +15,5 @@ "nodejs", "typescript", "nestjs" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/openapi-to-application-code" ] } diff --git a/plugins/openapi-to-application-python-fastapi/.github/plugin/plugin.json b/plugins/openapi-to-application-python-fastapi/plugin.json similarity index 84% rename from plugins/openapi-to-application-python-fastapi/.github/plugin/plugin.json rename to plugins/openapi-to-application-python-fastapi/plugin.json index 092e8803..74e73392 100644 --- a/plugins/openapi-to-application-python-fastapi/.github/plugin/plugin.json +++ b/plugins/openapi-to-application-python-fastapi/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "openapi-to-application-python-fastapi", "description": "Generate production-ready FastAPI applications from OpenAPI specifications. Includes project scaffolding, route generation, dependency injection, and Python best practices for async APIs.", "version": "1.0.0", @@ -13,11 +14,5 @@ "api", "python", "fastapi" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/openapi-to-application-code" ] } diff --git a/plugins/oracle-to-postgres-migration-expert/.github/plugin/plugin.json b/plugins/oracle-to-postgres-migration-expert/plugin.json similarity index 54% rename from plugins/oracle-to-postgres-migration-expert/.github/plugin/plugin.json rename to plugins/oracle-to-postgres-migration-expert/plugin.json index d0a10213..2c3a0dae 100644 --- a/plugins/oracle-to-postgres-migration-expert/.github/plugin/plugin.json +++ b/plugins/oracle-to-postgres-migration-expert/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "oracle-to-postgres-migration-expert", "description": "Expert agent for Oracle-to-PostgreSQL application migrations in .NET solutions. Performs code edits, runs commands, and invokes extension tools to migrate .NET/Oracle data access patterns to PostgreSQL.", "version": "1.0.0", @@ -16,17 +17,5 @@ "migration", "integration-testing", "stored-procedures" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/creating-oracle-to-postgres-master-migration-plan", - "./skills/creating-oracle-to-postgres-migration-bug-report", - "./skills/creating-oracle-to-postgres-migration-integration-tests", - "./skills/migrating-oracle-to-postgres-stored-procedures", - "./skills/planning-oracle-to-postgres-migration-integration-testing", - "./skills/reviewing-oracle-to-postgres-migration", - "./skills/scaffolding-oracle-to-postgres-migration-test-project" ] } diff --git a/plugins/ospo-sponsorship/.github/plugin/plugin.json b/plugins/ospo-sponsorship/plugin.json similarity index 81% rename from plugins/ospo-sponsorship/.github/plugin/plugin.json rename to plugins/ospo-sponsorship/plugin.json index 65e25605..9bc62029 100644 --- a/plugins/ospo-sponsorship/.github/plugin/plugin.json +++ b/plugins/ospo-sponsorship/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "ospo-sponsorship", "description": "Tools and resources for Open Source Program Offices (OSPOs) to identify, evaluate, and manage sponsorship of open source dependencies through GitHub Sponsors, Open Collective, and other funding platforms.", "version": "1.0.0", @@ -6,8 +7,5 @@ "name": "Awesome Copilot Community" }, "repository": "https://github.com/github/awesome-copilot", - "license": "MIT", - "skills": [ - "./skills/sponsor-finder" - ] + "license": "MIT" } diff --git a/plugins/partners/.github/plugin/plugin.json b/plugins/partners/plugin.json similarity index 85% rename from plugins/partners/.github/plugin/plugin.json rename to plugins/partners/plugin.json index ffaac25e..e9f696dc 100644 --- a/plugins/partners/.github/plugin/plugin.json +++ b/plugins/partners/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "partners", "description": "Custom agents that have been created by GitHub partners", "version": "1.0.0", @@ -18,8 +19,5 @@ "cicd", "migration", "performance" - ], - "agents": [ - "./agents" ] } diff --git a/plugins/pcf-development/.github/plugin/plugin.json b/plugins/pcf-development/plugin.json similarity index 85% rename from plugins/pcf-development/.github/plugin/plugin.json rename to plugins/pcf-development/plugin.json index a692982d..d6a02935 100644 --- a/plugins/pcf-development/.github/plugin/plugin.json +++ b/plugins/pcf-development/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "pcf-development", "description": "Complete toolkit for developing custom code components using Power Apps Component Framework for model-driven and canvas apps", "version": "1.0.0", diff --git a/plugins/phoenix/.github/plugin/plugin.json b/plugins/phoenix/plugin.json similarity index 83% rename from plugins/phoenix/.github/plugin/plugin.json rename to plugins/phoenix/plugin.json index de2a4609..b07d72c0 100644 --- a/plugins/phoenix/.github/plugin/plugin.json +++ b/plugins/phoenix/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "phoenix", "description": "Phoenix AI observability skills for LLM application debugging, evaluation, and tracing. Includes CLI debugging tools, LLM evaluation workflows, and OpenInference tracing instrumentation.", "version": "1.0.0", @@ -16,10 +17,5 @@ "evaluation", "openinference", "instrumentation" - ], - "skills": [ - "./skills/phoenix-cli", - "./skills/phoenix-evals", - "./skills/phoenix-tracing" ] } diff --git a/plugins/php-mcp-development/.github/plugin/plugin.json b/plugins/php-mcp-development/plugin.json similarity index 85% rename from plugins/php-mcp-development/.github/plugin/plugin.json rename to plugins/php-mcp-development/plugin.json index d07e16f1..649dcfca 100644 --- a/plugins/php-mcp-development/.github/plugin/plugin.json +++ b/plugins/php-mcp-development/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "php-mcp-development", "description": "Comprehensive resources for building Model Context Protocol servers using the official PHP SDK with attribute-based discovery, including best practices, project generation, and expert assistance", "version": "1.0.0", @@ -15,11 +16,5 @@ "sdk", "attributes", "composer" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/php-mcp-server-generator" ] } diff --git a/plugins/power-apps-code-apps/.github/plugin/plugin.json b/plugins/power-apps-code-apps/plugin.json similarity index 85% rename from plugins/power-apps-code-apps/.github/plugin/plugin.json rename to plugins/power-apps-code-apps/plugin.json index c64f85c4..0707707a 100644 --- a/plugins/power-apps-code-apps/.github/plugin/plugin.json +++ b/plugins/power-apps-code-apps/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "power-apps-code-apps", "description": "Complete toolkit for Power Apps Code Apps development including project scaffolding, development standards, and expert guidance for building code-first applications with Power Platform integration.", "version": "1.0.0", @@ -15,11 +16,5 @@ "code-apps", "dataverse", "connectors" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/power-apps-code-app-scaffold" ] } diff --git a/plugins/power-bi-development/.github/plugin/plugin.json b/plugins/power-bi-development/plugin.json similarity index 71% rename from plugins/power-bi-development/.github/plugin/plugin.json rename to plugins/power-bi-development/plugin.json index b9a7f0a0..6e60d2b6 100644 --- a/plugins/power-bi-development/.github/plugin/plugin.json +++ b/plugins/power-bi-development/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "power-bi-development", "description": "Comprehensive Power BI development resources including data modeling, DAX optimization, performance tuning, visualization design, security best practices, and DevOps/ALM guidance for building enterprise-grade Power BI solutions.", "version": "1.0.0", @@ -16,14 +17,5 @@ "security", "devops", "business-intelligence" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/power-bi-dax-optimization", - "./skills/power-bi-model-design-review", - "./skills/power-bi-performance-troubleshooting", - "./skills/power-bi-report-design-consultation" ] } diff --git a/plugins/power-platform-architect/.github/plugin/plugin.json b/plugins/power-platform-architect/plugin.json similarity index 84% rename from plugins/power-platform-architect/.github/plugin/plugin.json rename to plugins/power-platform-architect/plugin.json index ebc58286..08c494b6 100644 --- a/plugins/power-platform-architect/.github/plugin/plugin.json +++ b/plugins/power-platform-architect/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "power-platform-architect", "description": "Solution Architect for the Microsoft Power Platform, turning business requirements into functioning Power Platform solution architectures.", "version": "1.0.0", @@ -15,8 +16,5 @@ "name": "Tim Hanewich" }, "repository": "https://github.com/github/awesome-copilot", - "license": "MIT", - "skills": [ - "./skills/power-platform-architect" - ] + "license": "MIT" } diff --git a/plugins/power-platform-mcp-connector-development/.github/plugin/plugin.json b/plugins/power-platform-mcp-connector-development/plugin.json similarity index 75% rename from plugins/power-platform-mcp-connector-development/.github/plugin/plugin.json rename to plugins/power-platform-mcp-connector-development/plugin.json index 4bd57ffa..a28f25ec 100644 --- a/plugins/power-platform-mcp-connector-development/.github/plugin/plugin.json +++ b/plugins/power-platform-mcp-connector-development/plugin.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "power-platform-mcp-connector-development", "description": "Complete toolkit for developing Power Platform custom connectors with Model Context Protocol integration for Microsoft Copilot Studio", "version": "1.0.0", @@ -13,12 +14,5 @@ "copilot-studio", "custom-connector", "json-rpc" - ], - "agents": [ - "./agents" - ], - "skills": [ - "./skills/mcp-copilot-studio-server-generator", - "./skills/power-platform-mcp-connector-suite" ] } diff --git a/plugins/pr-artifact-explorer/README.md b/plugins/pr-artifact-explorer/README.md new file mode 100644 index 00000000..8cd0ce23 --- /dev/null +++ b/plugins/pr-artifact-explorer/README.md @@ -0,0 +1,17 @@ +# Pr Artifact Explorer Plugin + +Navigate pull requests and securely explore GitHub Actions artifacts, including test results, static sites, terminal recordings, and source files. + +## Installation + +``bash +copilot plugin install pr-artifact-explorer@awesome-copilot +`` + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). + +## License + +MIT diff --git a/plugins/pr-artifact-explorer/extensions/pr-artifact-explorer/README.md b/plugins/pr-artifact-explorer/extensions/pr-artifact-explorer/README.md new file mode 100644 index 00000000..84e2f704 --- /dev/null +++ b/plugins/pr-artifact-explorer/extensions/pr-artifact-explorer/README.md @@ -0,0 +1,66 @@ +# Artifact Explorer + +> **GitHub, the missing parts:** GitHub Actions makes artifacts easy to produce, but pull-request reviewers still have to download opaque ZIP files and inspect them out of context. Artifact Explorer turns those artifacts into a fast, GitHub-native, navigable experience inside Copilot. + +Artifact Explorer is a community-built Copilot canvas extension with the internal extension ID `pr-artifact-explorer`. It is not an official GitHub product or endorsed feature. + + + + + Artifact Explorer showing pull request workflow artifacts in a responsive GitHub-style interface + + +## What it does + +- Navigates repositories, pull requests, artifacts, and files with persistent breadcrumbs. +- Loads pull requests progressively, caches result pages, and supports query filters for fast review. +- Bounds artifact discovery to the newest 30 workflow runs per head commit and reports when older runs are omitted. +- Downloads an artifact once, indexes its ZIP, and streams entries directly from the archive without extracting them to disk. +- Previews root-level static sites in a sandboxed loopback frame when the artifact contains `index.html`. +- Plays asciinema terminal recordings and renders structured TRX test reports. +- Syntax-highlights JSON and XML, previews images and PDFs, renders text with safe escaping, and identifies binary content. +- Keeps large files available for direct download and falls back to the original artifact archive when a ZIP entry uses an unsupported encoding. +- Provides per-artifact cleanup, clear-all cleanup, and a cache usage view. +- Uses a responsive Primer interface that follows Copilot's light and dark themes. + +## Security and local data + +Artifact Explorer inherits eligible GitHub accounts from the Copilot app, GitHub CLI, or process environment. Tokens remain inside the extension process: the canvas receives only sanitized account metadata, while authenticated GitHub API requests are made by the local extension server. + +Artifact ZIPs and preferences are stored under: + +```text +$COPILOT_HOME/extensions/pr-artifact-explorer/artifacts/ +``` + +When `COPILOT_HOME` is unset, it defaults to `~/.copilot`. Cached archives remain local until removed through the per-artifact or clear-all controls. + +The canvas server validates its canonical loopback host and requires a per-instance capability token for API, artifact-content, and event requests. Static-site previews bind to a separate ephemeral `127.0.0.1` port, validate their loopback host, apply a restrictive content security policy, prevent path traversal, and stream files from the ZIP. A preview requires `index.html` at the artifact root. Static content is untrusted, remains sandboxed by the canvas, and has no access to GitHub tokens. + +## Install + +Ask Copilot to install the committed extension: + +```text +Install this extension: https://github.com/github/awesome-copilot/tree/main/extensions/pr-artifact-explorer +``` + +You can also copy the folder to one of the supported extension locations: + +- `~/.copilot/extensions/pr-artifact-explorer/` for user scope +- `.github/extensions/pr-artifact-explorer/` for project scope + +Reload extensions, then ask Copilot to open the `pr-artifact-explorer` canvas. You can optionally provide a repository (`owner/name`), pull request number, or GitHub pull request URL when opening it. + +## Agent actions + +- `open_pull_request { repository, pullNumber }` - navigate an open canvas to a pull request. +- `inspect_artifact { repository, artifactId }` - download, index, detect, and open an Actions artifact. +- `cache_status` - report local artifact cache usage. +- `clear_cache { artifactId? }` - remove one cached artifact or clear the entire cache. +- `accounts` - list sanitized GitHub account metadata available to the extension. +- `set_account { id }` - select the GitHub account used for repository and artifact requests. + +## Included third-party components + +The extension bundles Primer CSS, Octicons, and the asciinema player for offline rendering. Their notices are preserved in `third-party-licenses/`. diff --git a/plugins/pr-artifact-explorer/extensions/pr-artifact-explorer/accounts.mjs b/plugins/pr-artifact-explorer/extensions/pr-artifact-explorer/accounts.mjs new file mode 100644 index 00000000..8542f520 --- /dev/null +++ b/plugins/pr-artifact-explorer/extensions/pr-artifact-explorer/accounts.mjs @@ -0,0 +1,329 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { GITHUB_API, USER_AGENT } from "./constants.mjs"; + +const execFileAsync = promisify(execFile); +const CACHE_TTL_MS = 5 * 60 * 1000; +const RESOLUTION_CACHE_LIMIT = 20; +const SOURCE_PRIORITY = Object.freeze({ copilot: 30, gh: 20, env: 10 }); +const SOURCE_LABEL = Object.freeze({ + copilot: "Copilot app", + gh: "GitHub CLI", + env: "Environment", +}); + +const cachedResolutions = new Map(); + +function cleanGhEnvironment() { + const env = { ...process.env }; + delete env.GH_TOKEN; + delete env.GITHUB_TOKEN; + return env; +} + +function tokenHash(token) { + return createHash("sha256").update(token).digest("hex").slice(0, 12); +} + +function decodeCopilotLabel(variableName) { + let encoded = variableName.replace(/^COPILOT_GH_ACCOUNT_/, ""); + encoded = encoded.replace(/_([0-9A-Fa-f]{2})_/g, (_, hex) => + String.fromCharCode(Number.parseInt(hex, 16))); + return encoded.replace(/^github\.com_/, "") || null; +} + +async function listGhAccounts() { + let output = ""; + try { + const result = await execFileAsync("gh", ["auth", "status"], { + timeout: 8_000, + env: cleanGhEnvironment(), + windowsHide: true, + }); + output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + } catch (error) { + if (error?.code === "ENOENT") return []; + output = `${error?.stdout ?? ""}\n${error?.stderr ?? ""}`; + if (!output.trim()) return []; + } + + const accounts = []; + const seen = new Set(); + const pattern = /Logged in to (\S+) account (\S+)/g; + let match; + while ((match = pattern.exec(output)) !== null) { + const key = `${match[1].toLowerCase()}/${match[2].toLowerCase()}`; + if (!seen.has(key)) { + seen.add(key); + accounts.push({ host: match[1], login: match[2] }); + } + } + return accounts; +} + +async function readGhToken(host, login) { + try { + const { stdout } = await execFileAsync( + "gh", + ["auth", "token", "--hostname", host, "--user", login], + { + timeout: 8_000, + env: cleanGhEnvironment(), + windowsHide: true, + }, + ); + return stdout.trim() || null; + } catch (error) { + if (error?.code === "ENOENT") return null; + return null; + } +} + +async function detectCandidates() { + const candidates = []; + const seenTokens = new Set(); + const add = (source, token, hintedLogin) => { + if (!token) return; + const hash = tokenHash(token); + if (seenTokens.has(hash)) return; + seenTokens.add(hash); + candidates.push({ source, token, hash, hintedLogin: hintedLogin ?? null }); + }; + + for (const [name, value] of Object.entries(process.env)) { + if (name.startsWith("COPILOT_GH_ACCOUNT_") && value) { + add("copilot", value, decodeCopilotLabel(name)); + } + } + add("env", process.env.GH_TOKEN, null); + add("env", process.env.GITHUB_TOKEN, null); + + for (const account of await listGhAccounts()) { + if (account.host.toLowerCase() !== "github.com") continue; + add("gh", await readGhToken(account.host, account.login), account.login); + } + return candidates; +} + +async function apiProbe(token, path) { + const response = await fetch(`${GITHUB_API}${path}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "User-Agent": USER_AGENT, + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(12_000), + }); + const payload = await response.json().catch(() => null); + return { response, payload }; +} + +async function probeCandidate(candidate, repository) { + const safe = { + source: candidate.source, + hash: candidate.hash, + hintedLogin: candidate.hintedLogin, + }; + + try { + const viewer = await apiProbe(candidate.token, "/user"); + if (!viewer.response.ok || !viewer.payload?.login) { + return { + ...safe, + token: candidate.token, + login: candidate.hintedLogin ?? "unknown", + avatarUrl: null, + status: "failed", + repositoryAccess: false, + reason: viewer.payload?.message ?? `Authentication failed (${viewer.response.status}).`, + scopes: [], + }; + } + + let repositoryAccess = null; + let repositoryReason = null; + if (repository) { + const access = await apiProbe( + candidate.token, + `/repos/${repository.split("/").map(encodeURIComponent).join("/")}`, + ); + repositoryAccess = access.response.ok; + if (!repositoryAccess) { + repositoryReason = access.payload?.message ?? `Repository check failed (${access.response.status}).`; + } + } + + const scopesHeader = viewer.response.headers.get("x-oauth-scopes") ?? ""; + return { + ...safe, + token: candidate.token, + login: viewer.payload.login, + avatarUrl: viewer.payload.avatar_url ?? null, + name: viewer.payload.name ?? null, + profileUrl: viewer.payload.html_url ?? null, + type: viewer.payload.type ?? null, + company: viewer.payload.company ?? null, + status: repositoryAccess === false ? "limited" : "ok", + repositoryAccess, + reason: repositoryReason, + scopes: scopesHeader.split(",").map((scope) => scope.trim()).filter(Boolean), + }; + } catch (error) { + return { + ...safe, + token: candidate.token, + login: candidate.hintedLogin ?? "unknown", + avatarUrl: null, + name: null, + profileUrl: null, + type: null, + company: null, + status: "failed", + repositoryAccess: false, + reason: error instanceof Error ? error.message : String(error), + scopes: [], + }; + } +} + +function accountId(login) { + return `acct:${String(login).toLowerCase()}`; +} + +function candidateScore(candidate) { + let score = SOURCE_PRIORITY[candidate.source] ?? 0; + if (candidate.status === "ok") score += 1_000; + if (candidate.repositoryAccess === true) score += 10_000; + if (candidate.status === "failed") score -= 100_000; + return score; +} + +function publicAccount(account) { + const { token, ...safe } = account; + return safe; +} + +export function invalidateAccounts() { + cachedResolutions.clear(); +} + +async function resolveAccountsUncached(preferredId, repository) { + const probes = await Promise.all( + (await detectCandidates()).map((candidate) => probeCandidate(candidate, repository)), + ); + const groups = new Map(); + for (const probe of probes) { + const keyForLogin = String(probe.login).toLowerCase(); + if (!groups.has(keyForLogin)) groups.set(keyForLogin, []); + groups.get(keyForLogin).push(probe); + } + + const accounts = []; + for (const [loginKey, candidates] of groups) { + candidates.sort((left, right) => candidateScore(right) - candidateScore(left)); + const best = candidates[0]; + accounts.push({ + id: accountId(loginKey), + login: best.login, + avatarUrl: best.avatarUrl ?? candidates.find((item) => item.avatarUrl)?.avatarUrl ?? null, + name: best.name ?? candidates.find((item) => item.name)?.name ?? null, + profileUrl: + best.profileUrl ?? candidates.find((item) => item.profileUrl)?.profileUrl ?? null, + type: best.type ?? candidates.find((item) => item.type)?.type ?? null, + company: best.company ?? candidates.find((item) => item.company)?.company ?? null, + status: best.status, + repositoryAccess: best.repositoryAccess, + reason: best.reason ?? null, + scopes: best.scopes, + sourceKinds: [...new Set(candidates.map((item) => item.source))], + sources: candidates.map((item, index) => ({ + label: SOURCE_LABEL[item.source] ?? item.source, + source: item.source, + hash: item.hash, + status: item.status, + repositoryAccess: item.repositoryAccess, + reason: item.reason ?? null, + scopes: item.scopes, + chosen: index === 0, + })), + token: best.token, + }); + } + accounts.sort((left, right) => { + const leftAccess = left.repositoryAccess === true ? 1 : 0; + const rightAccess = right.repositoryAccess === true ? 1 : 0; + if (leftAccess !== rightAccess) return rightAccess - leftAccess; + if (left.status !== right.status) return left.status === "ok" ? -1 : 1; + return left.login.localeCompare(right.login); + }); + + let active = preferredId + ? accounts.find((account) => account.id === preferredId && account.status !== "failed") + : null; + active ??= accounts.find((account) => account.repositoryAccess === true); + active ??= accounts.find((account) => account.status === "ok"); + active ??= null; + + const value = { + active: active ? publicAccount(active) : null, + activeToken: active?.token ?? null, + accounts: accounts.map(publicAccount), + }; + return value; +} + +export async function resolveAccounts({ + preferredId = null, + repository = null, + force = false, +} = {}) { + const key = `${preferredId ?? ""}|${repository ?? ""}`; + let entry = cachedResolutions.get(key); + if (!force && entry?.value && Date.now() - entry.at < CACHE_TTL_MS) { + cachedResolutions.delete(key); + cachedResolutions.set(key, entry); + return entry.value; + } + if (entry?.promise && (!force || entry.forceRefresh)) return entry.promise; + + entry ??= { + at: 0, + forceRefresh: false, + promise: null, + value: null, + }; + const promise = resolveAccountsUncached(preferredId, repository).then( + (value) => { + if (cachedResolutions.get(key) === entry && entry.promise === promise) { + entry.at = Date.now(); + entry.forceRefresh = false; + entry.promise = null; + entry.value = value; + cachedResolutions.delete(key); + cachedResolutions.set(key, entry); + } + return value; + }, + (error) => { + if (cachedResolutions.get(key) === entry && entry.promise === promise) { + entry.forceRefresh = false; + entry.promise = null; + if (!entry.value || Date.now() - entry.at >= CACHE_TTL_MS) { + cachedResolutions.delete(key); + } + } + throw error; + }, + ); + entry.forceRefresh = force; + entry.promise = promise; + cachedResolutions.set(key, entry); + while (cachedResolutions.size > RESOLUTION_CACHE_LIMIT) { + const candidate = [...cachedResolutions].find(([, value]) => !value.promise); + if (!candidate) break; + cachedResolutions.delete(candidate[0]); + } + return promise; +} diff --git a/plugins/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/app.css b/plugins/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/app.css new file mode 100644 index 00000000..d25e82b4 --- /dev/null +++ b/plugins/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/app.css @@ -0,0 +1,3814 @@ +:root { + --app-bg: var(--bgColor-default, var(--background-color-default, #ffffff)); + --app-bg-muted: var(--bgColor-muted, var(--background-color-muted, #f6f8fa)); + --app-bg-inset: var(--bgColor-inset, #f6f8fa); + --app-fg: var(--fgColor-default, var(--text-color-default, #1f2328)); + --app-fg-muted: var(--fgColor-muted, var(--text-color-muted, #59636e)); + --app-border: var(--borderColor-default, var(--border-color-default, #d1d9e0)); + --app-border-muted: var(--borderColor-muted, #d8dee4); + --app-accent: var(--fgColor-accent, var(--true-color-blue, #0969da)); + --app-success: var(--fgColor-success, var(--true-color-green, #1a7f37)); + --app-danger: var(--fgColor-danger, var(--true-color-red, #cf222e)); + --app-attention: var(--fgColor-attention, var(--true-color-yellow, #9a6700)); + --app-done: var(--fgColor-done, #8250df); + --app-header-bg: rgb(from var(--header-bgColor, #25292e) r g b / 1); + --app-radius: 6px; + --app-font: var( + --fontStack-system, + var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif) + ); + --app-mono: var( + --fontStack-monospace, + var(--font-mono, "SFMono-Regular", Consolas, "Liberation Mono", monospace) + ); +} + +[data-color-mode="dark"] { + --app-header-bg: rgb(21, 27, 35); +} + +@media (prefers-color-scheme: dark) { + [data-color-mode="auto"] { + --app-header-bg: rgb(21, 27, 35); + } +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 320px; + min-height: 100%; +} + +body { + margin: 0; + color: var(--app-fg); + background: var(--app-bg); + font-family: var(--app-font); +} + +button, +input, +select { + font-family: inherit; +} + +.app-button { + display: inline-flex; + height: 32px; + padding: 0 var(--base-size-12, 12px); + color: var(--button-default-fgColor-rest, var(--app-fg)); + font-size: var(--text-body-size-small, 12px); + font-weight: var(--base-text-weight-semibold, 600); + line-height: 30px; + text-decoration: none; + white-space: nowrap; + background: var(--button-default-bgColor-rest, var(--app-bg-muted)); + border: 1px solid var(--button-default-borderColor-rest, var(--app-border)); + border-radius: var(--app-radius); + box-shadow: var(--button-default-shadow-resting, 0 1px 0 rgb(31 35 40 / 4%)); + align-items: center; + justify-content: center; + gap: var(--base-size-6, 6px); + cursor: pointer; +} + +.app-button:hover:not(:disabled) { + color: var(--button-default-fgColor-rest, var(--app-fg)); + text-decoration: none; + background: var(--button-default-bgColor-hover, var(--app-bg-inset)); + border-color: var(--button-default-borderColor-hover, var(--app-border)); +} + +.app-button:active:not(:disabled) { + background: var(--button-default-bgColor-active, var(--app-bg-inset)); + border-color: var(--button-default-borderColor-active, var(--app-border)); +} + +.app-button:focus-visible { + outline: 2px solid var(--focus-outlineColor, var(--app-accent)); + outline-offset: 2px; +} + +.app-button:disabled, +.app-button[aria-disabled="true"] { + color: var(--button-default-fgColor-disabled, var(--app-fg-muted)); + background: var(--button-default-bgColor-disabled, var(--app-bg-muted)); + border-color: var(--button-default-borderColor-disabled, var(--app-border-muted)); + box-shadow: none; + cursor: not-allowed; + opacity: 0.65; +} + +.app-button-primary { + color: var(--button-primary-fgColor-rest, #ffffff); + background: var(--button-primary-bgColor-rest, #1f883d); + border-color: var(--button-primary-borderColor-rest, rgb(31 35 40 / 15%)); +} + +.app-button-primary:hover:not(:disabled) { + color: var(--button-primary-fgColor-rest, #ffffff); + background: var(--button-primary-bgColor-hover, #1a7f37); + border-color: var(--button-primary-borderColor-hover, rgb(31 35 40 / 15%)); +} + +.app-button-danger { + color: var(--button-danger-fgColor-rest, var(--app-danger)); + background: var(--button-danger-bgColor-rest, var(--button-default-bgColor-rest, var(--app-bg-muted))); + border-color: var(--button-danger-borderColor-rest, var(--app-border)); +} + +.app-button-danger:hover:not(:disabled) { + color: var(--button-danger-fgColor-hover, #ffffff); + background: var(--button-danger-bgColor-hover, var(--app-danger)); + border-color: var(--button-danger-borderColor-hover, var(--app-danger)); +} + +.app-button-invisible { + color: var(--button-invisible-fgColor-rest, var(--app-fg)); + background: var(--button-invisible-bgColor-rest, transparent); + border-color: var(--button-invisible-borderColor-rest, transparent); + box-shadow: none; +} + +.app-button-invisible:hover:not(:disabled) { + color: var(--button-invisible-fgColor-hover, var(--app-fg)); + background: var(--button-invisible-bgColor-hover, var(--app-bg-muted)); + border-color: var(--button-invisible-borderColor-hover, transparent); +} + +.app-button-small { + height: 28px; + padding: 0 var(--base-size-8, 8px); + line-height: 26px; +} + +.app-button-icon-only { + width: 28px; + padding: 0; +} + +.app-button > .octicon { + width: 16px; + height: 16px; + flex: none; +} + +.app-shell { + min-height: 100dvh; +} + +.app-header { + position: sticky; + top: 0; + z-index: 30; + min-height: 60px; + gap: var(--base-size-12, 12px); + padding: var(--base-size-12, 12px) var(--base-size-16, 16px); + background: var(--app-header-bg); +} + +.app-brand { + gap: var(--base-size-8, 8px); + margin-right: 0; + font-weight: 400; +} + +.app-brand-mark { + width: 28px; + height: 28px; +} + +.repository-form { + display: flex; + max-width: 620px; + margin-right: auto; + gap: var(--base-size-8, 8px); + align-items: center; +} + +.repository-combobox { + position: relative; + width: 100%; + min-width: 220px; +} + +.repository-input-shell { + position: relative; + display: flex; + height: 34px; + overflow: hidden; + color: var(--header-fgColor-default, #ffffff); + background: transparent; + border: 1px solid var(--headerSearch-borderColor, rgb(255 255 255 / 24%)); + border-radius: var(--app-radius); + align-items: center; +} + +.repository-combobox.is-open .repository-input-shell, +.repository-input-shell:focus-within { + background: transparent; + border-color: var(--focus-outlineColor, var(--color-accent-fg, #58a6ff)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-outlineColor, #58a6ff) 35%, transparent); +} + +.repository-input { + flex: 1; + width: 100%; + min-width: 0; + height: 32px; + padding: 0 34px; + color: inherit; + background: transparent; + border: 0; + outline: 0; +} + +.repository-input::placeholder { + color: var(--header-fgColor-logo, rgb(255 255 255 / 72%)); +} + +.repository-input[aria-invalid="true"] { + color: var(--header-fgColor-default, #ffffff); +} + +.repository-search-icon { + position: absolute; + top: 50%; + left: 10px; + z-index: 1; + color: rgb(255 255 255 / 75%); + transform: translateY(-50%); + pointer-events: none; +} + +.repository-search-icon.spin { + animation-name: repository-search-spin; + transform-origin: center; +} + +.repository-menu-button { + position: absolute; + right: 0; + display: grid; + width: 32px; + height: 32px; + padding: 0; + color: rgb(255 255 255 / 78%); + background: transparent; + border: 0; + border-left: 1px solid rgb(255 255 255 / 14%); + place-items: center; +} + +.repository-menu-button:hover { + color: #ffffff; + background: rgb(255 255 255 / 10%); +} + +.repository-menu-button:focus-visible, +.account-chip:focus-visible { + outline: 2px solid var(--focus-outlineColor, #58a6ff); + outline-offset: 2px; +} + +.repository-panel { + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 60; + width: min(520px, calc(100vw - 32px)); + max-height: min(520px, calc(100vh - 100px)); + overflow: hidden auto; + color: var(--app-fg); + background: var(--overlay-bgColor, var(--app-bg)); + border: 1px solid var(--app-border); + border-radius: var(--borderRadius-large, 12px); + box-shadow: var(--shadow-floating-large, 0 8px 24px rgb(140 149 159 / 20%)); +} + +.repository-panel[hidden] { + display: none; +} + +.repository-panel-header, +.repository-panel-footer { + display: flex; + padding: var(--base-size-8, 8px) var(--base-size-12, 12px); + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + background: var(--app-bg-muted); + align-items: center; + justify-content: space-between; + gap: var(--base-size-12, 12px); +} + +.repository-panel-header { + position: sticky; + top: 0; + z-index: 1; + border-bottom: 1px solid var(--app-border); +} + +.repository-panel-header strong { + color: var(--app-fg); + font-size: var(--text-body-size-medium, 14px); +} + +.repository-panel-footer { + position: sticky; + bottom: 0; + border-top: 1px solid var(--app-border); + display: block; + line-height: var(--text-body-lineHeight-medium, 20px); + font-size: var(--text-body-size-medium, 14px); +} + +.repository-panel-footer code { + margin: 0 2px; + padding: 2px 5px; + color: var(--app-fg); + font-family: var(--app-mono); + font-size: inherit; + background: var(--app-bg); + border-radius: var(--borderRadius-medium, 6px); +} + +.repository-results { + padding: var(--base-size-8, 8px); +} + +.repository-result-group + .repository-result-group { + padding-top: var(--base-size-8, 8px); + margin-top: var(--base-size-8, 8px); + border-top: 1px solid var(--app-border-muted); +} + +.repository-result-heading { + padding: 2px var(--base-size-8, 8px) var(--base-size-4, 4px); + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + font-weight: var(--base-text-weight-semibold, 600); +} + +.repository-result { + display: flex; + min-width: 0; + border-radius: var(--app-radius); + align-items: stretch; +} + +.repository-result:has(.repository-result-main.is-active), +.repository-result:hover { + background: var(--control-transparent-bgColor-hover, var(--app-bg-muted)); +} + +.repository-result-main { + display: flex; + min-width: 0; + flex: 1; + padding: var(--base-size-8, 8px); + color: var(--app-fg); + text-align: left; + background: transparent; + border: 0; + border-radius: var(--app-radius); + align-items: flex-start; + gap: var(--base-size-8, 8px); +} + +.repository-result-main:focus-visible { + outline: 2px solid var(--focus-outlineColor, var(--app-accent)); + outline-offset: -2px; +} + +.repository-result-main > .octicon { + flex: none; + margin-top: 2px; + color: var(--app-fg-muted); +} + +.repository-result-copy { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.repository-result-name { + display: flex; + min-width: 0; + font-weight: var(--base-text-weight-semibold, 600); + align-items: center; + gap: var(--base-size-6, 6px); +} + +.repository-result-description { + overflow: hidden; + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + text-overflow: ellipsis; + white-space: nowrap; +} + +.repository-result-actions { + display: inline-flex; + flex: none; + padding-right: var(--base-size-4, 4px); + align-items: center; + gap: 2px; +} + +.repository-result-action { + display: grid; + width: 28px; + height: 28px; + padding: 0; + color: var(--app-fg-muted); + background: transparent; + border: 1px solid transparent; + border-radius: var(--app-radius); + place-items: center; +} + +.repository-result-action:hover:not(:disabled), +.repository-result-action.is-selected { + color: var(--app-accent); + background: var(--control-transparent-bgColor-hover, var(--app-bg-muted)); + border-color: var(--app-border); +} + +.repository-result-action:disabled { + opacity: 0.35; +} + +.repository-result-status { + display: flex; + min-height: 48px; + padding: var(--base-size-12, 12px); + color: var(--app-fg-muted); + align-items: center; + justify-content: center; + gap: var(--base-size-8, 8px); +} + +.repository-result-status.error { + color: var(--app-danger); +} + +.account-control, +.header-cache-item { + margin-right: 0; +} + +.account-chip { + display: grid; + width: 36px; + height: 36px; + padding: 0; + color: var(--header-fgColor-default, #ffffff); + text-decoration: none; + background: transparent; + border: 0; + border-radius: 50%; + place-items: center; +} + +.account-chip:hover, +.account-chip.is-active { + color: #ffffff; + text-decoration: none; + background: rgb(255 255 255 / 10%); +} + +.account-avatar, +.account-avatar-fallback { + display: grid; + width: 32px; + height: 32px; + overflow: hidden; + flex: none; + background: rgb(255 255 255 / 10%); + border: 1px solid rgb(255 255 255 / 28%); + border-radius: 50%; + place-items: center; +} + +.account-avatar-fallback .octicon { + width: 13px; + height: 13px; +} + +.artifact-filter { + min-width: 180px; + width: min(300px, 32vw); + flex: 1 1 240px; +} + +.cache-link { + display: inline-flex; + gap: var(--base-size-6, 6px); + align-items: center; + font-weight: 400; +} + +.breadcrumb-bar { + position: sticky; + top: 60px; + z-index: 20; + min-height: 42px; + padding: 0 var(--base-size-16, 16px); + overflow: visible; + background: var(--app-bg); + border-bottom: 1px solid var(--app-border); +} + +.breadcrumbs { + position: relative; + display: flex; + width: 100%; + max-width: 1280px; + min-height: 41px; + padding: 0; + margin: 0 auto; + overflow: visible; + list-style: none; + align-items: center; +} + +.breadcrumb-item { + display: inline-flex; + min-width: 0; + max-width: min(24vw, 320px); + flex: none; + align-items: center; + white-space: nowrap; +} + +.breadcrumb-item + .breadcrumb-item::before { + display: block; + width: 16px; + height: 16px; + margin: 0 var(--base-size-4, 4px); + flex: none; + content: ""; + background: var(--app-fg-muted); + mask: url("/assets/octicons/chevron-right.svg") center / 16px 16px no-repeat; + opacity: 0.55; +} + +.breadcrumb-item a { + display: block; + min-width: 0; + overflow: hidden; + color: var(--app-fg-muted); + text-decoration: none; +} + +.breadcrumb-item a:hover { + color: var(--app-accent); + text-decoration: underline; +} + +.breadcrumb-item-static { + color: var(--app-fg-muted); + cursor: default; +} + +.breadcrumb-item-label, +.breadcrumb-label-full, +.breadcrumb-label-compact { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.breadcrumb-label-compact { + display: none; +} + +.breadcrumb-item-root { + max-width: min(28vw, 240px); +} + +.breadcrumb-item[aria-current="page"] { + min-width: 0; + max-width: none; + overflow: hidden; + flex: 1; + color: var(--app-fg); + font-weight: 600; +} + +.breadcrumb-item[aria-current="page"] .breadcrumb-item-label { + width: 100%; +} + +.breadcrumb-overflow { + position: static; + display: none; + max-width: none; +} + +.breadcrumb-overflow-trigger { + display: grid; + width: 28px; + height: 28px; + padding: 0; + color: var(--app-fg-muted); + background: transparent; + border: 1px solid transparent; + border-radius: var(--app-radius); + place-items: center; + cursor: pointer; +} + +.breadcrumb-overflow-trigger:hover, +.breadcrumb-overflow.is-open .breadcrumb-overflow-trigger { + color: var(--app-fg); + background: var(--app-bg-muted); + border-color: var(--app-border); +} + +.breadcrumb-overflow-trigger:focus-visible, +.breadcrumb-overflow-option:focus-visible { + outline: 2px solid var(--focus-outlineColor, var(--app-accent)); + outline-offset: -2px; +} + +.breadcrumb-overflow-menu { + position: absolute; + top: calc(100% + var(--base-size-8, 8px)); + left: 0; + z-index: 30; + width: min(320px, calc(100vw - 32px)); + overflow: hidden; + color: var(--app-fg); + background: var(--overlay-bgColor, var(--app-bg)); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); + box-shadow: var(--shadow-floating-large, 0 8px 24px rgb(140 149 159 / 20%)); +} + +.breadcrumb-overflow-menu[hidden] { + display: none; +} + +.breadcrumb-overflow-header { + min-height: 36px; + padding: var(--base-size-8, 8px) var(--base-size-12, 12px); + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + font-weight: var(--base-text-weight-semibold, 600); + line-height: 20px; + border-bottom: 1px solid var(--app-border); +} + +.breadcrumb-overflow-option { + display: block; + min-height: 36px; + padding: var(--base-size-8, 8px) var(--base-size-12, 12px); + overflow: hidden; + color: var(--app-fg); + font-size: var(--text-body-size-small, 12px); + line-height: 20px; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + border-top: 1px solid var(--app-border-muted); +} + +.breadcrumb-overflow-header + .breadcrumb-overflow-option { + border-top: 0; +} + +.breadcrumb-overflow-option:hover { + color: var(--app-fg); + text-decoration: none; + background: var(--app-bg-muted); +} + +.breadcrumb-overflow-option-static, +.breadcrumb-overflow-option-static:hover { + color: var(--app-fg-muted); + background: transparent; + cursor: default; +} + +.app-main { + width: 100%; + padding: var(--base-size-24, 24px) var(--base-size-16, 16px) + var(--base-size-40, 40px); +} + +.page-header { + display: flex; + gap: var(--base-size-16, 16px); + margin-bottom: var(--base-size-16, 16px); + align-items: flex-start; + justify-content: space-between; +} + +.page-title { + margin: 0; + font-size: 24px; + font-weight: 400; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.page-title strong { + font-weight: 600; +} + +.page-title-number { + color: var(--app-fg-muted); + font-weight: 300; +} + +.page-subtitle { + margin: var(--base-size-4, 4px) 0 0; + color: var(--app-fg-muted); +} + +.page-actions { + display: flex; + gap: var(--base-size-8, 8px); + flex: none; + align-items: center; +} + +.repo-heading { + display: inline-flex; + gap: var(--base-size-8, 8px); + align-items: center; +} + +.pulls-repo-heading { + font-size: var(--text-title-size-medium, 20px); +} + +.Box { + overflow: hidden; + background: var(--app-bg); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); +} + +.Box-header { + display: flex; + min-height: 46px; + padding: var(--base-size-8, 8px) var(--base-size-16, 16px); + background: var(--app-bg-muted); + border-bottom: 1px solid var(--app-border); + align-items: center; +} + +.Box-title { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +.Box-footer { + display: flex; + min-height: 40px; + padding: var(--base-size-8, 8px) var(--base-size-16, 16px); + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + line-height: 20px; + background: var(--app-bg-muted); + border-top: 1px solid var(--app-border); + align-items: center; + justify-content: space-between; + gap: var(--base-size-16, 16px); +} + +.Box-footer-count { + margin-left: auto; + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; +} + +.Box-row { + position: relative; + display: flex; + min-width: 0; + gap: var(--base-size-12, 12px); + padding: var(--base-size-12, 12px) var(--base-size-16, 16px); + border-top: 1px solid var(--app-border-muted); +} + +.Box-header + .Box-row { + border-top: 0; +} + +.Box-row:hover { + background: var(--app-bg-muted); +} + +.Box-row-main { + min-width: 0; + flex: 1; +} + +.Box-row-title { + color: var(--app-fg); + font-weight: 600; + overflow-wrap: anywhere; +} + +.Box-row-title:hover { + color: var(--app-accent); +} + +.title-inline-code { + padding: 2px 4px; + margin: 0; + color: inherit; + white-space: normal; + background: var(--bgColor-neutral-muted, rgb(110 118 129 / 40%)); + border-radius: var(--app-radius); + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", + monospace; + font-size: 90%; + font-weight: inherit; + line-height: 1; +} + +.Box-row-meta { + margin-top: var(--base-size-4, 4px); + color: var(--app-fg-muted); + font-size: 12px; +} + +.Box-row-actions { + display: flex; + gap: var(--base-size-8, 8px); + flex: none; + align-items: center; +} + +.pull-icon { + margin-top: 2px; + color: var(--app-success); + flex: none; +} + +.pull-icon.draft { + color: var(--app-fg-muted); +} + +.pull-icon.closed { + color: var(--app-danger); +} + +.pull-icon.merged { + color: var(--app-done); +} + +.labels { + display: inline-flex; + gap: var(--base-size-4, 4px); + margin-left: var(--base-size-6, 6px); + flex-wrap: wrap; + vertical-align: middle; +} + +.issue-label { + display: inline-flex; + min-height: 18px; + padding: 0 6px; + border: 1px solid color-mix(in srgb, var(--label-color, var(--app-border)) 65%, transparent); + border-radius: 2em; + color: var(--label-text-light, var(--app-fg)); + background: color-mix(in srgb, var(--label-color, var(--app-border)) 18%, transparent); + font-size: 11px; + font-weight: 500; + line-height: 16px; + align-items: center; +} + +[data-color-mode="dark"] .issue-label { + color: var(--label-text-dark, var(--app-fg)); +} + +@media (prefers-color-scheme: dark) { + [data-color-mode="auto"] .issue-label { + color: var(--label-text-dark, var(--app-fg)); + } +} + +.toolbar { + display: flex; + gap: var(--base-size-8, 8px); + align-items: center; +} + +.toolbar-spacer { + flex: 1; +} + +.filter-select { + width: auto; + min-width: 112px; +} + +.artifact-list-box { + overflow: visible; +} + +.artifact-toolbar { + position: relative; + gap: var(--base-size-8, 8px); + flex-wrap: wrap; +} + +.artifact-toolbar .Box-title { + display: inline-flex; + gap: var(--base-size-8, 8px); + align-items: center; +} + +.artifact-refresh { + margin-left: auto; + flex: none; +} + +.artifact-filter-picker { + position: relative; + flex: none; +} + +.artifact-filter-controls { + display: flex; + align-items: center; + gap: var(--base-size-8, 8px); +} + +.artifact-filter-trigger { + max-width: 220px; + font-weight: 400; +} + +.artifact-filter-trigger > span { + overflow: hidden; + text-overflow: ellipsis; +} + +.artifact-filter-trigger.is-filtered { + color: var(--app-accent); + background: var( + --button-default-bgColor-rest, + var(--app-bg-muted) + ); + border-color: var(--borderColor-accent-emphasis, var(--app-accent)); +} + +.artifact-filter-picker.is-open .artifact-filter-trigger { + background: var(--button-default-bgColor-active, var(--app-bg-inset)); + border-color: var(--button-default-borderColor-active, var(--app-border)); +} + +.artifact-filter-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 25; + width: min(300px, calc(100vw - 32px)); + overflow: hidden; + color: var(--app-fg); + background: var(--overlay-bgColor, var(--app-bg)); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); + box-shadow: var(--shadow-floating-large, 0 8px 24px rgb(140 149 159 / 20%)); +} + +.artifact-filter-panel[hidden] { + display: none; +} + +.artifact-filter-options { + max-height: min(320px, 60dvh); + overflow-y: auto; +} + +.artifact-filter-panel-header { + display: flex; + min-height: 40px; + padding: var(--base-size-4, 4px) var(--base-size-8, 8px) + var(--base-size-4, 4px) var(--base-size-12, 12px); + border-bottom: 1px solid var(--app-border); + align-items: center; + justify-content: space-between; + gap: var(--base-size-8, 8px); +} + +.artifact-filter-panel-header strong { + font-size: var(--text-body-size-small, 12px); +} + +.artifact-filter-close { + display: grid; + width: 28px; + height: 28px; + padding: 0; + color: var(--app-fg-muted); + background: transparent; + border: 0; + border-radius: var(--app-radius); + place-items: center; + cursor: pointer; +} + +.artifact-filter-close:hover { + color: var(--app-fg); + background: var(--app-bg-muted); +} + +.artifact-filter-option { + display: flex; + width: 100%; + min-height: 36px; + padding: var(--base-size-6, 6px) var(--base-size-12, 12px); + color: var(--app-fg); + text-align: left; + background: transparent; + border: 0; + border-top: 1px solid var(--app-border-muted); + align-items: center; + gap: var(--base-size-8, 8px); + cursor: pointer; +} + +.artifact-filter-option:first-child { + border-top: 0; +} + +.artifact-filter-option:hover { + background: var(--app-bg-muted); +} + +.artifact-filter-option[aria-checked="true"] { + background: var( + --bgColor-accent-muted, + color-mix(in srgb, var(--app-accent) 12%, transparent) + ); +} + +.artifact-filter-check { + display: grid; + width: 16px; + height: 16px; + flex: none; + color: var(--app-accent); + place-items: center; +} + +.artifact-filter-option-copy { + min-width: 0; + overflow: hidden; + flex: 1; + font-size: var(--text-body-size-small, 12px); + text-overflow: ellipsis; + white-space: nowrap; +} + +.artifact-filter-option-count { + min-width: 2ch; + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.artifact-filter-trigger:focus-visible, +.artifact-filter-close:focus-visible, +.artifact-filter-option:focus-visible { + outline: 2px solid var(--focus-outlineColor, var(--app-accent)); + outline-offset: -2px; +} + +.pull-query-form { + display: block; + margin-bottom: var(--base-size-16, 16px); +} + +.pull-query-input-wrap { + position: relative; + min-width: 0; + background: var(--control-bgColor-rest, var(--app-bg)); + border-radius: var(--app-radius); +} + +.pull-query-input-wrap > .octicon { + position: absolute; + top: 50%; + left: var(--base-size-12, 12px); + z-index: 3; + color: var(--app-fg-muted); + transform: translateY(-50%); + pointer-events: none; +} + +.pull-query-highlight { + position: absolute; + inset: 0; + z-index: 1; + overflow: hidden; + padding: 6px var(--base-size-12, 12px) 6px 36px; + color: var(--app-fg); + font-family: var(--app-mono); + font-size: var(--text-body-size-small, 12px); + line-height: 20px; + white-space: pre; + pointer-events: none; +} + +.pull-query-highlight > span { + display: inline-block; + transform: translateX(0); +} + +.pull-query-syntax-negation { + color: var(--fgColor-danger, var(--app-danger)); + font-weight: var(--base-text-weight-semibold, 600); +} + +.pull-query-syntax-qualifier { + color: var(--fgColor-accent, var(--app-accent)); + font-weight: var(--base-text-weight-semibold, 600); +} + +.pull-query-syntax-punctuation { + color: var(--app-fg-muted); +} + +.pull-query-syntax-value { + color: var(--fgColor-done, var(--app-fg)); +} + +.pull-query-input { + position: relative; + z-index: 2; + width: 100%; + height: 34px; + padding: 5px var(--base-size-12, 12px) 5px 36px; + color: transparent; + caret-color: var(--app-fg); + font-family: var(--app-mono); + font-size: var(--text-body-size-small, 12px); + line-height: 20px; + background: transparent; + -webkit-text-fill-color: transparent; +} + +.pull-query-input::placeholder { + color: var(--app-fg-muted); + opacity: 1; + -webkit-text-fill-color: var(--app-fg-muted); +} + +.pull-query-suggestions { + position: absolute; + top: calc(100% + var(--base-size-4, 4px)); + right: 0; + left: 0; + z-index: 25; + overflow: hidden; + color: var(--app-fg); + background: var(--app-bg); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); + box-shadow: var(--shadow-floating-small, 0 8px 24px rgb(140 149 159 / 20%)); +} + +.pull-query-suggestion-heading { + padding: var(--base-size-8, 8px) var(--base-size-12, 12px); + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + font-weight: var(--base-text-weight-semibold, 600); + background: var(--app-bg-muted); + border-bottom: 1px solid var(--app-border-muted); +} + +.pull-query-suggestion-options { + overflow-y: auto; + max-height: 280px; + padding: var(--base-size-4, 4px); +} + +.pull-query-suggestion { + display: grid; + width: 100%; + padding: var(--base-size-8, 8px); + color: var(--app-fg); + text-align: left; + background: transparent; + border: 0; + border-radius: var(--app-radius); + grid-template-columns: minmax(130px, 0.38fr) minmax(0, 1fr); + align-items: baseline; + gap: var(--base-size-12, 12px); +} + +.pull-query-suggestion:hover, +.pull-query-suggestion.is-active { + background: var(--control-transparent-bgColor-hover, var(--app-bg-muted)); +} + +.pull-query-suggestion:focus-visible { + outline: 2px solid var(--focus-outlineColor, var(--app-accent)); + outline-offset: -2px; +} + +.pull-query-suggestion code { + overflow: hidden; + color: var(--app-fg); + font-family: var(--app-mono); + font-size: var(--text-body-size-small, 12px); + font-weight: var(--base-text-weight-semibold, 600); + text-overflow: ellipsis; + white-space: nowrap; +} + +.pull-query-suggestion > span { + overflow: hidden; + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + text-overflow: ellipsis; + white-space: nowrap; +} + +.pull-query-suggestion-footer { + display: flex; + padding: var(--base-size-8, 8px) var(--base-size-12, 12px); + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + background: var(--app-bg-muted); + border-top: 1px solid var(--app-border-muted); + gap: var(--base-size-16, 16px); +} + +.pull-query-suggestion-footer kbd { + padding: 1px 5px; + color: var(--app-fg); + font-family: var(--app-mono); + font-size: 11px; + background: var(--app-bg); + border: 1px solid var(--app-border); + border-bottom-width: 2px; + border-radius: var(--borderRadius-small, 4px); + box-shadow: none; +} + +@media (forced-colors: active) { + .pull-query-highlight { + display: none; + } + + .pull-query-input { + color: CanvasText; + -webkit-text-fill-color: CanvasText; + } +} + +.pull-list-box { + overflow: visible; +} + +.pull-list-progress { + display: flex; + min-height: 36px; + padding: var(--base-size-8, 8px) var(--base-size-16, 16px); + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + background: var(--app-bg-muted); + border-bottom: 1px solid var(--app-border); + align-items: center; + gap: var(--base-size-8, 8px); +} + +.pull-list-refresh-status { + display: inline-flex; + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + align-items: center; + gap: var(--base-size-4, 4px); +} + +.pull-list-box.is-loading > .pull-list-rows, +.pull-list-box.is-loading > #pull-list-footer { + opacity: 0.55; + pointer-events: none; +} + +.pull-list-toolbar { + position: relative; + border-radius: var(--app-radius) var(--app-radius) 0 0; +} + +.pull-state-picker, +.pull-author-picker, +.pull-signal-picker { + position: relative; +} + +.pull-state-trigger { + display: inline-flex; + height: 32px; + padding: 0 var(--base-size-8, 8px); + color: var(--app-fg-muted); + font-size: var(--text-body-size-medium, 14px); + font-weight: 400; + background: transparent; + border: 1px solid transparent; + border-radius: var(--app-radius); + align-items: center; + gap: var(--base-size-4, 4px); +} + +.pull-state-trigger:hover, +.pull-state-picker.is-open .pull-state-trigger, +.pull-author-picker.is-open .pull-state-trigger, +.pull-signal-picker.is-open .pull-state-trigger { + color: var(--app-fg); + background: var(--control-transparent-bgColor-hover, var(--app-bg-inset)); + border-color: var(--app-border); +} + +.pull-state-trigger.is-filtered { + color: var(--app-accent); + font-weight: var(--base-text-weight-semibold, 600); +} + +.pull-filter-value { + max-width: 150px; + overflow: hidden; + color: var(--app-fg-muted); + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pull-state-trigger:focus-visible, +.pull-state-close:focus-visible, +.pull-state-option:focus-visible { + outline: 2px solid var(--focus-outlineColor, var(--app-accent)); + outline-offset: -2px; +} + +.pull-state-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 25; + width: min(340px, calc(100vw - 32px)); + overflow: hidden; + color: var(--app-fg); + background: var(--overlay-bgColor, var(--app-bg)); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); + box-shadow: var(--shadow-floating-large, 0 8px 24px rgb(140 149 159 / 20%)); +} + +.pull-state-panel[hidden], +.pull-author-panel[hidden] { + display: none; +} + +.pull-author-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 25; + width: min(340px, calc(100vw - 32px)); + overflow: hidden; + color: var(--app-fg); + background: var(--overlay-bgColor, var(--app-bg)); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); + box-shadow: var(--shadow-floating-large, 0 8px 24px rgb(140 149 159 / 20%)); +} + +.pull-state-panel-header { + display: flex; + min-height: 40px; + padding: var(--base-size-4, 4px) var(--base-size-8, 8px) + var(--base-size-4, 4px) var(--base-size-12, 12px); + border-bottom: 1px solid var(--app-border); + align-items: center; + justify-content: space-between; + gap: var(--base-size-8, 8px); +} + +.pull-state-panel-header strong { + font-size: var(--text-body-size-small, 12px); +} + +.pull-state-close { + display: grid; + width: 28px; + height: 28px; + padding: 0; + color: var(--app-fg-muted); + background: transparent; + border: 0; + border-radius: var(--app-radius); + place-items: center; +} + +.pull-state-close:hover { + color: var(--app-fg); + background: var(--app-bg-muted); +} + +.pull-state-option { + display: flex; + width: 100%; + min-height: 48px; + padding: var(--base-size-6, 6px) var(--base-size-12, 12px); + color: var(--app-fg); + text-align: left; + background: transparent; + border: 0; + border-top: 1px solid var(--app-border-muted); + align-items: center; + gap: var(--base-size-8, 8px); +} + +.pull-state-option:first-child { + border-top: 0; +} + +.pull-state-option:hover { + background: var(--app-bg-muted); +} + +.pull-state-option[aria-checked="true"] { + background: var(--bgColor-accent-muted, color-mix(in srgb, var(--app-accent) 12%, transparent)); +} + +.pull-signal-option { + min-height: 40px; +} + +.pull-state-check { + display: grid; + width: 16px; + height: 16px; + flex: none; + color: var(--app-accent); + place-items: center; +} + +.pull-state-option strong, +.pull-state-option small { + display: block; +} + +.pull-author-filter-wrap { + position: relative; + padding: var(--base-size-8, 8px); + border-bottom: 1px solid var(--app-border); +} + +.pull-author-filter-wrap > .octicon { + position: absolute; + top: 50%; + left: 18px; + color: var(--app-fg-muted); + transform: translateY(-50%); + pointer-events: none; +} + +.pull-author-filter-wrap .form-control { + width: 100%; + padding-left: 32px; +} + +.pull-author-options { + max-height: 288px; + overflow: auto; +} + +.pull-author-option { + display: grid; + width: 100%; + min-height: 40px; + padding: var(--base-size-6, 6px) var(--base-size-12, 12px); + color: var(--app-fg); + text-align: left; + background: transparent; + border: 0; + border-top: 1px solid var(--app-border-muted); + grid-template-columns: 16px 20px minmax(0, 1fr); + align-items: center; + gap: var(--base-size-8, 8px); +} + +.pull-author-option:first-child { + border-top: 0; +} + +.pull-author-option:hover, +.pull-author-option[aria-checked="true"] { + background: var(--app-bg-muted); +} + +.pull-author-option:focus-visible { + outline: 2px solid var(--focus-outlineColor, var(--app-accent)); + outline-offset: -2px; +} + +.pull-author-check { + display: grid; + width: 16px; + color: var(--app-accent); + place-items: center; +} + +.pull-author-avatar { + display: grid; + width: 20px; + height: 20px; + overflow: hidden; + background: var(--app-bg-muted); + border: 1px solid var(--app-border); + border-radius: 50%; + object-fit: cover; + place-items: center; +} + +.pull-author-avatar-fallback { + color: var(--app-fg-muted); +} + +.pull-author-avatar-fallback .octicon { + width: 12px; + height: 12px; +} + +.pull-author-identity { + display: flex; + min-width: 0; + align-items: baseline; + gap: var(--base-size-6, 6px); +} + +.pull-author-identity strong { + flex: none; +} + +.pull-author-name { + min-width: 0; + color: var(--app-fg-muted); + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pull-author-status { + display: flex; + min-height: 44px; + padding: var(--base-size-8, 8px); + color: var(--app-fg-muted); + align-items: center; + justify-content: center; + gap: var(--base-size-8, 8px); +} + +.pull-author-status.error { + color: var(--app-danger); +} + +.pull-state-option small { + margin-top: 0; + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + line-height: 16px; +} + +.pull-row { + min-height: 70px; +} + +.pull-row .Box-row-title { + font-size: 16px; + line-height: 24px; +} + +.pull-list-toolbar .Box-title { + display: inline-flex; + align-items: center; + gap: var(--base-size-8, 8px); +} + +.pull-list-heading-icon { + color: var(--app-fg-muted); +} + +.pull-row-title-line, +.pull-row-meta { + display: flex; + min-width: 0; + align-items: center; + flex-wrap: wrap; +} + +.pull-row-title-line { + gap: var(--base-size-4, 4px); +} + +.pull-row-title-line .labels { + margin-left: var(--base-size-2, 2px); +} + +.pull-check { + display: inline-grid; + width: 16px; + height: 16px; + flex: none; + place-items: center; +} + +.pull-check .octicon { + width: 16px; + height: 16px; +} + +.pull-check.passing { + color: var(--app-success); +} + +.pull-check.failing { + color: var(--app-danger); +} + +.pull-check.pending { + color: var(--app-attention); +} + +.pull-row-meta { + gap: var(--base-size-6, 6px); +} + +.author-association { + display: inline-flex; + min-height: 20px; + padding: 0 7px; + border: 1px solid var(--app-border); + border-radius: 2em; + align-items: center; +} + +.pull-review-status { + display: inline-flex; + align-items: center; +} + +.pull-review-status::before { + margin-right: var(--base-size-6, 6px); + color: var(--app-fg-muted); + content: "•"; +} + +.pull-comments { + display: inline-flex; + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + align-items: center; + gap: var(--base-size-4, 4px); +} + +.state-badge { + display: inline-flex; + gap: var(--base-size-6, 6px); + min-height: 32px; + padding: 5px 12px; + border-radius: 2em; + color: #ffffff; + background: var(--app-success); + font-size: 14px; + font-weight: 600; + align-items: center; +} + +.state-badge.closed { + background: var(--app-danger); +} + +.state-badge.merged { + background: var(--app-done); +} + +.state-badge.draft { + background: var(--app-fg-muted); +} + +.pr-summary { + display: flex; + gap: var(--base-size-8, 8px); + margin-bottom: var(--base-size-24, 24px); + color: var(--app-fg-muted); + align-items: center; + flex-wrap: wrap; +} + +.avatar { + width: 20px; + height: 20px; + border-radius: 50%; +} + +.artifact-icon { + margin-top: 2px; + color: var(--app-fg-muted); + flex: none; + width: 14px; + height: 14px; +} + +.artifact-row.is-clickable, +.cached-artifact-row.is-clickable { + cursor: pointer; +} + +.artifact-row.is-downloading, +.artifact-row.is-downloading .artifact-row-overlay, +.artifact-row.is-downloading .Box-row-actions .app-button:disabled { + cursor: progress; +} + +.artifact-row.is-downloading .Box-row-actions .app-button:disabled { + opacity: 1; +} + +.artifact-row.has-transfer-row, +.artifact-row.has-transfer-row:hover, +.artifact-transfer-row { + background: color-mix(in srgb, var(--app-accent) 4%, var(--app-bg)); + box-shadow: inset 3px 0 0 var(--app-accent); +} + +.artifact-transfer-row { + display: grid; + min-width: 0; + padding: var(--base-size-10, 10px) var(--base-size-16, 16px) + var(--base-size-12, 12px); + border-top: 1px solid color-mix( + in srgb, + var(--app-accent) 18%, + var(--app-border-muted) + ); + grid-template-columns: 14px minmax(0, 1fr); + gap: var(--base-size-12, 12px); +} + +.artifact-transfer-icon { + width: 14px; + height: 14px; + margin-top: 2px; + color: var(--app-accent); +} + +.artifact-transfer-row .artifact-download-progress { + max-width: none; + padding: 0; + margin: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.artifact-row-overlay { + position: absolute; + inset: 0; + z-index: 1; + padding: 0; + background: transparent; + border: 0; + border-radius: inherit; + cursor: pointer; +} + +.artifact-row-overlay:focus-visible { + outline: 2px solid var(--focus-outlineColor, var(--app-accent)); + outline-offset: -2px; +} + +.artifact-row.is-expired { + color: var(--app-fg-muted); +} + +.artifact-row .Box-row-actions, +.cached-artifact-row .Box-row-actions { + position: relative; + z-index: 2; +} + +.artifact-summary { + display: flex; + gap: var(--base-size-8, 8px); + color: var(--app-fg-muted); + font-size: 12px; + align-items: center; + flex-wrap: wrap; +} + +.artifact-download-progress { + max-width: 680px; + margin-top: var(--base-size-8, 8px); + padding: var(--base-size-8, 8px) var(--base-size-10, 10px); + background: var(--app-bg-muted); + border: 1px solid var(--app-border-muted); + border-radius: var(--app-radius); +} + +.artifact-download-copy { + display: flex; + min-width: 0; + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + justify-content: space-between; + gap: var(--base-size-12, 12px); +} + +.artifact-download-phrase { + min-width: 0; + color: var(--app-fg); + font-weight: 600; +} + +.artifact-download-stats { + flex: none; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.artifact-download-stats:empty { + display: none; +} + +.artifact-download-track { + height: 6px; + margin-top: var(--base-size-6, 6px); + overflow: hidden; + background: var(--app-bg-inset); + border-radius: 2em; +} + +.artifact-download-bar { + display: block; + width: var(--artifact-download-percent, 0%); + height: 100%; + background: var(--app-accent); + border-radius: inherit; + transition: width 200ms linear; +} + +.artifact-download-progress.is-indeterminate .artifact-download-bar { + width: 35%; + animation: artifact-download-indeterminate 1.2s ease-in-out infinite; +} + +.artifact-download-progress.is-error { + border-color: var(--app-danger); +} + +.artifact-download-progress.is-error .artifact-download-bar { + background: var(--app-danger); +} + +@keyframes artifact-download-indeterminate { + from { + transform: translateX(-120%); + } + + to { + transform: translateX(360%); + } +} + +.artifact-summary-item { + display: inline-flex; + gap: var(--base-size-4, 4px); + align-items: center; +} + +.artifact-summary-item .octicon { + width: 14px; + height: 14px; +} + +.status-success { + color: var(--app-success); +} + +.status-danger { + color: var(--app-danger); +} + +.status-attention { + color: var(--app-attention); +} + +.status-muted { + color: var(--app-fg-muted); +} + +.account-repository-context { + margin-bottom: var(--base-size-16, 16px); +} + +.account-list { + display: grid; + gap: var(--base-size-12, 12px); +} + +.account-card { + overflow: hidden; + background: var(--app-bg); + border: 1px solid var(--app-border); + border-radius: var(--borderRadius-large, 12px); +} + +.account-card.is-active { + border-color: var(--borderColor-accent-emphasis, var(--app-accent)); + box-shadow: inset 0 0 0 1px + color-mix(in srgb, var(--borderColor-accent-emphasis, var(--app-accent)) 24%, transparent); +} + +.account-card-main { + display: flex; + width: 100%; + min-width: 0; + padding: var(--base-size-16, 16px); + color: var(--app-fg); + text-align: left; + background: transparent; + border: 0; + align-items: center; + gap: var(--base-size-12, 12px); +} + +.account-card-main:not(:disabled):hover { + background: var(--app-bg-muted); +} + +.account-card-main:disabled { + color: var(--app-fg); + cursor: default; + opacity: 1; +} + +.account-card-main:focus-visible { + outline: 2px solid var(--focus-outlineColor, var(--app-accent)); + outline-offset: -2px; +} + +.account-card-avatar { + display: grid; + width: 44px; + height: 44px; + overflow: hidden; + flex: none; + background: var(--app-bg-muted); + border: 1px solid var(--app-border); + border-radius: 50%; + object-fit: cover; + place-items: center; +} + +.account-card-avatar-fallback .octicon { + width: 22px; + height: 22px; + color: var(--app-fg-muted); +} + +.account-card-identity { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.account-card-title { + display: flex; + min-width: 0; + font-size: var(--text-title-size-small, 16px); + align-items: center; + gap: var(--base-size-8, 8px); + flex-wrap: wrap; +} + +.account-card-login, +.account-card-company { + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); +} + +.account-access { + display: inline-flex; + flex: none; + padding: 4px 8px; + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + font-weight: var(--base-text-weight-semibold, 600); + background: var(--app-bg-muted); + border: 1px solid var(--app-border); + border-radius: 2em; + align-items: center; + gap: var(--base-size-4, 4px); +} + +.account-access.available { + color: var(--app-success); +} + +.account-access.limited { + color: var(--app-attention); +} + +.account-access.failed { + color: var(--app-danger); +} + +.account-card-footer { + display: flex; + min-height: 45px; + padding: var(--base-size-8, 8px) var(--base-size-16, 16px); + background: var(--app-bg-muted); + border-top: 1px solid var(--app-border-muted); + align-items: center; + justify-content: space-between; + gap: var(--base-size-12, 12px); +} + +.account-scope-list { + display: flex; + min-width: 0; + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + align-items: center; + gap: var(--base-size-4, 4px); + flex-wrap: wrap; +} + +.account-scope-list code { + padding: 2px 7px; + color: var(--app-fg-muted); + background: var(--app-bg); + border: 1px solid var(--app-border-muted); + border-radius: 2em; + font-family: var(--app-mono); + font-size: 11px; + line-height: 16px; +} + +.account-sources { + border-top: 1px solid var(--app-border-muted); +} + +.account-sources summary { + display: flex; + min-height: 40px; + padding: var(--base-size-8, 8px) var(--base-size-16, 16px); + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + list-style: none; + align-items: center; + justify-content: space-between; + gap: var(--base-size-12, 12px); + cursor: pointer; +} + +.account-sources summary::-webkit-details-marker { + display: none; +} + +.account-sources summary:hover { + color: var(--app-fg); + background: var(--app-bg-muted); +} + +.account-sources summary:focus-visible { + outline: 2px solid var(--focus-outlineColor, var(--app-accent)); + outline-offset: -2px; +} + +.account-sources-chevron { + flex: none; + transition: transform 120ms ease-out; +} + +.account-sources[open] .account-sources-chevron { + transform: rotate(180deg); +} + +.credential-source-list { + border-top: 1px solid var(--app-border-muted); +} + +.credential-source { + display: flex; + padding: var(--base-size-12, 12px) var(--base-size-16, 16px); + border-top: 1px solid var(--app-border-muted); + align-items: flex-start; + gap: var(--base-size-8, 8px); +} + +.credential-source:first-child { + border-top: 0; +} + +.credential-source-icon { + color: var(--app-success); +} + +.credential-source-copy { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.credential-source-name { + display: flex; + font-weight: var(--base-text-weight-semibold, 600); + align-items: center; + gap: var(--base-size-8, 8px); +} + +.credential-source-meta, +.credential-source-error { + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); +} + +.credential-source-error { + color: var(--app-danger); +} + +.explorer { + display: grid; + min-height: min(680px, calc(100dvh - 220px)); + overflow: hidden; + border: 1px solid var(--app-border); + border-radius: var(--app-radius); + grid-template-columns: minmax(230px, 310px) minmax(0, 1fr); +} + +.explorer.is-sidebar-collapsed { + grid-template-columns: minmax(0, 1fr); +} + +.explorer.is-sidebar-collapsed .file-panel { + display: none; +} + +.file-panel { + position: relative; + z-index: 2; + min-width: 0; + background: var(--app-bg); + border-right: 1px solid var(--app-border); +} + +.file-panel-header { + position: relative; + padding: var(--base-size-12, 12px); + background: var(--app-bg-muted); + border-bottom: 1px solid var(--app-border); +} + +.file-filter-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) 32px; + gap: var(--base-size-8, 8px); +} + +.file-filter-wrap { + position: relative; +} + +.file-filter { + width: 100%; + padding-left: 32px; +} + +.file-filter-icon { + position: absolute; + top: 50%; + left: 10px; + color: var(--app-fg-muted); + transform: translateY(-50%); + pointer-events: none; +} + +.file-tree-filter-root { + position: relative; +} + +.file-tree-filter-trigger { + width: 32px; + height: 32px; +} + +.file-tree-filter-trigger.is-filtered { + color: var(--app-accent); + background: var(--bgColor-accent-muted, var(--app-bg)); + border-color: var(--borderColor-accent-emphasis, var(--app-accent)); +} + +.file-tree-filter-root.is-open .file-tree-filter-trigger { + background: var(--button-default-bgColor-active, var(--app-bg-inset)); +} + +.file-tree-filter-menu { + position: absolute; + top: calc(100% + var(--base-size-8, 8px)); + left: 0; + z-index: 35; + width: min(260px, calc(100vw - 32px)); + overflow: hidden; + color: var(--app-fg); + background: var(--overlay-bgColor, var(--app-bg)); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); + box-shadow: var(--shadow-floating-large, 0 8px 24px rgb(140 149 159 / 20%)); +} + +.file-tree-filter-menu[hidden] { + display: none; +} + +.file-tree-filter-heading { + padding: var(--base-size-12, 12px) var(--base-size-12, 12px) + var(--base-size-8, 8px); + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + font-weight: var(--base-text-weight-semibold, 600); +} + +.file-tree-filter-options { + max-height: min(240px, 45dvh); + padding-bottom: var(--base-size-4, 4px); + overflow-y: auto; +} + +.file-tree-filter-categories { + padding: var(--base-size-4, 4px) 0; + border-top: 1px solid var(--app-border-muted); +} + +.file-tree-filter-option { + display: grid; + width: 100%; + min-height: 36px; + padding: var(--base-size-6, 6px) var(--base-size-12, 12px); + color: var(--app-fg); + text-align: left; + background: transparent; + border: 0; + grid-template-columns: 16px minmax(0, 1fr) auto; + align-items: center; + gap: var(--base-size-8, 8px); + cursor: pointer; +} + +.file-tree-filter-option:hover { + background: var(--app-bg-muted); +} + +.file-tree-filter-check { + display: grid; + width: 16px; + height: 16px; + color: var(--app-fg-muted); + place-items: center; +} + +.file-tree-filter-option[aria-checked="true"] .file-tree-filter-check { + color: var(--app-fg); +} + +.file-tree-filter-copy { + min-width: 0; + overflow: hidden; + font-size: var(--text-body-size-small, 12px); + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-tree-filter-option .Counter { + min-width: 24px; + font-variant-numeric: tabular-nums; + text-align: center; +} + +.file-tree-filter-trigger:focus-visible, +.file-tree-filter-option:focus-visible { + outline: 2px solid var(--focus-outlineColor, var(--app-accent)); + outline-offset: -2px; +} + +.file-list { + max-height: min(620px, calc(100dvh - 278px)); + padding: var(--base-size-4, 4px) 0; + margin: 0; + overflow: auto; + list-style: none; +} + +.file-tree-group { + padding: 0; + margin: 0; + list-style: none; +} + +.file-tree-item { + margin: 0; +} + +.file-tree-row { + display: grid; + width: 100%; + min-height: 30px; + padding: var(--base-size-4, 4px) var(--base-size-8, 8px) + var(--base-size-4, 4px) + calc(var(--base-size-8, 8px) + var(--file-tree-depth, 0) * 16px); + color: var(--app-fg); + font-size: var(--text-body-size-small, 12px); + text-align: left; + background: transparent; + border: 0; + align-items: center; + gap: var(--base-size-6, 6px); + cursor: pointer; +} + +.file-tree-directory { + grid-template-columns: 12px 16px minmax(0, 1fr); + font-weight: var(--base-text-weight-semibold, 600); +} + +.file-tree-file { + grid-template-columns: 12px 16px minmax(0, 1fr) auto; +} + +.file-tree-row:hover { + background: var(--app-bg-muted); +} + +.file-tree-row:focus-visible { + position: relative; + z-index: 1; + outline: 2px solid var(--focus-outlineColor, var(--app-accent)); + outline-offset: -2px; +} + +.file-tree-file[aria-current="true"] { + background: var(--bgColor-accent-muted, #ddf4ff); +} + +.file-tree-chevron { + width: 12px; + height: 12px; + color: var(--app-fg-muted); +} + +.file-tree-folder, +.file-tree-file-icon { + color: var(--app-fg-muted); +} + +.file-tree-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-tree-size { + color: var(--app-fg-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.file-tree-spacer { + width: 12px; + height: 1px; +} + +.preview-panel { + position: relative; + z-index: 1; + display: flex; + min-width: 0; + min-height: 0; + background: var(--app-bg-inset); + flex-direction: column; +} + +.preview-toolbar { + display: flex; + min-height: 48px; + gap: var(--base-size-8, 8px); + padding: var(--base-size-8, 8px) var(--base-size-12, 12px); + background: var(--app-bg); + border-bottom: 1px solid var(--app-border); + align-items: center; +} + +.preview-path { + min-width: 0; + overflow: hidden; + font-family: var(--app-mono); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.preview-content { + position: relative; + display: grid; + min-height: 0; + padding: var(--base-size-16, 16px); + overflow: auto; + place-items: start stretch; + flex: 1; +} + +.preview-frame { + width: 100%; + min-height: min(600px, calc(100dvh - 310px)); + background: var(--app-bg); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); +} + +.preview-image { + max-width: 100%; + max-height: min(600px, calc(100dvh - 310px)); + margin: auto; + object-fit: contain; +} + +.text-preview { + width: 100%; + min-height: 100%; + padding: var(--base-size-16, 16px); + margin: 0; + overflow: auto; + color: var(--app-fg); + background: var(--app-bg); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); + font-family: var(--app-mono); + font-size: 12px; + line-height: 1.5; + tab-size: 2; + white-space: pre; +} + +.syntax-code code { + color: inherit; + font: inherit; +} + +.syntax-xml .syntax-punctuation { + color: var(--app-fg-muted); +} + +.syntax-xml .syntax-tag { + color: var(--app-done); +} + +.syntax-xml .syntax-attribute { + color: var(--app-accent); +} + +.syntax-xml .syntax-string, +.syntax-xml .syntax-cdata { + color: var(--app-success); +} + +.syntax-xml .syntax-comment { + color: var(--app-fg-muted); + font-style: italic; +} + +.syntax-xml .syntax-meta { + color: var(--app-attention); +} + +.syntax-json .syntax-key { + color: var(--app-accent); +} + +.syntax-json .syntax-string { + color: var(--app-success); +} + +.syntax-json .syntax-number { + color: var(--app-attention); +} + +.syntax-json .syntax-boolean { + color: var(--app-done); + font-weight: var(--base-text-weight-semibold, 600); +} + +.syntax-json .syntax-null { + color: var(--app-fg-muted); + font-style: italic; +} + +.trx-preview { + display: block; + width: 100%; + min-width: 0; +} + +.trx-overview, +.trx-results-panel, +.trx-raw-panel { + overflow: hidden; + background: var(--app-bg); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); +} + +.trx-results-panel, +.trx-raw-panel { + margin-top: var(--base-size-16, 16px); +} + +.trx-run-heading, +.trx-section-heading { + display: flex; + min-width: 0; + padding: var(--base-size-16, 16px); + align-items: flex-start; + justify-content: space-between; + gap: var(--base-size-16, 16px); +} + +.trx-run-heading h2, +.trx-section-heading h3, +.trx-error h2, +.trx-no-results h4 { + margin: 0; + color: var(--app-fg); +} + +.trx-run-heading h2 { + font-size: 20px; + line-height: 28px; + overflow-wrap: anywhere; +} + +.trx-section-heading { + border-bottom: 1px solid var(--app-border-muted); +} + +.trx-section-heading h3 { + font-size: 16px; + line-height: 24px; +} + +.trx-section-heading p { + margin: var(--base-size-2, 2px) 0 0; + color: var(--app-fg-muted); + font-size: 12px; +} + +.trx-eyebrow { + display: block; + margin-bottom: var(--base-size-2, 2px); + color: var(--app-fg-muted); + font-size: 11px; + font-weight: var(--base-text-weight-semibold, 600); + letter-spacing: 0.04em; + line-height: 16px; + text-transform: uppercase; +} + +.trx-run-outcome, +.trx-outcome-badge { + display: inline-flex; + white-space: nowrap; + border: 1px solid currentColor; + border-radius: 999px; + align-items: center; + gap: var(--base-size-4, 4px); +} + +.trx-run-outcome { + min-height: 28px; + padding: 2px var(--base-size-8, 8px); + font-size: 12px; + font-weight: var(--base-text-weight-semibold, 600); +} + +.trx-outcome-passed { + color: var(--app-success); +} + +.trx-outcome-failed { + color: var(--app-danger); +} + +.trx-outcome-skipped { + color: var(--app-attention); +} + +.trx-outcome-other { + color: var(--app-done); +} + +.trx-metrics { + display: grid; + padding: 0 var(--base-size-16, 16px) var(--base-size-16, 16px); + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--base-size-8, 8px); +} + +.trx-metric { + display: grid; + min-width: 0; + padding: var(--base-size-12, 12px); + color: var(--app-fg); + background: var(--app-bg-muted); + border: 1px solid var(--app-border-muted); + border-radius: var(--app-radius); + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: auto auto; + column-gap: var(--base-size-8, 8px); +} + +.trx-metric-icon { + grid-row: 1 / span 2; + align-self: center; +} + +.trx-metric-passed .trx-metric-icon { + color: var(--app-success); +} + +.trx-metric-failed .trx-metric-icon { + color: var(--app-danger); +} + +.trx-metric-skipped .trx-metric-icon { + color: var(--app-attention); +} + +.trx-metric-value { + min-width: 0; + font-size: 20px; + font-weight: var(--base-text-weight-semibold, 600); + font-variant-numeric: tabular-nums; + line-height: 24px; +} + +.trx-metric-label { + color: var(--app-fg-muted); + font-size: 12px; + line-height: 16px; +} + +.trx-run-metadata, +.trx-result-metadata { + display: grid; + margin: 0; + gap: 0; +} + +.trx-run-metadata { + padding: var(--base-size-12, 12px) var(--base-size-16, 16px); + background: var(--app-bg-muted); + border-top: 1px solid var(--app-border-muted); + grid-template-columns: repeat(3, minmax(0, 1fr)); + column-gap: var(--base-size-24, 24px); + row-gap: var(--base-size-12, 12px); +} + +.trx-meta-item { + min-width: 0; +} + +.trx-meta-item dt { + color: var(--app-fg-muted); + font-size: 11px; + font-weight: var(--base-text-weight-semibold, 600); + line-height: 16px; +} + +.trx-meta-item dd { + margin: 0; + overflow: hidden; + color: var(--app-fg); + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; + text-overflow: ellipsis; +} + +.trx-controls { + display: flex; + padding: var(--base-size-12, 12px) var(--base-size-16, 16px); + align-items: center; + gap: var(--base-size-12, 12px); + flex-wrap: wrap; +} + +.trx-search { + position: relative; + display: block; + width: min(320px, 100%); +} + +.trx-search > .octicon { + position: absolute; + top: 8px; + left: 10px; + z-index: 1; + width: 14px; + height: 14px; + color: var(--app-fg-muted); + pointer-events: none; +} + +.trx-search .form-control { + width: 100%; + padding-left: 32px; +} + +.trx-filters { + display: flex; + min-width: 0; + overflow-x: auto; + border: 1px solid var(--app-border); + border-radius: var(--app-radius); +} + +.trx-filter { + display: inline-flex; + min-height: 32px; + padding: 0 var(--base-size-8, 8px); + color: var(--app-fg-muted); + font-size: 12px; + white-space: nowrap; + background: var(--app-bg); + border: 0; + border-left: 1px solid var(--app-border-muted); + align-items: center; + gap: var(--base-size-4, 4px); + cursor: pointer; +} + +.trx-filter:first-child { + border-left: 0; +} + +.trx-filter:hover { + color: var(--app-fg); + background: var(--app-bg-muted); +} + +.trx-filter[aria-pressed="true"] { + color: var(--app-fg); + font-weight: var(--base-text-weight-semibold, 600); + background: var( + --bgColor-accent-muted, + color-mix(in srgb, var(--app-accent) 12%, transparent) + ); +} + +.trx-filter:focus-visible { + position: relative; + z-index: 1; + outline: 2px solid var(--focus-outlineColor, var(--app-accent)); + outline-offset: -2px; +} + +.trx-limit-note { + display: flex; + padding: var(--base-size-8, 8px) var(--base-size-16, 16px); + color: var(--app-fg-muted); + font-size: 12px; + line-height: 18px; + background: var(--app-bg-muted); + border-top: 1px solid var(--app-border-muted); + gap: var(--base-size-8, 8px); +} + +.trx-limit-note .octicon { + margin-top: 1px; +} + +.trx-result-list { + border-top: 1px solid var(--app-border-muted); +} + +.trx-result { + color: var(--app-fg); + border-top: 1px solid var(--app-border-muted); +} + +.trx-result:first-child { + border-top: 0; +} + +.trx-result[hidden] { + display: none; +} + +.trx-result > summary { + display: grid; + min-width: 0; + min-height: 56px; + padding: var(--base-size-8, 8px) var(--base-size-16, 16px); + padding-left: calc( + var(--base-size-16, 16px) + var(--trx-depth, 0) * var(--base-size-12, 12px) + ); + list-style: none; + cursor: pointer; + align-items: center; + grid-template-columns: 16px 16px minmax(0, 1fr) auto auto; + gap: var(--base-size-8, 8px); +} + +.trx-result > summary::-webkit-details-marker { + display: none; +} + +.trx-result > summary:hover { + background: var(--app-bg-muted); +} + +.trx-result > summary:focus-visible { + position: relative; + z-index: 1; + outline: 2px solid var(--focus-outlineColor, var(--app-accent)); + outline-offset: -2px; +} + +.trx-result-chevron { + color: var(--app-fg-muted); + transition: transform 80ms ease; +} + +.trx-result[open] .trx-result-chevron { + transform: rotate(90deg); +} + +.trx-result-status { + color: currentColor; +} + +.trx-result-copy { + display: flex; + min-width: 0; + flex-direction: column; +} + +.trx-result-name { + overflow: hidden; + color: var(--app-fg); + font-size: 13px; + font-weight: var(--base-text-weight-semibold, 600); + line-height: 20px; + overflow-wrap: anywhere; + text-overflow: ellipsis; +} + +.trx-result-secondary { + overflow: hidden; + color: var(--app-fg-muted); + font-size: 11px; + line-height: 16px; + overflow-wrap: anywhere; + text-overflow: ellipsis; +} + +.trx-result-duration { + color: var(--app-fg-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.trx-outcome-badge { + min-height: 22px; + padding: 0 var(--base-size-6, 6px); + font-size: 11px; + font-weight: var(--base-text-weight-semibold, 600); +} + +.trx-result-details { + padding: var(--base-size-16, 16px); + padding-left: calc( + var(--base-size-56, 56px) + var(--trx-depth, 0) * var(--base-size-12, 12px) + ); + background: var(--app-bg-muted); + border-top: 1px solid var(--app-border-muted); +} + +.trx-result-metadata { + margin-bottom: var(--base-size-16, 16px); + grid-template-columns: repeat(3, minmax(0, 1fr)); + column-gap: var(--base-size-24, 24px); + row-gap: var(--base-size-12, 12px); +} + +.trx-output-block, +.trx-result-files, +.trx-properties { + margin-top: var(--base-size-16, 16px); +} + +.trx-output-block:first-child, +.trx-result-files:first-child, +.trx-properties:first-child { + margin-top: 0; +} + +.trx-output-block h4, +.trx-result-files h4, +.trx-properties h4 { + margin: 0 0 var(--base-size-6, 6px); + color: var(--app-fg); + font-size: 12px; + line-height: 18px; +} + +.trx-output-block pre { + max-height: 360px; + padding: var(--base-size-12, 12px); + margin: 0; + overflow: auto; + color: var(--app-fg); + background: var(--app-bg); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); + font-family: var(--app-mono); + font-size: 11px; + line-height: 17px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.trx-truncation-note, +.trx-detail-empty { + margin: var(--base-size-6, 6px) 0 0; + color: var(--app-fg-muted); + font-size: 11px; +} + +.trx-result-files ul { + padding-left: var(--base-size-24, 24px); + margin: 0; + color: var(--app-fg-muted); + font-size: 11px; +} + +.trx-properties dl { + display: grid; + margin: 0; + gap: var(--base-size-4, 4px); +} + +.trx-properties dl > div { + display: grid; + grid-template-columns: minmax(120px, 0.3fr) minmax(0, 1fr); + gap: var(--base-size-12, 12px); +} + +.trx-properties dt { + color: var(--app-fg-muted); + font-size: 11px; + font-weight: var(--base-text-weight-semibold, 600); +} + +.trx-properties dd { + margin: 0; + font-size: 11px; + overflow-wrap: anywhere; +} + +.trx-no-results { + padding: var(--base-size-48, 48px) var(--base-size-16, 16px); + color: var(--app-fg-muted); + text-align: center; +} + +.trx-no-results > .octicon { + width: 24px; + height: 24px; + margin-bottom: var(--base-size-8, 8px); +} + +.trx-no-results h4 { + font-size: 14px; +} + +.trx-no-results p { + margin: var(--base-size-4, 4px) 0 0; + font-size: 12px; +} + +.trx-raw-source { + min-height: 420px; + border: 0; + border-radius: 0; +} + +.trx-error { + display: flex; + padding: var(--base-size-16, 16px); + color: var(--app-danger); + background: var( + --bgColor-danger-muted, + color-mix(in srgb, var(--app-danger) 10%, var(--app-bg)) + ); + border: 1px solid var(--app-danger); + border-radius: var(--app-radius); + gap: var(--base-size-12, 12px); +} + +.trx-error-icon .octicon { + width: 20px; + height: 20px; +} + +.trx-error h2 { + font-size: 16px; + line-height: 20px; +} + +.trx-error p { + margin: var(--base-size-4, 4px) 0 0; + color: var(--app-fg); + font-size: 12px; + line-height: 18px; +} + +.asciinema-preview { + display: flex; + width: 100%; + min-width: 0; + flex-direction: column; + gap: 0; +} + +.player-control-bar { + display: flex; + padding: var(--base-size-12, 12px); + background: var(--app-bg-muted); + border: 1px solid var(--app-border); + border-top: 0; + border-radius: var(--app-radius); + border-top-left-radius: 0; + border-top-right-radius: 0; + align-items: center; + gap: var(--base-size-12, 12px); + flex-wrap: wrap; +} + +.player-control-group { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--base-size-4, 4px); +} + +.player-control-label { + color: var(--app-fg-muted); + font-size: 11px; + font-weight: var(--base-text-weight-semibold, 600); + line-height: 16px; +} + +.player-segmented, +.player-stepper { + display: inline-flex; + overflow: hidden; + background: var(--app-bg); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); + align-items: center; +} + +.player-segment, +.player-step { + height: 28px; + padding: 0 var(--base-size-8, 8px); + color: var(--app-fg-muted); + font-size: var(--text-body-size-small, 12px); + font-weight: 400; + line-height: 26px; + white-space: nowrap; + background: transparent; + border: 0; + border-left: 1px solid var(--app-border-muted); + cursor: pointer; +} + +.player-segment:first-child, +.player-step:first-child { + border-left: 0; +} + +.player-segment:hover, +.player-step:hover:not(:disabled) { + color: var(--app-fg); + background: var(--app-bg-inset); +} + +.player-segment[aria-pressed="true"] { + color: var(--app-fg); + font-weight: var(--base-text-weight-semibold, 600); + background: var( + --bgColor-accent-muted, + color-mix(in srgb, var(--app-accent) 12%, transparent) + ); +} + +.player-segment:focus-visible, +.player-step:focus-visible { + position: relative; + z-index: 1; + outline: 2px solid var(--focus-outlineColor, var(--app-accent)); + outline-offset: -2px; +} + +.player-step:disabled { + color: var(--app-fg-muted); + cursor: not-allowed; + opacity: 0.5; +} + +.player-step-value { + min-width: 48px; + height: 28px; + padding: 0 var(--base-size-6, 6px); + color: var(--app-fg); + font-size: var(--text-body-size-small, 12px); + font-variant-numeric: tabular-nums; + line-height: 28px; + text-align: center; + border-right: 1px solid var(--app-border-muted); + border-left: 1px solid var(--app-border-muted); +} + +.asciinema-host { + width: 100%; + min-width: 0; + overflow: auto; + background: #121314; + border: 1px solid var(--app-border); + border-radius: var(--app-radius); + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.asciinema-host .ap-player { + max-width: 100%; + margin: 0 auto; +} + +.preview-empty { + display: grid; + width: 100%; + min-height: 360px; + padding: var(--base-size-32, 32px); + color: var(--app-fg-muted); + text-align: center; + place-items: center; +} + +.preview-empty-inner { + max-width: 480px; +} + +.preview-empty-inner > .octicon { + width: 32px; + height: 32px; + margin-bottom: var(--base-size-12, 12px); +} + +.cache-stats { + display: flex; + gap: var(--base-size-8, 8px); + color: var(--app-fg-muted); + align-items: center; +} + +.blankslate { + padding: var(--base-size-64, 64px) var(--base-size-24, 24px); + color: var(--app-fg-muted); + text-align: center; +} + +.blankslate .octicon { + width: 32px; + height: 32px; + margin-bottom: var(--base-size-12, 12px); +} + +.blankslate-actions { + display: flex; + margin-top: var(--base-size-16, 16px); + justify-content: center; +} + +.blankslate-actions .octicon { + width: 16px; + height: 16px; + margin-bottom: 0; +} + +.blankslate h2 { + margin: 0 0 var(--base-size-8, 8px); + color: var(--app-fg); + font-size: 18px; +} + +.blankslate p { + max-width: 560px; + margin: 0 auto; +} + +.flash { + border-radius: var(--app-radius); +} + +.loading-list { + overflow: hidden; + border: 1px solid var(--app-border); + border-radius: var(--app-radius); +} + +.skeleton-row { + display: grid; + gap: var(--base-size-12, 12px); + padding: var(--base-size-16, 16px); + border-top: 1px solid var(--app-border); + grid-template-columns: 18px minmax(0, 1fr) 90px; +} + +.skeleton-row:first-child { + border-top: 0; +} + +.skeleton { + min-height: 14px; + background: var(--app-bg-muted); + border-radius: var(--app-radius); +} + +.skeleton.short { + width: 55%; + margin-top: var(--base-size-8, 8px); +} + +@media (prefers-reduced-motion: no-preference) { + .skeleton { + animation: skeleton-pulse 1.6s ease-in-out infinite; + } +} + +@keyframes skeleton-pulse { + 50% { + opacity: 0.45; + } +} + +.toast-region { + position: fixed; + right: var(--base-size-16, 16px); + bottom: var(--base-size-16, 16px); + z-index: 60; + display: grid; + max-width: min(420px, calc(100vw - 32px)); + gap: var(--base-size-8, 8px); +} + +.toast-message { + padding: var(--base-size-12, 12px) var(--base-size-16, 16px); + color: var(--app-fg); + background: var(--app-bg); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); + box-shadow: var(--shadow-resting-medium, 0 8px 24px rgb(140 149 159 / 20%)); +} + +.toast-message.error { + border-color: var(--app-danger); +} + +.mono { + font-family: var(--app-mono); +} + +.nowrap { + white-space: nowrap; +} + +.octicon { + display: inline-block; + width: 16px; + height: 16px; + background: currentColor; + mask: var(--octicon-url) center / contain no-repeat; + vertical-align: text-bottom; + flex: none; +} + +.icon-archive { --octicon-url: url("/assets/octicons/archive.svg"); } +.icon-browser { --octicon-url: url("/assets/octicons/browser.svg"); } +.icon-check { --octicon-url: url("/assets/octicons/check.svg"); } +.icon-check-circle { --octicon-url: url("/assets/octicons/check-circle.svg"); } +.icon-chevron-down { --octicon-url: url("/assets/octicons/chevron-down.svg"); } +.icon-chevron-left { --octicon-url: url("/assets/octicons/chevron-left.svg"); } +.icon-chevron-right { --octicon-url: url("/assets/octicons/chevron-right.svg"); } +.icon-clock { --octicon-url: url("/assets/octicons/clock.svg"); } +.icon-code { --octicon-url: url("/assets/octicons/code.svg"); } +.icon-comment { --octicon-url: url("/assets/octicons/comment.svg"); } +.icon-download { --octicon-url: url("/assets/octicons/download.svg"); } +.icon-eye { --octicon-url: url("/assets/octicons/eye.svg"); } +.icon-file { --octicon-url: url("/assets/octicons/file.svg"); } +.icon-file-binary { --octicon-url: url("/assets/octicons/file-binary.svg"); } +.icon-file-code { --octicon-url: url("/assets/octicons/file-code.svg"); } +.icon-file-directory { --octicon-url: url("/assets/octicons/file-directory.svg"); } +.icon-file-media { --octicon-url: url("/assets/octicons/file-media.svg"); } +.icon-file-zip { --octicon-url: url("/assets/octicons/file-zip.svg"); } +.icon-filter { --octicon-url: url("/assets/octicons/filter.svg"); } +.icon-gear { --octicon-url: url("/assets/octicons/gear.svg"); } +.icon-git-pull-request { --octicon-url: url("/assets/octicons/git-pull-request.svg"); } +.icon-kebab-horizontal { --octicon-url: url("/assets/octicons/kebab-horizontal.svg"); } +.icon-link-external { --octicon-url: url("/assets/octicons/link-external.svg"); } +.icon-mark-github { --octicon-url: url("/assets/octicons/mark-github.svg"); } +.icon-markdown { --octicon-url: url("/assets/octicons/markdown.svg"); } +.icon-package { --octicon-url: url("/assets/octicons/package.svg"); } +.icon-person { --octicon-url: url("/assets/octicons/person.svg"); } +.icon-pin { --octicon-url: url("/assets/octicons/pin.svg"); } +.icon-pin-slash { --octicon-url: url("/assets/octicons/pin-slash.svg"); } +.icon-play { --octicon-url: url("/assets/octicons/play.svg"); } +.icon-repo { --octicon-url: url("/assets/octicons/repo.svg"); } +.icon-search { --octicon-url: url("/assets/octicons/search.svg"); } +.icon-sidebar-collapse { --octicon-url: url("/assets/octicons/sidebar-collapse.svg"); } +.icon-sidebar-expand { --octicon-url: url("/assets/octicons/sidebar-expand.svg"); } +.icon-star { --octicon-url: url("/assets/octicons/star.svg"); } +.icon-star-fill { --octicon-url: url("/assets/octicons/star-fill.svg"); } +.icon-sync { --octicon-url: url("/assets/octicons/sync.svg"); } +.icon-terminal { --octicon-url: url("/assets/octicons/terminal.svg"); } +.icon-trash { --octicon-url: url("/assets/octicons/trash.svg"); } +.icon-workflow { --octicon-url: url("/assets/octicons/workflow.svg"); } +.icon-x { --octicon-url: url("/assets/octicons/x.svg"); } +.icon-x-circle { --octicon-url: url("/assets/octicons/x-circle.svg"); } + +.spin { + animation: app-spin 1s linear infinite; +} + +@keyframes app-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes repository-search-spin { + from { + transform: translateY(-50%) rotate(0deg); + } + + to { + transform: translateY(-50%) rotate(360deg); + } +} + +@media (min-width: 768px) { + .breadcrumb-bar { + padding-right: 0; + padding-left: 0; + } + + .breadcrumbs { + box-sizing: border-box; + padding-right: var(--base-size-16, 16px); + padding-left: var(--base-size-16, 16px); + } + + .breadcrumb-overflow-menu { + left: var(--base-size-16, 16px); + } +} + +@media (min-width: 1600px) { + .app-header { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1280px) minmax(0, 1fr); + column-gap: 0; + } + + .app-brand { + grid-column: 1; + grid-row: 1; + justify-self: start; + } + + .repository-form { + width: min(620px, calc(100% - var(--base-size-16, 16px))); + margin-right: 0; + margin-left: var(--base-size-16, 16px); + grid-column: 2; + grid-row: 1; + justify-self: start; + } + + .header-cache-item, + .account-control { + grid-column: 3; + grid-row: 1; + justify-self: end; + } + + .header-cache-item { + margin-right: 48px; + } +} + +@media (prefers-reduced-motion: reduce) { + .spin, + .account-sources-chevron, + .artifact-download-bar { + animation: none; + transition: none; + } +} + +@media (max-width: 900px) { + .breadcrumbs.has-overflow .breadcrumb-item-collapsible { + display: none; + } + + .breadcrumbs.has-overflow .breadcrumb-overflow { + display: inline-flex; + } + + .breadcrumbs.has-overflow .breadcrumb-item-root { + max-width: min(34vw, 200px); + } + + .breadcrumbs.has-overflow + .breadcrumb-item-current + .breadcrumb-item-label.has-compact-label + .breadcrumb-label-full { + display: none; + } + + .breadcrumbs.has-overflow + .breadcrumb-item-current + .breadcrumb-item-label.has-compact-label + .breadcrumb-label-compact { + display: block; + } +} + +@media (max-width: 767px) { + .app-header { + min-height: auto; + flex-wrap: wrap; + } + + .app-brand span:last-child, + .header-cache-item #cache-label { + display: none; + } + + .repository-form { + max-width: none; + order: 3; + width: 100%; + } + + .repository-input { + min-width: 0; + } + + .account-control { + margin-left: auto; + } + + .toolbar { + flex-wrap: wrap; + } + + .pull-state-picker, + .pull-author-picker, + .pull-signal-picker { + position: static; + } + + .pull-state-panel, + .pull-author-panel { + top: calc(100% + 1px); + right: 0; + width: min(340px, 100%); + } + + .artifact-filter { + width: 100%; + max-width: none; + flex-basis: 100%; + } + + .artifact-filter-picker { + position: static; + } + + .artifact-filter-controls { + min-width: 0; + flex: 1 1 180px; + } + + .artifact-filter-picker:last-child { + min-width: 0; + flex: 1; + } + + .artifact-filter-picker:last-child .artifact-filter-trigger { + width: 100%; + max-width: none; + } + + .artifact-filter-panel { + top: calc(100% + 8px); + right: 0; + left: 0; + width: 100%; + } + + .artifact-filter-trigger { + max-width: min(220px, calc(100vw - 96px)); + } + + .breadcrumb-bar { + position: relative; + top: auto; + padding-right: var(--base-size-12, 12px); + padding-left: var(--base-size-12, 12px); + } + + .app-main { + padding: var(--base-size-16, 16px) var(--base-size-12, 12px) + var(--base-size-32, 32px); + } + + .page-header { + flex-direction: column; + } + + .page-actions { + width: 100%; + flex-wrap: wrap; + } + + .account-card-main { + align-items: flex-start; + flex-wrap: wrap; + } + + .account-access { + margin-left: 56px; + } + + .account-card-footer { + align-items: flex-start; + flex-direction: column; + } + + .player-control-bar { + align-items: stretch; + } + + .player-control-group { + flex: 1 1 100%; + } + + .player-segmented { + display: grid; + width: 100%; + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; + } + + .player-segment { + min-width: 0; + padding-right: var(--base-size-6, 6px); + padding-left: var(--base-size-6, 6px); + overflow: hidden; + text-overflow: ellipsis; + } + + .player-stepper { + align-self: flex-start; + } + + .Box-row { + align-items: flex-start; + } + + .Box-row-actions { + align-self: center; + } + + .explorer { + min-height: auto; + grid-template-columns: 1fr; + } + + .file-panel { + border-right: 0; + border-bottom: 1px solid var(--app-border); + } + + .file-tree-filter-menu { + right: 0; + left: auto; + } + + .file-list { + max-height: 240px; + } + + .preview-content { + min-height: 420px; + } + + .preview-frame { + min-height: 420px; + } + + .trx-run-metadata, + .trx-result-metadata { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .skeleton-row { + grid-template-columns: 18px minmax(0, 1fr); + } + + .skeleton-row > :last-child { + display: none; + } +} + +@media (max-width: 480px) { + .trx-run-heading, + .trx-section-heading { + align-items: stretch; + flex-direction: column; + gap: var(--base-size-8, 8px); + } + + .trx-run-outcome { + align-self: flex-start; + } + + .trx-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .trx-run-metadata, + .trx-result-metadata { + grid-template-columns: 1fr; + } + + .trx-controls { + align-items: stretch; + flex-direction: column; + } + + .trx-search, + .trx-filters { + width: 100%; + } + + .trx-filter { + flex: 1 0 auto; + } + + .trx-result > summary { + padding-left: var(--base-size-12, 12px); + grid-template-columns: 16px 16px minmax(0, 1fr) auto; + } + + .trx-result-duration { + display: none; + } + + .trx-outcome-badge { + grid-column: 3 / -1; + justify-self: start; + } + + .trx-result-details { + padding-left: var(--base-size-16, 16px); + } + + .trx-properties dl > div { + grid-template-columns: 1fr; + gap: 0; + } + + .pull-query-suggestion { + grid-template-columns: minmax(0, 1fr); + gap: var(--base-size-2, 2px); + } + + .pull-query-suggestion-footer { + justify-content: space-between; + } + + .artifact-transfer-row { + padding-right: var(--base-size-12, 12px); + padding-left: var(--base-size-12, 12px); + } + + .artifact-download-copy { + flex-direction: column; + gap: var(--base-size-2, 2px); + } + + .artifact-download-stats { + white-space: normal; + } + + .account-chip { + width: 36px; + padding: 0; + } + + .labels { + display: flex; + margin: var(--base-size-4, 4px) 0 0; + } + + .Box-row { + padding-right: var(--base-size-12, 12px); + padding-left: var(--base-size-12, 12px); + } + + .artifact-summary { + display: grid; + } +} + +@media (max-width: 479px) { + .breadcrumbs:not(.has-overflow) .breadcrumb-item-root { + max-width: min(30vw, 160px); + } + + .breadcrumbs:not(.has-overflow) .breadcrumb-item-collapsible { + max-width: min(32vw, 140px); + } + + .breadcrumbs.has-overflow .breadcrumb-item-root { + display: none; + } + + .breadcrumbs.has-overflow .breadcrumb-overflow::before { + display: none; + } + + .breadcrumb-overflow-menu { + width: calc(100vw - 24px); + } +} + +@media (min-width: 480px) and (max-width: 900px) { + .breadcrumb-overflow-menu .breadcrumb-overflow-option-root { + display: none; + } + + .breadcrumb-overflow-menu + .breadcrumb-overflow-option-root + + .breadcrumb-overflow-option { + border-top: 0; + } +} diff --git a/plugins/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/app.js b/plugins/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/app.js new file mode 100644 index 00000000..bbc2969c --- /dev/null +++ b/plugins/pr-artifact-explorer/extensions/pr-artifact-explorer/assets/app.js @@ -0,0 +1,5362 @@ +const view = document.getElementById("view"); +const breadcrumbs = document.getElementById("breadcrumbs"); +const repositoryForm = document.getElementById("repository-form"); +const repositoryInput = document.getElementById("repository-input"); +const repositoryCombobox = document.getElementById("repository-combobox"); +const repositoryMenuButton = document.getElementById("repository-menu-button"); +const repositoryPanel = document.getElementById("repository-panel"); +const repositoryResults = document.getElementById("repository-results"); +const accountButton = document.getElementById("account-button"); +const accountAvatar = document.getElementById("account-avatar"); +const accountFallback = document.getElementById("account-fallback"); +const cacheLabel = document.getElementById("cache-label"); +const toastRegion = document.getElementById("toast-region"); +const capabilityToken = + document.querySelector('meta[name="pr-artifact-explorer-token"]')?.content ?? ""; + +let bootstrapState = null; +let currentArtifact = null; +let currentPullContext = null; +let currentPlayer = null; +let currentPlayerIsPlaying = false; +let artifactFilters = { + context: null, + query: "", + status: "all", + run: "latest", +}; +const artifactDownloadProgress = new Map(); +const LARGE_ARTIFACT_PROGRESS_BYTES = 2 * 1024 * 1024; +const MAX_VISIBLE_ARTIFACT_FILES = 1_000; +const EXPAND_ALL_FILE_TREE_LIMIT = 200; +const PULL_PAYLOAD_CACHE_FRESH_MS = 30 * 1_000; +const PULL_PAYLOAD_CACHE_STALE_MS = 10 * 60 * 1_000; +const PULL_PAYLOAD_CACHE_LIMIT = 30; +const PULL_QUERY_QUALIFIERS = [ + { + name: "in", + description: "Search within a field", + values: [ + ["title", "Pull request titles"], + ["body", "Pull request descriptions"], + ["comments", "Pull request comments"], + ], + }, + { + name: "is", + description: "Filter by state or type", + values: [ + ["open", "Open pull requests"], + ["closed", "Closed pull requests"], + ["merged", "Merged pull requests"], + ["unmerged", "Pull requests that are not merged"], + ["draft", "Draft pull requests"], + ["pr", "Pull requests only"], + ], + }, + { + name: "author", + description: "Opened by a user", + dynamicValues: "authors", + }, + { + name: "assignee", + description: "Assigned to a user", + dynamicValues: "authors", + }, + { + name: "label", + description: "Has a label", + dynamicValues: "labels", + }, + { + name: "review", + description: "Filter by review state", + values: [ + ["none", "No reviews"], + ["required", "Review required"], + ["approved", "Approved"], + ["changes_requested", "Changes requested"], + ], + }, + { + name: "reviewed-by", + description: "Reviewed by a user", + dynamicValues: "authors", + }, + { + name: "review-requested", + description: "Review requested from a user", + dynamicValues: "authors", + }, + { + name: "draft", + description: "Filter by draft state", + values: [ + ["true", "Draft pull requests"], + ["false", "Ready for review"], + ], + }, + { + name: "status", + description: "Filter by commit status", + values: [ + ["success", "Checks succeeded"], + ["failure", "Checks failed"], + ["pending", "Checks are pending"], + ], + }, + { + name: "no", + description: "Exclude missing metadata", + values: [ + ["label", "No labels"], + ["assignee", "No assignee"], + ["milestone", "No milestone"], + ["project", "No project"], + ], + }, + { + name: "sort", + description: "Choose result order", + values: [ + ["updated-desc", "Recently updated first"], + ["updated-asc", "Least recently updated first"], + ["created-desc", "Newest first"], + ["created-asc", "Oldest first"], + ["comments-desc", "Most commented first"], + ["comments-asc", "Least commented first"], + ], + }, + { name: "base", description: "Targets a base branch" }, + { name: "head", description: "Comes from a head branch" }, + { name: "milestone", description: "Belongs to a milestone" }, + { name: "mentions", description: "Mentions a user" }, + { name: "commenter", description: "Commented on by a user" }, + { name: "involves", description: "Involves a user" }, + { name: "created", description: "Created on a date or range" }, + { name: "updated", description: "Updated on a date or range" }, + { name: "closed", description: "Closed on a date or range" }, + { name: "merged", description: "Merged on a date or range" }, +]; +const MANIFEST_FILE_NAMES = new Set([ + "bun.lock", + "bun.lockb", + "cargo.lock", + "cargo.toml", + "composer.json", + "composer.lock", + "compose.yaml", + "compose.yml", + "deno.json", + "deno.jsonc", + "directory.build.props", + "directory.build.targets", + "dockerfile", + "gemfile", + "gemfile.lock", + "global.json", + "go.mod", + "go.sum", + "mix.exs", + "mix.lock", + "npm-shrinkwrap.json", + "nuget.config", + "package-lock.json", + "package.json", + "pipfile", + "pipfile.lock", + "pnpm-lock.yaml", + "poetry.lock", + "pubspec.yaml", + "pyproject.toml", + "yarn.lock", +]); +const VENDORED_PATH_SEGMENTS = new Set([ + ".pnpm", + ".venv", + "__pycache__", + "bower_components", + "node_modules", + "site-packages", + "third-party", + "third_party", + "vendor", + "vendors", +]); +const FILE_PATH_COLLATOR = new Intl.Collator("en", { + numeric: true, + sensitivity: "base", +}); +const pullPayloadCache = new Map(); +let renderSequence = 0; +let repositorySearchTimer = null; +let repositorySearchController = null; +let repositorySearchSequence = 0; +let repositoryRemoteResults = []; +let repositorySearchState = "idle"; +let repositorySearchError = null; +let repositoryActiveIndex = -1; +let repositoryPickerQuery = ""; +let pullAuthors = []; +let pullAuthorsRepository = null; +let pullAuthorsState = "idle"; +let pullAuthorsError = null; +let pullAuthorsLoadSequence = 0; +let pullQueryLabels = []; +let pullQuerySuggestionIndex = -1; +let pullQueryPendingCommit = null; +let pullTableRefreshSequence = 0; +let pullFilterStorageWarningShown = false; +const PULL_FILTER_STORAGE_KEY = "pr-artifact-explorer:pull-filters:v1"; +const MAX_LOCAL_PULL_FILTER_REPOSITORIES = 50; +const previewThemeMedia = window.matchMedia("(prefers-color-scheme: dark)"); + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function titleHtml(value) { + const source = String(value ?? ""); + let result = ""; + let cursor = 0; + + while (cursor < source.length) { + const opening = source.indexOf("`", cursor); + if (opening === -1) { + result += escapeHtml(source.slice(cursor)); + break; + } + + result += escapeHtml(source.slice(cursor, opening)); + let runLength = 1; + while (source[opening + runLength] === "`") runLength++; + const delimiter = "`".repeat(runLength); + let closing = source.indexOf(delimiter, opening + runLength); + while ( + closing !== -1 && + (source[closing - 1] === "`" || source[closing + runLength] === "`") + ) { + closing = source.indexOf(delimiter, closing + runLength); + } + + if (closing === -1) { + result += escapeHtml(source.slice(opening)); + break; + } + + let code = source.slice(opening + runLength, closing).replace(/\s+/g, " "); + if (code.startsWith(" ") && code.endsWith(" ") && code.trim()) { + code = code.slice(1, -1); + } + result += `${escapeHtml(code)}`; + cursor = closing + runLength; + } + + return result; +} + +function highlightXmlTag(token) { + const opening = token.startsWith("") ? "/>" : ">"; + const nameStart = opening.length; + const name = token.slice(nameStart).match(/^[^\s/>]+/)?.[0]; + if (!name) { + return `${escapeHtml(token)}`; + } + + const attributesStart = nameStart + name.length; + const attributesEnd = token.length - ending.length; + const attributes = token.slice(attributesStart, attributesEnd); + const matcher = /([^\s=/>]+)(\s*=\s*)("(?:[^"]*)"|'(?:[^']*)'|[^\s>]+)/g; + let highlightedAttributes = ""; + let cursor = 0; + for (const match of attributes.matchAll(matcher)) { + highlightedAttributes += escapeHtml(attributes.slice(cursor, match.index)); + highlightedAttributes += `${escapeHtml(match[1])}`; + highlightedAttributes += `${escapeHtml(match[2])}`; + highlightedAttributes += `${escapeHtml(match[3])}`; + cursor = match.index + match[0].length; + } + highlightedAttributes += escapeHtml(attributes.slice(cursor)); + + return [ + `${escapeHtml(opening)}`, + `${escapeHtml(name)}`, + highlightedAttributes, + `${escapeHtml(ending)}`, + ].join(""); +} + +function highlightXml(value) { + const source = String(value ?? ""); + let highlighted = ""; + let cursor = 0; + while (cursor < source.length) { + const opening = source.indexOf("<", cursor); + if (opening === -1) { + highlighted += escapeHtml(source.slice(cursor)); + break; + } + highlighted += escapeHtml(source.slice(cursor, opening)); + + let closing; + if (source.startsWith("", opening + 4); + closing = index === -1 ? source.length : index + 3; + } else if (source.startsWith("", opening + 9); + closing = index === -1 ? source.length : index + 3; + } else { + let quote = null; + let subsetDepth = 0; + let foundClosing = false; + closing = source.length; + for (let index = opening + 1; index < source.length; index++) { + const character = source[index]; + if (quote) { + if (character === quote) quote = null; + continue; + } + if (character === '"' || character === "'") { + quote = character; + } else if (character === "[") { + subsetDepth++; + } else if (character === "]" && subsetDepth > 0) { + subsetDepth--; + } else if (character === ">" && subsetDepth === 0) { + closing = index + 1; + foundClosing = true; + break; + } + } + if (!foundClosing) { + highlighted += escapeHtml(source.slice(opening)); + break; + } + } + + const token = source.slice(opening, closing); + if (token.startsWith("/g, " ") + .replace(/!\[[^\]]*]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)]\([^)]*\)/g, "$1") + .replace(/`{1,3}[^`]*`{1,3}/g, " ") + .replace(/[*_>#|~-]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function firstReadmeParagraph(readme) { + for (const block of String(readme || "").split(/\r?\n\s*\r?\n/)) { + const trimmed = block.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("![") || trimmed.startsWith("[![")) { + continue; + } + const cleaned = cleanMarkdown(trimmed); + if (cleaned.length >= 30) { + return cleaned.slice(0, 600); + } + } + return ""; +} + +function sentenceDescription(name, source) { + const cleaned = cleanMarkdown(source); + if (!cleaned) { + return `${name} is an open source tool built to make a focused task simpler and more delightful. Explore the repository for installation and usage details.`; + } + const first = /[.!?]$/.test(cleaned) ? cleaned : `${cleaned}.`; + return `${first} The project is free and open source, with its source and usage details available on GitHub.`; +} + +function truncate(value, maxLength) { + const text = cleanMarkdown(value); + if (text.length <= maxLength) { + return text; + } + return `${text.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`; +} + +function detectLicenseText(content) { + const text = String(content || "").toLowerCase(); + if (text.includes("mit license") || text.includes("licensed under the mit")) return "MIT"; + if (text.includes("apache license") || text.includes("apache-2.0")) return "Apache-2.0"; + if (text.includes("gnu general public license") || text.includes("gpl-3.0") || text.includes("gplv3")) return "GPL-3.0"; + if (text.includes("gpl-2.0") || text.includes("gplv2")) return "GPL-2.0"; + if (text.includes("bsd 2-clause")) return "BSD-2-Clause"; + if (text.includes("bsd 3-clause")) return "BSD-3-Clause"; + if (text.includes("mozilla public license") || text.includes("mpl-2.0")) return "MPL-2.0"; + if (text.includes("isc license")) return "ISC"; + if (text.includes("the unlicense")) return "Unlicense"; + return ""; +} + +async function scanLanguages(root) { + const counts = new Map(); + let visited = 0; + + async function visit(directory, depth) { + if (depth > 6 || visited > 12000) return; + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (visited > 12000) break; + if (entry.isDirectory()) { + if (!SKIPPED_DIRECTORIES.has(entry.name)) { + await visit(join(directory, entry.name), depth + 1); + } + continue; + } + visited += 1; + const language = SOURCE_LANGUAGES.get(extname(entry.name).toLowerCase()); + if (language) { + counts.set(language, (counts.get(language) || 0) + 1); + } + } + } + + await visit(root, 0); + return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || ""; +} + +function isLikelyBadge(url) { + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + const pathname = parsed.pathname.toLowerCase(); + return BAD_IMAGE_HOSTS.has(hostname) + || hostname.endsWith(".githubusercontent.com") && hostname.startsWith("avatars.") + || pathname.endsWith(".svg") + || pathname.includes("/contributors/") + || pathname.includes("/sponsors/"); + } catch { + return false; + } +} + +function normalizeImageUrl(rawUrl, githubUrl, defaultBranch, readmePath, root) { + const value = String(rawUrl || "").trim().replace(/^<|>$/g, "").split(/\s+["']/)[0]; + if (!value || value.startsWith("data:")) return ""; + if (/^https?:\/\//i.test(value)) return isLikelyBadge(value) ? "" : value; + if (!githubUrl || value.startsWith("#")) return ""; + + const relativeReadmeDirectory = value.startsWith("/") + ? "" + : relative(root, resolve(readmePath, "..")).replaceAll("\\", "/"); + const imagePath = [relativeReadmeDirectory, value] + .filter(Boolean) + .join("/") + .replace(/^\.\//, "") + .replace(/^\/+/, "") + .replaceAll("\\", "/"); + return `${githubUrl.replace("github.com", "raw.githubusercontent.com")}/${defaultBranch}/${imagePath}`; +} + +function detectThumbnail(readme, githubUrl, defaultBranch, readmePath, root) { + const candidates = []; + const imagePattern = /!\[(?[^\]]*)]\((?[^)]+)\)/g; + for (const match of readme.matchAll(imagePattern)) { + candidates.push({ + index: match.index, + url: match.groups?.url, + alt: match.groups?.alt, + }); + } + const htmlImagePattern = /]*>/gi; + for (const match of readme.matchAll(htmlImagePattern)) { + const tag = match[0]; + const srcMatch = tag.match(/\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i); + const altMatch = tag.match(/\balt\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i); + if (srcMatch) { + candidates.push({ + index: match.index, + url: srcMatch[1] || srcMatch[2] || srcMatch[3], + alt: altMatch ? altMatch[1] || altMatch[2] || altMatch[3] || "" : "", + }); + } + } + candidates.sort((left, right) => left.index - right.index); + for (const candidate of candidates) { + const url = normalizeImageUrl(candidate.url, githubUrl, defaultBranch, readmePath, root); + if (url) { + return { url, alt: String(candidate.alt || "").trim() }; + } + } + return { url: "", alt: "" }; +} + +function detectWebsite(readme, githubUrl, packageJson, githubInfo) { + const candidates = [packageJson?.homepage, githubInfo?.homepageUrl]; + const markdownLinks = [...String(readme || "").matchAll(/\[(?