前言
大家好,这里是 CSS 魔法使——alphardex。
之前在 appstore 上有这样一个游戏,叫 stack(中文译名为“反应堆”),游戏规则是这样的:在竖直方向上会不停地有方块出现并来回移动,点击屏幕能叠方块,而你的目的是尽量使它们保持重合,不重合就会被削掉,叠得越多分数越高。玩法虽简单但极其令人上瘾。
碰巧笔者最近在学习 three.js——一个基于 webgl 的 3d 框架,于是乎就思索着能不能用 three.js 来实现这样的效果,以摸清那些 3D 游戏的套路。
最终的效果图如下

技术栈
规则解析
- 每一关创建一个方块,并使其在 x 轴或者 z 轴上来回移动,方块的高度和速度是递增的
 
- 点击时进行重叠的判定,将不重叠的部分削掉,重叠的部分固定在原先的位置,完全不重叠则游戏结束
 
- 方块的颜色随关卡数的增加进行有规律的变换
 
撒,哈吉马路油!
基础场景
首先先创建一个最简单的场景,也是 three.js 里的 hello world
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
   | interface Cube {   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);   }      init() {     this.createScene();     this.createCamera();     this.createRenderer();     const box = this.createBox({});     this.box = box;     this.createLight();     this.addListeners();     this.setLoop();   }      createScene() {     const scene = new Scene();     if (this.debug) {       scene.add(new AxesHelper());     }     this.scene = scene;   }      createCamera() {     const aspect = calcAspect(this.container!);     const camera = new PerspectiveCamera(75, aspect, 0.1, 100);     camera.position.set(0, 1, 10);     this.camera = camera;   }      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);   }      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;   }      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;   }      addListeners() {     this.onResize();   }      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       );     });   }      update() {     console.log("animation");   }      setLoop() {     this.renderer.setAnimationLoop(() => {       this.update();       this.renderer.render(this.scene, this.camera);     });   } }
  | 
 
这个场景把 three.js 最基本的要素都囊括在内了:场景、相机、渲染、物体、光源、事件、动画。效果图如下:

游戏场景
初始化
首先,将本游戏所必要的参数全部设定好。
相机采用了正交相机(无论物体远近,大小始终不变)
创建了一个底座,高度设定为大约场景高的二分之一
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85
   | class Stack extends Base {   cameraParams: Record<string, any>;    cameraPosition: Vector3;    lookAtPosition: Vector3;    colorOffset: number;    boxParams: Record<string, any>;    level: number;    moveLimit: number;    moveAxis: "x" | "z";    moveEdge: "width" | "depth";    currentY: number;    state: string;    speed: number;    speedInc: number;    speedLimit: number;    gamestart: boolean;    gameover: boolean;    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;   }      updateCameraParams() {     const { container } = this;     const aspect = calcAspect(container!);     const zoom = 2;     this.cameraParams = {       left: -zoom * aspect,       right: zoom * aspect,       top: zoom,       bottom: -zoom,       near: -100,       far: 1000,     };   }      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;   }      init() {     this.createScene();     this.createCamera();     this.createRenderer();     this.updateColor();      const baseParams = { ...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();   } }
  | 
 

有规律地改变方块颜色
这里采用了正弦函数来周期性地改变方块的颜色
1 2 3 4 5 6 7 8 9 10 11 12
   | class Stack extends Base {   ...      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 = new Color(r, g, b);   } }
  | 
 

创建并移动方块
每开始一个关卡,我们就要做以下的事情:
- 确定方块是在 x 轴还是 z 轴上移动
 
- 增加方块的高度和移动速度
 
- 更新方块颜色
 
- 创建方块
 
- 根据移动轴来确定方块的初始移动位置
 
- 更新相机和视角的高度
 
- 开始移动方块,当移动到最大距离时反转速度,形成来回移动的效果
 
- 用户点击时进行重叠判定
 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
   | class Stack extends Base {   ...      start() {     this.gamestart = true;     this.startNextLevel();   }      startNextLevel() {     this.level += 1;          this.moveAxis = this.level % 2 ? "x" : "z";     this.moveEdge = this.level % 2 ? "width" : "depth";          this.currentY += this.boxParams.height;          if (this.speed <= this.speedLimit) {       this.speed += this.speedInc;     }     this.updateColor();     const boxParams = { ...this.boxParams };     boxParams.y = this.currentY;     const box = this.createBox(boxParams);     this.box = box;          this.box.position[this.moveAxis] = this.moveLimit * -1;     this.state = "running";     if (this.level > 1) {       this.updateCameraHeight();     }   }      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,     });   }      update() {     if (this.state === "running") {       const { moveAxis } = this;       this.box.position[moveAxis] += this.speed;              if (Math.abs(this.box.position[moveAxis]) > this.moveLimit) {         this.speed = this.speed * -1;       }     }   }      addListeners() {     if (this.debug) {       this.onKeyDown();       } else {       this.onClick();     }   }      onClick() {     this.renderer.domElement.addEventListener("click", () => {       if (this.level === 0) {         this.start();       } else {         this.detectOverlap();        }     });   } }
  | 
 

调试模式
由于某些数值的计算对本游戏来说很是关键,因此我们要弄一个调试模式,在这个模式下,我们能通过键盘来暂停方块的运动,并动态改变方块的位置,配合three.js 扩展程序来调试各个数值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
   | class Stack extends Base {   ...      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();          }       } else if (code === "ArrowUp") {         this.box.position[this.moveAxis] += this.speed / 2;       } else if (code === "ArrowDown") {         this.box.position[this.moveAxis] -= this.speed / 2;       }     });   } }
  | 
 
检测重叠部分
本游戏最难的部分来了,笔者调了很久才成功,一句话:耐心就是胜利。
方块切下来的效果是怎么实现的呢?其实这是一个障眼法:方块本身并没有被“切开”,而是在同一个位置创建了 2 个方块:一个就是重叠的方块,另一个就是不重叠的方块,即被“切开”的那个方块。
尽管我们现在知道了要创建这两个方块,但确定它俩的参数可绝非易事,建议拿一个草稿纸将方块移动的位置画下来,再动手计算那几个数值(想起我可怜的数学水平),如果实在是算不来,就直接 CV 笔者的公式吧:)
计算完后,一切就豁然开朗了,将那两个方块创建出来,并用 gsap 将不重叠的那个方块落下,本游戏就算正式完成了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
   | class Stack extends Base {   ...         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);     const edge = boxParams![moveEdge];          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 {              const overlapBoxParams = { ...boxParams };       const overlapBoxPosition = currentPosition / 2 + prevPosition / 2;       overlapBoxParams.y = currentY;       overlapBoxParams[moveEdge] = overlap;       overlapBoxParams[moveAxis] = overlapBoxPosition;       this.createBox(overlapBoxParams);              const slicedBoxParams = { ...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();     }   }      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,     });   } }
  | 
 

在线游玩地址
猛戳这里