Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
<title>Fruit Fall 3D</title> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
<style> | |
body { margin: 0; overflow: hidden; font-family: Arial, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f0f0f0; } | |
#gameCanvas { display: block; } | |
#ui-container { | |
position: absolute; | |
top: 10px; | |
left: 10px; | |
padding: 10px; | |
background-color: rgba(0,0,0,0.5); | |
color: white; | |
border-radius: 5px; | |
z-index: 100; | |
} | |
#score { font-size: 1.5em; margin-bottom: 5px; } | |
#start-button, #reset-button { | |
padding: 8px 15px; | |
font-size: 1em; | |
background-color: #4CAF50; | |
color: white; | |
border: none; | |
border-radius: 3px; | |
cursor: pointer; | |
margin-top: 5px; | |
} | |
#reset-button { background-color: #f44336; display: none;} | |
#game-over-message { | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%); | |
color: white; | |
background-color: rgba(0,0,0,0.7); | |
padding: 20px; | |
border-radius: 10px; | |
font-size: 2em; | |
text-align: center; | |
z-index: 101; | |
display: none; /* Hidden by default */ | |
} | |
</style> | |
</head> | |
<body> | |
<div id="ui-container"> | |
<div id="score">Score: 0</div> | |
<button id="start-button">Start Game</button> | |
<button id="reset-button">Play Again</button> | |
</div> | |
<div id="game-over-message"> | |
Game Over!<br> | |
<span id="final-score-message"></span> | |
</div> | |
<canvas id="gameCanvas"></canvas> | |
<script> | |
let scene, camera, renderer; | |
let fruits = []; | |
let score = 0; | |
let gameActive = false; | |
let raycaster, mouse; // For click detection | |
let audioContext; | |
let gameTimer, timeLeft; | |
const GAME_DURATION = 30; // seconds | |
const scoreElement = document.getElementById('score'); | |
const startButton = document.getElementById('start-button'); | |
const resetButton = document.getElementById('reset-button'); | |
const gameOverMessageDiv = document.getElementById('game-over-message'); | |
const finalScoreMessageSpan = document.getElementById('final-score-message'); | |
const fruitTypes = [ | |
{ name: 'Mango', color: 0xFFBF00, size: 0.5, points: 10 }, | |
{ name: 'Apple', color: 0xFF0000, size: 0.4, points: 10 }, | |
{ name: 'Banana', color: 0xFFFF00, size: 0.35, points: 15 }, // Represented as sphere | |
{ name: 'Grapes', color: 0x800080, size: 0.3, points: 20 }, | |
{ name: 'Watermelon', color: 0x00FF00, size: 0.7, points: 5 } | |
]; | |
function initAudio() { | |
if (!audioContext) { | |
audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
} | |
} | |
function playSound(type = 'click', freq = 440, duration = 0.05) { | |
if (!audioContext) return; | |
const oscillator = audioContext.createOscillator(); | |
const gainNode = audioContext.createGain(); | |
oscillator.connect(gainNode); | |
gainNode.connect(audioContext.destination); | |
oscillator.type = (type === 'miss') ? 'sawtooth' : 'triangle'; | |
oscillator.frequency.setValueAtTime(freq, audioContext.currentTime); | |
gainNode.gain.setValueAtTime(0.15, audioContext.currentTime); // Lowered volume | |
gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + duration); | |
oscillator.start(); | |
oscillator.stop(audioContext.currentTime + duration); | |
} | |
function init() { | |
// Scene | |
scene = new THREE.Scene(); | |
scene.background = new THREE.Color(0x87CEEB); // Sky blue | |
// Camera | |
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
camera.position.set(0, 2, 7); // Positioned to see the falling fruits well | |
camera.lookAt(0, 0, 0); | |
// Renderer | |
renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('gameCanvas'), antialias: true }); | |
renderer.setSize(window.innerWidth, window.innerHeight); | |
renderer.shadowMap.enabled = true; | |
// Lighting | |
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); | |
scene.add(ambientLight); | |
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
directionalLight.position.set(5, 10, 7); | |
directionalLight.castShadow = true; | |
scene.add(directionalLight); | |
// Ground (optional, for visual reference) | |
const groundGeometry = new THREE.PlaneGeometry(20, 20); | |
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x228B22, side: THREE.DoubleSide }); // Forest green | |
const ground = new THREE.Mesh(groundGeometry, groundMaterial); | |
ground.rotation.x = -Math.PI / 2; | |
ground.position.y = -5; // Fruits fall past this | |
ground.receiveShadow = true; | |
scene.add(ground); | |
// Raycaster for mouse clicks | |
raycaster = new THREE.Raycaster(); | |
mouse = new THREE.Vector2(); | |
// Event Listeners | |
window.addEventListener('resize', onWindowResize, false); | |
renderer.domElement.addEventListener('click', onClick, false); // For desktop | |
renderer.domElement.addEventListener('touchstart', onTouch, false); // For mobile | |
startButton.addEventListener('click', startGame); | |
resetButton.addEventListener('click', startGame); // Reset button also starts the game | |
} | |
function startGame() { | |
initAudio(); | |
score = 0; | |
timeLeft = GAME_DURATION; | |
updateScoreDisplay(); | |
gameActive = true; | |
fruits.forEach(fruit => scene.remove(fruit.mesh)); // Clear existing fruits | |
fruits = []; | |
startButton.style.display = 'none'; | |
resetButton.style.display = 'none'; | |
gameOverMessageDiv.style.display = 'none'; | |
if (gameTimer) clearInterval(gameTimer); | |
gameTimer = setInterval(() => { | |
timeLeft--; | |
// Optionally display timer: scoreElement.textContent = `Score: ${score} | Time: ${timeLeft}`; | |
if (timeLeft <= 0) { | |
endGame(); | |
} | |
}, 1000); | |
spawnFruitLoop(); // Start spawning fruits | |
if (!renderer.xr.isPresenting) animate(); // Ensure animate isn't called twice if in XR | |
} | |
function endGame() { | |
gameActive = false; | |
clearInterval(gameTimer); | |
clearTimeout(fruitSpawnTimeout); // Stop spawning new fruits | |
finalScoreMessageSpan.textContent = `Your Score: ${score}`; | |
gameOverMessageDiv.style.display = 'flex'; | |
resetButton.style.display = 'inline-block'; | |
} | |
let fruitSpawnTimeout; | |
function spawnFruitLoop() { | |
if (!gameActive) return; | |
createFruit(); | |
const spawnInterval = Math.random() * 1500 + 500; // 0.5 to 2 seconds | |
fruitSpawnTimeout = setTimeout(spawnFruitLoop, spawnInterval); | |
} | |
function createFruit() { | |
if (!gameActive) return; | |
const type = fruitTypes[Math.floor(Math.random() * fruitTypes.length)]; | |
const geometry = new THREE.SphereGeometry(type.size, 16, 12); | |
const material = new THREE.MeshStandardMaterial({ color: type.color, roughness: 0.5, metalness: 0.1 }); | |
const fruitMesh = new THREE.Mesh(geometry, material); | |
fruitMesh.castShadow = true; | |
// Random spawn position at the top | |
fruitMesh.position.x = (Math.random() - 0.5) * 10; // Range: -5 to 5 | |
fruitMesh.position.y = 7 + Math.random() * 3; // Start above camera view | |
fruitMesh.position.z = (Math.random() - 0.5) * 4; // Some depth variation | |
fruitMesh.userData = { type: type.name, points: type.points, fallSpeed: 0.02 + Math.random() * 0.03 }; | |
scene.add(fruitMesh); | |
fruits.push({ mesh: fruitMesh, data: fruitMesh.userData }); | |
} | |
function onWindowResize() { | |
camera.aspect = window.innerWidth / window.innerHeight; | |
camera.updateProjectionMatrix(); | |
renderer.setSize(window.innerWidth, window.innerHeight); | |
} | |
function handleInteraction(clientX, clientY) { | |
if (!gameActive) return; | |
mouse.x = (clientX / window.innerWidth) * 2 - 1; | |
mouse.y = -(clientY / window.innerHeight) * 2 + 1; | |
raycaster.setFromCamera(mouse, camera); | |
const fruitMeshes = fruits.map(f => f.mesh); | |
const intersects = raycaster.intersectObjects(fruitMeshes); | |
if (intersects.length > 0) { | |
const clickedFruitMesh = intersects[0].object; | |
const fruitIndex = fruits.findIndex(f => f.mesh === clickedFruitMesh); | |
if (fruitIndex !== -1) { | |
const fruitData = fruits[fruitIndex].data; | |
score += fruitData.points; | |
updateScoreDisplay(); | |
playSound('click', 300 + Math.random()*200); | |
// Simple "pop" effect: shrink and remove | |
// More complex particle effects are possible but add overhead | |
clickedFruitMesh.scale.set(0.1,0.1,0.1); // Visually shrink | |
setTimeout(() => { // Remove after slight delay | |
scene.remove(clickedFruitMesh); | |
}, 50); | |
fruits.splice(fruitIndex, 1); | |
} | |
} | |
} | |
function onClick(event) { | |
handleInteraction(event.clientX, event.clientY); | |
} | |
function onTouch(event) { | |
if (event.touches.length > 0) { | |
handleInteraction(event.touches[0].clientX, event.touches[0].clientY); | |
} | |
} | |
function updateScoreDisplay() { | |
scoreElement.textContent = `Score: ${score} | Time: ${timeLeft}`; | |
} | |
function animate() { | |
if (!gameActive && timeLeft <= 0) { // If game ended, stop animation loop | |
return; | |
} | |
requestAnimationFrame(animate); | |
if (gameActive) { | |
// Fruit falling logic | |
for (let i = fruits.length - 1; i >= 0; i--) { | |
const fruitObj = fruits[i]; | |
fruitObj.mesh.position.y -= fruitObj.data.fallSpeed; | |
fruitObj.mesh.rotation.x += 0.01; | |
fruitObj.mesh.rotation.y += 0.01; | |
// Remove fruit if it falls below a certain point (miss) | |
if (fruitObj.mesh.position.y < -6) { // Below the ground plane | |
scene.remove(fruitObj.mesh); | |
fruits.splice(i, 1); | |
// playSound('miss', 150); // Optional miss sound | |
// score -= 5; // Optional penalty | |
updateScoreDisplay(); | |
} | |
} | |
} | |
renderer.render(scene, camera); | |
} | |
// --- Start --- | |
init(); | |
// startGame(); // Initial call to animate if not waiting for button | |
</script> | |
</body> | |
</html> |