diff --git a/index.html b/index.html index aa09f1b..851ba57 100644 --- a/index.html +++ b/index.html @@ -270,6 +270,8 @@ const GROUND_Y = 0.16; const GRAVITY = -9.2; const RESPAWN_AFTER = 2.6; // how long a branch stays empty before a new apple grows back const MISS_LINGER = 1.4; // how long a missed apple stays on the grass before fading away +const LEAF_LINGER = 1.8; // how long a fallen leaf rests before fading away +const SQUIRREL_COOLDOWN = 6; // min seconds between squirrel appearances // a tree "slot" holds at most one apple at a time and regrows independently of // whatever happens to the apple once it falls (caught in the basket or not) @@ -284,6 +286,114 @@ const fallingApples = []; // independent apples in flight: falling | resting | v const basketApples = []; // apples caught and resting in the basket let basketCount = 0; +// ---------- leaves ---------- +const leafGeo = new THREE.PlaneGeometry(0.14, 0.18); +const leafMats = [0x4f9d6e, 0x3f8a5d, 0x5aab77].map( + (c) => new THREE.MeshStandardMaterial({ color: c, roughness: 0.85, side: THREE.DoubleSide }) +); +function createLeafMesh(){ + return new THREE.Mesh(leafGeo, leafMats[Math.floor(Math.random() * leafMats.length)]); +} +const fallingLeaves = []; // falling | resting | vanishing + +function randomFoliageWorldPoint(){ + const spec = foliageSpecs[Math.floor(Math.random() * foliageSpecs.length)]; + const dir = new THREE.Vector3(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).normalize(); + const local = new THREE.Vector3(spec.pos[0], spec.pos[1], spec.pos[2]).addScaledVector(dir, spec.r * 0.9); + return treeGroup.localToWorld(local); +} + +function knockOffLeaf(){ + if (fallingLeaves.length > 30) return; + const world = randomFoliageWorldPoint(); + const mesh = createLeafMesh(); + mesh.position.copy(world); + mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI); + scene.add(mesh); + + fallingLeaves.push({ + mesh, + velocity: new THREE.Vector3( + (Math.random() - 0.5) * 0.8 + angVel.z * 0.04, + 0.3 + Math.random() * 0.3, + (Math.random() - 0.5) * 0.8 + ), + spin: new THREE.Vector3( + (Math.random() - 0.5) * 4, + (Math.random() - 0.5) * 4, + (Math.random() - 0.5) * 4 + ), + driftPhase: Math.random() * Math.PI * 2, + state: 'falling', + timer: 0, + }); +} + +// ---------- squirrel ---------- +function createSquirrelMesh(){ + const bodyMat = new THREE.MeshStandardMaterial({ color: 0x8a6642, roughness: 0.9, flatShading: true }); + const bellyMat = new THREE.MeshStandardMaterial({ color: 0xe4c9a0, roughness: 0.9, flatShading: true }); + + const g = new THREE.Group(); + + const body = new THREE.Mesh(new THREE.SphereGeometry(0.16, 8, 6), bodyMat); + body.scale.set(1, 0.85, 1.3); + g.add(body); + + const belly = new THREE.Mesh(new THREE.SphereGeometry(0.1, 8, 6), bellyMat); + belly.scale.set(0.8, 0.7, 1); + belly.position.set(0, -0.04, 0.05); + g.add(belly); + + const head = new THREE.Mesh(new THREE.SphereGeometry(0.11, 8, 6), bodyMat); + head.position.set(0, 0.06, 0.22); + g.add(head); + + const earGeo = new THREE.ConeGeometry(0.035, 0.07, 6); + const earL = new THREE.Mesh(earGeo, bodyMat); + earL.position.set(-0.06, 0.15, 0.26); + earL.rotation.x = -0.3; + g.add(earL); + const earR = earL.clone(); + earR.position.x = 0.06; + g.add(earR); + + const tail = new THREE.Mesh(new THREE.SphereGeometry(0.14, 8, 6), bodyMat); + tail.scale.set(0.7, 1.4, 0.7); + tail.position.set(0, 0.2, -0.24); + tail.rotation.x = 0.7; + g.add(tail); + + return g; +} + +const fallingSquirrels = []; // falling | fleeing + +function knockOffSquirrel(){ + if (fallingSquirrels.length) return; + const world = randomFoliageWorldPoint(); + const mesh = createSquirrelMesh(); + mesh.position.copy(world); + scene.add(mesh); + + const shadow = makeShadowBlob(0.55); + scene.add(shadow); + + const fleeAngle = Math.random() * Math.PI * 2; + fallingSquirrels.push({ + mesh, + shadow, + velocity: new THREE.Vector3( + (Math.random() - 0.5) * 1.2 + angVel.z * 0.05, + 0.6 + Math.random() * 0.4, + (Math.random() - 0.5) * 1.2 + ), + fleeDir: new THREE.Vector3(Math.sin(fleeAngle), 0, Math.cos(fleeAngle)), + state: 'falling', + timer: 0, + }); +} + function updateBasketCounter(){ basketCountEl.textContent = `${basketCount} / ${BASKET_FULL}`; } @@ -300,6 +410,8 @@ const angVel = { x: 0, z: 0 }; const SPRING_K = 42, SPRING_C = 7; let shakeEnergy = 0; let lastDetachAt = -10; +let lastLeafAt = -10; +let lastSquirrelAt = -999; function toNdc(clientX, clientY){ const rect = renderer.domElement.getBoundingClientRect(); @@ -470,6 +582,21 @@ function tick(){ } } + if (shakeEnergy > 0.35 && now - lastLeafAt > 0.05){ + const chance = 1 - Math.exp(-shakeEnergy * dt * 1.6); + if (Math.random() < chance){ + knockOffLeaf(); + lastLeafAt = now; + } + } + + if (shakeEnergy > 1.1 && fallingSquirrels.length === 0 && now - lastSquirrelAt > SQUIRREL_COOLDOWN){ + if (Math.random() < shakeEnergy * dt * 0.03){ + knockOffSquirrel(); + lastSquirrelAt = now; + } + } + for (let i = fallingApples.length - 1; i >= 0; i--){ const apple = fallingApples[i]; if (apple.state === 'falling'){ @@ -522,6 +649,73 @@ function tick(){ } } + for (let i = fallingLeaves.length - 1; i >= 0; i--){ + const leaf = fallingLeaves[i]; + if (leaf.state === 'falling'){ + leaf.timer += dt; + leaf.velocity.y += GRAVITY * 0.28 * dt; + leaf.mesh.position.x += leaf.velocity.x * dt + Math.sin(leaf.timer * 4 + leaf.driftPhase) * 0.4 * dt; + leaf.mesh.position.z += leaf.velocity.z * dt + Math.cos(leaf.timer * 3.3 + leaf.driftPhase) * 0.4 * dt; + leaf.mesh.position.y += leaf.velocity.y * dt; + leaf.mesh.rotation.x += leaf.spin.x * dt; + leaf.mesh.rotation.y += leaf.spin.y * dt; + leaf.mesh.rotation.z += leaf.spin.z * dt; + + if (leaf.mesh.position.y <= 0.01){ + leaf.mesh.position.y = 0.01; + leaf.state = 'resting'; + leaf.timer = 0; + } + } else if (leaf.state === 'resting'){ + leaf.timer += dt; + if (leaf.timer > LEAF_LINGER){ + leaf.state = 'vanishing'; + leaf.timer = 0; + } + } else if (leaf.state === 'vanishing'){ + leaf.timer += dt; + const t = Math.min(leaf.timer / 0.4, 1); + leaf.mesh.scale.setScalar(1 - t); + if (t >= 1){ + scene.remove(leaf.mesh); + fallingLeaves.splice(i, 1); + } + } + } + + for (let i = fallingSquirrels.length - 1; i >= 0; i--){ + const sq = fallingSquirrels[i]; + if (sq.state === 'falling'){ + sq.velocity.y += GRAVITY * dt; + sq.mesh.position.addScaledVector(sq.velocity, dt); + sq.mesh.rotation.x += sq.velocity.z * dt; + sq.mesh.rotation.z -= sq.velocity.x * dt; + + if (sq.mesh.position.y <= GROUND_Y){ + sq.mesh.position.y = GROUND_Y; + sq.mesh.rotation.set(0, Math.atan2(sq.fleeDir.x, sq.fleeDir.z), 0); + sq.state = 'fleeing'; + sq.timer = 0; + } + sq.shadow.position.set(sq.mesh.position.x, 0.01, sq.mesh.position.z); + const h = Math.max(sq.mesh.position.y - GROUND_Y, 0); + sq.shadow.material.opacity = THREE.MathUtils.clamp(1 - h * 0.6, 0.15, 1); + } else if (sq.state === 'fleeing'){ + sq.timer += dt; + const speed = 3.4; + sq.mesh.position.addScaledVector(sq.fleeDir, speed * dt); + sq.mesh.position.y = GROUND_Y + Math.abs(Math.sin(sq.timer * 14)) * 0.05; + sq.shadow.position.set(sq.mesh.position.x, 0.01, sq.mesh.position.z); + + const distFromCenter = Math.hypot(sq.mesh.position.x, sq.mesh.position.z); + if (distFromCenter > 6.2 || sq.timer > 3){ + scene.remove(sq.mesh); + scene.remove(sq.shadow); + fallingSquirrels.splice(i, 1); + } + } + } + for (const slot of treeSlots){ if (slot.state === 'empty'){ slot.timer += dt;