preface

Hello, this is CSS magic – Alphardex.

Before has such a game on the appstore, call stack (Chinese translation for “reactor”), the rules of the game is this: will keep having appeared in vertical direction and move back and forth, click on the screen can fold the square, and your goal is to try to keep them, don’t overlap will be cut off, the higher the score the more. The gameplay is simple but extremely addictive.

It just so happens that I’ve been studying Three. js, a WEBGL-based 3D framework, and wondered if I could use three.js to do just that, in order to get a feel for how 3d games work.

The final effect is shown below

Technology stack

  • Three. Js: The main character of this article
  • Gsap: The tool man who makes the values move
  • Kyouka: My TS tool library, named after princess Connected glacier Mirror (XCW)
  • Aqua.css: my CSS framework, named after Akuya, the goddess of wisdom

Parsing rules

  • Each level creates a block and moves it back and forth on either the X or Z axes, increasing the height and speed of the block
  • Click on the overlapped judgment, will not overlap part cut, the overlapped part fixed in the original position, not overlapped completely the end of the game
  • The color of the blocks changes regularly as the number of levels increases

Spread, Haji road oil!

Basic scenario

First, create the simplest scenario, also known as hello World in Three.js

interfaceCube { width? :number; height? :number; depth? :number; x? :number; y? :number; z? :number; color? :string | Color;
}

const calcAspect = (el: HTMLElement) = > el.clientWidth / el.clientHeight;

class Base {
  debug: boolean;
  container: HTMLElement | null; scene! : Scene; camera! : PerspectiveCamera | OrthographicCamera; renderer! : WebGLRenderer; box! : Mesh; light! : PointLight | DirectionalLight;constructor(sel: string, debug = false) {
    this.debug = debug;
    this.container = document.querySelector(sel);
  }
  / / initialization
  init() {
    this.createScene();
    this.createCamera();
    this.createRenderer();
    const box = this.createBox({});
    this.box = box;
    this.createLight();
    this.addListeners();
    this.setLoop();
  }
  // Create a scene
  createScene() {
    const scene = new Scene();
    if (this.debug) {
      scene.add(new AxesHelper());
    }
    this.scene = scene;
  }
  // Create perspective camera
  createCamera() {
    const aspect = calcAspect(this.container!) ;const camera = new PerspectiveCamera(75, aspect, 0.1.100);
    camera.position.set(0.1.10);
    this.camera = camera;
  }
  // Create render
  createRenderer() {
    const renderer = new WebGLRenderer({
      alpha: true.antialias: true}); renderer.setSize(this.container! .clientWidth,this.container! .clientHeight);this.container? .appendChild(renderer.domElement);this.renderer = renderer;
    this.renderer.setClearColor(0x000000.0);
  }
  // Create a block
  createBox(cube: Cube) {
    const { width = 1, height = 1, depth = 1, color = new Color("#d9dfc8"), x = 0, y = 0, z = 0 } = cube;
    const geo = new BoxBufferGeometry(width, height, depth);
    const material = new MeshToonMaterial({ color, flatShading: true });
    const box = new Mesh(geo, material);
    box.position.x = x;
    box.position.y = y;
    box.position.z = z;
    this.scene.add(box);
    return box;
  }
  // Create light source
  createLight() {
    const light = new DirectionalLight(new Color("#ffffff"), 0.5);
    light.position.set(0.50.0);
    this.scene.add(light);
    const ambientLight = new AmbientLight(new Color("#ffffff"), 0.4);
    this.scene.add(ambientLight);
    this.light = light;
  }
  // Listen on events
  addListeners() {
    this.onResize();
  }
  // Monitor screen zoom
  onResize() {
    window.addEventListener("resize".(e) = > {
      const aspect = calcAspect(this.container!) ;const camera = this.camera as PerspectiveCamera;
      camera.aspect = aspect;
      camera.updateProjectionMatrix();
      this.renderer.setSize(this.container! .clientWidth,this.container! .clientHeight); }); }/ / animation
  update() {
    console.log("animation");
  }
  / / rendering
  setLoop() {
    this.renderer.setAnimationLoop(() = > {
      this.update();
      this.renderer.render(this.scene, this.camera); }); }}Copy the code

This scene contains the most basic elements of Three.js: scene, camera, render, object, light, event, animation. The renderings are as follows:

The game scene

Initialize the

First, set all the necessary parameters for the game.

The camera adopts orthogonal camera (the size of the object is always the same regardless of the distance)

A base is created with the height set to about half the height of the scene

