board-game-engine-react 1.0.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.storybook/webpack.config.js +1 -1
  2. package/babel.config.js +2 -1
  3. package/dist/board-game-engine-react.cjs +100 -90
  4. package/dist/board-game-engine-react.mjs +103 -78
  5. package/dist/components/abstract-choices/abstract-choices.d.ts +2 -0
  6. package/dist/components/abstract-choices/abstract-choices.d.ts.map +1 -0
  7. package/dist/components/board/grid.d.ts +5 -0
  8. package/dist/components/board/grid.d.ts.map +1 -0
  9. package/dist/components/entity/entity.d.ts +5 -0
  10. package/dist/components/entity/entity.d.ts.map +1 -0
  11. package/dist/components/game/game.d.ts +8 -0
  12. package/dist/components/game/game.d.ts.map +1 -0
  13. package/dist/components/game-status/game-status.d.ts +5 -0
  14. package/dist/components/game-status/game-status.d.ts.map +1 -0
  15. package/dist/components/space/space.d.ts +5 -0
  16. package/dist/components/space/space.d.ts.map +1 -0
  17. package/dist/contexts/game-context.d.ts +15 -0
  18. package/dist/contexts/game-context.d.ts.map +1 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/types.d.ts +24 -0
  22. package/dist/types.d.ts.map +1 -0
  23. package/dist/use-gameserver-connection.d.ts +8 -0
  24. package/dist/use-gameserver-connection.d.ts.map +1 -0
  25. package/examples/checkers.json +2 -2
  26. package/examples/connect-four.json +1 -1
  27. package/examples/eights.json +15 -15
  28. package/examples/index.html +219 -0
  29. package/package.json +12 -10
  30. package/scripts/build.mjs +3 -3
  31. package/src/components/abstract-choices/{abstract-choices.js → abstract-choices.tsx} +12 -8
  32. package/src/components/board/grid.tsx +29 -0
  33. package/src/components/entity/entity.tsx +40 -0
  34. package/src/components/game/{game.js → game.tsx} +22 -9
  35. package/src/components/game-status/game-status.tsx +30 -0
  36. package/src/components/space/space.tsx +52 -0
  37. package/src/contexts/game-context.tsx +60 -0
  38. package/src/global.d.ts +1 -0
  39. package/src/index.ts +8 -0
  40. package/src/types.ts +25 -0
  41. package/src/use-gameserver-connection.ts +82 -0
  42. package/stories/BoardGameEngineReact.stories.tsx +85 -0
  43. package/test-app/index.html +1 -1
  44. package/test-app/{main.jsx → main.tsx} +13 -11
  45. package/tsconfig.build.json +14 -0
  46. package/tsconfig.json +34 -0
  47. package/board-game-engine-react.test.js +0 -12
  48. package/jest.config.js +0 -13
  49. package/src/components/board/grid.js +0 -28
  50. package/src/components/entity/entity.js +0 -38
  51. package/src/components/game-status/game-status.js +0 -20
  52. package/src/components/space/space.js +0 -68
  53. package/src/contexts/game-context.js +0 -35
  54. package/src/index.js +0 -5
  55. package/src/use-gameserver-connection.js +0 -74
  56. package/stories/BoardGameEngineReact.stories.js +0 -72
