我用GPT做了一个html版mc!
2026-09-27 11:26:06
发布于:广东
代码如下:
两百行左右可以调参数
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Three.js Minecraft 类体素游戏</title>
<style>
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #87ceeb;
font-family: Arial, sans-serif;
}
canvas {
display: block;
}
#startScreen {
position: fixed;
inset: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
color: white;
background: rgba(0, 0, 0, 0.62);
text-align: center;
cursor: pointer;
user-select: none;
}
#startScreen h1 {
margin: 0 0 18px;
font-size: 34px;
}
#startScreen p {
margin: 6px 0;
font-size: 16px;
line-height: 1.7;
}
#hud {
position: fixed;
top: 12px;
left: 12px;
z-index: 5;
color: white;
font-size: 14px;
line-height: 1.5;
text-shadow: 1px 1px 2px #000;
pointer-events: none;
user-select: none;
}
#fps {
position: fixed;
top: 12px;
right: 14px;
z-index: 5;
color: white;
font-size: 15px;
text-shadow: 1px 1px 2px #000;
pointer-events: none;
user-select: none;
}
#saveLoadPanel {
margin-top: 24px;
display: flex;
gap: 12px;
}
#saveBtn,
#loadBtn {
padding: 10px 22px;
border: 1px solid rgba(255,255,255,0.6);
border-radius: 6px;
background: rgba(255,255,255,0.12);
color: white;
font-size: 15px;
cursor: pointer;
transition: background 0.15s;
}
#saveBtn:hover,
#loadBtn:hover {
background: rgba(255,255,255,0.28);
}
#crosshair {
position: fixed;
z-index: 4;
left: 50%;
top: 50%;
width: 18px;
height: 18px;
transform: translate(-50%, -50%);
pointer-events: none;
}
#crosshair::before,
#crosshair::after {
content: "";
position: absolute;
background: white;
box-shadow: 0 0 2px black;
}
#crosshair::before {
width: 18px;
height: 2px;
left: 0;
top: 8px;
}
#crosshair::after {
width: 2px;
height: 18px;
left: 8px;
top: 0;
}
#hotbar {
position: fixed;
left: 50%;
bottom: 18px;
z-index: 5;
display: flex;
gap: 5px;
transform: translateX(-50%);
pointer-events: none;
user-select: none;
}
.slot {
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid rgba(255, 255, 255, 0.55);
background: rgba(0, 0, 0, 0.42);
color: white;
font-weight: bold;
font-size: 16px;
box-sizing: border-box;
text-shadow: 1px 1px 2px black;
}
.slot.selected {
border-color: white;
box-shadow: 0 0 0 2px #ffdc47;
}
</style>
</head>
<body>
<div id="startScreen">
<h1>Voxel World</h1>
<p>点击开始游戏</p>
<p>WASD 移动 空格跳跃 鼠标左键破坏 右键放置</p>
<p>数字 1~6 切换方块 ESC 释放鼠标</p>
<div id="saveLoadPanel">
<button id="saveBtn">保存世界</button>
<button id="loadBtn">读取世界</button>
</div>
</div>
<div id="hud">
<div>WASD:移动</div>
<div>空格:跳跃</div>
<div>左键:破坏方块</div>
<div>右键:放置方块</div>
<div>1~6:选择方块</div>
</div>
<div id="fps">FPS: 0</div>
<input type="file" id="loadFile" accept=".json" style="display:none;">
<div id="crosshair"></div>
<div id="hotbar">
<div class="slot selected" data-type="1">1 草</div>
<div class="slot" data-type="2">2 泥</div>
<div class="slot" data-type="3">3 石</div>
<div class="slot" data-type="4">4 木</div>
<div class="slot" data-type="5">5 沙</div>
<div class="slot" data-type="6">6 玻璃</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { PointerLockControls } from "three/addons/controls/PointerLockControls.js";
/*
* ============================================================
* 基础常量
* ============================================================
*/
// 世界总大小:100 表示 100×100 方块。调大会增加加载时间和内存
const WORLD_SIZE = 100;
// 世界边界(以原点为中心,范围 -50 ~ 49)
const WORLD_MIN = -Math.floor(WORLD_SIZE / 2);
const WORLD_MAX = WORLD_MIN + WORLD_SIZE - 1;
// 每个区块的边长(16×16)。别改,16 是体素引擎标准值
const CHUNK_SIZE = 16;
// 玩家周围加载几个区块的半径。调小=省性能但视野近,调大=看得远但卡
const LOAD_RADIUS = 3;
// 世界高度。调大地形更高,但每个区块内存和构建时间增加
const WORLD_HEIGHT = 32;
// 玩家碰撞箱宽(左右)
const PLAYER_WIDTH = 0.6;
// 玩家总身高
const PLAYER_HEIGHT = 1.8;
// 眼睛离脚底的高度(相机位置)
const PLAYER_EYE_HEIGHT = 1.62;
// 走路速度。调大跑得快
const WALK_SPEED = 5.2;
// 重力加速度。调大下落快,调小飘
const GRAVITY = 22;
// 跳跃初速度。调大跳得高
const JUMP_SPEED = 8.4;
// 挖/放方块的最大距离。调大能隔空操作
const REACH_DISTANCE = 6;
/*
* 方块类型:
* 0:空气
* 1:草
* 2:泥土
* 3:石头
* 4:木头
* 5:沙子
* 6:玻璃
*/
const BLOCK_AIR = 0;
const BLOCK_GRASS = 1;
const BLOCK_DIRT = 2;
const BLOCK_STONE = 3;
const BLOCK_WOOD = 4;
const BLOCK_SAND = 5;
const BLOCK_GLASS = 6;
const solidTypes = new Set([
BLOCK_GRASS,
BLOCK_DIRT,
BLOCK_STONE,
BLOCK_WOOD,
BLOCK_SAND,
BLOCK_GLASS
]);
/*
* 玻璃属于可碰撞方块,但生成面时不能简单把玻璃当成普通实心方块:
* 只有邻居也是同样的玻璃时才隐藏公共面。
* 因此玻璃与其他类型方块之间的面仍会生成。
*/
function isSolid(type) {
return solidTypes.has(type);
}
function isFaceOccluded(currentType, neighborType) {
if (neighborType === BLOCK_AIR) {
return false;
}
if (currentType === BLOCK_GLASS || neighborType === BLOCK_GLASS) {
return currentType === neighborType;
}
return true;
}
/*
* ============================================================
* Three.js 场景
* ============================================================
*/
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb);
scene.fog = new THREE.Fog(0x87ceeb, 55, 150);
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.05,
220
);
const renderer = new THREE.WebGLRenderer({
antialias: true,
powerPreference: "high-performance"
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = false;
document.body.appendChild(renderer.domElement);
const ambientLight = new THREE.HemisphereLight(
0xdff2ff,
0x665544,
1.8
);
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xffffff, 1.4);
sunLight.position.set(45, 90, 25);
scene.add(sunLight);
const controls = new PointerLockControls(camera, document.body);
scene.add(controls.object);
const startScreen = document.getElementById("startScreen");
startScreen.addEventListener("click", (event) => {
// 如果点的是按钮或按钮面板,不触发锁定
if (event.target.closest("#saveLoadPanel")) {
return;
}
controls.lock();
});
controls.addEventListener("lock", () => {
startScreen.style.display = "none";
});
controls.addEventListener("unlock", () => {
startScreen.style.display = "flex";
});
/*
* ============================================================
* 方块材质
* ============================================================
*/
const materials = [];
materials[BLOCK_GRASS] = new THREE.MeshLambertMaterial({
color: 0x55aa44,
side: THREE.FrontSide
});
materials[BLOCK_DIRT] = new THREE.MeshLambertMaterial({
color: 0x8a5a32,
side: THREE.FrontSide
});
materials[BLOCK_STONE] = new THREE.MeshLambertMaterial({
color: 0x858585,
side: THREE.FrontSide
});
materials[BLOCK_WOOD] = new THREE.MeshLambertMaterial({
color: 0x9b642e,
side: THREE.FrontSide
});
materials[BLOCK_SAND] = new THREE.MeshLambertMaterial({
color: 0xd8c17a,
side: THREE.FrontSide
});
materials[BLOCK_GLASS] = new THREE.MeshLambertMaterial({
color: 0x91d8e8,
transparent: true,
opacity: 0.48,
depthWrite: false,
side: THREE.DoubleSide
});
/*
* 几何体使用世界坐标。
* 每个 Chunk 只有一个 Mesh,但包含 6 个 material groups。
*/
const chunkMaterialList = [
materials[BLOCK_GRASS],
materials[BLOCK_DIRT],
materials[BLOCK_STONE],
materials[BLOCK_WOOD],
materials[BLOCK_SAND],
materials[BLOCK_GLASS]
];
/*
* ============================================================
* 世界数据
*
* 只保存已经生成或被修改的方块。
* key 使用 x,y,z 字符串。
* Chunk 数据使用 Uint8Array,尺寸为 16×32×16。
* ============================================================
*/
const worldBlocks = new Map();
const chunks = new Map();
function blockKey(x, y, z) {
return `${x},${y},${z}`;
}
function inWorldXZ(x, z) {
return (
x >= WORLD_MIN &&
x <= WORLD_MAX &&
z >= WORLD_MIN &&
z <= WORLD_MAX
);
}
function getWorldBlock(x, y, z) {
if (!inWorldXZ(x, z) || y < 0 || y >= WORLD_HEIGHT) {
return BLOCK_AIR;
}
return worldBlocks.get(blockKey(x, y, z)) || BLOCK_AIR;
}
function setWorldBlock(x, y, z, type) {
if (!inWorldXZ(x, z) || y < 0 || y >= WORLD_HEIGHT) {
return;
}
const key = blockKey(x, y, z);
if (type === BLOCK_AIR) {
worldBlocks.delete(key);
} else {
worldBlocks.set(key, type);
}
}
function chunkKey(cx, cz) {
return `${cx},${cz}`;
}
function floorDiv(value, divisor) {
return Math.floor(value / divisor);
}
function chunkCoordFromWorld(value) {
return floorDiv(value - WORLD_MIN, CHUNK_SIZE);
}
function chunkOriginX(cx) {
return WORLD_MIN + cx * CHUNK_SIZE;
}
function chunkOriginZ(cz) {
return WORLD_MIN + cz * CHUNK_SIZE;
}
function getChunkLocalIndex(lx, y, lz) {
return lx + CHUNK_SIZE * (lz + CHUNK_SIZE * y);
}
function getChunkArrayValue(chunk, lx, y, lz) {
if (
lx < 0 ||
lx >= CHUNK_SIZE ||
lz < 0 ||
lz >= CHUNK_SIZE ||
y < 0 ||
y >= WORLD_HEIGHT
) {
return BLOCK_AIR;
}
return chunk.data[getChunkLocalIndex(lx, y, lz)];
}
function setChunkArrayValue(chunk, lx, y, lz, type) {
if (
lx < 0 ||
lx >= CHUNK_SIZE ||
lz < 0 ||
lz >= CHUNK_SIZE ||
y < 0 ||
y >= WORLD_HEIGHT
) {
return;
}
chunk.data[getChunkLocalIndex(lx, y, lz)] = type;
}
/*
* ============================================================
* 确定性随机数与地形生成
* ============================================================
*/
function hash2D(x, z) {
let h = Math.imul(x, 374761393);
h = Math.imul(h ^ (h >>> 13), 1274126177);
h = Math.imul(h ^ (h >>> 16), 1103515245);
let k = Math.imul(z, 668265263);
k = Math.imul(k ^ (k >>> 15), 2246822519);
h ^= k;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function smoothNoise(x, z, scale) {
const fx = x / scale;
const fz = z / scale;
const x0 = Math.floor(fx);
const z0 = Math.floor(fz);
const x1 = x0 + 1;
const z1 = z0 + 1;
const tx = fx - x0;
const tz = fz - z0;
const sx = tx * tx * (3 - 2 * tx);
const sz = tz * tz * (3 - 2 * tz);
const a = hash2D(x0, z0);
const b = hash2D(x1, z0);
const c = hash2D(x0, z1);
const d = hash2D(x1, z1);
const top = a + (b - a) * sx;
const bottom = c + (d - c) * sx;
return top + (bottom - top) * sz;
}
function terrainHeight(x, z) {
const broad = smoothNoise(x, z, 42);
const detail = smoothNoise(x + 1000, z - 1000, 17);
const centerDistance = Math.sqrt(x * x + z * z);
const centerFactor = Math.max(0, 1 - centerDistance / 72);
let height = 5;
height += broad * 5.5;
height += detail * 2.8;
height += centerFactor * 7.0;
return Math.max(2, Math.min(WORLD_HEIGHT - 7, Math.floor(height)));
}
function isBeach(x, z, height) {
const noise = smoothNoise(x - 500, z + 500, 24);
return height <= 7 && noise > 0.37;
}
function createTree(x, y, z) {
const trunkHeight = 3 + Math.floor(hash2D(x + 77, z - 31) * 2);
for (let i = 0; i < trunkHeight; i++) {
if (y + i < WORLD_HEIGHT) {
setWorldBlock(x, y + i, z, BLOCK_WOOD);
}
}
const leafBase = y + trunkHeight - 1;
for (let dx = -2; dx <= 2; dx++) {
for (let dz = -2; dz <= 2; dz++) {
for (let dy = 0; dy <= 2; dy++) {
const distance = Math.abs(dx) + Math.abs(dz) + dy;
if (distance <= 4) {
const lx = x + dx;
const ly = leafBase + dy;
const lz = z + dz;
if (
inWorldXZ(lx, lz) &&
ly >= 0 &&
ly < WORLD_HEIGHT &&
getWorldBlock(lx, ly, lz) === BLOCK_AIR
) {
setWorldBlock(lx, ly, lz, BLOCK_GRASS);
}
}
}
}
}
}
function generateWorld() {
for (let x = WORLD_MIN; x <= WORLD_MAX; x++) {
for (let z = WORLD_MIN; z <= WORLD_MAX; z++) {
const height = terrainHeight(x, z);
const beach = isBeach(x, z, height);
for (let y = 0; y <= height; y++) {
let type;
if (y === 0 || y < height - 3) {
type = BLOCK_STONE;
} else if (beach) {
type = BLOCK_SAND;
} else if (y === height) {
type = BLOCK_GRASS;
} else {
type = BLOCK_DIRT;
}
setWorldBlock(x, y, z, type);
}
}
}
for (let x = WORLD_MIN + 3; x <= WORLD_MAX - 3; x++) {
for (let z = WORLD_MIN + 3; z <= WORLD_MAX - 3; z++) {
const height = terrainHeight(x, z);
const chance = hash2D(x * 3 + 17, z * 5 - 23);
if (
chance > 0.986 &&
height > 8 &&
getWorldBlock(x, height + 1, z) === BLOCK_AIR
) {
createTree(x, height + 1, z);
}
}
}
}
/*
* ============================================================
* Chunk Mesh 构建
*
* 一个 Chunk 一个 Mesh。
* 只生成暴露在空气中的面。
* Chunk 边界处直接查询世界数据,因此相邻 Chunk 的面也能正确剔除。
* ============================================================
*/
const faceDefinitions = [
{
direction: [1, 0, 0],
corners: [
[1, 0, 0],
[1, 1, 0],
[1, 1, 1],
[1, 0, 1]
],
normal: [1, 0, 0]
},
{
direction: [-1, 0, 0],
corners: [
[0, 0, 1],
[0, 1, 1],
[0, 1, 0],
[0, 0, 0]
],
normal: [-1, 0, 0]
},
{
direction: [0, 1, 0],
corners: [
[0, 1, 1],
[1, 1, 1],
[1, 1, 0],
[0, 1, 0]
],
normal: [0, 1, 0]
},
{
direction: [0, -1, 0],
corners: [
[0, 0, 0],
[1, 0, 0],
[1, 0, 1],
[0, 0, 1]
],
normal: [0, -1, 0]
},
{
direction: [0, 0, 1],
corners: [
[1, 0, 1],
[1, 1, 1],
[0, 1, 1],
[0, 0, 1]
],
normal: [0, 0, 1]
},
{
direction: [0, 0, -1],
corners: [
[0, 0, 0],
[0, 1, 0],
[1, 1, 0],
[1, 0, 0]
],
normal: [0, 0, -1]
}
];
function addFace(geometryData, edgePositions, x, y, z, face, blockType) {
const materialIndex = blockType - 1;
const vertices = geometryData.vertices[materialIndex];
const normals = geometryData.normals[materialIndex];
const indices = geometryData.indices[materialIndex];
const startIndex = vertices.length / 3;
for (const corner of face.corners) {
vertices.push(
x + corner[0],
y + corner[1],
z + corner[2]
);
normals.push(
face.normal[0],
face.normal[1],
face.normal[2]
);
}
indices.push(
startIndex + 0,
startIndex + 1,
startIndex + 2,
startIndex + 0,
startIndex + 2,
startIndex + 3
);
// 画这个面的 4 条边
const c = face.corners;
for (let i = 0; i < 4; i++) {
const a = c[i];
const b = c[(i + 1) % 4];
edgePositions.push(
x + a[0], y + a[1], z + a[2],
x + b[0], y + b[1], z + b[2]
);
}
}
function buildChunkMesh(chunk) {
const geometryData = {
vertices: [[], [], [], [], [], []],
normals: [[], [], [], [], [], []],
indices: [[], [], [], [], [], []]
};
const edgePositions = [];
const originX = chunk.originX;
const originZ = chunk.originZ;
for (let lx = 0; lx < CHUNK_SIZE; lx++) {
for (let lz = 0; lz < CHUNK_SIZE; lz++) {
for (let y = 0; y < WORLD_HEIGHT; y++) {
const type = getChunkArrayValue(chunk, lx, y, lz);
if (type === BLOCK_AIR) {
continue;
}
const worldX = originX + lx;
const worldZ = originZ + lz;
for (const face of faceDefinitions) {
const neighborX = worldX + face.direction[0];
const neighborY = y + face.direction[1];
const neighborZ = worldZ + face.direction[2];
const neighborType = getWorldBlock(
neighborX,
neighborY,
neighborZ
);
if (!isFaceOccluded(type, neighborType)) {
addFace(
geometryData,
edgePositions,
worldX,
y,
worldZ,
face,
type
);
}
}
}
}
}
const geometry = new THREE.BufferGeometry();
let hasGeometry = false;
for (let materialIndex = 0; materialIndex < 6; materialIndex++) {
const vertices = geometryData.vertices[materialIndex];
const normals = geometryData.normals[materialIndex];
const indices = geometryData.indices[materialIndex];
if (vertices.length === 0) {
continue;
}
hasGeometry = true;
const positionOffset = geometry.getAttribute("position")
? geometry.getAttribute("position").count
: 0;
const oldPositions = geometry.getAttribute("position");
const oldNormals = geometry.getAttribute("normal");
const oldIndices = geometry.index;
const oldPositionArray = oldPositions
? Array.from(oldPositions.array)
: [];
const oldNormalArray = oldNormals
? Array.from(oldNormals.array)
: [];
const oldIndexArray = oldIndices
? Array.from(oldIndices.array)
: [];
const combinedPositions = oldPositionArray.concat(vertices);
const combinedNormals = oldNormalArray.concat(normals);
const combinedIndices = oldIndexArray.concat(
indices.map(index => index + positionOffset)
);
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(combinedPositions, 3)
);
geometry.setAttribute(
"normal",
new THREE.Float32BufferAttribute(combinedNormals, 3)
);
geometry.setIndex(combinedIndices);
geometry.addGroup(
oldIndexArray.length,
indices.length,
materialIndex
);
}
if (!hasGeometry) {
geometry.dispose();
return null;
}
geometry.computeBoundingSphere();
const mesh = new THREE.Mesh(
geometry,
chunkMaterialList
);
mesh.name = `chunk_${chunk.cx}_${chunk.cz}`;
mesh.userData.chunk = chunk;
// 方块边线
let edgeMesh = null;
if (edgePositions.length > 0) {
const edgeGeo = new THREE.BufferGeometry();
edgeGeo.setAttribute(
"position",
new THREE.Float32BufferAttribute(edgePositions, 3)
);
const edgeMat = new THREE.LineBasicMaterial({
color: 0x000000,
transparent: true,
opacity: 0.1 //边线透明度
});
edgeMesh = new THREE.LineSegments(edgeGeo, edgeMat);
edgeMesh.name = `chunkEdges_${chunk.cx}_${chunk.cz}`;
// 让线和方块一起被卸载
mesh.add(edgeMesh);
}
return mesh;
}
function buildChunkData(cx, cz) {
const originX = chunkOriginX(cx);
const originZ = chunkOriginZ(cz);
const chunk = {
cx,
cz,
originX,
originZ,
data: new Uint8Array(
CHUNK_SIZE * WORLD_HEIGHT * CHUNK_SIZE
),
mesh: null
};
for (let lx = 0; lx < CHUNK_SIZE; lx++) {
for (let lz = 0; lz < CHUNK_SIZE; lz++) {
const worldX = originX + lx;
const worldZ = originZ + lz;
if (!inWorldXZ(worldX, worldZ)) {
continue;
}
for (let y = 0; y < WORLD_HEIGHT; y++) {
const type = getWorldBlock(worldX, y, worldZ);
setChunkArrayValue(
chunk,
lx,
y,
lz,
type
);
}
}
}
return chunk;
}
function loadChunk(cx, cz) {
const key = chunkKey(cx, cz);
if (chunks.has(key)) {
return chunks.get(key);
}
const chunk = buildChunkData(cx, cz);
chunk.mesh = buildChunkMesh(chunk);
if (chunk.mesh) {
scene.add(chunk.mesh);
}
chunks.set(key, chunk);
return chunk;
}
function unloadChunk(cx, cz) {
const key = chunkKey(cx, cz);
const chunk = chunks.get(key);
if (!chunk) {
return;
}
if (chunk.mesh) {
scene.remove(chunk.mesh);
if (chunk.mesh.geometry) {
chunk.mesh.geometry.dispose();
}
chunk.mesh = null;
}
chunk.data = null;
chunks.delete(key);
}
function rebuildChunk(cx, cz) {
const key = chunkKey(cx, cz);
const chunk = chunks.get(key);
if (!chunk) {
return;
}
if (chunk.mesh) {
scene.remove(chunk.mesh);
if (chunk.mesh.geometry) {
chunk.mesh.geometry.dispose();
}
}
const freshChunk = buildChunkData(cx, cz);
freshChunk.mesh = buildChunkMesh(freshChunk);
if (freshChunk.mesh) {
scene.add(freshChunk.mesh);
}
chunks.set(key, freshChunk);
}
function updateLoadedChunks(force = false) {
const playerPosition = controls.object.position;
const currentChunkX = chunkCoordFromWorld(playerPosition.x);
const currentChunkZ = chunkCoordFromWorld(playerPosition.z);
if (
!force &&
currentChunkX === loadedCenterChunkX &&
currentChunkZ === loadedCenterChunkZ
) {
return;
}
loadedCenterChunkX = currentChunkX;
loadedCenterChunkZ = currentChunkZ;
const desired = new Set();
for (
let cx = currentChunkX - LOAD_RADIUS;
cx <= currentChunkX + LOAD_RADIUS;
cx++
) {
for (
let cz = currentChunkZ - LOAD_RADIUS;
cz <= currentChunkZ + LOAD_RADIUS;
cz++
) {
const originX = chunkOriginX(cx);
const originZ = chunkOriginZ(cz);
const chunkMaxX = originX + CHUNK_SIZE - 1;
const chunkMaxZ = originZ + CHUNK_SIZE - 1;
if (
chunkMaxX < WORLD_MIN ||
originX > WORLD_MAX ||
chunkMaxZ < WORLD_MIN ||
originZ > WORLD_MAX
) {
continue;
}
desired.add(chunkKey(cx, cz));
loadChunk(cx, cz);
}
}
const keysToUnload = [];
for (const key of chunks.keys()) {
if (!desired.has(key)) {
keysToUnload.push(key);
}
}
for (const key of keysToUnload) {
const [cx, cz] = key.split(",").map(Number);
unloadChunk(cx, cz);
}
}
/*
* ============================================================
* 玩家输入与物理
* ============================================================
*/
const keys = {
forward: false,
backward: false,
left: false,
right: false
};
const velocity = new THREE.Vector3();
let canJump = false;
function onKeyDown(event) {
switch (event.code) {
case "KeyW":
case "ArrowUp":
keys.forward = true;
break;
case "KeyS":
case "ArrowDown":
keys.backward = true;
break;
case "KeyA":
case "ArrowLeft":
keys.left = true;
break;
case "KeyD":
case "ArrowRight":
keys.right = true;
break;
case "Space":
if (canJump) {
velocity.y = JUMP_SPEED;
canJump = false;
}
event.preventDefault();
break;
case "Digit1":
selectBlock(BLOCK_GRASS);
break;
case "Digit2":
selectBlock(BLOCK_DIRT);
break;
case "Digit3":
selectBlock(BLOCK_STONE);
break;
case "Digit4":
selectBlock(BLOCK_WOOD);
break;
case "Digit5":
selectBlock(BLOCK_SAND);
break;
case "Digit6":
selectBlock(BLOCK_GLASS);
break;
}
}
function onKeyUp(event) {
switch (event.code) {
case "KeyW":
case "ArrowUp":
keys.forward = false;
break;
case "KeyS":
case "ArrowDown":
keys.backward = false;
break;
case "KeyA":
case "ArrowLeft":
keys.left = false;
break;
case "KeyD":
case "ArrowRight":
keys.right = false;
break;
}
}
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
function playerAABB(position) {
return {
minX: position.x - PLAYER_WIDTH / 2,
maxX: position.x + PLAYER_WIDTH / 2,
minY: position.y - PLAYER_EYE_HEIGHT,
maxY: position.y + (PLAYER_HEIGHT - PLAYER_EYE_HEIGHT),
minZ: position.z - PLAYER_WIDTH / 2,
maxZ: position.z + PLAYER_WIDTH / 2
};
}
function boxIntersectsPlayer(x, y, z, position) {
const boxMinX = x;
const boxMaxX = x + 1;
const boxMinY = y;
const boxMaxY = y + 1;
const boxMinZ = z;
const boxMaxZ = z + 1;
const player = playerAABB(position);
return (
player.maxX > boxMinX &&
player.minX < boxMaxX &&
player.maxY > boxMinY &&
player.minY < boxMaxY &&
player.maxZ > boxMinZ &&
player.minZ < boxMaxZ
);
}
function collidesAt(position) {
const box = playerAABB(position);
const minX = Math.floor(box.minX);
const maxX = Math.floor(box.maxX - 0.000001);
const minY = Math.floor(box.minY);
const maxY = Math.floor(box.maxY - 0.000001);
const minZ = Math.floor(box.minZ);
const maxZ = Math.floor(box.maxZ - 0.000001);
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) {
for (let z = minZ; z <= maxZ; z++) {
if (isSolid(getWorldBlock(x, y, z))) {
return true;
}
}
}
}
return false;
}
function movePlayerAxis(axis, amount) {
if (amount === 0) {
return;
}
const position = controls.object.position;
const oldValue = position[axis];
position[axis] += amount;
if (!collidesAt(position)) {
return;
}
position[axis] = oldValue;
const direction = Math.sign(amount);
const step = 0.02;
let moved = 0;
while (Math.abs(moved) < Math.abs(amount)) {
const remaining = Math.abs(amount) - Math.abs(moved);
const delta = Math.min(step, remaining) * direction;
position[axis] += delta;
if (collidesAt(position)) {
position[axis] -= delta;
break;
}
moved += delta;
}
if (axis === "y") {
if (amount < 0) {
canJump = true;
}
velocity.y = 0;
}
}
function updatePhysics(deltaTime) {
if (!controls.isLocked) {
return;
}
const moveX =
(keys.right ? 1 : 0) -
(keys.left ? 1 : 0);
const moveZ =
(keys.forward ? 1 : 0) -
(keys.backward ? 1 : 0);
if (moveX !== 0 || moveZ !== 0) {
// 取相机的前方向和右方向,压平到水平面
const forward = new THREE.Vector3();
camera.getWorldDirection(forward);
forward.y = 0;
forward.normalize();
const right = new THREE.Vector3();
right.crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
// 前 + 右,合成本次移动方向
const move = new THREE.Vector3();
move.addScaledVector(forward, moveZ);
move.addScaledVector(right, moveX);
move.normalize();
move.multiplyScalar(WALK_SPEED * deltaTime);
movePlayerAxis("x", move.x);
movePlayerAxis("z", move.z);
}
velocity.y -= GRAVITY * deltaTime;
movePlayerAxis("y", velocity.y * deltaTime);
const minimumPlayerY = 2.5;
if (controls.object.position.y < minimumPlayerY) {
controls.object.position.y = minimumPlayerY;
velocity.y = 0;
canJump = true;
}
updateLoadedChunks(false);
}
/*
* ============================================================
* 精确体素 DDA 射线拾取
*
* 不使用步进采样。
* 采用 Amanatides-Woo 风格的 3D DDA:
* 每次直接跳到下一个体素边界。
* ============================================================
*/
const rayOrigin = new THREE.Vector3();
const rayDirection = new THREE.Vector3();
function raycastVoxel(origin, direction, maxDistance) {
let x = Math.floor(origin.x);
let y = Math.floor(origin.y);
let z = Math.floor(origin.z);
const stepX = direction.x > 0 ? 1 : -1;
const stepY = direction.y > 0 ? 1 : -1;
const stepZ = direction.z > 0 ? 1 : -1;
const nextBoundaryX =
direction.x > 0 ? x + 1 : x;
const nextBoundaryY =
direction.y > 0 ? y + 1 : y;
const nextBoundaryZ =
direction.z > 0 ? z + 1 : z;
let tMaxX =
direction.x === 0
? Infinity
: (nextBoundaryX - origin.x) / direction.x;
let tMaxY =
direction.y === 0
? Infinity
: (nextBoundaryY - origin.y) / direction.y;
let tMaxZ =
direction.z === 0
? Infinity
: (nextBoundaryZ - origin.z) / direction.z;
const tDeltaX =
direction.x === 0
? Infinity
: Math.abs(1 / direction.x);
const tDeltaY =
direction.y === 0
? Infinity
: Math.abs(1 / direction.y);
const tDeltaZ =
direction.z === 0
? Infinity
: Math.abs(1 / direction.z);
let previousX = x;
let previousY = y;
let previousZ = z;
let distance = 0;
for (let iteration = 0; iteration < 256; iteration++) {
if (
inWorldXZ(x, z) &&
y >= 0 &&
y < WORLD_HEIGHT &&
isSolid(getWorldBlock(x, y, z))
) {
return {
x,
y,
z,
previousX,
previousY,
previousZ,
distance
};
}
previousX = x;
previousY = y;
previousZ = z;
if (tMaxX < tMaxY && tMaxX < tMaxZ) {
x += stepX;
distance = tMaxX;
tMaxX += tDeltaX;
} else if (tMaxY < tMaxZ) {
y += stepY;
distance = tMaxY;
tMaxY += tDeltaY;
} else {
z += stepZ;
distance = tMaxZ;
tMaxZ += tDeltaZ;
}
if (distance > maxDistance) {
break;
}
}
return null;
}
function getLookDirection() {
controls.getDirection(rayDirection);
return rayDirection;
}
function getVoxelHit() {
camera.getWorldPosition(rayOrigin);
getLookDirection();
return raycastVoxel(
rayOrigin,
rayDirection,
REACH_DISTANCE
);
}
/*
* ============================================================
* 方块选择、破坏与放置
* ============================================================
*/
let selectedBlock = BLOCK_GRASS;
function selectBlock(type) {
selectedBlock = type;
document.querySelectorAll(".slot").forEach(slot => {
slot.classList.toggle(
"selected",
Number(slot.dataset.type) === type
);
});
}
function refreshChunkAndNeighbors(x, z) {
const cx = chunkCoordFromWorld(x);
const cz = chunkCoordFromWorld(z);
const localX = x - chunkOriginX(cx);
const localZ = z - chunkOriginZ(cz);
const targets = new Set();
targets.add(chunkKey(cx, cz));
if (localX === 0) {
targets.add(chunkKey(cx - 1, cz));
}
if (localX === CHUNK_SIZE - 1) {
targets.add(chunkKey(cx + 1, cz));
}
if (localZ === 0) {
targets.add(chunkKey(cx, cz - 1));
}
if (localZ === CHUNK_SIZE - 1) {
targets.add(chunkKey(cx, cz + 1));
}
for (const key of targets) {
const [targetCX, targetCZ] = key.split(",").map(Number);
if (chunks.has(key)) {
rebuildChunk(targetCX, targetCZ);
}
}
}
function breakBlock() {
if (!controls.isLocked) {
return;
}
const hit = getVoxelHit();
if (!hit) {
return;
}
setWorldBlock(hit.x, hit.y, hit.z, BLOCK_AIR);
refreshChunkAndNeighbors(hit.x, hit.z);
}
function placeBlock() {
if (!controls.isLocked) {
return;
}
const hit = getVoxelHit();
if (!hit) {
return;
}
const x = hit.previousX;
const y = hit.previousY;
const z = hit.previousZ;
if (
!inWorldXZ(x, z) ||
y < 0 ||
y >= WORLD_HEIGHT
) {
return;
}
if (getWorldBlock(x, y, z) !== BLOCK_AIR) {
return;
}
if (boxIntersectsPlayer(x, y, z, controls.object.position)) {
return;
}
setWorldBlock(x, y, z, selectedBlock);
refreshChunkAndNeighbors(x, z);
}
document.addEventListener("mousedown", event => {
if (!controls.isLocked) {
return;
}
if (event.button === 0) {
breakBlock();
} else if (event.button === 2) {
placeBlock();
}
});
document.addEventListener("contextmenu", event => {
event.preventDefault();
});
/*
* ============================================================
* 射线高亮线框
* ============================================================
*/
const highlightGeometry = new THREE.BoxGeometry(1.006, 1.006, 1.006);
const highlightMaterial = new THREE.LineBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.95,
depthTest: false
});
const highlight = new THREE.LineSegments(
new THREE.EdgesGeometry(highlightGeometry),
highlightMaterial
);
highlight.visible = false;
highlight.renderOrder = 20;
scene.add(highlight);
function updateHighlight() {
if (!controls.isLocked) {
highlight.visible = false;
return;
}
const hit = getVoxelHit();
if (!hit) {
highlight.visible = false;
return;
}
highlight.visible = true;
highlight.position.set(
hit.x + 0.5,
hit.y + 0.5,
hit.z + 0.5
);
}
/*
* ============================================================
* FPS 与初始化
* ============================================================
*/
let loadedCenterChunkX = Infinity;
let loadedCenterChunkZ = Infinity;
let frameCounter = 0;
let fpsTime = performance.now();
function updateFPS(now) {
frameCounter++;
if (now - fpsTime >= 500) {
const fps = frameCounter * 1000 / (now - fpsTime);
document.getElementById("fps").textContent =
`FPS: ${fps.toFixed(0)} | Chunk: ${chunks.size}`;
frameCounter = 0;
fpsTime = now;
}
}
function findSpawnPoint() {
const spawnX = 0;
const spawnZ = 0;
// 从世界顶部往下找,第一个实心方块的上面
for (let y = WORLD_HEIGHT - 1; y >= 0; y--) {
if (isSolid(getWorldBlock(spawnX, y, spawnZ))) {
return new THREE.Vector3(
spawnX + 0.5,
y + 1 + PLAYER_EYE_HEIGHT,
spawnZ + 0.5
);
}
}
// 整列都没方块,退回原点上方
return new THREE.Vector3(
spawnX + 0.5,
10,
spawnZ + 0.5
);
}
generateWorld();
const spawnPosition = findSpawnPoint();
controls.object.position.copy(spawnPosition);
updateLoadedChunks(true);
/*
* ============================================================
* 主循环
* ============================================================
*/
// ============ 保存 / 读取世界 ============
document.getElementById("saveBtn").addEventListener("click", () => {
const arr = [];
for (const [key, type] of worldBlocks.entries()) {
arr.push([key, type]);
}
const blob = new Blob([JSON.stringify(arr)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "world.json";
a.click();
URL.revokeObjectURL(url);
});
document.getElementById("loadBtn").addEventListener("click", () => {
document.getElementById("loadFile").click();
});
document.getElementById("loadFile").addEventListener("change", (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const arr = JSON.parse(reader.result);
worldBlocks.clear();
for (const entry of arr) {
worldBlocks.set(entry[0], entry[1]);
}
// 把已加载的 chunk 全部卸载
for (const key of Array.from(chunks.keys())) {
const [cx, cz] = key.split(",").map(Number);
unloadChunk(cx, cz);
}
// 强制下次一定重建
loadedCenterChunkX = Infinity;
loadedCenterChunkZ = Infinity;
// 重新加载(会从 worldBlocks 重新读数据)
updateLoadedChunks(true);
// 重新找落脚点
const newSpawn = findSpawnPoint();
controls.object.position.copy(newSpawn);
velocity.set(0, 0, 0);
canJump = false;
} catch (error) {
console.error("读取失败", error);
}
};
reader.readAsText(file);
// 允许再次选择同一个文件
event.target.value = "";
});
// ==========================================
const clock = new THREE.Clock();
function animate(now) {
requestAnimationFrame(animate);
const deltaTime = Math.min(clock.getDelta(), 0.05);
updatePhysics(deltaTime);
updateHighlight();
updateFPS(now);
renderer.render(scene, camera);
}
requestAnimationFrame(animate);
/*
* ============================================================
* 窗口大小变化
* ============================================================
*/
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(
window.innerWidth,
window.innerHeight
);
});
</script>
</body>
</html>
虽然画质......
全部评论 4
用什么写的?
1周前 来自 北京
0GPT5.6啊,怎么了
1周前 来自 广东
0duck.ai
1周前 来自 广东
0
d
1周前 来自 广东
0dddd
1周前 来自 广东
0dddd
1周前 来自 广东
0




















有帮助,赞一个