<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
body {
margin: 0;
}
canvas {
display: block;
}
</style>
<link rel="stylesheet" type="text/css" href="./style.css">
</head>
<body>
<script src="./main.js"></script>
<div id="instructions">
<div class="content">
<p>将方块一个个堆叠起来</p>
<p>当方块悬停在堆栈上方时,点击屏幕、轻触或按空格键放置。</p>
<p>挑战自己,看能否堆到蓝色方块!</p>
<p style="color: #4af">点击屏幕、轻触或按空格键「开始游戏」</p>
</div>
</div>
<div id="results">
<div class="content">
<p>方块放偏了</p>
<p>按R键重新开始游戏</p>
</div>
</div>
<div class="score_wrap">得分:<span id="score">0</span></div>
</body>
</html>
index.html
main.js
package.json
style.css
现在支持上传本地图片了!
// 作品来源 https://codepen.io/HunorMarton/pen/MWjBRWp
import * as THREE from 'three';
import CANNON from "cannon"
/*
Three.js + Cannon.js 视频教程解释源代码
在教程中,我们讲解了这个游戏的源代码。我们涵盖了如何设置带有盒子对象的 Three.js 场景,如何添加灯光,如何设置相机,如何添加动画和事件处理程序,最后我们使用 Cannon.js 添加了物理模拟。
与教程相比,这个版本有一些额外的功能:
- 游戏开始前的自动模式
- 介绍和结果屏幕
- 显示添加层数的分数指示器
- 你还可以通过触摸事件和按下空格键来控制游戏
- 你可以重置游戏
- 一旦方块超出堆叠范围,游戏停止
- 游戏失败后,最后一个方块会掉落
- 游戏会响应窗口大小调整
*/
console.log(123)
let camera, scene, renderer; // ThreeJS 全局变量
let world; // CannonJs 世界
let lastTime; // 动画的最后时间戳
let stack; // 堆叠在一起的固定部分
let overhangs; // 掉落的悬空部分
const boxHeight = 1; // 每层的高度
const originalBoxSize = 3; // 方块的原始宽度和高度
let autopilot;
let gameEnded;
let robotPrecision; // 决定自动模式下游戏的精确度
const scoreElement = document.getElementById("score");
const instructionsElement = document.getElementById("instructions");
const resultsElement = document.getElementById("results");
init();
// 决定自动模式下游戏的精确度
function setRobotPrecision() {
robotPrecision = Math.random() * 1 - 0.5;
}
function init() {
autopilot = true;
gameEnded = false;
lastTime = 0;
stack = [];
overhangs = [];
setRobotPrecision();
// 初始化 CannonJS
world = new CANNON.World();
world.gravity.set(0, -10, 0); // 重力将物体向下拉
world.broadphase = new CANNON.NaiveBroadphase();
world.solver.iterations = 40;
// 初始化 ThreeJs
const aspect = window.innerWidth / window.innerHeight;
const width = 10;
const height = width / aspect;
camera = new THREE.OrthographicCamera(
width / -2, // 左
width / 2, // 右
height / 2, // 上
height / -2, // 下
0, // 近平面
100 // 远平面
);
/*
// 如果你想使用透视相机,取消注释这些行
camera = new THREE.PerspectiveCamera(
45, // 视野
aspect, // 宽高比
1, // 近平面
100 // 远平面
);
*/
camera.position.set(4, 4, 4);
camera.lookAt(0, 0, 0);
scene = new THREE.Scene();
// 基础层
addLayer(0, 0, originalBoxSize, originalBoxSize);
// 第一层
addLayer(-10, 0, originalBoxSize, originalBoxSize, "x");
// 设置灯光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
dirLight.position.set(10, 20, 0);
scene.add(dirLight);
// 设置渲染器
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setAnimationLoop(animation);
document.body.appendChild(renderer.domElement);
}
function startGame() {
autopilot = false;
gameEnded = false;
lastTime = 0;
stack = [];
overhangs = [];
if (instructionsElement) instructionsElement.style.display = "none";
if (resultsElement) resultsElement.style.display = "none";
if (scoreElement) scoreElement.innerText = 0;
if (world) {
// Remove every object from world
while (world.bodies.length > 0) {
world.remove(world.bodies[0]);
}
}
if (scene) {
// Remove every Mesh from the scene
while (scene.children.find((c) => c.type == "Mesh")) {
const mesh = scene.children.find((c) => c.type == "Mesh");
scene.remove(mesh);
}
// Foundation
addLayer(0, 0, originalBoxSize, originalBoxSize);
// First layer
addLayer(-10, 0, originalBoxSize, originalBoxSize, "x");
}
if (camera) {
// Reset camera positions
camera.position.set(4, 4, 4);
camera.lookAt(0, 0, 0);
}
}
function addLayer(x, z, width, depth, direction) {
const y = boxHeight * stack.length; // 将新方块添加到一个更高的层
const layer = generateBox(x, y, z, width, depth, false);
layer.direction = direction;
stack.push(layer);
}
function addOverhang(x, z, width, depth) {
const y = boxHeight * (stack.length - 1); // 将新方块添加到同一层
const overhang = generateBox(x, y, z, width, depth, true);
overhangs.push(overhang);
}
function generateBox(x, y, z, width, depth, falls) {
// ThreeJS
const geometry = new THREE.BoxGeometry(width, boxHeight, depth);
const color = new THREE.Color(`hsl(${30 + stack.length * 4}, 100%, 50%)`);
const material = new THREE.MeshLambertMaterial({ color });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(x, y, z);
scene.add(mesh);
// CannonJS
const shape = new CANNON.Box(
new CANNON.Vec3(width / 2, boxHeight / 2, depth / 2)
);
let mass = falls ? 5 : 0; // 如果它不应该掉落,则将质量设置为零以保持静止
mass *= width / originalBoxSize; // 按大小比例减少质量
mass *= depth / originalBoxSize; // 按大小比例减少质量
const body = new CANNON.Body({ mass, shape });
body.position.set(x, y, z);
world.addBody(body);
return {
threejs: mesh,
cannonjs: body,
width,
depth
};
}
function cutBox(topLayer, overlap, size, delta) {
const direction = topLayer.direction;
const newWidth = direction == "x" ? overlap : topLayer.width;
const newDepth = direction == "z" ? overlap : topLayer.depth;
// Update metadata
topLayer.width = newWidth;
topLayer.depth = newDepth;
// Update ThreeJS model
topLayer.threejs.scale[direction] = overlap / size;
topLayer.threejs.position[direction] -= delta / 2;
// Update CannonJS model
topLayer.cannonjs.position[direction] -= delta / 2;
// Replace shape to a smaller one (in CannonJS you can't simply just scale a shape)
const shape = new CANNON.Box(
new CANNON.Vec3(newWidth / 2, boxHeight / 2, newDepth / 2)
);
topLayer.cannonjs.shapes = [];
topLayer.cannonjs.addShape(shape);
}
window.addEventListener("mousedown", eventHandler);
window.addEventListener("touchstart", eventHandler);
window.addEventListener("keydown", function (event) {
if (event.key == " ") {
event.preventDefault();
eventHandler();
return;
}
if (event.key == "R" || event.key == "r") {
event.preventDefault();
startGame();
return;
}
});
function eventHandler() {
if (autopilot) startGame();
else splitBlockAndAddNextOneIfOverlaps();
}
function splitBlockAndAddNextOneIfOverlaps() {
if (gameEnded) return;
const topLayer = stack[stack.length - 1];
const previousLayer = stack[stack.length - 2];
const direction = topLayer.direction;
const size = direction == "x" ? topLayer.width : topLayer.depth;
const delta =
topLayer.threejs.position[direction] -
previousLayer.threejs.position[direction];
const overhangSize = Math.abs(delta);
const overlap = size - overhangSize;
if (overlap > 0) {
cutBox(topLayer, overlap, size, delta);
// Overhang
const overhangShift = (overlap / 2 + overhangSize / 2) * Math.sign(delta);
const overhangX =
direction == "x"
? topLayer.threejs.position.x + overhangShift
: topLayer.threejs.position.x;
const overhangZ =
direction == "z"
? topLayer.threejs.position.z + overhangShift
: topLayer.threejs.position.z;
const overhangWidth = direction == "x" ? overhangSize : topLayer.width;
const overhangDepth = direction == "z" ? overhangSize : topLayer.depth;
addOverhang(overhangX, overhangZ, overhangWidth, overhangDepth);
// Next layer
const nextX = direction == "x" ? topLayer.threejs.position.x : -10;
const nextZ = direction == "z" ? topLayer.threejs.position.z : -10;
const newWidth = topLayer.width; // New layer has the same size as the cut top layer
const newDepth = topLayer.depth; // New layer has the same size as the cut top layer
const nextDirection = direction == "x" ? "z" : "x";
if (scoreElement) scoreElement.innerText = stack.length - 1;
addLayer(nextX, nextZ, newWidth, newDepth, nextDirection);
} else {
missedTheSpot();
}
}
function missedTheSpot() {
const topLayer = stack[stack.length - 1];
// Turn to top layer into an overhang and let it fall down
addOverhang(
topLayer.threejs.position.x,
topLayer.threejs.position.z,
topLayer.width,
topLayer.depth
);
world.remove(topLayer.cannonjs);
scene.remove(topLayer.threejs);
gameEnded = true;
if (resultsElement && !autopilot) resultsElement.style.display = "flex";
}
function animation(time) {
if (lastTime) {
const timePassed = time - lastTime;
const speed = 0.008;
const topLayer = stack[stack.length - 1];
const previousLayer = stack[stack.length - 2];
// 如果游戏没有结束,并且不在自动模式下,或者处于自动模式但方块尚未到达机器人位置,则顶层方块应该移动
const boxShouldMove =
!gameEnded &&
(!autopilot ||
(autopilot &&
topLayer.threejs.position[topLayer.direction] <
previousLayer.threejs.position[topLayer.direction] +
robotPrecision));
if (boxShouldMove) {
// 保持 UI 上的位置和模型中的位置同步
topLayer.threejs.position[topLayer.direction] += speed * timePassed;
topLayer.cannonjs.position[topLayer.direction] += speed * timePassed;
// 如果方块超出堆叠范围,则显示失败屏幕
if (topLayer.threejs.position[topLayer.direction] > 10) {
missedTheSpot();
}
} else {
// 如果它不应该移动,那么是否因为自动模式到达了正确位置?如果是,则下一层即将到来
if (autopilot) {
splitBlockAndAddNextOneIfOverlaps();
setRobotPrecision();
}
}
// 4 是相机的初始高度
if (camera.position.y < boxHeight * (stack.length - 2) + 4) {
camera.position.y += speed * timePassed;
}
updatePhysics(timePassed);
renderer.render(scene, camera);
}
lastTime = time;
}
function updatePhysics(timePassed) {
world.step(timePassed / 1000); // Step the physics world
// Copy coordinates from Cannon.js to Three.js
overhangs.forEach((element) => {
element.threejs.position.copy(element.cannonjs.position);
element.threejs.quaternion.copy(element.cannonjs.quaternion);
});
}
window.addEventListener("resize", () => {
// Adjust camera
console.log("resize", window.innerWidth, window.innerHeight);
const aspect = window.innerWidth / window.innerHeight;
const width = 10;
const height = width / aspect;
camera.top = height / 2;
camera.bottom = height / -2;
// Reset renderer
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.render(scene, camera);
});
{
"dependencies": {
"three": "0.173.0",
"cannon": "0.6.2"
}
}
@import url("https://fonts.googleapis.com/css2?family=Montserrat:wght@900&display=swap");
body {
margin: 0;
color: white;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
cursor: pointer;
}
#instructions {
display: none;
}
#results,
body:hover #instructions {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
background-color: rgba(20, 20, 20, 0.75);
}
a:visited {
color: inherit;
}
#results {
display: none;
cursor: default;
}
#results .content,
#instructions .content {
max-width: 300px;
padding: 50px;
border-radius: 20px;
}
#results {}
.score_wrap {
position: absolute;
color: white;
font-size: 1.5em;
top: 30px;
right: 30px;
display: flex;
align-items: center;
}
#score {
font-size: 1.6em;
font-weight: bold;
}
预览