@@ -0,0 +1,82 @@
1
+ import { useEffect, useReducer, useState } from 'react'
2
+ import {
3
+ Client,
4
+ type ClientOptions,
5
+ type GameFactoryInput,
6
+ } from 'board-game-engine'
7
+ import type { GameserverConnection } from './types'
8
+
9
+ export type UseGameserverConnectionParams = Omit<ClientOptions, 'gameRules'> & {
10
+ gameRules?: GameFactoryInput
11
+ enabled?: boolean
12
+ }
13
+
14
+ export function useGameserverConnection ({
15
+ server,
16
+ multiplayer,
17
+ matchID,
18
+ gameRules,
19
+ gameName,
20
+ boardgameIOGame,
21
+ playerID,
22
+ credentials,
23
+ numPlayers,
24
+ debug,
25
+ enabled = true,
26
+ }: UseGameserverConnectionParams): GameserverConnection | Record<string, never> {
27
+ const [_tick, forceUpdate] = useReducer((x: number) => x + 1, 0)
28
+ const [connection, setConnection] = useState<Client | null>(null)
29
+
30
+ useEffect(() => {
31
+ if (
32
+ (!gameRules && !boardgameIOGame) ||
33
+ (credentials && !(matchID && enabled && server))
34
+ ) {
35
+ return
36
+ }
37
+
38
+ const options: ClientOptions = {
39
+ server,
40
+ numPlayers,
41
+ onClientUpdate: () => {
42
+ forceUpdate()
43
+ },
44
+ debug,
45
+ matchID,
46
+ gameRules,
47
+ boardgameIOGame,
48
+ gameName,
49
+ playerID,
50
+ credentials,
51
+ multiplayer,
52
+ }
53
+
54
+ const newConnection = new Client(options)
55
+
56
+ newConnection.connect()
57
+
58
+ setConnection(newConnection)
59
+
60
+ return () => {
61
+ newConnection.client?.stop()
62
+ setConnection(null)
63
+ }
64
+ }, [
65
+ matchID,
66
+ server,
67
+ playerID,
68
+ credentials,
69
+ gameRules,
70
+ boardgameIOGame,
71
+ enabled,
72
+ multiplayer,
73
+ ])
74
+
75
+ if (connection) {
76
+ return Object.assign(
77
+ connection,
78
+ connection.getState?.(),
79
+ ) as GameserverConnection
80
+ }
81
+ return {}
82
+ }
@@ -0,0 +1,85 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { GameFactoryInput } from 'board-game-engine'
3
+ import { Game, useGameserverConnection } from '../src/index'
4
+ import ticTacToe from './tic-tac-toe.json'
5
+ import { ErrorBoundary } from 'react-error-boundary'
6
+ import './styles.css'
7
+
8
+ const sampleGame = ticTacToe as unknown as GameFactoryInput
9
+
10
+ export default {
11
+ title: 'Game',
12
+ component: Game,
13
+ className: 'board-game-engine-react',
14
+ args: {
15
+ gameRules: sampleGame,
16
+ numPlayers: 2,
17
+ isSpectator: false,
18
+ },
19
+ argTypes: {
20
+ gameRules: { control: 'object' },
21
+ numPlayers: { control: 'number' },
22
+ isSpectator: { control: 'boolean' },
23
+ },
24
+ }
25
+
26
+ const fallbackRender = ({ error }: { error: Error }) => <div>Invalid Game Rules {error.message}</div>
27
+
28
+ const Template = ({
29
+ gameRules,
30
+ numPlayers,
31
+ loading,
32
+ isSpectator,
33
+ }: {
34
+ gameRules: GameFactoryInput
35
+ numPlayers: number
36
+ loading?: ReactNode
37
+ isSpectator?: boolean
38
+ }) => {
39
+ const gameConnection = useGameserverConnection({
40
+ gameRules,
41
+ numPlayers,
42
+ })
43
+
44
+ return (
45
+ <div className="story">
46
+ <ErrorBoundary
47
+ resetKeys={[JSON.stringify(gameRules), numPlayers]}
48
+ fallbackRender={fallbackRender}
49
+ >
50
+ <Game
51
+ gameConnection={gameConnection}
52
+ loading={loading}
53
+ isSpectator={isSpectator}
54
+ />
55
+ </ErrorBoundary>
56
+ </div>
57
+ )
58
+ }
59
+
60
+ export const Basic = Template.bind({})
61
+
62
+ type TemplateProps = Parameters<typeof Template>[0]
63
+
64
+ export const WithLoading = Object.assign(
65
+ (args: TemplateProps) => (
66
+ <Template
67
+ {...args}
68
+ loading={<div className="story-loading">Loading game…</div>}
69
+ />
70
+ ),
71
+ {
72
+ args: {
73
+ gameRules: sampleGame,
74
+ numPlayers: 2,
75
+ },
76
+ }
77
+ )
78
+
79
+ export const Spectator = Object.assign(Template.bind({}), {
80
+ args: {
81
+ gameRules: sampleGame,
82
+ numPlayers: 2,
83
+ isSpectator: true,
84
+ },
85
+ })
@@ -20,6 +20,6 @@
20
20
  </head>
