Add skill system, level-ups, and new enemy types

This commit is contained in:
2026-08-17 16:38:58 +02:00
parent 4751d428a7
commit 6f207ba1a0
23 changed files with 2192 additions and 83 deletions
+285
View File
@@ -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();
}
}