<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Minecraft Web - Mobile Edition</title>
<style>
body { margin: 0; overflow: hidden; background-color: #000; touch-action: none; user-select: none; -webkit-user-select: none; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
canvas { display: block; width: 100%; height: 100dvh; }
/* UI 层 */
#ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
/* 准星 */
#crosshair {
position: absolute; top: 50%; left: 50%; width: 16px; height: 16px;
transform: translate(-50%, -50%); pointer-events: none;
background-image: linear-gradient(to bottom, transparent 45%, rgba(255,255,255,0.8) 45%, rgba(255,255,255,0.8) 55%, transparent 55%),
linear-gradient(to right, transparent 45%, rgba(255,255,255,0.8) 45%, rgba(255,255,255,0.8) 55%, transparent 55%);
z-index: 5;
}
/* 横屏提示 */
#orientation-warning {
display: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: #1a1a1a; color: white; z-index: 100;
justify-content: center; align-items: center; flex-direction: column; text-align: center;
}
@media screen and (orientation: portrait) {
#orientation-warning { display: flex; }
}
/* 移动端控制区 */
.mobile-control { pointer-events: auto; position: absolute; }
/* 左侧摇杆区 */
#joystick-zone {
bottom: 50px; left: 50px; width: 120px; height: 120px;
background: rgba(255, 255, 255, 0.1); border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.3);
display: none; /* 默认隐藏,JS 检测触摸设备开启 */
}
#joystick-knob {
position: absolute; top: 50%; left: 50%; width: 50px; height: 50px;
background: rgba(255, 255, 255, 0.5); border-radius: 50%;
transform: translate(-50%, -50%);
}
/* 右侧按钮区 */
#action-buttons {
bottom: 40px; right: 40px; display: none; flex-direction: column; gap: 15px;
}
.btn {
width: 60px; height: 60px; border-radius: 50%;
background: rgba(0, 0, 0, 0.5); border: 2px solid rgba(255,255,255,0.5);
color: white; font-weight: bold; font-size: 12px;
display: flex; justify-content: center; align-items: center;
backdrop-filter: blur(4px);
}
.btn:active { background: rgba(255, 255, 255, 0.3); transform: scale(0.95); }
.btn-jump { background: rgba(100, 100, 255, 0.5); }
.btn-break { background: rgba(255, 100, 100, 0.5); }
.btn-place { background: rgba(100, 255, 100, 0.5); }
/* 物品栏 */
#hotbar {
position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%);
display: flex; gap: 5px; background: rgba(0,0,0,0.6); padding: 5px;
border-radius: 8px; pointer-events: auto;
}
.slot {
width: 40px; height: 40px; border: 2px solid #555; background-size: cover;
image-rendering: pixelated;
}
.slot.active { border-color: white; transform: scale(1.1); box-shadow: 0 0 10px rgba(255,255,255,0.5); }
/* 说明 */
#info {
position: absolute; top: 10px; left: 10px; color: rgba(255,255,255,0.7);
font-size: 12px; pointer-events: none; text-shadow: 1px 1px 2px black;
}
</style>
</head>
<body>
<!-- 3D 容器 -->
<div id="container"></div>
<!-- UI 层 -->
<div id="ui-layer">
<div id="crosshair"></div>
<div id="info">
PC: WASD 移动,鼠标看/操作<br>
Mobile: 左摇杆移动,右屏看,按钮操作
</div>
<!-- 移动端控件 -->
<div id="joystick-zone" class="mobile-control">
<div id="joystick-knob"></div>
</div>
<div id="action-buttons" class="mobile-control">
<div class="btn btn-jump" id="btn-jump">跳跃</div>
<div class="btn btn-break" id="btn-break">破坏</div>
<div class="btn btn-place" id="btn-place">放置</div>
</div>
<div id="hotbar"></div>
</div>
<!-- 横屏提示 -->
<div id="orientation-warning">
<h1>⚠️ 请旋转手机</h1>
<p>为了最佳体验,请使用横屏模式游玩</p>
<div style="font-size: 50px; margin-top: 20px;">📱 ➡️ 📱</div>
</div>
<!-- 引入 Three.js -->
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
// --- 配置 ---
const WORLD_SIZE = 32;
const BLOCK_SIZE = 1;
const RENDER_DISTANCE = 40;
// --- 纹理生成器 (程序化纹理,无需外部图片) ---
function createBlockTexture(colorHex, border = true) {
const canvas = document.createElement('canvas');
canvas.width = 64; canvas.height = 64;
const ctx = canvas.getContext('2d');
// 底色
ctx.fillStyle = colorHex;
ctx.fillRect(0, 0, 64, 64);
// 噪点
for(let i=0; i<400; i++) {
ctx.fillStyle = `rgba(0,0,0,${Math.random() * 0.15})`;
ctx.fillRect(Math.random()*64, Math.random()*64, 2, 2);
ctx.fillStyle = `rgba(255,255,255,${Math.random() * 0.1})`;
ctx.fillRect(Math.random()*64, Math.random()*64, 2, 2);
}
// 边框
if(border) {
ctx.strokeStyle = 'rgba(0,0,0,0.2)';
ctx.lineWidth = 4;
ctx.strokeRect(0, 0, 64, 64);
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.lineWidth = 2;
ctx.strokeRect(2, 2, 60, 60);
}
const tex = new THREE.CanvasTexture(canvas);
tex.magFilter = THREE.NearestFilter; // 像素风
tex.minFilter = THREE.NearestFilter;
return tex;
}
const materials = {
grass: new THREE.MeshLambertMaterial({ map: createBlockTexture('#559c44') }),
dirt: new THREE.MeshLambertMaterial({ map: createBlockTexture('#5d4037') }),
stone: new THREE.MeshLambertMaterial({ map: createBlockTexture('#757575') }),
wood: new THREE.MeshLambertMaterial({ map: createBlockTexture('#8d6e63') }),
brick: new THREE.MeshLambertMaterial({ map: createBlockTexture('#b71c1c') }),
sand: new THREE.MeshLambertMaterial({ map: createBlockTexture('#fdd835') })
};
const materialKeys = Object.keys(materials);
let currentMaterialIndex = 0;
// --- 场景初始化 ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87CEEB);
scene.fog = new THREE.FogExp2(0x87CEEB, 0.02);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 10, 0);
const renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: "high-performance" });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 优化移动端性能
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.getElementById('container').appendChild(renderer.domElement);
// --- 光照 ---
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6);
scene.add(hemiLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(50, 100, 50);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 1024;
dirLight.shadow.mapSize.height = 1024;
dirLight.shadow.camera.near = 0.5;
dirLight.shadow.camera.far = 200;
dirLight.shadow.camera.left = -50;
dirLight.shadow.camera.right = 50;
dirLight.shadow.camera.top = 50;
dirLight.shadow.camera.bottom = -50;
scene.add(dirLight);
// --- 世界管理 ---
const objects = [];
const geometry = new THREE.BoxGeometry(BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
// 简单的世界生成
function generateWorld() {
for (let x = -WORLD_SIZE / 2; x < WORLD_SIZE / 2; x++) {
for (let z = -WORLD_SIZE / 2; z < WORLD_SIZE / 2; z++) {
const y = Math.floor(Math.sin(x / 8) * 3 + Math.cos(z / 8) * 3);
for (let h = -2; h <= y; h++) {
let mat = materials.stone;
if (h === y) mat = materials.grass;
else if (h > y - 2) mat = materials.dirt;
if (y < -1) mat = materials.sand; // 低处是沙子
createBlock(x, h, z, mat);
}
}
}
}
function createBlock(x, y, z, material) {
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(x * BLOCK_SIZE, y * BLOCK_SIZE, z * BLOCK_SIZE);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
objects.push(mesh);
return mesh;
}
generateWorld();
// --- 玩家手臂 (视觉细节) ---
const armGroup = new THREE.Group();
const armGeo = new THREE.BoxGeometry(0.2, 0.6, 0.2);
const armMat = new THREE.MeshLambertMaterial({ color: 0xffccaa }); // 皮肤色
const armMesh = new THREE.Mesh(armGeo, armMat);
armMesh.position.set(0.3, -0.4, -0.3);
armGroup.add(armMesh);
camera.add(armGroup);
scene.add(camera); // 将相机加入场景以便手臂跟随
// --- 输入控制系统 (核心:兼容 PC 和 手机) ---
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// 状态变量
const moveState = { forward: 0, right: 0, jump: false };
const lookState = { yaw: 0, pitch: 0 };
// 初始化相机角度
camera.rotation.order = 'YXZ';
// --- 桌面端控制 (PointerLock) ---
if (!isMobile) {
const controls = new (await import('three/addons/controls/PointerLockControls.js')).PointerLockControls(camera, document.body);
scene.add(controls.getObject());
// 重新绑定相机,因为 PointerLockControls 会创建一个 Object3D 包裹相机
// 为了简化,我们这里直接用键盘更新速度,手动更新相机位置,不使用 PointerLockControls 的内部移动逻辑
// 这样能更好地统一手机和电脑的物理逻辑
document.addEventListener('click', () => {
// 简单的锁定逻辑,实际项目中需要更完善的 UI 引导
// document.body.requestPointerLock();
});
document.addEventListener('keydown', (e) => {
switch(e.code) {
case 'KeyW': moveState.forward = 1; break;
case 'KeyS': moveState.forward = -1; break;
case 'KeyA': moveState.right = -1; break;
case 'KeyD': moveState.right = 1; break;
case 'Space': if(canJump) { velocity.y = 12; canJump = false; } break;
case 'Digit1': selectMaterial(0); break;
case 'Digit2': selectMaterial(1); break;
case 'Digit3': selectMaterial(2); break;
case 'Digit4': selectMaterial(3); break;
case 'Digit5': selectMaterial(4); break;
}
});
document.addEventListener('keyup', (e) => {
switch(e.code) {
case 'KeyW': case 'KeyS': moveState.forward = 0; break;
case 'KeyA': case 'KeyD': moveState.right = 0; break;
}
});
// 鼠标看视角
document.addEventListener('mousemove', (e) => {
if (document.pointerLockElement === document.body) {
lookState.yaw -= e.movementX * 0.002;
lookState.pitch -= e.movementY * 0.002;
lookState.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, lookState.pitch));
camera.rotation.set(lookState.pitch, lookState.yaw, 0, 'YXZ');
}
});
// 鼠标点击交互
document.addEventListener('mousedown', (e) => {
if (document.pointerLockElement !== document.body && !isMobile) return;
handleInteraction(e.button === 2); // 右键放置,左键破坏
});
// 阻止右键菜单
document.addEventListener('contextmenu', e => e.preventDefault());
} else {
// --- 移动端控制 ---
document.getElementById('joystick-zone').style.display = 'block';
document.getElementById('action-buttons').style.display = 'flex';
// 1. 虚拟摇杆 (移动)
const joystickZone = document.getElementById('joystick-zone');
const joystickKnob = document.getElementById('joystick-knob');
let joystickTouchId = null;
let joystickCenter = { x: 0, y: 0 };
joystickZone.addEventListener('touchstart', (e) => {
e.preventDefault();
const touch = e.changedTouches[0];
joystickTouchId = touch.identifier;
const rect = joystickZone.getBoundingClientRect();
joystickCenter = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
updateJoystick(touch.clientX, touch.clientY);
}, { passive: false });
joystickZone.addEventListener('touchmove', (e) => {
e.preventDefault();
for (let i = 0; i < e.changedTouches.length; i++) {
if (e.changedTouches[i].identifier === joystickTouchId) {
updateJoystick(e.changedTouches[i].clientX, e.changedTouches[i].clientY);
break;
}
}
}, { passive: false });
const endJoystick = (e) => {
e.preventDefault();
for (let i = 0; i < e.changedTouches.length; i++) {
if (e.changedTouches[i].identifier === joystickTouchId) {
joystickTouchId = null;
moveState.forward = 0;
moveState.right = 0;
joystickKnob.style.transform = `translate(-50%, -50%)`;
break;
}
}
};
joystickZone.addEventListener('touchend', endJoystick);
joystickZone.addEventListener('touchcancel', endJoystick);
function updateJoystick(x, y) {
const maxDist = 35;
let dx = x - joystickCenter.x;
let dy = y - joystickCenter.y;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist > maxDist) {
const ratio = maxDist / dist;
dx *= ratio;
dy *= ratio;
}
joystickKnob.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
// 映射到移动状态 (-1 到 1)
moveState.right = dx / maxDist;
moveState.forward = -dy / maxDist; // Y 轴反向
}
// 2. 右侧屏幕滑动 (视角)
let lastTouchX = 0;
let lastTouchY = 0;
let lookTouchId = null;
document.addEventListener('touchstart', (e) => {
// 如果点在按钮或摇杆上,不处理视角
if (e.target.closest('.mobile-control') || e.target.closest('#hotbar')) return;
// 只在屏幕右半部分触发视角
const touch = e.changedTouches[0];
if (touch.clientX > window.innerWidth / 2) {
lookTouchId = touch.identifier;
lastTouchX = touch.clientX;
lastTouchY = touch.clientY;
}
}, { passive: false });
document.addEventListener('touchmove', (e) => {
if (lookTouchId === null) return;
for (let i = 0; i < e.changedTouches.length; i++) {
if (e.changedTouches[i].identifier === lookTouchId) {
const touch = e.changedTouches[i];
const deltaX = touch.clientX - lastTouchX;
const deltaY = touch.clientY - lastTouchY;
lookState.yaw -= deltaX * 0.005;
lookState.pitch -= deltaY * 0.005;
lookState.pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, lookState.pitch));
camera.rotation.set(lookState.pitch, lookState.yaw, 0, 'YXZ');
lastTouchX = touch.clientX;
lastTouchY = touch.clientY;
break;
}
}
}, { passive: false });
const endLook = (e) => {
for (let i = 0; i < e.changedTouches.length; i++) {
if (e.changedTouches[i].identifier === lookTouchId) {
lookTouchId = null;
break;
}
}
};
document.addEventListener('touchend', endLook);
// 3. 按钮绑定
document.getElementById('btn-jump').addEventListener('touchstart', (e) => {
e.preventDefault();
if(canJump) { velocity.y = 12; canJump = false; }
});
document.getElementById('btn-break').addEventListener('touchstart', (e) => {
e.preventDefault();
handleInteraction(false); // 左键逻辑
});
document.getElementById('btn-place').addEventListener('touchstart', (e) => {
e.preventDefault();
handleInteraction(true); // 右键逻辑
});
// 物品栏点击
document.querySelectorAll('.slot').forEach((slot, idx) => {
slot.addEventListener('click', () => selectMaterial(idx));
});
}
// --- 交互逻辑 (射线检测) ---
const raycaster = new THREE.Raycaster();
const highlightGeo = new THREE.BoxGeometry(1.02, 1.02, 1.02);
const highlightMat = new THREE.MeshBasicMaterial({ color: 0x000000, wireframe: true, transparent: true, opacity: 0.5 });
const highlightMesh = new THREE.Mesh(highlightGeo, highlightMat);
scene.add(highlightMesh);
function handleInteraction(isPlace) {
raycaster.setFromCamera(new THREE.Vector2(0, 0), camera);
const intersects = raycaster.intersectObjects(objects);
if (intersects.length > 0 && intersects[0].distance < 6) {
const intersect = intersects[0];
if (!isPlace) {
// 破坏
scene.remove(intersect.object);
objects.splice(objects.indexOf(intersect.object), 1);
// 简单的粒子效果占位
} else {
// 放置
const pos = intersect.point.add(intersect.face.normal.multiplyScalar(0.5)).floor();
// 防止卡住自己
const playerPos = camera.position;
const dx = Math.abs(playerPos.x - (pos.x + 0.5));
const dz = Math.abs(playerPos.z - (pos.z + 0.5));
const dy = Math.abs((playerPos.y - 1.5) - (pos.y + 0.5));
if (dx < 0.8 && dz < 0.8 && dy < 1.5) return; // 太近了不放置
const exists = objects.some(obj =>
Math.abs(obj.position.x - (pos.x+0.5)) < 0.1 &&
Math.abs(obj.position.y - (pos.y+0.5)) < 0.1 &&
Math.abs(obj.position.z - (pos.z+0.5)) < 0.1
);
if (!exists) {
createBlock(pos.x + 0.5, pos.y + 0.5, pos.z + 0.5, materials[materialKeys[currentMaterialIndex]]);
}
}
}
}
// --- 物品栏 UI ---
const hotbar = document.getElementById('hotbar');
materialKeys.forEach((key, index) => {
const div = document.createElement('div');
div.className = 'slot' + (index === 0 ? ' active' : '');
// 使用生成的纹理作为背景
const canvas = materials[key].map.image;
div.style.backgroundImage = `url(${canvas.toDataURL()})`;
div.onclick = () => selectMaterial(index);
hotbar.appendChild(div);
});
function selectMaterial(index) {
currentMaterialIndex = index;
const slots = document.querySelectorAll('.slot');
slots.forEach((s, i) => {
if (i === index) s.classList.add('active');
else s.classList.remove('active');
});
}
// --- 物理与动画循环 ---
let velocity = new THREE.Vector3();
let canJump = false;
let prevTime = performance.now();
const speed = 10.0;
const gravity = 30.0;
// 简单的碰撞检测辅助函数
const playerBox = new THREE.Box3();
const blockBox = new THREE.Box3();
function checkCollision(newPos) {
// 玩家包围盒 (宽 0.6, 高 1.8)
playerBox.setFromCenterAndSize(newPos, new THREE.Vector3(0.6, 1.8, 0.6));
for (let obj of objects) {
// 优化:只检测附近的方块
if (obj.position.distanceTo(newPos) > 2) continue;
blockBox.setFromObject(obj);
if (playerBox.intersectsBox(blockBox)) {
return true;
}
}
return false;
}
function animate() {
requestAnimationFrame(animate);
const time = performance.now();
const delta = Math.min((time - prevTime) / 1000, 0.1); // 防止卡顿时飞出去
prevTime = time;
// 1. 视角更新 (统一处理)
camera.rotation.set(lookState.pitch, lookState.yaw, 0, 'YXZ');
// 2. 移动计算
const direction = new THREE.Vector3();
direction.z = moveState.forward;
direction.x = moveState.right;
direction.normalize();
if (moveState.forward !== 0 || moveState.right !== 0) {
// 获取相机朝向的水平向量
const camDir = new THREE.Vector3();
camera.getWorldDirection(camDir);
camDir.y = 0;
camDir.normalize();
const camRight = new THREE.Vector3();
camRight.crossVectors(camDir, new THREE.Vector3(0, 1, 0));
const moveVec = new THREE.Vector3();
moveVec.addScaledVector(camDir, direction.z);
moveVec.addScaledVector(camRight, direction.x);
moveVec.normalize().multiplyScalar(speed * delta);
// 尝试移动 XZ
const originalPos = camera.position.clone();
camera.position.x += moveVec.x;
camera.position.z += moveVec.z;
if (checkCollision(camera.position)) {
camera.position.x = originalPos.x;
camera.position.z = originalPos.z;
}
// 手臂摆动动画
const swing = Math.sin(time * 0.015) * 0.1;
armMesh.position.x = 0.3 + swing;
armMesh.rotation.z = -swing;
armMesh.rotation.x = Math.abs(swing);
} else {
armMesh.position.x = 0.3;
armMesh.rotation.set(0,0,0);
}
// 3. 重力与跳跃
velocity.y -= gravity * delta;
camera.position.y += velocity.y * delta;
// 地面碰撞
if (checkCollision(camera.position)) {
// 如果下落时碰撞,说明落地
if (velocity.y < 0) {
// 简单修正:回到上一帧高度附近
// 更精确的做法是分离轴测试,这里简化处理
velocity.y = 0;
canJump = true;
// 防止陷入地下
camera.position.y = Math.ceil(camera.position.y - 1.5) + 1.5;
} else {
// 顶头碰撞
velocity.y = 0;
camera.position.y -= 0.1;
}
}
// 4. 高亮框更新
raycaster.setFromCamera(new THREE.Vector2(0, 0), camera);
const intersects = raycaster.intersectObjects(objects);
if (intersects.length > 0 && intersects[0].distance < 6) {
const target = intersects[0].object;
highlightMesh.position.copy(target.position);
highlightMesh.visible = true;
} else {
highlightMesh.visible = false;
}
renderer.render(scene, camera);
}
// 窗口调整
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
animate();
</script>
</body>
</html>
index.html
md
README.md
index.html