,
+ window: Window,
+) => {
+ if (!entities || !window) {
+ return null;
+ }
+
+ return Object.keys(entities)
+ .filter((key) => entities[key].renderer)
+ .map(key => {
+ const entity = entities[key];
+
+ if (typeof entity.renderer === "object")
+ return (
+
+ );
+ else if (typeof entity.renderer === "function")
+ return (
+
+ );
+ });
+};
+
+export default DefaultRenderer;
diff --git a/src/DefaultTimer.js b/src/DefaultTimer.tsx
similarity index 77%
rename from src/DefaultTimer.js
rename to src/DefaultTimer.tsx
index d2b72c4..ca23e60 100644
--- a/src/DefaultTimer.js
+++ b/src/DefaultTimer.tsx
@@ -1,4 +1,4 @@
-/*
+/*
With thanks, https://github.com/FormidableLabs/react-game-kit/blob/master/src/native/utils/game-loop.js
*/
@@ -15,40 +15,38 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
*/
export default class DefaultTimer {
- constructor() {
- this.subscribers = [];
- this.loopId = null;
- }
+ subscribers: ((time: number) => void)[] = [];
+ timestamp: number | null = null;
- loop = time => {
- if (this.loopId) {
+ loop = (time: number) => {
+ if (this.timestamp) {
this.subscribers.forEach(callback => {
callback(time);
});
}
- this.loopId = requestAnimationFrame(this.loop);
+ this.timestamp = requestAnimationFrame(this.loop);
};
start() {
- if (!this.loopId) {
- this.loop();
+ if (!this.timestamp) {
+ this.loop(undefined!);
}
}
stop() {
- if (this.loopId) {
- cancelAnimationFrame(this.loopId);
- this.loopId = null;
+ if (this.timestamp) {
+ cancelAnimationFrame(this.timestamp);
+ this.timestamp = null;
}
}
- subscribe(callback) {
+ subscribe(callback: (time: number) => void) {
if (this.subscribers.indexOf(callback) === -1)
this.subscribers.push(callback);
}
- unsubscribe(callback) {
+ unsubscribe(callback: (time: number) => void) {
this.subscribers = this.subscribers.filter(s => s !== callback)
}
}
diff --git a/src/GameEngine.js b/src/GameEngine.js
deleted file mode 100644
index ebd7a70..0000000
--- a/src/GameEngine.js
+++ /dev/null
@@ -1,177 +0,0 @@
-import React, { Component } from "react";
-import DefaultTimer from "./DefaultTimer";
-import DefaultRenderer from "./DefaultRenderer";
-
-const getEntitiesFromProps = props =>
- props.initState ||
- props.initialState ||
- props.state ||
- props.initEntities ||
- props.initialEntities ||
- props.entities;
-
-const isPromise = obj => {
- return !!(
- obj &&
- obj.then &&
- obj.then.constructor &&
- obj.then.call &&
- obj.then.apply
- );
-};
-
-const events = `onClick onContextMenu onDoubleClick onDrag onDragEnd onDragEnter onDragExit onDragLeave onDragOver onDragStart onDrop onMouseDown onMouseEnter onMouseLeave onMouseMove onMouseOut onMouseOver onMouseUp onWheel onTouchCancel onTouchEnd onTouchMove onTouchStart onKeyDown onKeyPress onKeyUp`;
-
-export default class GameEngine extends Component {
- constructor(props) {
- super(props);
- this.state = {
- entities: null
- };
- this.timer = props.timer || new DefaultTimer();
- this.input = [];
- this.previousTime = null;
- this.previousDelta = null;
- this.events = [];
- this.container = React.createRef();
- }
-
- async componentDidMount() {
- this.timer.subscribe(this.updateHandler);
-
- let entities = getEntitiesFromProps(this.props);
-
- if (isPromise(entities)) entities = await entities;
-
- this.setState(
- {
- entities: entities || {}
- },
- () => {
- if (this.props.running) this.start();
- }
- );
- }
-
- componentWillUnmount() {
- this.stop();
- this.timer.unsubscribe(this.updateHandler);
- }
-
- componentDidUpdate(prevProps) {
- if (prevProps.running !== this.props.running) {
- if (this.props.running) this.start();
- else this.stop();
- }
- }
-
- clear = () => {
- this.input.length = 0;
- this.events.length = 0;
- this.previousTime = null;
- this.previousDelta = null;
- };
-
- start = () => {
- this.clear();
- this.timer.start();
- this.dispatch({ type: "started" });
- this.container.current.focus();
- };
-
- stop = () => {
- this.timer.stop();
- this.dispatch({ type: "stopped" });
- };
-
- swap = async newEntities => {
- if (isPromise(newEntities)) newEntities = await newEntities;
-
- this.setState({ entities: newEntities || {} }, () => {
- this.clear();
- this.dispatch({ type: "swapped" });
- });
- };
-
- defer = e => {
- this.dispatch(e);
- };
-
- dispatch = e => {
- setTimeout(() => {
- this.events.push(e);
- if (this.props.onEvent) this.props.onEvent(e);
- }, 0);
- };
-
- updateHandler = currentTime => {
- let args = {
- input: this.input,
- window: window,
- events: this.events,
- dispatch: this.dispatch,
- defer: this.defer,
- time: {
- current: currentTime,
- previous: this.previousTime,
- delta: currentTime - (this.previousTime || currentTime),
- previousDelta: this.previousDelta
- }
- };
-
- this.setState(prevState => {
- let newEntities = this.props.systems.reduce(
- (state, sys) => sys(state, args),
- prevState.entities
- );
- this.input.length = 0;
- this.events.length = 0;
- this.previousTime = currentTime;
- this.previousDelta = args.time.delta;
- return { entities: newEntities};
- });
- };
-
- inputHandlers = events
- .split(" ")
- .map(name => ({
- name,
- handler: payload => {
- payload.persist();
- this.input.push({ name, payload });
- }
- }))
- .reduce((acc, val) => {
- acc[val.name] = val.handler;
- return acc;
- }, {});
-
- render() {
- return (
-
- {this.props.renderer(this.state.entities, window)}
- {this.props.children}
-
- );
- }
-}
-
-GameEngine.defaultProps = {
- systems: [],
- entities: {},
- renderer: DefaultRenderer,
- running: true
-};
-
-const css = {
- container: {
- flex: 1,
- outline: "none"
- }
-};
diff --git a/src/GameEngine.tsx b/src/GameEngine.tsx
new file mode 100644
index 0000000..99d9d05
--- /dev/null
+++ b/src/GameEngine.tsx
@@ -0,0 +1,216 @@
+import React, { Component } from 'react';
+
+import DefaultTimer from './DefaultTimer';
+import DefaultRenderer from './DefaultRenderer';
+
+import type { EventName } from './events';
+import EVENTS from './events';
+
+interface InitialStateProps {
+ initState?: T;
+ initialState?: T;
+ state?: T;
+ initEntities?: T;
+ initialEntities?: T;
+ entities?: T;
+}
+
+const getEntitiesFromProps = (props: InitialStateProps) =>
+ props.initState ??
+ props.initialState ??
+ props.state ??
+ props.initEntities ??
+ props.initialEntities ??
+ props.entities ?? {} as T;
+
+interface InternalEvent {
+ type: string;
+}
+
+type Input = React.MouseEvent | React.TouchEvent | React.KeyboardEvent;
+
+export interface SystemArgs {
+ input: { name: EventName, payload: Input }[];
+ window: Window;
+ events: (U | InternalEvent)[];
+ dispatch: (e: U | InternalEvent) => void;
+ defer: (e: U) => void;
+ time: {
+ current: number;
+ previous: number | null;
+ delta: number;
+ previousDelta: number | null;
+ };
+}
+
+interface GameEngineProps extends InitialStateProps {
+ running?: boolean;
+ onEvent: (e: U | InternalEvent) => void;
+ systems?: ((state: T, context: SystemArgs) => T)[];
+ style?: React.CSSProperties;
+ className?: string;
+ renderer: (entities: T, window: Window) => JSX.Element;
+ children: JSX.Element;
+ timer: DefaultTimer;
+}
+
+export default class GameEngine extends Component, { entities: T }> {
+ static defaultProps = {
+ systems: [],
+ entities: {},
+ renderer: DefaultRenderer,
+ running: true,
+ };
+
+ timer: DefaultTimer;
+ input: { name: EventName, payload: Input }[];
+ previousTime: number | null;
+ previousDelta: number | null;
+ events: (U | InternalEvent)[];
+ container: React.RefObject;
+ state: { entities: T };
+
+ constructor(props: GameEngineProps) {
+ super(props);
+ this.state = {
+ entities: null!,
+ };
+ this.timer = props.timer || new DefaultTimer();
+ this.input = [];
+ this.previousTime = null;
+ this.previousDelta = null;
+ this.events = [];
+ this.container = React.createRef();
+ }
+
+ async componentDidMount() {
+ this.timer.subscribe(this.updateHandler);
+
+ this.setState(
+ {
+ entities: await Promise.resolve(getEntitiesFromProps(this.props)),
+ },
+ () => {
+ if (this.props.running) {
+ this.start();
+ }
+ }
+ );
+ }
+
+ componentWillUnmount() {
+ this.stop();
+ this.timer.unsubscribe(this.updateHandler);
+ }
+
+ componentDidUpdate(prevProps: GameEngineProps) {
+ if (prevProps.running !== this.props.running) {
+ this.props.running ? this.start() : this.stop();
+ }
+ }
+
+ clear = () => {
+ this.input.length = 0;
+ this.events.length = 0;
+ this.previousTime = null;
+ this.previousDelta = null;
+ };
+
+ start = () => {
+ this.clear();
+ this.timer.start();
+ this.dispatch({ type: 'started' });
+ this.container.current?.focus();
+ };
+
+ stop = () => {
+ this.timer.stop();
+ this.dispatch({ type: 'stopped' });
+ };
+
+ swap = async (newEntities: T) => {
+ this.setState(
+ {
+ entities: await Promise.resolve(newEntities ?? {} as T)
+ },
+ () => {
+ this.clear();
+ this.dispatch({ type: 'swapped' });
+ },
+ );
+ };
+
+ defer = (e: U) => {
+ this.dispatch(e);
+ };
+
+ dispatch = (e: U | InternalEvent) => {
+ setTimeout(() => {
+ this.events.push(e);
+ if (this.props.onEvent) {
+ this.props.onEvent(e);
+ }
+ }, 0);
+ };
+
+ updateHandler = (currentTime: number) => {
+ let args = {
+ input: this.input,
+ window: window,
+ events: this.events,
+ dispatch: this.dispatch,
+ defer: this.defer,
+ time: {
+ current: currentTime,
+ previous: this.previousTime,
+ delta: currentTime - (this.previousTime || currentTime),
+ previousDelta: this.previousDelta
+ }
+ };
+
+ this.setState((prevState) => {
+ let newEntities = (this.props.systems ?? []).reduce(
+ (state, sys) => sys(state, args),
+ prevState.entities
+ );
+
+ this.input.length = 0;
+ this.events.length = 0;
+ this.previousTime = currentTime;
+ this.previousDelta = args.time.delta;
+
+ return { entities: newEntities};
+ });
+ };
+
+ inputHandlers = EVENTS
+ .reduce((acc, name) => ({
+ ...acc,
+ [name]: (payload: Input) => {
+ payload?.persist();
+ this.input.push({ name, payload });
+ }
+ }), {} as Record void>);
+
+ render() {
+ return (
+
+ {this.props.renderer(this.state.entities, window)}
+ {this.props.children}
+
+ );
+ }
+}
+
+const css = {
+ container: {
+ flex: 1,
+ outline: 'none'
+ }
+};
diff --git a/src/GameLoop.js b/src/GameLoop.tsx
similarity index 50%
rename from src/GameLoop.js
rename to src/GameLoop.tsx
index 8e5e2c5..c8f767f 100644
--- a/src/GameLoop.js
+++ b/src/GameLoop.tsx
@@ -1,10 +1,42 @@
-import React, { Component } from "react";
-import DefaultTimer from "./DefaultTimer";
+import React, { Component } from 'react';
-const events = `onClick onContextMenu onDoubleClick onDrag onDragEnd onDragEnter onDragExit onDragLeave onDragOver onDragStart onDrop onMouseDown onMouseEnter onMouseLeave onMouseMove onMouseOut onMouseOver onMouseUp onWheel onTouchCancel onTouchEnd onTouchMove onTouchStart onKeyDown onKeyPress onKeyUp`;
+import DefaultTimer from './DefaultTimer';
+import EVENTS, { EventName } from './events';
-export default class GameLoop extends Component {
- constructor(props) {
+type Input = React.MouseEvent | React.TouchEvent | React.KeyboardEvent;
+
+export interface UpdateArgs {
+ input: { name: EventName, payload: Input }[];
+ window: Window;
+ time: {
+ current: number;
+ previous: number | null;
+ delta: number;
+ previousDelta: number | null;
+ };
+}
+
+interface GameLoopProps {
+ running?: boolean;
+ style?: React.CSSProperties;
+ className?: string;
+ children: JSX.Element;
+ timer: DefaultTimer;
+ onUpdate: (args: UpdateArgs) => void;
+}
+
+export default class GameLoop extends Component {
+ static defaultProps = {
+ running: true,
+ };
+
+ timer: DefaultTimer;
+ input: { name: EventName, payload: Input }[];
+ previousTime: number | null;
+ previousDelta: number | null;
+ container: React.RefObject;
+
+ constructor(props: GameLoopProps) {
super(props);
this.timer = props.timer || new DefaultTimer();
this.input = [];
@@ -15,8 +47,10 @@ export default class GameLoop extends Component {
componentDidMount() {
this.timer.subscribe(this.updateHandler);
-
- if (this.props.running) this.start();
+
+ if (this.props.running) {
+ this.start();
+ }
}
componentWillUnmount() {
@@ -24,10 +58,9 @@ export default class GameLoop extends Component {
this.timer.unsubscribe(this.updateHandler);
}
- componentDidUpdate(prevProps) {
+ componentDidUpdate(prevProps: GameLoopProps) {
if (prevProps.running !== this.props.running) {
- if (this.props.running) this.start();
- else this.stop();
+ this.props.running ? this.start() : this.stop();
}
}
@@ -36,14 +69,14 @@ export default class GameLoop extends Component {
this.previousTime = null;
this.previousDelta = null;
this.timer.start();
- this.container.current.focus();
+ this.container.current?.focus();
};
stop = () => {
this.timer.stop();
};
- updateHandler = currentTime => {
+ updateHandler = (currentTime: number) => {
let args = {
input: this.input,
window: window,
@@ -62,19 +95,15 @@ export default class GameLoop extends Component {
this.previousDelta = args.time.delta;
};
- inputHandlers = events
- .split(" ")
- .map(name => ({
- name,
- handler: payload => {
- payload.persist();
+ inputHandlers = EVENTS
+ .reduce((acc, name) => ({
+ ...acc,
+ [name]: (payload: Input) => {
+ payload?.persist();
this.input.push({ name, payload });
}
- }))
- .reduce((acc, val) => {
- acc[val.name] = val.handler;
- return acc;
- }, {});
+ }), {} as Record void>);
+
render() {
return (
@@ -98,6 +127,6 @@ GameLoop.defaultProps = {
const css = {
container: {
flex: 1,
- outline: "none"
+ outline: 'none'
}
};
diff --git a/src/events.ts b/src/events.ts
new file mode 100644
index 0000000..7160a0c
--- /dev/null
+++ b/src/events.ts
@@ -0,0 +1,11 @@
+const EVENTS = [
+ 'onClick', 'onContextMenu', 'onDoubleClick', 'onDrag', 'onDragEnd', 'onDragEnter', 'onDragExit',
+ 'onDragLeave', 'onDragOver', 'onDragStart', 'onDrop', 'onMouseDown', 'onMouseEnter',
+ 'onMouseLeave', 'onMouseMove', 'onMouseOut', 'onMouseOver', 'onMouseUp', 'onWheel',
+ 'onTouchCancel', 'onTouchEnd', 'onTouchMove', 'onTouchStart', 'onKeyDown', 'onKeyPress',
+ 'onKeyUp',
+] as const;
+
+export type EventName = typeof EVENTS[number];
+
+export default EVENTS;
diff --git a/src/index.js b/src/index.ts
similarity index 100%
rename from src/index.js
rename to src/index.ts
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..7552bb8
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,35 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "lib": [
+ "DOM",
+ "DOM.Iterable",
+ "ESNext"
+ ],
+ "declaration": true,
+ "module": "ESNext",
+ "skipLibCheck": true,
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strictNullChecks": true,
+ "allowJs": true,
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "./tsconfig.node.json"
+ }
+ ]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..b5a3431
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": [
+ "vite.config.ts"
+ ]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..2fea20c
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,23 @@
+import { resolve } from 'path';
+import { defineConfig } from 'vite';
+import dts from 'vite-plugin-dts';
+
+export default defineConfig({
+ build: {
+ lib: {
+ entry: resolve(__dirname, 'src/index.ts'),
+ name: 'react-game-engine',
+ fileName: 'react-game-engine',
+ },
+ rollupOptions: {
+ external: ['react', 'react/jsx-runtime'],
+ output: {
+ globals: {
+ react: 'React',
+ 'react/jsx-runtime': 'jsxRuntime',
+ },
+ },
+ },
+ },
+ plugins: [dts()],
+});
diff --git a/webpack.config.js b/webpack.config.js
deleted file mode 100644
index d2ca9c7..0000000
--- a/webpack.config.js
+++ /dev/null
@@ -1,28 +0,0 @@
-var path = require('path');
-module.exports = {
- entry: './src/index.js',
- output: {
- path: path.resolve(__dirname, 'build'),
- filename: 'index.js',
- libraryTarget: 'commonjs2'
- },
- module: {
- rules: [
- {
- test: /\.js$/,
- include: path.resolve(__dirname, 'src'),
- exclude: /(node_modules|bower_components|build)/,
- use: {
- loader: 'babel-loader',
- options: {
- presets: ['env'],
- plugins: ['transform-class-properties']
- }
- }
- }
- ]
- },
- externals: {
- 'react': 'commonjs react'
- }
-};
\ No newline at end of file