feat(stage-tamagotchi-godot): add Godot stage G0 sidecar preview (#1724)

## Summary

Add the G0 Godot Stage sidecar preview for `stage-tamagotchi`.

## Design

- Keep Electron as the desktop host and run Godot as a separate sidecar
window by design.
- Use the settings window as the entry point for starting/stopping the
experimental Godot Stage.
- Require explicit `GODOT4` in development mode instead of
auto-discovering local Godot installs.
- Use the packaged sidecar under `process.resourcesPath/godot-stage` in
the packaged runtime.
- Close the Godot WebSocket lifecycle channel on startup failure and
stage shutdown.

## Changes

- Add the Electron main-side Godot Stage lifecycle service.
- Add renderer/settings wiring for switching to the experimental Godot
Stage.
- Add the minimal Godot stage runtime skeleton and WebSocket handshake
path.
- Add Godot export presets for sidecar builds.
- Add sidecar path verification for the Godot export preset and
`electron-builder` resource contract.
- Document the development runtime setup and packaging expectations.

## CI/CD

- Align Godot CI to `4.6.2`.
- Add Linux Godot export smoke coverage as the fastest CI export path.
- Add sidecar contract verification to CI.
- Run packaged sidecar verification after `electron-builder --dir`.

CD is intentionally deferred in this PR. The release workflow still
needs a later step to export the Godot sidecar before packaging release
artefacts.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Lilia_Chen
2026-04-25 00:56:03 +08:00
committed by GitHub
co-authored by autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
parent 255ce71cba
commit c6c0494998
27 changed files with 2269 additions and 41 deletions
@@ -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.
+10 -2
View File
@@ -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 }}'"
run: "echo 'Downgraded: ${{ steps.check.outputs.downgraded }}'"
@@ -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',
+8 -3
View File
@@ -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({
@@ -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<typeof vi.fn>
id: string
request: {
url?: string
}
send: ReturnType<typeof vi.fn>
}
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<typeof vi.fn>
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'),
})
})
})
@@ -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<typeof createContext>['context']
type GodotStageWebSocketHooks = Exclude<Parameters<typeof defineWebSocketHandler>[0], (...args: never[]) => unknown>
type GodotStagePeer = Parameters<NonNullable<GodotStageWebSocketHooks['open']>>[0]
type GodotStageMessage = Parameters<NonNullable<GodotStageWebSocketHooks['message']>>[1]
type GodotStageProcess = ChildProcessByStdio<null, Readable, Readable>
interface Deferred<T> {
promise: Promise<T>
reject: (error?: unknown) => void
resolve: (value: T | PromiseLike<T>) => void
}
interface GodotStageSocketRuntime {
port: number
server: ReturnType<typeof serve>
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<void>
getStatus: () => ElectronGodotStageStatus
start: () => Promise<ElectronGodotStageStatus>
stop: () => Promise<ElectronGodotStageStatus>
subscribe: (callback: (status: ElectronGodotStageStatus) => void) => () => void
}
function createDeferred<T>(): Deferred<T> {
let resolve!: Deferred<T>['resolve']
let reject!: Deferred<T>['reject']
const promise = new Promise<T>((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<string, unknown>).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 `<resourcesPath>/godot-stage/`.
*
* Returns:
* - Path to the exported binary, or undefined if not found
*/
async function resolveExportedGodotBinary(): Promise<string | undefined> {
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<GodotBinaryResolution> {
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<void>()
let currentReady: Deferred<void> | 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<ElectronGodotStageStatus> & Pick<ElectronGodotStageStatus, 'state'>) {
broadcastStatus({
...currentStatus,
...next,
updatedAt: Date.now(),
})
}
function clearProcessState() {
currentProcess = undefined
currentSocketPeer = undefined
currentProcessExit.resolve()
currentProcessExit = createDeferred<void>()
}
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<void>(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<void>()
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<boolean>(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
}
@@ -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,
@@ -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 })
@@ -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,
@@ -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' }))
@@ -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
@@ -153,6 +153,18 @@ const modelSettingsRuntimeSnapshot = computed<ModelSettingsRuntimeSnapshot>(() =
})
}
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(),
@@ -1,28 +1,232 @@
<script setup lang="ts">
import { ModelSettingsPanel } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings'
import { ref } from 'vue'
import type { DisplayModel } from '@proj-airi/stage-ui/stores/display-models'
import type {
ElectronGodotStageSceneInputPayload,
ElectronGodotStageStatus,
} from '../../../../shared/eventa'
import { errorMessageFrom } from '@moeru/std'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { ModelSettingsPanel } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings'
import { DisplayModelFormat } from '@proj-airi/stage-ui/stores/display-models'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { Button, Callout } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import {
electronGodotStageApplySceneInput,
electronGodotStageGetStatus,
electronGodotStageStart,
electronGodotStageStop,
} from '../../../../shared/eventa'
import { useModelSettingsRuntimeSnapshot } from '../../../composables/model-settings-runtime-snapshot'
const settingsStore = useSettings()
const { stageModelRenderer, stageModelSelectedDisplayModel } = storeToRefs(settingsStore)
const applyGodotStageSceneInput = useElectronEventaInvoke(electronGodotStageApplySceneInput)
const getGodotStageStatus = useElectronEventaInvoke(electronGodotStageGetStatus)
const startGodotStage = useElectronEventaInvoke(electronGodotStageStart)
const stopGodotStage = useElectronEventaInvoke(electronGodotStageStop)
const palette = ref<string[]>([])
const godotStageError = ref<string>()
const godotStageStatus = ref<ElectronGodotStageStatus>({
state: 'stopped',
pid: null,
updatedAt: 0,
})
const switchingGodotStage = ref(false)
const { runtimeSnapshot } = useModelSettingsRuntimeSnapshot()
let latestSceneSyncRequest = 0
const usesGodotStage = computed(() => stageModelRenderer.value === 'godot')
const godotToggleLabel = computed(() => usesGodotStage.value
? 'Back to Built-in Stage'
: 'Switch to Godot Stage (Experimental)')
const godotStatusMessage = computed(() => {
if (godotStageError.value)
return godotStageError.value
if (godotStageStatus.value.state === 'error')
return godotStageStatus.value.lastError
return undefined
})
function createEmptyGodotStageStatus(): ElectronGodotStageStatus {
return {
state: 'stopped',
pid: null,
updatedAt: Date.now(),
}
}
function inferModelFileExtension(format: DisplayModelFormat) {
switch (format) {
case DisplayModelFormat.Live2dZip:
return '.zip'
case DisplayModelFormat.Live2dDirectory:
return '.live2d'
case DisplayModelFormat.VRM:
return '.vrm'
case DisplayModelFormat.PMXZip:
return '.zip'
case DisplayModelFormat.PMXDirectory:
return '.pmxdir'
case DisplayModelFormat.PMD:
return '.pmd'
default:
return '.bin'
}
}
function inferModelFileName(model: DisplayModel) {
if (model.type === 'file')
return model.file.name
try {
const url = new URL(model.url)
const parsedName = url.pathname.split('/').pop()
if (parsedName)
return parsedName
}
catch {}
return `${model.id}${inferModelFileExtension(model.format)}`
}
async function readSceneInputData(model: DisplayModel) {
if (model.type === 'file')
return new Uint8Array(await model.file.arrayBuffer())
const response = await fetch(model.url)
if (!response.ok)
throw new Error(`Failed to fetch model asset (${response.status} ${response.statusText})`)
return new Uint8Array(await response.arrayBuffer())
}
async function createSceneInputPayload(model: DisplayModel): Promise<ElectronGodotStageSceneInputPayload> {
return {
modelId: model.id,
format: model.format,
name: model.name,
fileName: inferModelFileName(model),
data: await readSceneInputData(model),
}
}
async function refreshGodotStageStatus() {
try {
godotStageStatus.value = await getGodotStageStatus()
}
catch (error) {
godotStageStatus.value = createEmptyGodotStageStatus()
godotStageError.value = errorMessageFrom(error) ?? 'Failed to query Godot stage status.'
}
}
async function syncGodotSceneInput(model: DisplayModel) {
const requestId = ++latestSceneSyncRequest
try {
const payload = await createSceneInputPayload(model)
if (requestId !== latestSceneSyncRequest)
return
await applyGodotStageSceneInput(payload)
if (requestId !== latestSceneSyncRequest)
return
godotStageError.value = undefined
}
catch (error) {
if (requestId !== latestSceneSyncRequest)
return
godotStageError.value = errorMessageFrom(error) ?? 'Failed to apply model input to Godot stage.'
}
}
async function handleGodotStageToggle() {
switchingGodotStage.value = true
godotStageError.value = undefined
try {
if (usesGodotStage.value) {
godotStageStatus.value = await stopGodotStage()
settingsStore.restoreBuiltInStageModelRenderer()
return
}
godotStageStatus.value = await startGodotStage()
settingsStore.setStageModelRenderer('godot')
}
catch (error) {
godotStageError.value = errorMessageFrom(error) ?? 'Failed to switch Godot stage mode.'
await refreshGodotStageStatus()
}
finally {
switchingGodotStage.value = false
}
}
watch(
[stageModelRenderer, stageModelSelectedDisplayModel, () => godotStageStatus.value.state],
([renderer, model, stageState]) => {
if (renderer !== 'godot' || stageState !== 'running' || !model)
return
void syncGodotSceneInput(model)
},
{ immediate: true },
)
onMounted(async () => {
await refreshGodotStageStatus()
})
</script>
<template>
<div :class="['relative', 'h-full', 'flex', 'justify-center']">
<ModelSettingsPanel
:allow-extract-colors="false"
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
:settings-class="[
'w-full',
'max-w-6xl',
'h-fit',
'sm:max-h-[80dvh]',
'overflow-y-scroll',
'relative',
]"
/>
<div :class="['relative', 'h-full', 'flex flex-col items-center gap-3']">
<Callout
v-if="godotStatusMessage"
:class="['w-full max-w-6xl']"
label="Godot Stage"
theme="orange"
>
<p>{{ godotStatusMessage }}</p>
</Callout>
<div :class="['relative', 'h-full', 'flex justify-center', 'w-full']">
<ModelSettingsPanel
:allow-extract-colors="false"
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
:settings-class="[
'w-full',
'max-w-6xl',
'h-fit',
'sm:max-h-[80dvh]',
'overflow-y-scroll',
'relative',
]"
>
<template #actions>
<Button
variant="secondary"
:loading="switchingGodotStage"
:toggled="usesGodotStage"
@click="handleGodotStageToggle"
>
{{ godotToggleLabel }}
</Button>
</template>
</ModelSettingsPanel>
</div>
</div>
<div
@@ -220,6 +220,56 @@ export const electronWindowSetAlwaysOnTop = defineInvokeEventa<void, boolean>('e
export const electronAppOpenUserDataFolder = defineInvokeEventa<{ path: string }>('eventa:invoke:electron:app:open-user-data-folder')
export const electronAppQuit = defineInvokeEventa<void>('eventa:invoke:electron:app:quit')
export type ElectronGodotStageState = 'stopped' | 'starting' | 'running' | 'stopping' | 'error'
/**
* Snapshot of the Godot sidecar lifecycle owned by Electron main.
*
* Use when:
* - Renderer windows need to reflect whether the external Godot window is available
* - Settings or stage pages need lifecycle feedback after start/stop actions
*
* Expects:
* - `pid` is only set while the Godot child process exists
* - `lastError` is present for the most recent lifecycle or scene-apply failure
*
* Returns:
* - N/A
*/
export interface ElectronGodotStageStatus {
state: ElectronGodotStageState
pid: number | null
lastError?: string
updatedAt: number
}
/**
* Serialized scene input payload forwarded from renderer to Electron main.
*
* Use when:
* - The selected model should be materialized to disk and applied to the Godot scene
*
* Expects:
* - `data` contains the full model file bytes
* - `fileName` matches the original model asset name when available
*
* Returns:
* - N/A
*/
export interface ElectronGodotStageSceneInputPayload {
modelId: string
format: string
name: string
fileName: string
data: Uint8Array
}
export const electronGodotStageStart = defineInvokeEventa<ElectronGodotStageStatus>('eventa:invoke:electron:godot-stage:start')
export const electronGodotStageStop = defineInvokeEventa<ElectronGodotStageStatus>('eventa:invoke:electron:godot-stage:stop')
export const electronGodotStageGetStatus = defineInvokeEventa<ElectronGodotStageStatus>('eventa:invoke:electron:godot-stage:get-status')
export const electronGodotStageApplySceneInput = defineInvokeEventa<void, ElectronGodotStageSceneInputPayload>('eventa:invoke:electron:godot-stage:apply-scene-input')
export const electronGodotStageStatusChanged = defineEventa<ElectronGodotStageStatus>('eventa:event:electron:godot-stage:status-changed')
export type StageThreeRuntimeTraceEnvelope
= | { type: 'three-render-info', payload: ThreeSceneRenderInfoTracePayload }
| { type: 'three-hit-test-read', payload: ThreeHitTestReadTracePayload }
@@ -2,3 +2,42 @@ root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{cs,csproj,props,targets,sln}]
indent_style = space
indent_size = 4
max_line_length = 100
[*.cs]
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
dotnet_style_predefined_type_for_member_access = true:suggestion
csharp_style_var_for_built_in_types = false:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = false:suggestion
csharp_new_line_before_open_brace = all
csharp_prefer_braces = true:suggestion
csharp_preserve_single_line_statements = false
csharp_preserve_single_line_blocks = false
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_style.underscore_camel_case.required_prefix = _
dotnet_naming_style.underscore_camel_case.capitalization = camel_case
dotnet_naming_rule.private_fields_should_be_underscore_camel_case.symbols = private_fields
dotnet_naming_rule.private_fields_should_be_underscore_camel_case.style = underscore_camel_case
dotnet_naming_rule.private_fields_should_be_underscore_camel_case.severity = suggestion
dotnet_diagnostic.IDE0005.severity = warning
+60
View File
@@ -46,6 +46,66 @@ Godot-native desktop stage runtime project for `stage-tamagotchi`.
Both commands currently run `dotnet build` against the Godot-generated C# project file.
## Development Runtime
The Electron development app does not export this project on every dev run. When
Godot Stage is started from the Tamagotchi settings page, Electron main starts a
local WebSocket bridge and launches a local Godot engine against this project:
```bash
godot --path ./engines/stage-tamagotchi-godot -- --airi-ws-url=<runtime-url>
```
Set `GODOT4` before starting the Electron development app. Dev mode requires an
explicit Godot executable path and does not auto-discover local installations.
PowerShell:
```powershell
$env:GODOT4 = "C:\Path\To\Godot_v4.x-stable_mono_win64.exe"
pnpm dev:tamagotchi
```
macOS / Linux:
```bash
GODOT4="/path/to/godot" pnpm dev:tamagotchi
```
With GodotEnv:
```bash
GODOT4="$(godotenv godot env path)" pnpm dev:tamagotchi
```
Keep machine-specific Godot paths outside the repository. The current Electron
main service reads `process.env.GODOT4`, so the shell or local development
environment must provide it before starting `pnpm dev:tamagotchi`.
## Exporting
Export presets produce the sidecar runtime that Electron packages for release:
```bash
godot --headless --export-release "Windows Desktop" build/win/godot-stage.exe
godot --headless --export-release "Linux" build/linux/godot-stage
godot --headless --export-release "macOS" build/mac/godot-stage.app
```
The output directories intentionally match electron-builder's `${os}` names:
- Windows: `build/win`
- Linux: `build/linux`
- macOS: `build/mac`
`apps/stage-tamagotchi/electron-builder.config.ts` copies the matching directory
into `resources/godot-stage` via `extraResources`. To inspect an unpacked
Electron build locally, run:
```bash
pnpm -F @proj-airi/stage-tamagotchi run build:unpack
```
## Notes
### Environment Management
@@ -0,0 +1,172 @@
# Stage Tamagotchi Godot C# Development Method
This guide applies only to `engines/stage-tamagotchi-godot`.
It defines how C# code in this Godot engine should be structured and how modern
C# features should be used. Formatting and naming rules are secondary and live
in the local `.editorconfig` plus `docs/csharp-style.md`.
## 1. Scope
Use this guide for:
- Godot scene scripts
- runtime coordinators and controllers
- host-stage transport contracts
- registry and discovery code
- tooling and editor-support code inside this engine
Do not treat this guide as a repo-wide C# standard.
## 2. Layer Model
Split engine C# code into these layers before writing implementation:
1. Scene Script
- Godot-owned `Node` or `Node3D` partial classes
- lifecycle entrypoints and scene binding
2. Runtime Core
- plain C# runtime logic
- controllers, coordinators, state holders, services
3. Contract and Transport
- message types
- settings snapshots
- ready, fatal, shutdown, and state-update payloads
4. Registry and Discovery
- descriptors
- startup-time discovery
- catalogues and lookup tables
5. Tooling and Editor Support
- inspector-facing helpers
- import/export helpers
- debug or editor-only data assembly
Do not collapse these responsibilities into a single Godot script by default.
## 3. Scene Script Rules
Scene scripts should stay thin.
Use scene scripts for:
- Godot lifecycle entrypoints such as `_Ready`
- node lookup and scene wiring
- handing control to runtime objects
- bridging Godot callbacks into explicit runtime code
Avoid putting these directly into scene scripts unless the code is trivial:
- transport protocol handling
- registry construction
- complex state transitions
- business or gameplay rules
- large data transformation pipelines
If a scene script starts owning lifecycle, runtime state, protocol handling, and
tool configuration at once, split the code.
## 4. Runtime Core Rules
Put durable runtime logic into plain C# objects first.
Prefer:
- small coordinators over large all-knowing classes
- explicit state objects over hidden mutable flags
- constructor or method injection of dependencies
- clear call flow over implicit control transfer
Runtime core code should be easy to trace in a debugger. Favor explicit maps,
state, and control flow over clever abstraction.
## 5. Contract and Transport Rules
Make cross-boundary communication type-driven.
Prefer explicit types for:
- transport messages
- payloads
- settings snapshots
- descriptors
- registry entries
- runtime state snapshots
Avoid:
- `Dictionary<string, object?>` as a default contract shape
- magic-string protocols spread across files
- anonymous objects crossing subsystem boundaries
- comments standing in for real type definitions
The rule is simple: define the boundary as types first, then implement the
transport around those types.
## 6. Reflection and LINQ Policy
Reflection is for discovery, not execution.
Good uses of reflection:
- startup-time module discovery
- attribute metadata reading
- descriptor generation
- editor or tooling support
Do not use reflection for:
- per-frame logic
- runtime hot-path dispatch
- core state-machine execution
- repeated dynamic invocation in steady-state runtime
LINQ is for cold-path querying and data shaping.
Good uses of LINQ:
- building registries
- filtering descriptors
- configuration projection
- debug or tooling views
Avoid heavy LINQ in hot paths, per-frame loops, or repeatedly executed runtime
queries when an explicit index or dictionary would be clearer and cheaper.
Use this mental model:
- reflection builds the catalogue
- LINQ shapes and queries the catalogue
- runtime executes through explicit structures
## 7. Async Boundary Policy
Use async at I/O and process boundaries.
Good async boundaries:
- socket and transport setup
- file I/O
- host-side process interaction
- startup loading that is naturally asynchronous
Avoid pushing async into:
- per-frame updates
- core runtime loops
- timing-sensitive behavior that should stay explicit
Do not use async to hide lifecycle or ordering problems.
## 8. Deferred Decisions
These items are intentionally deferred and should not be guessed:
- nullable reference type rollout policy
- namespace strategy
- `record` usage boundaries
- `required` member usage boundaries
- primary constructor usage boundaries
- helper-layer versus scene-script feature allowances
When one of these becomes relevant, decide it explicitly and add it to the
engine-local guidance instead of inferring it from style tools.
@@ -0,0 +1,55 @@
# Stage Tamagotchi Godot C# Style
This is a secondary formatting and naming reference.
Read `docs/csharp-development-method.md` first for structure, layering, and
feature-usage guidance. Use this file only for low-level code style.
This profile applies only to `engines/stage-tamagotchi-godot`.
It is a simple C# code-style baseline for this Godot engine. It does not define
runtime structure, nullable policy, namespace policy, or broader architecture
rules.
## Baseline
- Microsoft: Common C# code conventions
- Microsoft: .NET code style rule options
- Godot: C# style guide
## Rules
- Use 4 spaces.
- Use LF line endings.
- Use UTF-8.
- Keep lines at 100 columns when practical.
- Use Allman braces.
- Put `System.*` usings first, then sort the rest alphabetically.
- Prefer C# keyword types such as `string`, `int`, and `bool`.
- Use `var` only when the right-hand side makes the type obvious.
- Use `PascalCase` for types and members.
- Use `camelCase` for locals and parameters.
- Use `_camelCase` for private fields.
- Remove unused `using` directives.
- Remove Godot template comments from touched files.
- Remove empty lifecycle methods from touched files unless they are intentionally kept.
- Keep `using` directives explicit. Do not enable implicit usings for this engine.
## Out of Scope
These are intentionally not decided here:
- Runtime layering and design method
- Nullable reference types
- Namespace strategy
- `record`, `required`, and primary constructors
- DTO or helper-specific style rules
## Verification
When changing C# files or the local `.editorconfig`, verify from the engine
directory with:
```powershell
dotnet format --verify-no-changes
```
@@ -0,0 +1,97 @@
[preset.0]
name="Windows Desktop"
platform="Windows Desktop"
runnable=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="build/win/godot-stage.exe"
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
[preset.0.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=0
binary_format/embed_pck=true
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
codesign/enable=false
application/modify_resources=true
application/icon=""
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version=""
application/product_version=""
application/company_name=""
application/product_name="Godot Stage"
application/file_description="AIRI Godot Stage Sidecar"
application/copyright=""
application/trademarks=""
application/export_angle=0
ssh_remote_deploy/enabled=false
[preset.1]
name="Linux"
platform="Linux/X11"
runnable=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="build/linux/godot-stage"
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
[preset.1.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=0
binary_format/embed_pck=true
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
ssh_remote_deploy/enabled=false
[preset.2]
name="macOS"
platform="macOS"
runnable=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="build/mac/godot-stage.app"
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
[preset.2.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=0
binary_format/embed_pck=true
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
codesign/codesign=0
codesign/entitlements/app_sandbox/enabled=false
notarization/notarization=0
application/bundle_identifier="ai.moeru.airi.godot-stage"
application/short_version="1.0"
application/version="1.0"
application/icon=""
ssh_remote_deploy/enabled=false
@@ -1,16 +1,205 @@
using Godot;
using System;
using System.IO;
using System.Text.Json;
using Godot;
public partial class StageRoot : Node3D
{
// Called when the node enters the scene tree for the first time.
private const string WebSocketUrlArgumentPrefix = "--airi-ws-url=";
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
private readonly WebSocketPeer _socket = new();
private Label3D _statusLabel = null!;
private bool _readyAnnounced;
private bool _shutdownRequested;
public override void _Ready()
{
GD.Print("StageRoot ready");
_statusLabel = CreateStatusLabel();
AddChild(_statusLabel);
var webSocketUrl = ResolveWebSocketUrl();
if (string.IsNullOrWhiteSpace(webSocketUrl))
{
UpdateStatus("Missing Electron bridge URL.");
GD.PushError("Godot stage missing --airi-ws-url argument.");
GetTree().Quit();
return;
}
var connectError = _socket.ConnectToUrl(webSocketUrl);
if (connectError != Error.Ok)
{
UpdateStatus("Failed to connect to Electron main.");
GD.PushError($"Godot stage failed to connect to {webSocketUrl}: {connectError}.");
GetTree().Quit();
return;
}
UpdateStatus("Connecting to Electron main...");
GD.Print($"StageRoot connecting to {webSocketUrl}");
}
// Called every frame. 'delta' is the elapsed time since the previous frame.
public override void _Process(double delta)
{
_socket.Poll();
switch (_socket.GetReadyState())
{
case WebSocketPeer.State.Open:
if (!_readyAnnounced)
{
SendEnvelope("stage.ready");
_readyAnnounced = true;
UpdateStatus("Connected to Electron main.");
}
while (_socket.GetAvailablePacketCount() > 0)
{
HandleMessage(_socket.GetPacket().GetStringFromUtf8());
}
break;
case WebSocketPeer.State.Closed:
if (_shutdownRequested)
{
GetTree().Quit();
return;
}
var message = $"Electron bridge closed ({_socket.GetCloseCode()}).";
UpdateStatus(message);
GD.PushWarning(message);
GetTree().Quit();
break;
}
}
private Label3D CreateStatusLabel()
{
return new Label3D
{
Billboard = BaseMaterial3D.BillboardModeEnum.Enabled,
FontSize = 56,
Modulate = new Color(0.95f, 0.98f, 1.0f),
PixelSize = 0.0035f,
Position = new Vector3(0.0f, 1.35f, 0.0f),
Text = "Godot Stage (experimental)",
};
}
private void HandleMessage(string rawMessage)
{
try
{
var envelope = JsonSerializer.Deserialize<GodotEnvelope>(rawMessage, _jsonOptions);
if (envelope == null || string.IsNullOrWhiteSpace(envelope.Type))
{
return;
}
switch (envelope.Type)
{
case "host.scene.apply":
ApplySceneInput(envelope.Payload);
break;
case "host.shutdown":
_shutdownRequested = true;
UpdateStatus("Shutdown requested by Electron main.");
GetTree().Quit();
break;
}
}
catch (Exception error)
{
var message = $"Failed to parse Electron message: {error.Message}";
UpdateStatus(message);
SendEnvelope("scene.error", new
{
message,
});
}
}
private void ApplySceneInput(JsonElement? payloadElement)
{
if (payloadElement == null)
{
SendEnvelope("scene.error", new
{
message = "Scene input payload was empty.",
});
return;
}
try
{
var payload = payloadElement.Value.Deserialize<SceneApplyPayload>(_jsonOptions);
if (payload == null)
{
throw new InvalidOperationException("Scene input payload could not be parsed.");
}
var fileName = Path.GetFileName(payload.Path);
UpdateStatus($"Connected to Electron main.\nModel: {payload.Name}\nAsset: {fileName}");
SendEnvelope("scene.applied", new
{
modelId = payload.ModelId,
});
}
catch (Exception error)
{
var message = $"Failed to apply scene input: {error.Message}";
UpdateStatus(message);
SendEnvelope("scene.error", new
{
message,
});
}
}
private static string ResolveWebSocketUrl()
{
foreach (var argument in OS.GetCmdlineUserArgs())
{
if (argument.StartsWith(WebSocketUrlArgumentPrefix, StringComparison.Ordinal))
{
return argument[WebSocketUrlArgumentPrefix.Length..];
}
}
return string.Empty;
}
private void SendEnvelope(string type, object payload = null)
{
if (_socket.GetReadyState() != WebSocketPeer.State.Open)
{
return;
}
_socket.SendText(JsonSerializer.Serialize(new
{
type,
payload,
}, _jsonOptions));
}
private void UpdateStatus(string message)
{
_statusLabel.Text = $"Godot Stage (experimental)\n{message}";
}
private sealed record GodotEnvelope(string Type, JsonElement? Payload);
private sealed record SceneApplyPayload(
string ModelId,
string Format,
string Name,
string Path
);
}
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { ModelSettingsRuntimeSnapshot } from './runtime'
import { Callout } from '@proj-airi/ui'
import { computed } from 'vue'
const props = defineProps<{
runtimeSnapshot: ModelSettingsRuntimeSnapshot
}>()
const statusText = computed(() => {
if (props.runtimeSnapshot.phase === 'no-model')
return 'Select Model continues to update the active scene input. Godot-specific stage controls will land here later.'
return 'Model selection stays active while the embedded scene renderer is paused. Godot-specific stage controls will land here later.'
})
</script>
<template>
<Callout label="Godot Stage (Experimental)">
<p>{{ statusText }}</p>
</Callout>
</template>
@@ -7,6 +7,7 @@ import { Button, Callout } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import Godot from './godot.vue'
import Live2D from './live2d.vue'
import VRM from './vrm.vue'
@@ -68,12 +69,13 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) {
uses 3D model that is driven by VRM / MMD open formats.
</p>
</Callout>
<div :class="['flex flex-wrap gap-2']">
<div :class="['flex flex-wrap items-center gap-2']">
<ModelSelectorDialog v-model:show="modelSelectorOpen" :selected-model="currentSelectedDisplayModel" @pick="handleModelPick">
<Button variant="secondary">
Select Model
</Button>
</ModelSelectorDialog>
<slot name="actions" />
</div>
<Live2D
v-if="effectiveRenderer === 'live2d'"
@@ -89,5 +91,9 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) {
:runtime-snapshot="runtimeSnapshot"
@extract-colors-from-model="$emit('extractColorsFromModel')"
/>
<Godot
v-if="effectiveRenderer === 'godot'"
:runtime-snapshot="runtimeSnapshot"
/>
</div>
</template>
@@ -106,6 +106,18 @@ const runtimeSnapshot = computed<ModelSettingsRuntimeSnapshot>(() => {
})
}
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(),
@@ -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 {
@@ -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"
/>
<div
v-if="stageModelRenderer === 'godot'"
:class="[
'h-full w-full',
'flex items-center justify-center',
'px-4 py-6',
]"
>
<div
:class="[
'w-96 max-w-full',
'min-h-32',
'flex items-center justify-center',
]"
>
<Callout label="Godot Stage (Experimental)">
<p>Godot Stage (experimental) is running...</p>
</Callout>
</div>
</div>
</div>
</div>
</template>
@@ -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,
}
@@ -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<StageModelRenderer, 'godot'>
export const useSettingsStageModel = defineStore('settings-stage-model', () => {
const displayModelsStore = useDisplayModelsStore()
@@ -24,6 +25,7 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => {
const stageModelSelectedDisplayModel = refManualReset<DisplayModel | undefined>(undefined)
const stageModelSelectedUrl = refManualReset<string | undefined>(undefined)
const stageModelRenderer = refManualReset<StageModelRenderer>(undefined)
const stageModelBuiltInRenderer = refManualReset<BuiltInStageModelRenderer>(undefined)
const stageViewControlsEnabled = refManualReset<boolean>(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,
}