Make a multiplayer card game - Episode 7 | Create 3D graphical interface with Three.js

Published on 02 April 2021
13 min read
Tutorial:Make a multiplayer card game
Make a multiplayer card game - Episode 7 | Create 3D graphical interface with Three.js

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, extend, useFrame, 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 to data-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, extend, useFrame, 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