From e57585c976654aa3d0d32b5ecf207891bd2b9337 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sun, 18 Oct 2026 02:27:04 +0300 Subject: [PATCH 1/7] Add browser Studio with isolated AdaEngine viewport and editor tools --- .gitignore | 1 + Editor/AGENTS.md | 19 + Editor/Documentation/StudioWeb.md | 124 + Editor/Documentation/StudioWebValidation.md | 126 + Editor/Package.swift | 2 + .../Sources/AdaEditor/EditorSceneModel.swift | 62 +- .../Editor/EditorViewModel+AdaScriptWeb.swift | 6 +- Editor/StudioCore/Package.resolved | 24 + Editor/StudioCore/Package.swift | 16 + .../Sources/AdaStudioCore/StudioArchive.swift | 116 + .../AdaStudioCore/StudioDocument.swift | 76 + .../Sources/AdaStudioCore/StudioProject.swift | 118 + .../Sources/AdaStudioCore/StudioScene.swift | 68 + .../AdaStudioCore/StudioTemplate.swift | 59 + .../Sources/AdaStudioCore/StudioValue.swift | 67 + .../StudioDocumentTests.swift | 78 + Editor/Web/Package.resolved | 132 ++ Editor/Web/Package.swift | 26 + .../Sources/AdaEditorWeb/AdaEditorWeb.swift | 331 +++ .../Resources/AdaScriptRecipes.md | 232 ++ .../AdaEditorWeb/Resources/studio-browser.js | 200 ++ .../Sources/AdaEditorWeb/StudioAgent.swift | 164 ++ .../Sources/AdaEditorWeb/StudioBrowser.swift | 97 + .../AdaEditorWeb/StudioPublishing.swift | 57 + .../AdaEditorWeb/StudioViewModel.swift | 336 +++ Editor/WebUI/.gitignore | 3 + Editor/WebUI/.npmrc | 1 + Editor/WebUI/index.html | 41 + Editor/WebUI/package.json | 27 + Editor/WebUI/pnpm-lock.yaml | 460 ++++ Editor/WebUI/pnpm-workspace.yaml | 2 + Editor/WebUI/scripts/build.mjs | 22 + Editor/WebUI/src/adascript.js | 70 + Editor/WebUI/src/agent.js | 286 +++ Editor/WebUI/src/app.jsx | 200 ++ Editor/WebUI/src/documents.js | 417 ++++ Editor/WebUI/src/platform.js | 111 + Editor/WebUI/src/preview.js | 186 ++ Editor/WebUI/src/studio.js | 621 +++++ Editor/WebUI/src/style.css | 2031 +++++++++++++++++ Editor/WebUI/src/ui/assistant.jsx | 237 ++ Editor/WebUI/src/ui/code-assets.jsx | 115 + Editor/WebUI/src/ui/controls.jsx | 105 + Editor/WebUI/src/ui/dialogs.jsx | 176 ++ Editor/WebUI/src/ui/home.jsx | 165 ++ Editor/WebUI/src/ui/inspector.jsx | 206 ++ Editor/WebUI/src/ui/source-editor.jsx | 148 ++ Editor/WebUI/src/ui/state.js | 2 + Editor/WebUI/src/ui/workspace.jsx | 315 +++ Editor/WebUI/test/adascript.test.mjs | 44 + Editor/WebUI/test/agent.test.mjs | 94 + Editor/WebUI/test/documents.test.mjs | 174 ++ Editor/WebUI/test/editor-math.test.mjs | 101 + Editor/WebUI/test/preview.test.mjs | 198 ++ Package.swift | 5 +- .../AdaWebExportPlugin.swift | 67 +- .../Runtime/studio-editor-math.js | 187 ++ .../Runtime/studio-editor.js | 699 ++++++ .../Runtime/studio-player.js | 133 ++ Sources/AdaNavigation/NavigationMesh.swift | 19 +- .../Web/BrowserWindowManager.swift | 15 +- .../AdaRender/Image/Image+PortablePNG.swift | 4 + .../AdaWebPlayerProject.swift | 6 +- Sources/AdaScripting/AdaScriptTestWorld.swift | 5 + .../AnnotatedGravityScriptPlugin.swift | 16 + .../AdaWebPlayerTests.swift | 8 +- Tools/AdaWebPlayer/AdaWebPlayer.swift | 74 +- Tools/AdaWebPlayer/PlayerBrowserSession.swift | 7 + Tools/AdaWebPlayer/PlayerEditorBridge.swift | 174 ++ Tools/AdaWebPlayer/PlayerScene.swift | 81 +- .../AdaWebPlayer/PlayerSceneComponents.swift | 106 + script/ensure_wasi_yams.py | 37 + script/export_studio_web.py | 95 + script/prepare_studio_frontend.py | 74 + script/tests/test_prepare_studio_frontend.py | 30 + 75 files changed, 10811 insertions(+), 126 deletions(-) create mode 100644 Editor/Documentation/StudioWeb.md create mode 100644 Editor/Documentation/StudioWebValidation.md create mode 100644 Editor/StudioCore/Package.resolved create mode 100644 Editor/StudioCore/Package.swift create mode 100644 Editor/StudioCore/Sources/AdaStudioCore/StudioArchive.swift create mode 100644 Editor/StudioCore/Sources/AdaStudioCore/StudioDocument.swift create mode 100644 Editor/StudioCore/Sources/AdaStudioCore/StudioProject.swift create mode 100644 Editor/StudioCore/Sources/AdaStudioCore/StudioScene.swift create mode 100644 Editor/StudioCore/Sources/AdaStudioCore/StudioTemplate.swift create mode 100644 Editor/StudioCore/Sources/AdaStudioCore/StudioValue.swift create mode 100644 Editor/StudioCore/Tests/AdaStudioCoreTests/StudioDocumentTests.swift create mode 100644 Editor/Web/Package.resolved create mode 100644 Editor/Web/Package.swift create mode 100644 Editor/Web/Sources/AdaEditorWeb/AdaEditorWeb.swift create mode 100644 Editor/Web/Sources/AdaEditorWeb/Resources/AdaScriptRecipes.md create mode 100644 Editor/Web/Sources/AdaEditorWeb/Resources/studio-browser.js create mode 100644 Editor/Web/Sources/AdaEditorWeb/StudioAgent.swift create mode 100644 Editor/Web/Sources/AdaEditorWeb/StudioBrowser.swift create mode 100644 Editor/Web/Sources/AdaEditorWeb/StudioPublishing.swift create mode 100644 Editor/Web/Sources/AdaEditorWeb/StudioViewModel.swift create mode 100644 Editor/WebUI/.gitignore create mode 100644 Editor/WebUI/.npmrc create mode 100644 Editor/WebUI/index.html create mode 100644 Editor/WebUI/package.json create mode 100644 Editor/WebUI/pnpm-lock.yaml create mode 100644 Editor/WebUI/pnpm-workspace.yaml create mode 100644 Editor/WebUI/scripts/build.mjs create mode 100644 Editor/WebUI/src/adascript.js create mode 100644 Editor/WebUI/src/agent.js create mode 100644 Editor/WebUI/src/app.jsx create mode 100644 Editor/WebUI/src/documents.js create mode 100644 Editor/WebUI/src/platform.js create mode 100644 Editor/WebUI/src/preview.js create mode 100644 Editor/WebUI/src/studio.js create mode 100644 Editor/WebUI/src/style.css create mode 100644 Editor/WebUI/src/ui/assistant.jsx create mode 100644 Editor/WebUI/src/ui/code-assets.jsx create mode 100644 Editor/WebUI/src/ui/controls.jsx create mode 100644 Editor/WebUI/src/ui/dialogs.jsx create mode 100644 Editor/WebUI/src/ui/home.jsx create mode 100644 Editor/WebUI/src/ui/inspector.jsx create mode 100644 Editor/WebUI/src/ui/source-editor.jsx create mode 100644 Editor/WebUI/src/ui/state.js create mode 100644 Editor/WebUI/src/ui/workspace.jsx create mode 100644 Editor/WebUI/test/adascript.test.mjs create mode 100644 Editor/WebUI/test/agent.test.mjs create mode 100644 Editor/WebUI/test/documents.test.mjs create mode 100644 Editor/WebUI/test/editor-math.test.mjs create mode 100644 Editor/WebUI/test/preview.test.mjs create mode 100644 Plugins/AdaWebExportPlugin/Runtime/studio-editor-math.js create mode 100644 Plugins/AdaWebExportPlugin/Runtime/studio-editor.js create mode 100644 Plugins/AdaWebExportPlugin/Runtime/studio-player.js create mode 100644 Tools/AdaWebPlayer/PlayerEditorBridge.swift create mode 100644 Tools/AdaWebPlayer/PlayerSceneComponents.swift create mode 100644 script/ensure_wasi_yams.py create mode 100644 script/export_studio_web.py create mode 100644 script/prepare_studio_frontend.py create mode 100644 script/tests/test_prepare_studio_frontend.py diff --git a/.gitignore b/.gitignore index cb4834eaa..9a223cc3a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ .build/ **/.build/ .build-codex/ +.build-studio-core/ .build-web-*/ .build-tools/ .zed/ diff --git a/Editor/AGENTS.md b/Editor/AGENTS.md index 7d380b122..caca3766b 100644 --- a/Editor/AGENTS.md +++ b/Editor/AGENTS.md @@ -51,6 +51,25 @@ and assess gaps before proposing new features. ## Capability inventory +### Browser Studio + +`WebUI/` is the default React/DOM browser shell, with floating panels and an +native text inputs. The scene viewport and Play use one isolated fixed AdaPlayer +with authored cameras, materials and lighting. Opening a project loads the engine; +document/mode changes replace its world without recreating WASM or the GPU device. +There is no separate SVG scene renderer. Editor mode disables gameplay, physics +and autoplay; selecting and transforming objects currently uses hierarchy/inspector, +with direct viewport picking/gizmos still unavailable. It implements the existing +portable snapshot/scene/API contracts with transactions/undo, cloud recovery, ZIP +interchange and an in-tab agent. `Web/` retains the prior AdaUI/WASM application +(`AdaEditorWeb`) behind `--legacy-wasm-editor`, including its UGC publication UI; +publication controls are not yet ported to DOM. Native Studio uses the shared +`StudioCore/` value codec and supported runtime component contract. The browser does +not use native ACP, processes, SourceKit, key storage or file dialogs. See +[Documentation/StudioWeb.md](Documentation/StudioWeb.md) for export, service +boundaries and the browser release gate; source presence is not acceptance of a +hosted deployment or a live paid provider. + Snapshot: **2026-10-04**, checked against the current working-tree source and documentation. “Present” means an implementation exists, not that every interaction or platform was exercised during this inventory. Test links identify diff --git a/Editor/Documentation/StudioWeb.md b/Editor/Documentation/StudioWeb.md new file mode 100644 index 000000000..3b0c257e8 --- /dev/null +++ b/Editor/Documentation/StudioWeb.md @@ -0,0 +1,124 @@ +# Ada Studio Web + +`Editor/WebUI` is the default browser editor: a small React/DOM shell with native +text inputs and floating object/assistant/inspector panels. Both the scene viewport +and Play use the fixed `AdaWebPlayer` in one isolated iframe; the editor does not +draw a separate scene in SVG, Canvas or another renderer. Opening a project starts +AdaEngine asynchronously while the DOM controls remain available. There is no +user Swift compilation, native process runner, ACP subprocess, SourceKit or +credential-store dependency in the browser target. + +## Modules and boundaries + +- `Editor/StudioCore` owns portable values, project snapshots, scene operations, + document transactions/undo, source templates, bounded ZIP interchange and the + platform service protocols. Native Studio uses its scene value codec and runtime + capabilities; its existing workbench remains the native editor. +- `Editor/WebUI/src` owns the DOM interface, document transactions, ZIP interchange, + cloud/recovery controller and browser agent loop. It consumes the existing + portable snapshot/scene/API contracts; it does not import native Swift tooling. +- `platform.js` provides HTTP/SSE, IndexedDB and download; `preview.js` owns the isolated engine bridge. +- `Editor/Web` retains the previous AdaUI/WASM editor (`AdaEditorWeb`) as an + optional legacy export, including its explicit publishing/release UI. Publishing + controls have not yet been ported to the DOM shell; local testing has them disabled. +- `studio-player.js` runs on a separate HTTPS origin. It accepts only the + configured Studio origins and mounts source snapshots into the fixed player's + virtual filesystem. No Cloud token or provider key is part of that payload. + +The Editor/Code/Game tabs share one document. File and scene tools use the same +transactions as manual changes. Undo preserves the conversation. Scene preview +installs component schemas without running gameplay systems; Game runs gameplay. +The inspector edits authored component values, including position/quaternion/scale. +Unknown runtime components fail with a diagnostic before scene entities spawn. +Scene selection uses the object hierarchy; the inspector edits supported transforms, +materials and camera properties. Scene changes replace the world inside the running +Player, retaining its WASM instance, GPU device and browser window. Editor mode uses +the authored game cameras and renderer with gameplay scripts, physics and autoplay +disabled. Play recreates the world from the same source snapshot with gameplay on; +Stop restores the authored scene. Readiness is acknowledged after an actual completed +GPU frame. Failed updates show engine diagnostics, and older queued updates cannot +mark a newer document revision ready. Direct viewport picking/gizmos are not yet +implemented; do not substitute a separate schematic for the engine viewport. + +## Export + +Use Swift 6.3.2 with its `swift-6.3.2-RELEASE_wasm` SDK on Linux/macOS (WSL on +Windows), plus the pinned Tint host tool: + +```sh +cd Editor/WebUI +pnpm install --frozen-lockfile +pnpm test +pnpm build +cd ../.. +python3 script/export_studio_web.py --output dist/studio-web \ + --studio-origin https://cloud-test.adaengine.org +``` + +The command copies the DOM build to `studio/` and exports `player/` using the existing +`export-web` plugin. `--legacy-wasm-editor` additionally builds the older AdaUI shell +instead of the DOM frontend. `--prepare-only` installs entrypoints into existing +exports; `--ui-dist` selects an already built DOM bundle. For frontend-only edits, +run `pnpm build` and `python3 script/prepare_studio_frontend.py --output dist/studio-web` +without rebuilding Swift. The helper removes non-runtime debug/name custom sections +from the player and writes `AdaWebPlayer.wasm.gz`; configure the static server to +serve precompressed gzip files with `Content-Encoding: gzip`. The helper verifies/prepares Tint and propagates the +selected Swift toolchain to plugin subprocesses. Each allowed Studio origin needs its own +`--studio-origin`. `ADAENGINE_YAMS_PACKAGE_PATH` can select an already prepared +WASI dependency; otherwise the helper creates a pinned private Yams copy with the +WASI binary64 precision constant. It never rewrites shared dependency checkouts. + +Serve `studio/` at `/studio/` on the API origin. Serve the fixed player on a +separate HTTPS hostname, with `studio-preview.html` as the preview URL (a different +port on the Cloud host would still share host cookies). See the Cloud +repository's `docs/studio-web.md` for service/database/environment configuration. + +Web Player **runtime API 4** and community source package **API 2** are separate +contracts. Do not compare or substitute their numbers. + +## Saving, recovery and agents + +Cloud snapshots include source files, assets and chat. Autosave sends an expected +server revision; a conflict creates a separate project copy. IndexedDB stores +pending changes, scoped by account and project. Restoring a checkpoint creates a +new revision. ZIP export contains editable portable sources, never authentication +settings. Import creates another project identity. ZIP import accepts stored and +deflated entries and validates paths, sizes and CRCs. + +The provider registry is controlled by Cloud. Models without tools are unavailable +to the agent. Ada AI uses the existing credit reservation/settlement API; BYOK +passes the ephemeral in-memory key to the Cloud provider proxy. Neither mode sends +credentials to the player. Generation stops advancing when the tab closes; an +already submitted upstream response may finish server-side solely to settle its +actual cost. Interrupted tool calls are not blindly replayed on return. + +The retained legacy publishing UI captures a source snapshot, checks Pro and uses the existing upload, +validation and moderation pipeline. An agent cannot call publication. Draft saves +do not alter approved releases. Public release activation uses the existing +approved-release rollback API. + +## Validation + +```sh +swift test --package-path Editor/StudioCore +ADA_CLOUD_ONLY=1 swift test --filter 'StudioProvider|Workspace' +``` + +The second command belongs in the Cloud checkout. Its workspace integration suite +requires a disposable PostgreSQL database, Redis and private S3-compatible storage; +see the Cloud guide. Run `script/validate_webgpu.sh` with a compatible native +Swan/Dawn configuration and build native Studio after shared-code changes. + +The browser release gate also requires real WebGPU frames, keyboard/text input, +2D/3D scene edits, recovery/conflict handling and an end-to-end publication in the +test environment. Compilation or deterministic provider fixtures alone do not +establish live-provider, audio or hosted moderation acceptance. + +Local verification results and remaining hosted release gates are recorded in +[StudioWebValidation.md](StudioWebValidation.md). + +For local development only, the editor and isolated player may use HTTP on +different loopback hosts (for example `localhost` and `127.0.0.1`). Hosted +previews still require HTTPS on a distinct hostname. Set +`STUDIO_PUBLISHING_ENABLED=false` in Cloud to hide publication/Pro controls +while testing project editing. diff --git a/Editor/Documentation/StudioWebValidation.md b/Editor/Documentation/StudioWebValidation.md new file mode 100644 index 000000000..182654897 --- /dev/null +++ b/Editor/Documentation/StudioWebValidation.md @@ -0,0 +1,126 @@ +# Ada Studio Web validation — 2026-10-17 + +The implementation spans AdaEngine and the sibling `ada_cloud` checkout. No +external environment was deployed, no paid provider request was submitted, and +`STUDIO_WEB_ENABLED` remains off by default. + +## Engine viewport correction (current) + +The SVG scene renderer has been removed. Opening a project now starts the actual +AdaEngine Player in editor mode; both editor and Play use its authored cameras, +materials, lights and asset loaders. DOM panels still render independently of WASM. + +- The updated fixed Player exports with Swift 6.3.2/WASI. Scene/source updates mount + a new bounded `/game` tree and replace the SceneView world in the same WASM/GPU + instance. The bridge acknowledges readiness only after a completed GPU frame. +- Real browser checks covered 3D object transforms and undo, camera movement and + undo, a malformed AdaScript diagnostic, and recovery after fixing the source. + The Player retained its browser window/canvas across changes and Play/Editor. +- A 500 × 330 pixel viewport region of the static 3D test scene was **identical** + between editor and Play (mean absolute channel error 0). Moving the player caused + a measurable difference in that same region, confirming the engine applied the edit. +- The existing 2D project with scripted behavior loads in editor mode and runs in + Play; Stop restores its authored scene. World changes do not refetch/reinstantiate + WASM. Logs show one initial player load/window creation per project. +- All 16 DOM/document/bridge tests pass, including coalescing duplicate updates, + rejecting superseded acknowledgements, cancellation and origin/source checks. + The WebGPU validation script passes 38 tests in 13 suites, including actual pixels. + +Selection and transforms currently use the hierarchy/inspector. Direct on-viewport +picking/gizmos remain unimplemented; there is no substitute schematic or second +renderer. First project opening still waits for engine compilation/GPU setup in the +viewport while panels and code remain usable. + +## Earlier DOM frontend iteration (superseded viewport) + +The default frontend now lives in `Editor/WebUI`; the AdaUI/WASM editor remains +an optional legacy export. This iteration changed the browser shell and packaging, +not the Swift renderer. Local Playwright checks used real Edge/WebGPU, the running +Workspace/PostgreSQL/S3 service and separate local identity/provider fixtures. + +- `pnpm install --frozen-lockfile`, `pnpm test` (10 tests) and `pnpm build` pass. + Document tests cover shared tool/manual undo, atomic invalid edits, native YAML, + ZIP import/export, CRC corruption, unsafe paths, entry limits and secret exclusion. +- Two Python tests verify player debug-section stripping and malformed-module + rejection. `export_studio_web.py --prepare-only` installs the DOM shell and player + bridge successfully; both Cloud Caddy configurations adapt with fixture variables. +- A fresh browser context fetched approximately 359 KB of JS/CSS, with **zero WASM + requests** before Play. Local first contentful paint was 32 ms; this is a loopback + observation, not a hosted-network performance promise. +- The current debug player shrank from 232,970,294 bytes to 98,731,427 bytes after + removing non-runtime debug/name sections. Its gzip transfer is 34,508,239 bytes; + browser responses report `Content-Encoding: gzip` and `application/wasm`. + Both a real 2D sprite and the new primitive 3D environment render after stripping. +- Manual inspector/source editing, undo/redo, cloud save/reload, ZIP download/file + import, checkpoint creation/restoration, and a two-tab conflict copy passed. + A failed save survived in IndexedDB and recovered when its original project reopened. +- The deterministic agent completed scene/file edits, runtime validation and Play. + Rapid cancellation removes the iframe without a spurious error. The mobile game + viewport reaches 420 px while the editor controls remain desktop controls. +- Observed preview requests carried neither cookies nor Authorization, and the + iframe could not read the parent DOM (`SecurityError`). An entered fixture API key + was absent from the exported ZIP and empty after reload. Pro/publication controls + are absent in this local test iteration. + +The instant scene overview is an editable SVG map, not a 3D camera viewport. Actual +3D rendering/compiler validation uses the lazily loaded AdaPlayer. First runtime +startup still includes WASM compilation and GPU setup; release-player optimization +remains separate from the now lightweight editor startup. DOM publishing/release UI +has not yet been ported. The checks below describe the earlier retained AdaUI build; +they do not establish feature parity for every DOM interaction. + +## Confirmed locally + +| Area | Result | +|---|---| +| Portable core | 7 Swift Testing tests pass on Windows: ZIP CRC/deflate/round trip, import identity, atomic edits/undo, limits and credential exclusion | +| Native editor | Windows debug editor and its test executable build after extracting the common value codec; 11 selected scene/agent/mobile validation tests pass | +| WASM | `AdaEditorWeb` and fixed `AdaWebPlayer` export with Swift 6.3.2 and the matching WASM SDK | +| GPU | `script/validate_webgpu.sh` on Windows passes 38 tests in 13 suites, including actual pixels, picking and Tint conversion | +| Player source contract | Three focused scene-backed Web Player compiler tests pass, with runtime API 4 | +| Cloud | Services compile; 55 selected CloudCore/AI credit tests and 9 workspace/provider policy/integration tests pass against disposable PostgreSQL, Redis and S3-compatible HTTP storage; provider adapter tests also pass | +| Packaging | 16 Python archive/source-package tests pass; a ZIP actually downloaded from Studio assembles with the real fixed player into a 165-file publication build (runtime API 4, community package API 2) | +| Edge | Local/VPS Compose YAML parses; both Caddy configurations adapt successfully | + +Real Chrome/WebGPU interaction used the actual AdaUI/WASM application and Workspace +HTTP service, with local session/catalog/provider fixtures. These are separate +from the shipped application. Confirmed workflows include: + +- Create/open 2D and 3D projects and render actual sprite/primitive frames. +- Edit source text and camera position/cube rotation in the inspector, autosave, + and inspect the resulting server revision. The rotated cube has visible shaded faces. +- Type Cyrillic through browser keyboard events and verify UTF-8 source storage; + paste Cyrillic from the actual browser system clipboard into the AdaUI editor. +- Import and render the actual static GLB crate fixture; load a generated PCM WAV, + activate audio with pointer input, and observe nonzero audio samples submitted + by AdaAudio to Web Audio (peak 0.01144). This checks decoding/output, not a + human listening test. +- Lose connectivity, edit offline, reload, and recover from IndexedDB into Cloud. +- Download an editable ZIP and import it through the browser file picker as a new project. +- Run an in-tab deterministic agent through scene/file edits, source diagnostics and + game launch. Undo/redo preserves chat. The disposable API key is absent from snapshots. +- Save a conflicting revision through another client, then edit the stale browser + document: both original and automatically created copy retain their own changes. +- Create a checkpoint and reject publication for an account without Cloud Pro. +- Switch Game/Scene rapidly and retain exactly one preview iframe. +- Use a distinct HTTPS preview hostname: observed game requests carry neither + Cloud cookies nor Authorization; iframe parent DOM access is denied. + +## Remaining release gates + +A configured live provider must exercise both hosted Ada AI settlement and BYOK, +including a disconnected request. A Pro test account must complete the full hosted +upload, moderation, release activation and rollback path. Physical IME input, human +listening checks and the full set of supported browsers still need acceptance coverage. +The Windows GPU run does not establish every macOS-only 3D golden-frame suite. Debug WASM bundles are validation artifacts; prepare the +release exports before deploying. + +The existing full `AdaWebPlayerTests` suite has four failures in older `@view` +fixtures: the unchanged schema parser reports that AdaUI views in AdaScript are +temporarily unavailable. Scene-backed tests pass. This work does not enable the +disabled view feature or claim the full engine suite is green. The Windows Swift +6.3.1 driver also emits corrupted-JSON diagnostic-decoding warnings during otherwise +successful native compilation/linking; the selected executable tests run successfully. + +See [StudioWeb.md](StudioWeb.md) and the Cloud checkout's `docs/studio-web.md` for +commands, configuration and the feature-flag rollout gate. diff --git a/Editor/Package.swift b/Editor/Package.swift index 93f2e5033..361956577 100644 --- a/Editor/Package.swift +++ b/Editor/Package.swift @@ -56,6 +56,7 @@ let package = Package( ), ], dependencies: [ + .package(path: "StudioCore"), .package(url: "https://github.com/apple/swift-crypto.git", from: "4.0.0"), .package(name: "AdaEngine", path: adaEngineLocalPath), .package(name: "AdaDebugging", path: "Debugging"), @@ -104,6 +105,7 @@ let package = Package( makeEditorTarget( name: "AdaEditor", dependencies: [ + .product(name: "AdaStudioCore", package: "StudioCore"), .product(name: "AdaDebugging", package: "AdaDebugging"), .product(name: "AdaPlayerConnect", package: "AdaPlayerConnect"), .product(name: "AdaEngine", package: "AdaEngine"), diff --git a/Editor/Sources/AdaEditor/EditorSceneModel.swift b/Editor/Sources/AdaEditor/EditorSceneModel.swift index 83056bb79..f0887d321 100644 --- a/Editor/Sources/AdaEditor/EditorSceneModel.swift +++ b/Editor/Sources/AdaEditor/EditorSceneModel.swift @@ -1,4 +1,5 @@ @_spi(AdaEngine) import AdaEngine +import AdaStudioCore import Foundation import Yams @@ -20,66 +21,7 @@ enum EditorSceneEntityPreset: String, CaseIterable, Equatable, Sendable { } } -enum EditorSceneValue: Codable, Equatable, Sendable { - case null - case bool(Bool) - case int(Int) - case uint(UInt64) - case double(Double) - case string(String) - case array([Self]) - case object([String: Self]) - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - - if container.decodeNil() { - self = .null - } else if let value = try? container.decode(Bool.self) { - self = .bool(value) - } else if let value = try? container.decode(Int.self) { - self = .int(value) - } else if let value = try? container.decode(UInt64.self) { - self = .uint(value) - } else if let value = try? container.decode(Double.self) { - self = .double(value) - } else if let value = try? container.decode(String.self) { - self = .string(value) - } else if let value = try? container.decode([Self].self) { - self = .array(value) - } else if let value = try? container.decode([String: Self].self) { - self = .object(value) - } else { - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "Unsupported scene value" - ) - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - - switch self { - case .null: - try container.encodeNil() - case let .bool(value): - try container.encode(value) - case let .int(value): - try container.encode(value) - case let .uint(value): - try container.encode(value) - case let .double(value): - try container.encode(value) - case let .string(value): - try container.encode(value) - case let .array(values): - try container.encode(values) - case let .object(values): - try container.encode(values) - } - } -} +typealias EditorSceneValue = StudioValue extension EditorSceneValue { var jsonCompatibleValue: Any { diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+AdaScriptWeb.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+AdaScriptWeb.swift index d1df70a94..59cf5a7e7 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+AdaScriptWeb.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+AdaScriptWeb.swift @@ -1,5 +1,6 @@ #if os(macOS) import AdaScriptCompilerCore + import AdaStudioCore import Foundation extension EditorViewModel { @@ -81,7 +82,8 @@ guard let scene = artifact.sceneModel else { throw AdaScriptWebRunError.entrySceneRequired } - let supported: Set = ["AdaTransform.Transform", "AdaRender.Visibility", "AdaTilemap.TileMapComponent"] + let schemas = try AdaScriptSchemaParser.parse(sources: artifact.sources) + let supported = StudioRuntimeCapabilities.components.union(schemas.flatMap { [$0.id, $0.name] + $0.aliases }) if let unsupported = scene.entities.flatMap({ $0.components.keys }).first(where: { !supported.contains($0) }) { throw AdaScriptWebRunError.unsupportedSceneComponent(unsupported) } @@ -161,7 +163,7 @@ "bridge-js.js", "browser-wasi-shim/dist/index.js", "ada-resource-manifest.json", "ada-web-manifest.json", "package.json", "player-lobby.js", ] - return descriptor.runtimeAPI >= 3 && descriptor.profile == "universal" + return descriptor.runtimeAPI >= StudioRuntimeCapabilities.webPlayerAPI && descriptor.profile == "universal" && requiredFiles.allSatisfy { FileManager.default.fileExists(atPath: url.appendingPathComponent($0).path) } } diff --git a/Editor/StudioCore/Package.resolved b/Editor/StudioCore/Package.resolved new file mode 100644 index 000000000..d619c83fc --- /dev/null +++ b/Editor/StudioCore/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "475b11ab658616cef49931093f776ef2778054aaf1fbd65cf4a78c1f29d87c58", + "pins" : [ + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SpectralDragon/Yams.git", + "state" : { + "branch" : "fb676da", + "revision" : "fb676da5b91f677ab8e8d06b7fcde45b25181531" + } + }, + { + "identity" : "zlib", + "kind" : "remoteSourceControl", + "location" : "https://github.com/the-swift-collective/zlib.git", + "state" : { + "revision" : "f1d153b90420f9fcc6ef916cd67ea96f0e68d137", + "version" : "1.3.2" + } + } + ], + "version" : 3 +} \ No newline at end of file diff --git a/Editor/StudioCore/Package.swift b/Editor/StudioCore/Package.swift new file mode 100644 index 000000000..056dac51e --- /dev/null +++ b/Editor/StudioCore/Package.swift @@ -0,0 +1,16 @@ +// swift-tools-version: 6.2 +import PackageDescription +import Foundation + +let package = Package( + name: "AdaStudioCore", + platforms: [.macOS(.v15), .iOS(.v18)], + products: [.library(name: "AdaStudioCore", targets: ["AdaStudioCore"])], + dependencies: [ProcessInfo.processInfo.environment["ADAENGINE_YAMS_PACKAGE_PATH"].map { .package(name: "Yams", path: $0) } + ?? .package(url: "https://github.com/SpectralDragon/Yams.git", revision: "fb676da"), + .package(url: "https://github.com/the-swift-collective/zlib.git", from: "1.3.2")], + targets: [ + .target(name: "AdaStudioCore", dependencies: [.product(name: "Yams", package: "Yams"), .product(name: "ZLib", package: "zlib")]), + .testTarget(name: "AdaStudioCoreTests", dependencies: ["AdaStudioCore"]) + ] +) diff --git a/Editor/StudioCore/Sources/AdaStudioCore/StudioArchive.swift b/Editor/StudioCore/Sources/AdaStudioCore/StudioArchive.swift new file mode 100644 index 000000000..f67d6c576 --- /dev/null +++ b/Editor/StudioCore/Sources/AdaStudioCore/StudioArchive.swift @@ -0,0 +1,116 @@ +import Foundation +import ZLib + +/// Bounded, CRC-checked ZIP interchange. Exports use stored entries for portable decoding. +public enum StudioArchive { + public static func encode(_ files: [StudioFile]) throws -> Data { + guard files.count <= 4096, files.reduce(0, { $0 + $1.data.count }) <= StudioSnapshot.maximumBytes, + files.allSatisfy({ StudioSnapshot.validPath($0.path) || $0.path == "ada-game.json" || $0.path == "ada-manifest.json" }) else { + throw StudioError.invalid("Недопустимое содержимое архива.") + } + var archive = Data(), directory = Data() + for file in files.sorted(by: { $0.path < $1.path }) { + let name = Data(file.path.utf8), size = UInt32(file.data.count), offset = UInt32(archive.count), crc = crc32(file.data) + archive.integer(UInt32(0x04034b50)) + for value: UInt16 in [20, 0x0800, 0, 0, 33] { archive.integer(value) } + for value in [crc, size, size] { archive.integer(value) } + archive.integer(UInt16(name.count)); archive.integer(UInt16(0)); archive.append(name); archive.append(file.data) + directory.integer(UInt32(0x02014b50)) + for value: UInt16 in [20, 20, 0x0800, 0, 0, 33] { directory.integer(value) } + for value in [crc, size, size] { directory.integer(value) } + for value in [UInt16(name.count), 0, 0, 0, 0] { directory.integer(value) } + directory.integer(UInt32(0)); directory.integer(offset); directory.append(name) + } + let offset = UInt32(archive.count) + archive.append(directory); archive.integer(UInt32(0x06054b50)) + for value in [UInt16(0), 0, UInt16(files.count), UInt16(files.count)] { archive.integer(value) } + archive.integer(UInt32(directory.count)); archive.integer(offset); archive.integer(UInt16(0)) + return archive + } + + public static func decode(_ archive: Data) throws -> [StudioFile] { + guard archive.count <= StudioSnapshot.maximumBytes + 1024 * 1024, archive.count >= 22 else { throw StudioError.invalid("Архив слишком велик или повреждён.") } + var end: Int? + for offset in stride(from: archive.count - 22, through: max(0, archive.count - 65557), by: -1) { + if archive.read32(offset) == 0x06054b50, offset + 22 + Int(archive.read16(offset + 20)) == archive.count { end = offset; break } + } + guard let end, archive.read16(end + 4) == 0, archive.read16(end + 6) == 0, + archive.read16(end + 8) == archive.read16(end + 10) else { throw StudioError.invalid("Некорректный ZIP.") } + let count = Int(archive.read16(end + 10)) + guard let centralSize = Int(exactly: archive.read32(end + 12)), let start = Int(exactly: archive.read32(end + 16)), + start <= end, centralSize <= end else { throw StudioError.invalid("Некорректный каталог ZIP.") } + guard count > 0, count <= StudioSnapshot.maximumFiles, start + centralSize == end else { throw StudioError.invalid("Некорректный каталог ZIP.") } + var cursor = start, files: [StudioFile] = [], names = Set(), total = 0 + for _ in 0.. Data { + var stream = z_stream() + guard inflateInit2_(&stream, -MAX_WBITS, ZLIB_VERSION, Int32(MemoryLayout.size)) == Z_OK else { + throw StudioError.invalid("Не удалось открыть сжатый ZIP.") + } + defer { inflateEnd(&stream) } + var output = Data(count: max(1, size)) + let status = input.withUnsafeBytes { source in + output.withUnsafeMutableBytes { destination in + stream.next_in = UnsafeMutablePointer(mutating: source.bindMemory(to: UInt8.self).baseAddress) + stream.avail_in = UInt32(input.count) + stream.next_out = destination.bindMemory(to: UInt8.self).baseAddress + stream.avail_out = UInt32(max(1, size)) + defer { stream.next_in = nil; stream.next_out = nil } + return ZLib.inflate(&stream, Z_FINISH) + } + } + guard status == Z_STREAM_END, stream.total_out == size, stream.total_in == input.count else { + throw StudioError.invalid("Повреждённый сжатый файл ZIP.") + } + return output.prefix(size) + } + + public static func crc32(_ data: Data) -> UInt32 { + var crc = UInt32.max + for byte in data { crc = (crc >> 8) ^ crcTable[Int((crc ^ UInt32(byte)) & 0xff)] } + return crc ^ UInt32.max + } + private static let crcTable: [UInt32] = (0..<256).map { byte in + var value = UInt32(byte) + for _ in 0..<8 { value = (value >> 1) ^ (value & 1 == 1 ? 0xedb88320 : 0) } + return value + } +} + +private extension Data { + mutating func integer(_ value: T) { + for index in 0...size { append(UInt8(truncatingIfNeeded: value >> (index * 8))) } + } + func read16(_ offset: Int) -> UInt16 { UInt16(self[offset]) | UInt16(self[offset + 1]) << 8 } + func read32(_ offset: Int) -> UInt32 { UInt32(read16(offset)) | UInt32(read16(offset + 2)) << 16 } +} diff --git a/Editor/StudioCore/Sources/AdaStudioCore/StudioDocument.swift b/Editor/StudioCore/Sources/AdaStudioCore/StudioDocument.swift new file mode 100644 index 000000000..fbd230238 --- /dev/null +++ b/Editor/StudioCore/Sources/AdaStudioCore/StudioDocument.swift @@ -0,0 +1,76 @@ +import Foundation +import Observation + +/// One transactional edit path for manual operations, agent tools and undo. +@MainActor @Observable +public final class StudioDocument { + public private(set) var snapshot: StudioSnapshot + public private(set) var generation = 0 + public private(set) var selection: String? + @ObservationIgnored private var undoStack: [StudioSnapshot] = [] + @ObservationIgnored private var redoStack: [StudioSnapshot] = [] + public init(snapshot: StudioSnapshot) throws { try snapshot.validate(); self.snapshot = snapshot } + public var canUndo: Bool { !undoStack.isEmpty } + public var canRedo: Bool { !redoStack.isEmpty } + public func select(_ id: String?) { selection = id } + + public func edit(_ update: (inout StudioSnapshot) throws -> Void) throws { + var next = snapshot + try update(&next) + try next.validate() + guard next != snapshot else { return } + undoStack.append(snapshot) + if undoStack.count > 100 { undoStack.removeFirst() } + redoStack.removeAll(); snapshot = next; generation += 1 + } + + public func write(_ path: String, data: Data) throws { + try edit { snapshot in + if let index = snapshot.files.firstIndex(where: { $0.path == path }) { snapshot.files[index].data = data } + else { snapshot.files.append(StudioFile(path: path, data: data)) } + } + } + public func delete(_ path: String) throws { try edit { $0.files.removeAll { $0.path == path } } } + public func append(_ message: StudioMessage) throws { + var next = snapshot; next.messages.append(message); try next.validate() + snapshot = next; generation += 1 + } + public func undo() { + guard let previous = undoStack.popLast() else { return } + redoStack.append(snapshot) + var restored = previous; restored.messages = snapshot.messages + snapshot = restored; generation += 1 + } + public func redo() { + guard let next = redoStack.popLast() else { return } + undoStack.append(snapshot) + var restored = next; restored.messages = snapshot.messages + snapshot = restored; generation += 1 + } + public func replace(_ snapshot: StudioSnapshot) throws { try edit { $0 = snapshot } } +} + +@MainActor public protocol StudioCloudTransport { + func request(path: String, method: String, body: Data?) async throws -> Data + func stream(path: String, body: Data, onEvent: @escaping @MainActor (StudioValue) -> Void) async throws + func cancelRequests() +} + +@MainActor public protocol StudioRecoveryStorage { + func load(account: String, project: String) async throws -> Data? + func save(account: String, project: String, data: Data) async throws + func remove(account: String, project: String) async throws +} + +@MainActor public protocol StudioFileAccess { + func open(accept: String) async throws -> StudioFile? + func download(name: String, data: Data) throws +} + +@MainActor public protocol StudioPreview { + func load(_ snapshot: StudioSnapshot) async throws + func stop() + func capture() async throws -> String + func input(_ event: StudioValue) async throws + var logs: [String] { get } +} diff --git a/Editor/StudioCore/Sources/AdaStudioCore/StudioProject.swift b/Editor/StudioCore/Sources/AdaStudioCore/StudioProject.swift new file mode 100644 index 000000000..1b408e150 --- /dev/null +++ b/Editor/StudioCore/Sources/AdaStudioCore/StudioProject.swift @@ -0,0 +1,118 @@ +import Foundation + +public struct StudioFile: Codable, Equatable, Sendable, Identifiable { + public var path: String + public var data: Data + public var id: String { path } + public init(path: String, data: Data) { self.path = path; self.data = data } + public init(path: String, text: String) { self.init(path: path, data: Data(text.utf8)) } + public var text: String { String(decoding: data, as: UTF8.self) } +} + +public struct StudioMessage: Codable, Equatable, Sendable, Identifiable { + public var id: String + public var role: String + public var content: String + public var toolCalls: [StudioValue]? + public var toolCallID: String? + public var images: [String]? + public init(role: String, content: String, toolCalls: [StudioValue]? = nil, toolCallID: String? = nil, images: [String]? = nil) { + self.id = UUID().uuidString.lowercased(); self.role = role; self.content = content + self.toolCalls = toolCalls; self.toolCallID = toolCallID + self.images = images + } +} + +/// Working sources, never a compiled publication. Credentials are held outside this type. +public struct StudioSnapshot: Codable, Equatable, Sendable { + public static let maximumBytes = 64 * 1024 * 1024 + public static let maximumFiles = 512 + public var formatVersion = 1 + public var title: String + public var files: [StudioFile] + public var messages: [StudioMessage] + public init(title: String, files: [StudioFile], messages: [StudioMessage] = []) { + self.title = title; self.files = files; self.messages = messages + } + public func file(_ path: String) -> StudioFile? { files.first { $0.path == path } } + public var metadata: StudioValue { (try? file(".ada/project.json").map { try StudioValue.decode($0.data) }) ?? .null } + public var scenePath: String { metadata["runtime"]["entry"]["scene"].string ?? "Assets/Scenes/Main.ascn" } + public var sources: [StudioFile] { files.filter { $0.path.hasPrefix("Sources/") && $0.path.hasSuffix(".ada") }.sorted { $0.path < $1.path } } + + public func validate() throws { + guard formatVersion == 1, !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, title.utf8.count <= 240, + !files.isEmpty, files.count <= Self.maximumFiles, messages.count <= 1000 else { throw StudioError.invalid("Некорректный проект Studio.") } + var names = Set(), bytes = 0 + for file in files { + guard Self.validPath(file.path), names.insert(file.path.lowercased()).inserted, + file.data.count <= 16 * 1024 * 1024 else { throw StudioError.invalid("Недопустимый файл: \(file.path)") } + if file.path.hasPrefix("Sources/"), file.data.count > 512 * 1024 { throw StudioError.invalid("Исходный файл превышает 512 КБ.") } + bytes += file.data.count + guard bytes <= Self.maximumBytes else { throw StudioError.invalid("Размер проекта превышает 64 МБ.") } + } + guard file(".ada/project.json")?.data.count ?? Int.max <= 256 * 1024, + metadata["build"]["system"].string == "adascript", !sources.isEmpty, + metadata["runtime"]["entry"]["view"] == .null, metadata["runtime"]["entry"]["scene"].string != nil, file(scenePath) != nil else { + throw StudioError.invalid("Нужен AdaScript-проект со сценой и исходниками.") + } + guard [nil, "Sources"].contains(metadata["paths"]["sources"].string), [nil, "Assets"].contains(metadata["paths"]["assets"].string), + metadata["paths"]["resourceRoots"].array.allSatisfy({ $0.string == "Assets" }) else { throw StudioError.invalid("Web Studio использует Sources и Assets внутри проекта.") } + var chatBytes = 0 + for message in messages { + guard ["user", "assistant", "tool"].contains(message.role), message.content.utf8.count <= 512 * 1024 else { + throw StudioError.invalid("История чата слишком велика.") + } + guard (message.images?.count ?? 0) <= 4, (message.images ?? []).allSatisfy({ $0.hasPrefix("data:image/") && $0.utf8.count <= 512 * 1024 }) else { throw StudioError.invalid("Слишком большое изображение чата.") } + chatBytes += try JSONEncoder().encode(message).count + guard chatBytes <= 4 * 1024 * 1024 else { throw StudioError.invalid("История проекта превышает 4 МБ. Начните новый чат.") } + } + } + + public static func validPath(_ path: String) -> Bool { + guard !path.isEmpty, path.utf8.count <= 512, + path.unicodeScalars.allSatisfy({ CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_./-").contains($0) }), + path.split(separator: "/", omittingEmptySubsequences: false).allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) else { return false } + if path == ".ada/project.json" { return true } + if path.hasPrefix("Sources/") { return path.hasSuffix(".ada") } + return path.hasPrefix("Assets/") && ["png", "jpg", "jpeg", "wav", "ogg", "ascn", "tilemap", "tileset", "json", "ui", "glb", "gltf", "bin"].contains(URL(fileURLWithPath: path).pathExtension.lowercased()) + } + + /// Export uses source paths and strips host-specific settings, never flattened player sources. + public func exportFiles(newIdentity: Bool = false) throws -> [StudioFile] { + try validate() + var result = files + guard let index = result.firstIndex(where: { $0.path == ".ada/project.json" }) else { throw StudioError.invalid("Нет метаданных проекта.") } + var meta = metadata + meta["ai"] = .object([:]); meta["editor"] = .object([:]); meta["run"] = .object([:]) + meta["paths"]["build"] = .null; meta["paths"]["generated"] = .null; meta["paths"]["run"] = .object([:]) + if newIdentity { meta["project"]["id"] = .string(UUID().uuidString.lowercased()) } + result[index].data = try meta.encoded() + return result.sorted { $0.path < $1.path } + } + + public func importedCopy() throws -> Self { + var copy = self + copy.files = try exportFiles(newIdentity: true) + copy.messages = [] + return copy + } +} + +public struct StudioWorkspace: Codable, Sendable, Identifiable { + public var id: String + public var revision: Int + public var title: String + public var updatedAt: Double + public var snapshot: StudioSnapshot? + public var pageID: String? + public init(id: String, revision: Int, title: String, updatedAt: Double, snapshot: StudioSnapshot? = nil) { + self.id = id; self.revision = revision; self.title = title; self.updatedAt = updatedAt; self.snapshot = snapshot; self.pageID = nil + } +} + +public struct StudioCheckpoint: Codable, Sendable, Identifiable { + public var id: String { String(revision) } + public var revision: Int + public var title: String + public var updatedAt: Double +} diff --git a/Editor/StudioCore/Sources/AdaStudioCore/StudioScene.swift b/Editor/StudioCore/Sources/AdaStudioCore/StudioScene.swift new file mode 100644 index 000000000..1d700b8de --- /dev/null +++ b/Editor/StudioCore/Sources/AdaStudioCore/StudioScene.swift @@ -0,0 +1,68 @@ +import Foundation +import Yams + +/// Lossless scene editing: retain component and editor fields the web inspector does not expose. +public struct StudioScene: Sendable { + public var value: StudioValue + public init(data: Data) throws { + value = try YAMLDecoder().decode(StudioValue.self, from: String(decoding: data, as: UTF8.self)) + guard value["format"].string == "ada.scene", value["schemaVersion"].number == 1 else { throw StudioError.invalid("Неподдерживаемый формат сцены.") } + try validateHierarchy() + } + public init(value: StudioValue) { self.value = value } + public var entities: [StudioValue] { value["entities"].array } + public func encoded() throws -> Data { try value.encoded() } // JSON is also valid YAML. + + public func validateHierarchy() throws { + let ids = entities.compactMap { $0["id"].string } + guard ids.count == entities.count, Set(ids).count == ids.count else { throw StudioError.invalid("Повторяющийся ID объекта сцены.") } + let parents = Dictionary(uniqueKeysWithValues: entities.compactMap { entity -> (String, String)? in + guard let id = entity["id"].string, let parent = entity["parent"].string else { return nil } + return (id, parent) + }) + for id in ids { + var visited = Set([id]), node = id + while let parent = parents[node] { + guard ids.contains(parent), visited.insert(parent).inserted else { throw StudioError.invalid("Некорректная иерархия сцены.") } + node = parent + } + } + } + + public mutating func set(entity id: String, component: String, field: String, value newValue: StudioValue) throws { + var items = entities + guard let index = items.firstIndex(where: { $0["id"].string == id }) else { throw StudioError.invalid("Объект не найден.") } + items[index]["components"][component][field] = newValue + value["entities"] = .array(items) + } + public mutating func add(name: String, components: [String: StudioValue], parent: String? = nil) throws -> String { + let id = UUID().uuidString.lowercased() + var items = entities + items.append(.object(["id": .string(id), "name": .string(name), "enabled": .bool(true), "parent": parent.map(StudioValue.string) ?? .null, + "components": .object(components)])) + value["entities"] = .array(items) + try validateHierarchy() + return id + } + public mutating func remove(_ id: String) { + var removed = Set([id]) + while true { + let children = entities.filter { removed.contains($0["parent"].string ?? "") }.compactMap { $0["id"].string } + let before = removed.count; removed.formUnion(children) + if removed.count == before { break } + } + value["entities"] = .array(entities.filter { !removed.contains($0["id"].string ?? "") }) + } +} + +public enum StudioRuntimeCapabilities { + /// These describe different protocols, not interchangeable version numbers. + public static let webPlayerAPI = 4 + public static let communityPackageAPI = 2 + public static let components: Set = [ + "AdaTransform.Transform", "AdaRender.Visibility", "AdaRender.Camera", "AdaSprite.Sprite", + "AdaTilemap.TileMapComponent", "AdaScene.Mesh3DComponent", "AdaScene.Model3DSource", + "AdaScene.DirectionalLightComponent", "AdaScene.PointLightComponent", "AdaRender.Environment3D", + "AdaPhysics.PhysicsBody2DComponent", "AdaAudio.AudioSource" + ] +} diff --git a/Editor/StudioCore/Sources/AdaStudioCore/StudioTemplate.swift b/Editor/StudioCore/Sources/AdaStudioCore/StudioTemplate.swift new file mode 100644 index 000000000..0cac62126 --- /dev/null +++ b/Editor/StudioCore/Sources/AdaStudioCore/StudioTemplate.swift @@ -0,0 +1,59 @@ +import Foundation + +public enum StudioTemplate: String, CaseIterable, Sendable { + case game2D, game3D + public var title: String { self == .game3D ? "Новая 3D-игра" : "Новая 2D-игра" } + + public func makeProject(title: String? = nil) throws -> StudioSnapshot { + let title = title ?? self.title + let metadata: StudioValue = .object([ + "schemaVersion": .int(1), + "project": .object(["id": .string(UUID().uuidString.lowercased()), "name": .string("StudioGame"), "displayName": .string(title)]), + "build": .object(["system": .string("adascript")]), + "paths": .object(["sources": .string("Sources"), "assets": .string("Assets"), "resourceRoots": .array([.string("Assets")])]), + "runtime": .object(["moduleName": .string("StudioGame"), "entry": .object(["scene": .string("Assets/Scenes/Main.ascn")]), + "plugins": .object(["preset": .string(self == .game3D ? "game3d" : "game2d"), "settings": .object([:])])]), + "inputActions": .array([]) + ]) + var scene = StudioScene(value: .object([ + "format": .string("ada.scene"), "schemaVersion": .int(1), + "scene": .object(["id": .string(UUID().uuidString.lowercased()), "name": .string(title)]), "entities": .array([]) + ])) + _ = try scene.add(name: "Camera", components: [ + "AdaTransform.Transform": Self.transform(position: [0, 0, self == .game3D ? -8 : 0]), + "AdaRender.Camera": .object(["projection": .string(self == .game3D ? "perspective" : "orthographic"), "isActive": .bool(true), + "renderOrder": .int(0), "backgroundColor": Self.color(0.05, 0.08, 0.13)]) + ]) + if self == .game3D { + _ = try scene.add(name: "Sun", components: [ + "AdaTransform.Transform": Self.transform(position: [0, 4, 4]), + "AdaScene.DirectionalLightComponent": .object(["radiance": .array([.double(1), .double(1), .double(1)]), "intensity": .double(2)]) + ]) + _ = try scene.add(name: "Player", components: [ + "AdaTransform.Transform": Self.transform(), + "AdaScene.Mesh3DComponent": .object(["mesh": .string("Cube"), "size": .double(1), "color": Self.color(0.16, 0.55, 1), + "metallic": .double(0), "roughness": .double(0.6)]) + ]) + } else { + _ = try scene.add(name: "Player", components: [ + "AdaTransform.Transform": Self.transform(), + "AdaSprite.Sprite": .object(["tintColor": Self.color(0.16, 0.55, 1), "size": .object(["width": .double(80), "height": .double(80)])]) + ]) + } + let snapshot = StudioSnapshot(title: title, files: [ + .init(path: ".ada/project.json", data: try metadata.encoded()), + .init(path: "Assets/Scenes/Main.ascn", data: try scene.encoded()), + .init(path: "Sources/Game.ada", text: "// Ada Studio — describe your game in the chat to add gameplay.\n") + ]) + try snapshot.validate() + return snapshot + } + + public static func transform(position: [Double] = [0, 0, 0]) -> StudioValue { + .object(["position": .array(position.map(StudioValue.double)), "rotation": .array([.double(0), .double(0), .double(0), .double(1)]), + "scale": .array([.double(1), .double(1), .double(1)])]) + } + public static func color(_ r: Double, _ g: Double, _ b: Double) -> StudioValue { + .object(["red": .double(r), "green": .double(g), "blue": .double(b), "alpha": .double(1)]) + } +} diff --git a/Editor/StudioCore/Sources/AdaStudioCore/StudioValue.swift b/Editor/StudioCore/Sources/AdaStudioCore/StudioValue.swift new file mode 100644 index 000000000..646016c58 --- /dev/null +++ b/Editor/StudioCore/Sources/AdaStudioCore/StudioValue.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Portable scene and tool values, shared by native Studio and the browser. +public enum StudioValue: Codable, Equatable, Sendable { + case null, bool(Bool), int(Int), uint(UInt64), double(Double), string(String), array([Self]), object([String: Self]) + + public init(from decoder: any Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { self = .null } + else if let v = try? c.decode(Bool.self) { self = .bool(v) } + else if let v = try? c.decode(Int.self) { self = .int(v) } + else if let v = try? c.decode(UInt64.self) { self = .uint(v) } + else if let v = try? c.decode(Double.self) { self = .double(v) } + else if let v = try? c.decode(String.self) { self = .string(v) } + else if let v = try? c.decode([Self].self) { self = .array(v) } + else { self = .object(try c.decode([String: Self].self)) } + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .null: try c.encodeNil() + case .bool(let v): try c.encode(v) + case .int(let v): try c.encode(v) + case .uint(let v): try c.encode(v) + case .double(let v): try c.encode(v) + case .string(let v): try c.encode(v) + case .array(let v): try c.encode(v) + case .object(let v): try c.encode(v) + } + } + + public subscript(_ key: String) -> Self { + get { if case .object(let values) = self { values[key] ?? .null } else { .null } } + set { var values = object; values[key] = newValue; self = .object(values) } + } + public var object: [String: Self] { if case .object(let v) = self { v } else { [:] } } + public var array: [Self] { if case .array(let v) = self { v } else { [] } } + public var string: String? { if case .string(let v) = self { v } else { nil } } + public var number: Double? { + switch self { case .int(let v): Double(v); case .uint(let v): Double(v); case .double(let v): v; default: nil } + } + public var text: String { + switch self { + case .string(let v): v + case .null: "" + default: (try? String(decoding: encoded(), as: UTF8.self)) ?? "" + } + } + public static func decode(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } + public func encoded() throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(self) + } +} + +public enum StudioError: Error, LocalizedError, Equatable, Sendable { + case invalid(String), conflict, unavailable(String), authenticationRequired + public var errorDescription: String? { + switch self { + case .invalid(let message), .unavailable(let message): message + case .conflict: "Версия проекта изменилась. Обновите её перед повторной операцией." + case .authenticationRequired: "Войдите в Ada Cloud, чтобы продолжить." + } + } +} diff --git a/Editor/StudioCore/Tests/AdaStudioCoreTests/StudioDocumentTests.swift b/Editor/StudioCore/Tests/AdaStudioCoreTests/StudioDocumentTests.swift new file mode 100644 index 000000000..b3860513f --- /dev/null +++ b/Editor/StudioCore/Tests/AdaStudioCoreTests/StudioDocumentTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Testing +@testable import AdaStudioCore + +@Suite("Studio portable projects") +struct StudioDocumentTests { + @Test func standardZIPChecksum() { #expect(StudioArchive.crc32(Data("123456789".utf8)) == 0xcbf43926) } + @Test func importsDeflatedZIP() throws { + // Produced by Python zipfile, independently of the Studio ZIP writer. + let encoded = "UEsDBBQAAAAIANZ0UV2RnUWXEwAAAPcBAAAQAAAAU291cmNlcy9UZXN0LmFkYdPXV3BMSQxOLsosKOEaZY0UFgBQSwECFAAUAAAACADWdFFdkZ1FlxMAAAD3AQAAEAAAAAAAAAAAAAAAgAEAAAAAU291cmNlcy9UZXN0LmFkYVBLBQYAAAAAAQABAD4AAABBAAAAAAA=" + let archive = try #require(Data(base64Encoded: encoded)) + let files = try StudioArchive.decode(archive) + #expect(files.map(\.path) == ["Sources/Test.ada"]) + #expect(files.first?.text == "// " + String(repeating: "AdaScript\n", count: 50)) + } + @Test(arguments: StudioTemplate.allCases) + func archiveRoundTrip(_ template: StudioTemplate) throws { + let project = try template.makeProject() + let archive = try StudioArchive.encode(project.exportFiles()) + let files = try StudioArchive.decode(archive) + let imported = try StudioSnapshot(title: project.title, files: files).importedCopy() + try imported.validate() + #expect(imported.sources == project.sources) + #expect(imported.metadata["project"]["id"] != project.metadata["project"]["id"]) + #expect(imported.file(project.scenePath)?.data == project.file(project.scenePath)?.data) + } + + @Test @MainActor + func atomicEditsUndoAndScenePreservation() throws { + let document = try StudioDocument(snapshot: StudioTemplate.game3D.makeProject()) + var scene = try StudioScene(data: #require(document.snapshot.file(document.snapshot.scenePath)).data) + let player = try #require(scene.entities.first(where: { $0["name"].string == "Player" })?["id"].string) + let before = document.snapshot + try scene.set(entity: player, component: "AdaTransform.Transform", field: "position", value: .array([.int(2), .int(3), .int(4)])) + try document.write(document.snapshot.scenePath, data: scene.encoded()) + #expect(document.canUndo) + document.undo() + #expect(document.snapshot == before) + document.redo() + #expect(document.snapshot != before) + let valid = document.snapshot + #expect(throws: StudioError.self) { try document.delete(".ada/project.json") } + #expect(document.snapshot == valid) + } + + @Test + func archiveRejectsCorruptionAndUnsafePaths() throws { + let project = try StudioTemplate.game2D.makeProject() + var archive = try StudioArchive.encode(project.exportFiles()) + archive[60] ^= 0xff + #expect(throws: StudioError.self) { _ = try StudioArchive.decode(archive) } + for path in ["../key", "Assets/../../secret", "/etc/passwd", "Assets\\secret", ".ada/credentials.json", "Assets/a.png/../b.png"] { + #expect(!StudioSnapshot.validPath(path)) + } + #expect(throws: StudioError.self) { _ = try StudioArchive.decode(Data(repeating: 0, count: 30)) } + } + + @Test + func credentialsAndHostSettingsDoNotExport() throws { + var project = try StudioTemplate.game2D.makeProject() + var metadata = project.metadata + metadata["ai"] = .object(["apiKey": .string("SECRET")]) + metadata["editor"] = .object(["machinePath": .string("SECRET")]) + let index = try #require(project.files.firstIndex(where: { $0.path == ".ada/project.json" })) + project.files[index].data = try metadata.encoded() + let archive = try StudioArchive.encode(project.exportFiles()) + #expect(!String(decoding: archive, as: UTF8.self).contains("SECRET")) + } + + @Test + func malformedAndDuplicateProjectsAreRejected() throws { + var project = try StudioTemplate.game2D.makeProject() + project.files.append(project.files[0]) + #expect(throws: StudioError.self) { try project.validate() } + var scene = StudioScene(value: .object(["entities": .array([])])) + #expect(throws: StudioError.self) { _ = try scene.add(name: "Cycle", components: [:], parent: "missing") } + } +} diff --git a/Editor/Web/Package.resolved b/Editor/Web/Package.resolved new file mode 100644 index 000000000..09dcc85a2 --- /dev/null +++ b/Editor/Web/Package.resolved @@ -0,0 +1,132 @@ +{ + "originHash" : "5a9da92fdfc303bde6d27aebbc62462ec94571f99c3c08f575e2a40f9fdc8542", + "pins" : [ + { + "identity" : "javascriptkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftwasm/JavaScriptKit.git", + "state" : { + "revision" : "403ae956d68503957699b6dc4c07b73caad031b7", + "version" : "0.53.0" + } + }, + { + "identity" : "swan", + "kind" : "remoteSourceControl", + "location" : "https://github.com/adobe/swan", + "state" : { + "revision" : "1eb45a22c79439b50e9cde774454450e50147149", + "version" : "0.0.8" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark.git", + "state" : { + "revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a", + "version" : "0.9.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "35a1edcb8ebd2bfe251d587f0b69e62af28f508c", + "version" : "1.7.3" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing", + "state" : { + "revision" : "297b5b9c82a5631e3199ce3ec0993a3cfb0fbb84", + "version" : "1.7.2" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log", + "state" : { + "revision" : "4038b6a4f74a1426f47eb1dd5b4bb5628a15d597", + "version" : "1.16.1" + } + }, + { + "identity" : "swift-markdown", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-markdown.git", + "state" : { + "revision" : "25cb61d3482054b09ae76ca4f281b1bfe7fe5a43", + "version" : "0.9.0" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context", + "state" : { + "revision" : "853ba3d9ce78851b9cc20108e8bd4f13f7fa546c", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-subprocess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-subprocess.git", + "state" : { + "branch" : "0.2.1", + "revision" : "44922dfe46380cd354ca4b0208e717a3e92b13dd" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" + } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SpectralDragon/Yams.git", + "state" : { + "branch" : "fb676da", + "revision" : "fb676da5b91f677ab8e8d06b7fcde45b25181531" + } + }, + { + "identity" : "zlib", + "kind" : "remoteSourceControl", + "location" : "https://github.com/the-swift-collective/zlib.git", + "state" : { + "revision" : "f1d153b90420f9fcc6ef916cd67ea96f0e68d137", + "version" : "1.3.2" + } + } + ], + "version" : 3 +} diff --git a/Editor/Web/Package.swift b/Editor/Web/Package.swift new file mode 100644 index 000000000..c0724b53d --- /dev/null +++ b/Editor/Web/Package.swift @@ -0,0 +1,26 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "AdaStudioWeb", + platforms: [.macOS(.v15)], + products: [.executable(name: "AdaEditorWeb", targets: ["AdaEditorWeb"])], + dependencies: [ + .package(name: "AdaEngine", path: "../.."), + .package(path: "../StudioCore"), + // Match the engine's generated Swan bridges (newer JSKit changes the lowering ABI). + .package(url: "https://github.com/swiftwasm/JavaScriptKit.git", exact: "0.53.0") + ], + targets: [.executableTarget( + name: "AdaEditorWeb", + dependencies: [ + .product(name: "AdaEngine", package: "AdaEngine"), + .product(name: "AdaScriptCompilerCore", package: "AdaEngine"), + .product(name: "AdaStudioCore", package: "StudioCore"), + .product(name: "JavaScriptKit", package: "JavaScriptKit", condition: .when(platforms: [.wasi])) + ], + resources: [.copy("Resources")], + swiftSettings: [.define("WASM", .when(platforms: [.wasi])), .define("BROWSER", .when(platforms: [.wasi]))], + linkerSettings: [.unsafeFlags(["-Xclang-linker", "-mexec-model=reactor", "-Xlinker", "--export-if-defined=main", "-Xlinker", "--export-if-defined=__main_argc_argv"], .when(platforms: [.wasi]))] + )] +) diff --git a/Editor/Web/Sources/AdaEditorWeb/AdaEditorWeb.swift b/Editor/Web/Sources/AdaEditorWeb/AdaEditorWeb.swift new file mode 100644 index 000000000..c2fc997da --- /dev/null +++ b/Editor/Web/Sources/AdaEditorWeb/AdaEditorWeb.swift @@ -0,0 +1,331 @@ +import AdaEngine +import AdaStudioCore +import Foundation + +@main @MainActor +struct AdaEditorWeb: App { + var body: some AppScene { + WindowGroup { StudioRootView() } + .windowTitle("Ada Studio") + .windowMode(.windowed) + } +} + +@MainActor struct StudioRootView: View { + @State private var model = StudioViewModel(browser: StudioBrowser()) + private let background = Color.fromHex(0x0d131b) + private let panel = Color.fromHex(0x151e29) + private let muted = Color.fromHex(0x98a6ba) + private let blue = Color.fromHex(0x2585ff) + + var body: some View { + GeometryReader { geometry in + VStack(spacing: 0) { + header.frame(height: 80).background(Color.fromHex(0x171f2a)) + HStack(spacing: 0) { + navigation.frame(width: 72).background(panel) + if model.accountID == nil { + VStack(spacing: 24) { + Text("Ваша игровая студия в браузере").font(.system(size: 32, weight: .bold)) + Text("Войдите в Ada Cloud, чтобы создавать и сохранять игры.").foregroundColor(muted) + action("Войти в Ada Cloud", accent: true, model.signIn) + Text(model.status).font(.system(size: 12)).foregroundColor(muted) + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else if model.document != nil { + chat.frame(width: 360) + workbench + } else { + home + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + if let error = model.error { + HStack { + Text(error).font(.system(size: 12)).foregroundColor(Color.fromHex(0xffbf80)) + Spacer() + action("Закрыть") { model.error = nil } + }.padding(.horizontal, 18).frame(height: 36).background(panel) + } + } + .foregroundColor(.white) + .textFieldStyle(PlainTextFieldStyle()) + .textEditorColors(TextEditorColors(background: panel, border: Color.fromHex(0x2a384c), focusedBorder: blue, + gutter: muted, gutterRule: Color.fromHex(0x2a384c), currentLineBackground: blue.opacity(0.08), selection: blue.opacity(0.3))) + .background(background) + .frame(width: geometry.size.width, height: geometry.size.height) + .onAppear { layoutPreview(geometry.size) } + .onChange(of: geometry.size) { _, size in layoutPreview(size) } + .onChange(of: previewLayoutKey) { _, _ in layoutPreview(geometry.size) } + } + .task { await model.start() } + .accessibilityIdentifier("AdaStudio.Web") + } + + private var previewLayoutKey: String { "\(model.tab.rawValue):\(model.isRunning):\(model.showsHistory):\(model.showsSettings):\(model.error != nil):\(model.document != nil)" } + private func layoutPreview(_ size: Size) { + model.browser.previewBounds(x: 448, y: 156, width: size.width - 464 - (model.tab == .scene ? 280 : 0), + height: size.height - 242 - (model.error == nil ? 0 : 36), + visible: model.isRunning && model.tab != .code && !model.showsHistory && !model.showsSettings && model.document != nil) + } + + private var header: some View { + HStack(spacing: 22) { + Text("Λ").font(.system(size: 34, weight: .bold)).foregroundColor(.white) + Text("Ada Studio").font(.system(size: 24, weight: .semibold)) + Text(model.document?.snapshot.title ?? "Создаём игру").font(.system(size: 16, weight: .semibold)) + if model.document != nil { Text(model.status).font(.system(size: 13)).foregroundColor(model.status == "Сохранено" ? Color.fromHex(0x36d58a) : muted) } + Spacer() + if model.document != nil { + action("История") { Task { await model.history() } } + action("Скачать", model.download) + if model.publishingEnabled { action("Опубликовать PRO", accent: true) { Task { await model.publish() } } } + } + if model.accountID == nil { action("Войти", accent: true, model.signIn) } + else { Text(String(model.accountName.prefix(1))).font(.system(size: 17, weight: .bold)).padding(12).background(panel) } + } + .padding(.horizontal, 24) + .background(Color.fromHex(0x171f2a)) + } + + private var navigation: some View { + VStack(spacing: 20) { + navigationAction(.home, model.home) + navigationAction(.chat) { model.tab = .game } + navigationAction(.scene) { model.tab = .scene; Task { await model.play() } } + navigationAction(.play) { model.tab = .game; Task { await model.play() } } + Spacer() + navigationAction(.settings) { model.showsSettings.toggle(); model.showsHistory = false } + }.padding(.vertical, 22).frame(maxHeight: .infinity).background(panel) + } + + private var home: some View { + VStack(alignment: .leading, spacing: 24) { + Spacer() + Text("Какую игру создадим?").font(.system(size: 36, weight: .bold)) + Text("Опишите идею. Создавайте, пробуйте и улучшайте её вместе с агентом.").foregroundColor(muted) + composer.frame(height: 130) + HStack(spacing: 14) { + action("+ 2D-игра", accent: true) { Task { await model.create(.game2D) } } + action("+ 3D-игра") { Task { await model.create(.game3D) } } + action("Загрузить проект") { Task { await model.importProject() } } + } + Text("Мои проекты").font(.system(size: 20, weight: .semibold)) + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(model.projects, id: \.id) { project in + HStack { + action(project.title) { Task { await model.open(project) } } + action("Удалить") { Task { await model.deleteProject(project) } } + } + } + }.frame(maxWidth: .infinity, alignment: .leading) + }.frame(height: 220) + Spacer() + }.padding(48).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + + private var chat: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Создаём игру").font(.system(size: 24, weight: .semibold)).padding(.top, 20) + ScrollView { + VStack(alignment: .leading, spacing: 18) { + ForEach(model.visibleMessages, id: \.id) { message in + VStack(alignment: .leading, spacing: 8) { + Text(message.role == "user" ? "Вы" : "Λ Ada").font(.system(size: 12, weight: .semibold)).foregroundColor(muted) + Text(message.content).font(.system(size: 14)) + }.padding(16).frame(maxWidth: .infinity, alignment: .leading) + .background(message.role == "user" ? Color.fromHex(0x22334e) : panel) + } + if model.isAgentRunning { Text(model.agentText.isEmpty ? "Агент работает…" : model.agentText).font(.system(size: 14)).padding(16).background(panel) } + } + }.frame(maxHeight: .infinity) + if let selection = model.document?.selection { Text("В контексте: " + (model.selectedEntity?["name"].string ?? selection)).font(.system(size: 11)).foregroundColor(muted) } + if !model.attachmentPaths.isEmpty { Text("Ассеты: \(model.attachmentPaths.count)").font(.system(size: 11)).foregroundColor(muted) } + composer.frame(height: 120) + }.padding(.horizontal, 18).padding(.bottom, 20).frame(maxHeight: .infinity).background(Color.fromHex(0x101821)) + } + + private var composer: some View { + VStack(spacing: 8) { + TextEditor("Что изменим в игре?", text: Binding(get: { model.prompt }, set: { model.prompt = $0 }), showsLineNumbers: false, wrapsLines: true) + .font(.system(size: 14)).frame(maxWidth: .infinity, maxHeight: .infinity) + HStack { + action("+") { Task { await model.attachAsset() } } + action(model.billingMode == "ada" ? "Ada AI" : "Свой ключ") { model.showsSettings.toggle() } + Spacer() + if model.isAgentRunning { action("Стоп", model.stopAgent) } + else { action("Отправить", accent: true, model.sendPrompt) } + } + }.padding(10).background(panel) + } + + private var workbench: some View { + VStack(spacing: 0) { + HStack(spacing: 16) { + if let publication = model.publicationStatus { Text(publication).font(.system(size: 12)).foregroundColor(muted) } + ForEach(StudioTab.allCases, id: \.rawValue) { tab in + action(tab.rawValue, accent: model.tab == tab) { + model.tab = tab; model.showsSettings = false; model.showsHistory = false + if tab != .code { Task { await model.play() } } + } + } + Spacer() + action(model.isRunning ? "Перезапустить" : "Запустить") { Task { await model.play() } } + if model.isRunning { action("Стоп", model.stop) } + }.padding(.horizontal, 16).frame(height: 64) + if model.showsSettings { settings } + else if model.showsHistory { history } + else if model.tab == .code { code } + else { + HStack(spacing: 16) { + VStack(spacing: 14) { + Spacer() + if !model.isRunning { + Text("Ваша игра появится здесь").font(.system(size: 23, weight: .semibold)) + Text("Нажмите «Запустить», чтобы попробовать текущую версию.").foregroundColor(muted) + action("Запустить", accent: true) { Task { await model.play() } } + } + Spacer() + }.frame(maxWidth: .infinity, maxHeight: .infinity).background(panel) + if model.tab == .scene { inspector.frame(width: 264) } + }.padding(.horizontal, 16).padding(.top, 12).frame(maxHeight: .infinity) + HStack(spacing: 16) { + action("Добавить цель") { model.prompt = "Добавь понятную цель игры и условие победы"; model.sendPrompt() } + action("Улучшить окружение") { model.prompt = "Улучши окружение, сохрани управление и правила игры"; model.sendPrompt() } + action("Отменить", model.undo) + action("Повторить", model.redo) + }.frame(height: 86) + } + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var code: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + ForEach(model.document?.snapshot.sources ?? [], id: \.path) { file in action(file.path) { model.activeFile = file.path } } + Spacer() + action("Проверить") { let result = model.validateSources(); model.error = result.isEmpty ? "Ошибок не найдено." : result } + } + TextEditor(text: Binding(get: { model.document?.snapshot.file(model.activeFile)?.text ?? "" }, set: model.writeCode), + sourceInteraction: TextEditorSourceInteraction(onSelectionChange: { _, text in model.codeSelection = text }), + foldingStyle: .braces, showsIndentationGuides: true) + .font(.system(size: 14)).frame(maxWidth: .infinity, maxHeight: .infinity) + }.padding(16).frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var inspector: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("Сцена").font(.system(size: 16, weight: .semibold)) + ForEach(model.scene?.entities ?? [], id: \.text) { entity in + action(entity["name"].string ?? "Object", accent: model.document?.selection == entity["id"].string) { model.document?.select(entity["id"].string) } + } + action("+ Объект", model.addEntity) + if let entity = model.selectedEntity { + Text("Инспектор").font(.system(size: 16, weight: .semibold)).padding(.top, 12) + action("Удалить объект", model.removeSelectedEntity) + ForEach(entity["components"].object.keys.sorted(), id: \.self) { component in + Text(component.components(separatedBy: ".").last ?? component).font(.system(size: 13, weight: .semibold)).foregroundColor(muted) + ForEach(entity["components"][component].object.keys.sorted(), id: \.self) { field in + Text(field).font(.system(size: 11)).foregroundColor(muted) + TextField(field, text: Binding(get: { model.selectedEntity?["components"][component][field].text ?? "" }, + set: { model.setSceneField(component: component, field: field, text: $0) })).font(.system(size: 12)) + } + } + } + Text("Ассеты").font(.system(size: 16, weight: .semibold)).padding(.top, 12) + action("Загрузить ассет") { Task { await model.attachAsset() } } + ForEach(model.document?.snapshot.files.filter { $0.path.hasPrefix("Assets/") } ?? [], id: \.path) { file in + Text(file.path).font(.system(size: 11)).foregroundColor(muted) + } + }.padding(14).frame(maxWidth: .infinity, alignment: .leading) + }.frame(maxHeight: .infinity).background(panel) + } + + private var settings: some View { + VStack(alignment: .leading, spacing: 18) { + Text("Подключение агента").font(.system(size: 24, weight: .semibold)) + HStack { + action("Ada AI", accent: model.billingMode == "ada") { model.billingMode = "ada" } + action("Свой API-ключ", accent: model.billingMode == "byok") { model.billingMode = "byok" } + } + ForEach(model.providers, id: \.text) { provider in + action(provider["title"].string ?? provider["id"].text, accent: provider["id"].string == model.providerID) { + model.providerID = provider["id"].string ?? ""; model.modelID = model.models.first?["id"].string ?? "" + } + } + ForEach(model.models, id: \.text) { modelInfo in + action(modelInfo["title"].string ?? modelInfo["id"].text, accent: modelInfo["id"].string == model.modelID) { model.modelID = modelInfo["id"].string ?? "" } + } + if model.billingMode == "byok" { + TextField("API-ключ", text: Binding(get: { model.apiKey }, set: { model.apiKey = $0 })) + Text("Ключ используется Cloud-прокси для запросов к выбранному провайдеру. Он хранится только в памяти этой вкладки и не входит в проект.").foregroundColor(muted) + } else { Text(model.publishingEnabled ? "Используются AI-кредиты Ada. Cloud Pro оплачивает публикацию отдельно." : "Используется выбранный сервис агента.").foregroundColor(muted) } + action("Готово", accent: true) { model.showsSettings = false } + Spacer() + }.padding(30).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + + private var history: some View { + VStack(alignment: .leading, spacing: 16) { + Text("История проекта").font(.system(size: 24, weight: .semibold)) + action("Сохранить контрольную версию", accent: true) { Task { await model.save(checkpoint: true); await model.history() } } + ForEach(model.checkpoints, id: \.id) { checkpoint in + action("Версия \(checkpoint.revision) — восстановить") { Task { await model.restore(checkpoint) } } + } + if !model.releases.isEmpty { + Text("Публикации").font(.system(size: 18, weight: .semibold)) + ForEach(model.releases, id: \.text) { release in + HStack { + Text("Версия " + release["number"].text + " · " + release["status"].text).font(.system(size: 13)) + if release["status"].string == "approved" { action("Активировать") { Task { await model.activateRelease(release) } } } + } + } + } + action("Закрыть") { model.showsHistory = false } + Spacer() + }.padding(30).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + + private func action(_ title: String, accent: Bool = false, _ callback: @escaping () -> Void) -> some View { + Button(action: callback) { Text(title).font(.system(size: 13, weight: .regular)).padding(.horizontal, 12).padding(.vertical, 10) + .foregroundColor(.white).background(RoundedRectangleShape(cornerRadius: 7).fill(accent ? blue : panel)) } + .buttonStyle(DefaultButtonStyle()) + } + private func navigationAction(_ icon: StudioIcon, _ callback: @escaping () -> Void) -> some View { + Button(action: callback) { StudioIconShape(icon: icon).stroke(muted, lineWidth: 1.8).frame(width: 22, height: 22).padding(12) } + .buttonStyle(DefaultButtonStyle()) + } +} + +private enum StudioIcon { case home, chat, scene, play, settings } +private struct StudioIconShape: Shape { + typealias AnimatableData = EmptyAnimatableData + let icon: StudioIcon + func path(in rect: Rect) -> Path { + var path = Path() + func line(_ points: [(Float, Float)], close: Bool = false) { + for (index, point) in points.enumerated() { + let value = Point(rect.minX + point.0 * rect.width, rect.minY + point.1 * rect.height) + if index == 0 { path.move(to: value) } else { path.addLine(to: value) } + } + if close { path.closeSubpath() } + } + switch icon { + case .home: + line([(0.05,0.45),(0.5,0.08),(0.95,0.45)]) + line([(0.18,0.36),(0.18,0.92),(0.42,0.92),(0.42,0.63),(0.64,0.63),(0.64,0.92),(0.82,0.92),(0.82,0.36)]) + case .chat: + line([(0.08,0.12),(0.92,0.12),(0.92,0.75),(0.35,0.75),(0.08,0.96)], close: true) + line([(0.25,0.33),(0.75,0.33)]); line([(0.25,0.52),(0.6,0.52)]) + case .scene: + line([(0.5,0.04),(0.94,0.27),(0.94,0.76),(0.5,0.98),(0.06,0.76),(0.06,0.27)], close: true) + line([(0.06,0.27),(0.5,0.5),(0.94,0.27)]); line([(0.5,0.5),(0.5,0.98)]) + case .play: line([(0.22,0.08),(0.88,0.5),(0.22,0.92)], close: true) + case .settings: + for y: Float in [0.2,0.5,0.8] { line([(0.05,y),(0.95,y)]) } + line([(0.3,0.08),(0.3,0.32)]); line([(0.7,0.38),(0.7,0.62)]); line([(0.4,0.68),(0.4,0.92)]) + } + return path + } +} diff --git a/Editor/Web/Sources/AdaEditorWeb/Resources/AdaScriptRecipes.md b/Editor/Web/Sources/AdaEditorWeb/Resources/AdaScriptRecipes.md new file mode 100644 index 000000000..3b32aff36 --- /dev/null +++ b/Editor/Web/Sources/AdaEditorWeb/Resources/AdaScriptRecipes.md @@ -0,0 +1,232 @@ +# AdaScript gameplay recipes + +Short, working patterns for input, movement, spawning, collisions, score, restart and sound in AdaScript games. + +The complete game that uses all of them is the bundled example `side-scroller-3d`: a 3D side-scroller in about twenty small files with a scene, a HUD, sounds and tests. In Web Studio, inspect the current project with `editor_project_files`, `editor_file_read` and `editor_scene_get`. Blocks marked `// recipe` below are compiled by Studio's test suite, so they are safe to copy. Studio's pure AdaScript projects accept `@resource` structs, bound with `@res`; the example nevertheless keeps shared data (round state, intent, cues) in `@component` structs on one "Game" entity in the scene, read with `@query`, so the scene can author it and tests can read it. Syntax follows ADR-0001 to ADR-0003 (annotations and modules, ECS queries, components and resources); see and . + +## Lay the game out in files + +One responsibility per file, grouped by layer: `Config.ada` (all balance numbers), `Rules.ada` (pure functions), `Components/` (data only), `Systems/` (one `@system` per file), `UI/`, `Tests/`. Import across files with `import { Name } from "./Path.ada";`. Systems are ordered with `@before(id:)` and `@after(id:)`. Do not write a game as one file. + +```ada +// recipe +class Config { + static var gravity = 22.0; + static var flapSpeed = 7.5; +} + +class Rules { + static func fall(velocity, dt) { return velocity - Config.gravity * dt; } +} + +@test("gravity pulls down") +func gravityPullsDown() { + expect(Rules.fall(0.0, 0.1) < 0.0); +} +``` + +## Input + +Declare actions (name and bindings such as Space, left mouse button, touch) in project settings; they are stored in `.ada/project.json`. Bind the `Input` resource in exactly one system (`@res(optional: true)` with an `available()` check, so tests can run it without `Input`) and publish what the player asked for as a component on the Game entity. The other systems read that component, which keeps them testable because tests have no `Input`. + +```ada +// recipe +@component(id: "recipes.intent") +struct Intent { + @export var flap = false; + @export var move = 0.0; +} + +@system(id: "recipes.input") +class InputSystem { + @res(optional: true) var input: Input; + @query(Intent) var intents; + + func update(context) { + if (!input.available()) return; + for (var game in intents) { + game.intent.flap = input.isActionJustPressed("Flap"); + game.intent.move = input.getActionStrength("MoveRight") - input.getActionStrength("MoveLeft"); + } + } +} +``` + +`isActionPressed` is true while held, `isActionJustPressed` and `isActionJustReleased` are single-frame transitions, `getActionStrength` is 0 to 1. Action names are case sensitive; an unknown name is false or zero. + +## Movement and gravity + +Components hold state; the position is the entity's `Transform`. Read `position` as a list and write a whole list back. Scale all motion by `context.deltaTime`, clamped so a long frame cannot skip over a collision. + +```ada +// recipe +class Tuning { + static var gravity = 20.0; +} + +@component(id: "recipes.body") +struct Body { + @export var velocity = 0.0; +} + +@system(id: "recipes.fall") +class FallSystem { + @query(Body, Transform) var bodies; + + func update(context) { + var dt = Math.clamp(context.deltaTime, 0.0, 0.05); + for (var row in bodies) { + row.body.velocity -= Tuning.gravity * dt; + var position = row.transform.position; + row.transform.position = [position[0], position[1] + row.body.velocity * dt, position[2]]; + } + } +} + +@test("a body falls") +func bodyFalls() { + var world = TestWorld(); + var body = world.spawn([Body(), Transform(position: [0.0, 5.0, 0.0])]); + world.runSystem(FallSystem, 30); + expect(world.read(body, "Body", "velocity") < 0.0); +} +``` + +Put balance numbers in a `Config` class (`static var`), never in the systems. + +## Spawning and removing entities + +Structural changes go through `context.world.commands` and are applied after the system finishes. `spawn([...])` takes component constructors; `despawn(entity.id)` removes an entity. A query row is borrowed: use it inside the loop, do not store it. + +```ada +// recipe +@component(id: "recipes.shot") +struct Shot { + @export var life = 0.6; +} + +@component(id: "recipes.spawner") +struct Spawner { + @export var left = 0.0; +} + +@system(id: "recipes.spawn") +class SpawnSystem { + @query(Spawner) var spawners; + + func update(context) { + var fire = false; + for (var row in spawners) { + row.spawner.left -= context.deltaTime; + if (row.spawner.left <= 0.0) { + row.spawner.left = 0.25; + fire = true; + } + } + if (fire) { context.world.commands.spawn([Transform(position: [0.0, 0.0, 0.0]), Shot()]); } + } +} + +@system(id: "recipes.expire") +class ExpireSystem { + @query(Shot) var shots; + + func update(context) { + for (var row in shots) { + row.shot.life -= context.deltaTime; + if (row.shot.life <= 0.0) { context.world.commands.despawn(row.id); } + } + } +} + +@test("shots appear and expire") +func shotsComeAndGo() { + var world = TestWorld(); + world.spawn([Spawner()]); + world.run(30); + expect(world.count("Shot") > 0); + expect(world.count("Shot") < 5); +} +``` + +Mesh and material components cannot be constructed from a script, so anything with a visible 3D mesh is authored in the scene. Scroll a **pool** of scene entities and reuse them instead of spawning: when an obstacle leaves the screen, move it to the back of the line and give it a new gap (`ObstacleSystem` in the example). Spawn and despawn suit logic-only entities and entities built from components a script can construct. + +## Collisions and scoring + +Keep the geometry in a pure function and the reaction in a system. For a side view, test the circle or box against columns in x and y. Score a column once, with a `passed` flag on the obstacle component, and raise a cue for sound. + +```ada +// recipe +class Hit { + // True when a circle at (x, y) with radius r touches a circle at (cx, cy) with radius cr. + static func circles(x, y, r, cx, cy, cr) { + var dx = x - cx; + var dy = y - cy; + var reach = r + cr; + return dx * dx + dy * dy <= reach * reach; + } +} + +@test("circles touch only when close") +func circlesTouch() { + expect(Hit.circles(0.0, 0.0, 1.0, 1.5, 0.0, 1.0)); + expect(!Hit.circles(0.0, 0.0, 1.0, 3.0, 0.0, 1.0)); +} +``` + +Two queries in one system can be nested (`for (var a in as) { for (var b in bs) { ... } }`). Pass numbers into functions, not rows. + +## Phases, game over and restart + +Store the round in a component on the Game entity: `phase` (ready, playing, over), `score`, `best` and a `resetRequested` flag. One system changes the phase from the intent; another system performs the reset when the flag is set (positions, velocities, score, flags), then clears the flag. A reset flag that starts `true` also places everything on the first frame. Gameplay systems return early unless the phase is playing. + +## Sound with AudioSource + +Create the sound with `editor.audio.generate`, add one entity per sound in the scene with an `AudioSource` (`source: "@res://Audio/flap.wav"`) and a small marker component carrying the cue name. Systems raise a cue flag in a component on the Game entity; one audio system sets `audioSource.play = true` on the matching entity and clears the cues. The engine clears `play` itself. Use one entity per sound that can overlap itself, because a second `play` restarts the first. + +```ada +// recipe +@component(id: "recipes.cue-name") +struct CueName { + @export var name = ""; +} + +@component(id: "recipes.cues") +struct Cues { + @export var hit = false; +} + +@system(id: "recipes.audio") +class AudioSystem { + @query(Cues) var games; + @query(AudioSource, CueName) var sounds; + + func update(context) { + var hit = false; + for (var game in games) { + hit = game.cues.hit; + game.cues.hit = false; + } + for (var row in sounds) { + if (row.cueName.name == "hit" && hit) { row.audioSource.play = true; } + } + } +} + +@test("a hit cue plays the matching sound") +func hitPlaysSound() { + var world = TestWorld(); + world.spawn([Cues(hit: true)]); + var sound = world.spawn([AudioSource(source: "@res://Audio/hit.wav"), CueName(name: "hit")]); + world.runSystem(AudioSystem, 1); + expect(world.read(sound, "AudioSource", "play")); +} +``` + +## HUD + +Author the HUD as a `.ui` file with string inputs, add a `UIComponent` entity that points to it, and bind each input to an exported field of a `@scriptable` class with `scriptBindings`. A scriptable can bind a script-defined `@component` with `@component var state: GameState;` and read it in `update(context)`. The example instead has one system write the text into a small `static var` presentation model (`HudModel`) that the scriptable copies into its exported fields. Gameplay never reads the model. Keep the wording in a pure function so tests can check it. + +## Test the game without a window + +`TestWorld()` creates an isolated world with the module's systems; `run(ticks)` runs fixed 1/60 s ticks, `advance(seconds)` runs one step, `read(entity, "Component", "field")` and `count("Component")` inspect it. It has no `Input` resource, so bind `Input` optionally in the input system and press keys by replacing the intent component: `world.insert(game, Intent(flap: true))`, run one tick, then insert a fresh `Intent()`. A test cannot read resources, so keep data a test must check in components. `Tests/RoundTests.ada` in the example does this for the whole round: start, flap, fall, game over, restart, scoring and sound. diff --git a/Editor/Web/Sources/AdaEditorWeb/Resources/studio-browser.js b/Editor/Web/Sources/AdaEditorWeb/Resources/studio-browser.js new file mode 100644 index 000000000..96bfc6578 --- /dev/null +++ b/Editor/Web/Sources/AdaEditorWeb/Resources/studio-browser.js @@ -0,0 +1,200 @@ +// Browser capabilities for the AdaUI/WASM shell. No editor UI or agent loop lives here. +const controllers = new Set(); +const generations = new Set(); +let frame, previewPending = new Map(), previewBounds, previewOrigin, previewEpoch = 0, cancelFrameLoad; +const base64 = bytes => { + let text = ''; + for (let n = 0; n < bytes.length; n += 32768) text += String.fromCharCode(...bytes.subarray(n, n + 32768)); + return btoa(text); +}; +const unbase64 = value => Uint8Array.from(atob(value), c => c.charCodeAt(0)); +const safePath = path => typeof path === 'string' && /^[a-zA-Z0-9_./-]+$/.test(path) && path.length <= 512 && path.split('/').every(p => p && p !== '.' && p !== '..'); + +async function http(path, method, body, signal) { + if (!path.startsWith('/v1/') || path.includes('://') || path.includes('..')) throw new Error('Invalid API path'); + const send = () => fetch(path, {method, credentials: 'same-origin', cache: 'no-store', signal, + headers: {'Content-Type': 'application/json'}, ...(body == null ? {} : {body: JSON.stringify(body)})}); + let response = await send(); + if (response.status === 401) { + const refresh = await fetch('/v1/auth/refresh', {method: 'POST', credentials: 'same-origin', signal}); + if (refresh.ok) response = await send(); + } + if (!response.ok) { + const error = await response.json().catch(() => null); + const e = new Error(error?.error?.message || error?.reason || `HTTP ${response.status}`); + e.status = response.status; throw e; + } + return response; +} + +async function database() { + return new Promise((resolve, reject) => { + const open = indexedDB.open('ada-studio-recovery-v1', 1); + open.onupgradeneeded = () => open.result.createObjectStore('drafts'); + open.onsuccess = () => resolve(open.result); + open.onerror = () => reject(open.error); + }); +} +async function recovery(action, args) { + if (!args.account || !args.project) throw new Error('Recovery requires an account and project'); + const db = await database(); + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction('drafts', action === 'load' ? 'readonly' : 'readwrite'); + const store = tx.objectStore('drafts'), key = `${args.account}:${args.project}`; + const req = action === 'load' ? store.get(key) : action === 'save' ? store.put(args.value, key) : store.delete(key); + tx.oncomplete = () => resolve(action === 'load' ? req.result ?? null : null); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error || new Error('Recovery write aborted')); + }); + } finally { db.close(); } +} + +function stopPreview() { + previewEpoch += 1; + cancelFrameLoad?.(); cancelFrameLoad = null; + frame?.remove(); frame = null; + for (const pending of previewPending.values()) pending.reject(new Error('Предпросмотр остановлен.')); + previewPending.clear(); +} +function layoutPreview() { + if (!frame || !previewBounds) return; + const b = previewBounds; + Object.assign(frame.style, {position: 'fixed', left: `${b.x}px`, top: `${b.y}px`, width: `${Math.max(0,b.width)}px`, + height: `${Math.max(0,b.height)}px`, border: '1px solid #2a384c', borderRadius: '8px', zIndex: '10', + display: b.visible ? 'block' : 'none', background: '#10161f'}); +} +async function previewMessage(type, payload) { + if (!frame?.contentWindow) throw new Error('Сначала запустите игру.'); + const id = crypto.randomUUID(); + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { previewPending.delete(id); reject(new Error('Предпросмотр не отвечает.')); }, type === 'load' ? 120000 : 30000); + previewPending.set(id, {resolve: value => {clearTimeout(timeout);resolve(value);}, reject: e => {clearTimeout(timeout);reject(e);}}); + frame.contentWindow.postMessage({protocol:'ada-studio-preview-v1',id,type,payload}, previewOrigin); + }); +} +window.addEventListener('message', event => { + if (event.source !== frame?.contentWindow || event.origin !== previewOrigin || event.data?.protocol !== 'ada-studio-preview-v1') return; + const pending = previewPending.get(event.data.id); + if (!pending) return; + previewPending.delete(event.data.id); + event.data.error ? pending.reject(new Error(event.data.error)) : pending.resolve(event.data.value); +}); + +async function execute(method, args, notify) { + if (method === 'network.wait') { + if (!navigator.onLine) await new Promise(resolve => window.addEventListener('online', resolve, {once:true})); + await new Promise(resolve => setTimeout(resolve, 5000)); + return null; + } + if (method === 'auth.wait') { + for (let n=0;n<150;n++) { + const response=await fetch('/v1/me',{credentials:'same-origin',cache:'no-store'}); + if(response.ok)return response.json(); + await new Promise(resolve=>setTimeout(resolve,2000)); + } + throw new Error('Вход не завершён. Повторите попытку.'); + } + if (method === 'request' || method === 'stream') { + const controller = new AbortController(); controllers.add(controller); + if (method === 'stream') generations.add(controller); + try { + const response = await http(args.path, method === 'stream' ? 'POST' : args.method || 'GET', args.body, controller.signal); + if (method === 'request') return response.status === 204 ? null : await response.json(); + const reader = response.body.getReader(), decoder = new TextDecoder(); + let pending = ''; + while (true) { + const {done,value} = await reader.read(); + pending += decoder.decode(value, {stream: !done}); + if (pending.length > 2 * 1024 * 1024) throw new Error('Provider event too large'); + let end; + while ((end = pending.indexOf('\n')) >= 0) { + const line = pending.slice(0,end).trim();pending = pending.slice(end+1); + if (line.startsWith('data:') && line.slice(5).trim() !== '[DONE]') notify(JSON.parse(line.slice(5))); + } + if (done) break; + } + return null; + } finally { controllers.delete(controller); generations.delete(controller); } + } + if (method.startsWith('recovery.')) return recovery(method.split('.')[1], args); + if (method === 'digest') return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',unbase64(args)))).map(b=>b.toString(16).padStart(2,'0')).join(''); + if (method === 'media.upload') { + if (!/^data:image\/(jpeg|png);base64,/.test(args.image)) throw new Error('Invalid cover image'); + const response = await fetch('/v1/media',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'image/jpeg'},body:unbase64(args.image.split(',')[1])}); + if (!response.ok) throw new Error(`Cover upload HTTP ${response.status}`); + return response.json(); + } + if (method === 'file.open') { + const file = await new Promise(resolve => { + const input = document.createElement('input'); input.type = 'file'; input.accept = args.accept || '.zip'; + input.onchange = () => {resolve(input.files[0] ?? null);input.remove();}; + input.oncancel = () => {resolve(null);input.remove();}; + input.style.display = 'none';document.body.append(input);input.click(); + }); + if (!file) return null; + if (file.size > 65 * 1024 * 1024) throw new Error('Файл превышает 65 МБ.'); + return {path: file.name, data: base64(new Uint8Array(await file.arrayBuffer()))}; + } + if (method === 'preview.load') { + stopPreview(); + const epoch = previewEpoch; + const config = await (await http('/v1/ai/studio-config','GET',null)).json(); + if (epoch !== previewEpoch) throw new Error('Предпросмотр остановлен.'); + const url = new URL(config.previewURL); + // Cookies are host-scoped, not port-scoped: another port on the Cloud host is insufficient. + const loopback = host => ['localhost','127.0.0.1','[::1]'].includes(host); + const localHTTP = location.protocol === 'http:' && url.protocol === 'http:' && loopback(location.hostname) && loopback(url.hostname); + if ((url.protocol !== 'https:' && !localHTTP) || url.hostname === location.hostname || url.username || url.password) throw new Error('Preview requires a separate HTTPS host'); + previewOrigin = url.origin; + url.hash = new URLSearchParams({studioParent: location.origin}).toString(); + frame = document.createElement('iframe');frame.title = 'Игра Ada Studio'; + frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-pointer-lock'); + frame.allow = 'autoplay; fullscreen';frame.referrerPolicy = 'no-referrer'; + const loaded = new Promise((resolve,reject) => { + const timer = setTimeout(() => reject(new Error('Не удалось загрузить Web Player.')),30000); + cancelFrameLoad = () => {clearTimeout(timer);reject(new Error('Предпросмотр остановлен.'));}; + frame.onload = () => {clearTimeout(timer);resolve();};frame.onerror = () => {clearTimeout(timer);reject(new Error('Web Player unavailable'));}; + }); + frame.src = url.href;document.body.append(frame);layoutPreview();await loaded; + if (epoch !== previewEpoch) throw new Error('Предпросмотр остановлен.'); + cancelFrameLoad = null; + if (args.files.length > 512 || args.files.some(f => !safePath(f.path))) throw new Error('Invalid preview snapshot'); + return previewMessage('load', args); + } + if (method === 'preview.capture') return previewMessage('capture'); + if (method === 'preview.input') return previewMessage('input', args); + if (method === 'preview.logs') return previewMessage('logs'); + if (method === 'upload') { + const url = new URL(args.url); + if (url.protocol !== 'https:') throw new Error('Upload requires HTTPS'); + const response = await fetch(url, {method:'PUT', credentials:'omit', headers:{'Content-Type':args.contentType || 'application/zip'},body:unbase64(args.data)}); + if (!response.ok) throw new Error(`Upload HTTP ${response.status}`); + return null; + } + throw new Error(`Unknown browser operation: ${method}`); +} + +globalThis.adaStudio = { + call(method, json, callback) { + const emit = message => callback(JSON.stringify(message)); + Promise.resolve().then(() => execute(method, JSON.parse(json), value => emit({kind:'event',value}))) + .then(value => emit({kind:'done',value:value ?? null}), error => emit({kind:'error',status:error.status || 0,message:error.message || 'Browser operation failed'})); + }, + fire(method, json) { + const args = JSON.parse(json); + if (method === 'cancel') {for (const controller of generations) controller.abort();} + else if (method === 'preview.stop') stopPreview(); + else if (method === 'preview.bounds') {previewBounds = args;layoutPreview();} + else if (method === 'signin') window.open('/cloud','_blank','noopener'); + else if (method === 'file.download') { + const url = URL.createObjectURL(new Blob([unbase64(args.data)], {type:'application/zip'})); + const link = document.createElement('a');link.href=url;link.download=args.name;link.click();setTimeout(()=>URL.revokeObjectURL(url),30000); + } + } +}; +window.addEventListener('pagehide', () => {for (const controller of controllers) controller.abort();stopPreview();}); +if (!navigator.gpu) { + const message = document.createElement('p');message.textContent = 'Ada Studio требует настольный браузер с WebGPU. Обновите браузер и включите аппаратное ускорение.'; + message.style.cssText='position:fixed;inset:0;display:grid;place-content:center;background:#10161f;color:white;font:18px system-ui;padding:32px;z-index:1000';document.body.append(message); +} diff --git a/Editor/Web/Sources/AdaEditorWeb/StudioAgent.swift b/Editor/Web/Sources/AdaEditorWeb/StudioAgent.swift new file mode 100644 index 000000000..e007472b9 --- /dev/null +++ b/Editor/Web/Sources/AdaEditorWeb/StudioAgent.swift @@ -0,0 +1,164 @@ +import AdaStudioCore +import Foundation + +extension StudioViewModel { + func sendPrompt() { + let text = prompt.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isAgentRunning else { return } + guard !providerID.isEmpty, !modelID.isEmpty else { showsSettings = true; error = "Выберите доступного провайдера и модель."; return } + guard billingMode != "byok" || !apiKey.isEmpty else { showsSettings = true; error = "Введите API-ключ. Он используется Cloud-прокси и хранится только в этой вкладке."; return } + prompt = ""; isAgentRunning = true; agentText = "" + agentTask = Task { + defer { isAgentRunning = false; agentTask = nil } + do { + if document == nil { await create(.game2D, title: String(text.prefix(60))) } + guard let document else { return } + // Do not replay a tool whose side effect may already have happened before tab closure. + let answered = Set(document.snapshot.messages.compactMap(\.toolCallID)) + for message in document.snapshot.messages { + for call in message.toolCalls ?? [] { + if let id = call["id"].string, !answered.contains(id) { + try document.append(.init(role: "tool", content: "Previous turn interrupted. Inspect the current project before continuing; this tool is not automatically replayed.", toolCallID: id)) + } + } + } + var context = text + if let entity = selectedEntity { context += "\n[Selected scene entity]\n" + entity.text } + if let codeSelection, !codeSelection.isEmpty { context += "\n[Selected code in \(activeFile)]\n" + codeSelection } + if !attachmentPaths.isEmpty { context += "\n[Project assets]\n" + attachmentPaths.joined(separator: "\n"); attachmentPaths = [] } + try document.append(.init(role: "user", content: context)); changed() + for _ in 0..<40 { + try Task.checkCancellation() + agentText = "" + var toolCalls: [StudioValue] = [] + var complete = false + var failure: String? + let input: StudioValue = .object([ + "operationID": .string(UUID().uuidString.lowercased()), "providerID": .string(providerID), "model": .string(modelID), + "billingMode": .string(billingMode), "apiKey": billingMode == "byok" ? .string(apiKey) : .null, + "messages": try StudioValue.decode(JSONEncoder().encode(contextMessages())), "tools": .array(Self.agentTools) + ]) + try await browser.stream(path: "/v1/ai/generate", body: input.encoded()) { [weak self] event in + switch event["type"].string { + case "text": self?.agentText += event["text"].string ?? "" + case "tool": toolCalls.append(event["call"]) + case "complete": complete = true + case "error": failure = event["message"].string ?? "Ошибка провайдера." + default: break + } + } + try Task.checkCancellation() + if let failure { throw StudioError.unavailable(failure) } + guard complete else { throw StudioError.unavailable("Ответ прерван. Продолжите с последнего сохранённого шага.") } + try document.append(.init(role: "assistant", content: agentText, toolCalls: toolCalls.isEmpty ? nil : toolCalls)); agentText = ""; changed() + if toolCalls.isEmpty { await save(checkpoint: true); return } + var capturedImages: [String] = [] + for call in toolCalls { + try Task.checkCancellation() + guard let id = call["id"].string, let name = call["function"]["name"].string, + let encoded = call["function"]["arguments"].string else { throw StudioError.invalid("Некорректный вызов инструмента.") } + let result: StudioValue + do { result = try await runTool(name, arguments: StudioValue.decode(Data(encoded.utf8))) } + catch { result = .object(["error": .string(error.localizedDescription)]) } + try Task.checkCancellation() + guard self.document === document else { throw CancellationError() } + if name == "editor_play_capture", let image = result.string, image.hasPrefix("data:image/") { + try document.append(.init(role: "tool", content: "Game frame captured. The image follows in the next message.", toolCallID: id)) + capturedImages.append(image) + } else { try document.append(.init(role: "tool", content: result.text, toolCallID: id)) } + changed() + } + if !capturedImages.isEmpty { + try document.append(.init(role: "user", content: "Current game camera frames. Verify them against the requested game.", images: Array(capturedImages.suffix(4)))) + changed() + } + } + throw StudioError.unavailable("Достигнут лимит 40 шагов. Результат сохранён; можно продолжить новым сообщением.") + } catch is CancellationError { status = "Агент остановлен" } + catch { if !Task.isCancelled { self.error = error.localizedDescription } } + await save(checkpoint: true) + } + } + + func stopAgent() { agentTask?.cancel(); browser.cancelRequests() } + + private func contextMessages() -> [StudioMessage] { + let messages = document?.snapshot.messages ?? [] + var groups: [[StudioMessage]] = [] + for message in messages { + if message.role == "tool", !groups.isEmpty { groups[groups.count - 1].append(message) } + else { groups.append([message]) } + } + var result: [StudioMessage] = [], bytes = 0 + for group in groups.reversed() { + let size = (try? JSONEncoder().encode(group).count) ?? 200_000 + guard bytes + size < 145_000 else { break } + result = group + result; bytes += size + } + if result.count < messages.count { + let goal = messages.last(where: { $0.role == "user" && $0.images == nil })?.content ?? "Continue the current project" + result.insert(.init(role: "user", content: "Earlier context omitted. Inspect the current files and scene. Current goal: " + String(goal.prefix(4000))), at: 0) + } + return result + } + + func runTool(_ name: String, arguments: StudioValue) async throws -> StudioValue { + guard let document else { throw StudioError.invalid("Нет открытого проекта.") } + switch name { + case "editor_project_files": return .array(document.snapshot.files.map { .string($0.path) }) + case "editor_file_read": + guard let path = arguments["path"].string, let file = document.snapshot.file(path) else { throw StudioError.invalid("Файл не найден.") } + guard file.data.count <= 100_000 else { throw StudioError.invalid("Файл слишком велик для текстового контекста.") } + return .string(file.text) + case "editor_file_write": + guard let path = arguments["path"].string, let text = arguments["content"].string else { throw StudioError.invalid("Нужны path и content.") } + try document.write(path, data: Data(text.utf8)); changed(); return .string("Saved " + path) + case "editor_file_delete": + guard let path = arguments["path"].string else { throw StudioError.invalid("Нужен path.") } + try document.delete(path); changed(); return .string("Deleted " + path) + case "editor_scene_get": return scene?.value ?? .null + case "editor_scene_set": + guard var scene, let entity = arguments["entity"].string, let component = arguments["component"].string, let field = arguments["field"].string else { throw StudioError.invalid("Нужны entity, component и field.") } + try scene.set(entity: entity, component: component, field: field, value: arguments["value"]) + try document.write(document.snapshot.scenePath, data: scene.encoded()); changed(); return .string("Scene updated") + case "editor_scene_add": + guard var scene else { throw StudioError.invalid("Нет сцены.") } + let id = try scene.add(name: arguments["name"].string ?? "Object", components: arguments["components"].object, parent: arguments["parent"].string) + try document.write(document.snapshot.scenePath, data: scene.encoded()); changed(); return .string(id) + case "editor_build": return .object(["diagnostics": .string(validateSources())]) + case "editor_play_start": tab = .game; await play(); return .object(["running": .bool(isRunning), "error": error.map(StudioValue.string) ?? .null]) + case "editor_play_stop": stop(); return .string("Stopped") + case "editor_play_logs": return try StudioValue.decode(await browser.perform("preview.logs")) + case "editor_play_input": try await browser.input(arguments); return .string("Input sent") + case "editor_play_capture": + guard models.first(where: { $0["id"].string == modelID })?["vision"] == .bool(true) else { throw StudioError.unavailable("Выбранная модель не поддерживает изображения.") } + return .string(try await browser.capture()) + case "editor_docs_read": + guard let url = Bundle.module.url(forResource: "AdaScriptRecipes", withExtension: "md", subdirectory: "Resources") else { throw StudioError.unavailable("Документация недоступна.") } + return .string(try String(contentsOf: url, encoding: .utf8)) + default: throw StudioError.invalid("Инструмент недоступен: " + name) + } + } + + static var agentTools: [StudioValue] { + func tool(_ name: String, _ description: String, _ properties: [String: StudioValue] = [:], required: [String] = []) -> StudioValue { + .object(["type": .string("function"), "function": .object(["name": .string(name), "description": .string(description), + "parameters": .object(["type": .string("object"), "properties": .object(properties), "required": .array(required.map(StudioValue.string))])])]) + } + let string: StudioValue = .object(["type": .string("string")]) + return [ + tool("editor_project_files", "List source and asset paths in the open AdaScript project."), + tool("editor_file_read", "Read a project text file.", ["path": string], required: ["path"]), + tool("editor_file_write", "Create or replace a source, scene or text asset through document undo history.", ["path": string, "content": string], required: ["path", "content"]), + tool("editor_file_delete", "Delete a nonessential project file.", ["path": string], required: ["path"]), + tool("editor_scene_get", "Read the authored scene, including entity IDs and component payloads."), + tool("editor_scene_set", "Edit a component field. Transform position/rotation/scale use numeric arrays.", ["entity": string, "component": string, "field": string, "value": .object([:])], required: ["entity", "component", "field", "value"]), + tool("editor_scene_add", "Add an entity with component payloads.", ["name": string, "components": .object(["type": .string("object")]), "parent": string], required: ["name", "components"]), + tool("editor_build", "Validate AdaScript schemas/types and the scene before play."), + tool("editor_play_start", "Run the game in the isolated Web Player."), tool("editor_play_stop", "Stop the running game."), + tool("editor_play_logs", "Read game diagnostics."), tool("editor_play_capture", "Capture the game's current camera frame as a data URL."), + tool("editor_play_input", "Send bounded keyboard/pointer input to the game.", ["key": string, "phase": string, "durationMs": .object(["type": .string("integer")])], required: ["key", "phase"]), + tool("editor_docs_read", "Read AdaScript gameplay recipes and syntax. Read before writing gameplay code.") + ] + } +} diff --git a/Editor/Web/Sources/AdaEditorWeb/StudioBrowser.swift b/Editor/Web/Sources/AdaEditorWeb/StudioBrowser.swift new file mode 100644 index 000000000..474cef88d --- /dev/null +++ b/Editor/Web/Sources/AdaEditorWeb/StudioBrowser.swift @@ -0,0 +1,97 @@ +import AdaStudioCore +import Foundation +#if os(WASI) +import JavaScriptKit +#endif + +/// The browser API boundary. Project operations and UI stay in Swift/AdaUI. +@MainActor final class StudioBrowser: StudioCloudTransport, StudioRecoveryStorage, StudioFileAccess, StudioPreview { + private(set) var logs: [String] = [] + var editMode = false + #if os(WASI) + private var callbacks: [String: JSClosure] = [:] + #endif + + func perform(_ method: String, _ arguments: StudioValue = .null, onEvent: (@MainActor (StudioValue) -> Void)? = nil) async throws -> Data { + #if os(WASI) + guard let bridge = JSObject.global["adaStudio"].object else { throw StudioError.unavailable("Не загружен браузерный модуль Studio.") } + let id = UUID().uuidString + let json = String(decoding: try arguments.encoded(), as: UTF8.self) + return try await withCheckedThrowingContinuation { continuation in + let callback = JSClosure { [weak self] values in + let text = values.first?.string ?? "{}" + Task { @MainActor in + guard let self else { return } + do { + let result = try StudioValue.decode(Data(text.utf8)) + if result["kind"].string == "event" { onEvent?(result["value"]); return } + self.callbacks[id] = nil + if result["kind"].string == "error" { + if result["status"].number == 401 { continuation.resume(throwing: StudioError.authenticationRequired) } + else if result["status"].number == 409, arguments["path"].string?.hasPrefix("/v1/workspaces") == true { continuation.resume(throwing: StudioError.conflict) } + else { continuation.resume(throwing: StudioError.unavailable(result["message"].string ?? "Ошибка браузера.")) } + } else { continuation.resume(returning: try result["value"].encoded()) } + } catch { self.callbacks[id] = nil; continuation.resume(throwing: error) } + } + return .undefined + } + callbacks[id] = callback + _ = bridge.call?(method, json, callback) + } + #else + throw StudioError.unavailable("Откройте Ada Studio Web в браузере.") + #endif + } + + func request(path: String, method: String = "GET", body: Data? = nil) async throws -> Data { + try await perform("request", .object(["path": .string(path), "method": .string(method), "body": try body.map(StudioValue.decode) ?? .null])) + } + func stream(path: String, body: Data, onEvent: @escaping @MainActor (StudioValue) -> Void) async throws { + _ = try await perform("stream", .object(["path": .string(path), "body": try StudioValue.decode(body)]), onEvent: onEvent) + } + func cancelRequests() { fire("cancel") } + func fire(_ method: String, _ value: StudioValue = .null) { + #if os(WASI) + guard let text = try? String(decoding: value.encoded(), as: UTF8.self) else { return } + _ = JSObject.global["adaStudio"].object?.fire?(method, text) + #endif + } + func load(account: String, project: String) async throws -> Data? { + let result = try await perform("recovery.load", .object(["account": .string(account), "project": .string(project)])) + return result == Data("null".utf8) ? nil : result + } + func save(account: String, project: String, data: Data) async throws { + _ = try await perform("recovery.save", .object(["account": .string(account), "project": .string(project), "value": try StudioValue.decode(data)])) + } + func remove(account: String, project: String) async throws { + _ = try await perform("recovery.remove", .object(["account": .string(account), "project": .string(project)])) + } + func open(accept: String) async throws -> StudioFile? { + let result = try await perform("file.open", .object(["accept": .string(accept)])) + return try JSONDecoder().decode(StudioFile?.self, from: result) + } + func download(name: String, data: Data) throws { + fire("file.download", .object(["name": .string(name), "data": .string(data.base64EncodedString())])) + } + func load(_ snapshot: StudioSnapshot) async throws { + logs = [] + var preview = snapshot + preview.files = try snapshot.exportFiles() + preview.messages = [] + var payload = try StudioValue.decode(JSONEncoder().encode(preview)) + payload["editMode"] = .bool(editMode) + _ = try await perform("preview.load", payload) { [weak self] event in + if let log = event["message"].string { self?.logs.append(log) } + } + } + func stop() { fire("preview.stop") } + func capture() async throws -> String { + let result = try StudioValue.decode(await perform("preview.capture")) + guard let image = result.string else { throw StudioError.unavailable("Кадр игры недоступен.") } + return image + } + func input(_ event: StudioValue) async throws { _ = try await perform("preview.input", event) } + func previewBounds(x: Float, y: Float, width: Float, height: Float, visible: Bool) { + fire("preview.bounds", .object(["x": .double(Double(x)), "y": .double(Double(y)), "width": .double(Double(width)), "height": .double(Double(height)), "visible": .bool(visible)])) + } +} diff --git a/Editor/Web/Sources/AdaEditorWeb/StudioPublishing.swift b/Editor/Web/Sources/AdaEditorWeb/StudioPublishing.swift new file mode 100644 index 000000000..f890999d1 --- /dev/null +++ b/Editor/Web/Sources/AdaEditorWeb/StudioPublishing.swift @@ -0,0 +1,57 @@ +import AdaStudioCore +import Foundation + +extension StudioViewModel { + /// A click publishes one captured source version; the agent has no access to this operation. + func publish() async { + guard publishingEnabled else { return } + guard !isAgentRunning else { error = "Остановите агента перед публикацией."; return } + guard let workspace, let snapshot = document?.snapshot else { return } + do { + let billing = try StudioValue.decode(await browser.request(path: "/v1/billing")) + guard billing["pro"] == .bool(true) else { throw StudioError.unavailable("Для публикации нужна подписка Cloud Pro. Сохранять и скачивать проект можно без неё.") } + publicationStatus = "Подготовка версии…" + try snapshot.validate() + let diagnostics = validateSources() + guard diagnostics.isEmpty else { throw StudioError.invalid(diagnostics) } + tab = .game; browser.editMode = false; isRunning = true; try await browser.load(snapshot) + let cover = try await browser.capture() + let media = try StudioValue.decode(await browser.perform("media.upload", .object(["image": .string(cover)]))) + let pageData: StudioValue = .object(["title": .string(String(snapshot.title.prefix(100))), + "description": .string(snapshot.title + ". Создано в Ada Studio."), "cover": media["id"], "screenshots": .array([]), "tags": .array([.string("Ada Studio")])]) + let pageID: String + if let existing = workspace.pageID { + _ = try await browser.request(path: "/v1/pages/" + existing, method: "PATCH", body: pageData.encoded()); pageID = existing + } else { + let page = try StudioValue.decode(await browser.request(path: "/v1/pages", method: "POST", body: pageData.encoded())) + guard let id = page["id"].string else { throw StudioError.invalid("Не удалось создать страницу игры.") } + let linked = try StudioValue.decode(await browser.request(path: "/v1/workspaces/\(workspace.id)/publication-page", method: "POST", body: StudioValue.object(["pageID": .string(id)]).encoded())) + guard let linkedID = linked["pageID"].string else { throw StudioError.invalid("Не удалось связать страницу игры.") } + pageID = linkedID; self.workspace?.pageID = linkedID + } + var files = try snapshot.exportFiles() + files.append(.init(path: "ada-game.json", data: try StudioValue.object(["formatVersion": .int(1), "runtime": .string("adascript"), "apiVersion": .int(StudioRuntimeCapabilities.communityPackageAPI)]).encoded())) + let archive = try StudioArchive.encode(files) + let digest = try StudioValue.decode(await browser.perform("digest", .string(archive.base64EncodedString()))) + let input: StudioValue = .object(["operationId": .string("studio:" + digest.text), "bytes": .int(archive.count), "mode": .string("ugc"), "pageId": .string(pageID)]) + var upload = try StudioValue.decode(await browser.request(path: "/v1/uploads", method: "POST", body: input.encoded())) + guard let uploadID = upload["id"].string else { throw StudioError.invalid("Нет ID загрузки.") } + if upload["status"].string == "created" { + guard let url = upload["uploadURL"].string else { throw StudioError.invalid("Нет адреса загрузки.") } + _ = try await browser.perform("upload", .object(["url": .string(url), "data": .string(archive.base64EncodedString())])) + _ = try await browser.request(path: "/v1/uploads/\(uploadID)/complete", method: "POST", body: Data("{}".utf8)) + } + for _ in 0..<120 { + upload = try StudioValue.decode(await browser.request(path: "/v1/uploads/" + uploadID)) + if ["ready", "published", "submitted"].contains(upload["status"].string ?? "") { break } + guard !["failed", "deleted"].contains(upload["status"].string ?? "") else { throw StudioError.invalid(upload["error"].string ?? "Проверка проекта не пройдена.") } + publicationStatus = "Проверка версии…" + try await Task.sleep(for: .seconds(1)) + } + guard ["ready", "published", "submitted"].contains(upload["status"].string ?? "") else { throw StudioError.unavailable("Версия ещё обрабатывается. Повторите публикацию позже.") } + let release = try StudioValue.decode(await browser.request(path: "/v1/uploads/\(uploadID)/publish", method: "POST", body: Data("{}".utf8))) + publicationStatus = release["status"].string == "approved" ? "Игра опубликована" : "Версия отправлена на модерацию" + await save(checkpoint: true) + } catch { self.error = error.localizedDescription; publicationStatus = nil } + } +} diff --git a/Editor/Web/Sources/AdaEditorWeb/StudioViewModel.swift b/Editor/Web/Sources/AdaEditorWeb/StudioViewModel.swift new file mode 100644 index 000000000..5ca0bd279 --- /dev/null +++ b/Editor/Web/Sources/AdaEditorWeb/StudioViewModel.swift @@ -0,0 +1,336 @@ +import AdaScriptCompilerCore +import AdaStudioCore +import Foundation +import Observation + +enum StudioTab: String, CaseIterable { case game = "Игра", scene = "Сцена", code = "Код" } + +@MainActor @Observable final class StudioViewModel { + var accountID: String? + var accountName = "" + var projects: [StudioWorkspace] = [] + var workspace: StudioWorkspace? + var document: StudioDocument? + var tab = StudioTab.game + var prompt = "" + var activeFile = "Sources/Game.ada" + var selectedCode = "" + var status = "Загрузка…" + var error: String? + var providers: [StudioValue] = [] + var providerID = "" + var modelID = "" + var billingMode = "ada" + var apiKey = "" // Deliberately outside every Codable project/recovery model. + var isRunning = false + var agentText = "" + var isAgentRunning = false + var isLoading = false + var showsHistory = false + var showsSettings = false + var checkpoints: [StudioCheckpoint] = [] + var releases: [StudioValue] = [] + var publicationStatus: String? + var publishingEnabled = false + var attachmentPaths: [String] = [] + var codeSelection: String? + let browser: StudioBrowser + @ObservationIgnored private var saveTask: Task? + @ObservationIgnored var agentTask: Task? + @ObservationIgnored private var savedGeneration = -1 + @ObservationIgnored private var saveInFlight = false + @ObservationIgnored private var saveWaiters: [CheckedContinuation] = [] + @ObservationIgnored private var checkpointPending = false + @ObservationIgnored private var recoveryTask: Task? + @ObservationIgnored private var previewRequest = UUID() + @ObservationIgnored private var scenePreviewTask: Task? + @ObservationIgnored private var retrySaveTask: Task? + + init(browser: StudioBrowser) { self.browser = browser } + var models: [StudioValue] { providers.first { $0["id"].string == providerID }?["models"].array ?? [] } + var visibleMessages: [StudioMessage] { document?.snapshot.messages.filter { $0.role != "tool" && !$0.content.isEmpty } ?? [] } + var scene: StudioScene? { + guard let document, let file = document.snapshot.file(document.snapshot.scenePath) else { return nil } + return try? StudioScene(data: file.data) + } + var selectedEntity: StudioValue? { scene?.entities.first { $0["id"].string == document?.selection } } + + func start() async { + isLoading = true + defer { isLoading = false } + do { + let me = try StudioValue.decode(await browser.request(path: "/v1/me")) + guard let id = me["id"].string else { throw StudioError.unavailable("Войдите в Ada Cloud.") } + accountID = id; accountName = me["name"].string ?? "Ada Creator" + let config = try StudioValue.decode(await browser.request(path: "/v1/ai/studio-config")) + guard config["enabled"] == .bool(true) else { throw StudioError.unavailable("Ada Studio Web пока недоступен в этом окружении.") } + publishingEnabled = config["publishingEnabled"] != .bool(false) + providers = try StudioValue.decode(await browser.request(path: "/v1/ai/providers")).array + providerID = providers.first?["id"].string ?? "" + modelID = models.first?["id"].string ?? "" + try await refreshProjects() + status = "Готово" + } catch StudioError.authenticationRequired { accountID = nil; error = nil; status = "Войдите в Ada Cloud" } + catch { self.error = error.localizedDescription; status = "Нет соединения" } + } + func refreshProjects() async throws { projects = try JSONDecoder().decode([StudioWorkspace].self, from: await browser.request(path: "/v1/workspaces")) } + func signIn() { + browser.fire("signin") + status = "Войдите в открывшейся вкладке Ada Cloud" + Task { + do { _ = try await browser.perform("auth.wait"); await start() } + catch { self.error = error.localizedDescription } + } + } + func home() { + guard !isLoading else { return } + isLoading = true + stopAgent(); stop() + Task { + defer { isLoading = false } + await agentTask?.value + await save() + saveTask?.cancel(); retrySaveTask?.cancel() + document = nil; workspace = nil + try? await refreshProjects() + } + } + + func create(_ template: StudioTemplate, title: String? = nil) async { + do { try await create(snapshot: template.makeProject(title: title)) } catch { self.error = error.localizedDescription } + } + private func portable(_ snapshot: StudioSnapshot) throws -> StudioSnapshot { + var snapshot = snapshot; snapshot.files = try snapshot.exportFiles(); return snapshot + } + private func create(snapshot: StudioSnapshot) async throws { + let snapshot = try portable(snapshot) + let input = StudioValue.object(["operationID": .string(UUID().uuidString.lowercased()), "snapshot": try StudioValue.decode(JSONEncoder().encode(snapshot))]) + let record = try JSONDecoder().decode(StudioWorkspace.self, from: await browser.request(path: "/v1/workspaces", method: "POST", body: input.encoded())) + workspace = record; document = try StudioDocument(snapshot: snapshot); savedGeneration = 0; status = "Сохранено"; tab = .game + isRunning = false; browser.stop() + try await refreshProjects() + } + func open(_ project: StudioWorkspace) async { + guard !isLoading else { return } + stopAgent(); stop(); isLoading = true + defer { isLoading = false } + do { + await agentTask?.value + await save() + let record = try JSONDecoder().decode(StudioWorkspace.self, from: await browser.request(path: "/v1/workspaces/" + project.id)) + guard let snapshot = record.snapshot else { throw StudioError.invalid("Проект не содержит исходников.") } + workspace = record; document = try StudioDocument(snapshot: snapshot); savedGeneration = 0; status = "Сохранено"; tab = .game + if let accountID, let data = try await browser.load(account: accountID, project: record.id) { + let recovery = try JSONDecoder().decode(StudioRecovery.self, from: data) + if recovery.snapshot != snapshot { + if recovery.revision == record.revision { + try document?.replace(recovery.snapshot); changed() + } else { + var copy = recovery.snapshot; copy.title += " — восстановлено" + try await create(snapshot: copy) + error = "Найдены несохранённые изменения. Они восстановлены отдельным проектом." + } + } + } + } catch { self.error = error.localizedDescription; status = "Нет соединения" } + } + + func changed(checkpoint: Bool = false) { + checkpointPending = checkpointPending || checkpoint + guard let document, workspace != nil else { return } + guard savedGeneration != document.generation || checkpointPending else { status = "Сохранено"; return } + status = "Сохраняется…" + // Serialize recovery writes so a slower old IndexedDB transaction cannot replace a newer draft. + let previous = recoveryTask + if let accountID, let workspace { + let snapshot = document.snapshot + recoveryTask = Task { [browser] in + await previous?.value + do { try await browser.save(account: accountID, project: workspace.id, data: JSONEncoder().encode(StudioRecovery(revision: workspace.revision, snapshot: snapshot))) } + catch { self.error = "Не удалось сохранить локальное восстановление: \(error.localizedDescription)" } + } + } + saveTask?.cancel() + saveTask = Task { + do { try await Task.sleep(for: .seconds(1)); try Task.checkCancellation(); await save() } catch { } + } + } + func save(checkpoint: Bool = false) async { + checkpointPending = checkpointPending || checkpoint + if saveInFlight { + await withCheckedContinuation { saveWaiters.append($0) } + await save() + return + } + guard let workspace, let document else { return } + guard savedGeneration != document.generation || checkpointPending else { return } + saveInFlight = true + defer { + saveInFlight = false + let waiters = saveWaiters; saveWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } + let generation = document.generation, snapshot = document.snapshot, checkpoint = checkpointPending + checkpointPending = false + do { + let input: StudioValue = .object(["operationID": .string(UUID().uuidString.lowercased()), "expectedRevision": .int(workspace.revision), + "checkpoint": .bool(checkpoint), "snapshot": try StudioValue.decode(JSONEncoder().encode(portable(snapshot)))]) + let record = try JSONDecoder().decode(StudioWorkspace.self, from: await browser.request(path: "/v1/workspaces/" + workspace.id, method: "PUT", body: input.encoded())) + guard self.workspace?.id == workspace.id else { return } + self.workspace = record; savedGeneration = generation + if document.generation == generation && !checkpointPending { + status = "Сохранено" + await recoveryTask?.value + if let accountID, document.generation == generation { try await browser.remove(account: accountID, project: record.id) } + } else { changed() } + } catch StudioError.authenticationRequired { + guard self.workspace?.id == workspace.id, self.document === document else { return } + stopAgent(); accountID = nil; status = "Войдите в Ada Cloud"; self.error = nil + } catch StudioError.conflict { + guard self.workspace?.id == workspace.id, self.document === document else { return } + stopAgent() + do { + var copy = document.snapshot; copy.title += " — копия" + try await create(snapshot: copy) + error = "Проект изменён в другой вкладке. Ваши изменения сохранены отдельным проектом." + } catch { self.error = error.localizedDescription; status = "Нет соединения" } + } catch { + guard self.workspace?.id == workspace.id, self.document === document else { return } + checkpointPending = checkpointPending || checkpoint; self.error = error.localizedDescription; status = "Нет соединения" + retrySaveTask?.cancel() + retrySaveTask = Task { + do { + _ = try await browser.perform("network.wait") + try Task.checkCancellation() + guard self.workspace?.id == workspace.id else { return } + await save() + } catch { } + } + } + } + + func importProject() async { + do { + guard let archive = try await browser.open(accept: ".zip") else { return } + let files = try StudioArchive.decode(archive.data) + let metadata = try files.first(where: { $0.path == ".ada/project.json" }).map { try StudioValue.decode($0.data) } + let snapshot = StudioSnapshot(title: metadata?["project"]["displayName"].string ?? "Импортированная игра", files: files) + try await create(snapshot: snapshot.importedCopy()) + } catch { self.error = error.localizedDescription } + } + func download() { + do { + guard let snapshot = document?.snapshot else { return } + try browser.download(name: "AdaStudio-Project.zip", data: StudioArchive.encode(snapshot.exportFiles())) + } catch { self.error = error.localizedDescription } + } + func attachAsset() async { + do { + if document == nil { try await create(snapshot: StudioTemplate.game2D.makeProject()) } + guard let file = try await browser.open(accept: ".png,.jpg,.jpeg,.wav,.ogg,.glb") else { return } + let name = file.path.replacingOccurrences(of: " ", with: "_") + let path = "Assets/Imports/" + UUID().uuidString.lowercased() + "_" + name + try document?.write(path, data: file.data); attachmentPaths.append(path); changed() + } catch { self.error = error.localizedDescription } + } + func writeCode(_ text: String) { + do { try document?.write(activeFile, data: Data(text.utf8)); changed() } catch { self.error = error.localizedDescription } + } + func undo() { document?.undo(); changed(); refreshScenePreview() } + func redo() { document?.redo(); changed(); refreshScenePreview() } + func setSceneField(component: String, field: String, text: String) { + do { + guard var scene, let id = document?.selection, let document else { return } + let value = (try? StudioValue.decode(Data(text.utf8))) ?? .string(text) + try scene.set(entity: id, component: component, field: field, value: value) + try document.write(document.snapshot.scenePath, data: scene.encoded()); changed() + refreshScenePreview() + } catch { self.error = error.localizedDescription } + } + func addEntity() { + do { + guard var scene, let document else { return } + let id = try scene.add(name: "Object", components: ["AdaTransform.Transform": StudioTemplate.transform()]) + try document.write(document.snapshot.scenePath, data: scene.encoded()); document.select(id); changed() + refreshScenePreview() + } catch { self.error = error.localizedDescription } + } + func removeSelectedEntity() { + do { + guard var scene, let document, let selection = document.selection else { return } + scene.remove(selection) + try document.write(document.snapshot.scenePath, data: scene.encoded()); document.select(nil); changed(); refreshScenePreview() + } catch { self.error = error.localizedDescription } + } + func history() async { + do { + guard let workspace else { return } + checkpoints = try JSONDecoder().decode([StudioCheckpoint].self, from: await browser.request(path: "/v1/workspaces/\(workspace.id)/history")) + if publishingEnabled, let pageID = workspace.pageID { + releases = try StudioValue.decode(await browser.request(path: "/v1/game-releases?pageId=" + pageID)).array + } else { releases = [] } + showsHistory = true; isRunning = false; browser.stop() + } catch { self.error = error.localizedDescription } + } + func restore(_ checkpoint: StudioCheckpoint) async { + do { + guard let workspace else { return } + let input: StudioValue = .object(["operationID": .string(UUID().uuidString.lowercased()), "revision": .int(checkpoint.revision), "expectedRevision": .int(workspace.revision)]) + _ = try await browser.request(path: "/v1/workspaces/\(workspace.id)/restore", method: "POST", body: input.encoded()) + await recoveryTask?.value + if let accountID { try await browser.remove(account: accountID, project: workspace.id) } + showsHistory = false; await open(workspace) + } catch { self.error = error.localizedDescription } + } + func activateRelease(_ release: StudioValue) async { + guard publishingEnabled else { return } + do { + guard let id = release["id"].string else { return } + _ = try await browser.request(path: "/v1/game-releases/\(id)/activate", method: "POST", body: Data("{}".utf8)) + publicationStatus = "Опубликованная версия восстановлена" + } catch { self.error = error.localizedDescription } + } + func deleteProject(_ project: StudioWorkspace) async { + do { + _ = try await browser.request(path: "/v1/workspaces/" + project.id, method: "DELETE") + if let accountID { try await browser.remove(account: accountID, project: project.id) } + try await refreshProjects() + } catch { self.error = error.localizedDescription } + } + func play() async { + let request = UUID(); previewRequest = request + do { + guard let snapshot = document?.snapshot else { return } + let diagnostics = validateSources() + guard diagnostics.isEmpty else { throw StudioError.invalid(diagnostics) } + isRunning = true + browser.editMode = tab == .scene + try await browser.load(snapshot) + } catch { if previewRequest == request { self.error = error.localizedDescription; isRunning = false } } + } + func stop() { scenePreviewTask?.cancel(); previewRequest = UUID(); browser.stop(); isRunning = false } + private func refreshScenePreview() { + guard tab == .scene else { return } + scenePreviewTask?.cancel() + scenePreviewTask = Task { + do { try await Task.sleep(for: .milliseconds(500)); try Task.checkCancellation(); await play() } catch { } + } + } + func validateSources() -> String { + guard let snapshot = document?.snapshot else { return "Нет проекта." } + do { + let sources = snapshot.sources.map { AdaScriptCompilerSource(path: $0.path, source: $0.text) } + let schemas = try AdaScriptSchemaParser.parse(sources: sources) + var environment = AdaScriptTypeEnvironment.standard; environment.register(schemas: schemas) + try AdaScriptAnalyzer.analyze(sources: sources, environment: environment).requireValidTypes(mode: .strict) + _ = try StudioScene(data: requireScene(snapshot)) + return "" + } catch { return error.localizedDescription } + } + private func requireScene(_ snapshot: StudioSnapshot) throws -> Data { + guard let data = snapshot.file(snapshot.scenePath)?.data else { throw StudioError.invalid("Нет сцены.") } + return data + } +} + +private struct StudioRecovery: Codable { let revision: Int; let snapshot: StudioSnapshot } diff --git a/Editor/WebUI/.gitignore b/Editor/WebUI/.gitignore new file mode 100644 index 000000000..c19e9e50f --- /dev/null +++ b/Editor/WebUI/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.sites-runtime/ diff --git a/Editor/WebUI/.npmrc b/Editor/WebUI/.npmrc new file mode 100644 index 000000000..97b895e2f --- /dev/null +++ b/Editor/WebUI/.npmrc @@ -0,0 +1 @@ +ignore-scripts=true diff --git a/Editor/WebUI/index.html b/Editor/WebUI/index.html new file mode 100644 index 000000000..0b76e7966 --- /dev/null +++ b/Editor/WebUI/index.html @@ -0,0 +1,41 @@ + + + + + + + Ada Studio + + + + + +
+
+ + Ada Studio +
+
+ + + diff --git a/Editor/WebUI/package.json b/Editor/WebUI/package.json new file mode 100644 index 000000000..0fb54a67e --- /dev/null +++ b/Editor/WebUI/package.json @@ -0,0 +1,27 @@ +{ + "name": "ada-studio-web", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "pnpm@11.25.0", + "scripts": { + "build": "node scripts/build.mjs", + "test": "node --test test/*.test.mjs" + }, + "dependencies": { + "@codemirror/commands": "^6.11.1", + "@codemirror/language": "^6.13.1", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.14", + "@lezer/highlight": "^1.2.5", + "fflate": "0.8.2", + "react": "18.3.1", + "react-dom": "18.3.1", + "yaml": "2.8.1" + }, + "devDependencies": { + "esbuild": "0.25.5", + "prettier": "3.6.2" + } +} diff --git a/Editor/WebUI/pnpm-lock.yaml b/Editor/WebUI/pnpm-lock.yaml new file mode 100644 index 000000000..31114fbb8 --- /dev/null +++ b/Editor/WebUI/pnpm-lock.yaml @@ -0,0 +1,460 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@codemirror/commands': + specifier: ^6.11.1 + version: 6.11.1 + '@codemirror/language': + specifier: ^6.13.1 + version: 6.13.1 + '@codemirror/search': + specifier: ^6.7.2 + version: 6.7.2 + '@codemirror/state': + specifier: ^6.7.6 + version: 6.7.6 + '@codemirror/view': + specifier: ^6.43.14 + version: 6.43.14 + '@lezer/highlight': + specifier: ^1.2.5 + version: 1.2.5 + fflate: + specifier: 0.8.2 + version: 0.8.2 + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + yaml: + specifier: 2.8.1 + version: 2.8.1 + devDependencies: + esbuild: + specifier: 0.25.5 + version: 0.25.5 + prettier: + specifier: 3.6.2 + version: 3.6.2 + +packages: + + '@codemirror/commands@6.11.1': + resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==} + + '@codemirror/language@6.13.1': + resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==} + + '@codemirror/search@6.7.2': + resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==} + + '@codemirror/state@6.7.6': + resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==} + + '@codemirror/streamparser@6.0.0': + resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==} + + '@codemirror/view@6.43.14': + resolution: {integrity: sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==} + + '@esbuild/aix-ppc64@0.25.5': + resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.5': + resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.5': + resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.5': + resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.5': + resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.5': + resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.5': + resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.5': + resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.5': + resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.5': + resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.5': + resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.5': + resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.5': + resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.5': + resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.5': + resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.5': + resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.5': + resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.5': + resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.5': + resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.5': + resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.5': + resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.25.5': + resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.5': + resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.5': + resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.5': + resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@lezer/common@1.5.3': + resolution: {integrity: sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==} + + '@lezer/highlight@1.2.5': + resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==} + + '@lezer/lr@1.4.11': + resolution: {integrity: sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==} + + '@marijn/find-cluster-break@1.0.4': + resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} + + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + + esbuild@0.25.5: + resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} + engines: {node: '>=18'} + hasBin: true + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + style-mod@4.1.4: + resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==} + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + yaml@2.8.1: + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@codemirror/commands@6.11.1': + dependencies: + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 + '@lezer/common': 1.5.3 + + '@codemirror/language@6.13.1': + dependencies: + '@codemirror/state': 6.7.6 + '@codemirror/streamparser': 6.0.0 + '@codemirror/view': 6.43.14 + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 + style-mod: 4.1.4 + + '@codemirror/search@6.7.2': + dependencies: + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 + crelt: 1.0.7 + + '@codemirror/state@6.7.6': + dependencies: + '@marijn/find-cluster-break': 1.0.4 + + '@codemirror/streamparser@6.0.0': {} + + '@codemirror/view@6.43.14': + dependencies: + '@codemirror/state': 6.7.6 + crelt: 1.0.7 + style-mod: 4.1.4 + w3c-keyname: 2.2.8 + + '@esbuild/aix-ppc64@0.25.5': + optional: true + + '@esbuild/android-arm64@0.25.5': + optional: true + + '@esbuild/android-arm@0.25.5': + optional: true + + '@esbuild/android-x64@0.25.5': + optional: true + + '@esbuild/darwin-arm64@0.25.5': + optional: true + + '@esbuild/darwin-x64@0.25.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.5': + optional: true + + '@esbuild/freebsd-x64@0.25.5': + optional: true + + '@esbuild/linux-arm64@0.25.5': + optional: true + + '@esbuild/linux-arm@0.25.5': + optional: true + + '@esbuild/linux-ia32@0.25.5': + optional: true + + '@esbuild/linux-loong64@0.25.5': + optional: true + + '@esbuild/linux-mips64el@0.25.5': + optional: true + + '@esbuild/linux-ppc64@0.25.5': + optional: true + + '@esbuild/linux-riscv64@0.25.5': + optional: true + + '@esbuild/linux-s390x@0.25.5': + optional: true + + '@esbuild/linux-x64@0.25.5': + optional: true + + '@esbuild/netbsd-arm64@0.25.5': + optional: true + + '@esbuild/netbsd-x64@0.25.5': + optional: true + + '@esbuild/openbsd-arm64@0.25.5': + optional: true + + '@esbuild/openbsd-x64@0.25.5': + optional: true + + '@esbuild/sunos-x64@0.25.5': + optional: true + + '@esbuild/win32-arm64@0.25.5': + optional: true + + '@esbuild/win32-ia32@0.25.5': + optional: true + + '@esbuild/win32-x64@0.25.5': + optional: true + + '@lezer/common@1.5.3': {} + + '@lezer/highlight@1.2.5': + dependencies: + '@lezer/common': 1.5.3 + + '@lezer/lr@1.4.11': + dependencies: + '@lezer/common': 1.5.3 + + '@marijn/find-cluster-break@1.0.4': {} + + crelt@1.0.7: {} + + esbuild@0.25.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.5 + '@esbuild/android-arm': 0.25.5 + '@esbuild/android-arm64': 0.25.5 + '@esbuild/android-x64': 0.25.5 + '@esbuild/darwin-arm64': 0.25.5 + '@esbuild/darwin-x64': 0.25.5 + '@esbuild/freebsd-arm64': 0.25.5 + '@esbuild/freebsd-x64': 0.25.5 + '@esbuild/linux-arm': 0.25.5 + '@esbuild/linux-arm64': 0.25.5 + '@esbuild/linux-ia32': 0.25.5 + '@esbuild/linux-loong64': 0.25.5 + '@esbuild/linux-mips64el': 0.25.5 + '@esbuild/linux-ppc64': 0.25.5 + '@esbuild/linux-riscv64': 0.25.5 + '@esbuild/linux-s390x': 0.25.5 + '@esbuild/linux-x64': 0.25.5 + '@esbuild/netbsd-arm64': 0.25.5 + '@esbuild/netbsd-x64': 0.25.5 + '@esbuild/openbsd-arm64': 0.25.5 + '@esbuild/openbsd-x64': 0.25.5 + '@esbuild/sunos-x64': 0.25.5 + '@esbuild/win32-arm64': 0.25.5 + '@esbuild/win32-ia32': 0.25.5 + '@esbuild/win32-x64': 0.25.5 + + fflate@0.8.2: {} + + js-tokens@4.0.0: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + prettier@3.6.2: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + style-mod@4.1.4: {} + + w3c-keyname@2.2.8: {} + + yaml@2.8.1: {} diff --git a/Editor/WebUI/pnpm-workspace.yaml b/Editor/WebUI/pnpm-workspace.yaml new file mode 100644 index 000000000..5ed0b5af0 --- /dev/null +++ b/Editor/WebUI/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/Editor/WebUI/scripts/build.mjs b/Editor/WebUI/scripts/build.mjs new file mode 100644 index 000000000..df6e4d455 --- /dev/null +++ b/Editor/WebUI/scripts/build.mjs @@ -0,0 +1,22 @@ +import { build } from "esbuild"; +import { mkdir, copyFile } from "node:fs/promises"; +await mkdir("dist", { recursive: true }); +await build({ + entryPoints: ["src/app.jsx"], + bundle: true, + minify: true, + format: "esm", + target: "es2022", + outdir: "dist", + entryNames: "studio", + legalComments: "eof", +}); +await copyFile("index.html", "dist/index.html"); +// Copy the canonical brand assets unchanged; do not redraw the AdaEngine mark. +await copyFile("../../Assets/logo_dark.svg", "dist/ada-logo.svg"); +await copyFile("../../Assets/logo_light.svg", "dist/ada-logo-light.svg"); +await copyFile( + "../Web/Sources/AdaEditorWeb/Resources/AdaScriptRecipes.md", + "dist/recipes.md", +); +console.log("Ada Studio Web → dist/ (no editor WASM)"); diff --git a/Editor/WebUI/src/adascript.js b/Editor/WebUI/src/adascript.js new file mode 100644 index 000000000..6dd9580ca --- /dev/null +++ b/Editor/WebUI/src/adascript.js @@ -0,0 +1,70 @@ +import { StreamLanguage } from "@codemirror/language"; + +// AdaScript's Gravity grammar plus the engine's public annotation syntax. +const keywords = new Set( + "as async await break case class const continue else enum extern for from func if import in is not private public return self static struct super switch true false null undefined var while".split( + " ", + ), +); +export const adaScriptParser = { + name: "AdaScript", + startState: () => ({ block: false, quote: null, depth: 0 }), + token(stream, state) { + if (state.block) { + while (!stream.eol()) { + if (stream.match("*/")) { + state.block = false; + break; + } + stream.next(); + } + return "comment"; + } + if (stream.eatSpace()) return null; + if (stream.match("//")) { + stream.skipToEnd(); + return "comment"; + } + if (stream.match("/*")) { + state.block = true; + return "comment"; + } + if (stream.match(/@[a-zA-Z_][\w]*/)) return "meta"; + const char = stream.next(); + if (char === '"' || char === "'") { + let escaped = false; + while (!stream.eol()) { + const next = stream.next(); + if (next === char && !escaped) break; + escaped = !escaped && next === "\\"; + } + return "string"; + } + if (/\d/.test(char)) { + stream.eatWhile(/[\w.]/); + return "number"; + } + if (/[a-zA-Z_]/.test(char)) { + stream.eatWhile(/[\w]/); + const word = stream.current(); + if (keywords.has(word)) + return ["true", "false", "null", "undefined"].includes(word) + ? "atom" + : "keyword"; + if (/^[A-Z]/.test(word)) return "type"; + if (stream.match(/^\s*\(/, false)) return "def"; + return "variableName"; + } + if (char === "{") state.depth++; + if (char === "}") state.depth = Math.max(0, state.depth - 1); + if (/[+\-*/=<>!&|?:%]/.test(char)) return "operator"; + return null; + }, + indent: (state, textAfter, context) => + Math.max(0, state.depth - (/^\s*}/.test(textAfter) ? 1 : 0)) * context.unit, + languageData: { + commentTokens: { line: "//", block: { open: "/*", close: "*/" } }, + closeBrackets: { brackets: ["(", "[", "{", '"', "'"] }, + }, +}; +export const adaScript = StreamLanguage.define(adaScriptParser); diff --git a/Editor/WebUI/src/agent.js b/Editor/WebUI/src/agent.js new file mode 100644 index 000000000..f9915610f --- /dev/null +++ b/Editor/WebUI/src/agent.js @@ -0,0 +1,286 @@ +import { stream, decode, bytes } from "./platform.js"; +import { file, template, validate, scene } from "./documents.js"; +export async function runAgent(studio, text) { + if (studio.running || !text.trim()) return; + if (!studio.providerID || !studio.model?.tools) { + studio.settings = true; + studio.emit(); + throw new Error("Выберите модель с поддержкой инструментов"); + } + if (studio.billingMode === "byok" && !studio.apiKey.trim()) { + studio.settings = true; + studio.emit(); + throw new Error("Введите API-ключ провайдера"); + } + if (!studio.document) + await studio.createSnapshot(template(3, text.slice(0, 60))); + const document = studio.document, + controller = new AbortController(); + studio.agent = controller; + studio.running = true; + studio.prompt = ""; + studio.activity = []; + try { + const answered = new Set( + studio.snapshot.messages.map((m) => m.toolCallID).filter(Boolean), + ); + for (const m of [...studio.snapshot.messages]) + for (const call of m.toolCalls ?? []) + if (!answered.has(call.id)) + studio.append({ + role: "tool", + toolCallID: call.id, + content: + "Interrupted previous tool. Inspect the current project; do not replay without checking.", + }); + let content = text; + if (studio.entity) + content += "\n[Selected entity]\n" + JSON.stringify(studio.entity); + if (studio.latestCodeSelection) + content += + "\n[Selected code]\n" + studio.latestCodeSelection.slice(0, 8000); + studio.append({ role: "user", content }); + studio.emit(); + for (let step = 0; step < 40; step++) { + controller.signal.throwIfAborted(); + studio.streamText = ""; + const calls = []; + let complete = false, + failure; + const groups = []; + for (const m of studio.snapshot.messages) { + if (m.role === "tool" && groups.length) groups.at(-1).push(m); + else groups.push([m]); + } + let messages = [], + budget = 0; + for (const group of groups.reverse()) { + const size = new TextEncoder().encode(JSON.stringify(group)).length; + if (size + budget > 145000) break; + messages = group.concat(messages); + budget += size; + } + if (messages.length < studio.snapshot.messages.length) + messages.unshift({ + role: "user", + content: + "Inspect the current project before making changes. Goal: " + + text.slice(0, 4000), + }); + await stream( + { + operationID: crypto.randomUUID(), + providerID: studio.providerID, + model: studio.modelID, + billingMode: studio.billingMode, + apiKey: studio.billingMode === "byok" ? studio.apiKey : undefined, + messages, + tools, + }, + controller.signal, + (event) => { + if (event.type === "text") studio.streamText += event.text ?? ""; + if (event.type === "tool") calls.push(event.call); + if (event.type === "complete") complete = true; + if (event.type === "error") failure = event.message; + studio.emit(); + }, + ); + controller.signal.throwIfAborted(); + if (failure || !complete) + throw new Error( + failure || "Ответ прерван. Можно продолжить с сохранённого шага.", + ); + studio.append({ + role: "assistant", + content: studio.streamText, + toolCalls: calls.length ? calls : undefined, + }); + studio.streamText = ""; + if (!calls.length) break; + const images = []; + for (const call of calls) { + controller.signal.throwIfAborted(); + const entry = { + id: call.id, + name: call.function.name, + status: "running", + }; + studio.activity.push(entry); + studio.emit(); + let result; + try { + result = await studio.runTool( + entry.name, + JSON.parse(call.function.arguments), + ); + entry.status = "done"; + } catch (e) { + result = { error: e.message }; + entry.status = "error"; + } + controller.signal.throwIfAborted(); + if (studio.document !== document) + throw new Error("Открыт другой проект"); + if ( + entry.name === "editor_play_capture" && + typeof result === "string" && + result.startsWith("data:image/") + ) { + studio.append({ + role: "tool", + toolCallID: call.id, + content: "Game frame captured.", + }); + images.push(result); + } else + studio.append({ + role: "tool", + toolCallID: call.id, + content: + typeof result === "string" ? result : JSON.stringify(result), + }); + } + if (images.length) + studio.append({ + role: "user", + content: "Current game frames", + images: images.slice(-4), + }); + } + } catch (e) { + if (!controller.signal.aborted) studio.fail(e); + } finally { + studio.running = false; + studio.agent = null; + studio.streamText = ""; + studio.emit(); + await studio.save(true); + } +} +export async function runTool(studio, name, args) { + switch (name) { + case "editor_project_files": + return studio.snapshot.files.map((f) => f.path); + case "editor_file_read": { + const f = file(studio.snapshot, args.path); + if (!f) throw new Error("Файл не найден"); + if (bytes(f.data).length > 100000) + throw new Error("Файл слишком велик для текстового контекста"); + return decode(f.data); + } + case "editor_file_write": + studio.document.write(args.path, args.content); + studio.changed(); + return "Saved " + args.path; + case "editor_file_delete": + studio.document.edit((s) => { + s.files = s.files.filter((f) => f.path !== args.path); + }); + studio.changed(); + return "Deleted"; + case "editor_scene_get": + return studio.scene; + case "editor_scene_set": + studio.editScene((s) => { + const e = s.entities.find((e) => e.id === args.entity); + if (!e) throw new Error("Объект не найден"); + e.components[args.component] ??= {}; + e.components[args.component][args.field] = args.value; + }); + return "Scene updated"; + case "editor_scene_add": { + const id = crypto.randomUUID(); + studio.editScene((s) => + s.entities.push({ + id, + name: args.name, + enabled: true, + parent: args.parent ?? null, + components: args.components, + }), + ); + return id; + } + case "editor_build": + try { + validate(studio.snapshot); + scene(studio.snapshot); + await studio.play(true); + return { diagnostics: "", runtime: "AdaPlayer API 4" }; + } catch (e) { + return { diagnostics: e.message }; + } + case "editor_play_start": + await studio.play(); + return { running: studio.preview.ready }; + case "editor_play_stop": + await studio.stop(); + return "Stopped"; + case "editor_play_logs": + return studio.preview.message("logs"); + case "editor_play_input": + return studio.preview.message("input", args); + case "editor_play_capture": + if (!studio.model?.vision) + throw new Error("Модель не поддерживает изображения"); + return studio.preview.message("capture"); + case "editor_docs_read": { + const response = await fetch("./recipes.md"); + if (!response.ok) throw new Error("Документация недоступна"); + return response.text(); + } + default: + throw new Error("Недоступный инструмент: " + name); + } +} +const string = { type: "string" }; +const tool = (name, description, properties = {}, required = []) => ({ + type: "function", + function: { + name, + description, + parameters: { type: "object", properties, required }, + }, +}); +export const tools = [ + tool("editor_project_files", "List project file paths."), + tool("editor_file_read", "Read project text.", { path: string }, ["path"]), + tool( + "editor_file_write", + "Write a project text file with document undo.", + { path: string, content: string }, + ["path", "content"], + ), + tool("editor_file_delete", "Delete a nonessential file.", { path: string }, [ + "path", + ]), + tool("editor_scene_get", "Read scene entities and components."), + tool( + "editor_scene_set", + "Set component field. Transform arrays use position XYZ, scale XYZ, rotation quaternion XYZW.", + { entity: string, component: string, field: string, value: {} }, + ["entity", "component", "field", "value"], + ), + tool( + "editor_scene_add", + "Create entity.", + { name: string, components: { type: "object" }, parent: string }, + ["name", "components"], + ), + tool( + "editor_build", + "Compile and validate the current scene with the isolated AdaPlayer.", + ), + tool("editor_play_start", "Run the game."), + tool("editor_play_stop", "Stop game."), + tool("editor_play_logs", "Read player logs."), + tool("editor_play_capture", "Capture game camera."), + tool( + "editor_play_input", + "Send bounded keyboard input.", + { key: string, phase: string, durationMs: { type: "integer" } }, + ["key", "phase"], + ), + tool("editor_docs_read", "Read AdaScript syntax before editing gameplay."), +]; diff --git a/Editor/WebUI/src/app.jsx b/Editor/WebUI/src/app.jsx new file mode 100644 index 000000000..a3a45f0a7 --- /dev/null +++ b/Editor/WebUI/src/app.jsx @@ -0,0 +1,200 @@ +import React, { useEffect, useSyncExternalStore } from "react"; +import { createRoot } from "react-dom/client"; +import { studio } from "./ui/state.js"; +import { Icon, Mark, Button, act } from "./ui/controls.jsx"; +import { Home } from "./ui/home.jsx"; +import { Workspace } from "./ui/workspace.jsx"; +import { Settings, History } from "./ui/dialogs.jsx"; +import "./style.css"; +function App() { + useSyncExternalStore(studio.subscribe, studio.getVersion); + const s = studio; + useEffect(() => { + studio.start(); + }, []); + return ( +
+ +
+
+
+ + + {s.document ? ( + s.action(() => s.rename(e.target.value))} + onKeyDown={(e) => { + if (e.key === "Enter") e.target.blur(); + }} + /> + ) : ( + Ваше пространство + )} + {s.document && ( + + + {s.status === "saved" + ? "Сохранено" + : s.status === "offline" + ? "Нет соединения" + : "Сохраняется"} + + )} +
+ {s.document && ( + + )} +
+ {s.document && ( + <> + +
+
+ {s.document ? : } +
+ {s.settings && } + {s.history && } + {s.error && ( +
+ {s.error} +
+ )} +
+ ); +} +createRoot(document.getElementById("root")).render(); diff --git a/Editor/WebUI/src/documents.js b/Editor/WebUI/src/documents.js new file mode 100644 index 000000000..55bcf53ef --- /dev/null +++ b/Editor/WebUI/src/documents.js @@ -0,0 +1,417 @@ +import { parse } from "yaml"; +import { unzipSync, zipSync } from "fflate"; +import { bytes, decode, encode, base64 } from "./platform.js"; +export const clone = (value) => structuredClone(value); +export const textFile = (path, text) => ({ path, data: encode(text) }); +export const file = (snapshot, path) => + snapshot.files.find((f) => f.path === path); +export const metadata = (snapshot) => + JSON.parse(decode(file(snapshot, ".ada/project.json").data)); +export const scenePath = (snapshot) => metadata(snapshot).runtime.entry.scene; +export function scene(snapshot) { + const value = parse(decode(file(snapshot, scenePath(snapshot)).data), { + maxAliasCount: 0, + }); + if ( + value?.format !== "ada.scene" || + value.schemaVersion !== 1 || + !Array.isArray(value.entities) + ) + throw new Error("Неподдерживаемый формат сцены"); + const ids = new Set(value.entities.map((e) => e.id)); + if (ids.size !== value.entities.length) + throw new Error("Повторяющиеся ID объектов"); + const parents = new Map(value.entities.map((e) => [e.id, e.parent])); + for (const e of value.entities) { + if ( + typeof e.id !== "string" || + !e.id || + typeof e.name !== "string" || + !e.components || + Array.isArray(e.components) || + typeof e.components !== "object" + ) + throw new Error("Некорректный объект сцены"); + const visited = new Set([e.id]); + let p = e.parent; + while (p) { + if (!ids.has(p) || visited.has(p)) + throw new Error("Некорректная иерархия"); + visited.add(p); + p = parents.get(p); + } + } + return value; +} +export function validPath(path) { + return ( + typeof path === "string" && + path.length <= 512 && + /^[a-zA-Z0-9_./-]+$/.test(path) && + path.split("/").every((p) => p && p !== "." && p !== "..") && + (path === ".ada/project.json" || + (path.startsWith("Sources/") && path.endsWith(".ada")) || + (path.startsWith("Assets/") && + /\.(png|jpg|jpeg|wav|ogg|ascn|tilemap|tileset|json|ui|glb|gltf|bin)$/i.test( + path, + ))) + ); +} +export function validate(snapshot) { + if ( + snapshot.formatVersion !== 1 || + !snapshot.title.trim() || + new TextEncoder().encode(snapshot.title).length > 240 || + snapshot.files.length > 512 || + !snapshot.files.length + ) + throw new Error("Некорректный проект"); + const names = new Set(); + let total = 0; + for (const f of snapshot.files) { + const size = bytes(f.data).length; + if ( + !validPath(f.path) || + names.has(f.path.toLowerCase()) || + size > 16 * 1024 * 1024 || + (f.path.startsWith("Sources/") && size > 512 * 1024) + ) + throw new Error(`Недопустимый файл: ${f.path}`); + names.add(f.path.toLowerCase()); + total += size; + } + if (total > 64 * 1024 * 1024) + throw new Error("Размер проекта превышает 64 МБ"); + const meta = metadata(snapshot); + if ( + meta.build?.system !== "adascript" || + meta.runtime?.entry?.view || + !file(snapshot, scenePath(snapshot)) || + !snapshot.files.some((f) => f.path.startsWith("Sources/")) + ) + throw new Error("Нужен AdaScript-проект со сценой"); + if ( + (meta.paths?.sources && meta.paths.sources !== "Sources") || + (meta.paths?.assets && meta.paths.assets !== "Assets") || + meta.paths?.resourceRoots?.some((p) => p !== "Assets") + ) + throw new Error("Используйте папки Sources и Assets внутри проекта"); + if ( + (snapshot.messages?.length ?? 0) > 1000 || + new TextEncoder().encode(JSON.stringify(snapshot.messages ?? [])).length > + 4 * 1024 * 1024 + ) + throw new Error("История чата превышает лимит проекта"); + scene(snapshot); +} +export function portable(snapshot, newIdentity = false) { + validate(snapshot); + const copy = clone(snapshot), + meta = metadata(copy); + meta.ai = {}; + meta.editor = {}; + meta.run = {}; + if (meta.paths) { + delete meta.paths.build; + delete meta.paths.generated; + meta.paths.run = {}; + } + if (newIdentity) meta.project.id = crypto.randomUUID(); + file(copy, ".ada/project.json").data = encode(JSON.stringify(meta, null, 2)); + return copy; +} +export function exportZIP(snapshot) { + return zipSync( + Object.fromEntries( + portable(snapshot).files.map((f) => [f.path, bytes(f.data)]), + ), + { level: 6 }, + ); +} +export function importZIP(data) { + if (data.byteLength > 65 * 1024 * 1024) + throw new Error("Архив превышает 65 МБ"); + const checksums = zipChecksums(data); + let total = 0; + const names = new Set(); + const entries = unzipSync(new Uint8Array(data), { + filter: (f) => { + if (f.name.endsWith("/")) return false; + if ( + !validPath(f.name) || + names.has(f.name.toLowerCase()) || + f.originalSize > 16 * 1024 * 1024 || + names.size >= 512 + ) + throw new Error("Небезопасный путь или размер ZIP"); + names.add(f.name.toLowerCase()); + total += f.originalSize; + if (total > 64 * 1024 * 1024) + throw new Error("Распакованный проект превышает 64 МБ"); + return true; + }, + }); + const files = Object.entries(entries).map(([path, value]) => { + if (crc32(value) !== checksums.get(path)) + throw new Error("Повреждённый ZIP: " + path); + return { path, data: base64(value) }; + }); + const snapshot = { + formatVersion: 1, + title: "Импортированный проект", + files, + messages: [], + }; + const meta = metadata(snapshot); + snapshot.title = + meta.project?.displayName || meta.project?.name || snapshot.title; + return portable(snapshot, true); +} +// fflate inflates entries but does not verify their CRC. Check the bounded central +// directory as well, so corrupt packages never become editable cloud projects. +function zipChecksums(input) { + const data = new Uint8Array(input), + view = new DataView(data.buffer, data.byteOffset, data.byteLength), + result = new Map(); + let end = data.length - 22; + for (; end >= Math.max(0, data.length - 65557); end--) + if ( + view.getUint32(end, true) === 0x06054b50 && + end + 22 + view.getUint16(end + 20, true) === data.length + ) + break; + if (end < 0 || view.getUint32(end, true) !== 0x06054b50) + throw new Error("Повреждённый ZIP"); + const count = view.getUint16(end + 10, true), + size = view.getUint32(end + 12, true); + let offset = view.getUint32(end + 16, true); + const limit = offset + size; + if ( + view.getUint16(end + 4, true) || + view.getUint16(end + 6, true) || + count !== view.getUint16(end + 8, true) || + count > 1024 || + limit !== end + ) + throw new Error("Неподдерживаемая структура ZIP"); + for (let i = 0; i < count; i++) { + if (offset + 46 > limit || view.getUint32(offset, true) !== 0x02014b50) + throw new Error("Повреждённый ZIP"); + const flags = view.getUint16(offset + 8, true), + method = view.getUint16(offset + 10, true), + length = view.getUint16(offset + 28, true), + extra = view.getUint16(offset + 30, true), + comment = view.getUint16(offset + 32, true), + next = offset + 46 + length + extra + comment; + if ( + next > limit || + flags & 1 || + ![0, 8].includes(method) || + view.getUint32(offset + 20, true) === 0xffffffff || + view.getUint32(offset + 24, true) === 0xffffffff + ) + throw new Error("Неподдерживаемое сжатие ZIP"); + const name = new TextDecoder("utf-8", { fatal: true }).decode( + data.subarray(offset + 46, offset + 46 + length), + ); + if (!name.endsWith("/")) { + if (result.has(name)) throw new Error("Повторяющийся путь ZIP"); + result.set(name, view.getUint32(offset + 16, true)); + } + offset = next; + } + if (offset !== limit) throw new Error("Повреждённый ZIP"); + return result; +} +function crc32(data) { + let value = 0xffffffff; + for (const byte of data) { + value ^= byte; + for (let i = 0; i < 8; i++) + value = (value >>> 1) ^ (value & 1 ? 0xedb88320 : 0); + } + return (value ^ 0xffffffff) >>> 0; +} +export const transform = ( + position = [0, 0, 0], + scale = [1, 1, 1], + rotation = [0, 0, 0, 1], +) => ({ position, scale, rotation }); +const color = (red, green, blue) => ({ red, green, blue, alpha: 1 }); +export function template( + dimension = 3, + title = dimension === 3 ? "Долина ветров" : "Новая 2D-игра", +) { + const meta = { + schemaVersion: 1, + project: { + id: crypto.randomUUID(), + name: "StudioGame", + displayName: title, + }, + build: { system: "adascript" }, + paths: { sources: "Sources", assets: "Assets", resourceRoots: ["Assets"] }, + runtime: { + moduleName: "StudioGame", + entry: { scene: "Assets/Scenes/Main.ascn" }, + plugins: { preset: `game${dimension}d`, settings: {} }, + }, + inputActions: [], + }; + const entities = []; + const add = (name, components, parent = null) => { + const id = crypto.randomUUID(); + entities.push({ id, name, enabled: true, parent, components }); + return id; + }; + add("Камера", { + "AdaTransform.Transform": + dimension === 3 + ? transform([0, 5, -12], [1, 1, 1], [0.17, 0, 0, 0.985444]) + : transform(), + "AdaRender.Camera": { + projection: dimension === 3 ? "perspective" : "orthographic", + isActive: true, + renderOrder: 0, + backgroundColor: color(0.09, 0.13, 0.18), + }, + }); + const mesh = (name, kind, position, scale, tint) => + add(name, { + "AdaTransform.Transform": transform(position, scale), + "AdaScene.Mesh3DComponent": { + mesh: kind, + size: 1, + color: color(...tint), + metallic: 0, + roughness: 0.85, + }, + }); + if (dimension === 3) { + add("Солнце", { + "AdaTransform.Transform": transform( + [0, 6, -4], + [1, 1, 1], + [0.25, 0.2, 0, 0.94736], + ), + "AdaScene.DirectionalLightComponent": { + radiance: [1, 0.94, 0.83], + intensity: 3, + castShadows: true, + }, + }); + mesh("Лужайка", "Cube", [0, -0.3, 4], [24, 0.5, 28], [0.33, 0.44, 0.25]); + mesh("Player", "Cube", [0, 0.5, 0], [0.8, 1, 0.8], [0.79, 0.65, 0.44]); + for (let i = 0; i < 9; i++) { + const x = i % 2 === 0 ? -4 - i * 0.35 : 4 + i * 0.22, + z = 1 + Math.floor(i / 2) * 3; + const parent = mesh( + `Сосна ${i + 1}`, + "Cube", + [x, 1.1, z], + [0.25, 2.2, 0.25], + [0.32, 0.24, 0.16], + ); + mesh( + `Крона ${i + 1}`, + "Sphere", + [x, 2.4, z], + [1.8, 2.7, 1.8], + [0.17, 0.3 + i * 0.006, 0.19], + ); + } + for (let i = 0; i < 5; i++) + mesh( + `Скала ${i + 1}`, + "Sphere", + [-3 + i * 1.5, 0.25, 8 + (i % 2) * 1.3], + [1.7, 0.8, 1.3], + [0.48, 0.47, 0.42], + ); + } else + add("Player", { + "AdaTransform.Transform": transform(), + "AdaSprite.Sprite": { + tintColor: color(0.5, 0.46, 1), + size: { width: 80, height: 80 }, + }, + }); + const value = { + format: "ada.scene", + schemaVersion: 1, + scene: { id: crypto.randomUUID(), name: title }, + entities, + }; + return { + formatVersion: 1, + title, + files: [ + textFile(".ada/project.json", JSON.stringify(meta, null, 2)), + textFile(meta.runtime.entry.scene, JSON.stringify(value, null, 2)), + textFile( + "Sources/Game.ada", + "// Опишите вашу игру ассистенту, чтобы добавить игровой процесс.\n", + ), + ], + messages: [], + }; +} +export class Document { + constructor(snapshot) { + validate(snapshot); + this.snapshot = clone(snapshot); + this.undoStack = []; + this.redoStack = []; + this.generation = 0; + this.contentGeneration = 0; + } + edit(update) { + const next = clone(this.snapshot); + update(next); + validate(next); + if (JSON.stringify(next) === JSON.stringify(this.snapshot)) return; + this.undoStack.push(this.snapshot); + if (this.undoStack.length > 50) this.undoStack.shift(); + this.redoStack = []; + this.snapshot = next; + this.generation++; + this.contentGeneration++; + } + write(path, text) { + this.edit((s) => { + const f = file(s, path); + if (f) f.data = encode(text); + else s.files.push(textFile(path, text)); + }); + } + scene(update) { + this.edit((s) => { + const value = scene(s); + update(value); + file(s, scenePath(s)).data = encode(JSON.stringify(value, null, 2)); + }); + } + append(value) { + const next = clone(this.snapshot); + next.messages.push({ id: crypto.randomUUID(), ...value }); + validate(next); + this.snapshot = next; + this.generation++; + } + undo() { + const previous = this.undoStack.pop(); + if (!previous) return; + this.redoStack.push(this.snapshot); + this.snapshot = { ...previous, messages: this.snapshot.messages }; + this.generation++; + this.contentGeneration++; + } + redo() { + const next = this.redoStack.pop(); + if (!next) return; + this.undoStack.push(this.snapshot); + this.snapshot = { ...next, messages: this.snapshot.messages }; + this.generation++; + this.contentGeneration++; + } +} diff --git a/Editor/WebUI/src/platform.js b/Editor/WebUI/src/platform.js new file mode 100644 index 000000000..528bb7592 --- /dev/null +++ b/Editor/WebUI/src/platform.js @@ -0,0 +1,111 @@ +export const encoder = new TextEncoder(); +export const decode = (base64) => new TextDecoder().decode(bytes(base64)); +export const bytes = (base64) => + Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); +export function base64(data) { + let text = ""; + for (let n = 0; n < data.length; n += 32768) + text += String.fromCharCode(...data.subarray(n, n + 32768)); + return btoa(text); +} +export const encode = (text) => base64(encoder.encode(text)); +export async function api( + path, + body, + method = body === undefined ? "GET" : "POST", + signal, +) { + if (!path.startsWith("/v1/") || path.includes("..") || path.includes("://")) + throw new Error("Недопустимый адрес API"); + const send = () => + fetch(path, { + method, + credentials: "same-origin", + signal, + cache: "no-store", + headers: { "Content-Type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + let response = await send(); + if (response.status === 401) { + const refresh = await fetch("/v1/auth/refresh", { + method: "POST", + credentials: "same-origin", + signal, + }); + if (refresh.ok) response = await send(); + } + if (!response.ok) { + const value = await response.json().catch(() => null), + error = new Error( + value?.error?.message || + value?.reason || + `Ошибка сервера: ${response.status}`, + ); + error.status = response.status; + throw error; + } + return response; +} +export async function json(path, body, method, signal) { + const response = await api(path, body, method, signal); + return response.status === 204 ? null : response.json(); +} +export async function stream(body, signal, event) { + const response = await api("/v1/ai/generate", body, "POST", signal), + reader = response.body.getReader(), + decoder = new TextDecoder(); + let pending = ""; + for (;;) { + const { value, done } = await reader.read(); + pending += decoder.decode(value, { stream: !done }); + if (pending.length > 2 * 1024 * 1024) + throw new Error("Слишком большой ответ провайдера"); + let end; + while ((end = pending.indexOf("\n")) >= 0) { + const line = pending.slice(0, end).trim(); + pending = pending.slice(end + 1); + if (line.startsWith("data:") && line.slice(5).trim() !== "[DONE]") + event(JSON.parse(line.slice(5))); + } + if (done) break; + } +} +export async function recovery(action, account, project, value) { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open("ada-studio-recovery-v1", 1); + request.onupgradeneeded = () => request.result.createObjectStore("drafts"); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction( + "drafts", + action === "load" ? "readonly" : "readwrite", + ), + store = tx.objectStore("drafts"), + key = `${account}:${project}`; + const request = + action === "load" + ? store.get(key) + : action === "save" + ? store.put(value, key) + : store.delete(key); + tx.oncomplete = () => resolve(request.result ?? null); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + } finally { + db.close(); + } +} +export function download(name, data, type = "application/zip") { + const url = URL.createObjectURL(new Blob([data], { type })), + link = document.createElement("a"); + link.href = url; + link.download = name; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 30000); +} +export { Preview } from "./preview.js"; diff --git a/Editor/WebUI/src/preview.js b/Editor/WebUI/src/preview.js new file mode 100644 index 000000000..624cf6fde --- /dev/null +++ b/Editor/WebUI/src/preview.js @@ -0,0 +1,186 @@ +const cancelled = () => + new DOMException("Обновление сцены отменено", "AbortError"); + +/** One AdaEngine instance per open project; snapshots replace its scene, not its renderer. */ +export class Preview { + frame = null; + host = null; + epoch = 0; + sequence = 0; + pending = new Map(); + ready = false; + hasFrame = false; + queue = Promise.resolve(); + key = ""; + requestedKey = ""; + loading = null; + cancelMount = null; + editorRevision = null; + + constructor(changed) { + this.changed = changed; + window.addEventListener("message", (event) => { + if ( + event.source !== this.frame?.contentWindow || + event.origin !== this.origin || + event.data?.protocol !== "ada-studio-preview-v1" + ) + return; + if (event.data.event === "editor") { + if (this.ready && event.data.revision === this.editorRevision) + this.onEditorEvent?.(event.data.type, event.data.value); + return; + } + const entry = this.pending.get(event.data.id); + if (!entry) return; + this.pending.delete(event.data.id); + event.data.error + ? entry.reject(new Error(event.data.error)) + : entry.resolve(event.data.value); + }); + } + + stop() { + ++this.epoch; + ++this.sequence; + this.cancelMount?.(); + this.cancelMount = null; + this.frame?.remove(); + this.frame = null; + this.ready = false; + this.editorRevision = null; + this.hasFrame = false; + this.key = ""; + this.requestedKey = ""; + this.loading = null; + for (const entry of this.pending.values()) entry.reject(cancelled()); + this.pending.clear(); + this.queue = Promise.resolve(); + this.changed("idle"); + } + + load(snapshot, config, editMode = false) { + const key = JSON.stringify({ files: snapshot.files, editMode }); + if (this.loading && this.requestedKey === key) return this.loading; + const epoch = this.epoch, + sequence = ++this.sequence; + this.requestedKey = key; + const active = () => { + if (epoch !== this.epoch || sequence !== this.sequence) throw cancelled(); + }; + const work = async () => { + active(); + if (this.ready && this.key === key) return; + this.ready = false; + this.changed("loading"); + if (!this.frame) await this.mount(config); + active(); + const revision = crypto.randomUUID(); + await this.message("load", { + formatVersion: 1, + title: snapshot.title, + files: snapshot.files, + editMode, + revision, + }); + if (epoch !== this.epoch) throw cancelled(); + this.hasFrame = true; + this.key = key; + this.editorRevision = revision; + active(); + this.ready = true; + this.changed("ready"); + }; + const result = this.queue.catch(() => {}).then(work); + this.queue = result; + this.loading = result; + const clear = () => { + if (this.loading === result) this.loading = null; + }; + result.then(clear, clear); + return result; + } + + async mount(config) { + if (!navigator.gpu) + throw new Error( + "Для сцены нужен браузер с WebGPU. Панели и код доступны без него.", + ); + if (!this.host) throw new Error("Игровая область пока недоступна"); + const url = new URL(config.previewURL); + const local = (host) => ["localhost", "127.0.0.1", "[::1]"].includes(host); + const loopback = + location.protocol === "http:" && + url.protocol === "http:" && + local(location.hostname) && + local(url.hostname); + if ( + (url.protocol !== "https:" && !loopback) || + url.hostname === location.hostname || + url.username || + url.password + ) + throw new Error("Для сцены нужен отдельный защищённый хост"); + this.origin = url.origin; + url.hash = new URLSearchParams({ studioParent: location.origin }); + const frame = document.createElement("iframe"); + this.frame = frame; + frame.title = "AdaEngine — сцена и игровой предпросмотр"; + frame.setAttribute( + "sandbox", + "allow-scripts allow-same-origin allow-pointer-lock", + ); + frame.allow = "autoplay; fullscreen"; + frame.referrerPolicy = "no-referrer"; + const mounted = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("AdaPlayer не ответил")), + 30000, + ); + this.cancelMount = () => { + clearTimeout(timer); + reject(cancelled()); + }; + frame.onload = () => { + clearTimeout(timer); + resolve(); + }; + frame.onerror = () => { + clearTimeout(timer); + reject(new Error("Не удалось открыть AdaPlayer")); + }; + }); + frame.src = url; + this.host.append(frame); + await mounted; + this.cancelMount = null; + } + + message(type, payload) { + if (!this.frame) throw new Error("Сцена ещё не загружена"); + const id = crypto.randomUUID(); + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => { + this.pending.delete(id); + reject(new Error("AdaPlayer не отвечает")); + }, + type === "load" ? 120000 : 30000, + ); + this.pending.set(id, { + resolve: (value) => { + clearTimeout(timer); + resolve(value); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + this.frame.contentWindow.postMessage( + { protocol: "ada-studio-preview-v1", id, type, payload }, + this.origin, + ); + }); + } +} diff --git a/Editor/WebUI/src/studio.js b/Editor/WebUI/src/studio.js new file mode 100644 index 000000000..6f71b0050 --- /dev/null +++ b/Editor/WebUI/src/studio.js @@ -0,0 +1,621 @@ +import { runAgent, runTool } from "./agent.js"; +import { json, recovery, Preview, base64, download } from "./platform.js"; +import { + Document, + clone, + file, + metadata, + scene, + template, + portable, + exportZIP, + importZIP, + transform, +} from "./documents.js"; +export class Studio { + version = 0; + listeners = new Set(); + account = null; + config = {}; + projects = []; + workspace = null; + document = null; + view = "editor"; + panel = "objects"; + selected = null; + inspector = true; + assistant = true; + assetPanel = false; + status = "loading"; + error = ""; + prompt = ""; + streamText = ""; + running = false; + runtime = "idle"; + playing = false; + viewportError = ""; + activity = []; + providers = []; + providerID = ""; + modelID = ""; + billingMode = "ada"; + apiKey = ""; + settings = false; + history = false; + checkpoints = []; + activeFile = "Sources/Game.ada"; + savePromise = null; + savedGeneration = 0; + timer = null; + recoveryQueue = Promise.resolve(); + agent = null; + checkpointPending = false; + latestCodeSelection = ""; + tool = "move"; + localAxes = false; + snapTransforms = false; + renderedGeneration = -1; + constructor() { + this.preview = new Preview((state) => { + this.runtime = state; + this.emit(); + }); + this.preview.onEditorEvent = (type, value) => + this.action(() => this.editorEvent(type, value)); + window.addEventListener("online", () => { + if (this.document) this.save().catch((e) => this.fail(e)); + }); + window.addEventListener("pagehide", () => { + this.agent?.abort(); + this.preview.stop(); + }); + window.addEventListener("keydown", (e) => { + if ( + (e.metaKey || e.ctrlKey) && + e.key.toLowerCase() === "z" && + !document.activeElement?.closest( + "input, textarea, [contenteditable=true]", + ) + ) { + e.preventDefault(); + e.shiftKey ? this.redo() : this.undo(); + } + }); + window.addEventListener("keydown", (e) => { + if ( + this.document && + this.view === "editor" && + !this.playing && + !e.target.closest?.("input, textarea, [contenteditable=true]") && + !e.ctrlKey && + !e.metaKey && + !e.altKey + ) { + const tool = { w: "move", e: "rotate", r: "scale", q: "hand" }[ + e.key.toLowerCase() + ]; + if (tool) { + e.preventDefault(); + this.tool = tool; + this.emit(); + this.editorCommand(); + } + if (e.key.toLowerCase() === "f") { + e.preventDefault(); + this.editorCommand({ action: "focus" }); + } + } + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") { + e.preventDefault(); + this.save().catch((error) => this.fail(error)); + } + }); + } + subscribe = (fn) => { + this.listeners.add(fn); + return () => this.listeners.delete(fn); + }; + getVersion = () => this.version; + emit() { + this.version++; + for (const fn of this.listeners) fn(); + } + fail(error) { + if (error?.name === "AbortError") return; + this.error = error.message || String(error); + this.emit(); + } + async action(fn) { + try { + return await fn(); + } catch (e) { + this.fail(e); + } + } + get snapshot() { + return this.document?.snapshot; + } + get scene() { + try { + return this.snapshot ? scene(this.snapshot) : null; + } catch { + return null; + } + } + get entity() { + return this.scene?.entities.find((e) => e.id === this.selected); + } + get models() { + return this.providers.find((p) => p.id === this.providerID)?.models ?? []; + } + get model() { + return this.models.find((m) => m.id === this.modelID); + } + get sources() { + return ( + this.snapshot?.files + .filter((f) => f.path.startsWith("Sources/")) + .sort((a, b) => a.path.localeCompare(b.path)) ?? [] + ); + } + async start() { + try { + this.account = await json("/v1/me"); + this.config = await json("/v1/ai/studio-config"); + if (!this.config.enabled) + throw new Error("Studio пока недоступен в этом окружении"); + this.providers = await json("/v1/ai/providers"); + this.providerID = this.providers[0]?.id ?? ""; + this.modelID = this.models.find((m) => m.tools)?.id ?? ""; + this.projects = await json("/v1/workspaces"); + this.status = "saved"; + this.emit(); + const last = localStorage.getItem(`studio:last:${this.account.id}`), + existing = this.projects.find((p) => p.id === last); + if (existing) await this.open(existing); + } catch (e) { + this.status = e.status === 401 ? "signedout" : "offline"; + this.fail(e); + } + } + async refresh() { + this.projects = await json("/v1/workspaces"); + this.emit(); + } + async create(dimension = 3) { + await this.save(); + await this.createSnapshot(template(dimension)); + } + async createSnapshot(snapshot) { + const clean = portable(snapshot), + record = await json("/v1/workspaces", { + operationID: crypto.randomUUID(), + snapshot: clean, + }); + this.install(record, clean); + await this.refresh(); + } + install(record, snapshot) { + this.preview.stop(); + this.workspace = record; + this.document = new Document(snapshot); + this.selected = null; + this.latestCodeSelection = ""; + this.activity = []; + this.savedGeneration = 0; + this.view = "editor"; + this.playing = false; + this.viewportError = ""; + this.activeFile = this.sources[0]?.path ?? "Sources/Game.ada"; + this.status = "saved"; + this.history = false; + this.error = ""; + this.checkpointPending = false; + localStorage.setItem(`studio:last:${this.account.id}`, record.id); + this.emit(); + } + async open(record) { + if (this.running) + throw new Error("Остановите ассистента перед сменой проекта"); + await this.save(); + const value = await json(`/v1/workspaces/${record.id}`); + if (!value.snapshot) throw new Error("Снимок проекта недоступен"); + this.install(value, value.snapshot); + const draft = await recovery("load", this.account.id, record.id); + if ( + draft && + JSON.stringify(draft.snapshot) !== JSON.stringify(value.snapshot) + ) { + if (draft.revision === value.revision) { + this.document.edit((s) => Object.assign(s, draft.snapshot)); + this.changed(); + } else { + await this.createSnapshot({ + ...draft.snapshot, + title: draft.snapshot.title + " — восстановлено", + }); + this.error = "Несохранённые изменения восстановлены отдельным проектом"; + this.emit(); + } + } + } + async home() { + if (this.running) throw new Error("Сначала остановите ассистента"); + await this.save(); + this.preview.stop(); + this.document = null; + this.workspace = null; + this.history = false; + this.emit(); + await this.refresh(); + } + changed(checkpoint = false) { + this.checkpointPending ||= checkpoint; + if (!this.document) return; + this.status = "saving"; + const record = this.workspace, + snapshot = clone(this.snapshot), + account = this.account.id; + this.recoveryQueue = this.recoveryQueue + .then(() => + recovery("save", account, record.id, { + revision: record.revision, + snapshot, + }), + ) + .catch((e) => this.fail(e)); + clearTimeout(this.timer); + this.timer = setTimeout(() => this.save().catch((e) => this.fail(e)), 900); + this.emit(); + } + async save(checkpoint = false) { + this.checkpointPending ||= checkpoint; + clearTimeout(this.timer); + if (this.savePromise) { + await this.savePromise; + return this.save(); + } + const doc = this.document, + record = this.workspace; + if ( + !doc || + !record || + (doc.generation === this.savedGeneration && !this.checkpointPending) + ) + return; + const snapshot = portable(doc.snapshot), + generation = doc.generation, + keeps = this.checkpointPending; + this.checkpointPending = false; + const save = async () => { + try { + const result = await json( + `/v1/workspaces/${record.id}`, + { + operationID: crypto.randomUUID(), + expectedRevision: record.revision, + snapshot, + checkpoint: keeps, + }, + "PUT", + ); + if (this.document !== doc) return; + this.workspace = { ...result }; + this.savedGeneration = generation; + if (doc.generation === generation) { + await this.recoveryQueue; + if (doc.generation === generation) + await recovery("remove", this.account.id, record.id); + } + this.status = doc.generation === generation ? "saved" : "saving"; + } catch (e) { + if (this.document !== doc) return; + if (e.status === 409) { + this.agent?.abort(); + await this.createSnapshot({ + ...clone(doc.snapshot), + title: doc.snapshot.title + " — копия", + }); + this.error = + "Проект изменён в другой вкладке. Ваши правки сохранены отдельной копией."; + } else { + this.status = e.status === 401 ? "signedout" : "offline"; + this.checkpointPending ||= keeps; + throw e; + } + } finally { + this.emit(); + } + }; + this.savePromise = save(); + try { + await this.savePromise; + } finally { + this.savePromise = null; + } + if ( + this.document === doc && + (doc.generation !== this.savedGeneration || this.checkpointPending) + ) + return this.save(); + } + editScene(update) { + this.document.scene(update); + this.changed(); + } + select(id) { + this.selected = id; + this.inspector = true; + this.emit(); + this.editorCommand(); + } + editorCommand(options = {}) { + if (!this.preview.ready || this.playing) return; + return this.preview + .message("editor", { + selected: this.selected, + tool: this.tool, + local: this.localAxes, + snap: this.snapTransforms, + ...options, + }) + .catch((error) => this.fail(error)); + } + editorEvent(type, value) { + if (this.playing || !this.document || this.view !== "editor") return; + if (type === "select") { + this.selected = this.scene.entities.some((e) => e.id === value.id) + ? value.id + : null; + this.inspector = true; + this.emit(); + } + if ( + type === "tool" && + ["move", "rotate", "scale", "hand"].includes(value.tool) + ) { + this.tool = value.tool; + this.emit(); + } + if (type === "error") throw new Error(value.message); + if (type === "transform") { + // A tool drag may span an agent/manual revision. Never overwrite that edit. + if ( + this.renderedGeneration !== this.document.contentGeneration || + this.running + ) { + this.preview.key = ""; + return this.syncViewport(); + } + const t = value.transform; + if ( + ![t?.position, t?.rotation, t?.scale].every( + (v, i) => + Array.isArray(v) && + v.length === [3, 4, 3][i] && + v.every( + (n) => + typeof n === "number" && + Number.isFinite(n) && + Math.abs(n) < 1_000_000, + ), + ) + ) + throw new Error("Некорректная трансформация"); + this.editScene((scene) => { + const entity = scene.entities.find((e) => e.id === value.id); + if (entity) entity.components["AdaTransform.Transform"] = clone(t); + }); + } + if (type === "shortcut") { + if (value.key === "z") value.shift ? this.redo() : this.undo(); + if (value.key === "s") return this.save(); + if (!this.running && this.selected && value.key === "delete") + this.removeEntity(); + if (!this.running && this.selected && value.key === "d") + this.duplicateEntity(); + } + } + duplicateEntity() { + if (!this.selected) return; + const selected = this.selected, + ids = new Map([[selected, crypto.randomUUID()]]); + this.editScene((scene) => { + for (;;) { + const count = ids.size; + for (const e of scene.entities) + if (ids.has(e.parent) && !ids.has(e.id)) + ids.set(e.id, crypto.randomUUID()); + if (count === ids.size) break; + } + scene.entities.push( + ...scene.entities + .filter((e) => ids.has(e.id)) + .map((e) => ({ + ...clone(e), + id: ids.get(e.id), + parent: ids.get(e.parent) ?? e.parent, + name: e.id === selected ? e.name + " — копия" : e.name, + })), + ); + }); + this.select(ids.get(selected)); + } + setField(component, field, value) { + this.editScene((s) => { + const entity = s.entities.find((e) => e.id === this.selected); + if (entity) entity.components[component][field] = value; + }); + } + addEntity() { + const id = crypto.randomUUID(); + const visual = + metadata(this.snapshot).runtime.plugins.preset === "game2d" + ? { + "AdaSprite.Sprite": { + tintColor: { red: 0.52, green: 0.47, blue: 0.98, alpha: 1 }, + size: { width: 80, height: 80 }, + }, + } + : { + "AdaScene.Mesh3DComponent": { + mesh: "Cube", + size: 1, + color: { red: 0.52, green: 0.47, blue: 0.98, alpha: 1 }, + roughness: 0.65, + metallic: 0, + }, + }; + this.editScene((s) => + s.entities.push({ + id, + name: "Новый объект", + enabled: true, + parent: null, + components: { + "AdaTransform.Transform": transform(), + ...visual, + }, + }), + ); + this.select(id); + } + removeEntity() { + const removed = new Set([this.selected]); + this.editScene((s) => { + for (;;) { + const before = removed.size; + for (const e of s.entities) + if (removed.has(e.parent)) removed.add(e.id); + if (before === removed.size) break; + } + s.entities = s.entities.filter((e) => !removed.has(e.id)); + }); + this.selected = null; + this.emit(); + } + undo() { + this.document?.undo(); + this.changed(); + } + redo() { + this.document?.redo(); + this.changed(); + } + writeCode(text) { + this.document.write(this.activeFile, text); + this.changed(); + } + rename(title) { + if (!title.trim() || title === this.snapshot.title) return; + this.document.edit((s) => { + s.title = title.trim(); + const meta = metadata(s); + meta.project.displayName = s.title; + file(s, ".ada/project.json").data = base64( + new TextEncoder().encode(JSON.stringify(meta, null, 2)), + ); + }); + this.changed(); + } + async uploadAsset(input) { + const path = + "Assets/Imports/" + + crypto.randomUUID() + + "_" + + input.name.replace(/[^a-zA-Z0-9_.-]/g, "_"), + data = base64(new Uint8Array(await input.arrayBuffer())); + this.document.edit((s) => s.files.push({ path, data })); + this.changed(); + return path; + } + async importFile(input) { + await this.save(); + await this.createSnapshot(importZIP(await input.arrayBuffer())); + } + export() { + download(this.snapshot.title + ".zip", exportZIP(this.snapshot)); + } + async showHistory() { + await this.save(); + this.checkpoints = await json( + `/v1/workspaces/${this.workspace.id}/history`, + ); + this.history = true; + this.emit(); + } + async checkpoint() { + await this.save(true); + await this.showHistory(); + } + async restore(revision) { + if (this.running) + throw new Error("Остановите ассистента перед восстановлением"); + await this.save(); + const id = this.workspace.id; + await json(`/v1/workspaces/${id}/restore`, { + operationID: crypto.randomUUID(), + expectedRevision: this.workspace.revision, + revision, + }); + await this.recoveryQueue; + await recovery("remove", this.account.id, id); + const restored = await json(`/v1/workspaces/${id}`); + this.install(restored, restored.snapshot); + } + async play(editMode = false) { + if (!this.snapshot) return; + this.playing = !editMode; + this.view = editMode ? "editor" : "preview"; + this.emit(); + return this.syncViewport(editMode); + } + async syncViewport(editMode = !this.playing) { + if (!this.snapshot) return; + const document = this.document; + const generation = document.contentGeneration; + const snapshot = portable(this.snapshot); + snapshot.messages = []; + try { + // Creating a project from the home composer mounts Workspace asynchronously. + if (!this.preview.host) await new Promise(requestAnimationFrame); + await this.preview.load(snapshot, this.config, editMode); + if (this.document === document) { + this.viewportError = ""; + this.renderedGeneration = generation; + if (editMode) await this.editorCommand(); + } + } catch (e) { + if (e.name !== "AbortError" && this.document === document) { + this.runtime = "error"; + this.viewportError = e.message; + this.emit(); + } + throw e; + } + } + async stop() { + this.playing = false; + this.view = "editor"; + this.emit(); + return this.syncViewport(true); + } + async retryViewport() { + this.preview.stop(); + this.viewportError = ""; + return this.syncViewport(); + } + stopAgent() { + this.agent?.abort(); + } + append(value) { + this.document.append(value); + this.changed(); + } + send(text) { + return runAgent(this, text); + } + runTool(name, args) { + return runTool(this, name, args); + } +} diff --git a/Editor/WebUI/src/style.css b/Editor/WebUI/src/style.css new file mode 100644 index 000000000..2ef77d0b4 --- /dev/null +++ b/Editor/WebUI/src/style.css @@ -0,0 +1,2031 @@ +:root { + font-family: Inter, "Segoe UI", Arial, sans-serif; + color: #e9e9ed; + background: #101112; + font-synthesis: none; + font-size: 13px; + color-scheme: dark; + --muted: #888b90; + --border: #ffffff0e; + --accent: #8b80f9; + --panel: #171819; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +button, +input, +select, +textarea { + font: inherit; +} +button { + cursor: pointer; +} +button:disabled { + opacity: 0.35; + cursor: default; +} +button, +a, +input, +textarea, +select { + -webkit-tap-highlight-color: transparent; +} +button { + color: inherit; + border: 0; + background: none; +} +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid #8b80f9; + outline-offset: 3px; +} +button { + transition: + background 0.14s, + color 0.14s, + box-shadow 0.14s; +} +svg { + flex-shrink: 0; +} +input, +select, +textarea { + color: inherit; +} +input, +textarea { + min-width: 0; +} +button:hover { + color: #fff; +} +button:disabled:hover { + background: inherit; +} +button.active { + background: #ffffff0d; + color: #b2aaff; +} +h1, +h2, +h3, +p { + margin: 0; +} +strong { + font-weight: 550; +} +.app { + height: 100dvh; + min-height: 520px; + display: flex; + overflow: hidden; + background: #101112; +} +.main { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; +} +.rail { + width: 58px; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + border-right: 1px solid #ffffff05; + z-index: 12; +} +.brand { + height: 63px; + display: grid; + place-content: center; + color: #efedf6; +} +.brand-mark { + display: block; + object-fit: contain; + flex-shrink: 0; +} +.brand:hover .brand-mark { + opacity: 0.8; +} +.rail-group, +.rail-bottom { + display: flex; + flex-direction: column; + align-items: center; + gap: 9px; +} +.rail-group { + padding-top: 15px; +} +.rail-bottom { + margin-top: auto; + padding-bottom: 18px; + gap: 18px; +} +.avatar { + height: 28px; + width: 28px; + border-radius: 50%; + background: #42413d; + color: #dfdacb; + display: grid; + place-content: center; + font-size: 11px; + font-weight: 600; + border: 1px solid #ffffff20; +} +.icon-button { + height: 32px; + width: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + color: #909296; + flex-shrink: 0; +} +.icon-button:hover { + background: #ffffff0b; + color: #dedde6; +} +.rail .icon-button { + height: 34px; + width: 34px; +} +.rail .icon-button.active { + background: #25242e; + color: #c4bcff; +} +.button { + height: 33px; + border-radius: 7px; + padding: 0 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + white-space: nowrap; + background: #242527; + color: #d5d5dc; + font-size: 12px; + font-weight: 500; +} +.button:hover { + background: #303134; +} +.button.primary { + background: #6b5bf3; + color: #fff; + box-shadow: 0 1px 3px #0002; +} +.button.primary:hover { + background: #7b6cff; +} +.button.primary svg { + width: 14px; +} +.wide { + width: 100%; +} +.topbar { + height: 61px; + flex-shrink: 0; + display: flex; + align-items: center; + gap: 14px; + padding: 0 22px; + position: relative; + z-index: 8; +} +.breadcrumbs { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.breadcrumbs > button { + font-weight: 550; + color: #d7d7dd; + font-size: 12px; + padding: 4px 0; +} +.breadcrumbs > svg { + color: #5a5c62; +} +.breadcrumbs > span:not(.save-state) { + color: #888b90; + font-size: 12px; +} +.breadcrumbs > input { + border: 0; + background: none; + padding: 5px 2px; + font-weight: 500; + width: clamp(90px, 15vw, 205px); + text-overflow: ellipsis; + font-size: 12px; + outline: none; +} +.breadcrumbs > input:focus { + background: #202123; + border-radius: 4px; + outline: 1px solid #595169; +} +.save-state { + display: flex; + align-items: center; + gap: 5px; + color: #777f78; + font-size: 10px; + white-space: nowrap; +} +.save-state i, +.live-dot, +.muted-dot, +.home-footer i { + width: 5px; + height: 5px; + border-radius: 50%; + background: #6d9c7d; + display: inline-block; +} +.save-state.saving i { + background: #b3aa7b; + animation: pulse 1s infinite; +} +.save-state.offline { + color: #cfac7a; +} +.save-state.offline i { + background: #cfac7a; +} +.mode-tabs { + display: flex; + gap: 3px; + padding: 3px; + border: 1px solid #ffffff05; + border-radius: 9px; + position: absolute; + left: 50%; + transform: translateX(-50%); + background: #141516; +} +.mode-tabs .button { + font-size: 11px; + background: none; + height: 27px; + padding: 0 10px; + color: #808288; + gap: 6px; +} +.mode-tabs .button svg { + width: 13px; +} +.mode-tabs .button.active { + background: #2a2b2d; + color: #ededf3; + box-shadow: 0 2px 5px #0002; +} +.header-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 10px; +} +.header-actions > .primary { + height: 30px; + font-size: 11px; +} +.header-actions > .icon-button { + width: 27px; + height: 28px; +} +.header-actions > .icon-button svg { + width: 16px; +} +.workspace { + position: relative; + flex: 1; + min-height: 0; + isolation: isolate; + overflow: hidden; +} +.stage { + position: absolute; + inset: 0 12px 12px 0; + border-radius: 19px; + overflow: hidden; + background: #202623; + border: 1px solid #ffffff0d; +} +.stage-surface { + height: 100%; + width: 100%; + position: relative; + overflow: hidden; + transition: width 0.25s; + border-radius: inherit; +} +.player-host { + position: absolute; + inset: 0; + display: none; + background: #191d20; +} +.player-host.visible { + display: block; +} +.player-host > iframe { + border: 0; + width: 100%; + height: 100%; + display: block; +} +.runtime-loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + background: radial-gradient(ellipse at center, #252a2d, #191d20); + gap: 18px; +} +.runtime-loading h3 { + font-size: 18px; + font-weight: 500; + letter-spacing: -0.4px; +} +.runtime-loading p { + font-size: 12px; + line-height: 1.8; + color: #92979c; + text-align: center; +} +.runtime-loading button { + margin-top: 6px; +} +.spinner { + width: 30px; + height: 30px; + border: 2px solid #ffffff14; + border-top-color: #a99dfb; + border-radius: 50%; + animation: spin 1.2s linear infinite; +} +.stage.mobile-preview { + background: #1a1c1d; +} +.mobile-preview .stage-surface { + width: min(420px, 100%); + margin: auto; + border: 1px solid #ffffff16; + border-radius: 26px; +} +.panel { + border: 1px solid #ffffff0d; + background: #171819f5; + box-shadow: + 0 12px 50px #0004, + 0 1px 3px #0003; + border-radius: 15px; + backdrop-filter: blur(18px); + overflow: hidden; + z-index: 4; +} +.panel-heading { + height: 46px; + display: flex; + align-items: center; + padding: 0 13px 0 17px; + gap: 9px; + flex-shrink: 0; +} +.panel-heading h2 { + font-weight: 550; + font-size: 12px; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} +.panel-heading h2 > svg { + color: #92919b; +} +.panel-heading > .icon-button { + width: 23px; + height: 25px; +} +.panel-heading > .icon-button svg { + width: 14px; +} +.objects-panel { + position: absolute; + left: 22px; + top: 20px; + width: 282px; + max-height: 44%; + display: flex; + flex-direction: column; +} +.objects-panel.collapsed { + height: 46px; +} +.object-search { + display: flex; + gap: 7px; + padding: 2px 12px 12px; +} +.object-search > div { + display: flex; + align-items: center; + gap: 7px; + background: #ffffff08; + border-radius: 7px; + padding: 0 9px; + height: 30px; + flex: 1; + min-width: 0; +} +.object-search > div svg { + color: #65696b; +} +.object-search input { + width: 100%; + background: none; + outline: none; + border: 0; + color: #dadcdf; + font-size: 11px; +} +.object-search input::placeholder { + color: #777a7d; +} +.object-search > button { + background: #ffffff09; + border-radius: 8px; + width: 30px; + height: 30px; +} +.tree-heading { + display: flex; + align-items: center; + gap: 8px; + padding: 0 18px 9px; + font-size: 10px; + font-weight: 500; + color: #777c7c; +} +.tree-heading > span { + margin-left: auto; + color: #5c6262; +} +.entity-list { + padding: 0 9px 9px; + overflow-y: auto; + min-height: 50px; + max-height: calc(44dvh - 148px); +} +.entity-row { + display: flex; + align-items: center; + gap: 9px; + height: 31px; + padding: 0 11px; + width: 100%; + font-size: 11px; + border-radius: 6px; + text-align: left; + color: #c2c5c4; +} +.entity-row > svg { + color: #757c78; +} +.entity-row.selected { + background: #ffffff08; + color: #b4a8ff; +} +.entity-row.selected > svg { + color: #9d91ed; +} +.entity-row:hover { + background: #ffffff07; +} +.entity-row > span:first-of-type { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.selection-dot { + margin-left: auto; + background: #a394ff; + width: 4px; + height: 4px; + border-radius: 50%; +} +.panel-footnote { + font-size: 9px; + display: flex; + align-items: center; + gap: 7px; + padding: 11px 16px; + color: #666e69; + border-top: 1px solid #ffffff05; +} +.assistant-panel { + position: absolute; + bottom: 50px; + left: 22px; + width: 304px; + max-height: 46%; + display: flex; + flex-direction: column; +} +.assistant-panel .panel-heading { + height: 45px; +} +.assistant-state { + font-size: 9px; + color: #747875; + white-space: nowrap; +} +.assistant-star { + display: grid; + place-content: center; + color: #aa97fe; +} +.conversation { + overflow-y: auto; + min-height: 95px; + padding: 0 15px 10px; + flex: 1; +} +.assistant-welcome { + padding: 6px 1px 3px; +} +.assistant-welcome > .brand-mark { + margin: 2px 0 11px; +} +.assistant-welcome h3 { + font-size: 12px; + font-weight: 500; + color: #dbd9e1; + line-height: 1.5; +} +.assistant-welcome p { + margin-top: 7px; + font-size: 10.5px; + line-height: 1.65; + color: #8f9393; +} +.suggestions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 13px 0 6px; +} +.suggestions button { + border: 1px solid #ffffff13; + border-radius: 20px; + padding: 5px 8px; + font-size: 9px; + display: flex; + gap: 4px; + align-items: center; + color: #aaaeb0; +} +.suggestions button:hover { + background: #ffffff08; + border-color: #8d7dc249; +} +.composer { + margin: 0 10px 10px; + border: 1px solid #ffffff05; + background: #ffffff08; + border-radius: 11px; + overflow: hidden; + flex-shrink: 0; +} +.composer:focus-within { + border-color: #9f91df50; + box-shadow: 0 0 0 2px #a094e608; +} +.composer textarea { + width: 100%; + display: block; + resize: none; + border: 0; + background: none; + outline: none; + padding: 13px 12px 2px; + min-height: 67px; + height: 67px; + max-height: 180px; + font-size: 11px; + line-height: 1.7; +} +.composer textarea:focus-visible { + outline: none; +} +.composer textarea::placeholder { + color: #7e8186; +} +.composer-footer { + display: flex; + align-items: center; + padding: 5px 8px 7px; + gap: 1px; + height: 38px; +} +.composer-footer > .icon-button { + width: 24px; + height: 24px; +} +.composer-footer > .icon-button > svg { + width: 15px; +} +.model-picker { + display: flex; + align-items: center; + gap: 5px; + font-size: 9px; + color: #8a8c93; + min-width: 0; +} +.model-picker > span { + max-width: 135px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} +.model-picker > svg { + color: #9991c4; +} +.composer-spacer { + flex: 1; +} +.composer-footer > .send { + background: #6d5df4; + color: #e9e5ff; + width: 27px; + height: 27px; + border-radius: 8px; +} +.composer-footer > .send:hover { + background: #8778ff; +} +.composer-footer > .send:disabled { + opacity: 0.35; +} +.chat-message { + margin: 12px 0 16px; +} +.message-author { + font-size: 10px; + color: #99939f; + display: flex; + gap: 5px; + align-items: center; + margin-bottom: 5px; +} +.chat-message p { + font-size: 11px; + line-height: 1.75; + color: #c7cbcd; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.chat-message.user { + background: #ffffff06; + border-radius: 8px; + padding: 9px 10px; +} +.chat-message.user p { + color: #dbdce0; +} +.tool-activity { + border-top: 1px solid #ffffff0a; + padding: 7px 0 0; + display: flex; + flex-direction: column; + gap: 5px; +} +.tool-activity > span { + display: flex; + gap: 7px; + align-items: center; + font-size: 9px; + color: #929e93; +} +.context-chip { + display: flex; + align-items: center; + gap: 5px; + margin: 0 13px 7px; + color: #b3a6f5; + font-size: 9px; +} +.context-chip > button { + margin-left: auto; + color: #9d91b3; +} +.assistant-panel.collapsed { + height: 45px; +} +.inspector-panel { + position: absolute; + right: 34px; + top: 20px; + width: 265px; + max-height: calc(100% - 118px); + display: flex; + flex-direction: column; +} +.inspector-content { + overflow-y: auto; + padding: 0 16px 16px; +} +.field-label { + display: flex; + flex-direction: column; + gap: 7px; + font-size: 10px; + color: #909497; + margin-bottom: 14px; +} +.field-label > input, +.field-label > select, +.field-label > textarea { + height: 31px; + background: #ffffff05; + border: 1px solid #ffffff06; + border-radius: 6px; + padding: 0 9px; + font-size: 11px; + outline: none; + color: #cdd0d3; + width: 100%; +} +.field-label > input:focus, +.field-label > select:focus { + border-color: #9684e66e; +} +.component { + border-top: 1px solid #ffffff08; + margin-top: 18px; + padding-top: 12px; +} +.component h3 { + display: flex; + align-items: center; + gap: 7px; + color: #b2b6b9; + font-size: 10px; + font-weight: 550; + margin-bottom: 14px; + overflow: hidden; + overflow-wrap: anywhere; +} +.component h3 > svg { + color: #777b80; +} +.vector-input { + display: flex; + gap: 4px; +} +.vector-input > div { + display: flex; + align-items: center; + min-width: 0; + flex: 1; + background: #ffffff04; + border: 1px solid #ffffff05; + border-radius: 5px; + overflow: hidden; + height: 28px; +} +.vector-input span { + font-size: 9px; + font-weight: 500; + padding: 0 0 0 6px; + opacity: 0.7; +} +.vector-input input { + width: 100%; + min-width: 0; + border: 0; + outline: none; + background: none; + padding: 0 3px; + font-size: 10px; + text-align: center; + -moz-appearance: textfield; +} +.vector-input input::-webkit-inner-spin-button { + -webkit-appearance: none; +} +.axis-0 { + color: #de918c; +} +.axis-1 { + color: #a5c896; +} +.axis-2 { + color: #9bafdf; +} +.axis-3 { + color: #b09dc5; +} +.color-field > span { + background: #ffffff04; + border-radius: 5px; + display: flex; + align-items: center; + gap: 8px; + height: 30px; + padding: 0 6px; + color: #a3a6af; + font-size: 10px; +} +.color-field input { + height: 20px; + width: 24px; + background: none; + border: 0; + padding: 0; + cursor: pointer; +} +.check-field { + display: flex; + align-items: center; + justify-content: space-between; + color: #969da1; + font-size: 10px; + margin: 10px 0 15px; + gap: 15px; +} +.check-field input { + accent-color: #8b7def; +} +.text-button { + font-size: 11px; + display: flex; + gap: 7px; + align-items: center; + color: #9c9fa7; + padding: 3px 0; +} +.text-button:hover { + color: #cdc8ed; +} +.text-button.danger { + color: #ab8a89; + margin-top: 20px; + font-size: 10px; +} +.inspector-hint { + font-size: 9px; + color: #666e71; + line-height: 1.65; + display: block; + margin-top: 14px; +} +.stage-badge { + position: absolute; + top: 22px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 7px; + background: #12161594; + border: 1px solid #ffffff0b; + color: #9ba49e; + font-size: 9px; + padding: 7px 10px; + border-radius: 16px; + z-index: 3; + pointer-events: none; +} +.live-dot { + background: #9fc695; +} +.muted-dot { + background: #777e77; +} +.viewport-controls { + position: absolute; + top: 20px; + right: 34px; + z-index: 3; + display: flex; + background: #171a189c; + border: 1px solid #ffffff0c; + border-radius: 8px; + padding: 3px; +} +.inspector-panel ~ .viewport-controls { + display: none; +} +.viewport-controls .icon-button { + height: 25px; + width: 29px; +} +.viewport-controls svg { + width: 14px; +} +.viewport-toolbar { + position: absolute; + bottom: 37px; + left: 50%; + transform: translateX(-50%); + z-index: 6; + display: flex; + align-items: center; + background: #181a19ef; + border: 1px solid #ffffff0d; + border-radius: 10px; + padding: 5px 7px; + gap: 3px; + box-shadow: 0 6px 30px #0003; +} +.viewport-toolbar .icon-button { + width: 30px; + height: 29px; +} +.viewport-toolbar .icon-button svg { + width: 15px; +} +.viewport-toolbar .icon-button.active { + color: #c3b5ff; + background: #5146663b; +} +.divider { + width: 1px; + height: 14px; + background: #ffffff10; + margin: 0 3px; +} +.viewport-caption { + position: absolute; + bottom: 19px; + right: 37px; + color: #8d988b; + font-size: 9px; + pointer-events: none; + display: flex; + gap: 12px; + align-items: center; +} +.viewport-caption kbd { + font: 8px inherit; + opacity: 0.5; +} +.code-panel { + position: absolute; + inset: 20px 34px 50px 22px; + z-index: 5; + display: flex; + flex-direction: column; + border-radius: 12px; + background: #191a1c; +} +.file-tabs { + display: flex; + height: 43px; + border-bottom: 1px solid #ffffff09; + gap: 0; + flex-shrink: 0; + padding-right: 8px; +} +.file-tabs > button:not(.icon-button) { + display: flex; + align-items: center; + gap: 8px; + padding: 0 17px; + border-right: 1px solid #ffffff07; + font-size: 11px; + color: #838995; +} +.file-tabs > button.active { + color: #d7d3e9; + background: #ffffff04; + border-top: 1px solid #9c8aea; +} +.file-tabs > span { + flex: 1; +} +.file-tabs .icon-button { + align-self: center; +} +.code-body { + display: flex; + flex: 1; + min-height: 0; + overflow: auto; + padding-top: 19px; +} +.line-numbers { + display: flex; + flex-direction: column; + align-items: flex-end; + line-height: 23px; + font: + 12px/23px Consolas, + monospace; + color: #4f5861; + min-width: 53px; + padding-right: 17px; + user-select: none; +} +.code-body > textarea { + width: 100%; + resize: none; + background: none; + border: 0; + outline: none; + padding: 0 25px 20px 5px; + color: #c6cfbf; + line-height: 23px; + font: + 12px/23px Consolas, + monospace; + tab-size: 2; + white-space: pre; + overflow: auto; + caret-color: #c9b8ff; +} +.code-body > textarea:focus-visible { + outline: none; +} +.code-status { + display: flex; + gap: 25px; + height: 29px; + align-items: center; + padding: 0 17px; + border-top: 1px solid #ffffff08; + font-size: 9px; + color: #7d8891; +} +.code-status > span:last-child { + margin-left: auto; +} +.assets-panel { + position: absolute; + bottom: 92px; + left: 350px; + right: 34px; + max-height: 330px; + z-index: 7; +} +.asset-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(95px, 1fr)); + gap: 8px; + padding: 0 15px 15px; + overflow: auto; + max-height: 270px; +} +.asset-grid > button { + border: 1px solid #ffffff08; + background: #ffffff03; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 13px 8px; + min-width: 0; +} +.asset-grid > button > img { + width: 55px; + height: 50px; + object-fit: contain; +} +.asset-grid > button > svg { + color: #727e76; + margin: 10px; +} +.asset-grid > button > span { + font-size: 10px; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.asset-grid > button > small { + font-size: 9px; + color: #6e7774; +} +.asset-grid > button:hover { + background: #ffffff09; +} +.home { + flex: 1; + min-height: 0; + overflow: auto; + padding: 21px max(40px, calc((100vw - 1200px) / 2)); + position: relative; + background: + radial-gradient(ellipse 47% 48% at 63% 10%, #40304e18, transparent), #121314; +} +.home-top { + display: flex; + justify-content: space-between; + align-items: center; +} +.eyebrow { + color: #88828e; + font-size: 9px; + letter-spacing: 2px; + font-weight: 600; +} +.home-version { + font-size: 10px; + color: #666a6d; +} +.home-hero { + max-width: 740px; + margin: 51px auto 40px; + position: relative; +} +.home-hero > .eyebrow { + color: #a19aaf; + font-size: 8px; +} +.home h1 { + font-size: clamp(34px, 3.6vw, 52px); + font-weight: 450; + letter-spacing: -2.1px; + line-height: 1.12; + margin: 15px 0; +} +.home h1 em { + font-style: normal; + color: #c3b5df; +} +.home-hero > p { + font-size: 12px; + line-height: 1.9; + color: #8b8d96; + margin: 18px 0 25px; +} +.home-composer { + margin: 0; + border-radius: 14px; + background: #202123; + border: 1px solid #ffffff0e; + position: relative; + z-index: 1; +} +.home-composer textarea { + height: 64px; + padding: 16px 17px 0; + font-size: 12px; +} +.home-composer .composer-footer { + padding: 7px 12px 11px; + height: 42px; +} +.home-composer .model-picker > span { + max-width: 300px; + font-size: 10px; +} +.home-composer .send { + height: 30px; + width: 30px; +} +.hero-orbit { + position: absolute; + right: 20px; + top: -5px; + width: 260px; + height: 217px; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; + transform: rotate(-17deg); + color: #d1c0ed; +} +.hero-orbit > i { + position: absolute; + border: 1px solid #aa8cd91d; + border-radius: 50%; + width: 220px; + height: 220px; + transform: rotateX(60deg); +} +.hero-orbit > i:nth-child(2) { + transform: rotateY(60deg); + width: 210px; + height: 210px; +} +.hero-orbit > i:nth-child(3) { + transform: rotateY(-35deg) rotateX(40deg); + border-color: #aa8cd930; +} +.hero-orbit .brand-mark { + width: 72px; + height: auto; + transform: rotate(17deg); + filter: drop-shadow(0 0 26px #bc9fe066); +} +.section-heading { + display: flex; + justify-content: space-between; + align-items: center; + margin: 28px 0 16px; + gap: 20px; +} +.section-heading > h2 { + font-size: 12px; + font-weight: 500; + color: #d1d0d6; + display: flex; + align-items: center; + gap: 9px; +} +.section-heading h2 > span { + font-size: 9px; + border-radius: 8px; + background: #ffffff08; + color: #898d95; + padding: 2px 6px; +} +.section-heading .text-button { + font-size: 10px; + color: #7f838b; +} +.template-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px; +} +.template-card { + border-radius: 12px; + background: #1a1c1d; + border: 1px solid #ffffff08; + overflow: hidden; + text-align: left; + padding: 0; + transition: + transform 0.17s, + border-color 0.17s; +} +.template-card:hover { + transform: translateY(-2px); + border-color: #a494c645; +} +.template-art { + height: 116px; + position: relative; + overflow: hidden; +} +.landscape .template-art { + background: linear-gradient(150deg, #818d7f, #636d59 50%, #4d5747); +} +.hill { + position: absolute; + background: #647450; + transform: rotate(-14deg); + border-radius: 55%; +} +.h1 { + width: 120%; + height: 130%; + top: 65%; + left: -30%; +} +.h2 { + width: 120%; + height: 90%; + top: 50%; + right: -60%; + background: #84916b; +} +.h3 { + width: 90%; + height: 90%; + top: 85%; + left: 40%; + background: #b1a27b; +} +.little-tree { + position: absolute; + bottom: 20px; + width: 15px; + height: 37px; + border-radius: 80% 80% 40% 40%; + background: #364835; + box-shadow: 0 7px 0 -5px #3a342a; +} +.t1 { + left: 14%; + bottom: 22%; + transform: scale(1.3); +} +.t2 { + left: 23%; + bottom: 18%; + transform: scale(0.65); +} +.t3 { + right: 12%; + bottom: 20%; + transform: scale(1.15); +} +.temple { + position: absolute; + left: 60%; + bottom: 20px; + display: flex; + gap: 11px; + transform: skewY(-7deg); +} +.temple i { + display: block; + width: 6px; + height: 26px; + background: #cec7ac; + box-shadow: 1px 0 #969c80; +} +.twod .template-art { + background: linear-gradient(150deg, #34354d, #484961 50%, #56516a); +} +.platform { + position: absolute; + width: 77px; + height: 10px; + border-radius: 3px; + background: #8d859e; + box-shadow: 0 5px #343644; +} +.p1 { + bottom: 18px; + left: 17%; +} +.p2 { + bottom: 52px; + left: 52%; + width: 90px; +} +.p3 { + bottom: 15px; + right: -18px; + width: 90px; +} +.pixel-player { + width: 17px; + height: 21px; + background: #d8c5dd; + position: absolute; + bottom: 29px; + left: 27%; + border-radius: 4px 4px 2px 2px; + box-shadow: 0 -5px #e7dce9; +} +.star { + position: absolute; + color: #c7b4d1; + font-style: normal; + font-size: 16px; +} +.s1 { + top: 24px; + left: 25%; +} +.s2 { + top: 10px; + left: 59%; + font-size: 12px; +} +.s3 { + top: 43px; + right: 8%; + font-size: 9px; +} +.template-card > div:last-child { + padding: 14px 17px; + position: relative; +} +.template-card > div:last-child > span { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 500; + color: #d9d7df; +} +.template-card > div:last-child > span > svg { + color: #97909f; + width: 14px; +} +.template-card small { + display: block; + color: #797f83; + font-size: 10px; + margin-top: 7px; +} +.template-card > div:last-child > svg { + position: absolute; + right: 16px; + top: 24px; + color: #777a84; + width: 15px; +} +.project-list { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 9px; +} +.project-list > button { + display: flex; + align-items: center; + gap: 13px; + border: 1px solid #ffffff07; + border-radius: 10px; + padding: 12px 14px; + text-align: left; + min-width: 0; + background: #ffffff01; +} +.project-list > button:hover { + background: #ffffff04; + border-color: #ffffff12; +} +.project-icon { + width: 43px; + height: 43px; + border-radius: 9px; + background: #2a332c; + display: grid; + place-content: center; + color: #7e947b; + flex-shrink: 0; +} +.project-icon.shade-1 { + background: #302e3b; + color: #a092b9; +} +.project-icon.shade-2 { + background: #343229; + color: #a39b75; +} +.project-list > button > div:nth-child(2) { + min-width: 0; +} +.project-list strong { + font-size: 11px; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 250px; +} +.project-list small { + display: block; + font-size: 9px; + color: #6d7478; + margin-top: 7px; +} +.project-list > button > svg { + margin-left: auto; + color: #545b5d; + width: 12px; +} +.empty-projects { + border: 1px dashed #ffffff0c; + border-radius: 10px; + padding: 24px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + color: #666d70; + gap: 9px; +} +.empty-projects > svg { + width: 24px; + color: #515a5b; +} +.empty-projects > p { + font-size: 11px; +} +.empty-projects > span { + font-size: 10px; + color: #555c60; +} +.home-footer { + display: flex; + justify-content: space-between; + font-size: 9px; + color: #535b60; + gap: 20px; + margin: 28px 0 8px; +} +.home-footer > span:first-child { + display: flex; + align-items: center; + gap: 6px; +} +.home-footer i { + width: 4px; + height: 4px; + background: #6e7b68; +} +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 50; + background: #05060a99; + backdrop-filter: blur(7px); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} +.modal { + width: 420px; + max-height: 85dvh; + overflow: auto; + border-radius: 16px; + background: #1b1c1f; + border-color: #ffffff12; +} +.modal .panel-heading { + height: 56px; + padding: 0 17px 0 23px; + border-bottom: 1px solid #ffffff07; +} +.modal .panel-heading h2 { + font-size: 14px; +} +.modal-body { + padding: 22px; +} +.muted { + color: #8e919c; + font-size: 11px; + line-height: 1.8; + margin-bottom: 20px; +} +.segmented { + display: flex; + background: #121315; + border-radius: 7px; + padding: 3px; + margin-bottom: 23px; +} +.segmented button { + flex: 1; + height: 31px; + font-size: 11px; + border-radius: 5px; + color: #777c87; +} +.segmented button.active { + color: #d1c9f3; + background: #33303f; +} +.modal .field-label { + font-size: 11px; + margin-bottom: 19px; +} +.modal .field-label > input, +.modal .field-label > select { + height: 37px; + font-size: 12px; + background: #151619; + border-color: #ffffff0a; +} +.fine-print { + font-size: 10px; + line-height: 1.7; + color: #787d85; + margin: -6px 0 20px; +} +.history-list { + display: flex; + flex-direction: column; + margin-top: 20px; + gap: 7px; + max-height: 330px; + overflow: auto; +} +.history-list > button { + display: flex; + align-items: center; + gap: 13px; + padding: 13px 10px; + text-align: left; + border-radius: 8px; + background: #ffffff03; +} +.history-list > button:hover { + background: #ffffff06; +} +.history-list strong { + display: block; + font-size: 11px; +} +.history-list small { + display: block; + font-size: 9px; + margin-top: 6px; + color: #747e86; +} +.history-list > button > svg { + color: #9a94ab; +} +.history-list > button > svg:last-child { + margin-left: auto; +} +.toast { + position: fixed; + bottom: 22px; + left: 50%; + transform: translateX(-50%); + z-index: 100; + max-width: min(670px, 95vw); + display: flex; + align-items: center; + gap: 17px; + padding: 12px 12px 12px 19px; + border: 1px solid #dfb4a528; + background: #282328f5; + backdrop-filter: blur(20px); + border-radius: 10px; + box-shadow: 0 10px 35px #0005; + color: #d6bab6; + font-size: 12px; + line-height: 1.7; +} +.toast > .icon-button { + width: 24px; + height: 24px; +} +::-webkit-scrollbar { + width: 4px; + height: 4px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: #ffffff16; + border-radius: 10px; +} +::selection { + background: #a594e34f; + color: #fff; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +@keyframes pulse { + 50% { + opacity: 0.3; + } +} +@media (min-width: 1800px) { + .home { + padding-left: max(80px, calc((100vw - 1240px) / 2)); + padding-right: max(100px, calc((100vw - 1240px) / 2)); + } + .home-hero { + margin-top: 80px; + margin-bottom: 55px; + } + .template-art { + height: 145px; + } + .objects-panel { + width: 294px; + } + .assistant-panel { + width: 320px; + } +} +@media (max-width: 1150px) { + .save-state { + font-size: 0; + gap: 0; + } + .mode-tabs { + position: static; + transform: none; + margin-left: auto; + } + .header-actions { + margin-left: 0; + gap: 3px; + } + .breadcrumbs > input { + width: 130px; + } + .header-actions > .primary { + padding: 0 10px; + } + .topbar { + padding: 0 14px; + } + .objects-panel { + width: 248px; + left: 16px; + } + .assistant-panel { + width: 270px; + left: 16px; + } + .inspector-panel { + right: 24px; + width: 232px; + } + .viewport-caption { + display: none; + } + .home { + padding: 20px 45px; + } + .hero-orbit { + right: 20px; + opacity: 0.8; + transform: scale(0.85) rotate(-17deg); + } +} +@media (max-height: 780px) { + .assistant-panel { + bottom: 36px; + max-height: 43%; + } + .objects-panel { + top: 16px; + max-height: 44%; + } + .assistant-welcome > .brand-mark { + display: none; + } + .assistant-welcome h3 { + font-size: 11px; + } + .assistant-welcome p { + font-size: 10px; + } + .assistant-welcome .suggestions { + margin-top: 9px; + } + .assistant-panel .composer textarea { + height: 52px; + min-height: 52px; + } + .assistant-state { + display: none; + } + .panel-footnote { + display: none; + } + .conversation { + min-height: 70px; + } + .home-hero { + margin-top: 20px; + } + .hero-orbit { + transform: scale(0.8) rotate(-17deg); + } +} +@media (max-width: 780px) { + .rail { + width: 47px; + } + .rail .icon-button { + width: 32px; + height: 32px; + } + .topbar { + height: 55px; + padding: 0 10px; + gap: 6px; + } + .breadcrumbs { + gap: 5px; + } + .breadcrumbs > button, + .breadcrumbs > svg { + display: none; + } + .breadcrumbs > input { + width: 110px; + font-size: 11px; + } + .mode-tabs { + gap: 0; + border: 0; + background: none; + padding: 0; + } + .mode-tabs .button { + padding: 0 7px; + height: 29px; + font-size: 0; + gap: 0; + } + .mode-tabs .button > svg { + width: 16px; + } + .header-actions > .primary { + font-size: 0; + gap: 0; + width: 31px; + padding: 0; + } + .header-actions > .primary > svg { + width: 14px; + } + .header-actions > .icon-button { + width: 25px; + } + .header-actions > .icon-button:last-child { + display: none; + } + .stage { + inset: 0 8px 8px 0; + border-radius: 13px; + } + .objects-panel { + width: 220px; + top: 13px; + left: 12px; + } + .assistant-panel { + width: 260px; + left: 12px; + bottom: 25px; + } + .inspector-panel { + right: 19px; + top: 13px; + width: 225px; + z-index: 6; + } + .viewport-toolbar { + bottom: 23px; + left: auto; + right: 20px; + transform: none; + padding: 4px; + } + .viewport-toolbar .icon-button { + width: 25px; + height: 25px; + } + .viewport-toolbar .divider { + display: none; + } + .viewport-controls { + right: 20px; + top: 13px; + } + .stage-badge { + display: none; + } + .home { + padding: 15px 25px; + } + .home-top > .home-version { + display: none; + } + .home-hero { + margin: 25px 0 30px; + } + .home h1 { + font-size: 40px; + } + .hero-orbit { + right: -25px; + top: 10px; + transform: scale(0.6) rotate(-17deg); + opacity: 0.5; + } + .home-composer { + margin-top: 24px; + } + .home .template-grid { + gap: 10px; + } + .template-card > div:last-child { + padding: 12px; + } + .template-card small { + font-size: 9px; + line-height: 1.6; + padding-right: 10px; + } + .template-card > div:last-child > svg { + display: none; + } + .project-list { + grid-template-columns: 1fr; + } + .home-footer { + display: block; + line-height: 2; + } + .home-footer > span:last-child { + display: none; + } + .assets-panel { + left: 12px; + right: 19px; + bottom: 80px; + } + .code-panel { + inset: 13px 19px 65px 12px; + } + .panel-heading h2 { + font-size: 11px; + } + .assistant-state { + display: none; + } +} +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + animation-duration: 0.01ms !important; + } +} +.source-editor { + width: 100%; + min-width: 0; + height: 100%; + overflow: hidden; +} +.code-body { + display: block; + min-height: 0; + overflow: hidden; +} +.viewport-help { + position: absolute; + right: 18px; + bottom: 90px; + z-index: 30; + width: 355px; + max-height: calc(100% - 120px); + overflow: auto; + padding: 16px; +} +.viewport-help dl { + display: grid; + grid-template-columns: 120px 1fr; + gap: 12px; + font-size: 11px; + line-height: 1.5; + margin: 16px 0 0; +} +.viewport-help dt { + color: #edf0f5; +} +.viewport-help dd { + margin: 0; + color: #9aa6b8; +} +.viewport-toolbar { + max-width: calc(100% - 32px); + overflow-x: auto; +} +.object-actions { + display: flex; + gap: 8px; + margin-bottom: 14px; +} +.object-actions .button { + flex: 1; + justify-content: center; + font-size: 11px; + padding: 7px; +} diff --git a/Editor/WebUI/src/ui/assistant.jsx b/Editor/WebUI/src/ui/assistant.jsx new file mode 100644 index 000000000..092fefa94 --- /dev/null +++ b/Editor/WebUI/src/ui/assistant.jsx @@ -0,0 +1,237 @@ +import React from "react"; +import { studio } from "./state.js"; +import { useRef, useEffect, useState } from "react"; +import { Icon, Mark, Button, act, entityIcon } from "./controls.jsx"; + +function Assistant() { + const s = studio, + [collapsed, setCollapsed] = useState(false), + chat = useRef(); + useEffect(() => { + if (chat.current) chat.current.scrollTop = chat.current.scrollHeight; + }, [s.streamText, s.snapshot.messages.length, s.running]); + const messages = s.snapshot.messages.filter( + (m) => + m.role !== "tool" && + m.content && + !m.content.startsWith("Current game frame"), + ); + return ( +
+
+

+ + + + Ассистент +

+ + {s.running ? "Создаёт…" : "Рядом с вами"} + +
+ {!collapsed && ( + <> +
+ {!messages.length ? ( +
+ +

Давайте создадим что-то ваше

+

+ Расскажите об игре или выберите объект, +
+ который хотите изменить. +

+
+ {[ + "Добавь цель игры", + "Улучши окружение", + "Что можно сделать дальше?", + ].map((t) => ( + + ))} +
+
+ ) : ( + messages.map((m) => ( +
+
+ {m.role === "user" ? ( + "Вы" + ) : ( + <> + Ada + + )} +
+

+ {m.content.replace( + /\n\[Selected (scene )?entity\][\s\S]*/, + "", + )} +

+
+ )) + )} + {s.running && ( +
+
+ Ada +
+

{s.streamText || "Обдумываю изменения…"}

+
+ )} + {s.activity.length > 0 && ( +
+ {s.activity.slice(-3).map((a, i) => ( + + + {toolLabel(a.name)} + + ))} +
+ )} +
+ {s.selected && ( +
+ В контексте: {s.entity?.name} + +
+ )} + + + )} +
+ ); +} +const toolLabel = (name) => + ({ + editor_scene_get: "Изучаю сцену", + editor_file_write: "Обновляю код", + editor_scene_set: "Настраиваю объект", + editor_build: "Проверяю в AdaPlayer", + editor_play_start: "Запускаю игру", + editor_play_capture: "Смотрю на результат", + })[name] || "Работаю с проектом"; +function Composer({ home = false }) { + const s = studio, + attach = useRef(); + return ( +
{ + e.preventDefault(); + s.action(() => s.send(s.prompt)); + }} + > +