Make a multiplayer card game - Episode 7 | Create 3D graphical interface with Three.js
This section mainly introduces the use ofreact-three-fiber(referred to as R3F bellow) to realize the construction of interactive scenes.
Why Choose R3F
- R3F just expresses Three.js in JSX, no extra overhead
- Build scenes in a declarative way with react, including but not limited to components that can easily react to state, are easy to interact with, and can leverage React's ecosystem
Scenario construction implementation
- Card
Note that the texture required for rendering is obtained by passing the image address to useTexture
//Card.tsx
import { useTexture } from "@react-three/drei"
import { Mesh } from "three";
function Card(props: { faceTextureUrl: string, idx: number, beginX: number, serial: number }) {
let _selectedOffsetY = .2;
let _beginX = props.beginX;
const _texture = useTexture({
map: props.faceTextureUrl
})
return (
<mesh
position={[_beginX + props.idx * .5, 0, props.idx*0.001]}
onClick={(e: any) => {
e.stopPropagation();
let _targetMesh = e["eventObject"] as Mesh;
let _pos = _targetMesh.position;
let _upY;
if (_pos.y == 0) _upY = _selectedOffsetY;
else _upY = 0;
_targetMesh.position.set(_pos.x, _upY, _pos.z);
}}
userData={{"_d_cardSerial":props.serial}}
>
<boxGeometry args={[2, 2, .001]} />
<meshStandardMaterial {..._texture} />
</mesh>
);
}
export default Card;
- Game Scene
First you must declare a Canvas node, because all three.js must be under the Canvas node
<Canvas orthographic camera={{ zoom: 50, position: [0, 0, 100], rotation: [0, 0, 0] }}>
<R3fScene mainHandCards={mainHandCards} outCards={outCards} gameModel={_gameModel}></R3fScene>
</Canvas>
In order to facilitate the use of hooks provided by @react-three/fiber
(three.js related hooks can only be used under the Canvas node), the game scene node is proposed separately
const R3fScene = (props: { mainHandCards: number[], gameModel, outCards: number[][] }) => {
const {
scene,
camera,
} = useThree();
useEffect(() => {
console.log(scene.getObjectByName("handList"))
})
let mainHandCards = props.mainHandCards;
let _gameModel = props.gameModel;
_gameModel.context = scene;
function getFaceTextureUrl(serial): string {
let _prefix = "/faces/"
let _readableName = _gameModel.getCardReadableName(serial);
if (_readableName === "rJkr") return _prefix + "Poker_Joker_B.png"
else if (_readableName === "bJkr") return _prefix + "Poker_Joker_R.png"
else {
let _suitDic = { 0: "D", 1: "C", 2: "H", 3: "S" };
let _suitNumber: number = serial >> 4;
return _prefix + "Poker_" + _suitDic[_suitNumber] + _readableName + ".png";
}
}
let _cardCount = mainHandCards.length;
let _beginX = -((_cardCount-1) * .5+1.5)/2 ;
return (
<>
<CameraControls />
<ambientLight intensity={0.1} />
<directionalLight color="white" position={[0, 0, 5]} />
<group name="handList">
{mainHandCards.map((serial: number, idx) => {
return (<Card key={"k" + serial} faceTextureUrl={getFaceTextureUrl(serial)} idx={idx} beginX={_beginX} serial={serial} />)
})}
</group>
<group position={[2, 4, 0]} scale={.6} name="out-list-1">
{props.outCards[1].map((serial: number, idx) => {
return (<Card key={"k" + serial} faceTextureUrl={getFaceTextureUrl(serial)} idx={idx} beginX={_beginX} serial={serial} />)
})}
</group>
<group position={[-2, 4, 0]} scale={.6} name="out-list-2">
{props.outCards[2].map((serial: number, idx) => {
return (<Card key={"k" + serial} faceTextureUrl={getFaceTextureUrl(serial)} idx={idx} beginX={_beginX} serial={serial} />)
})}
</group>
<group position={[0, 2, 0]} scale={.6} name="out-list-0">
{props.outCards[0].map((serial: number, idx) => {
return (<Card key={"k" + serial} faceTextureUrl={getFaceTextureUrl(serial)} idx={idx} beginX={_beginX} serial={serial} />)
})}
</group>
</>
)
}
In order to reflect the 3D interface, add an orbiting camera (swipe the scene to adjust the camera corner)
extend({ OrbitControls });
const CameraControls = () => {
const {
camera,
gl: { domElement }
} = useThree();
camera.position.set(0, 0, 10);
const controls = useRef();
useFrame((state) => {
(controls.current as unknown as OrbitControls).update()
});
return (
// @ts-ignore
<orbitControls
ref={controls}
args={[camera, domElement]}
enableZoom={false}
maxAzimuthAngle={Math.PI / 4}
maxPolarAngle={Math.PI / 4}
minAzimuthAngle={-Math.PI / 4}
minPolarAngle={0}
rotationSpeed={0.01}
/>
);
};
Scene complete code:
//App.tsx
import { useEffect, useRef, useState } from "react";
import {
Canvas,
useFrame,
extend,
useThree,
} from "@react-three/fiber";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import "./App.css"
import Card from './component/Card';
import GameModel from "./base/src/game/model/GameModel";
import GameSceneMediator from "./base/src/game/view/GameSceneMediator";
import GameSceneView from "./component/GameSceneView";
extend({ OrbitControls });
const CameraControls = () => {
const {
camera,
gl: { domElement }
} = useThree();
camera.position.set(0, 0, 10);
const controls = useRef();
useFrame((state) => {
(controls.current as unknown as OrbitControls).update()
});
return (
// @ts-ignore
<orbitControls
ref={controls}
args={[camera, domElement]}
enableZoom={false}
maxAzimuthAngle={Math.PI / 4}
maxPolarAngle={Math.PI / 4}
minAzimuthAngle={-Math.PI / 4}
minPolarAngle={0}
rotationSpeed={0.01}
/>
);
};
const R3fScene = (props: { mainHandCards: number[], gameModel, outCards: number[][] }) => {
const {
scene,
camera,
} = useThree();
let mainHandCards = props.mainHandCards;
let _gameModel = props.gameModel;
_gameModel.context = scene;
function getFaceTextureUrl(serial): string {
let _prefix = "/faces/"
let _readableName = _gameModel.getCardReadableName(serial);
if (_readableName === "rJkr") return _prefix + "Poker_Joker_B.png"
else if (_readableName === "bJkr") return _prefix + "Poker_Joker_R.png"
else {
let _suitDic = { 0: "D", 1: "C", 2: "H", 3: "S" };
let _suitNumber: number = serial >> 4;
return _prefix + "Poker_" + _suitDic[_suitNumber] + _readableName + ".png";
}
}
let _cardCount = mainHandCards.length;
let _beginX = -((_cardCount-1) * .5+1.5)/2 ;
return (
<>
<CameraControls />
<ambientLight intensity={0.1} />
<directionalLight color="white" position={[0, 0, 5]} />
<group name="handList">
{mainHandCards.map((serial: number, idx) => {
return (<Card key={"k" + serial} faceTextureUrl={getFaceTextureUrl(serial)} idx={idx} beginX={_beginX} serial={serial} />)
})}
</group>
<group position={[2, 4, 0]} scale={.6} name="out-list-1">
{props.outCards[1].map((serial: number, idx) => {
return (<Card key={"k" + serial} faceTextureUrl={getFaceTextureUrl(serial)} idx={idx} beginX={_beginX} serial={serial} />)
})}
</group>
<group position={[-2, 4, 0]} scale={.6} name="out-list-2">
{props.outCards[2].map((serial: number, idx) => {
return (<Card key={"k" + serial} faceTextureUrl={getFaceTextureUrl(serial)} idx={idx} beginX={_beginX} serial={serial} />)
})}
</group>
<group position={[0, 2, 0]} scale={.6} name="out-list-0">
{props.outCards[0].map((serial: number, idx) => {
return (<Card key={"k" + serial} faceTextureUrl={getFaceTextureUrl(serial)} idx={idx} beginX={_beginX} serial={serial} />)
})}
</group>
</>
)
}
function App(props: any) {
let _gameFacade = props.gameFacade;
let _gameModel: GameModel = _gameFacade.retrieveProxy("GameModel");
let [mainHandCards, setMainHandCards] = useState(_gameModel.cardsArr);
let [outCards, setOutCards] = useState(_gameModel.outCards);
_gameModel.setMainHandCardsHook(setMainHandCards);
_gameModel.setOutCardsHook(setOutCards);
useEffect(() => {
if (_gameFacade.retrieveMediator("GameSceneMediator") == null) {
_gameFacade.registerMediator(new GameSceneMediator(null, new GameSceneView()));
}
});
return (
<>
<div id='canvas-container'>
<Canvas orthographic camera={{ zoom: 50, position: [0, 0, 100], rotation: [0, 0, 0] }}>
<R3fScene mainHandCards={mainHandCards} outCards={outCards} gameModel={_gameModel}></R3fScene>
</Canvas>
</div>
<div id="status" style={{ display: 'fix', textAlign: 'center', fontSize: "2em", userSelect: "none" }}>hello</div>
<div id='controlPanel-scores' className='controlPanel'>
<button id='controlPanel-scores-1' className='controlButton'>1</button>
<button id='controlPanel-scores-2' className='controlButton'>2</button>
<button id='controlPanel-scores-3' className='controlButton'>3</button>
</div>
<div id='controlPanel-operation' className='controlPanel'>
<button id='controlPanel-operation-pass' className='controlButton'>pass</button>
<button id='controlPanel-operation-play' className='controlButton'>play</button>
</div>
</>
);
}
export default App;
Framework adaptation changes
- Getting scene node
getViewComponent(name: string,isDOM:boolean = true,canvasScene:any = null) {
if(isDOM) return document.getElementById(name);
else {
if(canvasScene) return canvasScene.getObjectByName(name);
}
}
By passing in the scene object of three.js, call the getObjectByName interface to obtain the node with the pre-set name attribute
- Card value acquisition, card.userData (
userData
is the loading object of custom attributes in R3F, similar todata-yourAttribute
in react, here you can encapsulate a method to decouple GameSceneMediator from card value, making the mediator more reusable )
private onOutCards_C2S() {
this.hideControlPanel();
let _outCardsSerial = [];
let _cardsContainer = this.mViewClass.getViewComponent("handList",false,this.getGameModel().context);
let _cards = _cardsContainer.children;
for (let i = 0; i < _cards.length; i++) {
let _card = _cards[i];
if (this.mViewClass.isCardSelected(_card)) {
_outCardsSerial.push(_card.userData["_d_cardSerial"]||_card["_d_cardSerial"] || _card.getAttribute("data-card-serial"));
}
}
this.getNetFacade()?.sendNotification(card_game_pb.Cmd.PLAYCARDS_C2S, _outCardsSerial);
}
- Node coordinate adjustment
By judging the adjustment of the corresponding css attribute of the node style to the corresponding attribute of the node position object
isCardSelected(card) {
return card.position.y == .2 ? true : false;
}
Checkout the repo https://github.com/lizhiyu-me/Make-a-multiplayer-card-game/tree/episode7-r3f
本节主要介绍利用react-three-fiber(以下简称R3F)实现交互场景的搭建。
为什么选择R3F
- R3F仅仅是将Three.js用JSX进行表示,没有额外开销
- 可以用react的声明方式构建场景,包括但不限于组件可轻松对状态做出反应,易于交互,并且可以利用 React 的生态
场景构建实现
- 扑克牌
注意此处通过将图片地址传给useTexture
,得到渲染需要的纹理
//Card.tsx
import { useTexture } from "@react-three/drei"
import { Mesh } from "three";
function Card(props: { faceTextureUrl: string, idx: number, beginX: number, serial: number }) {
let _selectedOffsetY = .2;
let _beginX = props.beginX;
const _texture = useTexture({
map: props.faceTextureUrl
})
return (
<mesh
position={[_beginX + props.idx * .5, 0, props.idx*0.001]}
onClick={(e: any) => {
e.stopPropagation();
let _targetMesh = e["eventObject"] as Mesh;
let _pos = _targetMesh.position;
let _upY;
if (_pos.y == 0) _upY = _selectedOffsetY;
else _upY = 0;
_targetMesh.position.set(_pos.x, _upY, _pos.z);
}}
userData={{"_d_cardSerial":props.serial}}
>
<boxGeometry args={[2, 2, .001]} />
<meshStandardMaterial {..._texture} />
</mesh>
);
}
export default Card;
- 游戏场景
首先必须声明一个Canvas节点,因为three.js的所有都必须在Canvas节点下
<Canvas orthographic camera={{ zoom: 50, position: [0, 0, 100], rotation: [0, 0, 0] }}>
<R3fScene mainHandCards={mainHandCards} outCards={outCards} gameModel={_gameModel}></R3fScene>
</Canvas>
为了方便使用@react-three/fiber
提供的hook(three.js相关的hook只能在Canvas节点下使用),将游戏场景节点单独提出来
const R3fScene = (props: { mainHandCards: number[], gameModel, outCards: number[][] }) => {
const {
scene,
camera,
} = useThree();
useEffect(() => {
console.log(scene.getObjectByName("handList"))
})
let mainHandCards = props.mainHandCards;
let _gameModel = props.gameModel;
_gameModel.context = scene;
function getFaceTextureUrl(serial): string {
let _prefix = "/faces/"
let _readableName = _gameModel.getCardReadableName(serial);
if (_readableName === "rJkr") return _prefix + "Poker_Joker_B.png"
else if (_readableName === "bJkr") return _prefix + "Poker_Joker_R.png"
else {
let _suitDic = { 0: "D", 1: "C", 2: "H", 3: "S" };
let _suitNumber: number = serial >> 4;
return _prefix + "Poker_" + _suitDic[_suitNumber] + _readableName + ".png";
}
}
let _cardCount = mainHandCards.length;
let _beginX = -((_cardCount-1) * .5+1.5)/2 ;
return (
<>
<CameraControls />
<ambientLight intensity={0.1} />
<directionalLight color="white" position={[0, 0, 5]} />
<group name="handList">
{mainHandCards.map((serial: number, idx) => {
return (<Card key={"k" + serial} faceTextureUrl={getFaceTextureUrl(serial)} idx={idx} beginX={_beginX} serial={serial} />)
})}
</group>
<group position={[2, 4, 0]} scale={.6} name="out-list-1">
{props.outCards[1].map((serial: number, idx) => {
return (<Card key={"k" + serial} faceTextureUrl={getFaceTextureUrl(serial)} idx={idx} beginX={_beginX} serial={serial} />)
})}
</group>
<group position={[-2, 4, 0]} scale={.6} name="out-list-2">
{props.outCards[2].map((serial: number, idx) => {
return (<Card key={"k" + serial} faceTextureUrl={getFaceTextureUrl(serial)} idx={idx} beginX={_beginX} serial={serial} />)
})}
</group>
<group position={[0, 2, 0]} scale={.6} name="out-list-0">
{props.outCards[0].map((serial: number, idx) => {
return (<Card key={"k" + serial} faceTextureUrl={getFaceTextureUrl(serial)} idx={idx} beginX={_beginX} serial={serial} />)
})}
</group>
</>
)
}
为了体现3D界面,添加一个轨道相机(滑动场景即可调整相机转角)
extend({ OrbitControls });
const CameraControls = () => {
const {
camera,
gl: { domElement }
} = useThree();
camera.position.set(0, 0, 10);
const controls = useRef();
useFrame((state) => {
(controls.current as unknown as OrbitControls).update()
});
return (
// @ts-ignore
<orbitControls
ref={controls}
args={[camera, domElement]}
enableZoom={false}
maxAzimuthAngle={Math.PI / 4}
maxPolarAngle={Math.PI / 4}
minAzimuthAngle={-Math.PI / 4}
minPolarAngle={0}
rotationSpeed={0.01}
/>
);
};
场景完整代码:
//App.tsx
import { useEffect, useRef, useState } from "react";
import {
Canvas,
useFrame,
extend,
useThree,
} from "@react-three/fiber";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import "./App.css"
import Card from './component/Card';
import GameModel from "./base/src/game/model/GameModel";
import GameSceneMediator from "./base/src/game/view/GameSceneMediator";
import GameSceneView from "./component/GameSceneView";
extend({ OrbitControls });
const CameraControls = () => {
const {
camera,
gl: { domElement }
} = useThree();
camera.position.set(0, 0, 10);
const controls = useRef();
useFrame((state) => {
(controls.current as unknown as OrbitControls).update()
});
return (
// @ts-ignore
<orbitControls
ref={controls}
args={[camera, domElement]}
enableZoom={false}
maxAzimuthAngle={Math.PI / 4}
maxPolarAngle={Math.PI / 4}
minAzimuthAngle={-Math.PI / 4}
minPolarAngle={0}
rotationSpeed={0.01}
/>
);
};
const R3fScene = (props: { mainHandCards: number[], gameModel, outCards: number[][] }) => {
const {
scene,
camera,
} = useThree();
let mainHandCards = props.mainHandCards;
let _gameModel = props.gameModel;
_gameModel.context = scene;
function getFaceTextureUrl(serial): string {
let _prefix = "/faces/"
let _readableName = _gameModel.getCardReadableName(serial);
if (_readableName === "rJkr") return _prefix + "Poker_Joker_B.png"
else if (_readableName === "bJkr") return _prefix + "Poker_Joker_R.png"
else {
let _suitDic = { 0: "D", 1: "C", 2: "H", 3: "S" };
let _suitNumber: number = serial >> 4;
return _prefix + "Poker_" + _suitDic[_suitNumber] + _readableName + ".png";
}
}
let _cardCount = mainHandCards.length;
let _beginX = -((_cardCount-1) * .5+1.5)/2 ;
return (
<>
<CameraControls />
<ambientLight intensity={0.1} />
<directionalLight color="white" position={[0, 0, 5]} />
<group name="handList">
{mainHandCards.map((serial: number, idx) => {
return (<Card key={"k" + serial} faceTextureUrl={getFaceTextureUrl(serial)} idx={idx} beginX={_beginX} serial={serial} />)
})}
</group>
<group position={[2, 4, 0]} scale={.6} name="out-list-1">
{props.outCards[1].map((serial: number, idx) => {
return (<Card key={"k" + serial} faceTextureUrl={getFaceTextureUrl(serial)} idx={idx} beginX={_beginX} serial={serial} />)
})}
</group>
<group position={[-2, 4, 0]} scale={.6} name="out-list-2">
{props.outCards[2].map((serial: number, idx) => {
return (<Card key={"k" + serial} faceTextureUrl={getFaceTextureUrl(serial)} idx={idx} beginX={_beginX} serial={serial} />)
})}
</group>
<group position={[0, 2, 0]} scale={.6} name="out-list-0">
{props.outCards[0].map((serial: number, idx) => {
return (<Card key={"k" + serial} faceTextureUrl={getFaceTextureUrl(serial)} idx={idx} beginX={_beginX} serial={serial} />)
})}
</group>
</>
)
}
function App(props: any) {
let _gameFacade = props.gameFacade;
let _gameModel: GameModel = _gameFacade.retrieveProxy("GameModel");
let [mainHandCards, setMainHandCards] = useState(_gameModel.cardsArr);
let [outCards, setOutCards] = useState(_gameModel.outCards);
_gameModel.setMainHandCardsHook(setMainHandCards);
_gameModel.setOutCardsHook(setOutCards);
useEffect(() => {
if (_gameFacade.retrieveMediator("GameSceneMediator") == null) {
_gameFacade.registerMediator(new GameSceneMediator(null, new GameSceneView()));
}
});
return (
<>
<div id='canvas-container'>
<Canvas orthographic camera={{ zoom: 50, position: [0, 0, 100], rotation: [0, 0, 0] }}>
<R3fScene mainHandCards={mainHandCards} outCards={outCards} gameModel={_gameModel}></R3fScene>
</Canvas>
</div>
<div id="status" style={{ display: 'fix', textAlign: 'center', fontSize: "2em", userSelect: "none" }}>hello</div>
<div id='controlPanel-scores' className='controlPanel'>
<button id='controlPanel-scores-1' className='controlButton'>1</button>
<button id='controlPanel-scores-2' className='controlButton'>2</button>
<button id='controlPanel-scores-3' className='controlButton'>3</button>
</div>
<div id='controlPanel-operation' className='controlPanel'>
<button id='controlPanel-operation-pass' className='controlButton'>pass</button>
<button id='controlPanel-operation-play' className='controlButton'>play</button>
</div>
</>
);
}
export default App;
框架适配改动
- 获取场景节点
getViewComponent(name: string,isDOM:boolean = true,canvasScene:any = null) {
if(isDOM) return document.getElementById(name);
else {
if(canvasScene) return canvasScene.getObjectByName(name);
}
}
通过传入three.js的场景对象,调用getObjectByName接口获取预先设置好name属性的节点
- 牌值获取card.userData(userData是R3F中自定义属性的装载对象,类似react中的data-yourAttribute, 在这里可以封装一个方法,让GameSceneMediator与牌取值解耦,使mediator的复用性更强)
private onOutCards_C2S() {
this.hideControlPanel();
let _outCardsSerial = [];
let _cardsContainer = this.mViewClass.getViewComponent("handList",false,this.getGameModel().context);
let _cards = _cardsContainer.children;
for (let i = 0; i < _cards.length; i++) {
let _card = _cards[i];
if (this.mViewClass.isCardSelected(_card)) {
_outCardsSerial.push(_card.userData["_d_cardSerial"]||_card["_d_cardSerial"] || _card.getAttribute("data-card-serial"));
}
}
this.getNetFacade()?.sendNotification(card_game_pb.Cmd.PLAYCARDS_C2S, _outCardsSerial);
}
- 节点坐标调整
由判断节点style 相应css属性的调整为节点position对象相应属性
isCardSelected(card) {
return card.position.y == .2 ? true : false;
}
查看本节相关代码 https://github.com/lizhiyu-me/Make-a-multiplayer-card-game/tree/episode7-r3f