Spaces:
Running
Running
<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> |