diff --git a/.agents/skills/stage-tamagotchi-godot-csharp/SKILL.md b/.agents/skills/stage-tamagotchi-godot-csharp/SKILL.md new file mode 100644 index 000000000..ef8b991c4 --- /dev/null +++ b/.agents/skills/stage-tamagotchi-godot-csharp/SKILL.md @@ -0,0 +1,57 @@ +--- +name: stage-tamagotchi-godot-csharp +description: >- + Apply engine-local C# development method and code style only when working in + `engines/stage-tamagotchi-godot`, including its `.cs` files, `.csproj`, + engine-local `.editorconfig`, and Godot-specific C# structure decisions. Do + not use for TypeScript, Electron, renderer code, shared workspace config, + repo-wide C# conventions, or any file outside + `engines/stage-tamagotchi-godot`. +--- + +# Stage Tamagotchi Godot C# + +1. Confirm every touched file is under `engines/stage-tamagotchi-godot`. + If the task crosses that boundary, do not use this skill as the governing + instruction set. +2. Before editing C# files, read: + - `engines/stage-tamagotchi-godot/docs/csharp-development-method.md` + - `engines/stage-tamagotchi-godot/.editorconfig` + - `engines/stage-tamagotchi-godot/docs/csharp-style.md` +3. Treat the development-method document as the primary source of truth for + structure and feature usage. Treat `.editorconfig` and `csharp-style.md` as + secondary formatting and naming guidance. +4. Classify the change before coding: + - scene script + - runtime core + - contract and transport + - registry and discovery + - tooling and editor support +5. Apply the local design method: + - keep scene scripts thin + - push durable logic into plain C# runtime objects + - make subsystem boundaries explicit through types + - use reflection for discovery, not steady-state execution + - use LINQ for cold-path querying and shaping, not hot-path loops + - use async at I/O and process boundaries, not as a default runtime model +6. Apply the local low-level style baseline: + - `engines/stage-tamagotchi-godot/.editorconfig` + - 4 spaces, LF, UTF-8, 100 columns + - Allman braces + - `System.*` usings first + - keyword types such as `string` and `int` + - `var` only when the type is obvious + - `PascalCase` for types and members + - `camelCase` for locals and parameters + - `_camelCase` for private fields +7. Keep changes local to the engine. Do not push these C# rules into repo + root config or other workspaces. +8. After changing C# files or the engine-local `.editorconfig`, run the + verification command from `engines/stage-tamagotchi-godot`: + +```powershell +dotnet format --verify-no-changes +``` + +If verification fails because of pre-existing files outside the intended change +scope, report that clearly instead of broadening the edit set silently. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54da736c4..26fa7526f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,8 +92,9 @@ jobs: if: matrix.app_name == 'stage-tamagotchi-godot' uses: chickensoft-games/setup-godot@v2 with: - version: 4.6.1 + version: 4.6.2 use-dotnet: true + include-templates: true - run: pnpm install --frozen-lockfile - run: pnpm run build:packages @@ -101,6 +102,13 @@ jobs: - name: Build App run: ${{ matrix.command }} + - name: Export Godot Linux sidecar + if: matrix.app_name == 'stage-tamagotchi-godot' + working-directory: ./engines/stage-tamagotchi-godot + run: | + mkdir -p build/linux + godot --headless --export-release "Linux" build/linux/godot-stage + typecheck: name: Type Check runs-on: ubuntu-latest @@ -133,4 +141,4 @@ jobs: # base-ref: origin/main # optional, default: origin/main # fail-on-downgrade: true # optional, default: true - name: Print result - run: "echo 'Downgraded: ${{ steps.check.outputs.downgraded }}'" \ No newline at end of file + run: "echo 'Downgraded: ${{ steps.check.outputs.downgraded }}'" diff --git a/apps/stage-tamagotchi/electron-builder.config.ts b/apps/stage-tamagotchi/electron-builder.config.ts index 535c7ad86..342dc75d5 100644 --- a/apps/stage-tamagotchi/electron-builder.config.ts +++ b/apps/stage-tamagotchi/electron-builder.config.ts @@ -97,6 +97,13 @@ export default { asarUnpack: [ '**/*.node', ], + extraResources: [ + { + from: '../../engines/stage-tamagotchi-godot/build/${os}', + to: 'godot-stage', + filter: ['**/*'], + }, + ], extraMetadata: { name: 'ai.moeru.airi', main: 'out/main/index.js', diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index acf3e30c1..3c295efd5 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -26,6 +26,7 @@ import { setElectronMainDirname } from './libs/electron/location' import { createI18n } from './libs/i18n' import { createWindowAuthManagerService } from './services/airi/auth' import { setupServerChannel } from './services/airi/channel-server' +import { setupGodotStageManager } from './services/airi/godot-stage' import { setupBuiltInServer } from './services/airi/http-server' import { setupMcpStdioManager } from './services/airi/mcp-servers' import { setupPluginHost } from './services/airi/plugins' @@ -134,6 +135,10 @@ app.whenReady().then(async () => { build: async () => setupBuiltInServer({ servers: [] }), }) + const godotStageManager = injeca.provide('modules:godot-stage-manager', { + build: async () => setupGodotStageManager(), + }) + const mcpStdioManager = injeca.provide('modules:mcp-stdio-manager', { build: async () => setupMcpStdioManager(), }) @@ -176,12 +181,12 @@ app.whenReady().then(async () => { }) const settingsWindow = injeca.provide('windows:settings', { - dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow, serverChannel, mcpStdioManager, i18n, windowAuthManager }, + dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, windowAuthManager }, build: async ({ dependsOn }) => setupSettingsWindowReusableFunc(dependsOn), }) const mainWindow = injeca.provide('windows:main', { - dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, mcpStdioManager, i18n, onboardingWindowManager, windowAuthManager }, + dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater, serverChannel, godotStageManager, mcpStdioManager, i18n, onboardingWindowManager, windowAuthManager }, build: async ({ dependsOn }) => setupMainWindow(dependsOn), }) @@ -212,7 +217,7 @@ app.whenReady().then(async () => { } injeca.invoke({ - dependsOn: { mainWindow, tray, serverChannel, airiHttpServer, pluginHost, mcpStdioManager, onboardingWindow: onboardingWindowManager, widgetsWindow: widgetsManager, artistryConfig }, + dependsOn: { mainWindow, tray, serverChannel, airiHttpServer, godotStageManager, pluginHost, mcpStdioManager, onboardingWindow: onboardingWindowManager, widgetsWindow: widgetsManager, artistryConfig }, callback: async (deps) => { const { context } = createContext(ipcMain) await setupArtistryBridge({ diff --git a/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.test.ts new file mode 100644 index 000000000..93801af06 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.test.ts @@ -0,0 +1,310 @@ +import { EventEmitter } from 'node:events' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +interface TestWebSocketMessage { + text: () => string +} + +interface TestWebSocketPeer { + close: ReturnType + id: string + request: { + url?: string + } + send: ReturnType +} + +interface TestWebSocketHooks { + close?: (peer: TestWebSocketPeer) => void + message?: (peer: TestWebSocketPeer, message: TestWebSocketMessage) => void + open?: (peer: TestWebSocketPeer) => void +} + +const appMock = vi.hoisted(() => ({ + getPath: vi.fn((name: string) => `/tmp/airi/${name}`), + isPackaged: false, +})) + +const serverState = vi.hoisted(() => ({ + close: vi.fn(async () => {}), + serve: vi.fn(async () => {}), + webSocketHooks: undefined as TestWebSocketHooks | undefined, +})) + +const spawnMock = vi.hoisted(() => vi.fn()) + +const logMock = vi.hoisted(() => { + const logger = { + debug: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + withError: vi.fn(), + withFields: vi.fn(), + } + logger.withError.mockReturnValue(logger) + logger.withFields.mockReturnValue(logger) + return logger +}) + +vi.mock('electron', () => ({ + app: appMock, +})) + +vi.mock('node:child_process', () => ({ + spawn: spawnMock, +})) + +vi.mock('node:fs/promises', () => ({ + access: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), + stat: vi.fn(async () => ({ isFile: () => true })), + writeFile: vi.fn(async () => {}), +})) + +vi.mock('@guiiai/logg', () => ({ + useLogg: () => ({ + useGlobalConfig: () => logMock, + }), +})) + +vi.mock('crossws/server', () => ({ + plugin: vi.fn(() => ({})), +})) + +vi.mock('get-port-please', () => ({ + getRandomPort: vi.fn(async () => 48123), +})) + +vi.mock('h3', () => ({ + H3: class { + get = vi.fn() + }, + defineWebSocketHandler: vi.fn((hooks: TestWebSocketHooks) => { + serverState.webSocketHooks = hooks + return hooks + }), + serve: vi.fn(() => ({ + close: serverState.close, + serve: serverState.serve, + })), +})) + +vi.mock('../../../libs/bootkit/lifecycle', () => ({ + onAppBeforeQuit: vi.fn(), +})) + +vi.mock('../../../libs/electron/location', () => ({ + getElectronMainDirname: () => '/tmp/airi/out/main', +})) + +function createFakeGodotProcess() { + const processHandle = new EventEmitter() as EventEmitter & { + kill: ReturnType + pid: number + stderr: EventEmitter + stdout: EventEmitter + } + + processHandle.pid = 4321 + processHandle.stdout = new EventEmitter() + processHandle.stderr = new EventEmitter() + processHandle.kill = vi.fn(() => { + queueMicrotask(() => processHandle.emit('close', null, 'SIGTERM')) + return true + }) + + return processHandle +} + +function createTestPeer(url: string): TestWebSocketPeer { + return { + id: 'godot-test-peer', + request: { url }, + send: vi.fn(), + close: vi.fn(), + } +} + +function readSpawnedWebSocketUrl() { + const spawnArgs = spawnMock.mock.calls.at(-1)?.[1] + if (!Array.isArray(spawnArgs)) { + throw new TypeError('Expected Godot spawn arguments to be recorded.') + } + + const websocketArgument = spawnArgs.find((arg): arg is string => ( + typeof arg === 'string' && arg.startsWith('--airi-ws-url=') + )) + if (!websocketArgument) { + throw new Error('Expected Godot spawn arguments to include --airi-ws-url.') + } + + return websocketArgument.slice('--airi-ws-url='.length) +} + +async function waitForSpawnedGodotProcess() { + await waitForSpawnedGodotProcessCount(1) +} + +async function waitForSpawnedGodotProcessCount(expectedCount: number) { + for (let attempt = 0; attempt < 100; attempt++) { + if (spawnMock.mock.calls.length >= expectedCount) { + return + } + + await Promise.resolve() + } + + throw new Error('Expected Godot process to be spawned.') +} + +async function startRunningGodotStage() { + const { createGodotStageManager } = await import('./index') + const manager = createGodotStageManager() + const startPromise = manager.start() + + await waitForSpawnedGodotProcess() + + const peer = createTestPeer(readSpawnedWebSocketUrl()) + serverState.webSocketHooks?.open?.(peer) + serverState.webSocketHooks?.message?.(peer, { + text: () => JSON.stringify({ type: 'stage.ready' }), + }) + + await startPromise + + return { + manager, + peer, + } +} + +describe('createGodotStageManager lifecycle cleanup', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + appMock.isPackaged = false + serverState.webSocketHooks = undefined + delete process.env.GODOT4 + }) + + it('closes the websocket runtime when dev-mode Godot binary resolution fails', async () => { + // ROOT CAUSE: + // + // `start()` creates the websocket runtime before resolving the Godot binary. + // If `GODOT4` is missing, binary resolution throws and previously left the + // websocket server alive until the next start attempt or app quit. + const { createGodotStageManager } = await import('./index') + const manager = createGodotStageManager() + + await expect(manager.start()).rejects.toThrow('GODOT4 is required') + expect(serverState.close).toHaveBeenCalledWith(true) + expect(manager.getStatus()).toMatchObject({ + state: 'error', + pid: null, + lastError: expect.stringContaining('GODOT4 is required'), + }) + }) + + it('kills the Godot process and closes the websocket runtime when startup readiness times out', async () => { + // ROOT CAUSE: + // + // If Godot starts but never sends `stage.ready`, `start()` rejects after the + // readiness timeout. The startup transaction must still release the process + // and websocket runtime created for that failed attempt. + vi.useFakeTimers() + process.env.GODOT4 = '/tmp/godot' + + const processHandle = createFakeGodotProcess() + spawnMock.mockReturnValue(processHandle) + + const { createGodotStageManager } = await import('./index') + const manager = createGodotStageManager() + const startPromise = manager.start() + const startExpectation = expect(startPromise).rejects.toThrow('Godot stage did not report ready in time.') + + await vi.advanceTimersByTimeAsync(20_000) + + await startExpectation + expect(processHandle.kill).toHaveBeenCalled() + expect(serverState.close).toHaveBeenCalledWith(true) + expect(manager.getStatus()).toMatchObject({ + state: 'error', + pid: null, + lastError: expect.stringContaining('Godot stage did not report ready in time.'), + }) + }) + + it('closes the websocket runtime when stop fails while force-killing Godot', async () => { + // ROOT CAUSE: + // + // `stop()` can enter the force-kill path after waiting for graceful shutdown. + // Cleanup must not depend on that branch completing successfully; the + // websocket runtime belongs to the stopping session and must be released. + vi.useFakeTimers() + process.env.GODOT4 = '/tmp/godot' + + const processHandle = createFakeGodotProcess() + processHandle.kill.mockImplementation(() => { + throw new Error('kill failed') + }) + spawnMock.mockReturnValue(processHandle) + + const { manager } = await startRunningGodotStage() + const stopPromise = manager.stop() + const stopExpectation = expect(stopPromise).rejects.toThrow('kill failed') + + await vi.advanceTimersByTimeAsync(2_000) + + await stopExpectation + expect(serverState.close).toHaveBeenCalledWith(true) + expect(manager.getStatus()).toMatchObject({ + state: 'error', + pid: processHandle.pid, + lastError: expect.stringContaining('kill failed'), + }) + }) + + it('does not spawn a second process while a failed startup process is still shutting down', async () => { + // ROOT CAUSE: + // + // A timed-out startup kills the old Godot process, but only waits a bounded + // 2 seconds for its close event. A retry can start a new process before the + // old process emits close. The retry must not spawn another child process + // while the previous process is still tracked by the manager. + vi.useFakeTimers() + process.env.GODOT4 = '/tmp/godot' + + const staleProcess = createFakeGodotProcess() + staleProcess.pid = 1001 + staleProcess.kill.mockImplementation(() => true) + + const unexpectedProcess = createFakeGodotProcess() + unexpectedProcess.pid = 1002 + + spawnMock.mockReturnValueOnce(staleProcess).mockReturnValueOnce(unexpectedProcess) + + const { createGodotStageManager } = await import('./index') + const manager = createGodotStageManager() + const failedStartPromise = manager.start() + const failedStartExpectation = expect(failedStartPromise).rejects.toThrow('Godot stage did not report ready in time.') + + await waitForSpawnedGodotProcess() + await vi.advanceTimersByTimeAsync(20_000) + await vi.advanceTimersByTimeAsync(2_000) + await failedStartExpectation + + const retryStartPromise = manager.start() + const retryStartExpectation = expect(retryStartPromise).rejects.toThrow('Previous Godot stage process is still shutting down') + await vi.advanceTimersByTimeAsync(20_000) + await vi.advanceTimersByTimeAsync(2_000) + await retryStartExpectation + + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(manager.getStatus()).toMatchObject({ + state: 'error', + pid: null, + lastError: expect.stringContaining('Previous Godot stage process is still shutting down'), + }) + }) +}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts b/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts new file mode 100644 index 000000000..f71ea63cd --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts @@ -0,0 +1,825 @@ +import type { ChildProcessByStdio } from 'node:child_process' +import type { Readable } from 'node:stream' + +import type { createContext } from '@moeru/eventa/adapters/electron/main' +import type { BrowserWindow } from 'electron' + +import type { + ElectronGodotStageSceneInputPayload, + ElectronGodotStageStatus, +} from '../../../../shared/eventa' + +import process from 'node:process' + +import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { access, mkdir, stat, writeFile } from 'node:fs/promises' +import { basename, dirname, join, resolve } from 'node:path' + +import { useLogg } from '@guiiai/logg' +import { defineInvokeHandler } from '@moeru/eventa' +import { errorMessageFrom } from '@moeru/std' +import { Mutex } from 'async-mutex' +import { plugin as ws } from 'crossws/server' +import { app } from 'electron' +import { getRandomPort } from 'get-port-please' +import { defineWebSocketHandler, H3, serve } from 'h3' + +import { + electronGodotStageApplySceneInput, + electronGodotStageGetStatus, + electronGodotStageStart, + electronGodotStageStatusChanged, + electronGodotStageStop, +} from '../../../../shared/eventa' +import { onAppBeforeQuit } from '../../../libs/bootkit/lifecycle' +import { getElectronMainDirname } from '../../../libs/electron/location' + +type MainContext = ReturnType['context'] +type GodotStageWebSocketHooks = Exclude[0], (...args: never[]) => unknown> +type GodotStagePeer = Parameters>[0] +type GodotStageMessage = Parameters>[1] +type GodotStageProcess = ChildProcessByStdio + +interface Deferred { + promise: Promise + reject: (error?: unknown) => void + resolve: (value: T | PromiseLike) => void +} + +interface GodotStageSocketRuntime { + port: number + server: ReturnType + token: string +} + +interface GodotStageSceneApplyPayload { + format: string + modelId: string + name: string + path: string +} + +interface GodotStageSocketEnvelope { + payload?: unknown + type: string +} + +/** + * Godot sidecar lifecycle controller owned by Electron main. + * + * Use when: + * - Renderer windows need to start or stop the external Godot stage + * - The selected model should be materialized and forwarded to the Godot runtime + * + * Expects: + * - Production: pre-exported binary in `extraResources/godot-stage/` + * - Dev: `GODOT4` env var points to a local Godot 4.x .NET/Mono executable + * - The current workspace contains `engines/stage-tamagotchi-godot/project.godot` (dev mode only) + * + * Returns: + * - Lifecycle helpers, scene-input forwarding, and status subscriptions + */ +export interface GodotStageManager { + applySceneInput: (payload: ElectronGodotStageSceneInputPayload) => Promise + getStatus: () => ElectronGodotStageStatus + start: () => Promise + stop: () => Promise + subscribe: (callback: (status: ElectronGodotStageStatus) => void) => () => void +} + +function createDeferred(): Deferred { + let resolve!: Deferred['resolve'] + let reject!: Deferred['reject'] + + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + + return { + promise, + reject, + resolve, + } +} + +function createInitialStatus(): ElectronGodotStageStatus { + return { + state: 'stopped', + pid: null, + updatedAt: Date.now(), + } +} + +function createSocketEnvelope(type: string, payload?: unknown) { + return JSON.stringify({ type, payload }) +} + +function normalizeFileName(fileName: string) { + const normalized = basename(fileName.trim()) + return normalized || 'model.bin' +} + +function parseSocketMessage(message: GodotStageMessage): GodotStageSocketEnvelope { + const text = message.text() + return JSON.parse(text) as GodotStageSocketEnvelope +} + +function getPayloadMessage(payload: unknown) { + if (!payload || typeof payload !== 'object') { + return undefined + } + + const message = (payload as Record).message + return typeof message === 'string' ? message : undefined +} + +interface GodotBinaryResolution { + executable: string + mode: 'engine' | 'exported' +} + +/** + * Resolves the Godot project path by walking up from the Electron main + * bundle directory until `engines/stage-tamagotchi-godot/project.godot` is found. + * + * Use when: + * - Dev mode needs to point the Godot engine at the project directory + * + * Returns: + * - Absolute path to the Godot project directory + */ +async function resolveGodotProjectPath() { + let currentDirectory = getElectronMainDirname() + + while (true) { + const projectPath = resolve(currentDirectory, 'engines', 'stage-tamagotchi-godot') + + try { + await access(join(projectPath, 'project.godot')) + return projectPath + } + catch {} + + const parentDirectory = dirname(currentDirectory) + if (parentDirectory === currentDirectory) { + break + } + + currentDirectory = parentDirectory + } + + throw new Error(`Unable to locate engines/stage-tamagotchi-godot/project.godot from ${getElectronMainDirname()}.`) +} + +/** + * Resolves the Godot binary for production mode. + * + * Looks for the pre-exported standalone binary bundled via electron-builder + * `extraResources` at `/godot-stage/`. + * + * Returns: + * - Path to the exported binary, or undefined if not found + */ +async function resolveExportedGodotBinary(): Promise { + const platform = process.platform + let binaryName: string + + if (platform === 'win32') { + binaryName = 'godot-stage.exe' + } + else if (platform === 'darwin') { + binaryName = join('godot-stage.app', 'Contents', 'MacOS', 'godot-stage') + } + else { + binaryName = 'godot-stage' + } + + const binaryPath = join(process.resourcesPath, 'godot-stage', binaryName) + + try { + await access(binaryPath) + return binaryPath + } + catch { + return undefined + } +} + +/** + * Validates the explicitly configured dev-mode Godot executable. + * + * Use when: + * - Dev mode is about to spawn the local Godot engine + * + * Expects: + * - `GODOT4` points to a Godot 4.x .NET/Mono executable file + * + * Returns: + * - Throws a configuration error before spawn when the path is invalid + */ +async function validateConfiguredGodotEnginePath(executable: string) { + let executableStats + try { + executableStats = await stat(executable) + } + catch (error) { + throw new Error( + 'GODOT4 points to a missing Godot executable.\n' + + `Configured path: ${executable}\n` + + 'Set GODOT4 to the absolute path of your Godot 4.x .NET/Mono executable before starting dev mode.\n' + + `Original error: ${errorMessageFrom(error) ?? 'unknown error'}`, + ) + } + + if (!executableStats.isFile()) { + throw new Error( + 'GODOT4 must point to the Godot executable file, not a directory or app bundle.\n' + + `Configured path: ${executable}\n` + + 'Examples:\n' + + ' Windows: C:\\Path\\To\\Godot_v4.x-stable_mono_win64.exe\n' + + ' macOS: /Applications/Godot_mono.app/Contents/MacOS/Godot\n' + + ' Linux: /path/to/Godot_v4.x-stable_mono_linux.x86_64', + ) + } +} + +/** + * Resolves the Godot binary and execution mode. + * + * Use when: + * - The Godot stage is about to be spawned + * + * Expects: + * - Production: exported binary in `extraResources/godot-stage/` + * - Dev: `GODOT4` env var points to a local Godot 4.x .NET/Mono executable + * + * Returns: + * - `{ executable, mode }` where mode determines spawn arguments + */ +async function resolveGodotBinary(): Promise { + if (app.isPackaged) { + const exported = await resolveExportedGodotBinary() + if (exported) { + return { executable: exported, mode: 'exported' } + } + + throw new Error( + 'Godot stage exported binary not found. ' + + `Expected at: ${join(process.resourcesPath, 'godot-stage')}`, + ) + } + + const envPath = process.env.GODOT4?.trim() + if (!envPath) { + throw new Error( + 'GODOT4 is required to start Godot Stage in development mode.\n' + + 'Set GODOT4 to the absolute path of your Godot 4.x .NET/Mono executable, then restart the Electron dev app.\n' + + 'Examples:\n' + + ' PowerShell: $env:GODOT4 = "C:\\Path\\To\\Godot_v4.x-stable_mono_win64.exe"\n' + + ' Bash: export GODOT4="/path/to/godot"', + ) + } + + await validateConfiguredGodotEnginePath(envPath) + return { executable: envPath, mode: 'engine' } +} + +/** + * Creates the shared Godot stage manager. + * + * Call stack: + * + * setupGodotStageManager + * -> {@link createGodotStageManager} + * -> renderer invoke handlers + * -> Godot sidecar process + websocket bridge + */ +export function createGodotStageManager(): GodotStageManager { + const log = useLogg('main/godot-stage').useGlobalConfig() + const lifecycleMutex = new Mutex() + const listeners = new Set<(status: ElectronGodotStageStatus) => void>() + let currentStatus = createInitialStatus() + let currentProcess: GodotStageProcess | undefined + let currentProcessExit = createDeferred() + let currentReady: Deferred | undefined + let currentSceneInput: GodotStageSceneApplyPayload | undefined + let currentSocketRuntime: GodotStageSocketRuntime | undefined + let currentSocketPeer: GodotStagePeer | undefined + let expectedProcessExit = false + + function broadcastStatus(status: ElectronGodotStageStatus) { + currentStatus = status + + for (const listener of listeners) { + try { + listener(currentStatus) + } + catch (error) { + log.withError(error).warn('failed to publish Godot stage status change') + } + } + } + + function setStatus(next: Partial & Pick) { + broadcastStatus({ + ...currentStatus, + ...next, + updatedAt: Date.now(), + }) + } + + function clearProcessState() { + currentProcess = undefined + currentSocketPeer = undefined + currentProcessExit.resolve() + currentProcessExit = createDeferred() + } + + async function stopSocketRuntime() { + const runtime = currentSocketRuntime + currentSocketRuntime = undefined + currentSocketPeer = undefined + + if (!runtime) { + return + } + + await runtime.server.close(true).catch(() => {}) + } + + async function stopProcessAfterFailedStart() { + if (!currentProcess) { + return + } + + const activeProcess = currentProcess + const exitPromise = currentProcessExit.promise + expectedProcessExit = true + + // Startup failed after spawning Godot; release the child process before + // allowing the renderer to retry and create another stage runtime. + activeProcess.kill() + + await Promise.race([ + exitPromise, + new Promise(resolve => setTimeout(resolve, 2_000)), + ]).catch(() => {}) + } + + function sendSocketMessage(type: string, payload?: unknown) { + if (!currentSocketPeer) { + return + } + + currentSocketPeer.send(createSocketEnvelope(type, payload)) + } + + async function sendSceneInputToGodot(payload: GodotStageSceneApplyPayload) { + currentSceneInput = payload + + if (!currentSocketPeer) { + return + } + + sendSocketMessage('host.scene.apply', payload) + } + + function handleSocketMessage(message: GodotStageSocketEnvelope) { + switch (message.type) { + case 'stage.ready': { + setStatus({ + state: 'running', + pid: currentProcess?.pid ?? null, + lastError: undefined, + }) + currentReady?.resolve() + currentReady = undefined + + if (currentSceneInput) { + void sendSceneInputToGodot(currentSceneInput) + } + return + } + case 'stage.fatal': { + const error = getPayloadMessage(message.payload) ?? 'Godot stage reported a fatal startup error.' + setStatus({ + state: 'error', + pid: currentProcess?.pid ?? null, + lastError: error, + }) + currentReady?.reject(new Error(error)) + currentReady = undefined + currentProcess?.kill() + return + } + case 'scene.applied': { + if (currentStatus.state === 'running' && currentStatus.lastError) { + setStatus({ + state: 'running', + pid: currentProcess?.pid ?? null, + lastError: undefined, + }) + } + return + } + case 'scene.error': { + const error = getPayloadMessage(message.payload) ?? 'Godot stage failed to apply scene input.' + setStatus({ + state: currentStatus.state === 'running' ? 'running' : currentStatus.state, + pid: currentProcess?.pid ?? null, + lastError: error, + }) + return + } + default: { + log.withFields({ type: message.type }).debug('received unknown Godot stage message') + } + } + } + + async function startSocketRuntime() { + if (currentSocketRuntime) { + return currentSocketRuntime + } + + const host = '127.0.0.1' + const port = await getRandomPort(host) + const token = randomUUID() + const appServer = new H3() + + appServer.get('/ws', defineWebSocketHandler({ + open: (peer) => { + const requestUrl = peer.request.url ?? '' + const url = new URL(requestUrl, `ws://${host}:${port}`) + if (url.searchParams.get('token') !== token) { + peer.close?.() + return + } + + currentSocketPeer = peer + log.withFields({ peer: peer.id }).debug('Godot websocket connected') + }, + message: (_peer, message) => { + try { + handleSocketMessage(parseSocketMessage(message)) + } + catch (error) { + log.withError(error).warn('failed to parse Godot websocket message') + } + }, + close: (peer) => { + if (currentSocketPeer?.id === peer.id) { + currentSocketPeer = undefined + } + }, + })) + + const server = serve(appServer, { + // @ts-expect-error - h3 does not extend the crossws response type. + plugins: [ws({ resolve: async req => (await appServer.fetch(req)).crossws })], + port, + hostname: host, + manual: true, + reusePort: false, + silent: true, + gracefulShutdown: { + forceTimeout: 0.25, + gracefulTimeout: 0.25, + }, + }) + + await server.serve() + + currentSocketRuntime = { + port, + server, + token, + } + + return currentSocketRuntime + } + + function attachProcessListeners(processHandle: GodotStageProcess) { + processHandle.stdout.on('data', (data) => { + const message = data.toString('utf-8').trim() + if (message) { + log.log(message) + } + }) + + processHandle.stderr.on('data', (data) => { + const message = data.toString('utf-8').trim() + if (message) { + log.warn(message) + } + }) + + processHandle.on('error', (error) => { + if (currentProcess !== processHandle) { + log.withError(error).debug('ignored stale Godot stage process error') + return + } + + const message = errorMessageFrom(error) ?? 'Failed to spawn Godot stage process.' + setStatus({ + state: 'error', + pid: processHandle.pid ?? null, + lastError: message, + }) + currentReady?.reject(error) + currentReady = undefined + }) + + processHandle.on('close', (code, signal) => { + if (currentProcess !== processHandle) { + log.withFields({ + code, + pid: processHandle.pid ?? null, + signal, + }).debug('ignored stale Godot stage process close') + return + } + + const exitMessage = signal + ? `Godot stage exited with signal ${signal}.` + : `Godot stage exited with code ${code ?? 0}.` + + clearProcessState() + void stopSocketRuntime() + + if (expectedProcessExit) { + setStatus({ + state: 'stopped', + pid: null, + lastError: undefined, + }) + } + else { + setStatus({ + state: 'error', + pid: null, + lastError: exitMessage, + }) + } + + currentReady?.reject(new Error(exitMessage)) + currentReady = undefined + expectedProcessExit = false + }) + } + + return { + subscribe(callback) { + listeners.add(callback) + callback(currentStatus) + + return () => { + listeners.delete(callback) + } + }, + getStatus() { + return currentStatus + }, + async start() { + return await lifecycleMutex.runExclusive(async () => { + let spawnedProcess: GodotStageProcess | undefined + + try { + if (currentProcess && currentStatus.state === 'running') { + return currentStatus + } + + if (currentProcess && currentStatus.state === 'starting' && currentReady) { + await currentReady.promise + return currentStatus + } + + if (currentProcess) { + const activeProcess = currentProcess + await stopProcessAfterFailedStart() + + if (currentProcess === activeProcess) { + throw new Error('Previous Godot stage process is still shutting down. Retry after it exits.') + } + } + + await stopSocketRuntime() + + const socketRuntime = await startSocketRuntime() + const godotBinary = await resolveGodotBinary() + const websocketUrl = `ws://127.0.0.1:${socketRuntime.port}/ws?token=${socketRuntime.token}` + const readyDeferred = createDeferred() + const readyTimeout = setTimeout(() => { + readyDeferred.reject(new Error('Godot stage did not report ready in time.')) + }, 20_000) + + currentReady = readyDeferred + expectedProcessExit = false + setStatus({ + state: 'starting', + pid: null, + lastError: undefined, + }) + + let spawnArgs: string[] + let spawnCwd: string | undefined + + if (godotBinary.mode === 'engine') { + const godotProjectPath = await resolveGodotProjectPath() + spawnArgs = ['--path', godotProjectPath, '--', `--airi-ws-url=${websocketUrl}`] + spawnCwd = godotProjectPath + } + else { + spawnArgs = ['--', `--airi-ws-url=${websocketUrl}`] + } + + log.withFields({ executable: godotBinary.executable, mode: godotBinary.mode }).log('spawning Godot stage') + + const processHandle = spawn( + godotBinary.executable, + spawnArgs, + { + cwd: spawnCwd, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: false, + }, + ) + + spawnedProcess = processHandle + currentProcess = processHandle + attachProcessListeners(processHandle) + + setStatus({ + state: 'starting', + pid: processHandle.pid ?? null, + lastError: undefined, + }) + + try { + await readyDeferred.promise + } + finally { + if (currentReady === readyDeferred) + currentReady = undefined + clearTimeout(readyTimeout) + } + + return currentStatus + } + catch (error) { + if (spawnedProcess && currentProcess === spawnedProcess) { + await stopProcessAfterFailedStart() + } + await stopSocketRuntime() + setStatus({ + state: 'error', + pid: null, + lastError: errorMessageFrom(error) ?? 'Failed to start Godot stage.', + }) + throw error + } + }) + }, + async stop() { + return await lifecycleMutex.runExclusive(async () => { + if (!currentProcess) { + await stopSocketRuntime() + setStatus({ + state: 'stopped', + pid: null, + lastError: undefined, + }) + return currentStatus + } + + const activeProcess = currentProcess + const exitPromise = currentProcessExit.promise + + expectedProcessExit = true + setStatus({ + state: 'stopping', + pid: activeProcess.pid ?? null, + lastError: undefined, + }) + + try { + sendSocketMessage('host.shutdown') + + const exited = await Promise.race([ + exitPromise.then(() => true), + new Promise(resolve => setTimeout(resolve, 2_000, false)), + ]) + + if (!exited) { + activeProcess.kill() + await exitPromise.catch(() => {}) + } + } + catch (error) { + setStatus({ + state: 'error', + pid: activeProcess.pid ?? null, + lastError: errorMessageFrom(error) ?? 'Failed to stop Godot stage.', + }) + throw error + } + finally { + await stopSocketRuntime() + } + + setStatus({ + state: 'stopped', + pid: null, + lastError: undefined, + }) + + return currentStatus + }) + }, + async applySceneInput(payload) { + await lifecycleMutex.runExclusive(async () => { + if (currentStatus.state !== 'starting' && currentStatus.state !== 'running') { + throw new Error('Godot stage is not running.') + } + + const fileName = normalizeFileName(payload.fileName) + const modelDirectory = join(app.getPath('userData'), 'godot-stage', 'models', payload.modelId) + const materializedPath = join(modelDirectory, fileName) + + await mkdir(modelDirectory, { recursive: true }) + await writeFile(materializedPath, payload.data) + + await sendSceneInputToGodot({ + modelId: payload.modelId, + format: payload.format, + name: payload.name, + path: materializedPath, + }) + }) + }, + } +} + +/** + * Creates and wires the shared Godot stage manager into app lifecycle hooks. + * + * Use when: + * - Electron main needs one app-wide Godot sidecar lifecycle owner + * + * Expects: + * - App shutdown to call the registered `onAppBeforeQuit` hook + * + * Returns: + * - The ready-to-use Godot stage manager + */ +export function setupGodotStageManager() { + const manager = createGodotStageManager() + + onAppBeforeQuit(async () => { + await manager.stop() + }) + + return manager +} + +/** + * Registers Godot stage invoke handlers for one Electron window context. + * + * Call stack: + * + * createGodotStageService + * -> renderer invoke/eventa handlers + * -> {@link GodotStageManager} + */ +export function createGodotStageService(params: { + context: MainContext + manager: GodotStageManager + window: BrowserWindow +}) { + const unsubscribe = params.manager.subscribe((status) => { + if (!params.window.isDestroyed()) { + params.context.emit(electronGodotStageStatusChanged, status) + } + }) + + const cleanups: Array<() => void> = [ + unsubscribe, + defineInvokeHandler(params.context, electronGodotStageStart, async () => await params.manager.start()), + defineInvokeHandler(params.context, electronGodotStageStop, async () => await params.manager.stop()), + defineInvokeHandler(params.context, electronGodotStageGetStatus, async () => params.manager.getStatus()), + defineInvokeHandler(params.context, electronGodotStageApplySceneInput, async (payload) => { + await params.manager.applySceneInput(payload) + }), + ] + + const cleanup = () => { + for (const fn of cleanups) { + fn() + } + } + + params.window.on('closed', cleanup) + return cleanup +} diff --git a/apps/stage-tamagotchi/src/main/windows/main/index.ts b/apps/stage-tamagotchi/src/main/windows/main/index.ts index c038902a0..4651d71d0 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/index.ts @@ -4,6 +4,7 @@ import type { InferOutput } from 'valibot' import type { I18n } from '../../libs/i18n' import type { WindowAuthManager } from '../../services/airi/auth' import type { ServerChannel } from '../../services/airi/channel-server' +import type { GodotStageManager } from '../../services/airi/godot-stage' import type { McpStdioManager } from '../../services/airi/mcp-servers' import type { AutoUpdater } from '../../services/electron/auto-updater' import type { NoticeWindowManager } from '../notice' @@ -56,6 +57,7 @@ export async function setupMainWindow(params: { autoUpdater: AutoUpdater onWindowCreated?: (window: BrowserWindow) => void serverChannel: ServerChannel + godotStageManager: GodotStageManager mcpStdioManager: McpStdioManager i18n: I18n onboardingWindowManager: OnboardingWindowManager @@ -184,6 +186,7 @@ export async function setupMainWindow(params: { noticeWindow: params.noticeWindow, autoUpdater: params.autoUpdater, serverChannel: params.serverChannel, + godotStageManager: params.godotStageManager, mcpStdioManager: params.mcpStdioManager, i18n: params.i18n, onboardingWindowManager: params.onboardingWindowManager, diff --git a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts index 0889903a0..20c2501b7 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts @@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron' import type { I18n } from '../../../libs/i18n' import type { WindowAuthManager } from '../../../services/airi/auth' import type { ServerChannel } from '../../../services/airi/channel-server' +import type { GodotStageManager } from '../../../services/airi/godot-stage' import type { McpStdioManager } from '../../../services/airi/mcp-servers' import type { AutoUpdater } from '../../../services/electron/auto-updater' import type { NoticeWindowManager } from '../../notice' @@ -16,6 +17,7 @@ import { ipcMain } from 'electron' import { electronOpenChat, electronOpenMainDevtools, electronOpenSettings, noticeWindowEventa } from '../../../../shared/eventa' import { createAuthService } from '../../../services/airi/auth' +import { createGodotStageService } from '../../../services/airi/godot-stage' import { createMcpServersService } from '../../../services/airi/mcp-servers' import { createOnboardingService } from '../../../services/airi/onboarding' import { createWidgetsService } from '../../../services/airi/widgets' @@ -31,6 +33,7 @@ export async function setupMainWindowElectronInvokes(params: { noticeWindow: NoticeWindowManager autoUpdater: AutoUpdater serverChannel: ServerChannel + godotStageManager: GodotStageManager mcpStdioManager: McpStdioManager i18n: I18n onboardingWindowManager: OnboardingWindowManager @@ -47,6 +50,7 @@ export async function setupMainWindowElectronInvokes(params: { createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window }) createAutoUpdaterService({ context, window: params.window, service: params.autoUpdater }) createMcpServersService({ context, manager: params.mcpStdioManager }) + createGodotStageService({ context, manager: params.godotStageManager, window: params.window }) createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager, mainWindow: params.window }) createAuthService({ context, window: params.window, windowAuthManager: params.windowAuthManager }) diff --git a/apps/stage-tamagotchi/src/main/windows/settings/index.ts b/apps/stage-tamagotchi/src/main/windows/settings/index.ts index 79fa9b6ed..411c01845 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/index.ts @@ -1,6 +1,7 @@ import type { I18n } from '../../libs/i18n' import type { WindowAuthManager } from '../../services/airi/auth' import type { ServerChannel } from '../../services/airi/channel-server' +import type { GodotStageManager } from '../../services/airi/godot-stage' import type { McpStdioManager } from '../../services/airi/mcp-servers' import type { AutoUpdater } from '../../services/electron/auto-updater' import type { DevtoolsWindowManager } from '../devtools' @@ -30,6 +31,7 @@ export function setupSettingsWindowReusableFunc(params: { devtoolsWindow: DevtoolsWindowManager onWindowCreated?: (window: BrowserWindow) => void serverChannel: ServerChannel + godotStageManager: GodotStageManager mcpStdioManager: McpStdioManager i18n: I18n windowAuthManager: WindowAuthManager @@ -69,6 +71,7 @@ export function setupSettingsWindowReusableFunc(params: { autoUpdater: params.autoUpdater, devtoolsWindow: params.devtoolsWindow, serverChannel: params.serverChannel, + godotStageManager: params.godotStageManager, mcpStdioManager: params.mcpStdioManager, i18n: params.i18n, windowAuthManager: params.windowAuthManager, diff --git a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts index 5ed9ef109..11a7bfc8c 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts @@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron' import type { I18n } from '../../../libs/i18n' import type { WindowAuthManager } from '../../../services/airi/auth' import type { ServerChannel } from '../../../services/airi/channel-server' +import type { GodotStageManager } from '../../../services/airi/godot-stage' import type { McpStdioManager } from '../../../services/airi/mcp-servers' import type { AutoUpdater } from '../../../services/electron/auto-updater' import type { DevtoolsWindowManager } from '../../devtools' @@ -14,6 +15,7 @@ import { ipcMain } from 'electron' import { electronOpenDevtoolsWindow, electronOpenSettingsDevtools } from '../../../../shared/eventa' import { createAuthService } from '../../../services/airi/auth' +import { createGodotStageService } from '../../../services/airi/godot-stage' import { createMcpServersService } from '../../../services/airi/mcp-servers' import { createWidgetsService } from '../../../services/airi/widgets' import { createAutoUpdaterService } from '../../../services/electron' @@ -25,6 +27,7 @@ export async function setupSettingsWindowInvokes(params: { autoUpdater: AutoUpdater devtoolsWindow: DevtoolsWindowManager serverChannel: ServerChannel + godotStageManager: GodotStageManager mcpStdioManager: McpStdioManager i18n: I18n windowAuthManager: WindowAuthManager @@ -41,6 +44,7 @@ export async function setupSettingsWindowInvokes(params: { createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.settingsWindow }) createAutoUpdaterService({ context, window: params.settingsWindow, service: params.autoUpdater }) createMcpServersService({ context, manager: params.mcpStdioManager }) + createGodotStageService({ context, manager: params.godotStageManager, window: params.settingsWindow }) createAuthService({ context, window: params.settingsWindow, windowAuthManager: params.windowAuthManager }) defineInvokeHandler(context, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' })) diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue index ec9537930..754c9c6a0 100644 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ b/apps/stage-tamagotchi/src/renderer/App.vue @@ -28,6 +28,8 @@ import ResizeHandler from './components/ResizeHandler.vue' import { electronGetServerChannelConfig, + electronGodotStageGetStatus, + electronGodotStageStatusChanged, electronSettingsNavigate, electronStartTrackMousePosition, i18nSetLocale, @@ -91,10 +93,22 @@ const inspectPluginHost = useElectronEventaInvoke(electronPluginInspect) const startTrackingCursorPoint = useElectronEventaInvoke(electronStartTrackMousePosition) const reportPluginCapability = useElectronEventaInvoke(electronPluginUpdateCapability) const setLocale = useElectronEventaInvoke(i18nSetLocale) +const getGodotStageStatus = useElectronEventaInvoke(electronGodotStageGetStatus) const syncArtistryConfig = useElectronEventaInvoke(artistrySyncConfig) const isChatWindowRoute = () => route.path === '/chat' +const isGodotStageRoute = () => route.path === '/' || route.path.startsWith('/settings') const isWidgetsWindowRoute = () => route.path === '/widgets' +function syncGodotStageRenderer(state: { state: 'stopped' | 'starting' | 'running' | 'stopping' | 'error' }) { + if (state.state === 'running') { + settingsStore.setStageModelRenderer('godot') + return + } + + if ((state.state === 'stopped' || state.state === 'error') && settingsStore.stageModelRenderer === 'godot') + settingsStore.restoreBuiltInStageModelRenderer() +} + async function refreshPluginRuntimeTools() { try { await pluginToolsStore.refresh() @@ -175,6 +189,14 @@ context.value.on(electronSettingsNavigate, (event) => { }) }) +context.value.on(electronGodotStageStatusChanged, (event) => { + if (!event.body) { + return + } + + syncGodotStageRenderer(event.body) +}) + onMounted(async () => { analyticsStore.initialize() await displayModelsStore.initialize() @@ -185,6 +207,15 @@ onMounted(async () => { await settingsStore.initializeStageModel() await settingsAudioDeviceStore.initialize() + if (isGodotStageRoute()) { + try { + syncGodotStageRenderer(await getGodotStageStatus()) + } + catch (error) { + console.warn('[App] Failed to fetch Godot stage status:', error) + } + } + const serverChannelConfig = await getServerChannelConfig() serverChannelSettingsStore.tlsConfig = serverChannelConfig.tlsConfig ?? null serverChannelSettingsStore.hostname = serverChannelConfig.hostname diff --git a/apps/stage-tamagotchi/src/renderer/pages/index.vue b/apps/stage-tamagotchi/src/renderer/pages/index.vue index 1b33a702f..71773020f 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/index.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/index.vue @@ -153,6 +153,18 @@ const modelSettingsRuntimeSnapshot = computed(() = }) } + if (stageModelRenderer.value === 'godot') { + return createEmptyModelSettingsRuntimeSnapshot({ + ownerInstanceId: modelSettingsRuntimeOwnerInstanceId, + renderer: 'godot', + phase: hasModel ? 'mounted' : 'no-model', + controlsLocked: false, + previewAvailable: false, + canCapturePreview: false, + updatedAt: Date.now(), + }) + } + return createEmptyModelSettingsRuntimeSnapshot({ ownerInstanceId: modelSettingsRuntimeOwnerInstanceId, updatedAt: Date.now(), diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/models/index.vue b/apps/stage-tamagotchi/src/renderer/pages/settings/models/index.vue index 1809d5f41..a23f3394e 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/settings/models/index.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/models/index.vue @@ -1,28 +1,232 @@ diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/preview-stage.vue b/packages/stage-ui/src/components/scenarios/settings/model-settings/preview-stage.vue index f08d76e9c..692c40956 100644 --- a/packages/stage-ui/src/components/scenarios/settings/model-settings/preview-stage.vue +++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/preview-stage.vue @@ -106,6 +106,18 @@ const runtimeSnapshot = computed(() => { }) } + if (stageModelRenderer.value === 'godot') { + return createEmptyModelSettingsRuntimeSnapshot({ + ownerInstanceId: vrmPreviewStageInstanceId, + renderer: 'godot', + phase: hasModel ? 'mounted' : 'no-model', + controlsLocked: false, + previewAvailable: false, + canCapturePreview: false, + updatedAt: Date.now(), + }) + } + return createEmptyModelSettingsRuntimeSnapshot({ ownerInstanceId: vrmPreviewStageInstanceId, updatedAt: Date.now(), diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/runtime.ts b/packages/stage-ui/src/components/scenarios/settings/model-settings/runtime.ts index 25b920798..e15ef82f2 100644 --- a/packages/stage-ui/src/components/scenarios/settings/model-settings/runtime.ts +++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/runtime.ts @@ -1,4 +1,4 @@ -export type ModelSettingsRuntimeRenderer = 'disabled' | 'live2d' | 'vrm' +export type ModelSettingsRuntimeRenderer = 'disabled' | 'live2d' | 'vrm' | 'godot' export type ModelSettingsRuntimePhase = 'pending' | 'loading' | 'binding' | 'mounted' | 'no-model' | 'error' export interface ModelSettingsRuntimeSnapshot { diff --git a/packages/stage-ui/src/components/scenes/Stage.vue b/packages/stage-ui/src/components/scenes/Stage.vue index aa1e3c045..708477b04 100644 --- a/packages/stage-ui/src/components/scenes/Stage.vue +++ b/packages/stage-ui/src/components/scenes/Stage.vue @@ -13,6 +13,7 @@ import { Live2DScene, useLive2d } from '@proj-airi/stage-ui-live2d' import { ThreeScene } from '@proj-airi/stage-ui-three' import { animations } from '@proj-airi/stage-ui-three/assets/vrm' import { createQueue } from '@proj-airi/stream-kit' +import { Callout } from '@proj-airi/ui' import { useBroadcastChannel } from '@vueuse/core' // import { createTransformers } from '@xsai-transformers/embed' // import embedWorkerURL from '@xsai-transformers/embed/worker?worker&url' @@ -532,6 +533,10 @@ onMounted(async () => { }) watch([stageModelRenderer, () => props.paused], ([renderer]) => { + if (renderer === 'godot') { + componentState.value = 'mounted' + } + if (renderer !== 'live2d') { resetLive2dLipSync() return @@ -673,6 +678,26 @@ defineExpose({ :current-audio-source="currentAudioSource" @error="console.error" /> +
+
+ +

