方块世界 · VoxelCraft
by RocketDolphin441394 lines43.2 KB🛠️ Three.js (3D graphics)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>方块世界 · VoxelCraft</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0e0c;
--grass: #6cbf3a;
--grass-dark: #4a8f2a;
--dirt: #8b6a3a;
--dirt-dark: #6b5028;
--wood: #6b4a2a;
--stone: #888;
--sand: #e6d8a0;
--leaves: #4a8f3a;
--water: #3a7ab8;
--accent: #ff8c42;
--accent-glow: rgba(255, 140, 66, 0.4);
--fg: #f5f1e8;
--muted: rgba(245, 241, 232, 0.65);
--panel: rgba(15, 18, 16, 0.85);
--border: rgba(255, 255, 255, 0.12);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%;
overflow: hidden;
background: var(--bg);
color: var(--fg);
font-family: 'VT323', monospace;
user-select: none;
-webkit-user-select: none;
}
canvas { display: block; }
#ui {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10;
}
.crosshair {
position: absolute;
top: 50%; left: 50%;
width: 22px; height: 22px;
transform: translate(-50%, -50%);
opacity: 0.85;
}
.crosshair::before, .crosshair::after {
content: '';
position: absolute;
background: #fff;
mix-blend-mode: difference;
}
.crosshair::before {
top: 50%; left: 0; right: 0; height: 2px;
transform: translateY(-50%);
}
.crosshair::after {
left: 50%; top: 0; bottom: 0; width: 2px;
transform: translateX(-50%);
}
.topbar {
position: absolute;
top: 16px; left: 16px;
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 16px;
background: var(--panel);
border: 2px solid var(--border);
border-radius: 6px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
font-size: 18px;
text-shadow: 1px 1px 0 #000;
min-width: 220px;
}
.topbar .row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.topbar .label {
color: var(--muted);
font-size: 13px;
text-transform: uppercase;
letter-spacing: 1px;
}
.topbar .value {
color: var(--accent);
font-weight: bold;
}
.hotbar {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 4px;
padding: 6px;
background: var(--panel);
border: 2px solid var(--border);
border-radius: 8px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
pointer-events: auto;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
.slot {
width: 56px; height: 56px;
background: rgba(0,0,0,0.6);
border: 2px solid rgba(255,255,255,0.08);
border-radius: 4px;
position: relative;
cursor: pointer;
transition: all 0.15s ease;
}
.slot:hover {
border-color: rgba(255,255,255,0.25);
background: rgba(255,255,255,0.05);
}
.slot.active {
border-color: var(--accent);
background: rgba(255, 140, 66, 0.18);
transform: translateY(-6px) scale(1.05);
box-shadow: 0 6px 16px var(--accent-glow);
}
.slot .icon {
width: 36px; height: 36px;
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
border-radius: 3px;
box-shadow:
inset 0 5px 0 rgba(255,255,255,0.2),
inset 0 -5px 0 rgba(0,0,0,0.3),
inset 5px 0 0 rgba(0,0,0,0.15),
inset -5px 0 0 rgba(0,0,0,0.15),
0 2px 4px rgba(0,0,0,0.4);
}
.slot .num {
position: absolute;
top: 3px; left: 5px;
font-size: 14px;
color: var(--muted);
font-family: 'VT323', monospace;
text-shadow: 1px 1px 0 #000;
}
.slot .name {
position: absolute;
bottom: -22px;
left: 50%;
transform: translateX(-50%);
font-size: 16px;
color: var(--fg);
white-space: nowrap;
opacity: 0;
transition: opacity 0.2s;
text-shadow: 1px 1px 0 #000;
pointer-events: none;
}
.slot.active .name { opacity: 1; }
.icon-grass { background: linear-gradient(180deg, #6cbf3a 0% 50%, #9b7a4a 50% 100%); }
.icon-dirt { background: #8b6a3a; }
.icon-stone { background: repeating-linear-gradient(45deg, #aaa 0 4px, #888 4px 8px); }
.icon-wood { background: linear-gradient(180deg, #c8a070 0% 25%, #6b4a2a 25% 75%, #c8a070 75% 100%); }
.icon-leaves { background: radial-gradient(circle at 30% 30%, #6cbf3a, #3a7a2a); }
.icon-sand { background: #e6d8a0; }
.icon-water { background: linear-gradient(180deg, #4a8fc8 0%, #2a5a98 100%); }
.controls-panel {
position: absolute;
bottom: 24px;
right: 16px;
background: var(--panel);
border: 2px solid var(--border);
border-radius: 6px;
padding: 12px 16px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
min-width: 200px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.controls-panel h3 {
font-family: 'Press Start 2P', monospace;
font-size: 10px;
color: var(--accent);
margin-bottom: 8px;
letter-spacing: 1px;
}
.controls-panel .row {
display: flex;
justify-content: space-between;
align-items: center;
margin: 3px 0;
font-size: 16px;
color: var(--muted);
}
.controls-panel .key {
color: var(--fg);
background: rgba(255,255,255,0.08);
padding: 1px 8px;
border-radius: 3px;
border: 1px solid var(--border);
font-size: 14px;
font-family: 'VT323', monospace;
}
#start-screen {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at center, rgba(40,50,30,0.7), rgba(0,0,0,0.95)),
repeating-linear-gradient(45deg, transparent, transparent 100px, rgba(255,255,255,0.02) 100px, rgba(255,255,255,0.02) 101px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 30;
pointer-events: auto;
transition: opacity 0.4s;
padding: 20px;
}
#start-screen.hidden {
opacity: 0;
pointer-events: none;
}
.title-block {
text-align: center;
margin-bottom: 32px;
}
#start-screen h1 {
font-family: 'Press Start 2P', monospace;
font-size: clamp(36px, 8vw, 64px);
color: var(--grass);
text-shadow:
4px 4px 0 var(--dirt-dark),
8px 8px 0 rgba(0,0,0,0.6);
letter-spacing: 4px;
animation: titleBob 3s ease-in-out infinite;
margin-bottom: 12px;
}
@keyframes titleBob {
0%, 100% { transform: translateY(0) rotate(-1deg); }
50% { transform: translateY(-8px) rotate(1deg); }
}
#start-screen .subtitle {
font-size: 22px;
color: var(--muted);
letter-spacing: 2px;
}
#start-btn {
font-family: 'Press Start 2P', monospace;
font-size: 18px;
padding: 18px 40px;
background: linear-gradient(180deg, var(--grass) 0%, var(--grass-dark) 100%);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
letter-spacing: 3px;
box-shadow:
0 6px 0 #2a5a1a,
0 8px 16px rgba(0,0,0,0.5),
inset 0 2px 0 rgba(255,255,255,0.3);
transition: all 0.15s;
text-shadow: 2px 2px 0 rgba(0,0,0,0.4);
}
#start-btn:hover {
transform: translateY(-2px);
box-shadow:
0 8px 0 #2a5a1a,
0 10px 20px rgba(0,0,0,0.6),
inset 0 2px 0 rgba(255,255,255,0.4);
}
#start-btn:active {
transform: translateY(4px);
box-shadow:
0 2px 0 #2a5a1a,
inset 0 2px 0 rgba(255,255,255,0.2);
}
.instructions {
margin-top: 32px;
padding: 20px 28px;
background: rgba(0,0,0,0.5);
border: 2px solid var(--border);
border-radius: 6px;
min-width: 360px;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.instructions h2 {
font-family: 'Press Start 2P', monospace;
font-size: 12px;
color: var(--accent);
margin-bottom: 14px;
letter-spacing: 2px;
text-align: center;
}
.instructions .row {
display: flex;
justify-content: space-between;
align-items: center;
margin: 6px 0;
font-size: 18px;
color: var(--muted);
}
.instructions .key {
color: var(--fg);
background: rgba(255,255,255,0.08);
padding: 2px 10px;
border-radius: 3px;
border: 1px solid var(--border);
font-size: 16px;
font-family: 'VT323', monospace;
}
#toast {
position: absolute;
top: 90px;
left: 50%;
transform: translateX(-50%) translateY(-20px);
background: var(--panel);
border: 2px solid var(--accent);
color: var(--fg);
padding: 8px 18px;
border-radius: 4px;
font-size: 18px;
opacity: 0;
transition: all 0.3s;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
text-shadow: 1px 1px 0 #000;
white-space: nowrap;
}
#toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
#loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.85);
z-index: 25;
}
#loading.hidden { display: none; }
.loading-text {
font-family: 'Press Start 2P', monospace;
font-size: 14px;
color: var(--accent);
margin-bottom: 16px;
letter-spacing: 2px;
}
.loading-bar {
width: 300px;
height: 12px;
background: rgba(0,0,0,0.6);
border: 2px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.loading-fill {
height: 100%;
background: linear-gradient(90deg, var(--grass), var(--accent));
animation: loadingAnim 1.5s ease-in-out infinite;
}
@keyframes loadingAnim {
0% { width: 0%; }
50% { width: 80%; }
100% { width: 100%; }
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="ui">
<div class="crosshair"></div>
<div class="topbar">
<div class="row"><span class="label">坐标</span><span class="value" id="pos">0, 0, 0</span></div>
<div class="row"><span class="label">FPS</span><span class="value" id="fps">0</span></div>
<div class="row"><span class="label">方块数</span><span class="value" id="block-count">0</span></div>
<div class="row"><span class="label">模式</span><span class="value" id="mode">行走</span></div>
</div>
<div class="hotbar" id="hotbar"></div>
<div class="controls-panel">
<h3>操作面板</h3>
<div class="row"><span>移动</span><span class="key">WASD</span></div>
<div class="row"><span>跳跃</span><span class="key">空格</span></div>
<div class="row"><span>飞行</span><span class="key">F</span></div>
<div class="row"><span>破坏</span><span class="key">左键</span></div>
<div class="row"><span>放置</span><span class="key">右键</span></div>
<div class="row"><span>切换</span><span class="key">1-7</span></div>
</div>
<div id="toast"></div>
<div id="loading">
<div class="loading-text">正在生成世界...</div>
<div class="loading-bar"><div class="loading-fill"></div></div>
</div>
<div id="start-screen" class="hidden">
<div class="title-block">
<h1>VOXELCRAFT</h1>
<p class="subtitle">方块世界 · 创造与生存</p>
</div>
<button id="start-btn">开始游戏</button>
<div class="instructions">
<h2>操作说明</h2>
<div class="row"><span>移动</span><span class="key">W A S D</span></div>
<div class="row"><span>跳跃 / 向上飞</span><span class="key">空格</span></div>
<div class="row"><span>向下飞</span><span class="key">Shift</span></div>
<div class="row"><span>切换飞行</span><span class="key">F</span></div>
<div class="row"><span>破坏方块</span><span class="key">鼠标左键</span></div>
<div class="row"><span>放置方块</span><span class="key">鼠标右键</span></div>
<div class="row"><span>选择方块</span><span class="key">1 - 7</span></div>
<div class="row"><span>滚轮切换</span><span class="key">滚轮</span></div>
<div class="row"><span>解锁鼠标</span><span class="key">ESC</span></div>
</div>
</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from 'three';
// === 常量配置 ===
const WORLD_SIZE = 128;
const WORLD_HEIGHT = 40;
const WATER_LEVEL = 12;
const GRAVITY = 28;
const JUMP_SPEED = 9;
const MOVE_SPEED = 5.5;
const FLY_SPEED = 14;
const FLY_UP_SPEED = 8;
const PLAYER_HEIGHT = 1.8;
const EYE_HEIGHT = 1.62;
const PLAYER_RADIUS = 0.3;
const REACH = 6;
const FOG_NEAR = 30;
const FOG_FAR = 110;
const BLOCKS = {
GRASS: 1, DIRT: 2, STONE: 3, WOOD: 4, LEAVES: 5, SAND: 6, WATER: 7,
};
const BLOCK_INFO = [
null,
{ name: '草方块', top: 0x6cbf3a, side: 0x9b7a4a, bottom: 0x6b5028, icon: 'grass' },
{ name: '泥土', top: 0x9b7a4a, side: 0x8b6a3a, bottom: 0x6b5028, icon: 'dirt' },
{ name: '石头', top: 0x9a9a9a, side: 0x808080, bottom: 0x6a6a6a, icon: 'stone' },
{ name: '木头', top: 0xc8a070, side: 0x6b4a2a, bottom: 0xc8a070, icon: 'wood' },
{ name: '树叶', top: 0x5ca043, side: 0x4a8f3a, bottom: 0x3a7a2a, icon: 'leaves' },
{ name: '沙子', top: 0xe8dca0, side: 0xd8c890, bottom: 0xb8a878, icon: 'sand' },
{ name: '水', top: 0x4a8fc8, side: 0x3a7ab8, bottom: 0x2a5a98, icon: 'water' },
];
// === 噪声函数(用于地形生成)===
function hash2D(x, z) {
let h = Math.sin(x * 127.1 + z * 311.7) * 43758.5453;
return h - Math.floor(h);
}
function smoothNoise(x, z) {
const ix = Math.floor(x), iz = Math.floor(z);
const fx = x - ix, fz = z - iz;
const a = hash2D(ix, iz);
const b = hash2D(ix + 1, iz);
const c = hash2D(ix, iz + 1);
const d = hash2D(ix + 1, iz + 1);
const ux = fx * fx * (3 - 2 * fx);
const uz = fz * fz * (3 - 2 * fz);
return a * (1 - ux) * (1 - uz) + b * ux * (1 - uz) + c * (1 - ux) * uz + d * ux * uz;
}
function fbm(x, z) {
let v = 0, amp = 1, freq = 0.022;
for (let i = 0; i < 4; i++) {
v += smoothNoise(x * freq, z * freq) * amp;
amp *= 0.5;
freq *= 2;
}
return v / 1.875;
}
// === 世界数据存储 ===
const worldData = new Map();
const keyStr = (x, y, z) => `${x},${y},${z}`;
const getBlock = (x, y, z) => worldData.get(keyStr(x, y, z)) || 0;
function setBlockData(x, y, z, type) {
if (type === 0) worldData.delete(keyStr(x, y, z));
else worldData.set(keyStr(x, y, z), type);
}
// === 程序化地形生成 ===
function generateWorld() {
// 高度图与基础地形
for (let x = 0; x < WORLD_SIZE; x++) {
for (let z = 0; z < WORLD_SIZE; z++) {
const n = fbm(x, z);
const n2 = fbm(x + 500, z + 500);
const h = Math.floor(n * 14 + 6 + n2 * 6); // 高度范围约 6-26
for (let y = 0; y <= h; y++) {
let type;
if (y === h) {
// 顶层:水位以下用沙子,以上用草
type = h <= WATER_LEVEL + 1 ? BLOCKS.SAND : BLOCKS.GRASS;
} else if (y > h - 3) {
type = BLOCKS.DIRT;
} else {
type = BLOCKS.STONE;
}
setBlockData(x, y, z, type);
}
// 填充水
for (let y = h + 1; y <= WATER_LEVEL; y++) {
setBlockData(x, y, z, BLOCKS.WATER);
}
}
}
// 随机种树
const treePositions = [];
let attempts = 0;
while (treePositions.length < 240 && attempts < 2000) {
attempts++;
const x = 3 + Math.floor(Math.random() * (WORLD_SIZE - 6));
const z = 3 + Math.floor(Math.random() * (WORLD_SIZE - 6));
for (let y = WORLD_HEIGHT - 1; y >= 0; y--) {
if (getBlock(x, y, z) === BLOCKS.GRASS) {
let tooClose = false;
for (const p of treePositions) {
if (Math.abs(p.x - x) < 4 && Math.abs(p.z - z) < 4) {
tooClose = true; break;
}
}
if (!tooClose) treePositions.push({ x, y, z });
break;
}
}
}
for (const p of treePositions) placeTree(p.x, p.y, p.z);
}
function placeTree(x, y, z) {
const h = 4 + Math.floor(Math.random() * 3);
// 树干
for (let i = 1; i <= h; i++) {
setBlockData(x, y + i, z, BLOCKS.WOOD);
}
// 树冠
const topY = y + h;
for (let dy = -2; dy <= 1; dy++) {
const r = dy === 1 ? 1 : 2;
for (let dx = -r; dx <= r; dx++) {
for (let dz = -r; dz <= r; dz++) {
if (dx === 0 && dz === 0 && dy < 1) continue;
if (r === 2 && Math.abs(dx) === 2 && Math.abs(dz) === 2 && Math.random() < 0.6) continue;
if (dy === 1 && Math.abs(dx) + Math.abs(dz) === 2 && Math.random() < 0.4) continue;
const tx = x + dx, ty = topY + dy, tz = z + dz;
if (getBlock(tx, ty, tz) === 0) {
setBlockData(tx, ty, tz, BLOCKS.LEAVES);
}
}
}
}
setBlockData(x, topY + 2, z, BLOCKS.LEAVES);
}
// === Three.js 场景 ===
const scene = new THREE.Scene();
const fogColor = new THREE.Color(0xc8b890);
scene.background = fogColor;
scene.fog = new THREE.Fog(fogColor, FOG_NEAR, FOG_FAR);
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 1000);
const canvas = document.getElementById('canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
renderer.setSize(innerWidth, innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
// 渐变天空盒
const skyGeo = new THREE.SphereGeometry(500, 32, 16);
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
uniforms: {
topColor: { value: new THREE.Color(0x6ea4c8) },
midColor: { value: new THREE.Color(0xc8d8e0) },
bottomColor: { value: new THREE.Color(0xe8c890) },
},
vertexShader: `
varying vec3 vWorldPosition;
void main() {
vec4 wp = modelMatrix * vec4(position, 1.0);
vWorldPosition = wp.xyz;
gl_Position = projectionMatrix * viewMatrix * wp;
}
`,
fragmentShader: `
uniform vec3 topColor;
uniform vec3 midColor;
uniform vec3 bottomColor;
varying vec3 vWorldPosition;
void main() {
float h = normalize(vWorldPosition).y;
vec3 c;
if (h > 0.0) {
c = mix(midColor, topColor, smoothstep(0.0, 0.4, h));
} else {
c = mix(midColor, bottomColor, smoothstep(0.0, -0.2, h));
}
gl_FragColor = vec4(c, 1.0);
}
`,
});
scene.add(new THREE.Mesh(skyGeo, skyMat));
// 光照系统
scene.add(new THREE.AmbientLight(0xc8d8e0, 0.45));
const sun = new THREE.DirectionalLight(0xfff0d0, 0.85);
sun.position.set(80, 100, 60);
scene.add(sun);
scene.add(new THREE.HemisphereLight(0xb0d8ff, 0x6a5a3a, 0.35));
// 漂浮的云
const clouds = new THREE.Group();
const cloudMat = new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.7,
depthWrite: false,
fog: false,
});
for (let i = 0; i < 18; i++) {
const w = 15 + Math.random() * 30;
const d = 15 + Math.random() * 30;
const cloud = new THREE.Mesh(new THREE.PlaneGeometry(w, d), cloudMat);
cloud.position.set(
(Math.random() - 0.5) * 500,
75 + Math.random() * 15,
(Math.random() - 0.5) * 500
);
cloud.rotation.x = -Math.PI / 2;
cloud.userData.speed = 0.5 + Math.random() * 1.5;
clouds.add(cloud);
}
scene.add(clouds);
// === 方块纹理生成(程序化) ===
function createBlockTexture(type) {
const c = document.createElement('canvas');
c.width = c.height = 16;
const ctx = c.getContext('2d');
// 基础灰度
ctx.fillStyle = '#e8e8e8';
ctx.fillRect(0, 0, 16, 16);
let s = type * 9301 + 49297;
const rand = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
// 随机噪点
for (let i = 0; i < 80; i++) {
const x = Math.floor(rand() * 16);
const y = Math.floor(rand() * 16);
const v = Math.floor(rand() * 60 + 170);
ctx.fillStyle = `rgb(${v},${v},${v})`;
ctx.fillRect(x, y, 1, 1);
}
// 不同方块的特定图案
if (type === BLOCKS.STONE) {
ctx.strokeStyle = 'rgba(80,80,80,0.7)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(2, 4); ctx.lineTo(6, 8); ctx.lineTo(5, 12);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(10, 3); ctx.lineTo(13, 8);
ctx.stroke();
} else if (type === BLOCKS.WOOD) {
ctx.fillStyle = 'rgba(100,60,30,0.5)';
for (let x = 0; x < 16; x += 4) {
ctx.fillRect(x, 0, 1, 16);
}
} else if (type === BLOCKS.LEAVES) {
for (let i = 0; i < 25; i++) {
const x = Math.floor(rand() * 16);
const y = Math.floor(rand() * 16);
ctx.fillStyle = `rgba(80,160,40,0.5)`;
ctx.fillRect(x, y, 1, 1);
}
} else if (type === BLOCKS.SAND) {
for (let i = 0; i < 30; i++) {
const x = Math.floor(rand() * 16);
const y = Math.floor(rand() * 16);
ctx.fillStyle = `rgba(200,170,100,0.4)`;
ctx.fillRect(x, y, 1, 1);
}
} else if (type === BLOCKS.GRASS) {
for (let i = 0; i < 20; i++) {
const x = Math.floor(rand() * 16);
const y = Math.floor(rand() * 16);
ctx.fillStyle = `rgba(60,150,30,0.5)`;
ctx.fillRect(x, y, 1, 1);
}
} else if (type === BLOCKS.DIRT) {
for (let i = 0; i < 15; i++) {
const x = Math.floor(rand() * 16);
const y = Math.floor(rand() * 16);
ctx.fillStyle = `rgba(100,70,40,0.4)`;
ctx.fillRect(x, y, 1, 1);
}
}
const tex = new THREE.CanvasTexture(c);
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestMipmapLinearFilter;
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
// 创建方块几何体(带顶点色,用于区分顶/侧/底面)
function createBlockGeometry(type) {
const g = new THREE.BoxGeometry(1, 1, 1);
const colors = [];
const info = BLOCK_INFO[type];
const top = new THREE.Color(info.top);
const side = new THREE.Color(info.side);
const bottom = new THREE.Color(info.bottom);
// BoxGeometry 面顺序:+x, -x, +y, -y, +z, -z
const faceColors = [side, side, top, bottom, side, side];
for (let f = 0; f < 6; f++) {
const c = faceColors[f];
for (let v = 0; v < 4; v++) colors.push(c.r, c.g, c.b);
}
g.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
return g;
}
function createBlockMaterial(type) {
if (type === BLOCKS.WATER) {
return new THREE.MeshLambertMaterial({
color: 0x4a8fc8,
transparent: true,
opacity: 0.7,
depthWrite: false,
});
}
return new THREE.MeshLambertMaterial({
vertexColors: true,
map: createBlockTexture(type),
});
}
// === VoxelRenderer:基于 InstancedMesh 的高效方块渲染 ===
const MAX_INSTANCES = 60000;
class VoxelRenderer {
constructor() {
this.meshes = {};
this.positions = {};
this.pos2idx = {};
this.freeIndices = {};
this.dummy = new THREE.Object3D();
for (let t = 1; t <= 7; t++) {
const mesh = new THREE.InstancedMesh(createBlockGeometry(t), createBlockMaterial(t), MAX_INSTANCES);
mesh.count = 0;
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
mesh.frustumCulled = false;
scene.add(mesh);
this.meshes[t] = mesh;
this.positions[t] = [];
this.pos2idx[t] = new Map();
this.freeIndices[t] = [];
}
}
add(x, y, z, type) {
if (type < 1 || type > 7) return;
const k = keyStr(x, y, z);
if (this.pos2idx[type].has(k)) return;
let idx;
if (this.freeIndices[type].length > 0) {
idx = this.freeIndices[type].pop();
this.positions[type][idx] = { x, y, z };
} else {
idx = this.positions[type].length;
if (idx >= MAX_INSTANCES) return;
this.positions[type].push({ x, y, z });
}
this.pos2idx[type].set(k, idx);
this.dummy.position.set(x + 0.5, y + 0.5, z + 0.5);
this.dummy.updateMatrix();
this.meshes[type].setMatrixAt(idx, this.dummy.matrix);
this.meshes[type].instanceMatrix.needsUpdate = true;
if (this.meshes[type].count < idx + 1) {
this.meshes[type].count = idx + 1;
}
}
remove(x, y, z) {
const k = keyStr(x, y, z);
for (let t = 1; t <= 7; t++) {
if (this.pos2idx[t].has(k)) {
const idx = this.pos2idx[t].get(k);
this.pos2idx[t].delete(k);
this.freeIndices[t].push(idx);
this.meshes[t].setMatrixAt(idx, new THREE.Matrix4().makeScale(0, 0, 0));
this.meshes[t].instanceMatrix.needsUpdate = true;
return t;
}
}
return 0;
}
has(x, y, z) {
const k = keyStr(x, y, z);
for (let t = 1; t <= 7; t++) {
if (this.pos2idx[t].has(k)) return true;
}
return false;
}
getMeshes() {
return Object.values(this.meshes);
}
}
const voxelRenderer = new VoxelRenderer();
// === 可见性剔除 ===
function isExposed(x, y, z) {
return getBlock(x+1, y, z) === 0 || getBlock(x-1, y, z) === 0
|| getBlock(x, y+1, z) === 0 || getBlock(x, y-1, z) === 0
|| getBlock(x, y, z+1) === 0 || getBlock(x, y, z-1) === 0;
}
function buildRenderData() {
let count = 0;
for (const [k, type] of worldData) {
const [x, y, z] = k.split(',').map(Number);
if (type === BLOCKS.WATER) {
// 水方块只渲染顶层(性能优化)
if (getBlock(x, y+1, z) !== BLOCKS.WATER) {
voxelRenderer.add(x, y, z, type);
count++;
}
} else if (isExposed(x, y, z)) {
voxelRenderer.add(x, y, z, type);
count++;
}
}
return count;
}
// === 玩家状态 ===
const player = {
pos: new THREE.Vector3(WORLD_SIZE / 2, 25, WORLD_SIZE / 2),
vel: new THREE.Vector3(),
onGround: false,
flying: false,
inWater: false,
};
let yaw = 0, pitch = 0;
let isLocked = false;
function findSpawn() {
const cx = Math.floor(WORLD_SIZE / 2);
const cz = Math.floor(WORLD_SIZE / 2);
for (let r = 0; r < 30; r++) {
for (let dx = -r; dx <= r; dx++) {
for (let dz = -r; dz <= r; dz++) {
if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue;
const x = cx + dx, z = cz + dz;
for (let y = WORLD_HEIGHT - 1; y >= 0; y--) {
const t = getBlock(x, y, z);
if (t !== 0 && t !== BLOCKS.WATER) {
return new THREE.Vector3(x + 0.5, y + 1, z + 0.5);
}
}
}
}
}
return new THREE.Vector3(cx + 0.5, 25, cz + 0.5);
}
// === 输入处理 ===
const keys = {};
let currentBlock = BLOCKS.GRASS;
window.addEventListener('keydown', (e) => {
keys[e.code] = true;
if (e.code.startsWith('Digit')) {
const n = parseInt(e.code.slice(5));
if (n >= 1 && n <= 7) selectBlock(n);
}
if (e.code === 'KeyF' && isLocked) {
player.flying = !player.flying;
player.vel.set(0, 0, 0);
document.getElementById('mode').textContent = player.flying ? '飞行' : '行走';
showToast(player.flying ? '飞行模式' : '行走模式');
}
});
window.addEventListener('keyup', (e) => keys[e.code] = false);
document.addEventListener('mousemove', (e) => {
if (!isLocked) return;
yaw -= e.movementX * 0.0025;
pitch -= e.movementY * 0.0025;
pitch = Math.max(-Math.PI/2 + 0.01, Math.min(Math.PI/2 - 0.01, pitch));
});
document.addEventListener('mousedown', (e) => {
if (!isLocked) return;
if (e.button === 0) breakBlock();
else if (e.button === 2) placeBlock();
});
document.addEventListener('contextmenu', (e) => e.preventDefault());
document.addEventListener('wheel', (e) => {
if (!isLocked) return;
e.preventDefault();
const dir = e.deltaY > 0 ? 1 : -1;
let next = currentBlock + dir;
if (next < 1) next = 7;
if (next > 7) next = 1;
selectBlock(next);
}, { passive: false });
const startBtn = document.getElementById('start-btn');
startBtn.addEventListener('click', () => {
const p = document.body.requestPointerLock();
if (p && p.catch) p.catch(err => console.warn('Pointer lock failed:', err));
});
document.addEventListener('pointerlockchange', () => {
isLocked = document.pointerLockElement === document.body;
if (isLocked) {
document.getElementById('start-screen').classList.add('hidden');
} else {
document.getElementById('start-screen').classList.remove('hidden');
}
});
// === 射线检测与方块交互 ===
const raycaster = new THREE.Raycaster();
raycaster.far = REACH;
// 选中方块的高亮线框
const highlight = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(1.002, 1.002, 1.002)),
new THREE.LineBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.6, depthTest: true })
);
highlight.visible = false;
scene.add(highlight);
function getTargetBlock() {
raycaster.setFromCamera({ x: 0, y: 0 }, camera);
const intersects = raycaster.intersectObjects(voxelRenderer.getMeshes(), false);
if (intersects.length === 0) return null;
const hit = intersects[0];
const n = hit.face.normal;
const bx = Math.floor(hit.point.x - n.x * 0.5);
const by = Math.floor(hit.point.y - n.y * 0.5);
const bz = Math.floor(hit.point.z - n.z * 0.5);
return {
x: bx, y: by, z: bz,
normal: n.clone(),
point: hit.point.clone(),
type: getBlock(bx, by, bz),
};
}
function breakBlock() {
const t = getTargetBlock();
if (!t || t.type === 0 || t.type === BLOCKS.WATER) return;
// 生成破坏粒子
spawnParticles(t.x, t.y, t.z, t.type);
setBlockData(t.x, t.y, t.z, 0);
voxelRenderer.remove(t.x, t.y, t.z);
// 暴露周围原本被遮挡的方块
const dirs = [[1,0,0],[-1,0,0],[0,1,0],[0,-1,0],[0,0,1],[0,0,-1]];
for (const [dx, dy, dz] of dirs) {
const nx = t.x+dx, ny = t.y+dy, nz = t.z+dz;
const tt = getBlock(nx, ny, nz);
if (tt !== 0 && !voxelRenderer.has(nx, ny, nz) && isExposed(nx, ny, nz)) {
voxelRenderer.add(nx, ny, nz, tt);
}
}
}
function placeBlock() {
const t = getTargetBlock();
if (!t) return;
const nx = t.x + Math.round(t.normal.x);
const ny = t.y + Math.round(t.normal.y);
const nz = t.z + Math.round(t.normal.z);
if (getBlock(nx, ny, nz) !== 0) return;
// 不能放置在玩家所在位置(水方块除外)
if (currentBlock !== BLOCKS.WATER) {
const pBox = getPlayerAABB();
const bBox = { minX: nx, maxX: nx+1, minY: ny, maxY: ny+1, minZ: nz, maxZ: nz+1 };
if (boxesIntersect(pBox, bBox)) return;
}
setBlockData(nx, ny, nz, currentBlock);
voxelRenderer.add(nx, ny, nz, currentBlock);
}
function boxesIntersect(a, b) {
return a.maxX > b.minX && a.minX < b.maxX
&& a.maxY > b.minY && a.minY < b.maxY
&& a.maxZ > b.minZ && a.minZ < b.maxZ;
}
function getPlayerAABB() {
return {
minX: player.pos.x - PLAYER_RADIUS,
maxX: player.pos.x + PLAYER_RADIUS,
minY: player.pos.y,
maxY: player.pos.y + PLAYER_HEIGHT,
minZ: player.pos.z - PLAYER_RADIUS,
maxZ: player.pos.z + PLAYER_RADIUS,
};
}
// === 破坏方块时的粒子效果 ===
const particles = new THREE.Group();
scene.add(particles);
const particleGeo = new THREE.BoxGeometry(0.18, 0.18, 0.18);
const particleMatCache = {};
function getParticleMat(type) {
if (!particleMatCache[type]) {
particleMatCache[type] = new THREE.MeshLambertMaterial({ color: BLOCK_INFO[type].top });
}
return particleMatCache[type];
}
function spawnParticles(x, y, z, type) {
const mat = getParticleMat(type);
for (let i = 0; i < 8; i++) {
const p = new THREE.Mesh(particleGeo, mat);
p.position.set(
x + 0.5 + (Math.random() - 0.5) * 0.5,
y + 0.5 + (Math.random() - 0.5) * 0.5,
z + 0.5 + (Math.random() - 0.5) * 0.5
);
p.userData = {
vel: new THREE.Vector3(
(Math.random() - 0.5) * 5,
Math.random() * 5 + 3,
(Math.random() - 0.5) * 5
),
life: 0.8 + Math.random() * 0.5,
maxLife: 1.3,
};
particles.add(p);
}
}
function updateParticles(dt) {
for (let i = particles.children.length - 1; i >= 0; i--) {
const p = particles.children[i];
p.userData.life -= dt;
if (p.userData.life <= 0) {
particles.remove(p);
continue;
}
p.userData.vel.y -= 25 * dt;
p.position.x += p.userData.vel.x * dt;
p.position.y += p.userData.vel.y * dt;
p.position.z += p.userData.vel.z * dt;
p.rotation.x += dt * 8;
p.rotation.z += dt * 6;
p.scale.setScalar(Math.max(0.01, p.userData.life / p.userData.maxLife));
}
}
// === 玩家物理与碰撞 ===
function collideAxis(axis, delta) {
player.pos[axis] += delta;
let iter = 0;
while (iter++ < 3) {
const box = getPlayerAABB();
let collided = false;
const baseX = Math.floor(player.pos.x);
const baseY = Math.floor(player.pos.y);
const baseZ = Math.floor(player.pos.z);
for (let x = baseX - 1; x <= baseX + 1; x++) {
for (let y = baseY - 1; y <= baseY + 2; y++) {
for (let z = baseZ - 1; z <= baseZ + 1; z++) {
const t = getBlock(x, y, z);
if (t === 0 || t === BLOCKS.WATER) continue;
if (box.maxX > x && box.minX < x + 1
&& box.maxY > y && box.minY < y + 1
&& box.maxZ > z && box.minZ < z + 1) {
if (axis === 'x') {
if (delta > 0) player.pos.x = x - PLAYER_RADIUS - 0.0001;
else player.pos.x = x + 1 + PLAYER_RADIUS + 0.0001;
player.vel.x = 0;
} else if (axis === 'y') {
if (delta > 0) {
player.pos.y = y - PLAYER_HEIGHT - 0.0001;
} else {
player.pos.y = y + 1 + 0.0001;
player.onGround = true;
}
player.vel.y = 0;
} else {
if (delta > 0) player.pos.z = z - PLAYER_RADIUS - 0.0001;
else player.pos.z = z + 1 + PLAYER_RADIUS + 0.0001;
player.vel.z = 0;
}
collided = true;
break;
}
}
if (collided) break;
}
if (collided) break;
}
if (!collided) break;
}
}
function updatePlayer(dt) {
// 检测是否在水中
player.inWater = getBlock(
Math.floor(player.pos.x),
Math.floor(player.pos.y + 0.5),
Math.floor(player.pos.z)
) === BLOCKS.WATER;
// 计算前向与右向(基于相机朝向,投影到水平面)
const euler = new THREE.Euler(pitch, yaw, 0, 'YXZ');
const forward = new THREE.Vector3(0, 0, -1).applyEuler(euler);
forward.y = 0;
if (forward.lengthSq() > 0) forward.normalize();
const right = new THREE.Vector3(1, 0, 0).applyEuler(euler);
right.y = 0;
if (right.lengthSq() > 0) right.normalize();
if (player.flying) {
// 飞行模式
const speed = FLY_SPEED;
const move = new THREE.Vector3();
if (keys['KeyW']) move.add(forward);
if (keys['KeyS']) move.sub(forward);
if (keys['KeyA']) move.sub(right);
if (keys['KeyD']) move.add(right);
if (move.lengthSq() > 0) move.normalize().multiplyScalar(speed);
player.pos.x += move.x * dt;
player.pos.z += move.z * dt;
if (keys['Space']) player.pos.y += FLY_UP_SPEED * dt;
if (keys['ShiftLeft'] || keys['ShiftRight']) player.pos.y -= FLY_UP_SPEED * dt;
} else {
// 行走模式
const speed = player.inWater ? MOVE_SPEED * 0.5 : MOVE_SPEED;
const move = new THREE.Vector3();
if (keys['KeyW']) move.add(forward);
if (keys['KeyS']) move.sub(forward);
if (keys['KeyA']) move.sub(right);
if (keys['KeyD']) move.add(right);
if (move.lengthSq() > 0) move.normalize().multiplyScalar(speed);
player.vel.x = move.x;
player.vel.z = move.z;
// 重力(水中减速)
const g = player.inWater ? GRAVITY * 0.3 : GRAVITY;
player.vel.y -= g * dt;
// 跳跃 / 水中上浮
if (keys['Space']) {
if (player.inWater) {
player.vel.y = 4;
} else if (player.onGround) {
player.vel.y = JUMP_SPEED;
player.onGround = false;
}
}
// 水中阻力
if (player.inWater) {
player.vel.y *= 0.9;
}
player.onGround = false;
collideAxis('x', player.vel.x * dt);
collideAxis('z', player.vel.z * dt);
collideAxis('y', player.vel.y * dt);
// 防止掉出世界
if (player.pos.y < -10) {
player.pos.copy(findSpawn());
player.vel.set(0, 0, 0);
}
}
// 更新相机
camera.position.set(player.pos.x, player.pos.y + EYE_HEIGHT, player.pos.z);
camera.rotation.set(pitch, yaw, 0, 'YXZ');
}
// === UI 构建 ===
function selectBlock(n) {
currentBlock = n;
document.querySelectorAll('.slot').forEach(s => {
s.classList.toggle('active', parseInt(s.dataset.type) === n);
});
showToast(BLOCK_INFO[n].name);
}
function buildHotbar() {
const hb = document.getElementById('hotbar');
hb.innerHTML = '';
for (let i = 1; i <= 7; i++) {
const slot = document.createElement('div');
slot.className = 'slot';
slot.dataset.type = i;
slot.innerHTML = `
<div class="icon icon-${BLOCK_INFO[i].icon}"></div>
<div class="num">${i}</div>
<div class="name">${BLOCK_INFO[i].name}</div>
`;
slot.addEventListener('click', (e) => {
e.stopPropagation();
selectBlock(i);
});
hb.appendChild(slot);
}
// 默认激活第一个
document.querySelector('.slot[data-type="1"]').classList.add('active');
currentBlock = 1;
}
let toastTimer = null;
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 1500);
}
// === FPS 与信息更新 ===
let lastTime = performance.now();
let fpsTime = 0, fpsCount = 0, fps = 0;
function updateInfo() {
document.getElementById('pos').textContent =
`${player.pos.x.toFixed(1)}, ${player.pos.y.toFixed(1)}, ${player.pos.z.toFixed(1)}`;
document.getElementById('fps').textContent = fps;
const counts = Object.values(voxelRenderer.meshes).reduce((s, m) => s + m.count, 0);
document.getElementById('block-count').textContent = counts;
}
// === 主循环 ===
function animate() {
requestAnimationFrame(animate);
const now = performance.now();
const dt = Math.min((now - lastTime) / 1000, 0.05);
lastTime = now;
// FPS 统计
fpsTime += dt;
fpsCount++;
if (fpsTime > 0.5) {
fps = Math.round(fpsCount / fpsTime);
fpsTime = 0;
fpsCount = 0;
}
if (isLocked) {
updatePlayer(dt);
// 高亮当前指向的方块
const target = getTargetBlock();
if (target) {
highlight.position.set(target.x + 0.5, target.y + 0.5, target.z + 0.5);
highlight.visible = true;
} else {
highlight.visible = false;
}
} else {
highlight.visible = false;
}
// 粒子更新
updateParticles(dt);
// 云朵移动
clouds.children.forEach(c => {
c.position.x += dt * (c.userData.speed || 1);
if (c.position.x > 250) c.position.x = -250;
});
renderer.render(scene, camera);
updateInfo();
}
window.addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// === 启动流程 ===
buildHotbar();
// 延迟一帧让 loading 屏幕显示,再生成世界
setTimeout(() => {
const start = performance.now();
generateWorld();
const genTime = performance.now() - start;
console.log(`世界生成: ${genTime.toFixed(0)}ms, ${worldData.size} 方块`);
const buildStart = performance.now();
const renderCount = buildRenderData();
const buildTime = performance.now() - buildStart;
console.log(`渲染构建: ${buildTime.toFixed(0)}ms, ${renderCount} 可见方块`);
player.pos.copy(findSpawn());
document.getElementById('loading').classList.add('hidden');
document.getElementById('start-screen').classList.remove('hidden');
animate();
}, 100);
</script>
</body>
</html>
Game Source: 方块世界 · VoxelCraft
Creator: RocketDolphin44
Libraries: three
Complexity: complex (1394 lines, 43.2 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: voxelcraft-rocketdolphin44-ms4or07i" to link back to the original. Then publish at arcadelab.ai/publish.