Add skill system, level-ups, and new enemy types
This commit is contained in:
+285
@@ -0,0 +1,285 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user