diff --git a/packages/capacitor-plugin/.gitignore b/packages/capacitor-plugin/.gitignore new file mode 100644 index 000000000..ff7e6a8e4 --- /dev/null +++ b/packages/capacitor-plugin/.gitignore @@ -0,0 +1,10 @@ +# Temp files +.DS_Store +Thumbs.db +Desktop.ini +npm-debug.log + +# Project specific ignore +node_modules/ +package-lock.json +build/ diff --git a/packages/capacitor-plugin/CHANGELOG.md b/packages/capacitor-plugin/CHANGELOG.md new file mode 100644 index 000000000..6770f23d1 --- /dev/null +++ b/packages/capacitor-plugin/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## [1.0.0] - _unreleased_ + +### Added +- Initial release. `CapacitorPlugin` (`BasePlugin` subclass) bridges Capacitor's `appStateChange` → `state.pause` / `state.resume`, and forwards `backButton` events into the melonJS event bus as a `CapacitorBackEvent` with a `preventDefault()` API. Defaults: pause/resume on background, also pause/resume audio, exit the app when no handler intercepts the back press. +- `bindStageBack(stage, handler)` — wires a per-stage back-button handler whose lifetime matches the stage's `onResetEvent` / `onDestroyEvent`, removing the manual `event.on` / `event.off` boilerplate. +- `onBackButton(handler)` — subscribe a global, stage-agnostic back-button handler. Returns an unsubscribe function. +- `lockOrientation(o)` / `unlockOrientation()` — lazy wrappers around `@capacitor/screen-orientation` (optional peer dep, dynamically imported only when called). +- `hideSplash(opts?)` — lazy wrapper around `@capacitor/splash-screen` (optional peer dep, dynamically imported only when called). diff --git a/packages/capacitor-plugin/LICENSE b/packages/capacitor-plugin/LICENSE new file mode 100644 index 000000000..0faa1ff90 --- /dev/null +++ b/packages/capacitor-plugin/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (C) 2011 - 2026 Olivier Biot (AltByte Pte Ltd) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/capacitor-plugin/README.md b/packages/capacitor-plugin/README.md new file mode 100644 index 000000000..f2e234eba --- /dev/null +++ b/packages/capacitor-plugin/README.md @@ -0,0 +1,136 @@ +# @melonjs/capacitor-plugin + +A [melonJS](https://melonjs.org/) plugin that bridges [Capacitor](https://capacitorjs.com/)'s native lifecycle and hardware back-button events into the engine — so a melonJS game wrapped as an iOS or Android app pauses on background, resumes on foreground, and lets each `Stage` intercept the hardware back press without manual `event.on` / `event.off` boilerplate. + +melonJS games already run inside Capacitor's WebView with zero engine changes — this plugin is purely an ergonomics layer over the standard `@capacitor/app` events, plus optional helpers for orientation lock and splash-screen dismissal. + +## Install + +```sh +npm install melonjs @melonjs/capacitor-plugin @capacitor/app +``` + +The plugin's required runtime peer is `@capacitor/app`. The orientation and splash helpers are optional and pull their respective peer deps lazily — only install them if you call those helpers: + +```sh +# only if you call lockOrientation / unlockOrientation +npm install @capacitor/screen-orientation + +# only if you call hideSplash +npm install @capacitor/splash-screen +``` + +## Quick start + +```ts +import { Application, plugin, state, Stage } from "melonjs"; +import { + CapacitorPlugin, + bindStageBack, + hideSplash, + lockOrientation, +} from "@melonjs/capacitor-plugin"; + +await lockOrientation("landscape"); + +// `new Application(...)` boots the engine and creates the renderer +// in a single call — replaces the legacy `boot()` + `video.init(...)` +// pair. +const app = new Application(1024, 768, { + parent: "screen", + scaleMethod: "flex", +}); + +// One register call wires lifecycle (appStateChange → state.pause/resume) +// and forwards hardware back-button presses into the engine event bus. +plugin.register(CapacitorPlugin, "capacitor", { + pauseAudio: true, +}); + +class PlayStage extends Stage { + onResetEvent() { + // Bind a back-button handler that lives as long as this stage. + // Calling `evt.preventDefault()` keeps the engine from running + // the default action (App.exitApp). + bindStageBack(this, (evt) => { + state.change(state.MENU); + evt.preventDefault(); + }); + } +} + +state.set(state.PLAY, new PlayStage()); +state.change(state.PLAY); +await hideSplash({ fadeOutDuration: 300 }); +``` + +## API + +### `CapacitorPlugin` + +A `BasePlugin` subclass. Register it via the engine's plugin system: + +```ts +plugin.register(CapacitorPlugin, "capacitor", options?); +``` + +Once registered the instance is reachable at `plugin.cache.capacitor` (or `plugin.get(CapacitorPlugin)`). + +Options (`ConnectCapacitorOptions`): + +| key | default | description | +|---|---|---| +| `pauseOnBackground` | `true` | When the OS sends the app to background, call `state.pause()`; resume on foreground. | +| `pauseAudio` | `true` | Forwarded to `state.pause(music)` / `state.resume(music)`. Set `false` if you want background audio to keep playing. | +| `forwardBackButton` | `true` | Forward Capacitor's `backButton` event to the engine bus so subscribers can intercept it. | +| `onUnhandledBack` | `() => App.exitApp()` | Called when no subscriber calls `evt.preventDefault()`. Override to e.g. show a confirm dialog. | + +The instance exposes a `teardown()` method that removes every Capacitor listener it installed. Mostly useful for hot-reload and unit tests. It is async: `await cap.teardown()` to wait for all removals to settle, or fire-and-forget for opportunistic detachment. + +### `bindStageBack(stage, handler)` + +Subscribe a hardware-back handler whose lifetime matches the given `Stage`'s reset/destroy lifecycle. The handler is attached in `onResetEvent` and detached in `onDestroyEvent` automatically. + +The handler receives a `CapacitorBackEvent`: + +```ts +interface CapacitorBackEvent { + readonly defaultPrevented: boolean; + preventDefault(): void; +} +``` + +If any handler calls `evt.preventDefault()`, the plugin's `onUnhandledBack` action is suppressed for that press. + +### `onBackButton(handler)` + +Subscribe a global handler (not tied to any stage). Returns an unsubscribe function. Useful if you want a back-button policy that applies regardless of the active stage. Most code should prefer `bindStageBack` for per-screen behavior. + +### `lockOrientation(o)` / `unlockOrientation()` + +Thin lazy wrappers around `@capacitor/screen-orientation`. The dependency is `import()`-ed only when these are called. + +### `hideSplash(opts?)` + +Thin lazy wrapper around `@capacitor/splash-screen`'s `hide()`. Same lazy-import pattern. + +## Capacitor project setup + +This plugin is a runtime adapter — it doesn't replace the standard Capacitor project setup. After scaffolding a melonJS game (e.g. via `npm create melonjs my-game`) wire Capacitor as you normally would: + +```sh +npm install -D @capacitor/cli +npm install @capacitor/core @capacitor/app +npx cap init my-game com.example.mygame +npx cap add ios +npx cap add android +npm run build +npx cap copy +npx cap open ios # or: npx cap open android +``` + +melonJS's Vite-based build outputs to `dist/`, which is Capacitor's default `webDir`. No additional config required. + +## License + +MIT — see `LICENSE`. diff --git a/packages/capacitor-plugin/package.json b/packages/capacitor-plugin/package.json new file mode 100644 index 000000000..d7ae38915 --- /dev/null +++ b/packages/capacitor-plugin/package.json @@ -0,0 +1,85 @@ +{ + "name": "@melonjs/capacitor-plugin", + "version": "1.0.0", + "description": "melonJS Capacitor plugin", + "homepage": "https://www.npmjs.com/package/@melonjs/capacitor-plugin", + "type": "module", + "keywords": [ + "2D", + "HTML5", + "javascript", + "TypeScript", + "ES6", + "Canvas", + "WebGL", + "WebGL2", + "melonjs", + "plugin", + "capacitor", + "ionic", + "mobile", + "hybrid", + "ios", + "android" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/melonjs/melonJS.git", + "directory": "packages/capacitor-plugin" + }, + "bugs": { + "url": "https://github.com/melonjs/melonJS/issues" + }, + "license": "MIT", + "author": "AltByte Pte Ltd", + "funding": "https://github.com/sponsors/melonjs", + "engines": { + "node": ">=24.0.0" + }, + "exports": { + ".": "./build/index.js" + }, + "types": "./build/index.d.ts", + "sideEffects": false, + "files": [ + "build/", + "package.json", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "peerDependencies": { + "melonjs": ">=18.3.0", + "@capacitor/app": ">=6.0.0", + "@capacitor/screen-orientation": ">=6.0.0", + "@capacitor/splash-screen": ">=6.0.0" + }, + "peerDependenciesMeta": { + "@capacitor/screen-orientation": { + "optional": true + }, + "@capacitor/splash-screen": { + "optional": true + } + }, + "devDependencies": { + "@capacitor/app": "^6.0.0", + "@capacitor/screen-orientation": "^6.0.0", + "@capacitor/splash-screen": "^6.0.0", + "concurrently": "^9.2.1", + "esbuild": "^0.28.0", + "melonjs": "workspace:*", + "tsconfig": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^6.0.2" + }, + "scripts": { + "dev": "concurrently --raw \"pnpm build:watch\" \"pnpm tsc:watch\"", + "build": "tsx scripts/build.ts && pnpm types", + "build:watch": "tsx scripts/build.ts watch", + "prepublishOnly": "pnpm clean && pnpm build", + "clean": "tsx scripts/clean.ts", + "types": "tsc --project tsconfig.build.json", + "tsc:watch": "tsc --project tsconfig.build.json --watch --noUnusedParameters false --noUnusedLocals false --preserveWatchOutput" + } +} diff --git a/packages/capacitor-plugin/scripts/build.ts b/packages/capacitor-plugin/scripts/build.ts new file mode 100644 index 000000000..9cf0684c2 --- /dev/null +++ b/packages/capacitor-plugin/scripts/build.ts @@ -0,0 +1,38 @@ +import esbuild, { type BuildOptions } from "esbuild"; +import packageJson from "../package.json" with { type: "json" }; + +const banner = [ + "/*!", + ` * ${packageJson.description} - ${packageJson.version}`, + " * http://www.melonjs.org", + ` * ${packageJson.name} is licensed under the MIT License.`, + " * http://www.opensource.org/licenses/mit-license", + ` * @copyright (C) 2011 - ${new Date().getFullYear()} ${packageJson.author}`, + " */", +].join("\n"); + +const buildOptions = { + entryPoints: ["src/index.ts"], + external: [ + "melonjs", + "@capacitor/app", + "@capacitor/screen-orientation", + "@capacitor/splash-screen", + ], + splitting: true, + format: "esm", + outdir: "build", + sourcemap: true, + bundle: true, + define: { __VERSION__: JSON.stringify(packageJson.version) }, + banner: { + js: banner, + }, +} satisfies BuildOptions; + +if (process.argv[2] === "watch") { + const ctx = await esbuild.context(buildOptions); + await ctx.watch({}); +} else { + await esbuild.build(buildOptions); +} diff --git a/packages/capacitor-plugin/scripts/clean.ts b/packages/capacitor-plugin/scripts/clean.ts new file mode 100644 index 000000000..bf78895f0 --- /dev/null +++ b/packages/capacitor-plugin/scripts/clean.ts @@ -0,0 +1,3 @@ +import { rm } from "node:fs/promises"; + +await rm("build", { recursive: true, force: true }); diff --git a/packages/capacitor-plugin/src/back-event.ts b/packages/capacitor-plugin/src/back-event.ts new file mode 100644 index 000000000..d2daa6a43 --- /dev/null +++ b/packages/capacitor-plugin/src/back-event.ts @@ -0,0 +1,68 @@ +import { event } from "melonjs"; +import type { CapacitorBackEvent } from "./types.ts"; + +/** Internal event name used on the melonJS event bus. */ +export const BACK_EVENT = "@melonjs/capacitor-plugin:backButton"; + +// `event.on` / `event.emit` / `event.off` are typed against an +// internal `keyof Events` registry that this plugin can't augment +// from outside. The cast is contained: the BACK_EVENT name is unique +// to this plugin, and the listener signature is enforced by the +// public `onBackButton` / `bindStageBack` APIs. +type BackBus = { + on(name: string, h: (evt: CapacitorBackEvent) => void): void; + off(name: string, h: (evt: CapacitorBackEvent) => void): void; + emit(name: string, evt: CapacitorBackEvent): void; +}; +const bus = event as unknown as BackBus; + +/** + * Dispatch a fresh `CapacitorBackEvent` on the engine bus. + * @returns `true` if any handler called `preventDefault()`, `false` otherwise. + */ +export function emitBackEvent(): boolean { + const evt: CapacitorBackEvent = { + defaultPrevented: false, + preventDefault() { + (this as { defaultPrevented: boolean }).defaultPrevented = true; + }, + }; + bus.emit(BACK_EVENT, evt); + return evt.defaultPrevented; +} + +/** + * Subscribe a global hardware-back handler. Use `bindStageBack()` + * instead if the handler should only be active while a specific + * `Stage` is reset. + * @param handler - called with a `CapacitorBackEvent` on every back press. + * @returns an unsubscribe function. + */ +export function onBackButton( + handler: (evt: CapacitorBackEvent) => void, +): () => void { + bus.on(BACK_EVENT, handler); + return () => { + bus.off(BACK_EVENT, handler); + }; +} + +/** + * @internal + * @param handler - back-button handler to subscribe. + */ +export function subscribeBackHandler( + handler: (evt: CapacitorBackEvent) => void, +): void { + bus.on(BACK_EVENT, handler); +} + +/** + * @internal + * @param handler - back-button handler to unsubscribe. + */ +export function unsubscribeBackHandler( + handler: (evt: CapacitorBackEvent) => void, +): void { + bus.off(BACK_EVENT, handler); +} diff --git a/packages/capacitor-plugin/src/bind-stage-back.ts b/packages/capacitor-plugin/src/bind-stage-back.ts new file mode 100644 index 000000000..810f32829 --- /dev/null +++ b/packages/capacitor-plugin/src/bind-stage-back.ts @@ -0,0 +1,88 @@ +import type { Application, Stage } from "melonjs"; +import { subscribeBackHandler, unsubscribeBackHandler } from "./back-event.ts"; +import type { CapacitorBackEvent } from "./types.ts"; + +interface StageBinding { + /** the currently-bound handler (or null when none) */ + handler: ((evt: CapacitorBackEvent) => void) | null; + /** whether `handler` is currently subscribed on the engine bus */ + bound: boolean; +} + +// Per-stage binding registry. Keyed by Stage identity so the entry +// is GC'd along with the stage. `bindStageBack` wraps each stage's +// `onResetEvent` / `onDestroyEvent` exactly once; subsequent calls +// replace the bound handler in place rather than re-wrapping (which +// would stack closures and leak across destroy/reset cycles). +const stageBindings = new WeakMap(); + +/** + * Wire a hardware-back handler whose lifetime matches the given + * `Stage`'s. The handler is subscribed immediately (so it's active + * for the current stage activation when this is called from inside + * `onResetEvent`) and unsubscribed in `onDestroyEvent`. Idempotent + * across destroy/reset cycles: each stage carries at most one + * back-button handler at a time, and re-calling `bindStageBack` on + * the same stage replaces the previous handler. + * @param stage - the stage whose lifecycle drives the subscription. + * @param handler - called with a `CapacitorBackEvent` on every back press while the stage is active. + * @example + * class PlayStage extends Stage { + * onResetEvent() { + * bindStageBack(this, (evt) => { + * state.change(state.MENU); + * evt.preventDefault(); // do not quit + * }); + * } + * } + */ +export function bindStageBack( + stage: Stage, + handler: (evt: CapacitorBackEvent) => void, +): void { + let entry = stageBindings.get(stage); + + if (entry === undefined) { + // First call for this stage: install method wrappers exactly + // once, then store the binding entry. Subsequent calls reuse + // the wrappers and just swap `entry.handler`. + const fresh: StageBinding = { handler: null, bound: false }; + entry = fresh; + stageBindings.set(stage, fresh); + + const origReset = stage.onResetEvent.bind(stage); + const origDestroy = stage.onDestroyEvent.bind(stage); + stage.onResetEvent = function (app: Application, ...args: unknown[]) { + if (fresh.handler !== null && !fresh.bound) { + subscribeBackHandler(fresh.handler); + fresh.bound = true; + } + origReset(app, ...args); + }; + stage.onDestroyEvent = function (app: Application) { + if (fresh.handler !== null && fresh.bound) { + unsubscribeBackHandler(fresh.handler); + fresh.bound = false; + } + origDestroy(app); + }; + } + + // Replace the previously-bound handler (if any) with the new one. + if (entry.handler !== handler) { + if (entry.bound && entry.handler !== null) { + unsubscribeBackHandler(entry.handler); + entry.bound = false; + } + entry.handler = handler; + } + + // Subscribe eagerly so the handler is active for the current stage + // activation — the typical call site is inside `onResetEvent`, + // which already passed the wrapper's subscribe step by the time + // this code runs. + if (!entry.bound) { + subscribeBackHandler(entry.handler); + entry.bound = true; + } +} diff --git a/packages/capacitor-plugin/src/connect.ts b/packages/capacitor-plugin/src/connect.ts new file mode 100644 index 000000000..c57710211 --- /dev/null +++ b/packages/capacitor-plugin/src/connect.ts @@ -0,0 +1,89 @@ +import { App } from "@capacitor/app"; +import { state } from "melonjs"; +import { emitBackEvent } from "./back-event.ts"; +import type { ConnectCapacitorOptions } from "./types.ts"; + +interface CapacitorListenerHandle { + remove(): Promise; +} + +/** + * Wire Capacitor's lifecycle and back-button events into the engine. + * Most users should register `CapacitorPlugin` instead of calling + * this directly. + * @param options - tuning for which events to forward and the default + * action when no handler intercepts a back press. + * @returns an async teardown function. Awaiting it resolves once + * every Capacitor listener has been removed (using + * `Promise.allSettled`, so a single failed removal does not abort + * the rest); calling it without awaiting still detaches everything + * in the background. + */ +export function connectCapacitor( + options: ConnectCapacitorOptions = {}, +): () => Promise { + const { + pauseOnBackground = true, + pauseAudio = true, + forwardBackButton = true, + // Return the promise from `App.exitApp()` so a rejection flows + // through the same async-handling path used for user-provided + // `onUnhandledBack` handlers. + onUnhandledBack = () => App.exitApp(), + } = options; + + const handles: CapacitorListenerHandle[] = []; + + if (pauseOnBackground) { + handles.push( + App.addListener("appStateChange", ({ isActive }) => { + if (isActive) { + state.resume(pauseAudio); + } else { + state.pause(pauseAudio); + } + }) as unknown as CapacitorListenerHandle, + ); + } + + if (forwardBackButton) { + handles.push( + App.addListener("backButton", () => { + if (!emitBackEvent()) { + // `onUnhandledBack` is allowed to be async; wrap so a + // thrown / rejected promise surfaces as a console + // warning instead of an unhandled rejection. + try { + const ret = onUnhandledBack(); + if (ret && typeof ret.then === "function") { + ret.catch((err: unknown) => { + console.warn( + "[@melonjs/capacitor-plugin] onUnhandledBack rejected:", + err, + ); + }); + } + } catch (err) { + console.warn( + "[@melonjs/capacitor-plugin] onUnhandledBack threw:", + err, + ); + } + } + }) as unknown as CapacitorListenerHandle, + ); + } + + return async () => { + const results = await Promise.allSettled(handles.map((h) => h.remove())); + handles.length = 0; + for (const r of results) { + if (r.status === "rejected") { + console.warn( + "[@melonjs/capacitor-plugin] failed to remove Capacitor listener:", + r.reason, + ); + } + } + }; +} diff --git a/packages/capacitor-plugin/src/index.ts b/packages/capacitor-plugin/src/index.ts new file mode 100644 index 000000000..7423409ff --- /dev/null +++ b/packages/capacitor-plugin/src/index.ts @@ -0,0 +1,9 @@ +export { onBackButton } from "./back-event.ts"; +export { bindStageBack } from "./bind-stage-back.ts"; +export { lockOrientation, unlockOrientation } from "./orientation.ts"; +export { CapacitorPlugin } from "./plugin.ts"; +export { hideSplash } from "./splash.ts"; +export type { + CapacitorBackEvent, + ConnectCapacitorOptions, +} from "./types.ts"; diff --git a/packages/capacitor-plugin/src/orientation.ts b/packages/capacitor-plugin/src/orientation.ts new file mode 100644 index 000000000..1396a41a4 --- /dev/null +++ b/packages/capacitor-plugin/src/orientation.ts @@ -0,0 +1,18 @@ +/** + * Lock the device orientation via `@capacitor/screen-orientation`. + * The dependency is loaded lazily and is an optional peer — install + * it only if you call this. + * @param o - target orientation (`"portrait"` or `"landscape"`). + */ +export async function lockOrientation( + o: "portrait" | "landscape", +): Promise { + const { ScreenOrientation } = await import("@capacitor/screen-orientation"); + await ScreenOrientation.lock({ orientation: o }); +} + +/** Release any orientation lock. Pairs with `lockOrientation`. */ +export async function unlockOrientation(): Promise { + const { ScreenOrientation } = await import("@capacitor/screen-orientation"); + await ScreenOrientation.unlock(); +} diff --git a/packages/capacitor-plugin/src/plugin.ts b/packages/capacitor-plugin/src/plugin.ts new file mode 100644 index 000000000..16abe4458 --- /dev/null +++ b/packages/capacitor-plugin/src/plugin.ts @@ -0,0 +1,38 @@ +import { plugin } from "melonjs"; +import packageJson from "../package.json" with { type: "json" }; +import { connectCapacitor } from "./connect.ts"; +import type { ConnectCapacitorOptions } from "./types.ts"; + +/** + * melonJS plugin that bridges Capacitor's native lifecycle and + * hardware-back events into the engine. Register via + * `plugin.register(CapacitorPlugin, "capacitor", options?)` — the + * Capacitor wiring is installed automatically on registration. + * + * The instance is then accessible at `plugin.cache.capacitor`; call + * `instance.teardown()` to detach all listeners (typically only + * needed for hot-reload or unit tests). + */ +export class CapacitorPlugin extends plugin.BasePlugin { + /** + * Detach every Capacitor listener wired by this plugin. Returns a + * promise that resolves once all `remove()` calls have settled — + * `await` it from tests / hot-reload paths for deterministic + * cleanup, or fire-and-forget for opportunistic detachment. + */ + teardown: () => Promise; + + /** + * @param options - forwarded to the underlying `connectCapacitor` + * wiring (lifecycle, back-button, audio, default-action hook). + */ + constructor(options: ConnectCapacitorOptions = {}) { + super(); + // Compatibility floor: read straight from `peerDependencies.melonjs` + // so the version lives in one place (package.json). The engine's + // `checkVersion` regex picks the digits out of the range string, + // so the leading `>=` / `^` / `~` is harmless. + this.version = packageJson.peerDependencies.melonjs; + this.teardown = connectCapacitor(options); + } +} diff --git a/packages/capacitor-plugin/src/splash.ts b/packages/capacitor-plugin/src/splash.ts new file mode 100644 index 000000000..bad3f4e67 --- /dev/null +++ b/packages/capacitor-plugin/src/splash.ts @@ -0,0 +1,14 @@ +/** + * Hide Capacitor's splash screen via `@capacitor/splash-screen`. + * Optional peer dep — install it only if you call this. Typically + * called after the first frame has rendered (e.g. inside the first + * `Stage.onResetEvent`). + * @param opts - forwarded to `SplashScreen.hide()`. + * @param opts.fadeOutDuration - fade-out duration in milliseconds. + */ +export async function hideSplash(opts?: { + fadeOutDuration?: number; +}): Promise { + const { SplashScreen } = await import("@capacitor/splash-screen"); + await SplashScreen.hide(opts); +} diff --git a/packages/capacitor-plugin/src/types.ts b/packages/capacitor-plugin/src/types.ts new file mode 100644 index 000000000..2a9e347ad --- /dev/null +++ b/packages/capacitor-plugin/src/types.ts @@ -0,0 +1,43 @@ +/** + * Event payload dispatched on every Capacitor hardware-back press. + * Mirrors the DOM `Event` shape: handlers can call `preventDefault()` + * to keep the engine from running the default action (typically + * exiting the app). + */ +export interface CapacitorBackEvent { + readonly defaultPrevented: boolean; + preventDefault(): void; +} + +/** + * Options accepted by `CapacitorPlugin` (and the underlying + * `connectCapacitor` wiring). + */ +export interface ConnectCapacitorOptions { + /** + * Auto-pause the engine when the app goes to background and + * resume when it comes back. Default `true`. + */ + pauseOnBackground?: boolean; + + /** + * Forwarded to `state.pause(music)` / `state.resume(music)` — + * also pauses/resumes the current audio track. Default `true`. + */ + pauseAudio?: boolean; + + /** + * Forward Capacitor's `backButton` event into the engine event bus. + * When `false`, the plugin leaves back-button handling entirely to + * user code. Default `true`. + */ + forwardBackButton?: boolean; + + /** + * Called when a `backButton` event is dispatched and no handler + * calls `evt.preventDefault()`. Default `() => App.exitApp()` + * (quits the native app). May be async — useful for confirm + * dialogs or other awaitable user prompts. + */ + onUnhandledBack?: () => void | Promise; +} diff --git a/packages/capacitor-plugin/tsconfig.build.json b/packages/capacitor-plugin/tsconfig.build.json new file mode 100644 index 000000000..3130a17e2 --- /dev/null +++ b/packages/capacitor-plugin/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "tsconfig/base.json", + "compilerOptions": { + "rootDir": "src", + "declarationDir": "build", + "removeComments": false, + "declarationMap": true, + "declaration": true, + "emitDeclarationOnly": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/packages/capacitor-plugin/tsconfig.json b/packages/capacitor-plugin/tsconfig.json new file mode 100644 index 000000000..83b90bc4d --- /dev/null +++ b/packages/capacitor-plugin/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "tsconfig/base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src", "scripts"] +} diff --git a/packages/create-melonjs/CHANGELOG.md b/packages/create-melonjs/CHANGELOG.md index 38ced3115..ec2c8f02c 100644 --- a/packages/create-melonjs/CHANGELOG.md +++ b/packages/create-melonjs/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1.1.0 + +### New Features +- `--template ` (alias `-t`) flag to pick a starter template. `default` (current behavior, points at `melonjs/typescript-boilerplate`) and `capacitor` (points at `melonjs/typescript-boilerplate-capacitor` for iOS/Android wrapping via Capacitor) are recognized; any other value errors out with the list of known templates. Output now reports which template was used and prints template-specific next-step instructions (capacitor includes `cap add` / `cap copy` / `cap open`). + ## 1.0.1 ### Bug Fixes diff --git a/packages/create-melonjs/README.md b/packages/create-melonjs/README.md index 5fae52d6d..f6f4ca726 100644 --- a/packages/create-melonjs/README.md +++ b/packages/create-melonjs/README.md @@ -18,6 +18,19 @@ This downloads the [melonJS TypeScript boilerplate](https://github.com/melonjs/t - [Vite](https://vitejs.dev) — fast dev server and bundler - [Debug plugin](https://github.com/melonjs/debug-plugin) — auto-loaded in development mode +## Templates + +Pick a different starter with `--template ` (or `-t`): + +```bash +npm create melonjs my-game --template capacitor +``` + +| name | source | description | +| --- | --- | --- | +| `default` | [`melonjs/typescript-boilerplate`](https://github.com/melonjs/typescript-boilerplate) | Plain TypeScript + Vite (used when no `--template` flag is passed). | +| `capacitor` | [`melonjs/typescript-boilerplate-capacitor`](https://github.com/melonjs/typescript-boilerplate-capacitor) | TypeScript + Vite + [Capacitor](https://capacitorjs.com/) wrapper for iOS / Android, pre-wired with [`@melonjs/capacitor-plugin`](https://github.com/melonjs/melonJS/tree/master/packages/capacitor-plugin). | + ## Links - [melonJS Documentation](https://melonjs.github.io/melonJS/) diff --git a/packages/create-melonjs/bin/create-melonjs.js b/packages/create-melonjs/bin/create-melonjs.js index 2e728db46..9d16cfb5e 100755 --- a/packages/create-melonjs/bin/create-melonjs.js +++ b/packages/create-melonjs/bin/create-melonjs.js @@ -5,22 +5,49 @@ import { spawnSync } from "node:child_process"; import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; -const REPO = "melonjs/typescript-boilerplate"; +const TEMPLATES = { + default: "melonjs/typescript-boilerplate", + capacitor: "melonjs/typescript-boilerplate-capacitor", +}; const BRANCH = "main"; const green = (text) => `\x1b[32m${text}\x1b[0m`; const bold = (text) => `\x1b[1m${text}\x1b[0m`; const dim = (text) => `\x1b[2m${text}\x1b[0m`; +function parseArgs(argv) { + const args = { positional: [], template: "default" }; + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; + if (token === "--template" || token === "-t") { + args.template = argv[++i]; + } else if (token?.startsWith("--template=")) { + args.template = token.slice("--template=".length); + } else if (token?.startsWith("-")) { + console.error(`\nError: unknown flag "${token}".\n`); + process.exit(1); + } else { + args.positional.push(token); + } + } + return args; +} + function main() { - const projectName = process.argv[2]; + const { positional, template } = parseArgs(process.argv.slice(2)); + const projectName = positional[0]; if (!projectName) { console.log(` -${bold("Usage:")} npm create melonjs ${dim("")} +${bold("Usage:")} npm create melonjs ${dim("")} ${dim("[--template ]")} + +${bold("Templates:")} + ${dim("default")} Plain TypeScript + Vite boilerplate (default) + ${dim("capacitor")} TypeScript + Vite + Capacitor wrapper for iOS/Android -${bold("Example:")} +${bold("Examples:")} npm create melonjs my-game + npm create melonjs my-game --template capacitor cd my-game npm install npm run dev @@ -28,6 +55,14 @@ ${bold("Example:")} process.exit(1); } + const repo = TEMPLATES[template]; + if (!repo) { + console.error( + `\nError: unknown template "${template}". Known templates: ${Object.keys(TEMPLATES).join(", ")}.\n`, + ); + process.exit(1); + } + const targetDir = resolve(projectName); if (existsSync(targetDir)) { @@ -35,13 +70,15 @@ ${bold("Example:")} process.exit(1); } - console.log(`\nCreating a new melonJS game in ${green(targetDir)}...\n`); + console.log( + `\nCreating a new melonJS game in ${green(targetDir)} (template: ${bold(template)})...\n`, + ); // download the boilerplate // try degit first (fast, no git history) const degitResult = spawnSync( "npx", - ["--yes", "degit", `${REPO}#${BRANCH}`, targetDir], + ["--yes", "degit", `${repo}#${BRANCH}`, targetDir], { stdio: "inherit", shell: process.platform === "win32", @@ -59,7 +96,7 @@ ${bold("Example:")} "1", "-b", BRANCH, - `https://github.com/${REPO}.git`, + `https://github.com/${repo}.git`, targetDir, ], { stdio: "inherit" }, @@ -83,14 +120,25 @@ ${bold("Example:")} writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); } + const nextSteps = + template === "capacitor" + ? ` ${dim("$")} cd ${projectName} + ${dim("$")} npm install + ${dim("$")} npm run dev ${dim("# develop in the browser")} + ${dim("$")} npm run build + ${dim("$")} npx cap add ios ${dim("# or: npx cap add android")} + ${dim("$")} npx cap copy + ${dim("$")} npx cap open ios ${dim("# build & run from Xcode / Android Studio")}` + : ` ${dim("$")} cd ${projectName} + ${dim("$")} npm install + ${dim("$")} npm run dev`; + console.log(` ${green("Done!")} Created ${bold(projectName)}. Next steps: - ${dim("$")} cd ${projectName} - ${dim("$")} npm install - ${dim("$")} npm run dev +${nextSteps} Happy game making! ${green("🍈")} `); diff --git a/packages/create-melonjs/package.json b/packages/create-melonjs/package.json index 92496fb08..07b36c898 100644 --- a/packages/create-melonjs/package.json +++ b/packages/create-melonjs/package.json @@ -1,6 +1,6 @@ { "name": "create-melonjs", - "version": "1.0.1", + "version": "1.1.0", "description": "Create a new melonJS game project", "type": "module", "license": "MIT", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f2e748d5..9afebf1f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,36 @@ importers: specifier: ^1.59.1 version: 1.59.1 + packages/capacitor-plugin: + devDependencies: + '@capacitor/app': + specifier: ^6.0.0 + version: 6.0.3(@capacitor/core@6.2.1) + '@capacitor/screen-orientation': + specifier: ^6.0.0 + version: 6.0.4(@capacitor/core@6.2.1) + '@capacitor/splash-screen': + specifier: ^6.0.0 + version: 6.0.4(@capacitor/core@6.2.1) + concurrently: + specifier: ^9.2.1 + version: 9.2.1 + esbuild: + specifier: 0.28.0 + version: 0.28.0 + melonjs: + specifier: workspace:* + version: link:../melonjs + tsconfig: + specifier: workspace:* + version: link:../tsconfig + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + packages/create-melonjs: {} packages/debug-plugin: @@ -308,6 +338,24 @@ packages: '@blazediff/core@1.9.1': resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@capacitor/app@6.0.3': + resolution: {integrity: sha512-4gFUCbcVz0N/YYN32OBFerocWXslIv3Nc90gDiRsBkJc0plwK6kIUT6PKa5WtW2kfhteUeCVXQbvArH2fH+0Ug==} + peerDependencies: + '@capacitor/core': ^6.0.0 + + '@capacitor/core@6.2.1': + resolution: {integrity: sha512-urZwxa7hVE/BnA18oCFAdizXPse6fCKanQyEqpmz6cBJ2vObwMpyJDG5jBeoSsgocS9+Ax+9vb4ducWJn0y2qQ==} + + '@capacitor/screen-orientation@6.0.4': + resolution: {integrity: sha512-BKuGqUGvN7hSEmotI7m8UlGRRBGd9YpAzlnQAXp9VNvKkinSkreMHPrEOiR/sG3QX/vR/BJ190tGQiICmylcTg==} + peerDependencies: + '@capacitor/core': ^6.0.0 + + '@capacitor/splash-screen@6.0.4': + resolution: {integrity: sha512-uJXR+28cdaie7zIIUBvgkWgHim6Gr1itJym9voIMTmrjXkOaPtejwxYJsdQWPJz9zgGnSbXuC1mNNibLgv3OpQ==} + peerDependencies: + '@capacitor/core': ^6.0.0 + '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} @@ -2174,6 +2222,22 @@ snapshots: '@blazediff/core@1.9.1': {} + '@capacitor/app@6.0.3(@capacitor/core@6.2.1)': + dependencies: + '@capacitor/core': 6.2.1 + + '@capacitor/core@6.2.1': + dependencies: + tslib: 2.8.1 + + '@capacitor/screen-orientation@6.0.4(@capacitor/core@6.2.1)': + dependencies: + '@capacitor/core': 6.2.1 + + '@capacitor/splash-screen@6.0.4(@capacitor/core@6.2.1)': + dependencies: + '@capacitor/core': 6.2.1 + '@emnapi/core@1.9.2': dependencies: '@emnapi/wasi-threads': 1.2.1