前言
大家好,这里是 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, }); } }
|
在线游玩地址
猛戳这里