Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,13 @@ jobs:
timeout-minutes: 1
run: npx concurrently --kill-others --success first "npm run dev" "npx wait-on http://localhost:8050/api/game/health --timeout 20000"
working-directory: ./packages/multiplayer-template

- name: Install Playwright browsers
if: matrix.os != 'windows-latest'
run: npx playwright install --with-deps chromium
working-directory: ./packages/editor

- name: Run editor e2e tests
if: matrix.os != 'windows-latest'
run: npm run test:e2e
working-directory: ./packages/editor
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,6 @@ dist-ssr
.env
coverage
*.tsbuildinfo
test-results
playwright-report
packages/core/src/version.ts
64 changes: 64 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/core/src/Types/IEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default interface IEditor {
on(event: string, callback: (event?: any) => void): void
off(event: string, callback: (event?: any) => void): void
trigger(name: string, event?: any): void
ready: boolean
availableAssetCategories: string[]
activeAssetCategory: 'Object' | '_texture' | 'CubeTexture' | 'AudioBuffer' | 'Font'
setActiveAssetCategory(value: 'Object' | '_texture' | 'CubeTexture' | 'AudioBuffer' | 'Font'): void
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/Utils/LoadingScreen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default class LoadingScreen extends EventEmitter {
private overlay: Mesh<BufferGeometry, ShaderMaterial> | undefined

loaded = false
finished = false
progress = 0
duration = 2

Expand Down Expand Up @@ -73,8 +74,10 @@ export default class LoadingScreen extends EventEmitter {
Game.instance().uiRoot.style.opacity = `${progress}`

// Optional: remove overlay from scene after fade completes
if (progress >= 1) {
if (progress >= 1 && !this.finished) {
this.finished = true
Game.instance().scene.remove(this.overlay!)
this.trigger('finished')
}
}
}
Expand Down
103 changes: 103 additions & 0 deletions packages/core/tests/Utils/LoadingScreen.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import EventEmitter from '../../src/Utils/EventEmitter'
import LoadingScreen from '../../src/Utils/LoadingScreen'

vi.mock('../../src/Particles/Shaders/LoadingScreen/Fragment.glsl', () => ({ default: '' }))
vi.mock('../../src/Particles/Shaders/LoadingScreen/Vertex.glsl', () => ({ default: '' }))

vi.mock('three', async () => {
const actual = await vi.importActual<typeof import('three')>('three')
return {
...actual,
PlaneGeometry: vi.fn(),
ShaderMaterial: vi.fn().mockImplementation(({ uniforms }: any) => ({ uniforms })),
Mesh: vi.fn().mockImplementation((_geo: any, material: any) => ({ material })),
}
})

const mockScene = { add: vi.fn(), remove: vi.fn() }
const mockUiRoot = { style: { opacity: '0', removeProperty: vi.fn() } }
const gameListeners: Record<string, (...args: any[]) => void> = {}
const mockGame = {
on: vi.fn((event: string, cb: (...args: any[]) => void) => { gameListeners[event] = cb }),
scene: mockScene,
uiRoot: mockUiRoot,
}

vi.mock('../../src/Game', () => ({
default: { instance: vi.fn(() => mockGame) },
}))

describe('loadingScreen', () => {
let resources: EventEmitter
let loadingBar: HTMLDivElement
let loadingScreen: LoadingScreen

beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
Object.keys(gameListeners).forEach(k => delete gameListeners[k])

loadingBar = document.createElement('div')
loadingBar.id = 'loadingBar'
document.body.appendChild(loadingBar)

resources = new EventEmitter()
loadingScreen = new LoadingScreen(resources as any)
gameListeners.uiMounted?.()
})

afterEach(() => {
document.body.innerHTML = ''
vi.useRealTimers()
})

it('does nothing in update() before resources are loaded', () => {
loadingScreen.update(0.016)
expect(mockScene.remove).not.toHaveBeenCalled()
expect(loadingScreen.progress).toBe(0)
})

it('sets loaded=true when resources emit loaded', () => {
resources.trigger('loaded')
expect(loadingScreen.loaded).toBe(true)
})

it('updates loadingBar transform on progress event', () => {
resources.trigger('progress', { loaded: 1, total: 4 })
expect(loadingBar.style.transform).toBe('scaleX(0.25)')
})

it('adds ended class and removes visibility after 500ms on loaded', () => {
resources.trigger('loaded')
vi.advanceTimersByTime(500)
expect(loadingBar.classList.contains('ended')).toBe(true)
expect(mockUiRoot.style.removeProperty).toHaveBeenCalledWith('visibility')
})

it('fades the overlay during update() after loading', () => {
resources.trigger('loaded')
loadingScreen.update(1) // 1s into a 2s fade
expect(loadingScreen.progress).toBeGreaterThan(0)
expect(loadingScreen.progress).toBeLessThan(1)
})

it('emits finished, sets finished flag, removes overlay, and zeroes progress when fade completes', () => {
resources.trigger('loaded')
const onFinished = vi.fn()
loadingScreen.on('finished', onFinished)
loadingScreen.update(3) // past the 2s duration
expect(onFinished).toHaveBeenCalledOnce()
expect(loadingScreen.finished).toBe(true)
expect(mockScene.remove).toHaveBeenCalled()
expect(loadingScreen.progress).toBe(0)
})

it('does not emit finished or remove overlay more than once', () => {
resources.trigger('loaded')
loadingScreen.update(3)
loadingScreen.update(1) // extra update after completion
expect(mockScene.remove).toHaveBeenCalledOnce()
})
})
1 change: 1 addition & 0 deletions packages/editor/Global.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/// <reference types="vite/client" />
import type Game from '@mavonengine/core/Game'

