3D-GAME / index.html
kimhyunwoo's picture
Update index.html
8388c6c verified
<!DOCTYPE html>
<html>
<head>
<title>Forest Explorer</title>
<style>
body { margin: 0; background: black; overflow: hidden; }
canvas { width: 100vw; height: 100vh; }
.ui {
position: fixed;
color: white;
text-shadow: 2px 2px 2px rgba(0,0,0,0.5);
pointer-events: none;
font-family: Arial, sans-serif;
}
#stamina {
bottom: 20px;
left: 20px;
width: 200px;
height: 5px;
background: rgba(0,0,0,0.5);
border-radius: 3px;
}
#stamina-bar {
width: 100%;
height: 100%;
background: #4CAF50;
border-radius: 3px;
transition: width 0.2s;
}
#crosshair {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 20px;
}
#flashlight-status {
top: 20px;
right: 20px;
}
.vignette {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, transparent 50%, rgba(0,0,0,0.4) 100%);
pointer-events: none;
}
/* 터치 컨트롤 스타일 (선택 사항 - 가상 조이스틱) */
#joystick-zone {
position: fixed;
left: 20px; /* Adjust as needed */
bottom: 70px; /* Adjust as needed */
width: 150px; /*Adjust*/
height: 150px; /*Adjust*/
/*background-color: rgba(255, 255, 255, 0.2); /* Optional: Semi-transparent background */
border-radius: 50%;
touch-action: none; /* Prevent default touch behavior (like scrolling) */
display: flex;
align-items: center;
justify-content: center;
}
#joystick-knob {
width: 60px; /*Adjust*/
height: 60px; /*Adjust*/
background-color: rgba(255, 255, 255, 0.5);
border-radius: 50%;
position: relative; /* Important for positioning relative to the zone */
pointer-events: none; /* Let events pass through to the zone */
}
</style>
</head>
<body>
<div id="stamina" class="ui"><div id="stamina-bar"></div></div>
<div id="crosshair" class="ui">+</div>
<div id="flashlight-status" class="ui">Flashlight [F]</div>
<div class="vignette"></div>
<div id="joystick-zone">
<div id="joystick-knob"></div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/simplex-noise/4.0.1/simplex-noise.min.js"></script> <!-- defer 추가 -->
<script>
const SPAWN_POSITION = new THREE.Vector3(0, 1.7, 30);
const CAVE_ENTRANCE = new THREE.Vector3(0, 1.7, -15);
const CHUNK_SIZE = 50;
const VIEW_DISTANCE = 3;
const TREE_DENSITY = 0.25;
const VEGETATION_DENSITY = 0.45;
let camera, scene, renderer, clock;
let player = {
position: SPAWN_POSITION.clone(),
velocity: new THREE.Vector3(),
speed: { walk: 3.5, sprint: 7, current: 3.5 },
stamina: 100,
headBob: { value: 0, intensity: 0.07, speed: 8 },
inCave: false,
currentChunk: { x: 0, z: 0 }
};
let controls = { forward: false, backward: false, left: false, right: false, sprint: false };
let flashlight;
let isFlashlightOn = false;
let loadedChunks = {};
let simplex; // SimplexNoise 인스턴스는 여기서 선언
let objectPools = {
trees: [],
vegetation: []
};
let isTransitioning = false; // initial load
let caveSystem = [];
// --- 터치 컨트롤 변수 ---
let touchStartY = 0;
let touchStartX = 0;
let joystick = {
isActive: false,
deltaX: 0,
deltaY: 0
};
async function init() {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
clock = new THREE.Clock();
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
setupLighting();
setupParticles();
setupAudio(); // You would need to implement this
initializeObjectPools();
// Start at the spawn position and look towards the "world"
camera.position.copy(SPAWN_POSITION);
camera.lookAt(0, 1.7, 0); // Look towards the origin (adjust as needed)
//초기배경
scene.background = new THREE.Color(0x000000);
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('click', () => document.body.requestPointerLock());
window.addEventListener('resize', onWindowResize, false);
// --- 터치 이벤트 리스너 추가 ---
document.addEventListener('touchstart', onTouchStart, false);
document.addEventListener('touchmove', onTouchMove, false);
document.addEventListener('touchend', onTouchEnd, false);
// Simplex Noise 초기화 (이제 defer 때문에 여기서 가능)
simplex = new SimplexNoise();
generateCaveSystem();
updateChunks(); // Initial chunk loading (now after cave generation)
console.log("Scene after init:", scene); // Debugging
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function initializeObjectPools() {
// Create tree pool
for (let i = 0; i < 200; i++) {
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.5, 0.7, 5),
new THREE.MeshStandardMaterial({ color: 0x885533 })
);
const leaves = new THREE.Mesh(
new THREE.SphereGeometry(2),
new THREE.MeshStandardMaterial({ color: 0x227722 })
);
trunk.castShadow = true;
leaves.castShadow = true;
trunk.visible = false;
leaves.visible = false;
objectPools.trees.push({ trunk, leaves });
}
// Create vegetation pool
for (let i = 0; i < 500; i++) {
const color = Math.random() > 0.5 ? 0xff99cc : 0xffff99;
const flower = new THREE.Mesh(
new THREE.SphereGeometry(0.2),
new THREE.MeshStandardMaterial({
color: color,
emissive: color,
emissiveIntensity: 0.2
})
);
flower.visible = false;
objectPools.vegetation.push(flower);
}
console.log("Object pools initialized:", objectPools); // Debug
}
function getObjectFromPool(type) {
const pool = objectPools[type];
if (pool) {
for (const obj of pool) {
if (type === 'trees' && obj.trunk && !obj.trunk.visible) {
obj.trunk.visible = true;
obj.leaves.visible = true;
return obj;
} else if (type === 'vegetation' && !obj.visible) {
obj.visible = true;
return obj;
}
}
}
// If no object is available, create a new one (dynamic pool expansion)
if (type === 'trees') {
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.5, 0.7, 5),
new THREE.MeshStandardMaterial({ color: 0x885533 })
);
const leaves = new THREE.Mesh(
new THREE.SphereGeometry(2),
new THREE.MeshStandardMaterial({ color: 0x227722 })
);
trunk.castShadow = true;
leaves.castShadow = true;
const newTree = { trunk, leaves };
objectPools.trees.push(newTree);
return newTree;
} else if(type === "vegetation") {
const color = Math.random() > 0.5 ? 0xff99cc : 0xffff99;
const flower = new THREE.Mesh(
new THREE.SphereGeometry(0.2),
new THREE.MeshStandardMaterial({
color: color,
emissive: color,
emissiveIntensity: 0.2
})
);
objectPools.vegetation.push(flower);
return flower;
}
return null; // Should not happen, but good practice
}
function returnObjectToPool(type, obj) {
if (type === 'trees') {
obj.trunk.visible = false;
obj.leaves.visible = false;
obj.trunk.position.set(0, 0, 0);
obj.leaves.position.set(0, 0, 0);
obj.trunk.rotation.set(0, 0, 0);
obj.leaves.rotation.set(0, 0, 0);
} else { // vegetation
obj.visible = false;
obj.position.set(0, 0, 0);
obj.rotation.set(0, 0, 0);
}
}
function createTree(x, z, parent) {
const tree = getObjectFromPool('trees');
if (tree) {
tree.trunk.position.set(x, 2.5, z);
tree.leaves.position.set(x, 6, z);
parent.add(tree.trunk);
parent.add(tree.leaves);
}
}
function createVegetation(x, z, parent) {
const flower = getObjectFromPool('vegetation');
if (flower) {
flower.position.set(x, 0.2, z);
parent.add(flower);
}
}
function generateCaveSystem() {
let startPoint = CAVE_ENTRANCE.clone();
caveSystem.push(startPoint);
for (let i = 0; i < 20; i++) {
const lastPoint = caveSystem[caveSystem.length - 1];
const newPoint = lastPoint.clone();
newPoint.x += (Math.random() - 0.5) * 5;
newPoint.y += (Math.random() - 0.5) * 2;
newPoint.z -= 5 + Math.random() * 5;
caveSystem.push(newPoint);
}
const caveCurve = new THREE.CatmullRomCurve3(caveSystem);
const tunnelGeometry = new THREE.TubeGeometry(caveCurve, 128, 3, 8, false);
const caveMaterial = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 1, side: THREE.BackSide });
const tunnel = new THREE.Mesh(tunnelGeometry, caveMaterial);
scene.add(tunnel);
const markingGeometry = new THREE.CircleGeometry(0.3, 16);
const markingMaterial = new THREE.MeshStandardMaterial({
color: 0xff0000,
emissive: 0xff0000,
emissiveIntensity: 0.5
});
for (let i = 0; i < 30; i++) {
const point = caveCurve.getPointAt(i / 29);
const marking = new THREE.Mesh(markingGeometry, markingMaterial);
const normal = caveCurve.getTangentAt(i / 29); // Get the tangent (direction) at this point
const offset = new THREE.Vector3().randomDirection().multiplyScalar(2.5); // Random offset
marking.position.copy(point).add(offset);
marking.rotation.y = Math.random() * Math.PI;
scene.add(marking);
}
console.log("Cave system generated:", caveSystem); // Debug
}
function generateChunk(chunkX, chunkZ) {
const chunk = new THREE.Group();
chunk.chunkPosition = { x: chunkX, z: chunkZ };
const startX = chunkX * CHUNK_SIZE;
const startZ = chunkZ * CHUNK_SIZE;
// Ground
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(CHUNK_SIZE, CHUNK_SIZE),
new THREE.MeshStandardMaterial({ color: 0x33aa33, roughness: 0.8 })
);
ground.rotation.x = -Math.PI / 2;
ground.position.set(startX + CHUNK_SIZE / 2, 0, startZ + CHUNK_SIZE / 2);
ground.receiveShadow = true;
chunk.add(ground);
// Trees and vegetation
for (let x = startX; x < startX + CHUNK_SIZE; x++) {
for (let z = startZ; z < startZ + CHUNK_SIZE; z++) {
const treeDensity = simplex.noise2D(x * 0.02, z * 0.02);
const vegetationDensity = simplex.noise2D(x * 0.1, z * 0.1);
if (treeDensity > TREE_DENSITY) {
createTree(x, z, chunk);
}
if (vegetationDensity > VEGETATION_DENSITY) {
createVegetation(x, z, chunk);
}
}
}
scene.add(chunk);
// console.log("Chunk generated:", chunk); // Debugging: Check if the chunk is created
return chunk;
}
function updateChunks() {
const playerChunkX = Math.floor(camera.position.x / CHUNK_SIZE);
const playerChunkZ = Math.floor(camera.position.z / CHUNK_SIZE);
if (playerChunkX === player.currentChunk.x && playerChunkZ === player.currentChunk.z && !isTransitioning) {
return;
}
isTransitioning = true;
player.currentChunk.x = playerChunkX;
player.currentChunk.z = playerChunkZ;
const chunksToLoad = {};
for (let x = playerChunkX - VIEW_DISTANCE; x <= playerChunkX + VIEW_DISTANCE; x++) {
for (let z = playerChunkZ - VIEW_DISTANCE; z <= playerChunkZ + VIEW_DISTANCE; z++) {
chunksToLoad[`${x},${z}`] = true; // Mark for loading
}
}
// Unload chunks *before* loading new ones
for (const chunkId in loadedChunks) {
if (!chunksToLoad[chunkId]) {
const chunk = loadedChunks[chunkId];
// Return objects to pools
chunk.children.forEach(child => {
if (child instanceof THREE.Mesh) {
if (child.geometry instanceof THREE.CylinderGeometry) { // It's likely a tree trunk
let tree = objectPools.trees.find(t => t.trunk === child);
if (tree) returnObjectToPool('trees', tree);
} else if (child.geometry instanceof THREE.SphereGeometry) { // Could be leaves or vegetation
if (child.material.color.equals(new THREE.Color(0x227722))) { // Likely leaves
let tree = objectPools.trees.find(t => t.leaves === child);
if (tree) returnObjectToPool('trees', tree);
} else { // Likely vegetation
let vegetation = objectPools.vegetation.find(v => v === child);
if (vegetation) returnObjectToPool('vegetation', vegetation);
}
}
}
});
scene.remove(chunk);
delete loadedChunks[chunkId];
}
}
// Asynchronously load/generate chunks
const chunkPromises = [];
for (const chunkId in chunksToLoad) {
if (!loadedChunks[chunkId]) {
const [x, z] = chunkId.split(',').map(Number);
chunkPromises.push(
new Promise(resolve => {
// Simulate async loading (replace with actual loading/generation)
setTimeout(() => {
loadedChunks[chunkId] = generateChunk(x, z);
resolve();
}, 0);
})
);
}
}
// Wait for ALL chunks to be loaded/generated
Promise.all(chunkPromises)
.then(() => {
isTransitioning = false;
// document.getElementById('loading-indicator').style.display = 'none';
})
.catch(error => {
console.error("Error loading chunks:", error);
isTransitioning = false; // Ensure isTransitioning is reset
// document.getElementById('loading-indicator').style.display = 'none';
});
}
function update(delta) {
updatePlayer(delta);
updateEnvironment();
// updateChunks(); // Moved to be called *after* initial setup, and only when not transitioning
if (!isTransitioning) {
updateChunks();
}
}
function updatePlayer(delta) {
if (!document.pointerLockElement && !joystick.isActive) return; // Don't move if no pointer lock OR touch
const direction = new THREE.Vector3();
// Keyboard controls (existing)
if (controls.forward) direction.z -= 1;
if (controls.backward) direction.z += 1;
if (controls.left) direction.x -= 1;
if (controls.right) direction.x += 1;
// Touch controls (joystick)
if (joystick.isActive) {
direction.x += joystick.deltaX;
direction.z += joystick.deltaY;
}
direction.normalize();
if ((controls.sprint && player.stamina > 0 && direction.length() > 0) || (joystick.isActive && player.stamina >0) ) {
player.speed.current = player.speed.sprint;
player.stamina = Math.max(0, player.stamina - delta * 30);
} else {
player.speed.current = player.speed.walk;
player.stamina = Math.min(100, player.stamina + delta * 10);
}
document.getElementById('stamina-bar').style.width = player.stamina + '%';
if (direction.length() > 0) {
const moveX = direction.x * player.speed.current * delta;
const moveZ = direction.z * player.speed.current * delta;
camera.position.x += moveX * Math.cos(camera.rotation.y) + moveZ * Math.sin(camera.rotation.y);
camera.position.z += moveZ * Math.cos(camera.rotation.y) - moveX * Math.sin(camera.rotation.y);
player.headBob.value += delta * player.headBob.speed;
camera.position.y = player.position.y + Math.sin(player.headBob.value) * player.headBob.intensity;
}
if (isFlashlightOn) {
flashlight.position.copy(camera.position);
flashlight.rotation.copy(camera.rotation);
}
}
function updateEnvironment() {
const inCaveNow = camera.position.z < CAVE_ENTRANCE.z; // Simplified cave check
if (inCaveNow !== player.inCave) {
player.inCave = inCaveNow;
transitionEnvironment();
}
}
function transitionEnvironment() {
if (player.inCave) {
scene.fog = new THREE.FogExp2(0x000000, 0.15);
scene.background = new THREE.Color(0x000000); //동굴안 배경
} else {
scene.fog = null;
scene.background = new THREE.Color(0x88ccff); //동굴밖 배경
}
}
function setupLighting() {
const ambientLight = new THREE.AmbientLight(0xffffff); // White ambient light
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xffffbb, 2); // Stronger sunlight
sunLight.position.set(50, 100, 50);
sunLight.castShadow = true;
// Increase shadow map size
sunLight.shadow.mapSize.width = 1024;
sunLight.shadow.mapSize.height = 1024;
scene.add(sunLight);
flashlight = new THREE.SpotLight(0xffffff, 1);
flashlight.angle = Math.PI / 6;
flashlight.penumbra = 0.1;
flashlight.decay = 2;
flashlight.distance = 30;
flashlight.visible = false;
camera.add(flashlight);
scene.add(camera); // Important: Add the camera to the scene!
}
function setupParticles() {
const particleCount = 1000;
const positions = new Float32Array(particleCount * 3);
const colors = new Float32Array(particleCount * 3);
for (let i = 0; i < particleCount * 3; i += 3) {
positions[i] = Math.random() * 200 - 100;
positions[i + 1] = Math.random() * 20;
positions[i + 2] = Math.random() * 200 - 100;
colors[i] = 1;
colors[i + 1] = 1;
colors[i + 2] = 0.8;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({ size: 0.1, vertexColors: true, transparent: true, opacity: 0.6 });
const particles = new THREE.Points(geometry, material);
scene.add(particles);
}
function setupAudio() {
// TODO: Implement audio
}
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
update(delta);
renderer.render(scene, camera);
}
// --- Keyboard controls (existing) ---
function onKeyDown(event) {
switch (event.code) {
case 'KeyW': controls.forward = true; break;
case 'KeyS': controls.backward = true; break;
case 'KeyA': controls.left = true; break;
case 'KeyD': controls.right = true; break;
case 'ShiftLeft': controls.sprint = true; break;
case 'KeyF': toggleFlashlight(); break;
}
}
function onKeyUp(event) {
switch (event.code) {
case 'KeyW': controls.forward = false; break;
case 'KeyS': controls.backward = false; break;
case 'KeyA': controls.left = false; break;
case 'KeyD': controls.right = false; break;
case 'ShiftLeft': controls.sprint = false; break;
}
}
function onMouseMove(event) {
if (document.pointerLockElement) {
camera.rotation.y -= event.movementX * 0.002;
camera.rotation.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.rotation.x - event.movementY * 0.002));
}
}
function toggleFlashlight() {
isFlashlightOn = !isFlashlightOn;
flashlight.visible = isFlashlightOn;
document.getElementById('flashlight-status').textContent = `Flashlight [F] ${isFlashlightOn ? 'ON' : 'OFF'}`;
}
// --- Touch controls ---
function onTouchStart(event) {
event.preventDefault(); // Prevent other behaviors (like scrolling)
// For simplicity, we'll only handle the first touch point
const touch = event.touches[0];
if (touch.target.closest("#joystick-zone")) {
joystick.isActive = true;
touchStartX = touch.clientX;
touchStartY = touch.clientY;
//Put knob at center
joystickKnob.style.left = `0px`;
joystickKnob.style.top = `0px`;
} else { // General screen touch (for rotation, like mouse look)
touchStartX = touch.clientX;
touchStartY = touch.clientY;
}
}
let joystickKnob = document.getElementById("joystick-knob");
let joystickZone = document.getElementById("joystick-zone");
function onTouchMove(event) {
event.preventDefault();
const touch = event.touches[0];
if (joystick.isActive) {
// Calculate the distance the touch has moved from the starting point
const deltaX = touch.clientX - touchStartX;
const deltaY = touch.clientY - touchStartY;
// Limit delta, so it is circular
let length = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
const maxLength = 75 - 30; // radius of outer - radius of inner
if(length > maxLength) {
const angle = Math.atan2(deltaY, deltaX);
joystick.deltaX = Math.cos(angle) * maxLength / (maxLength*2);
joystick.deltaY = Math.sin(angle) * maxLength/ (maxLength*2);
// Move the joystick knob visually
joystickKnob.style.left = `${Math.cos(angle) * maxLength}px`;
joystickKnob.style.top = `${Math.sin(angle) * maxLength}px`;
} else {
joystick.deltaX = deltaX / (maxLength*2) ; // Normalize for movement
joystick.deltaY = deltaY / (maxLength*2);
joystickKnob.style.left = `${deltaX}px`;
joystickKnob.style.top = `${deltaY}px`;
}
} else { // Rotation (like mouse look)
const movementX = touch.clientX - touchStartX;
const movementY = touch.clientY - touchStartY;
camera.rotation.y -= movementX * 0.005; // Adjust sensitivity as needed
camera.rotation.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.rotation.x - movementY * 0.005));
// Reset for next movement calculation. Keep camera rotation smooth.
touchStartX = touch.clientX;
touchStartY = touch.clientY;
}
}
function onTouchEnd(event) {
if(joystick.isActive){
joystick.isActive = false;
joystick.deltaX = 0;
joystick.deltaY = 0;
// Reset joystick visuals
joystickKnob.style.left = `0px`;
joystickKnob.style.top = `0px`;
}
}
init();
animate();
</script>
</body>
</html>