import * as THREE from 'three'; import type { Game } from './Game'; import { Enemy } from './Enemy'; import { playSound } from './SoundManager'; const JUMP_DISTANCE = 6.5; const JUMP_HEIGHT = 2.6; const JUMP_DURATION = 0.65; const CROUCH_TIME = 0.35; const LAND_TIME = 0.3; const PAUSE_TIME = 0.6; type FrogState = 'crouch' | 'leap' | 'land' | 'pause'; export class Frog extends Enemy { private model: THREE.Group; private bodyMesh: THREE.Mesh; private eyeL: THREE.Mesh; private eyeR: THREE.Mesh; private legs: THREE.Mesh[] = []; private shadow: THREE.Mesh; private shadowMat: THREE.MeshBasicMaterial; private whiteMat: THREE.MeshToonMaterial; private state: FrogState = 'pause'; private stateTimer = 0.4; private jumpFrom = new THREE.Vector3(); private jumpTo = new THREE.Vector3(); private jumpProgress = 0; private airborne = false; private hopTime = 0; constructor() { super(); this.health = 35; this.xp = 12; this.model = new THREE.Group(); this.fadeOutModel = this.model; this.body.add(this.model); const greenMat = new THREE.MeshToonMaterial({ color: 0x55cc33 }); this.whiteMat = new THREE.MeshToonMaterial({ color: 0xf0fff0 }); // Squat body this.bodyMesh = new THREE.Mesh(new THREE.SphereGeometry(0.55, 20, 16), greenMat); this.bodyMesh.scale.set(1, 0.72, 1.1); this.bodyMesh.position.y = 0.42; this.bodyMesh.castShadow = true; this.registerFadeMesh(this.bodyMesh); this.model.add(this.bodyMesh); // Belly const belly = new THREE.Mesh(new THREE.SphereGeometry(0.4, 16, 12), this.whiteMat); belly.scale.set(0.85, 0.5, 0.85); belly.position.set(0, 0.3, 0.42); this.registerFadeMesh(belly); this.model.add(belly); // Bulging eyes (face toward the player: +Z) const eyeGeom = new THREE.SphereGeometry(0.17, 14, 12); const pupilGeom = new THREE.SphereGeometry(0.08, 10, 8); const pupilMat = new THREE.MeshToonMaterial({ color: 0x111122 }); this.eyeL = new THREE.Mesh(eyeGeom, this.whiteMat); this.eyeR = new THREE.Mesh(eyeGeom, this.whiteMat); this.eyeL.position.set(-0.24, 0.72, 0.28); this.eyeR.position.set(0.24, 0.72, 0.28); this.eyeL.castShadow = true; this.eyeR.castShadow = true; this.registerFadeMesh(this.eyeL); this.registerFadeMesh(this.eyeR); this.model.add(this.eyeL, this.eyeR); for (const eye of [this.eyeL, this.eyeR]) { const pupil = new THREE.Mesh(pupilGeom, pupilMat); pupil.position.set(0, 0.02, 0.12); this.registerFadeMesh(pupil); eye.add(pupil); } // Folded back legs const legGeom = new THREE.SphereGeometry(0.16, 12, 10); for (const side of [-1, 1]) { const thigh = new THREE.Mesh(legGeom, greenMat); thigh.scale.set(0.8, 1, 1.2); thigh.position.set(side * 0.42, 0.25, -0.35); thigh.rotation.y = side * 0.5; thigh.castShadow = true; this.registerFadeMesh(thigh); this.model.add(thigh); this.legs.push(thigh); } // Blob shadow on the ground this.shadowMat = new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.25, }); this.shadow = new THREE.Mesh(new THREE.CircleGeometry(0.5, 20), this.shadowMat); this.shadow.rotation.x = -Math.PI / 2; this.shadow.renderOrder = 998; this.body.add(this.shadow); } playAnim() { // No rigged animations, visuals are procedural } updateAnimation(dt: number) { this.updateFadeDeath(dt, { duration: 0.8, sink: 0.6 }); } update(dt: number, game: Game) { this.hopTime += dt; // Knocked back: fall to the ground and slide away from the player if (this.knockback > 0) { this.knockback -= dt; this.airborne = false; this.state = 'pause'; this.stateTimer = 0.3; const away = new THREE.Vector3() .subVectors(this.body.position, game.player.body.position); away.y = 0; away.normalize().multiplyScalar(this.speed); this.body.position.x += away.x * dt; this.body.position.z += away.z * dt; this.body.position.y = 0.1; } else { switch (this.state) { case 'pause': this.body.position.y = 0.1; this.stateTimer -= dt; if (this.stateTimer <= 0) { this.state = 'crouch'; this.stateTimer = CROUCH_TIME; } break; case 'crouch': { this.body.position.y = 0.1; const target = game.player.body.position.clone(); const toPlayer = target.sub(this.body.position); toPlayer.y = 0; if (toPlayer.length() > JUMP_DISTANCE) { toPlayer.normalize().multiplyScalar(JUMP_DISTANCE); } this.jumpFrom.copy(this.body.position); this.jumpTo.set( this.body.position.x + toPlayer.x, 0.1, this.body.position.z + toPlayer.z ); this.body.lookAt(this.jumpTo.x, this.body.position.y, this.jumpTo.z); this.stateTimer -= dt; if (this.stateTimer <= 0) { this.state = 'leap'; this.jumpProgress = 0; this.airborne = true; } break; } case 'leap': { this.jumpProgress += dt / JUMP_DURATION; const t = Math.min(this.jumpProgress, 1); this.body.position.x = THREE.MathUtils.lerp(this.jumpFrom.x, this.jumpTo.x, t); this.body.position.z = THREE.MathUtils.lerp(this.jumpFrom.z, this.jumpTo.z, t); this.body.position.y = 0.1 + Math.sin(t * Math.PI) * JUMP_HEIGHT; if (this.jumpProgress >= 1) { this.airborne = false; this.state = 'land'; this.stateTimer = LAND_TIME; this.spawnDust(game.scene); } break; } case 'land': this.body.position.y = 0.1; this.stateTimer -= dt; if (this.stateTimer <= 0) { this.state = 'pause'; this.stateTimer = PAUSE_TIME + Math.random() * 0.5; } break; } } // Squash & stretch animation let sy = 1; let sxz = 1; if (this.state === 'crouch') { sy = 0.62; sxz = 1.25; } else if (this.state === 'leap') { sy = 1.3; sxz = 0.8; } else if (this.state === 'land') { sy = 0.7; sxz = 1.25; } this.model.scale.set(sxz, sy, sxz); this.eyeL.position.y = 0.72 * sy; this.eyeR.position.y = 0.72 * sy; // Leg twitch for (let i = 0; i < this.legs.length; i++) { const side = i === 0 ? -1 : 1; this.legs[i].rotation.z = side * Math.sin(this.hopTime * 8) * 0.1; } // Shadow stays on the ground this.shadow.position.y = 0.06 - this.body.position.y; const shadowScale = 1 - (this.body.position.y - 0.1) / (JUMP_HEIGHT * 2); this.shadowMat.opacity = 0.25 * Math.max(0.4, shadowScale); game.clampToArena(this.body.position); } canBeSlashHit(): boolean { return !this.airborne; } protected onDeath(game: Game) { playSound('spawn', 0.4); this.beginFadeDeath(); if (this.body.position.y > 0.15) { this.body.position.y = 0.15; } void game; } private spawnDust(scene: THREE.Scene) { const count = 10; const positions = new Float32Array(count * 3); const velocities: THREE.Vector3[] = []; const lives = new Float32Array(count).fill(0.4); for (let i = 0; i < count; i++) { positions[i * 3] = this.body.position.x; positions[i * 3 + 1] = 0.15; positions[i * 3 + 2] = this.body.position.z; const angle = Math.random() * Math.PI * 2; velocities.push(new THREE.Vector3( Math.cos(angle) * 1.2, Math.random() * 1.5, Math.sin(angle) * 1.2 )); } const geom = new THREE.BufferGeometry(); geom.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const mat = new THREE.PointsMaterial({ color: 0xaa9988, size: 0.15, transparent: true, depthWrite: false, }); const points = new THREE.Points(geom, mat); points.renderOrder = 999; scene.add(points); const startTime = performance.now(); const update = () => { const elapsed = (performance.now() - startTime) / 1000; if (elapsed > 0.5) { points.removeFromParent(); geom.dispose(); mat.dispose(); return; } const arr = geom.getAttribute('position') as THREE.BufferAttribute; for (let i = 0; i < count; i++) { lives[i] -= 0.016; if (lives[i] <= 0) continue; arr.array[i * 3] += velocities[i].x * 0.016; arr.array[i * 3 + 1] += velocities[i].y * 0.016; arr.array[i * 3 + 2] += velocities[i].z * 0.016; } arr.needsUpdate = true; mat.opacity = Math.max(0, 1 - elapsed / 0.5); requestAnimationFrame(update); }; update(); } }