diff --git a/.changeset/application-keyboard.md b/.changeset/application-keyboard.md new file mode 100644 index 0000000..3210e90 --- /dev/null +++ b/.changeset/application-keyboard.md @@ -0,0 +1,5 @@ +--- +"@playcanvas/react": patch +--- + +`` now creates a `Keyboard` attached to the window, so `app.keyboard` is no longer `null` and scripts and hooks can read key state, for example `app.keyboard.isPressed(KEY_RIGHT)`. It is detached when the application is destroyed on unmount. diff --git a/packages/lib/src/Application.keyboard.test.tsx b/packages/lib/src/Application.keyboard.test.tsx new file mode 100644 index 0000000..57f6a29 --- /dev/null +++ b/packages/lib/src/Application.keyboard.test.tsx @@ -0,0 +1,77 @@ +import { fireEvent, render, waitFor } from '@testing-library/react'; +import type { RenderOptions } from '@testing-library/react'; +import type { Application as PlayCanvasApplication } from 'playcanvas'; +import { KEY_RIGHT, Keyboard } from 'playcanvas'; +import { useEffect } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Application } from './Application.tsx'; +import { useApp } from './hooks/index.ts'; + +// These tests run against the real engine. The mock in test/setup.ts builds a stand-in application +// with its own input devices, so it never sees the keyboard that creates. +vi.unmock('playcanvas'); + +// Renders an application and resolves with the engine application once it has been created +const renderApplication = async (options?: RenderOptions) => { + const onApp = vi.fn(); + const CaptureApp = () => { + const app = useApp(); + useEffect(() => onApp(app), [app]); + return null; + }; + + const result = render( + + + , + options + ); + + await waitFor(() => expect(onApp).toHaveBeenCalled()); + + return { ...result, app: onApp.mock.calls[0][0] as PlayCanvasApplication }; +}; + +describe('Application keyboard', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Scripts and hooks read app.keyboard, for example to push a rigid body while a key is held + it('gives the application a keyboard that listens on the window', async () => { + const { app } = await renderApplication(); + + expect(app.keyboard).toBeInstanceOf(Keyboard); + + fireEvent.keyDown(window, { keyCode: KEY_RIGHT }); + expect(app.keyboard!.isPressed(KEY_RIGHT)).toBe(true); + + fireEvent.keyUp(window, { keyCode: KEY_RIGHT }); + expect(app.keyboard!.isPressed(KEY_RIGHT)).toBe(false); + }); + + it('leaves one keyboard attached in StrictMode and detaches it on unmount', async () => { + const attach = vi.spyOn(Keyboard.prototype, 'attach'); + const detach = vi.spyOn(Keyboard.prototype, 'detach'); + const attached = () => attach.mock.contexts.filter((keyboard) => !detach.mock.contexts.includes(keyboard)); + + // StrictMode mounts, unmounts and remounts. The first mount is either abandoned before it + // creates an application (as here, where act() flushes the remount first) or its application + // is created and then destroyed (as in a browser). Either way, one keyboard stays attached. + const { app, unmount } = await renderApplication({ reactStrictMode: true }); + const keyboard = app.keyboard!; + + expect(attached()).toHaveLength(1); + expect(attached()[0]).toBe(keyboard); + + unmount(); + + expect(attached()).toHaveLength(0); + expect(app.keyboard).toBeNull(); + + // Key presses no longer reach it + fireEvent.keyDown(window, { keyCode: KEY_RIGHT }); + expect(keyboard.isPressed(KEY_RIGHT)).toBe(false); + }); +}); diff --git a/packages/lib/src/Application.tsx b/packages/lib/src/Application.tsx index a499c73..a52ea0a 100644 --- a/packages/lib/src/Application.tsx +++ b/packages/lib/src/Application.tsx @@ -7,6 +7,7 @@ import { Application as PlayCanvasApplication, Mouse, TouchDevice, + Keyboard, RESOLUTION_FIXED, DEVICETYPE_WEBGL2, DEVICETYPE_WEBGPU, @@ -163,6 +164,9 @@ export const ApplicationWithoutCanvas: FC = (prop const pcApp = new PlayCanvasApplication(canvas, { mouse: new Mouse(canvas), touch: new TouchDevice(canvas), + // On the window, as in the Editor and , so key presses register without the + // canvas having focus. Destroying the app detaches it. + keyboard: new Keyboard(window), graphicsDevice: dev }); pcApp.start();