class Stack extends Base {
  cameraParams: Record<string.any>; // Camera parameters
  cameraPosition: Vector3; // Camera position
  lookAtPosition: Vector3; / / point of view
  colorOffset: number; // Color offset
  boxParams: Record<string.any>; // Block property parameters
  level: number; / / levels
  moveLimit: number; // Move the upper limit
  moveAxis: "x" | "z"; // Move along the axis
  moveEdge: "width" | "depth"; // Moving edges
  currentY: number; // The current y height
  state: string; // state: paused; Running - movement
  speed: number; // Move speed
  speedInc: number; // Speed increment
  speedLimit: number; // Speed limit
  gamestart: boolean; // Start the game
  gameover: boolean; // Game over
  constructor(sel: string, debug: boolean) {
    super(sel, debug);
    this.cameraParams = {};
    this.updateCameraParams();
    this.cameraPosition = new Vector3(2.2.2);
    this.lookAtPosition = new Vector3(0.0.0);
    this.colorOffset = ky.randomIntegerInRange(0.255);
    this.boxParams = { width: 1.height: 0.2.depth: 1.x: 0.y: 0.z: 0.color: new Color("#d9dfc8")};this.level = 0;
    this.moveLimit = 1.2;
    this.moveAxis = "x";
    this.moveEdge = "width";
    this.currentY = 0;
    this.state = "paused";
    this.speed = 0.02;
    this.speedInc = 0.0005;
    this.speedLimit = 0.05;
    this.gamestart = false;
    this.gameover = false;
  }
  // Update camera parameters
  updateCameraParams() {
    const { container } = this;
    constaspect = calcAspect(container!) ;const zoom = 2;
    this.cameraParams = { left: -zoom * aspect, right: zoom * aspect, top: zoom, bottom: -zoom, near: -100.far: 1000 };
  }
  // Create an orthogonal camera
  createCamera() {
    const { cameraParams, cameraPosition, lookAtPosition } = this;
    const { left, right, top, bottom, near, far } = cameraParams;
    const camera = new OrthographicCamera(left, right, top, bottom, near, far);
    camera.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z);
    camera.lookAt(lookAtPosition.x, lookAtPosition.y, lookAtPosition.z);
    this.camera = camera;
  }
  / / initialization
  init() {
    this.createScene();
    this.createCamera();
    this.createRenderer();
    this.updateColor(); // This line will be commented out in the next section
    constbaseParams = { ... this.boxParams };const baseHeight = 2.5;
    baseParams.height = baseHeight;
    baseParams.y -= (baseHeight - this.boxParams.height) / 2;
    const base = this.createBox(baseParams);
    this.box = base;
    this.createLight();
    this.addListeners();
    this.setLoop(); }}Copy the code

Change the color of the blocks regularly

The sine function is used to change the color of the square periodically

class Stack extends Base {...// Update the color
  updateColor() {
    const { level, colorOffset } = this;
    const colorValue = (level + colorOffset) * 0.25;
    const r = (Math.sin(colorValue) * 55 + 200) / 255;
    const g = (Math.sin(colorValue + 2) * 55 + 200) / 255;
    const b = (Math.sin(colorValue + 4) * 55 + 200) / 255;
    this.boxParams.color = newColor(r, g, b); }}Copy the code

Create and move squares

Every time we start a level, we do the following:

  • Determine whether the cube moves along the x or z axis
  • Increases block height and movement speed
  • Update block color
  • Create a cube
  • Determine the initial movement position of the square according to the movement axis
  • Update camera and camera height
  • Start moving the block and reverse the speed when you reach the maximum distance to create a back-and-forth movement effect
  • When the user clicks, the overlap is determined