21
21
  <body>
22
22
  <div id="root"></div>
23
- <script type="module" src="./main.jsx"></script>
23
+ <script type="module" src="./main.tsx"></script>
24
24
  </body>
25
25
  </html>
@@ -1,5 +1,6 @@
1
- import React, { useState, useCallback } from 'react'
1
+ import { useState, useCallback } from 'react'
2
2
  import { createRoot } from 'react-dom/client'
3
+ import type { GameFactoryInput } from 'board-game-engine'
3
4
  import '../dist/board-game-engine-react.css'
4
5
  import { Game, useGameserverConnection } from 'board-game-engine-react'
5
6
 
@@ -14,9 +15,9 @@ const GAME_OPTIONS = [
14
15
  function Harness () {
15
16
  const [gameKey, setGameKey] = useState('tic-tac-toe')
16
17
  const [numPlayers, setNumPlayers] = useState(2)
17
- const [gameRules, setGameRules] = useState(null)
18
+ const [gameRules, setGameRules] = useState<GameFactoryInput | null>(null)
18
19
  const [loading, setLoading] = useState(false)
19
- const [error, setError] = useState(null)
20
+ const [error, setError] = useState<string | null>(null)
20
21
 
21
22
  const startGame = useCallback(async () => {
22
23
  setError(null)
@@ -24,12 +25,12 @@ function Harness () {
24
25
  try {
25
26
  const res = await fetch(`/games/${gameKey}.json`)
26
27
  if (!res.ok) throw new Error(res.statusText)
27
- const rules = await res.json()
28
+ const rules = await res.json() as GameFactoryInput
28
29
  const n = Number(numPlayers) || rules.numPlayers || rules.maxPlayers || 2
29
- setGameRules(JSON.stringify(rules))
30
+ setGameRules(rules)
30
31
  setNumPlayers(n)
31
32
  } catch (err) {
32
- setError(err.message || String(err))
33
+ setError(err instanceof Error ? err.message : String(err))
33
34
  setGameRules(null)
34
35
  } finally {
35
36
  setLoading(false)
@@ -37,14 +38,13 @@ function Harness () {
37
38
  }, [gameKey, numPlayers])
38
39
 
39
40
  const connection = useGameserverConnection({
40
- singlePlayer: true,
41
- gameRules: gameRules || '',
41
+ gameRules: gameRules ?? undefined,
42
42
  gameName: gameKey,
43
43
  numPlayers,
44
- enabled: !!gameRules,
44
+ enabled: gameRules != null,
45
45
  })
46
46
 
47
- const hasState = connection?.getState?.()
47
+ const hasState = connection && 'getState' in connection ? connection.getState?.() : undefined
48
48
 
49
49
  return (
50
50
  <div className="harness" data-testid="harness">
@@ -98,4 +98,6 @@ function Harness () {
98
98
  }
99
99
 
100
100
  const root = document.getElementById('root')
101
- createRoot(root).render(<Harness />)
101
+ if (root) {
102
+ createRoot(root).render(<Harness />)
103
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": false,
5
+ "emitDeclarationOnly": true,
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "outDir": "dist",
9
+ "rootDir": "src",
10
+ "stripInternal": true
11
+ },
12
+ "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
13
+ "exclude": ["node_modules", "dist"]
14
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": ".",
4
+ "paths": {
5
+ "board-game-engine-react": ["./src/index.ts"]
6
+ },
7
+ "target": "ES2020",
8
+ "module": "ESNext",
9
+ "moduleResolution": "Bundler",
10
+ "lib": ["ES2020", "DOM"],
11
+ "jsx": "react-jsx",
12
+ "strict": true,
13
+ "noImplicitAny": true,
14
+ "skipLibCheck": true,
15
+ "noEmit": true,
16
+ "allowJs": false,
17
+ "resolveJsonModule": true,
18
+ "isolatedModules": true,
19
+ "verbatimModuleSyntax": true,
20
+ "noUnusedLocals": true,
21
+ "noUnusedParameters": true,
22
+ "rootDir": "."
23
+ },
24
+ "include": [
25
+ "src/**/*.ts",
26
+ "src/**/*.tsx",
27
+ "src/**/*.d.ts",
28
+ "stories/**/*.ts",
29
+ "stories/**/*.tsx",
30
+ "test-app/**/*.tsx",
31
+ "board-game-engine-react.test.ts"
32
+ ],
33
+ "exclude": ["node_modules", "dist"]
34
+ }
@@ -1,12 +0,0 @@
1
- /* eslint-env jest */
2
-
3
- import '@testing-library/jest-dom/extend-expect'
4
- import React from 'react'
5
- import { render, screen } from '@testing-library/react'
6
- import userEvent from '@testing-library/user-event'
7
-
8
- describe('board-game-engine-react', () => {
9
- test('has a test', async () => {
10
- return true
11
- })
12
- })
package/jest.config.js DELETED
@@ -1,13 +0,0 @@
1
- module.exports = {
2
- transform: {
3
- '^.+\\.(js|jsx)$': 'babel-jest'
4
- },
5
- testEnvironment: 'jsdom',
6
- transformIgnorePatterns: [
7
- '/node_modules/(?!yarn-bound|@mnbroatch|react-dialogue-tree).+\\.js$'
8
- ],
9
- moduleNameMapper: {
10
- '^.+\\.(css|less|scss)$': 'babel-jest'
11
- },
12
- clearMocks: true
13
- }
@@ -1,28 +0,0 @@
1
- import React from 'react';
2
- import Entity from "../entity/entity.js";
3
-
4
- export default function Grid ({ grid }) {
5
- const { width, height, spaces } = grid.attributes;
6
- return (
7
- <div
8
- className="grid"
9
- style={{
10
- display: 'inline-grid',
11
- width: '100%',
12
- gridTemplateColumns: `repeat(${width}, 1fr)`,
13
- gridTemplateRows: `repeat(${height}, 1fr)`,
14
- }}
15
- >
16
- {spaces.map((space, index) => {
17
- return (
18
- <div
19
- key={index}
20
- className="grid__cell"
21
- >
22
- <Entity entity={space} />
23
- </div>
24
- );
25
- })}
26
- </div>
27
- );
28
- }
@@ -1,38 +0,0 @@
1
- import React from 'react'
2
- import { useGame } from "../../contexts/game-context.js";
3
- import Grid from '../board/grid.js'
4
- import Space from "../space/space.js";
5
-
6
- export default function Entity ({ entity }) {
7
- const { clickTarget, allClickable } = useGame()
8
- const isClickable = allClickable.has(entity)
9
- const attributes = entity.attributes
10
- const entityType = attributes.entityType ?? attributes.type
11
-
12
- switch (entityType) {
13
- case 'Grid':
14
- return <Grid grid={entity} isClickable={isClickable} />
15
- case 'Space':
16
- return <Space space={entity} isClickable={isClickable} />
17
- default:
18
- return <div
19
- onClick={(e) => {
20
- if (isClickable) {
21
- e.stopPropagation()
22
- clickTarget(entity)
23
- }
24
- }}
25
- className={[
26
- 'entity',
27
- attributes.player && `player-${attributes.player}`,
28
- isClickable && 'entity--clickable',
29
- ].filter(Boolean).join(' ')}
30
- >
31
- {entity.rule.displayProperties?.map((property, i) => (
32
- <div key={i}>
33
- {property}: {(entity.attributes[property])?.toString()}
34
- </div>
35
- ))}
36
- </div>
37
- }
38
- }
@@ -1,20 +0,0 @@
1
- import React from 'react'
2
-
3
- export default function GameStatus ({ gameConnection }) {
4
- const players = gameConnection.client.matchData
5
- const winner = gameConnection.state.ctx.gameover?.winner
6
- const draw = gameConnection.state.ctx.gameover?.draw
7
- let winnerString = ''
8
- if (draw) {
9
- winnerString = 'Draw!'
10
- } else if (players && winner) {
11
- winnerString = `${players[winner].name} Wins!`
12
- } else if (winner) {
13
- winnerString = `Player ${winner} Wins!`
14
- }
15
- return gameConnection.state.ctx.gameover && (
16
- <div className="game-status">
17
- {winnerString}
18
- </div>
19
- )
20
- }
@@ -1,68 +0,0 @@
1
- import React from 'react';
2
- import { useGame } from "../../contexts/game-context.js";
3
- import Entity from '../entity/entity.js'
4
-
5
- function calculateOptimalCols(numSquares) {
6
- if (numSquares === 0) return 1;
7
-
8
- let bestCols = 1;
9
- let bestAspectRatio = Infinity;
10
-
11
- for (let cols = 1; cols <= numSquares; cols++) {
12
- const rows = Math.ceil(numSquares / cols);
13
- const aspectRatio = Math.abs(cols - rows);
14
-
15
- if (aspectRatio < bestAspectRatio) {
16
- bestAspectRatio = aspectRatio;
17
- bestCols = cols;
18
- }
19
- }
20
-
21
- return bestCols;
22
- }
23
-
24
- export default function Space ({ space }) {
25
- const { clickTarget, allClickable, currentMoveTargets } = useGame()
26
- const { entities, entityId } = space.attributes
27
-
28
- const clickable = allClickable.has(space)
29
- const targeted = currentMoveTargets?.map(e => e.entityId).includes(entityId)
30
-
31
- return (
32
- <a
33
- className={[
34
- 'space',
35
- clickable && 'space--clickable',
36
- targeted && 'space--targeted'
37
- ].filter(Boolean).join(' ')}
38
- onClick={() => clickTarget(space)}
39
- style={{
40
- display: 'inline-block',
41
- flex: '1',
42
- }}
43
- >
44
- <div
45
- className="space__entity-grid"
46
- style={{
47
- display: 'flex',
48
- height: '100%',
49
- width: '100%',
50
- flexWrap: 'wrap',
51
- }}
52
- >
53
- {Array.from({ length: entities.length }, (_, i) => (
54
- <div
55
- className="space__entity-grid__cell"
56
- style={{
57
- display: 'inline-block',
58
- }}
59
- key={i}
60
- >
61
- <Entity entity={entities[i]} />
62
- </div>
63
- ))}
64
- {!entities.length && space.attributes.name}
65
- </div>
66
- </a>
67
- );
68
- }
@@ -1,35 +0,0 @@
1
- import React, { createContext, useContext, useEffect } from 'react';
2
-
3
- const GameContext = createContext({
4
- clickTarget: () => {},
5
- });
6
-
7
- // do we need isSpectator
8
- export function GameProvider({ gameConnection, children, isSpectator }) {
9
- useEffect(() => {
10
- if (gameConnection.state._stateID === 0) {
11
- gameConnection.reset()
12
- }
13
- }, [gameConnection.state._stateID]);
14
-
15
- return (
16
- <GameContext.Provider value={{
17
- clickTarget: target => {
18
- if (!isSpectator) {
19
- gameConnection.doStep(target)
20
- }
21
- },
22
- undoStep: () => { gameConnection.undoStep() },
23
- allClickable: (gameConnection.optimisticWinner || isSpectator)
24
- ? new Set()
25
- : gameConnection.allClickable,
26
- currentMoveTargets: (gameConnection.optimisticWinner || isSpectator)
27
- ? []
28
- : gameConnection.moveBuilder.targets,
29
- }}>
30
- {children}
31
- </GameContext.Provider>
32
- );
33
- }
34
-
35
- export const useGame = () => useContext(GameContext);
package/src/index.js DELETED
@@ -1,5 +0,0 @@
1
- import './styles.css'
2
- export { Client, gameFactory } from 'board-game-engine'
3
-
4
- export { default as Game } from './components/game/game.js'
5
- export { useGameserverConnection } from './use-gameserver-connection'
@@ -1,74 +0,0 @@
1
- import { useEffect, useReducer, useState } from 'react'
2
- import { Client } from 'board-game-engine'
3
-
4
-
5
- export const useGameserverConnection = ({
6
- server,
7
- multiplayer,
8
- matchID,
9
- gameRules,
10
- gameName,
11
- boardgameIOGame,
12
- playerID,
13
- credentials,
14
- numPlayers,
15
- debug,
16
- enabled = true,
17
- }) => {
18
- const [_, forceUpdate] = useReducer(x => x + 1, 0)
19
- const [connection, setConnection] = useState(null)
20
-
21
- useEffect(() => {
22
- if (
23
- !gameRules && !boardgameIOGame
24
- || credentials && !(matchID && enabled && server)
25
- ) {
26
- return
27
- }
28
-
29
- const options = {
30
- server,
31
- numPlayers,
32
- onClientUpdate: () => {
33
- forceUpdate()
34
- },
35
- debug,
36
- matchID,
37
- gameRules,
38
- boardgameIOGame,
39
- gameName,
40
- playerID,
41
- credentials,
42
- multiplayer
43
- }
44
-
45
- const newConnection = new Client(options)
46
-
47
- newConnection.connect()
48
-
49
- setConnection(newConnection)
50
-
51
- return () => {
52
- connection?.client?.stop()
53
- setConnection(null)
54
- }
55
- }, [
56
- matchID,
57
- server,
58
- playerID,
59
- credentials,
60
- gameRules,
61
- boardgameIOGame,
62
- enabled,
63
- multiplayer,
64
- ])
65
-
66
- if (connection) {
67
- return Object.assign(
68
- connection,
69
- connection?.getState?.(),
70
- )
71
- } else {
72
- return {}
73
- }
74
- }
@@ -1,72 +0,0 @@
1
- import React from 'react'
2
- import { Game, useGameserverConnection } from '../src/index'
3
- import ticTacToe from './tic-tac-toe.json'
4
- import { ErrorBoundary } from 'react-error-boundary'
5
- import './styles.css'
6
-
7
- export default {
8
- title: 'Game',
9
- component: Game,
10
- className: 'board-game-engine-react',
11
- args: {
12
- gameRules: JSON.stringify(ticTacToe, null, 2),
13
- numPlayers: 2,
14
- isSpectator: false,
15
- },
16
- argTypes: {
17
- gameRules: { control: 'text' },
18
- numPlayers: { control: 'number' },
19
- isSpectator: { control: 'boolean' },
20
- },
21
- }
22
-
23
- const parseGameRules = (gameRules, fallback) => {
24
- if (typeof gameRules !== 'string') return gameRules
25
- try {
26
- return JSON.parse(gameRules || '{}')
27
- } catch {
28
- return fallback
29
- }
30
- }
31
-
32
- const fallbackRender = ({ error }) => <div>Invalid Game Rules {error}</div>
33
-
34
- const Template = ({ gameRules, numPlayers, loading, isSpectator }) => {
35
- const rules = parseGameRules(gameRules, ticTacToe)
36
- const gameConnection = useGameserverConnection({
37
- gameRules: rules,
38
- numPlayers,
39
- })
40
-
41
- return (
42
- <div className="story">
43
- <ErrorBoundary resetKeys={[gameRules, numPlayers]} fallbackRender={fallbackRender}>
44
- <Game
45
- gameConnection={gameConnection}
46
- loading={loading}
47
- isSpectator={isSpectator}
48
- />
49
- </ErrorBoundary>
50
- </div>
51
- )
52
- }
53
-
54
- export const Basic = Template.bind({})
55
-
56
- export const WithLoading = (args) => (
57
- <Template
58
- {...args}
59
- loading={<div className="story-loading">Loading game…</div>}
60
- />
61
- )
62
- WithLoading.args = {
63
- gameRules: JSON.stringify(ticTacToe, null, 2),
64
- numPlayers: 2,
65
- }
66
-
67
- export const Spectator = Template.bind({})
68
- Spectator.args = {
69
- gameRules: JSON.stringify(ticTacToe, null, 2),
70
- numPlayers: 2,
71
- isSpectator: true,
72
- }