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.
- package/.storybook/webpack.config.js +1 -1
- package/babel.config.js +2 -1
- package/dist/board-game-engine-react.cjs +100 -90
- package/dist/board-game-engine-react.mjs +103 -78
- package/dist/components/abstract-choices/abstract-choices.d.ts +2 -0
- package/dist/components/abstract-choices/abstract-choices.d.ts.map +1 -0
- package/dist/components/board/grid.d.ts +5 -0
- package/dist/components/board/grid.d.ts.map +1 -0
- package/dist/components/entity/entity.d.ts +5 -0
- package/dist/components/entity/entity.d.ts.map +1 -0
- package/dist/components/game/game.d.ts +8 -0
- package/dist/components/game/game.d.ts.map +1 -0
- package/dist/components/game-status/game-status.d.ts +5 -0
- package/dist/components/game-status/game-status.d.ts.map +1 -0
- package/dist/components/space/space.d.ts +5 -0
- package/dist/components/space/space.d.ts.map +1 -0
- package/dist/contexts/game-context.d.ts +15 -0
- package/dist/contexts/game-context.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/types.d.ts +24 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/use-gameserver-connection.d.ts +8 -0
- package/dist/use-gameserver-connection.d.ts.map +1 -0
- package/examples/checkers.json +2 -2
- package/examples/connect-four.json +1 -1
- package/examples/eights.json +15 -15
- package/examples/index.html +219 -0
- package/package.json +12 -10
- package/scripts/build.mjs +3 -3
- package/src/components/abstract-choices/{abstract-choices.js → abstract-choices.tsx} +12 -8
- package/src/components/board/grid.tsx +29 -0
- package/src/components/entity/entity.tsx +40 -0
- package/src/components/game/{game.js → game.tsx} +22 -9
- package/src/components/game-status/game-status.tsx +30 -0
- package/src/components/space/space.tsx +52 -0
- package/src/contexts/game-context.tsx +60 -0
- package/src/global.d.ts +1 -0
- package/src/index.ts +8 -0
- package/src/types.ts +25 -0
- package/src/use-gameserver-connection.ts +82 -0
- package/stories/BoardGameEngineReact.stories.tsx +85 -0
- package/test-app/index.html +1 -1
- package/test-app/{main.jsx → main.tsx} +13 -11
- package/tsconfig.build.json +14 -0
- package/tsconfig.json +34 -0
- package/board-game-engine-react.test.js +0 -12
- package/jest.config.js +0 -13
- package/src/components/board/grid.js +0 -28
- package/src/components/entity/entity.js +0 -38
- package/src/components/game-status/game-status.js +0 -20
- package/src/components/space/space.js +0 -68
- package/src/contexts/game-context.js +0 -35
- package/src/index.js +0 -5
- package/src/use-gameserver-connection.js +0 -74
- package/stories/BoardGameEngineReact.stories.js +0 -72
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>Board Game Engine Examples</title>
|
|
6
|
+
<style>
|
|
7
|
+
body {
|
|
8
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 1rem 1.5rem 2rem;
|
|
11
|
+
background: #0f172a;
|
|
12
|
+
color: #e5e7eb;
|
|
13
|
+
}
|
|
14
|
+
h1 {
|
|
15
|
+
font-size: 1.4rem;
|
|
16
|
+
margin-bottom: 0.75rem;
|
|
17
|
+
}
|
|
18
|
+
.controls {
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-wrap: wrap;
|
|
21
|
+
gap: 0.75rem;
|
|
22
|
+
align-items: center;
|
|
23
|
+
margin-bottom: 1rem;
|
|
24
|
+
}
|
|
25
|
+
select, input, button {
|
|
26
|
+
background: #020617;
|
|
27
|
+
color: #e5e7eb;
|
|
28
|
+
border-radius: 999px;
|
|
29
|
+
border: 1px solid #1f2937;
|
|
30
|
+
padding: 0.35rem 0.9rem;
|
|
31
|
+
font-size: 0.9rem;
|
|
32
|
+
outline: none;
|
|
33
|
+
}
|
|
34
|
+
select:focus, input:focus, button:focus {
|
|
35
|
+
border-color: #38bdf8;
|
|
36
|
+
box-shadow: 0 0 0 1px #38bdf8;
|
|
37
|
+
}
|
|
38
|
+
button {
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
background: linear-gradient(135deg, #22c55e, #16a34a);
|
|
41
|
+
border: none;
|
|
42
|
+
color: #022c22;
|
|
43
|
+
font-weight: 600;
|
|
44
|
+
}
|
|
45
|
+
button:hover {
|
|
46
|
+
filter: brightness(1.08);
|
|
47
|
+
}
|
|
48
|
+
button:disabled {
|
|
49
|
+
opacity: 0.5;
|
|
50
|
+
cursor: default;
|
|
51
|
+
}
|
|
52
|
+
label {
|
|
53
|
+
font-size: 0.85rem;
|
|
54
|
+
opacity: 0.85;
|
|
55
|
+
}
|
|
56
|
+
.layout {
|
|
57
|
+
display: grid;
|
|
58
|
+
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
|
|
59
|
+
gap: 1rem;
|
|
60
|
+
align-items: flex-start;
|
|
61
|
+
}
|
|
62
|
+
#debug {
|
|
63
|
+
background: #020617;
|
|
64
|
+
border-radius: 0.85rem;
|
|
65
|
+
border: 1px solid #1f2937;
|
|
66
|
+
min-height: 320px;
|
|
67
|
+
padding: 0.5rem;
|
|
68
|
+
overflow: auto;
|
|
69
|
+
}
|
|
70
|
+
.panel {
|
|
71
|
+
background: #020617;
|
|
72
|
+
border-radius: 0.85rem;
|
|
73
|
+
border: 1px solid #1f2937;
|
|
74
|
+
padding: 0.75rem 0.9rem;
|
|
75
|
+
min-height: 160px;
|
|
76
|
+
}
|
|
77
|
+
.panel h2 {
|
|
78
|
+
margin: 0 0 0.5rem;
|
|
79
|
+
font-size: 0.9rem;
|
|
80
|
+
letter-spacing: 0.06em;
|
|
81
|
+
text-transform: uppercase;
|
|
82
|
+
opacity: 0.7;
|
|
83
|
+
}
|
|
84
|
+
pre {
|
|
85
|
+
margin: 0;
|
|
86
|
+
font-size: 0.78rem;
|
|
87
|
+
line-height: 1.4;
|
|
88
|
+
white-space: pre-wrap;
|
|
89
|
+
word-break: break-word;
|
|
90
|
+
}
|
|
91
|
+
.badge {
|
|
92
|
+
display: inline-flex;
|
|
93
|
+
align-items: center;
|
|
94
|
+
gap: 0.25rem;
|
|
95
|
+
padding: 0.1rem 0.5rem;
|
|
96
|
+
border-radius: 999px;
|
|
97
|
+
background: rgba(56, 189, 248, 0.1);
|
|
98
|
+
border: 1px solid rgba(56, 189, 248, 0.5);
|
|
99
|
+
font-size: 0.7rem;
|
|
100
|
+
text-transform: uppercase;
|
|
101
|
+
letter-spacing: 0.08em;
|
|
102
|
+
}
|
|
103
|
+
</style>
|
|
104
|
+
</head>
|
|
105
|
+
<body>
|
|
106
|
+
<h1>Board Game Engine – Examples</h1>
|
|
107
|
+
<div class="controls">
|
|
108
|
+
<label>
|
|
109
|
+
Game
|
|
110
|
+
<select id="game-select">
|
|
111
|
+
<option value="tic-tac-toe">Tic Tac Toe</option>
|
|
112
|
+
<option value="connect-four">Connect Four</option>
|
|
113
|
+
<option value="reversi">Reversi</option>
|
|
114
|
+
<option value="eights">Crazy Eights</option>
|
|
115
|
+
<option value="checkers">Checkers</option>
|
|
116
|
+
</select>
|
|
117
|
+
</label>
|
|
118
|
+
<label>
|
|
119
|
+
Players
|
|
120
|
+
<input id="players-input" type="number" min="1" max="6" value="2" />
|
|
121
|
+
</label>
|
|
122
|
+
<button id="start-btn">Start / Reset Game</button>
|
|
123
|
+
<span class="badge">Debug UI attaches below</span>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div class="layout">
|
|
127
|
+
<div id="debug"></div>
|
|
128
|
+
<div class="panel">
|
|
129
|
+
<h2>Client snapshot</h2>
|
|
130
|
+
<pre id="state-view">(no game started)</pre>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<script src="../dist/board-game-engine.js"></script>
|
|
135
|
+
<script>
|
|
136
|
+
(function () {
|
|
137
|
+
const gameFiles = {
|
|
138
|
+
'tic-tac-toe': './tic-tac-toe.json',
|
|
139
|
+
'connect-four': './connect-four.json',
|
|
140
|
+
'reversi': './reversi.json',
|
|
141
|
+
'eights': './eights.json',
|
|
142
|
+
'checkers': './checkers.json',
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const selectEl = document.getElementById('game-select');
|
|
146
|
+
const playersEl = document.getElementById('players-input');
|
|
147
|
+
const startBtn = document.getElementById('start-btn');
|
|
148
|
+
const stateView = document.getElementById('state-view');
|
|
149
|
+
|
|
150
|
+
let currentClient = null;
|
|
151
|
+
|
|
152
|
+
function toSerializableSnapshot(snapshot) {
|
|
153
|
+
// Avoid circular structures by swapping in originalG (plain JSON)
|
|
154
|
+
if (!snapshot || !snapshot.state) return snapshot;
|
|
155
|
+
const { state, ...rest } = snapshot;
|
|
156
|
+
const { originalG, G, ...stateRest } = state;
|
|
157
|
+
return {
|
|
158
|
+
...rest,
|
|
159
|
+
state: {
|
|
160
|
+
...stateRest,
|
|
161
|
+
// originalG is what came from boardgame.io before our deserialization step
|
|
162
|
+
G: originalG ?? G,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function startGame() {
|
|
168
|
+
const key = selectEl.value;
|
|
169
|
+
const file = gameFiles[key];
|
|
170
|
+
if (!file || !window.BoardGameEngine || !window.BoardGameEngine.Client) {
|
|
171
|
+
stateView.textContent = 'Engine bundle or game config not available.';
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
startBtn.disabled = true;
|
|
176
|
+
stateView.textContent = 'Loading ' + key + '…';
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const res = await fetch(file);
|
|
180
|
+
const rules = await res.json();
|
|
181
|
+
|
|
182
|
+
const numPlayers =
|
|
183
|
+
Number(playersEl.value) ||
|
|
184
|
+
rules.numPlayers ||
|
|
185
|
+
rules.maxPlayers ||
|
|
186
|
+
2;
|
|
187
|
+
|
|
188
|
+
const client = new window.BoardGameEngine.Client({
|
|
189
|
+
gameRules: JSON.stringify(rules),
|
|
190
|
+
gameName: key,
|
|
191
|
+
numPlayers,
|
|
192
|
+
onClientUpdate: () => {
|
|
193
|
+
const snapshot = client.getState();
|
|
194
|
+
const safe = toSerializableSnapshot(snapshot);
|
|
195
|
+
stateView.textContent = JSON.stringify(safe, null, 2);
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
currentClient = client.connect();
|
|
200
|
+
if (currentClient && typeof currentClient.getState === 'function') {
|
|
201
|
+
// Force initial snapshot after connect
|
|
202
|
+
const snapshot = currentClient.getState();
|
|
203
|
+
const safe = toSerializableSnapshot(snapshot);
|
|
204
|
+
stateView.textContent = JSON.stringify(safe, null, 2);
|
|
205
|
+
}
|
|
206
|
+
} catch (err) {
|
|
207
|
+
console.error(err);
|
|
208
|
+
stateView.textContent = 'Error loading game: ' + (err && err.message ? err.message : String(err));
|
|
209
|
+
} finally {
|
|
210
|
+
startBtn.disabled = false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
startBtn.addEventListener('click', startGame);
|
|
215
|
+
})();
|
|
216
|
+
</script>
|
|
217
|
+
</body>
|
|
218
|
+
</html>
|
|
219
|
+
|
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "board-game-engine-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "React library for using board-game-engine",
|
|
5
5
|
"main": "./dist/board-game-engine-react.cjs",
|
|
6
6
|
"module": "./dist/board-game-engine-react.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
7
8
|
"style": "./dist/board-game-engine-react.css",
|
|
8
9
|
"exports": {
|
|
9
10
|
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
10
12
|
"import": "./dist/board-game-engine-react.mjs",
|
|
11
13
|
"require": "./dist/board-game-engine-react.cjs"
|
|
12
14
|
},
|
|
@@ -14,9 +16,10 @@
|
|
|
14
16
|
},
|
|
15
17
|
"scripts": {
|
|
16
18
|
"start": "storybook dev p 6006",
|
|
17
|
-
"test": "
|
|
18
|
-
"
|
|
19
|
-
"build": "
|
|
19
|
+
"test": "playwright test",
|
|
20
|
+
"build": "node scripts/build.mjs && npm run build:types",
|
|
21
|
+
"build:types": "tsc -p tsconfig.build.json",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
20
23
|
"build:coverage": "cross-env COVERAGE=1 node scripts/build.mjs",
|
|
21
24
|
"storybook": "storybook dev -p 6006",
|
|
22
25
|
"prepare": "npm run build",
|
|
@@ -48,6 +51,7 @@
|
|
|
48
51
|
"@babel/plugin-proposal-object-rest-spread": "",
|
|
49
52
|
"@babel/preset-env": "^7.26.0",
|
|
50
53
|
"@babel/preset-react": "",
|
|
54
|
+
"@babel/preset-typescript": "^7.26.0",
|
|
51
55
|
"@chromatic-com/storybook": "^3.2.2",
|
|
52
56
|
"@playwright/test": "^1.49.0",
|
|
53
57
|
"@storybook/addon-essentials": "^8.4.6",
|
|
@@ -59,12 +63,10 @@
|
|
|
59
63
|
"@storybook/react": "^8.4.6",
|
|
60
64
|
"@storybook/react-webpack5": "^8.4.6",
|
|
61
65
|
"@storybook/test": "^8.4.6",
|
|
62
|
-
"@
|
|
63
|
-
"@
|
|
64
|
-
"@testing-library/user-event": "^13.5.0",
|
|
66
|
+
"@types/react": "^18.2.0",
|
|
67
|
+
"@types/react-dom": "^18.2.0",
|
|
65
68
|
"@vitejs/plugin-react": "^4.2.1",
|
|
66
69
|
"assert": "^2.0.0",
|
|
67
|
-
"babel-jest": "^27.4.4",
|
|
68
70
|
"babel-loader": "^8.3.0",
|
|
69
71
|
"babel-plugin-istanbul": "^6.1.1",
|
|
70
72
|
"cross-env": "^7.0.3",
|
|
@@ -78,7 +80,6 @@
|
|
|
78
80
|
"eslint-plugin-react": "",
|
|
79
81
|
"eslint-plugin-storybook": "^0.11.1",
|
|
80
82
|
"install": "^0.13.0",
|
|
81
|
-
"jest": "^27.4.3",
|
|
82
83
|
"markdown-to-jsx": "",
|
|
83
84
|
"node-sass": "^7.0.1",
|
|
84
85
|
"npm": "^8.3.1",
|
|
@@ -91,10 +92,11 @@
|
|
|
91
92
|
"sass-loader": "^12.4.0",
|
|
92
93
|
"source-loader": "",
|
|
93
94
|
"storybook": "^8.4.6",
|
|
95
|
+
"typescript": "^5.9.3",
|
|
94
96
|
"vite": "^5.0.0"
|
|
95
97
|
},
|
|
96
98
|
"dependencies": {
|
|
97
|
-
"board-game-engine": "^1.0
|
|
99
|
+
"board-game-engine": "^2.1.0",
|
|
98
100
|
"react": "^18.2.0",
|
|
99
101
|
"react-error-boundary": "^3.1.4",
|
|
100
102
|
"react-transition-group": "^4.4.5"
|
package/scripts/build.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as esbuild from 'esbuild'
|
|
2
2
|
|
|
3
|
-
const entry = 'src/index.
|
|
3
|
+
const entry = 'src/index.ts'
|
|
4
4
|
const outdir = 'dist'
|
|
5
5
|
const coverage = process.env.COVERAGE === '1'
|
|
6
6
|
|
|
@@ -10,7 +10,7 @@ const base = {
|
|
|
10
10
|
platform: 'browser',
|
|
11
11
|
target: ['es2020'],
|
|
12
12
|
jsx: 'automatic',
|
|
13
|
-
loader: { '.
|
|
13
|
+
loader: { '.ts': 'tsx', '.tsx': 'tsx' },
|
|
14
14
|
external: ['react', 'react-dom', 'board-game-engine'],
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -25,7 +25,7 @@ if (coverage) {
|
|
|
25
25
|
esbuildPluginIstanbul({
|
|
26
26
|
name: 'istanbul',
|
|
27
27
|
filter: /\.(js|ts|jsx|tsx)$/,
|
|
28
|
-
include: ['src/**/*.
|
|
28
|
+
include: ['src/**/*.ts', 'src/**/*.tsx'],
|
|
29
29
|
exclude: ['**/*.spec.js', '**/node_modules/**'],
|
|
30
30
|
}),
|
|
31
31
|
],
|
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { useGame } from '../../contexts/game-context'
|
|
2
|
+
import type { GameEntity } from '../../types'
|
|
3
3
|
|
|
4
4
|
export default function AbstractChoices () {
|
|
5
5
|
const { clickTarget, allClickable, undoStep, currentMoveTargets } = useGame()
|
|
6
6
|
|
|
7
|
-
const abstractChoices = [...allClickable].filter(
|
|
7
|
+
const abstractChoices = [...allClickable].filter(
|
|
8
|
+
(c) => (c as GameEntity).abstract
|
|
9
|
+
) as GameEntity[]
|
|
8
10
|
|
|
9
|
-
// spacer assumes only one row of choices.
|
|
10
|
-
// could save and store biggest height instead?
|
|
11
11
|
return (
|
|
12
12
|
<div style={{ position: 'relative' }}>
|
|
13
13
|
<button
|
|
14
|
-
style={{visibility: 'hidden'}}
|
|
14
|
+
style={{ visibility: 'hidden' }}
|
|
15
15
|
className="button button--style-b button--x-small abstract-choices__choice"
|
|
16
|
+
type="button"
|
|
16
17
|
>
|
|
17
18
|
Spacer
|
|
18
19
|
</button>
|
|
@@ -21,9 +22,11 @@ export default function AbstractChoices () {
|
|
|
21
22
|
position: 'absolute',
|
|
22
23
|
top: 0,
|
|
23
24
|
width: '100%',
|
|
24
|
-
}}
|
|
25
|
+
}}
|
|
26
|
+
>
|
|
25
27
|
{!!currentMoveTargets.length && (
|
|
26
28
|
<button
|
|
29
|
+
type="button"
|
|
27
30
|
className="button button--style-c button--x-small abstract-choices__choice abstract-choices__choice--undo"
|
|
28
31
|
onClick={undoStep}
|
|
29
32
|
>
|
|
@@ -33,10 +36,11 @@ export default function AbstractChoices () {
|
|
|
33
36
|
{abstractChoices.map((choice, i) => (
|
|
34
37
|
<button
|
|
35
38
|
key={i}
|
|
39
|
+
type="button"
|
|
36
40
|
className="button button--style-b button--x-small abstract-choices__choice"
|
|
37
41
|
onClick={() => clickTarget(choice)}
|
|
38
42
|
>
|
|
39
|
-
{choice.value}
|
|
43
|
+
{String(choice.value ?? '')}
|
|
40
44
|
</button>
|
|
41
45
|
))}
|
|
42
46
|
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import Entity from '../entity/entity'
|
|
2
|
+
import type { GameEntity } from '../../types'
|
|
3
|
+
|
|
4
|
+
export default function Grid ({ grid }: { grid: GameEntity }) {
|
|
5
|
+
const { width, height, spaces } = grid.attributes
|
|
6
|
+
const w = Number(width ?? 0)
|
|
7
|
+
const h = Number(height ?? 0)
|
|
8
|
+
const spaceList = (spaces ?? []) as GameEntity[]
|
|
9
|
+
return (
|
|
10
|
+
<div
|
|
11
|
+
className="grid"
|
|
12
|
+
style={{
|
|
13
|
+
display: 'inline-grid',
|
|
14
|
+
width: '100%',
|
|
15
|
+
gridTemplateColumns: `repeat(${w}, 1fr)`,
|
|
16
|
+
gridTemplateRows: `repeat(${h}, 1fr)`,
|
|
17
|
+
}}
|
|
18
|
+
>
|
|
19
|
+
{spaceList.map((space, index) => (
|
|
20
|
+
<div
|
|
21
|
+
key={index}
|
|
22
|
+
className="grid__cell"
|
|
23
|
+
>
|
|
24
|
+
<Entity entity={space} />
|
|
25
|
+
</div>
|
|
26
|
+
))}
|
|
27
|
+
</div>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useGame } from '../../contexts/game-context'
|
|
2
|
+
import Grid from '../board/grid'
|
|
3
|
+
import Space from '../space/space'
|
|
4
|
+
import type { GameEntity } from '../../types'
|
|
5
|
+
|
|
6
|
+
export default function Entity ({ entity }: { entity: GameEntity }) {
|
|
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} />
|
|
15
|
+
case 'Space':
|
|
16
|
+
return <Space space={entity} />
|
|
17
|
+
default:
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
onClick={(e) => {
|
|
21
|
+
if (isClickable) {
|
|
22
|
+
e.stopPropagation()
|
|
23
|
+
clickTarget(entity)
|
|
24
|
+
}
|
|
25
|
+
}}
|
|
26
|
+
className={[
|
|
27
|
+
'entity',
|
|
28
|
+
attributes.player !== undefined && `player-${String(attributes.player)}`,
|
|
29
|
+
isClickable && 'entity--clickable',
|
|
30
|
+
].filter(Boolean).join(' ')}
|
|
31
|
+
>
|
|
32
|
+
{entity.rule.displayProperties?.map((property, i) => (
|
|
33
|
+
<div key={i}>
|
|
34
|
+
{property}: {String((attributes[property] as string | number | boolean | undefined) ?? '')}
|
|
35
|
+
</div>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -1,16 +1,29 @@
|
|
|
1
|
-
import
|
|
2
|
-
import Entity from '../entity/entity
|
|
3
|
-
import AbstractChoices from '../abstract-choices/abstract-choices
|
|
4
|
-
import GameStatus from '../game-status/game-status
|
|
5
|
-
import { GameProvider } from
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import Entity from '../entity/entity'
|
|
3
|
+
import AbstractChoices from '../abstract-choices/abstract-choices'
|
|
4
|
+
import GameStatus from '../game-status/game-status'
|
|
5
|
+
import { GameProvider } from '../../contexts/game-context'
|
|
6
|
+
import type { GameEntity, GameserverConnection } from '../../types'
|
|
6
7
|
|
|
7
|
-
export default function Game ({
|
|
8
|
-
|
|
8
|
+
export default function Game ({
|
|
9
|
+
gameConnection,
|
|
10
|
+
loading,
|
|
11
|
+
isSpectator,
|
|
12
|
+
}: {
|
|
13
|
+
gameConnection?: GameserverConnection | Record<string, never> | null
|
|
14
|
+
loading?: ReactNode
|
|
15
|
+
isSpectator?: boolean
|
|
16
|
+
}) {
|
|
17
|
+
const G = gameConnection && 'state' in gameConnection
|
|
18
|
+
? (gameConnection as unknown as GameserverConnection).state?.G as
|
|
19
|
+
| { sharedBoard: { entities: GameEntity[] }; personalBoards?: { entities: GameEntity[] }[] }
|
|
20
|
+
| undefined
|
|
21
|
+
: undefined
|
|
9
22
|
|
|
10
23
|
return G
|
|
11
24
|
? (
|
|
12
25
|
<GameProvider
|
|
13
|
-
gameConnection={gameConnection}
|
|
26
|
+
gameConnection={gameConnection as GameserverConnection}
|
|
14
27
|
isSpectator={isSpectator}
|
|
15
28
|
>
|
|
16
29
|
<div className="game">
|
|
@@ -49,7 +62,7 @@ export default function Game ({ gameConnection, loading, isSpectator }) {
|
|
|
49
62
|
))}
|
|
50
63
|
</div>
|
|
51
64
|
)}
|
|
52
|
-
<GameStatus gameConnection={gameConnection} />
|
|
65
|
+
<GameStatus gameConnection={gameConnection as GameserverConnection} />
|
|
53
66
|
</div>
|
|
54
67
|
</GameProvider>
|
|
55
68
|
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { GameserverConnection } from '../../types'
|
|
2
|
+
|
|
3
|
+
export default function GameStatus ({
|
|
4
|
+
gameConnection,
|
|
5
|
+
}: {
|
|
6
|
+
gameConnection: GameserverConnection
|
|
7
|
+
}) {
|
|
8
|
+
const state = gameConnection.state
|
|
9
|
+
if (!state) return null
|
|
10
|
+
|
|
11
|
+
const players = gameConnection.client?.matchData as
|
|
12
|
+
| Record<string, { name?: string }>
|
|
13
|
+
| undefined
|
|
14
|
+
const winner = state.ctx.gameover?.winner as string | number | undefined
|
|
15
|
+
const draw = state.ctx.gameover?.draw
|
|
16
|
+
let winnerString = ''
|
|
17
|
+
if (draw) {
|
|
18
|
+
winnerString = 'Draw!'
|
|
19
|
+
} else if (players != null && winner != null && winner !== '') {
|
|
20
|
+
const key = String(winner)
|
|
21
|
+
winnerString = `${players[key]?.name ?? ''} Wins!`
|
|
22
|
+
} else if (winner != null && winner !== '') {
|
|
23
|
+
winnerString = `Player ${String(winner)} Wins!`
|
|
24
|
+
}
|
|
25
|
+
return state.ctx.gameover && (
|
|
26
|
+
<div className="game-status">
|
|
27
|
+
{winnerString}
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useGame } from '../../contexts/game-context'
|
|
2
|
+
import Entity from '../entity/entity'
|
|
3
|
+
import type { GameEntity } from '../../types'
|
|
4
|
+
|
|
5
|
+
export default function Space ({ space }: { space: GameEntity }) {
|
|
6
|
+
const { clickTarget, allClickable, currentMoveTargets } = useGame()
|
|
7
|
+
const { entities, entityId } = space.attributes
|
|
8
|
+
|
|
9
|
+
const entityList = entities ?? []
|
|
10
|
+
const clickable = allClickable.has(space)
|
|
11
|
+
const targeted = currentMoveTargets
|
|
12
|
+
.map((e) => (e as GameEntity).entityId ?? (e as GameEntity).attributes?.entityId)
|
|
13
|
+
.includes(entityId)
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<a
|
|
17
|
+
className={[
|
|
18
|
+
'space',
|
|
19
|
+
clickable && 'space--clickable',
|
|
20
|
+
targeted && 'space--targeted',
|
|
21
|
+
].filter(Boolean).join(' ')}
|
|
22
|
+
onClick={() => clickTarget(space)}
|
|
23
|
+
style={{
|
|
24
|
+
display: 'inline-block',
|
|
25
|
+
flex: '1',
|
|
26
|
+
}}
|
|
27
|
+
>
|
|
28
|
+
<div
|
|
29
|
+
className="space__entity-grid"
|
|
30
|
+
style={{
|
|
31
|
+
display: 'flex',
|
|
32
|
+
height: '100%',
|
|
33
|
+
width: '100%',
|
|
34
|
+
flexWrap: 'wrap',
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
{Array.from({ length: entityList.length }, (_, i) => (
|
|
38
|
+
<div
|
|
39
|
+
className="space__entity-grid__cell"
|
|
40
|
+
style={{
|
|
41
|
+
display: 'inline-block',
|
|
42
|
+
}}
|
|
43
|
+
key={i}
|
|
44
|
+
>
|
|
45
|
+
<Entity entity={entityList[i]} />
|
|
46
|
+
</div>
|
|
47
|
+
))}
|
|
48
|
+
{!entityList.length && String(space.attributes.name ?? '')}
|
|
49
|
+
</div>
|
|
50
|
+
</a>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
} from 'react'
|
|
7
|
+
import type { GameserverConnection } from '../types'
|
|
8
|
+
|
|
9
|
+
export interface GameContextValue {
|
|
10
|
+
clickTarget: (target: unknown) => void
|
|
11
|
+
undoStep: () => void
|
|
12
|
+
allClickable: Set<unknown>
|
|
13
|
+
currentMoveTargets: unknown[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const GameContext = createContext<GameContextValue>({
|
|
17
|
+
clickTarget: () => {},
|
|
18
|
+
undoStep: () => {},
|
|
19
|
+
allClickable: new Set(),
|
|
20
|
+
currentMoveTargets: [],
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export function GameProvider ({
|
|
24
|
+
gameConnection,
|
|
25
|
+
children,
|
|
26
|
+
isSpectator,
|
|
27
|
+
}: {
|
|
28
|
+
gameConnection: GameserverConnection
|
|
29
|
+
children: ReactNode
|
|
30
|
+
isSpectator?: boolean
|
|
31
|
+
}) {
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (gameConnection.state?._stateID === 0) {
|
|
34
|
+
gameConnection.reset()
|
|
35
|
+
}
|
|
36
|
+
}, [gameConnection.state?._stateID])
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<GameContext.Provider
|
|
40
|
+
value={{
|
|
41
|
+
clickTarget: (target: unknown) => {
|
|
42
|
+
if (!isSpectator) {
|
|
43
|
+
gameConnection.doStep(target)
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
undoStep: () => { gameConnection.undoStep() },
|
|
47
|
+
allClickable: (gameConnection.optimisticWinner || isSpectator)
|
|
48
|
+
? new Set()
|
|
49
|
+
: (gameConnection.allClickable ?? new Set()),
|
|
50
|
+
currentMoveTargets: (gameConnection.optimisticWinner || isSpectator)
|
|
51
|
+
? []
|
|
52
|
+
: (gameConnection.moveBuilder?.targets ?? []),
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
{children}
|
|
56
|
+
</GameContext.Provider>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const useGame = () => useContext(GameContext)
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare module '*.css' {}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import './styles.css'
|
|
2
|
+
export { Client, gameFactory } from 'board-game-engine'
|
|
3
|
+
export type { ClientOptions, GameFactoryInput } from 'board-game-engine'
|
|
4
|
+
|
|
5
|
+
export { default as Game } from './components/game/game'
|
|
6
|
+
export { useGameserverConnection } from './use-gameserver-connection'
|
|
7
|
+
export type { UseGameserverConnectionParams } from './use-gameserver-connection'
|
|
8
|
+
export type { GameserverConnection } from './types'
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Client } from 'board-game-engine'
|
|
2
|
+
|
|
3
|
+
export type GameserverConnection = Client & ReturnType<Client['getState']>
|
|
4
|
+
|
|
5
|
+
/** Bagel / board entity passed through the React tree */
|
|
6
|
+
export interface GameEntity {
|
|
7
|
+
attributes: {
|
|
8
|
+
entityType?: string
|
|
9
|
+
type?: string
|
|
10
|
+
player?: string | number
|
|
11
|
+
width?: number
|
|
12
|
+
height?: number
|
|
13
|
+
spaces?: GameEntity[]
|
|
14
|
+
entities?: GameEntity[]
|
|
15
|
+
entityId?: unknown
|
|
16
|
+
name?: string
|
|
17
|
+
[key: string]: unknown
|
|
18
|
+
}
|
|
19
|
+
rule: {
|
|
20
|
+
displayProperties?: string[]
|
|
21
|
+
}
|
|
22
|
+
entityId?: unknown
|
|
23
|
+
abstract?: boolean
|
|
24
|
+
value?: unknown
|
|
25
|
+
}
|