Godot Stage (experimental) is running...

+
+
+
diff --git a/packages/stage-ui/src/stores/settings/index.ts b/packages/stage-ui/src/stores/settings/index.ts index 67ca0ce8d..b3cddd540 100644 --- a/packages/stage-ui/src/stores/settings/index.ts +++ b/packages/stage-ui/src/stores/settings/index.ts @@ -96,6 +96,8 @@ export const useSettings = defineStore('settings', () => { applyPrimaryColorFrom: theme.applyPrimaryColorFrom, isColorSelectedForPrimary: theme.isColorSelectedForPrimary, initializeStageModel: stageModel.initializeStageModel, + restoreBuiltInStageModelRenderer: stageModel.restoreBuiltInStageModelRenderer, + setStageModelRenderer: stageModel.setStageModelRenderer, updateStageModel: stageModel.updateStageModel, resetState, } diff --git a/packages/stage-ui/src/stores/settings/stage-model.ts b/packages/stage-ui/src/stores/settings/stage-model.ts index 321a0d7cd..f0a28a9c0 100644 --- a/packages/stage-ui/src/stores/settings/stage-model.ts +++ b/packages/stage-ui/src/stores/settings/stage-model.ts @@ -7,7 +7,8 @@ import { computed, watch } from 'vue' import { DisplayModelFormat, useDisplayModelsStore } from '../display-models' -export type StageModelRenderer = 'live2d' | 'vrm' | 'disabled' | undefined +export type StageModelRenderer = 'live2d' | 'vrm' | 'godot' | 'disabled' | undefined +type BuiltInStageModelRenderer = Exclude export const useSettingsStageModel = defineStore('settings-stage-model', () => { const displayModelsStore = useDisplayModelsStore() @@ -24,6 +25,7 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => { const stageModelSelectedDisplayModel = refManualReset(undefined) const stageModelSelectedUrl = refManualReset(undefined) const stageModelRenderer = refManualReset(undefined) + const stageModelBuiltInRenderer = refManualReset(undefined) const stageViewControlsEnabled = refManualReset(false) @@ -40,6 +42,21 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => { stageModelSelectedUrl.value = nextUrl } + function resolveBuiltInStageModelRenderer(model?: DisplayModel): BuiltInStageModelRenderer { + if (!model) { + return 'disabled' + } + + switch (model.format) { + case DisplayModelFormat.Live2dZip: + return 'live2d' + case DisplayModelFormat.VRM: + return 'vrm' + default: + return 'disabled' + } + } + async function updateStageModel() { const requestId = ++stageModelUpdateSequence const selectedModelId = stageModelSelectedState.value @@ -47,7 +64,9 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => { if (!selectedModelId) { replaceStageModelUrl(undefined) stageModelSelectedDisplayModel.value = undefined - stageModelRenderer.value = 'disabled' + stageModelBuiltInRenderer.value = 'disabled' + if (stageModelRenderer.value !== 'godot') + stageModelRenderer.value = 'disabled' return } @@ -58,21 +77,16 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => { if (!model) { replaceStageModelUrl(undefined) stageModelSelectedDisplayModel.value = undefined - stageModelRenderer.value = 'disabled' + stageModelBuiltInRenderer.value = 'disabled' + if (stageModelRenderer.value !== 'godot') + stageModelRenderer.value = 'disabled' return } - switch (model.format) { - case DisplayModelFormat.Live2dZip: - stageModelRenderer.value = 'live2d' - break - case DisplayModelFormat.VRM: - stageModelRenderer.value = 'vrm' - break - default: - stageModelRenderer.value = 'disabled' - break - } + const builtInRenderer = resolveBuiltInStageModelRenderer(model) + stageModelBuiltInRenderer.value = builtInRenderer + if (stageModelRenderer.value !== 'godot') + stageModelRenderer.value = builtInRenderer if (model.type === 'file') { const nextUrl = URL.createObjectURL(model.file) @@ -90,6 +104,14 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => { stageModelSelectedDisplayModel.value = model } + function setStageModelRenderer(renderer: StageModelRenderer) { + stageModelRenderer.value = renderer + } + + function restoreBuiltInStageModelRenderer() { + stageModelRenderer.value = stageModelBuiltInRenderer.value ?? 'disabled' + } + async function initializeStageModel() { await updateStageModel() } @@ -109,6 +131,7 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => { stageModelSelectedDisplayModel.reset() stageModelSelectedUrl.reset() stageModelRenderer.reset() + stageModelBuiltInRenderer.reset() stageViewControlsEnabled.reset() await updateStageModel() @@ -122,6 +145,8 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => { stageViewControlsEnabled, initializeStageModel, + restoreBuiltInStageModelRenderer, + setStageModelRenderer, updateStageModel, resetState, }