166 lines
5.1 KiB
TypeScript
166 lines
5.1 KiB
TypeScript
import * as THREE from 'three';
|
|
import type { Game } from './Game';
|
|
import { Enemy } from './Enemy';
|
|
|
|
export class Crab extends Enemy {
|
|
private model: THREE.Group;
|
|
private clawL: THREE.Mesh;
|
|
private clawR: THREE.Mesh;
|
|
private time = Math.random() * Math.PI * 2;
|
|
private side = Math.random() < 0.5 ? 1 : -1;
|
|
|
|
constructor() {
|
|
super();
|
|
this.health = 90;
|
|
this.speed = 4.5;
|
|
this.xp = 20;
|
|
this.contactRadius = 2.2;
|
|
|
|
this.model = new THREE.Group();
|
|
this.fadeOutModel = this.model;
|
|
this.body.add(this.model);
|
|
|
|
const shellMat = new THREE.MeshToonMaterial({ color: 0xdd5533 });
|
|
const darkMat = new THREE.MeshToonMaterial({ color: 0x993322 });
|
|
|
|
// Shell body
|
|
const shell = new THREE.Mesh(new THREE.SphereGeometry(0.55, 18, 14), shellMat);
|
|
shell.scale.set(1, 0.55, 0.85);
|
|
shell.position.y = 0.35;
|
|
shell.castShadow = true;
|
|
this.registerFadeMesh(shell);
|
|
this.model.add(shell);
|
|
|
|
// Shell spots
|
|
for (const [dx, dz] of [[0, -0.15], [0.25, 0.1], [-0.25, 0.1]] as const) {
|
|
const spot = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 8), darkMat);
|
|
spot.scale.set(1, 0.4, 1);
|
|
spot.position.set(dx, 0.62, dz);
|
|
this.registerFadeMesh(spot);
|
|
this.model.add(spot);
|
|
}
|
|
|
|
// Eye stalks (on the armored front side, +Z)
|
|
const stalkMat = new THREE.MeshToonMaterial({ color: 0xdd5533 });
|
|
const eyeMat = new THREE.MeshToonMaterial({ color: 0x111122 });
|
|
for (const side of [-1, 1]) {
|
|
const stalk = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.22, 6), stalkMat);
|
|
stalk.position.set(side * 0.22, 0.55, 0.42);
|
|
this.registerFadeMesh(stalk);
|
|
this.model.add(stalk);
|
|
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.09, 10, 8), eyeMat);
|
|
eye.position.set(side * 0.22, 0.68, 0.42);
|
|
this.registerFadeMesh(eye);
|
|
this.model.add(eye);
|
|
}
|
|
|
|
// Legs
|
|
const legGeom = new THREE.CapsuleGeometry(0.05, 0.25, 4, 8);
|
|
for (const side of [-1, 1]) {
|
|
for (const legZ of [-0.3, 0.3]) {
|
|
const leg = new THREE.Mesh(legGeom, shellMat);
|
|
leg.position.set(side * 0.45, 0.15, legZ);
|
|
leg.rotation.z = side * 0.6;
|
|
this.registerFadeMesh(leg);
|
|
this.model.add(leg);
|
|
}
|
|
}
|
|
|
|
// Big claws (front, open/close)
|
|
const clawGeom = new THREE.SphereGeometry(0.22, 12, 10);
|
|
this.clawL = new THREE.Mesh(clawGeom, shellMat);
|
|
this.clawR = new THREE.Mesh(clawGeom, shellMat);
|
|
this.clawL.position.set(-0.45, 0.45, 0.5);
|
|
this.clawR.position.set(0.45, 0.45, 0.5);
|
|
this.clawL.scale.set(1.2, 0.7, 1.4);
|
|
this.clawR.scale.set(1.2, 0.7, 1.4);
|
|
this.clawL.castShadow = true;
|
|
this.clawR.castShadow = true;
|
|
this.registerFadeMesh(this.clawL);
|
|
this.registerFadeMesh(this.clawR);
|
|
this.model.add(this.clawL, this.clawR);
|
|
}
|
|
|
|
playAnim() {
|
|
// Procedural visuals only
|
|
}
|
|
|
|
updateAnimation(dt: number) {
|
|
this.updateFadeDeath(dt, { duration: 0.8, sink: 0.4 });
|
|
}
|
|
|
|
update(dt: number, game: Game) {
|
|
this.time += dt;
|
|
|
|
// Claw pinch
|
|
const pinch = 1 + Math.sin(this.time * 4) * 0.35;
|
|
this.clawL.scale.set(1.2, 0.7 * pinch, 1.4);
|
|
this.clawR.scale.set(1.2, 0.7 * pinch, 1.4);
|
|
|
|
const toPlayer = new THREE.Vector3()
|
|
.subVectors(game.player.body.position, this.body.position);
|
|
toPlayer.y = 0;
|
|
const dist = toPlayer.length();
|
|
|
|
// Face the player
|
|
if (dist > 0.1) {
|
|
this.body.lookAt(
|
|
this.body.position.x + toPlayer.x,
|
|
this.body.position.y,
|
|
this.body.position.z + toPlayer.z
|
|
);
|
|
}
|
|
|
|
// Strafe sideways around the player
|
|
if (dist > 3) {
|
|
const perp = new THREE.Vector3(-toPlayer.z, 0, toPlayer.x).normalize();
|
|
if (this.knockback > 0) {
|
|
this.knockback -= dt;
|
|
const away = toPlayer.clone().normalize().multiplyScalar(-this.speed);
|
|
this.body.position.x += away.x * dt;
|
|
this.body.position.z += away.z * dt;
|
|
} else {
|
|
// Flip side occasionally to circle
|
|
if (Math.random() < dt * 0.3) this.side *= -1;
|
|
this.body.position.x += perp.x * this.side * this.speed * dt;
|
|
this.body.position.z += perp.z * this.side * this.speed * dt;
|
|
}
|
|
game.clampToArena(this.body.position);
|
|
} else if (dist > 0.1) {
|
|
const dir = toPlayer.normalize();
|
|
if (this.knockback > 0) {
|
|
this.knockback -= dt;
|
|
this.body.position.x -= dir.x * this.speed * dt;
|
|
this.body.position.z -= dir.z * this.speed * dt;
|
|
}
|
|
}
|
|
}
|
|
|
|
takeDamage(
|
|
damage: number,
|
|
game: Game,
|
|
withKnockback = true,
|
|
sourcePos?: THREE.Vector3
|
|
) {
|
|
// Armored from the front: reduce damage coming from the facing side
|
|
if (sourcePos) {
|
|
const front = new THREE.Vector3(0, 0, 1)
|
|
.applyQuaternion(this.body.quaternion);
|
|
front.y = 0;
|
|
front.normalize();
|
|
const incoming = new THREE.Vector3()
|
|
.subVectors(sourcePos, this.body.position);
|
|
incoming.y = 0;
|
|
incoming.normalize();
|
|
if (front.dot(incoming) > 0.3) {
|
|
damage *= 0.4;
|
|
}
|
|
}
|
|
super.takeDamage(damage, game, withKnockback, sourcePos);
|
|
}
|
|
|
|
protected onDeath(_game: Game) {
|
|
this.beginFadeDeath();
|
|
}
|
|
}
|