class Stack extends Base {...// Start the game
  start() {
    this.gamestart = true;
    this.startNextLevel();
  }
  // Start the next level
  startNextLevel() {
    this.level += 1;
    // Determine the moving axis and moving edge: odd x; Even the z
    this.moveAxis = this.level % 2 ? "x" : "z";
    this.moveEdge = this.level % 2 ? "width" : "depth";
    // Increases the height of blocks generated
    this.currentY += this.boxParams.height;
    // Increases block speed
    if (this.speed <= this.speedLimit) {
      this.speed += this.speedInc;
    }
    this.updateColor();
    constboxParams = { ... this.boxParams }; boxParams.y =this.currentY;
    const box = this.createBox(boxParams);
    this.box = box;
    // Determine the initial move position
    this.box.position[this.moveAxis] = this.moveLimit * -1;
    this.state = "running";
    if (this.level > 1) {
      this.updateCameraHeight(); }}// Update the camera height
  updateCameraHeight() {
    this.cameraPosition.y += this.boxParams.height;
    this.lookAtPosition.y += this.boxParams.height;
    gsap.to(this.camera.position, {
      y: this.cameraPosition.y,
      duration: 0.4}); gsap.to(this.camera.lookAt, {
      y: this.lookAtPosition.y,
      duration: 0.4}); }/ / animation
  update() {
    if (this.state === "running") {
      const { moveAxis } = this;
      this.box.position[moveAxis] += this.speed;
      // Reverse direction when moving to the end
      if (Math.abs(this.box.position[moveAxis]) > this.moveLimit) {
        this.speed = this.speed * -1; }}}// Event listener
  addListeners() {
    if (this.debug) {
      this.onKeyDown();  // This line will be commented out in the next section
    } else {
      this.onClick(); }}// Listen for clicks
  onClick() {
    this.renderer.domElement.addEventListener("click".() = > {
      if (this.level === 0) {
        this.start();
      } else {
        this.detectOverlap(); // This line will be commented out first, and then used in the last section}}); }}Copy the code

Debug mode

Since some numerical calculations are critical to the game, we need a debug mode, in which we can pause the movement of the blocks using the keyboard and dynamically change the position of the blocks, and use the three.js extension to debug each value

class Stack extends Base {...// Listen keyboard (debug: space next level; P key pause; Up and down keys to control movement)
  onKeyDown() {
    document.addEventListener("keydown".(e) = > {
      const code = e.code;
      if (code === "KeyP") {
        this.state = this.state === "running" ? "paused" : "running";
      } else if (code === "Space") {
        if (this.level === 0) {
          this.start();
        } else {
          this.detectOverlap(); // This line will be commented out first, and then used in the last section}}else if (code === "ArrowUp") {
        this.box.position[this.moveAxis] += this.speed / 2;
      } else if (code === "ArrowDown") {
        this.box.position[this.moveAxis] -= this.speed / 2; }}); }}Copy the code

Detecting overlap

The most difficult part of the game came, the author adjusted for a long time to succeed, a word: patience is victory.

How does the effect of cutting off the cube work? This is a trick: the square itself is not “cut”, but two squares are created in the same place: one is the overlapping square, the other is the non-overlapping square, the “cut” square.

Although we now know that we need to create these two squares, it is not easy to determine the parameters of the two squares. It is recommended to draw the position of the squares with a draft paper, and then calculate the values (remember my poor math skills). If it is really difficult to calculate, just CV the author’s formula

After the calculation, everything suddenly became clear, the two blocks will be created, and gSAP will not overlap that block down, the game is officially completed

class Stack extends Base {...// Check for overlaps
  // Difficulties: 1. Calculation of overlapping distance 2. Calculation of overlapping square position 3. Cut off the square position calculation
  async detectOverlap() {
    const that = this;
    const { boxParams, moveEdge, box, moveAxis, currentY, camera } = this;
    const currentPosition = box.position[moveAxis];
    const prevPosition = boxParams[moveAxis];
    const direction = Math.sign(currentPosition - prevPosition);
    constedge = boxParams! [moveEdge];// Overlap distance = side length of previous square + direction * (previous square position - current square position)
    const overlap = edge + direction * (prevPosition - currentPosition);
    if (overlap <= 0) {
      this.state = "paused";
      this.dropBox(box);
      gsap.to(camera, {
        zoom: 0.6.duration: 1.ease: "Power1.easeOut".onUpdate() {
          camera.updateProjectionMatrix();
        },
        onComplete() {
          const score = that.level - 1;
          const prevHighScore = Number(localStorage.getItem('high-score')) || 0;
          if (score > prevHighScore) {
            localStorage.setItem('high-score'.`${score}`)
          }
          that.gameover = true; }}); }else {
      // Create blocks that overlap
      constoverlapBoxParams = { ... boxParams };const overlapBoxPosition = currentPosition / 2 + prevPosition / 2;
      overlapBoxParams.y = currentY;
      overlapBoxParams[moveEdge] = overlap;
      overlapBoxParams[moveAxis] = overlapBoxPosition;
      this.createBox(overlapBoxParams);
      // Create a block with the part cut off
      constslicedBoxParams = { ... boxParams };const slicedBoxEdge = edge - overlap;
      const slicedBoxPosition = direction * ((edge - overlap) / 2 + edge / 2 + direction * prevPosition);
      slicedBoxParams.y = currentY;
      slicedBoxParams[moveEdge] = slicedBoxEdge;
      slicedBoxParams[moveAxis] = slicedBoxPosition;
      const slicedBox = this.createBox(slicedBoxParams);
      this.dropBox(slicedBox);
      this.boxParams = overlapBoxParams;
      this.scene.remove(box);
      this.startNextLevel(); }}// Make the cube rotate and fall
  dropBox(box: Mesh) {
    const { moveAxis } = this;
    const that = this;
    gsap.to(box.position, {
      y: "- = 3.2".ease: "power1.easeIn".duration: 1.5.onComplete(){ that.scene.remove(box); }}); gsap.to(box.rotation, {delay: 0.1.x: moveAxis === "z" ? ky.randomNumberInRange(4.5) : 0.1.y: 0.1.z: moveAxis === "x" ? ky.randomNumberInRange(4.5) : 0.1.duration: 1.5}); }}Copy the code

Online play address

Stab here