declare global {
Expand Down
39 changes: 39 additions & 0 deletions packages/editor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# @mavonengine/editor

**Early WIP** — map saving and loading are not yet implemented. Currently useful mainly for browsing and viewing assets in a 3D scene.

An in-engine editor for [MavonEngine](https://mavonengine.com) — a [threejs game engine](https://mavonengine.com) — that provides a 3D scene editing interface. It can be activated at runtime by pressing `Insert` or `.` (Period), which boots the editor, destroys the current world, and mounts a React-based UI overlay.

Current features include:
- Fly camera controls for scene navigation
- Object selection and transform controls (translate, rotate, scale)
- Shade modes: solid, flat, and wireframe
- Light and object helpers
- Asset browser with category filtering
- Primitives and lights available for placement in the scene

## Testing

### E2E Tests

E2E tests **must** be run via the official Playwright Docker image. Running them directly on your local system will cause snapshot diffs to fail due to rendering differences between environments (fonts, etc.).

```bash
docker run --rm \
-v $(pwd):/app \
-w /app/packages/editor \
mcr.microsoft.com/playwright:v1.52.0-noble \
npm run test:e2e
```

### Updating Snapshots

To update snapshots, pass the `--update-snapshots` flag via the same Docker container:

```bash
docker run --rm \
-v $(pwd):/app \
-w /app/packages/editor \
mcr.microsoft.com/playwright:v1.52.0-noble \
npm run test:e2e -- --update-snapshots
```
74 changes: 74 additions & 0 deletions packages/editor/e2e/editor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { expect, test } from '@playwright/test'

test.beforeEach(async ({ page }) => {
await page.addInitScript(() => localStorage.clear())
await page.goto('/')
// Returns a Promise so Playwright waits for it rather than re-polling.
// Safe because editorRegistered fires after RAPIER.init() + dynamic import,
// both of which start after page load — so it can't have fired before we listen.
await page.waitForFunction(() =>
new Promise<boolean>((resolve) => {
const tryRegister = () => {
const game = (window as any).Game
if (game) {
game.on('editorRegistered', () => resolve(true))
}
else {
requestAnimationFrame(tryRegister)
}
}
tryRegister()
}),
)
})

test('editor opens on Insert key press', async ({ page }) => {
await page.keyboard.press('Insert')

// The editor mounts its React UI into #ui and sets the page title
await expect(page).toHaveTitle('MavonEngine | Editor')

await page.waitForFunction(() => window.Game?.loadingScreen?.finished === true)
await page.waitForFunction(() => window.Game?.editor?.ready === true)

await expect(page).toHaveScreenshot('editor-boot.png')
})

test('editor opens on Period key press', async ({ page }) => {
await page.keyboard.press('.')

await expect(page).toHaveTitle('MavonEngine | Editor')
})

test('shade mode switches to wireframe', async ({ page }) => {
await page.keyboard.press('Insert')
await expect(page).toHaveTitle('MavonEngine | Editor')

const wireframeBtn = page.getByTitle('Wireframe')
await wireframeBtn.click()

// Active button gets an extra CSS class — verify it's no longer the only button without it
const solidBtn = page.getByTitle('Solid')
await expect(solidBtn).not.toHaveClass(/active/i)
await expect(wireframeBtn).toHaveClass(/active/i)
})

test('scene explorer renders initial objects', async ({ page }) => {
await page.keyboard.press('Insert')
await expect(page).toHaveTitle('MavonEngine | Editor')

// Verify AmbientLight exists in the actual Three.js scene by traversing it.
const hasAmbientLight = await page.evaluate(() => {
let found = false
window.Game?.scene?.traverse((obj) => {
if (obj.type === 'AmbientLight')
found = true
})
return found
})
expect(hasAmbientLight).toBe(true)

// Also confirm the scene explorer UI reflects it.
// toBeAttached (not toBeVisible) because the panel ancestor uses overflow:hidden for sizing.
await expect(page.getByTestId('scene-explorer').getByText('AmbientLight')).toBeAttached()
})
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 5 additions & 1 deletion packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"main": "./dist/Editor.mjs",
"types": "./dist/Editor.d.ts",
"files": [
"README.md",
"dist"
],
"scripts": {
Expand All @@ -35,7 +36,9 @@
"test": "vitest",
"test:ui": "vitest --ui --coverage.enabled true --coverage.reporter html",
"test:coverage": "vitest --coverage.enabled true --coverage.reporter json-summary",
"test:dev": "vitest"
"test:dev": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"peerDependencies": {
"@mavonengine/core": "*",
Expand All @@ -53,6 +56,7 @@
"devDependencies": {
"@dimforge/rapier3d-compat": "0.18.2",
"@mavonengine/core": "*",
"@playwright/test": "^1.52.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.10",
Expand Down
17 changes: 17 additions & 0 deletions packages/editor/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import process from 'node:process'
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
testDir: './e2e',
timeout: 10_000,
webServer: {
command: 'npm run dev --prefix ../multiplayer-template/client',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
use: {
baseURL: 'http://localhost:5173',
...devices['Desktop Chrome'],
},
})
Loading
Loading