diff --git a/examples/react-web/config/webpack.config.js b/examples/react-web/config/webpack.config.js index 78934ecc3..e4fd2b849 100644 --- a/examples/react-web/config/webpack.config.js +++ b/examples/react-web/config/webpack.config.js @@ -322,6 +322,15 @@ module.exports = function(webpackEnv) { name: 'static/media/[name].[hash:8].[ext]', }, }, + { + test: [/\.bin$/], + // exclude hash in bin to enable gltf loading + loader: require.resolve('url-loader'), + options: { + limit: 10000, + name: 'static/media/[name].[ext]', + }, + }, // Process application JS with Babel. // The preset includes JSX, Flow, TypeScript, and some ESnext features. { diff --git a/examples/react-web/src/ui/chess3d/board3d.js b/examples/react-web/src/ui/chess3d/board3d.js new file mode 100644 index 000000000..476469bf3 --- /dev/null +++ b/examples/react-web/src/ui/chess3d/board3d.js @@ -0,0 +1,298 @@ +/* + * Copyright 2018 The boardgame.io Authors. + * + * Use of this source code is governed by a MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import Chess from 'chess.js'; +import Checkerboard from './checkerboard3d'; +import { Token } from 'boardgame.io/ui'; +import bishopObj from './pieces/bishop.gltf'; +import './pieces/bishop.bin'; +import kingObj from './pieces/king.gltf'; +import './pieces/king.bin'; +import knightObj from './pieces/knight.gltf'; +import './pieces/knight.bin'; +import pawnObj from './pieces/pawn.gltf'; +import './pieces/pawn.bin'; +import queenObj from './pieces/queen.gltf'; +import './pieces/queen.bin'; +import rookObj from './pieces/rook.gltf'; +import './pieces/rook.bin'; +var THREE = (window.THREE = require('../../../../../node_modules/three')); +require('../../../../../node_modules/three/examples/js/loaders/GLTFLoader'); + +const COL_NAMES = 'abcdefgh'; +const SELECTED_COLOR = 'green'; +const MOVABLE_COLOR = 'palegreen'; + +class Board extends React.Component { + static propTypes = { + G: PropTypes.any.isRequired, + ctx: PropTypes.any.isRequired, + moves: PropTypes.any.isRequired, + playerID: PropTypes.string, + isActive: PropTypes.bool, + isMultiplayer: PropTypes.bool, + isConnected: PropTypes.bool, + }; + + constructor(props) { + super(props); + this.chess = new Chess(); + this.loader = new THREE.GLTFLoader(); + } + + _handleMesh = (out, type) => { + out = out.scene.children[0]; + let temp = {}; + temp[type] = out; + // get the relative size of he piece + const bbox = new THREE.Box3().setFromObject(out); + let meshSize = new THREE.Vector3(); + bbox.getSize(meshSize); + temp[type].realSize = meshSize.x; + this.setState(temp); + }; + + state = { + selected: '', + mesh: {}, + loading: true, + }; + + componentDidMount() { + // loading 3d objects + this.loader.load(bishopObj, out => this._handleMesh(out, 'bishop')); + this.loader.load(kingObj, out => this._handleMesh(out, 'king')); + this.loader.load(knightObj, out => this._handleMesh(out, 'knight')); + this.loader.load(pawnObj, out => this._handleMesh(out, 'pawn')); + this.loader.load(queenObj, out => this._handleMesh(out, 'queen')); + this.loader.load(rookObj, out => this._handleMesh(out, 'rook')); + } + + // eslint-disable-next-line react/no-deprecated + componentWillReceiveProps(nextProps) { + if (nextProps.G.pgn) { + this.chess.load_pgn(nextProps.G.pgn); + this.setState({ selected: '' }); + } + } + + render() { + let disconnected = null; + if (this.props.isMultiplayer && !this.props.isConnected) { + disconnected =

Disconnected!

; + } + return ( +
+ + {this._getPieces()} + + {this._getStatus()} + {disconnected} +
+ ); + } + + click = ({ square }) => { + if (!this.props.isActive) { + return; + } + + if (!this.state.selected && this._isSelectable(square)) { + this.setState({ selected: square }); + } else if (this.state.selected) { + let moves = this._getMoves(); + let move = moves.find( + move => move.from == this.state.selected && move.to == square + ); + if (move) { + this.props.moves.move(move.san); + } else { + this.setState({ selected: '' }); + } + } + }; + + _getHighlightedSquares() { + let result = {}; + if (this.state.selected) { + result[this.state.selected] = SELECTED_COLOR; + } + for (let move of this._getMoves()) { + result[move.to] = MOVABLE_COLOR; + } + return result; + } + + _getLargest() { + let max = -Infinity; + for (const p of 'bknpqr') { + let piece = this._pieceShortToLong(p); + max = this.state[piece].realSize > max ? this.state[piece].realSize : max; + } + this._maxRealSize = max; + } + + _getSize(type) { + // if not finished loading return null + for (const p of 'bknpqr') { + let piece = this._pieceShortToLong(p); + if (!this.state[piece]) return null; + } + // otherwise return size ratio to max size + let piece = this._pieceShortToLong(type); + if (!this._maxRealSize) this._getLargest(); + return this.state[piece].realSize / this._maxRealSize; + } + + _pieceShortToLong(type) { + switch (type) { + case 'b': + return 'bishop'; + case 'k': + return 'king'; + case 'n': + return 'knight'; + case 'p': + return 'pawn'; + case 'q': + return 'queen'; + case 'r': + return 'rook'; + } + } + + _getPieces() { + let result = []; + for (let y = 1; y <= 8; y++) { + for (let x = 0; x < 8; x++) { + let square = COL_NAMES[x] + y; + let p = this.chess.get(square); + if (p) { + result.push( + + ); + } + } + } + return result; + } + + _getPieceByTypeAndColor(type, color) { + let piece = this._pieceShortToLong(type); + let mesh; + mesh = this.state[piece]; + if (mesh) { + let ret = mesh.clone(); + let blackMat = new THREE.MeshLambertMaterial({ color: 0x555555 }); + let whiteMat = new THREE.MeshLambertMaterial({ color: 0xeeeeee }); + // rotate the piece and assign color. + if (color == 'b') { + if (piece == 'bishop') { + // the bishop piece has different orientation with other pieces + ret.rotateOnWorldAxis(new THREE.Vector3(0, 1, 0), Math.PI); + } + ret.traverse(obj => { + if (obj.material) { + obj.material = blackMat; + } + }); + } else { + if (piece != 'bishop') { + ret.rotateOnWorldAxis(new THREE.Vector3(0, 1, 0), Math.PI); + } + ret.traverse(obj => { + if (obj.material) { + obj.material = whiteMat; + } + }); + } + return ret; + } else return null; + } + + _getStatus() { + let message = null; + if (this.chess.in_check()) { + message = 'CHECK'; + } + if (this.props.ctx.winner) { + switch (this.props.ctx.winner) { + case 'b': + message = 'Black won!'; + break; + case 'w': + message = 'White won!'; + break; + case 'd': + message = 'Draw!'; + break; + } + } + if (message) { + return ( +

+ {message} +

+ ); + } + } + + _getInitialCell(square) { + let history = this.chess.history({ verbose: true }); + let lastSeen = square; + for (let i = history.length - 1; i >= 0; i--) { + let move = history[i]; + if (lastSeen == move.to) { + lastSeen = move.from; + } + } + return lastSeen; + } + + _isSelectable(square) { + let piece = this.chess.get(square); + return ( + piece && + piece.color === this._getCurrentPlayer() && + this.chess.moves({ square }).length > 0 + ); + } + + _getCurrentPlayer() { + if (this.props.ctx.currentPlayer == 0) { + return 'w'; + } else { + return 'b'; + } + } + + _getMoves() { + if (!this.state.selected) { + return []; + } + return this.chess.moves({ + verbose: true, + square: this.state.selected, + }); + } +} + +export default Board; diff --git a/examples/react-web/src/ui/chess3d/checkerboard3d.js b/examples/react-web/src/ui/chess3d/checkerboard3d.js new file mode 100644 index 000000000..7ff95a7ed --- /dev/null +++ b/examples/react-web/src/ui/chess3d/checkerboard3d.js @@ -0,0 +1,132 @@ +/* + * Copyright 2018 The boardgame.io Authors. + * + * Use of this source code is governed by a MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { Grid } from 'boardgame.io/ui'; +import { UI } from 'boardgame.io/ui'; + +/** + * Checkerboard + * + * Component that will show a configurable checker board for games like + * chess, checkers and others. The vertical columns of squares are labeled + * with letters from a to z, while the rows are labeled with numbers, starting + * with 1. + * + * Props: + * rows - How many rows to show up, 8 by default. + * cols - How many columns to show up, 8 by default. Maximum is 26. + * onClick - On Click Callback, (row, col) of the square passed as argument. + * primaryColor - Primary color, #d18b47 by default. + * secondaryColor - Secondary color, #ffce9e by default. + * colorMap - Object of object having cell names as key and colors as values. + * Ex: { 'c5': 'red' } colors cells c5 with red. + * + * Usage: + * + * + * + * + * + * + */ +class Checkerboard extends React.Component { + static propTypes = { + rows: PropTypes.number, + cols: PropTypes.number, + onClick: PropTypes.func, + primaryColor: PropTypes.string, + secondaryColor: PropTypes.string, + highlightedSquares: PropTypes.object, + style: PropTypes.object, + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.element), + PropTypes.element, + ]), + }; + + static defaultProps = { + rows: 8, + cols: 8, + onClick: () => {}, + primaryColor: '#d18b47', + secondaryColor: '#ffce9e', + highlightedSquares: {}, + style: {}, + }; + + onClick = ({ x, y }) => { + this.props.onClick({ square: this._cartesianToAlgebraic(x, y) }); + }; + + render() { + // Convert the square="" prop to x and y. + const tokens = React.Children.map(this.props.children, child => { + const square = child.props.square; + const { x, y } = this._algebraicToCartesian(square); + const onClick = ({ x, y }) => { + child.props.onClick({ square: this._cartesianToAlgebraic(x, y) }); + }; + return React.cloneElement(child, { x, y, onClick }); + }); + + // Build colorMap with checkerboard pattern. + let colorMap = {}; + for (let x = 0; x < this.props.cols; x++) { + for (let y = 0; y < this.props.rows; y++) { + const key = `${x},${y}`; + let color = this.props.secondaryColor; + if ((x + y) % 2 == 0) { + color = this.props.primaryColor; + } + colorMap[key] = color; + } + } + + // Add highlighted squares. + for (const square in this.props.highlightedSquares) { + const { x, y } = this._algebraicToCartesian(square); + const key = `${x},${y}`; + colorMap[key] = this.props.highlightedSquares[square]; + } + + return ( + + + {tokens} + + + ); + } + + _algebraicToCartesian(square) { + let regexp = /([A-Za-z])(\d+)/g; + let match = regexp.exec(square); + if (match == null) { + throw 'Invalid square provided: ' + square; + } + let colSymbol = match[1].toLowerCase(); + let col = colSymbol.charCodeAt(0) - 'a'.charCodeAt(0); + let row = parseInt(match[2]); + return { x: col, y: this.props.rows - row }; + } + + _cartesianToAlgebraic(x, y) { + let colSymbol = String.fromCharCode(x + 'a'.charCodeAt(0)); + return colSymbol + (this.props.rows - y); + } +} + +export default Checkerboard; diff --git a/examples/react-web/src/ui/chess3d/game.js b/examples/react-web/src/ui/chess3d/game.js new file mode 100644 index 000000000..c26b9b5ea --- /dev/null +++ b/examples/react-web/src/ui/chess3d/game.js @@ -0,0 +1,70 @@ +/* + * Copyright 2018 The boardgame.io Authors + * + * Use of this source code is governed by a MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ + +import { Game } from 'boardgame.io/core'; +import Chess from 'chess.js'; + +// Helper to instantiate chess.js correctly on +// both browser and Node. +function Load(pgn) { + let chess = null; + if (Chess.Chess) { + chess = new Chess.Chess(); + } else { + chess = new Chess(); + } + chess.load_pgn(pgn); + return chess; +} + +const ChessGame = Game({ + name: 'chess', + + setup: () => ({ pgn: '' }), + + moves: { + move(G, ctx, san) { + const chess = Load(G.pgn); + if ( + (chess.turn() == 'w' && ctx.currentPlayer == '1') || + (chess.turn() == 'b' && ctx.currentPlayer == '0') + ) { + return { ...G }; + } + chess.move(san); + return { pgn: chess.pgn() }; + }, + }, + + flow: { + movesPerTurn: 1, + + endGameIf: G => { + const chess = Load(G.pgn); + if (chess.game_over()) { + if ( + chess.in_draw() || + chess.in_threefold_repetition() || + chess.insufficient_material() || + chess.in_stalemate() + ) { + return 'd'; + } + if (chess.in_checkmate()) { + if (chess.turn() == 'w') { + return 'b'; + } else { + return 'w'; + } + } + } + }, + }, +}); + +export default ChessGame; diff --git a/examples/react-web/src/ui/chess3d/pieces/CREDITS b/examples/react-web/src/ui/chess3d/pieces/CREDITS new file mode 100644 index 000000000..75510f096 --- /dev/null +++ b/examples/react-web/src/ui/chess3d/pieces/CREDITS @@ -0,0 +1,5 @@ +Chess piece artwork made by marcelo.medeirossilva: + +https://sketchfab.com/marcelo.medeirossilva + +The artwork is under CC Attribution-NonCommercial-ShareAlike license. \ No newline at end of file diff --git a/examples/react-web/src/ui/chess3d/pieces/bishop.bin b/examples/react-web/src/ui/chess3d/pieces/bishop.bin new file mode 100644 index 000000000..da3adbc04 Binary files /dev/null and b/examples/react-web/src/ui/chess3d/pieces/bishop.bin differ diff --git a/examples/react-web/src/ui/chess3d/pieces/bishop.gltf b/examples/react-web/src/ui/chess3d/pieces/bishop.gltf new file mode 100644 index 000000000..9e3a174b9 --- /dev/null +++ b/examples/react-web/src/ui/chess3d/pieces/bishop.gltf @@ -0,0 +1,211 @@ +{ + "accessors": [ + { + "bufferView": 1, + "componentType": 5126, + "count": 833, + "max": [ + 1.9688500165939331, + 1.9627100229263306, + 5.044950008392334 + ], + "min": [ + -1.9566400051116943, + -1.9627900123596191, + -2.8324899673461914 + ], + "type": "VEC3" + }, + { + "bufferView": 1, + "byteOffset": 9996, + "componentType": 5126, + "count": 833, + "max": [ + 1, + 0.95240366458892822, + 1 + ], + "min": [ + -1, + -0.95240366458892822, + -1 + ], + "type": "VEC3" + }, + { + "bufferView": 0, + "componentType": 5125, + "count": 1347, + "max": [ + 832 + ], + "min": [ + 0 + ], + "type": "SCALAR" + } + ], + "asset": { + "extras": { + "author": "marcelo.medeirossilva (https://sketchfab.com/marcelo.medeirossilva)", + "license": "CC-BY-NC-SA-4.0 (http://creativecommons.org/licenses/by-nc-sa/4.0/)", + "source": "https://sketchfab.com/models/75681488e5fe457280813781cf3d15c1", + "title": "Low Poly Chess - Bishop" + }, + "generator": "Sketchfab-3.18.7", + "version": "2.0" + }, + "bufferViews": [ + { + "buffer": 0, + "byteLength": 5388, + "byteOffset": 0, + "name": "floatBufferViews", + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 19992, + "byteOffset": 5388, + "byteStride": 12, + "name": "floatBufferViews", + "target": 34962 + } + ], + "buffers": [ + { + "byteLength": 25380, + "uri": "bishop.bin" + } + ], + "materials": [ + { + "doubleSided": true, + "emissiveFactor": [ + 0, + 0, + 0 + ], + "name": "Root", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 0.41025152440000001, + 0.41025152440000001, + 0.41025152440000001, + 1 + ], + "metallicFactor": 0, + "roughnessFactor": 0.59999999999999998 + } + } + ], + "meshes": [ + { + "name": "Chess_Bishop_0", + "primitives": [ + { + "attributes": { + "NORMAL": 1, + "POSITION": 0 + }, + "indices": 2, + "material": 0, + "mode": 4 + } + ] + } + ], + "nodes": [ + { + "children": [ + 1 + ], + "name": "RootNode (gltf orientation matrix)", + "rotation": [ + -0.70710678118654746, + -0, + -0, + 0.70710678118654757 + ] + }, + { + "children": [ + 2 + ], + "name": "RootNode (model correction matrix)" + }, + { + "children": [ + 3, + 5 + ], + "name": "Root" + }, + { + "children": [ + 4 + ], + "matrix": [ + -0.29086000000000006, + 0.95517000000000007, + -0.05519000000000001, + 0, + -0.77110000000000001, + -0.19988000000000003, + 0.60452000000000006, + 0, + 0.56639000000000006, + 0.21839000000000003, + 0.7946700000000001, + 0, + 4.0762500000000008, + 1.00545, + 5.9038600000000008, + 1 + ], + "name": "Lamp" + }, + { + "name": "Lamp" + }, + { + "children": [ + 6 + ], + "matrix": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + -0.0061000000000000021, + 4.0000000000000017e-05, + 2.8324899999999995, + 1 + ], + "name": "Chess_Bishop" + }, + { + "mesh": 0, + "name": "Chess_Bishop_0" + } + ], + "scene": 0, + "scenes": [ + { + "name": "OSG_Scene", + "nodes": [ + 0 + ] + } + ] +} + diff --git a/examples/react-web/src/ui/chess3d/pieces/king.bin b/examples/react-web/src/ui/chess3d/pieces/king.bin new file mode 100644 index 000000000..53c0077a9 Binary files /dev/null and b/examples/react-web/src/ui/chess3d/pieces/king.bin differ diff --git a/examples/react-web/src/ui/chess3d/pieces/king.gltf b/examples/react-web/src/ui/chess3d/pieces/king.gltf new file mode 100644 index 000000000..b45f4fb25 --- /dev/null +++ b/examples/react-web/src/ui/chess3d/pieces/king.gltf @@ -0,0 +1,211 @@ +{ + "accessors": [ + { + "bufferView": 1, + "componentType": 5126, + "count": 1111, + "max": [ + 2.20809006690979, + 2.20809006690979, + 6.3334498405456543 + ], + "min": [ + -2.20809006690979, + -2.20809006690979, + -3.4714999198913574 + ], + "type": "VEC3" + }, + { + "bufferView": 1, + "byteOffset": 13332, + "componentType": 5126, + "count": 1111, + "max": [ + 1, + 1, + 1 + ], + "min": [ + -1, + -1, + -1 + ], + "type": "VEC3" + }, + { + "bufferView": 0, + "componentType": 5125, + "count": 1890, + "max": [ + 1110 + ], + "min": [ + 0 + ], + "type": "SCALAR" + } + ], + "asset": { + "extras": { + "author": "marcelo.medeirossilva (https://sketchfab.com/marcelo.medeirossilva)", + "license": "CC-BY-NC-SA-4.0 (http://creativecommons.org/licenses/by-nc-sa/4.0/)", + "source": "https://sketchfab.com/models/640c64de51ba48c4aa341b803e243d36", + "title": "Low Poly Chess - King" + }, + "generator": "Sketchfab-3.18.7", + "version": "2.0" + }, + "bufferViews": [ + { + "buffer": 0, + "byteLength": 7560, + "byteOffset": 0, + "name": "floatBufferViews", + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 26664, + "byteOffset": 7560, + "byteStride": 12, + "name": "floatBufferViews", + "target": 34962 + } + ], + "buffers": [ + { + "byteLength": 34224, + "uri": "king.bin" + } + ], + "materials": [ + { + "doubleSided": true, + "emissiveFactor": [ + 0, + 0, + 0 + ], + "name": "Root", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 1, + 1, + 1, + 1 + ], + "metallicFactor": 0, + "roughnessFactor": 0.59999999999999998 + } + } + ], + "meshes": [ + { + "name": "Chess_King_0", + "primitives": [ + { + "attributes": { + "NORMAL": 1, + "POSITION": 0 + }, + "indices": 2, + "material": 0, + "mode": 4 + } + ] + } + ], + "nodes": [ + { + "children": [ + 1 + ], + "name": "RootNode (gltf orientation matrix)", + "rotation": [ + -0.70710678118654746, + -0, + -0, + 0.70710678118654757 + ] + }, + { + "children": [ + 2 + ], + "name": "RootNode (model correction matrix)" + }, + { + "children": [ + 3, + 5 + ], + "name": "Root" + }, + { + "children": [ + 4 + ], + "matrix": [ + -0.29086000000000006, + 0.95517000000000007, + -0.05519000000000001, + 0, + -0.77110000000000001, + -0.19988000000000003, + 0.60452000000000006, + 0, + 0.56639000000000006, + 0.21839000000000003, + 0.7946700000000001, + 0, + 4.0762500000000008, + 1.00545, + 5.9038600000000008, + 1 + ], + "name": "Lamp" + }, + { + "name": "Lamp" + }, + { + "children": [ + 6 + ], + "matrix": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + -0, + 3.4714999999999998, + 1 + ], + "name": "Chess_King" + }, + { + "mesh": 0, + "name": "Chess_King_0" + } + ], + "scene": 0, + "scenes": [ + { + "name": "OSG_Scene", + "nodes": [ + 0 + ] + } + ] +} + diff --git a/examples/react-web/src/ui/chess3d/pieces/knight.bin b/examples/react-web/src/ui/chess3d/pieces/knight.bin new file mode 100644 index 000000000..7591d2dec Binary files /dev/null and b/examples/react-web/src/ui/chess3d/pieces/knight.bin differ diff --git a/examples/react-web/src/ui/chess3d/pieces/knight.gltf b/examples/react-web/src/ui/chess3d/pieces/knight.gltf new file mode 100644 index 000000000..2eac38e22 --- /dev/null +++ b/examples/react-web/src/ui/chess3d/pieces/knight.gltf @@ -0,0 +1,211 @@ +{ + "accessors": [ + { + "bufferView": 1, + "componentType": 5126, + "count": 1457, + "max": [ + 1.9627499580383301, + 1.8802800178527832, + 4.1786699295043945 + ], + "min": [ + -1.9627499580383301, + -2.0452098846435547, + -2.522550106048584 + ], + "type": "VEC3" + }, + { + "bufferView": 1, + "byteOffset": 17484, + "componentType": 5126, + "count": 1457, + "max": [ + 1, + 0.98960000276565552, + 0.99914759397506714 + ], + "min": [ + -1, + -1, + -1 + ], + "type": "VEC3" + }, + { + "bufferView": 0, + "componentType": 5125, + "count": 2196, + "max": [ + 1456 + ], + "min": [ + 0 + ], + "type": "SCALAR" + } + ], + "asset": { + "extras": { + "author": "marcelo.medeirossilva (https://sketchfab.com/marcelo.medeirossilva)", + "license": "CC-BY-NC-SA-4.0 (http://creativecommons.org/licenses/by-nc-sa/4.0/)", + "source": "https://sketchfab.com/models/112534cb4cbb47588c2cf566441f37fc", + "title": "Low Poly Chess - Knight" + }, + "generator": "Sketchfab-3.18.7", + "version": "2.0" + }, + "bufferViews": [ + { + "buffer": 0, + "byteLength": 8784, + "byteOffset": 0, + "name": "floatBufferViews", + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 34968, + "byteOffset": 8784, + "byteStride": 12, + "name": "floatBufferViews", + "target": 34962 + } + ], + "buffers": [ + { + "byteLength": 43752, + "uri": "knight.bin" + } + ], + "materials": [ + { + "doubleSided": true, + "emissiveFactor": [ + 0, + 0, + 0 + ], + "name": "Root", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 1, + 1, + 1, + 1 + ], + "metallicFactor": 0, + "roughnessFactor": 0.59999999999999998 + } + } + ], + "meshes": [ + { + "name": "Chess_Knight_0", + "primitives": [ + { + "attributes": { + "NORMAL": 1, + "POSITION": 0 + }, + "indices": 2, + "material": 0, + "mode": 4 + } + ] + } + ], + "nodes": [ + { + "children": [ + 1 + ], + "name": "RootNode (gltf orientation matrix)", + "rotation": [ + -0.70710678118654746, + -0, + -0, + 0.70710678118654757 + ] + }, + { + "children": [ + 2 + ], + "name": "RootNode (model correction matrix)" + }, + { + "children": [ + 3, + 5 + ], + "name": "Root" + }, + { + "children": [ + 4 + ], + "matrix": [ + -0.29086000000000006, + 0.95517000000000007, + -0.05519000000000001, + 0, + -0.77110000000000001, + -0.19988000000000003, + 0.60452000000000006, + 0, + 0.56639000000000006, + 0.21839000000000003, + 0.7946700000000001, + 0, + 4.0762500000000008, + 1.00545, + 5.9038600000000008, + 1 + ], + "name": "Lamp" + }, + { + "name": "Lamp" + }, + { + "children": [ + 6 + ], + "matrix": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0.082470000000000016, + 2.5225499999999998, + 1 + ], + "name": "Chess_Knight" + }, + { + "mesh": 0, + "name": "Chess_Knight_0" + } + ], + "scene": 0, + "scenes": [ + { + "name": "OSG_Scene", + "nodes": [ + 0 + ] + } + ] +} + diff --git a/examples/react-web/src/ui/chess3d/pieces/pawn.bin b/examples/react-web/src/ui/chess3d/pieces/pawn.bin new file mode 100644 index 000000000..389ff4686 Binary files /dev/null and b/examples/react-web/src/ui/chess3d/pieces/pawn.bin differ diff --git a/examples/react-web/src/ui/chess3d/pieces/pawn.gltf b/examples/react-web/src/ui/chess3d/pieces/pawn.gltf new file mode 100644 index 000000000..9a76726d6 --- /dev/null +++ b/examples/react-web/src/ui/chess3d/pieces/pawn.gltf @@ -0,0 +1,211 @@ +{ + "accessors": [ + { + "bufferView": 1, + "componentType": 5126, + "count": 448, + "max": [ + 1.472059965133667, + 1.472059965133667, + 2.8698999881744385 + ], + "min": [ + -1.472059965133667, + -1.472059965133667, + -1.7796900272369385 + ], + "type": "VEC3" + }, + { + "bufferView": 1, + "byteOffset": 5376, + "componentType": 5126, + "count": 448, + "max": [ + 0.92387974262237549, + 0.92387974262237549, + 0.94439965486526489 + ], + "min": [ + -0.92387974262237549, + -0.92387974262237549, + -1 + ], + "type": "VEC3" + }, + { + "bufferView": 0, + "componentType": 5125, + "count": 666, + "max": [ + 447 + ], + "min": [ + 0 + ], + "type": "SCALAR" + } + ], + "asset": { + "extras": { + "author": "marcelo.medeirossilva (https://sketchfab.com/marcelo.medeirossilva)", + "license": "CC-BY-NC-SA-4.0 (http://creativecommons.org/licenses/by-nc-sa/4.0/)", + "source": "https://sketchfab.com/models/3443007498c54b5b9a31a08697a3b1b3", + "title": "Low Poly Chess - Pawn" + }, + "generator": "Sketchfab-3.18.7", + "version": "2.0" + }, + "bufferViews": [ + { + "buffer": 0, + "byteLength": 2664, + "byteOffset": 0, + "name": "floatBufferViews", + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 10752, + "byteOffset": 2664, + "byteStride": 12, + "name": "floatBufferViews", + "target": 34962 + } + ], + "buffers": [ + { + "byteLength": 13416, + "uri": "pawn.bin" + } + ], + "materials": [ + { + "doubleSided": true, + "emissiveFactor": [ + 0, + 0, + 0 + ], + "name": "Root", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 1, + 1, + 1, + 1 + ], + "metallicFactor": 0, + "roughnessFactor": 0.59999999999999998 + } + } + ], + "meshes": [ + { + "name": "Chess_Pawn_0", + "primitives": [ + { + "attributes": { + "NORMAL": 1, + "POSITION": 0 + }, + "indices": 2, + "material": 0, + "mode": 4 + } + ] + } + ], + "nodes": [ + { + "children": [ + 1 + ], + "name": "RootNode (gltf orientation matrix)", + "rotation": [ + -0.70710678118654746, + -0, + -0, + 0.70710678118654757 + ] + }, + { + "children": [ + 2 + ], + "name": "RootNode (model correction matrix)" + }, + { + "children": [ + 3, + 5 + ], + "name": "Root" + }, + { + "children": [ + 4 + ], + "matrix": [ + -0.29086000000000006, + 0.95517000000000007, + -0.05519000000000001, + 0, + -0.77110000000000001, + -0.19988000000000003, + 0.60452000000000006, + 0, + 0.56639000000000006, + 0.21839000000000003, + 0.7946700000000001, + 0, + 4.0762500000000008, + 1.00545, + 5.9038600000000008, + 1 + ], + "name": "Lamp" + }, + { + "name": "Lamp" + }, + { + "children": [ + 6 + ], + "matrix": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + -0, + 1.77969, + 1 + ], + "name": "Chess_Pawn" + }, + { + "mesh": 0, + "name": "Chess_Pawn_0" + } + ], + "scene": 0, + "scenes": [ + { + "name": "OSG_Scene", + "nodes": [ + 0 + ] + } + ] +} + diff --git a/examples/react-web/src/ui/chess3d/pieces/queen.bin b/examples/react-web/src/ui/chess3d/pieces/queen.bin new file mode 100644 index 000000000..1a72e3d29 Binary files /dev/null and b/examples/react-web/src/ui/chess3d/pieces/queen.bin differ diff --git a/examples/react-web/src/ui/chess3d/pieces/queen.gltf b/examples/react-web/src/ui/chess3d/pieces/queen.gltf new file mode 100644 index 000000000..93a67f370 --- /dev/null +++ b/examples/react-web/src/ui/chess3d/pieces/queen.gltf @@ -0,0 +1,211 @@ +{ + "accessors": [ + { + "bufferView": 1, + "componentType": 5126, + "count": 960, + "max": [ + 2.20809006690979, + 2.20809006690979, + 5.3958601951599121 + ], + "min": [ + -2.20809006690979, + -2.20809006690979, + -3.1525800228118896 + ], + "type": "VEC3" + }, + { + "bufferView": 1, + "byteOffset": 11520, + "componentType": 5126, + "count": 960, + "max": [ + 0.92388087511062622, + 0.92388087511062622, + 0.97531545162200928 + ], + "min": [ + -0.92388087511062622, + -0.92388087511062622, + -1 + ], + "type": "VEC3" + }, + { + "bufferView": 0, + "componentType": 5125, + "count": 1386, + "max": [ + 959 + ], + "min": [ + 0 + ], + "type": "SCALAR" + } + ], + "asset": { + "extras": { + "author": "marcelo.medeirossilva (https://sketchfab.com/marcelo.medeirossilva)", + "license": "CC-BY-NC-SA-4.0 (http://creativecommons.org/licenses/by-nc-sa/4.0/)", + "source": "https://sketchfab.com/models/ab958c61eb2a405aa7a7b0cec91c79b0", + "title": "Low Poly Chess - Queen" + }, + "generator": "Sketchfab-3.18.7", + "version": "2.0" + }, + "bufferViews": [ + { + "buffer": 0, + "byteLength": 5544, + "byteOffset": 0, + "name": "floatBufferViews", + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 23040, + "byteOffset": 5544, + "byteStride": 12, + "name": "floatBufferViews", + "target": 34962 + } + ], + "buffers": [ + { + "byteLength": 28584, + "uri": "queen.bin" + } + ], + "materials": [ + { + "doubleSided": true, + "emissiveFactor": [ + 0, + 0, + 0 + ], + "name": "Root", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 1, + 1, + 1, + 1 + ], + "metallicFactor": 0, + "roughnessFactor": 0.59999999999999998 + } + } + ], + "meshes": [ + { + "name": "Chess_Queen_0", + "primitives": [ + { + "attributes": { + "NORMAL": 1, + "POSITION": 0 + }, + "indices": 2, + "material": 0, + "mode": 4 + } + ] + } + ], + "nodes": [ + { + "children": [ + 1 + ], + "name": "RootNode (gltf orientation matrix)", + "rotation": [ + -0.70710678118654746, + -0, + -0, + 0.70710678118654757 + ] + }, + { + "children": [ + 2 + ], + "name": "RootNode (model correction matrix)" + }, + { + "children": [ + 3, + 5 + ], + "name": "Root" + }, + { + "children": [ + 4 + ], + "matrix": [ + -0.29086000000000006, + 0.95517000000000007, + -0.05519000000000001, + 0, + -0.77110000000000001, + -0.19988000000000003, + 0.60452000000000006, + 0, + 0.56639000000000006, + 0.21839000000000003, + 0.7946700000000001, + 0, + 4.0762500000000008, + 1.00545, + 5.9038600000000008, + 1 + ], + "name": "Lamp" + }, + { + "name": "Lamp" + }, + { + "children": [ + 6 + ], + "matrix": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + -0, + 3.1525799999999999, + 1 + ], + "name": "Chess_Queen" + }, + { + "mesh": 0, + "name": "Chess_Queen_0" + } + ], + "scene": 0, + "scenes": [ + { + "name": "OSG_Scene", + "nodes": [ + 0 + ] + } + ] +} + diff --git a/examples/react-web/src/ui/chess3d/pieces/rook.bin b/examples/react-web/src/ui/chess3d/pieces/rook.bin new file mode 100644 index 000000000..305aee51e Binary files /dev/null and b/examples/react-web/src/ui/chess3d/pieces/rook.bin differ diff --git a/examples/react-web/src/ui/chess3d/pieces/rook.gltf b/examples/react-web/src/ui/chess3d/pieces/rook.gltf new file mode 100644 index 000000000..166b75c07 --- /dev/null +++ b/examples/react-web/src/ui/chess3d/pieces/rook.gltf @@ -0,0 +1,211 @@ +{ + "accessors": [ + { + "bufferView": 1, + "componentType": 5126, + "count": 464, + "max": [ + 1.9627499580383301, + 1.9627499580383301, + 3.0701000690460205 + ], + "min": [ + -1.9627499580383301, + -1.9627499580383301, + -2.7254500389099121 + ], + "type": "VEC3" + }, + { + "bufferView": 1, + "byteOffset": 5568, + "componentType": 5126, + "count": 464, + "max": [ + 1, + 1, + 1 + ], + "min": [ + -1, + -1, + -1 + ], + "type": "VEC3" + }, + { + "bufferView": 0, + "componentType": 5125, + "count": 708, + "max": [ + 463 + ], + "min": [ + 0 + ], + "type": "SCALAR" + } + ], + "asset": { + "extras": { + "author": "marcelo.medeirossilva (https://sketchfab.com/marcelo.medeirossilva)", + "license": "CC-BY-NC-SA-4.0 (http://creativecommons.org/licenses/by-nc-sa/4.0/)", + "source": "https://sketchfab.com/models/cbd416e785f64648bff3675fd45b3594", + "title": "Low Poly Chess - Rook" + }, + "generator": "Sketchfab-3.18.7", + "version": "2.0" + }, + "bufferViews": [ + { + "buffer": 0, + "byteLength": 2832, + "byteOffset": 0, + "name": "floatBufferViews", + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 11136, + "byteOffset": 2832, + "byteStride": 12, + "name": "floatBufferViews", + "target": 34962 + } + ], + "buffers": [ + { + "byteLength": 13968, + "uri": "rook.bin" + } + ], + "materials": [ + { + "doubleSided": true, + "emissiveFactor": [ + 0, + 0, + 0 + ], + "name": "Root", + "pbrMetallicRoughness": { + "baseColorFactor": [ + 1, + 1, + 1, + 1 + ], + "metallicFactor": 0, + "roughnessFactor": 0.59999999999999998 + } + } + ], + "meshes": [ + { + "name": "Chess_Rook_0", + "primitives": [ + { + "attributes": { + "NORMAL": 1, + "POSITION": 0 + }, + "indices": 2, + "material": 0, + "mode": 4 + } + ] + } + ], + "nodes": [ + { + "children": [ + 1 + ], + "name": "RootNode (gltf orientation matrix)", + "rotation": [ + -0.70710678118654746, + -0, + -0, + 0.70710678118654757 + ] + }, + { + "children": [ + 2 + ], + "name": "RootNode (model correction matrix)" + }, + { + "children": [ + 3, + 5 + ], + "name": "Root" + }, + { + "children": [ + 4 + ], + "matrix": [ + -0.29086000000000006, + 0.95517000000000007, + -0.05519000000000001, + 0, + -0.77110000000000001, + -0.19988000000000003, + 0.60452000000000006, + 0, + 0.56639000000000006, + 0.21839000000000003, + 0.7946700000000001, + 0, + 4.0762500000000008, + 1.00545, + 5.9038600000000008, + 1 + ], + "name": "Lamp" + }, + { + "name": "Lamp" + }, + { + "children": [ + 6 + ], + "matrix": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + -0, + 2.7254499999999999, + 1 + ], + "name": "Chess_Rook" + }, + { + "mesh": 0, + "name": "Chess_Rook_0" + } + ], + "scene": 0, + "scenes": [ + { + "name": "OSG_Scene", + "nodes": [ + 0 + ] + } + ] +} + diff --git a/examples/react-web/src/ui/chess3d/singleplayer3d.js b/examples/react-web/src/ui/chess3d/singleplayer3d.js new file mode 100644 index 000000000..7abbbcd8f --- /dev/null +++ b/examples/react-web/src/ui/chess3d/singleplayer3d.js @@ -0,0 +1,25 @@ +/* + * Copyright 2018 The boardgame.io Authors. + * + * Use of this source code is governed by a MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ + +import React from 'react'; +import { Client } from 'boardgame.io/react'; +import ChessGame from './game'; +import ChessBoard from './board3d'; + +const App = Client({ + game: ChessGame, + board: ChessBoard, +}); + +const Singleplayer = () => ( +
+ +
+); + +export default Singleplayer; diff --git a/examples/react-web/src/ui/drag-n-drop.js b/examples/react-web/src/ui/drag-n-drop.js index 3ac0467a9..c46e6fcce 100644 --- a/examples/react-web/src/ui/drag-n-drop.js +++ b/examples/react-web/src/ui/drag-n-drop.js @@ -37,22 +37,28 @@ class Board extends React.Component { render() { return ( - +
Drag the card into the deck
+ + + {this.state.deck.map(c => ( + + ))} + - - {this.state.deck.map(c => ( - - ))} - - - {this.state.free && ( - - )} - + {this.state.free && ( + + )} + +
); } } diff --git a/examples/react-web/src/ui/index.js b/examples/react-web/src/ui/index.js index ae69639d1..3d65016ca 100644 --- a/examples/react-web/src/ui/index.js +++ b/examples/react-web/src/ui/index.js @@ -7,13 +7,25 @@ */ import DragDrop from './drag-n-drop'; +import Test3D from './test3d'; +import Singleplayer3d from './chess3d/singleplayer3d'; const routes = [ { - path: '/ui', + path: '/ui/2d', text: 'Drag and Drop', component: DragDrop, }, + { + path: '/ui/3d', + text: '3D Grid', + component: Test3D, + }, + { + path: '/ui/chess3d', + text: 'Chess 3D', + component: Singleplayer3d, + }, ]; export default { routes }; diff --git a/examples/react-web/src/ui/test3d.js b/examples/react-web/src/ui/test3d.js new file mode 100644 index 000000000..009138ac6 --- /dev/null +++ b/examples/react-web/src/ui/test3d.js @@ -0,0 +1,93 @@ +/* + * Copyright 2018 The boardgame.io Authors. + * + * Use of this source code is governed by a MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ + +import React from 'react'; +import { Client } from 'boardgame.io/react'; +import { Game } from 'boardgame.io/core'; +import { UI, Grid, Token } from 'boardgame.io/ui'; +import bishop from './chess3d/pieces/bishop.gltf'; +import knight from './chess3d/pieces/knight.gltf'; +import './chess3d/pieces/bishop.bin'; +import './chess3d/pieces/knight.bin'; +// example and source use different modules, so direct to source modules here to make THREE.js global. +var THREE = (window.THREE = require('../../../../node_modules/three')); +require('../../../../node_modules/three/examples/js/loaders/GLTFLoader'); + +class Board extends React.Component { + constructor(props) { + super(props); + + this.state = { + deck: [], + free: true, + loading: true, + }; + } + + onClick = ({ x, y }) => { + console.log(x + ' ' + y); + }; + + componentDidMount() { + this.loader = new THREE.GLTFLoader(); + this.loader.load(bishop, out => { + out = out.scene.children[0]; + this.setState({ + bishop: out, + }); + }); + + this.loader.load(knight, out => { + out = out.scene.children[0]; + this.setState({ + knight: out, + }); + }); + } + + render() { + return ( +
+
Showing chess pieces on the grid
+ + + + + + +
+ ); + } +} + +const App = Client({ + game: Game({}), + board: Board, + debug: false, +}); + +const Singleplayer = () => ( +
+ +
+); + +export default Singleplayer; diff --git a/src/ui/3d/deck.js b/src/ui/3d/deck.js index 188114c4f..489bcbb91 100644 --- a/src/ui/3d/deck.js +++ b/src/ui/3d/deck.js @@ -48,7 +48,6 @@ export class DeckImpl extends React.Component { onEvent = e => { if (e.type == 'drop') { - console.log(e.what[0]); e.what[0].position.x = -2; e.what[0].position.z = 0; e.what[0].position.y += 20 * 0.02; diff --git a/src/ui/3d/grid.js b/src/ui/3d/grid.js index c163a1ced..75778beb1 100644 --- a/src/ui/3d/grid.js +++ b/src/ui/3d/grid.js @@ -7,17 +7,184 @@ */ import React from 'react'; +import PropTypes from 'prop-types'; +import UIContext from '../ui-context'; +import * as THREE from 'three'; + +/** + * Grid + * + * Component that will show children on a cartesian regular grid. + * + * Props: + * rows - Number of rows (height) of the grid. + * cols - Number of columns (width) of the grid. + * cellSize - Size of a square. + * thichness - Thichness of a square. + * padding - Padding between squares. + * colorMap - A map from 'x,y' => color. + * onClick - (x, y) => {} + * Called when a square is clicked. + * onMouseOver - (x, y) => {} + * Called when a square is mouse over. + * onMouseOut - (x, y) => {} + * Called when a square is mouse out. + * + * Usage: + * + * + * + * + */ +export const Grid = props => ( + + {context => } + +); + +class GridImpl extends React.Component { + static propTypes = { + rows: PropTypes.number.isRequired, + cols: PropTypes.number.isRequired, + cellSize: PropTypes.number, + thickness: PropTypes.number, + padding: PropTypes.number, + colorMap: PropTypes.object, + onClick: PropTypes.func, + onMouseOver: PropTypes.func, + onMouseOut: PropTypes.func, + context: PropTypes.any, + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.element), + PropTypes.element, + ]), + }; + static defaultProps = { + colorMap: {}, + cellSize: 1, + padding: 0.1, + thickness: 0.1, + }; + + constructor(props) { + super(props); + this.boardGroup = new THREE.Group(); + this.tokenGroup = new THREE.Group(); + this.boardGroup.add(this.tokenGroup); + // translate the board to center on (0,0,0) + this.boardGroup.translateX( + (-(this.props.padding + this.props.cellSize) * (this.props.cols - 1)) / 2 + ); + this.boardGroup.translateZ( + (-(this.props.padding + this.props.cellSize) * (this.props.rows - 1)) / 2 + ); + } + + _getCellColor(x, y) { + const key = `${x},${y}`; + let color = '#777777'; + if (key in this.props.colorMap) { + color = this.props.colorMap[key]; + } + return color; + } + + componentWillUnmount() { + this.context.remove(this.boardGroup); + } -// Not yet implemented. -export class Grid extends React.Component { render() { + this.context = this.props.context; + this.context.add(this.boardGroup); + + // when rerendering, render a new squareGroup + this.boardGroup.remove(this.squareGroup); + this.squareGroup = new THREE.Group(); + this.boardGroup.add(this.squareGroup); + + // add square base + for (let x = 0; x < this.props.cols; x++) { + for (let y = 0; y < this.props.rows; y++) { + const squareProps = { + x: x, + y: y, + size: this.props.cellSize, + color: this._getCellColor(x, y), + padding: this.props.padding, + thickness: this.props.thickness, + }; + + const square = new Square(squareProps); + this.squareGroup.add(square); + + const onEvent = e => { + if (e.type == 'click') { + if (this.props.onClick) this.props.onClick({ x: x, y: y }); + } else if (e.type == 'mouseOver') { + if (this.props.onMouseOver) this.props.onMouseOver({ x: x, y: y }); + } else if (e.type == 'mouseOut') { + if (this.props.onMouseOut) this.props.onMouseOut({ x: x, y: y }); + } + }; + + this.context.regCall(square, onEvent); + } + } + + // set tokens + const tokens = React.Children.map(this.props.children, child => { + return React.cloneElement(child, { + three: true, + boardSize: this.props.cellSize, + parent: this.tokenGroup, + padding: this.props.padding, + lift: this.props.thickness, + }); + }); + + if (tokens) { + return tokens; + } + return null; } } -// Not yet implemented. -export class Square extends React.Component { - render() { - return null; +/** + * Square + * + * Component that renders a square inside a Grid. + * + * Props + * x - X coordinate on grid coordinates. + * y - Y coordinate on grid coordinates. + * size - Square size. + * color - Color of the square + * thichness - Thichness of a square. + * padding - Padding between squares. + * + * Not meant to be used by the end user directly (use Token). + * Also not exposed in the NPM. + */ +export class Square extends THREE.Mesh { + constructor(props) { + super(); + this.userData = { + responsive: true, + draggable: false, + ...props, + }; + props = this.userData; + this.geometry = new THREE.BoxBufferGeometry( + props.size, + props.thickness, + props.size + ); + this.material = new THREE.MeshLambertMaterial({ color: props.color }); + + this.receiveShadow = true; + this.translateX(this.userData.x * (props.size + props.padding)); + this.translateZ(this.userData.y * (props.size + props.padding)); + this.translateY(this.userData.thickness / 2); } } diff --git a/src/ui/3d/grid.test.js b/src/ui/3d/grid.test.js new file mode 100644 index 000000000..47ebeadf8 --- /dev/null +++ b/src/ui/3d/grid.test.js @@ -0,0 +1,117 @@ +/* + * Copyright 2018 The boardgame.io Authors + * + * Use of this source code is governed by a MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ + +import React from 'react'; +import { Grid } from './grid'; +import { UI } from './ui'; +import Enzyme from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import * as THREE from 'three'; + +THREE.WebGLRenderer = jest.fn(function() { + this.shadowMap = {}; + this.setSize = () => { + return null; + }; + this.domElement = document.createElement('canvas'); + this.render = () => { + return null; + }; +}); +Enzyme.configure({ adapter: new Adapter() }); + +class MockChild extends React.Component { + render() { + return ; + } +} + +test('render correctly', () => { + const grid = Enzyme.mount( + + + + + + ); + expect(grid.html()).toContain('rect'); + expect(grid.html()).toContain('bgio-canvas'); + const gridIns = grid.find('GridImpl').instance(); + expect(gridIns.squareGroup.children).toHaveLength(12); +}); + +// test callback function +test('click handler', () => { + { + const onClick = jest.fn(); + const onMouseOver = jest.fn(); + const onMouseOut = jest.fn(); + const grid = Enzyme.mount( + + + + + + ); + const uiIns = grid.instance(); + const gridIns = grid.find('GridImpl').instance(); + const id = gridIns.squareGroup.children[0].id; + uiIns.callbacks_[id]({ + type: 'mouseOver', + }); + uiIns.callbacks_[id]({ + type: 'click', + }); + uiIns.callbacks_[id]({ + type: 'mouseOut', + }); + expect(onClick).toHaveBeenCalled(); + expect(onMouseOut).toHaveBeenCalled(); + expect(onMouseOver).toHaveBeenCalled(); + } + + // No crash when onClick is not provided. + const grid = Enzyme.mount( + + + + + + ); + const uiIns = grid.instance(); + const gridIns = grid.find('GridImpl').instance(); + const id = gridIns.squareGroup.children[0].id; + uiIns.callbacks_[id]({ + type: 'mouseOver', + }); + uiIns.callbacks_[id]({ + type: 'click', + }); + uiIns.callbacks_[id]({ + type: 'mouseOut', + }); +}); + +test('colorMap', () => { + const colorMap = { '0,0': '#d18b47' }; + const grid = Enzyme.mount( + + + + ); + let gridIns = grid.find('GridImpl').instance(); + expect(gridIns.squareGroup.children[0].material.color.getHexString()).toBe( + 'd18b47' + ); +}); diff --git a/src/ui/3d/loading.css b/src/ui/3d/loading.css new file mode 100644 index 000000000..c45942346 --- /dev/null +++ b/src/ui/3d/loading.css @@ -0,0 +1,17 @@ +.loader { + border: 16px solid #f3f3f3; /* Light grey */ + border-top: 16px solid #3498db; /* Blue */ + border-radius: 50%; + width: 80px; + height: 80px; + animation: spin 2s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/src/ui/3d/token.js b/src/ui/3d/token.js index ab0690d48..a1d5b2c9d 100644 --- a/src/ui/3d/token.js +++ b/src/ui/3d/token.js @@ -7,10 +7,156 @@ */ import React from 'react'; +import PropTypes from 'prop-types'; +import UIContext from '../ui-context'; +import * as THREE from 'three'; + +/** + * Token + * + * Component that represents a board game piece (or token). + * Can be used by itself or with one of the grid systems + * provided (Grid or HexGrid). + * + * A token renders as a 3D Mesh. IF no mesh prop is passed. + * It will render a white box on the grid. + * + * Props: + * x - X coordinate on grid / hex grid. + * y - Y coordinate on grid / hex grid. + * z - Z coordinate on hex grid. + * onClick - Called when the token is clicked. + * onMouseOver - Called when the token is mouse over. + * onMouseOut - Called when the token is mouse out. + * + * Usage: + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + */ +export const Token = props => ( + + {context => } + +); + +class TokenImpl extends React.Component { + static propTypes = { + x: PropTypes.number, + y: PropTypes.number, + z: PropTypes.number, + mesh: PropTypes.any, + padding: PropTypes.number, + size: PropTypes.number, + lift: PropTypes.number, + boardSize: PropTypes.number, + parent: PropTypes.instanceOf(THREE.Object3D), + context: PropTypes.object, + animate: PropTypes.bool, + onClick: PropTypes.func, + onMouseOver: PropTypes.func, + onMouseOut: PropTypes.func, + children: PropTypes.element, + animationDuration: PropTypes.number, + }; + + static defaultProps = { + animationDuration: 750, + size: 1, + padding: 0.1, + lift: 0.1, + }; + + constructor(props) { + super(); + + if (!props.size) { + this.size = props.boardSize; + } else { + this.size = props.size; + } + + if (props.parent) { + this.parent = props.parent; + } else { + this.parent = props.context; + } + } + + _attachMesh = mesh => { + const size = this.size; + let meshSize = new THREE.Vector3(); + let meshCenter = new THREE.Vector3(); + const bbox = new THREE.Box3().setFromObject(mesh); + bbox.getSize(meshSize); + bbox.getCenter(meshCenter); + // determine the scale factor + let scale = meshSize.z < meshSize.x ? meshSize.x : meshSize.z; + scale = size / scale; + mesh.scale.set(scale, scale, scale); + // set the mesh to the ground + if (this.props.boardSize && this.props.lift && this.props.padding) { + mesh.position.x = + this.props.x * (this.props.boardSize + this.props.padding); + mesh.position.z = + this.props.y * (this.props.boardSize + this.props.padding); + mesh.position.y = -bbox.min.y + this.props.lift; + } else { + mesh.position.x = this.props.x; + mesh.position.z = this.props.y; + mesh.position.y = -bbox.min.y; + } + this.parent.add(mesh); + // register the event + const onEvent = e => { + if (e.type == 'click') { + this.props.onClick({ x: this.props.x, y: this.props.y }); + } else if (e.type == 'mouseOver') { + this.props.onMouseOver({ x: this.props.x, y: this.props.y }); + } else if (e.type == 'mouseOut') { + this.props.onMouseOut({ x: this.props.x, y: this.props.y }); + } + }; + this.props.context.regCall(mesh, onEvent); + }; + + componentWillUnmount() { + this.parent.remove(this.prevMesh); + } -// Not yet implemented. -export class Token extends React.Component { render() { + let mesh = this.props.mesh; + + if (this.prevMesh && this.prevMesh === mesh) return null; + + if (!mesh) { + mesh = new THREE.Mesh( + new THREE.BoxBufferGeometry(1, 1 * 0.3, 1), + new THREE.MeshLambertMaterial({ color: '#eeeeee' }) + ); + this._attachMesh(mesh); + } else if (mesh.isObject3D) { + this._attachMesh(mesh); + } else { + console.error('Your input to tokens should be an three js 3d object'); + } + this.parent.remove(this.prevMesh); + this.prevMesh = mesh; + return null; } } diff --git a/src/ui/3d/token.test.js b/src/ui/3d/token.test.js new file mode 100644 index 000000000..a670a73d8 --- /dev/null +++ b/src/ui/3d/token.test.js @@ -0,0 +1,89 @@ +/* + * Copyright 2018 The boardgame.io Authors + * + * Use of this source code is governed by a MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ + +import React from 'react'; +import { Grid } from './grid'; +import { UI } from './ui'; +import { Token } from './token'; +import Enzyme from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import * as THREE from 'three'; + +// mock renderer function +THREE.WebGLRenderer = jest.fn(function() { + this.shadowMap = {}; + this.setSize = () => { + return null; + }; + this.domElement = document.createElement('canvas'); + this.render = () => { + return null; + }; +}); +Enzyme.configure({ adapter: new Adapter() }); + +test('click handler', () => { + const onClick = jest.fn(); + const onMouseOver = jest.fn(); + const onMouseOut = jest.fn(); + const token = Enzyme.mount( + + + + + + ); + + const uiIns = token.instance(); + const tokenIns = token.find('TokenImpl').instance(); + + const id = tokenIns.prevMesh.id; + uiIns.callbacks_[id]({ + type: 'mouseOver', + }); + uiIns.callbacks_[id]({ + type: 'click', + }); + uiIns.callbacks_[id]({ + type: 'mouseOut', + }); + expect(onClick).toHaveBeenCalled(); + expect(onMouseOut).toHaveBeenCalled(); + expect(onMouseOver).toHaveBeenCalled(); +}); + +test('correct x and y', () => { + const grid = Enzyme.mount( + + + + + + ); + const tokenIns = grid.find('TokenImpl').instance(); + const x = + tokenIns.props.x * (tokenIns.props.boardSize + tokenIns.props.padding); + const y = + tokenIns.props.y * (tokenIns.props.boardSize + tokenIns.props.padding); + expect(tokenIns.prevMesh.position.x).toBe(x); + expect(tokenIns.prevMesh.position.z).toBe(y); + + // No crash when componentWillUnmount + { + grid + .find('TokenImpl') + .instance() + .componentWillUnmount(); + } +}); diff --git a/src/ui/3d/ui.js b/src/ui/3d/ui.js index acd4d5d0c..9520939cb 100644 --- a/src/ui/3d/ui.js +++ b/src/ui/3d/ui.js @@ -11,19 +11,31 @@ import PropTypes from 'prop-types'; import UIContext from '../ui-context'; import * as THREE from 'three'; import TWEEN from '@tweenjs/tween.js'; +import './loading.css'; /** * Root element of the React/threejs based 3D UI framework. */ export class UI extends React.Component { static propTypes = { + width: PropTypes.number, + height: PropTypes.number, children: PropTypes.any, onMouseEvent: PropTypes.func, }; + static defaultProps = { + width: 1024, + height: 768, + }; + constructor(props) { super(props); + this.state = { + isLoading: false, + }; + /** * Set of callbacks that children of this element pass via context.subscribeToMouseEvents * in order to receive mouse events that pertain to the objects that they manage. @@ -47,13 +59,13 @@ export class UI extends React.Component { this.renderer = new THREE.WebGLRenderer({ antialias: true }); this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; - this.renderer.setSize(window.innerWidth, window.innerHeight); + this.renderer.setSize(this.props.width, this.props.height); // Set up camera. this.camera = new THREE.PerspectiveCamera( 45, - window.innerWidth / window.innerHeight, + this.props.width / this.props.height, 0.1, 1000 ); @@ -94,6 +106,40 @@ export class UI extends React.Component { this.childGroup = new THREE.Group(); this.scene.add(this.childGroup); + + // set up loading screen + + this.loader =
; + THREE.DefaultLoadingManager.onStart = () => { + this.setState({ isLoading: true }); + this.ref_.current.removeChild(this.renderer.domElement); + console.log('Started loading file'); + }; + THREE.DefaultLoadingManager.onLoad = () => { + this.setState({ isLoading: false }); + this.ref_.current.appendChild(this.renderer.domElement); + console.log('Loading Complete!'); + }; + + THREE.DefaultLoadingManager.onProgress = function( + url, + itemsLoaded, + itemsTotal + ) { + console.log( + 'Loading file: ' + + url + + '\nLoaded ' + + itemsLoaded + + ' of ' + + itemsTotal + + ' files.' + ); + }; + + THREE.DefaultLoadingManager.onError = function(url) { + console.log('There was an error loading: ' + url); + }; } setupMouseEvents() { @@ -139,20 +185,24 @@ export class UI extends React.Component { true ); } - if (this.props.onMouseEvent) { this.props.onMouseEvent(e, objects); } - objects.forEach(obj => { + // only intersect the nearest object. + let obj = objects[0]; + if (obj) { e.point = obj.point; - if (obj.object.id in this.callbacks_) { - this.callbacks_[obj.object.id](e); - } - if (obj.object.parent.id in this.callbacks_) { - this.callbacks_[obj.object.parent.id](e); + let current = this.childGroup.getObjectById(obj.object.id); + // check parents until we hit a callback or hit the top level. + while (current && current.parent && current.id != this.childGroup.id) { + if (current.id in this.callbacks_) { + this.callbacks_[current.id](e); + break; + } + current = current.parent; } - }); + } }; const onMouseDown = e => { @@ -224,8 +274,8 @@ export class UI extends React.Component { t = t.parentNode; } - mouse.x = (x / window.innerWidth) * 2 - 1; - mouse.y = -(y / window.innerHeight) * 2 + 1; + mouse.x = (x / this.props.width) * 2 - 1; + mouse.y = -(y / this.props.height) * 2 + 1; dispatchMouseCallbacks(e); @@ -287,6 +337,12 @@ export class UI extends React.Component { } }; + registerCallback = (obj, callback) => { + if (obj && callback) { + this.callbacks_[obj.id] = callback; + } + }; + getContext = () => { return { three: true, @@ -294,21 +350,31 @@ export class UI extends React.Component { remove: obj => this.scene.remove(obj), scene: this.scene, camera: this.camera, + regCall: this.registerCallback, }; }; - componentDidMount() { + _initCanvas() { this.renderer.domElement.id = 'bgio-canvas'; this.ref_.current.appendChild(this.renderer.domElement); this.setupMouseEvents(); this.animate(); } + componentDidMount() { + this._initCanvas(); + } + render() { + const children = React.Children.map(this.props.children, child => { + return React.cloneElement(child, { + three: true, + }); + }); return (
- {this.props.children} + {this.state.isLoading ? this.loader : children}
);