<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ball Clicker 3D Game</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Updated HUD -->
<div id="hud">
<div>Score: <span id="score">0</span></div>
<div>Time: <span id="time">60</span></div>
</div>
<!-- Game Overlays -->
<div id="start-screen" class="overlay visible">
<div class="overlay-content">
<h2>Ball Clicker 3D</h2>
<p>Click the balls inside the rotating shape before time runs out!</p>
<p>Move mouse to rotate the shape. Drag background to rotate view.</p>
<button id="start-button">Start Game</button>
</div>
</div>
<div id="game-over-screen" class="overlay">
<div class="overlay-content">
<h2>Game Over!</h2>
<p>Final Score: <span id="final-score">0</span></p>
<button id="restart-button">Play Again</button>
</div>
</div>
<div id="container"></div>
<!-- Sound Effect (Optional) -->
<audio id="click-sound" src="audio/click.wav" preload="auto"></audio> <!-- You need to provide this audio file -->
<!-- Include Three.js Library -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<!-- Include OrbitControls Add-on -->
<script src="https://unpkg.com/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<!-- Your simulation script -->
<script src="script.js"></script>
</body>
</html>
body {
margin: 0;
font-family: Arial, sans-serif;
background-color: #111; /* Dark background */
color: #eee;
overflow: hidden; /* Prevent scrollbars from canvas */
}
#container {
width: 100vw;
height: 100vh;
display: block;
cursor: crosshair; /* Indicate clickable area */
}
#hud {
position: absolute;
top: 10px;
left: 10px;
padding: 10px 15px;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 5px;
z-index: 100; /* Ensure it's on top */
color: #fff;
display: flex;
gap: 20px; /* Space between score and time */
font-size: 1.1em;
pointer-events: none; /* Allow clicks through HUD */
}
#hud span {
font-weight: bold;
color: #4CAF50; /* Green */
min-width: 30px;
display: inline-block;
}
/* Overlay Styles */
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.75);
display: flex;
justify-content: center;
align-items: center;
z-index: 110; /* Above HUD */
opacity: 0;
visibility: hidden;
transition: opacity 0.4s ease, visibility 0s 0.4s linear;
}
.overlay.visible {
opacity: 1;
visibility: visible;
transition-delay: 0s;
}
.overlay-content {
background-color: #2a2a3a;
color: #eee;
padding: 30px 40px;
border-radius: 8px;
text-align: center;
box-shadow: 0 5px 15px rgba(0,0,0,0.5);
transform: scale(0.9);
transition: transform 0.4s ease;
}
.overlay.visible .overlay-content {
transform: scale(1);
}
.overlay h2 {
color: #00ffff; /* Cyan */
margin-bottom: 15px;
}
.overlay p {
margin-bottom: 10px;
line-height: 1.5;
}
.overlay span {
font-weight: bold;
color: #4CAF50; /* Green */
}
.overlay button {
padding: 10px 25px;
font-size: 1.1em;
font-weight: 600;
color: #333;
background-color: #00ffff; /* Cyan */
border: none;
border-radius: 5px;
cursor: pointer;
margin-top: 15px;
transition: background-color 0.2s, transform 0.2s;
}
.overlay button:hover {
background-color: #00dddd;
transform: translateY(-2px);
}
// Basic check for Three.js and OrbitControls
if (typeof THREE === 'undefined' || typeof THREE.OrbitControls === 'undefined') {
alert('Error: Three.js or OrbitControls library not loaded!');
} else {
// --- DOM Elements ---
const scoreDisplay = document.getElementById('score');
const timeDisplay = document.getElementById('time');
const startScreen = document.getElementById('start-screen');
const gameOverScreen = document.getElementById('game-over-screen');
const startButton = document.getElementById('start-button');
const restartButton = document.getElementById('restart-button');
const finalScoreDisplay = document.getElementById('final-score');
const clickSound = document.getElementById('click-sound'); // Audio element
const container = document.getElementById('container');
// --- Game Config ---
const INITIAL_TIME = 60; // seconds
const BALL_COUNT = 100;
const TETRA_RADIUS = 10;
const BALL_RADIUS = 0.4;
const BOUNDS = TETRA_RADIUS * 0.8; // Approximate inner bounds
// --- Game State ---
let score = 0;
let timeLeft = INITIAL_TIME;
let gameActive = false;
let timerInterval = null;
let balls = []; // Holds ball mesh objects
let ballsToRemove = []; // Buffer for balls clicked this frame
// --- Scene Setup ---
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = 25;
// --- Renderer Setup ---
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
// --- Orbit Controls ---
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.screenSpacePanning = false;
controls.minDistance = 5;
controls.maxDistance = 100;
// --- Lighting ---
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(5, 10, 7.5);
scene.add(directionalLight);
// --- Tetrahedron Container ---
const tetraGeometry = new THREE.TetrahedronGeometry(TETRA_RADIUS);
const tetraMaterial = new THREE.MeshBasicMaterial({
color: 0x00ffff,
wireframe: true,
transparent: true, // Make slightly transparent
opacity: 0.5 // To see balls inside better
});
const tetrahedronMesh = new THREE.Mesh(tetraGeometry, tetraMaterial);
scene.add(tetrahedronMesh);
// --- Ball Creation Function ---
function createBall() {
const ballGeometry = new THREE.SphereGeometry(BALL_RADIUS, 12, 12); // Lower segments
const ballMaterial = new THREE.MeshStandardMaterial({
color: Math.random() * 0xffffff,
roughness: 0.5,
metalness: 0.2,
});
const ballMesh = new THREE.Mesh(ballGeometry, ballMaterial);
ballMesh.position.set(
(Math.random() - 0.5) * 2 * BOUNDS * 0.8, // Start closer to center
(Math.random() - 0.5) * 2 * BOUNDS * 0.8,
(Math.random() - 0.5) * 2 * BOUNDS * 0.8
);
ballMesh.userData.velocity = new THREE.Vector3(
(Math.random() - 0.5) * 0.2,
(Math.random() - 0.5) * 0.2,
(Math.random() - 0.5) * 0.2
);
// Add a flag to prevent clicking the same ball multiple times per frame
ballMesh.userData.isClicked = false;
return ballMesh;
}
// --- Initialize Balls ---
function initBalls() {
// Remove existing balls first if any
balls.forEach(ball => scene.remove(ball));
balls = []; // Clear the array
for (let i = 0; i < BALL_COUNT; i++) {
const ballMesh = createBall();
balls.push(ballMesh);
scene.add(ballMesh);
}
}
// --- Raycasting ---
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
function onMouseClick(event) {
if (!gameActive) return; // Only register clicks if game is active
// Calculate mouse position in normalized device coordinates (-1 to +1)
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
// Update the picking ray with the camera and mouse position
raycaster.setFromCamera(mouse, camera);
// Calculate objects intersecting the picking ray
// Check only against the balls array for efficiency
const intersects = raycaster.intersectObjects(balls);
if (intersects.length > 0) {
const clickedBall = intersects[0].object; // Closest intersected object
// Check if already clicked this frame/cycle
if (!clickedBall.userData.isClicked) {
handleBallClick(clickedBall);
}
}
}
container.addEventListener('click', onMouseClick);
// --- Tetrahedron Rotation Control ---
let lastMouseX = null, lastMouseY = null;
const tetraRotationSpeed = 0.008;
function onMouseMove(event) {
// Rotate tetrahedron only if game is active and mouse isn't dragging for OrbitControls
if (!gameActive || controls.state !== -1) { // -1 is the idle state for OrbitControls
lastMouseX = null; // Reset when dragging camera
lastMouseY = null;
return;
}
if(lastMouseX !== null && lastMouseY !== null) {
const deltaX = event.clientX - lastMouseX;
const deltaY = event.clientY - lastMouseY;
// Apply rotation based on mouse movement delta
// Rotate around Y axis based on horizontal movement
tetrahedronMesh.rotation.y += deltaX * tetraRotationSpeed;
// Rotate around X axis based on vertical movement
tetrahedronMesh.rotation.x += deltaY * tetraRotationSpeed;
}
lastMouseX = event.clientX;
lastMouseY = event.clientY;
}
container.addEventListener('mousemove', onMouseMove);
// Reset lastMouse on mouse leave to prevent jump on re-entry
container.addEventListener('mouseleave', () => {
lastMouseX = null;
lastMouseY = null;
});
// --- Game Logic Functions ---
function updateHUD() {
scoreDisplay.textContent = score;
timeDisplay.textContent = Math.max(0, timeLeft); // Ensure time doesn't go below 0
}
function handleBallClick(ball) {
// Mark as clicked to prevent multi-trigger
ball.userData.isClicked = true;
score++;
updateHUD();
playSound(clickSound);
// Add to removal buffer instead of removing immediately
ballsToRemove.push(ball);
// Optional: Add visual feedback (e.g., scale up/down quickly)
// new TWEEN.Tween(ball.scale)
// .to({ x: 1.5, y: 1.5, z: 1.5 }, 100)
// .yoyo(true).repeat(1) // Scale up then back down
// .start();
}
function processBallRemovals() {
if (ballsToRemove.length === 0) return;
ballsToRemove.forEach(ball => {
scene.remove(ball);
// Remove from the main balls array
const index = balls.indexOf(ball);
if (index > -1) {
balls.splice(index, 1);
}
});
// Clear the buffer
ballsToRemove = [];
}
function startGame() {
score = 0;
timeLeft = INITIAL_TIME;
gameActive = true;
startScreen.classList.remove('visible');
gameOverScreen.classList.remove('visible');
initBalls(); // Create fresh set of balls
updateHUD();
startTimer();
}
function endGame() {
gameActive = false;
stopTimer();
finalScoreDisplay.textContent = score;
gameOverScreen.classList.add('visible');
}
function restartGame() {
gameOverScreen.classList.remove('visible');
startGame();
}
// --- Timer ---
function startTimer() {
stopTimer(); // Clear existing timer
timerInterval = setInterval(() => {
timeLeft--;
updateHUD();
if (timeLeft <= 0) {
endGame();
}
}, 1000);
}
function stopTimer() {
clearInterval(timerInterval);
timerInterval = null;
}
// --- Sound Utility ---
function playSound(audioElement) {
if (audioElement && typeof audioElement.play === 'function') {
audioElement.currentTime = 0;
audioElement.play().catch(e => console.warn("Audio play failed:", e.message)); // Catch potential errors
}
}
// --- Event Listeners for Buttons ---
startButton.addEventListener('click', startGame);
restartButton.addEventListener('click', restartGame);
// --- Window Resize Handling ---
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// --- Animation Loop ---
function animate() {
requestAnimationFrame(animate); // Loop animation
// Update TWEEN if using it for effects
// TWEEN.update();
// Process any pending ball removals
processBallRemovals();
// Animate remaining balls only if game is active
if (gameActive) {
balls.forEach(ball => {
// Mark as not clicked for the next frame/interaction
ball.userData.isClicked = false;
// Move ball
ball.position.add(ball.userData.velocity);
// Boundary check and bounce
if (Math.abs(ball.position.x) >= BOUNDS - BALL_RADIUS) {
ball.userData.velocity.x *= -1;
ball.position.x = Math.sign(ball.position.x) * (BOUNDS - BALL_RADIUS);
}
if (Math.abs(ball.position.y) >= BOUNDS - BALL_RADIUS) {
ball.userData.velocity.y *= -1;
ball.position.y = Math.sign(ball.position.y) * (BOUNDS - BALL_RADIUS);
}
if (Math.abs(ball.position.z) >= BOUNDS - BALL_RADIUS) {
ball.userData.velocity.z *= -1;
ball.position.z = Math.sign(ball.position.z) * (BOUNDS - BALL_RADIUS);
}
});
}
// Update OrbitControls
controls.update();
// Render the scene
renderer.render(scene, camera);
}
// --- Initial Setup ---
updateHUD(); // Show initial state
// Don't initBalls() here, wait for Start button
animate(); // Start rendering loop
}
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.