376 lines
12 KiB
TypeScript
376 lines
12 KiB
TypeScript
import * as THREE from 'three';
|
|
import { Sword } from './Sword';
|
|
import { Staff } from './Staff';
|
|
import { ChainLightning } from './ChainLightning';
|
|
import { loadGLB } from './MeshLoader';
|
|
import type { Game } from './Game';
|
|
import type { SkillSystem } from './Skills';
|
|
|
|
export interface PlayerStats {
|
|
maxHealth: number;
|
|
regen: number;
|
|
lifesteal: number;
|
|
shield: number;
|
|
shieldRechargeDelay: number;
|
|
moveSpeed: number;
|
|
swordDamage: number;
|
|
swordRange: number;
|
|
thornDamage: number;
|
|
}
|
|
|
|
export class Player {
|
|
body: THREE.Group;
|
|
leftHandWeapon: Staff;
|
|
rightHandWeapon: Sword;
|
|
skillLightning: ChainLightning;
|
|
velocity = new THREE.Vector3();
|
|
health = 100;
|
|
game!: Game;
|
|
jumpVelocity = 0;
|
|
onGround = true;
|
|
shieldHp = 0;
|
|
shieldTimer = 0;
|
|
slowTimer = 0;
|
|
slowFactor = 0.6;
|
|
pullTime = 0;
|
|
pullDir = new THREE.Vector3();
|
|
stunTime = 0;
|
|
rootTime = 0;
|
|
rootCooldown = 0;
|
|
stats: PlayerStats = {
|
|
maxHealth: 100,
|
|
regen: 0,
|
|
lifesteal: 0,
|
|
shield: 0,
|
|
shieldRechargeDelay: 8,
|
|
moveSpeed: 7.5,
|
|
swordDamage: 50,
|
|
swordRange: 2.2,
|
|
thornDamage: 0,
|
|
};
|
|
mixer!: THREE.AnimationMixer;
|
|
private clips: Map<string, THREE.AnimationAction> = new Map();
|
|
private currentAnim = '';
|
|
private backShield: THREE.Group | null = null;
|
|
private bodyMaterials: THREE.MeshToonMaterial[] = [];
|
|
private hurtCooldown = 0;
|
|
private flashTime = 0;
|
|
private stunStars: THREE.Group;
|
|
private static readonly BASE_COLOR = 0x44aa44;
|
|
private static readonly HURT_COLOR = 0xff5555;
|
|
private static readonly SLOW_COLOR = 0x9944cc;
|
|
private static readonly HURT_FLASH_TIME = 0.3;
|
|
|
|
constructor() {
|
|
this.body = new THREE.Group();
|
|
|
|
// Stun-Sterne: kreisen um den Kopf, solange der Spieler gestunnt ist
|
|
this.stunStars = new THREE.Group();
|
|
const starGeom = new THREE.OctahedronGeometry(0.13);
|
|
const starMat = new THREE.MeshBasicMaterial({ color: 0xffdd33 });
|
|
for (let i = 0; i < 3; i++) {
|
|
const a = (i / 3) * Math.PI * 2;
|
|
const star = new THREE.Mesh(starGeom, starMat);
|
|
star.position.set(Math.cos(a) * 0.55, 1.35, Math.sin(a) * 0.55);
|
|
this.stunStars.add(star);
|
|
}
|
|
this.stunStars.visible = false;
|
|
this.body.add(this.stunStars);
|
|
|
|
// Helmet
|
|
const helmetGeom = new THREE.SphereGeometry(0.45, 8, 8, 0, Math.PI * 2, 0, Math.PI / 2);
|
|
const helmetMat = new THREE.MeshToonMaterial({ color: 0x888888 });
|
|
const helmet = new THREE.Mesh(helmetGeom, helmetMat);
|
|
helmet.position.y = 0.9;
|
|
helmet.castShadow = true;
|
|
this.body.add(helmet);
|
|
|
|
// Try loading the real helmet model (replaces the placeholder sphere)
|
|
loadGLB('helmet').then((gltf) => {
|
|
const realHelmet = gltf.scene;
|
|
const helmetTexture = new THREE.TextureLoader().load('./textures/helmet.png');
|
|
helmetTexture.flipY = false;
|
|
helmetTexture.colorSpace = THREE.SRGBColorSpace;
|
|
realHelmet.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
child.castShadow = true;
|
|
child.material = new THREE.MeshToonMaterial({ map: helmetTexture });
|
|
}
|
|
});
|
|
realHelmet.scale.set(0.35, 0.35, 0.35);
|
|
realHelmet.position.set(0, 0.9, -0.2);
|
|
realHelmet.rotation.set(-0.4, 0, 0);
|
|
this.body.remove(helmet);
|
|
this.body.add(realHelmet);
|
|
}).catch(() => {
|
|
// Keep placeholder helmet
|
|
});
|
|
|
|
// Shadow disc
|
|
const shadowGeom = new THREE.CircleGeometry(0.4, 16);
|
|
const shadowMat = new THREE.MeshBasicMaterial({
|
|
color: 0x000000, transparent: true, opacity: 0.3,
|
|
});
|
|
const shadow = new THREE.Mesh(shadowGeom, shadowMat);
|
|
shadow.rotation.x = -Math.PI / 2;
|
|
shadow.position.y = -0.48;
|
|
shadow.renderOrder = 999;
|
|
this.body.add(shadow);
|
|
|
|
// Shield on the back while the shield skill is charged
|
|
loadGLB('shield').then((gltf) => {
|
|
const shieldModel = gltf.scene;
|
|
const shieldTexture = new THREE.TextureLoader().load('./textures/shield.png');
|
|
shieldTexture.flipY = false;
|
|
shieldTexture.colorSpace = THREE.SRGBColorSpace;
|
|
shieldModel.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
const geo = child.geometry;
|
|
// The converted model has no UVs; project them from the
|
|
// front plane so the shield texture maps onto the face
|
|
if (!geo.attributes.uv) {
|
|
geo.computeBoundingBox();
|
|
const bb = geo.boundingBox!;
|
|
const pos = geo.attributes.position;
|
|
const uv = new Float32Array(pos.count * 2);
|
|
for (let i = 0; i < pos.count; i++) {
|
|
uv[i * 2] = (pos.getX(i) - bb.min.x) / (bb.max.x - bb.min.x);
|
|
uv[i * 2 + 1] = (pos.getY(i) - bb.min.y) / (bb.max.y - bb.min.y);
|
|
}
|
|
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
|
}
|
|
child.castShadow = true;
|
|
child.material = new THREE.MeshToonMaterial({ map: shieldTexture });
|
|
}
|
|
});
|
|
// The geometry is offset from the model origin; recenter it so
|
|
// rotation and scale act on the shield's center
|
|
const box = new THREE.Box3().setFromObject(shieldModel);
|
|
const center = box.getCenter(new THREE.Vector3());
|
|
shieldModel.position.sub(center);
|
|
const shield = new THREE.Group();
|
|
shield.add(shieldModel);
|
|
shield.scale.set(0.25, 0.25, 0.25);
|
|
shield.position.set(0, 0.85, -0.5);
|
|
shield.rotation.set(-0.15, Math.PI, 0);
|
|
shield.visible = this.stats.shield > 0 && this.shieldHp > 0;
|
|
this.backShield = shield;
|
|
this.body.add(shield);
|
|
}).catch(() => {
|
|
// No shield model available
|
|
});
|
|
|
|
// Weapons
|
|
this.leftHandWeapon = new Staff();
|
|
this.rightHandWeapon = new Sword();
|
|
this.skillLightning = new ChainLightning();
|
|
this.leftHandWeapon.mesh.visible = false;
|
|
this.body.add(this.leftHandWeapon.mesh);
|
|
this.body.add(this.rightHandWeapon.mesh);
|
|
}
|
|
|
|
async init() {
|
|
try {
|
|
const gltf = await loadGLB('blob');
|
|
const model = gltf.scene;
|
|
model.scale.set(0.5, 0.5, 0.5);
|
|
model.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
child.castShadow = true;
|
|
child.receiveShadow = true;
|
|
const mat = new THREE.MeshToonMaterial({
|
|
color: Player.BASE_COLOR,
|
|
});
|
|
child.material = mat;
|
|
this.bodyMaterials.push(mat);
|
|
}
|
|
});
|
|
this.body.add(model);
|
|
|
|
if (gltf.animations.length > 0) {
|
|
this.mixer = new THREE.AnimationMixer(model);
|
|
for (const clip of gltf.animations) {
|
|
const action = this.mixer.clipAction(clip);
|
|
this.clips.set(clip.name, action);
|
|
}
|
|
this.playAnimation('stand');
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to load blob model, using fallback', e);
|
|
// Fallback capsule
|
|
const bodyGeom = new THREE.CapsuleGeometry(0.5, 0.5, 8, 16);
|
|
const bodyMat = new THREE.MeshToonMaterial({ color: Player.BASE_COLOR });
|
|
const bodyMesh = new THREE.Mesh(bodyGeom, bodyMat);
|
|
bodyMesh.position.y = 0.75;
|
|
bodyMesh.castShadow = true;
|
|
this.body.add(bodyMesh);
|
|
this.bodyMaterials.push(bodyMat);
|
|
}
|
|
}
|
|
|
|
playAnimation(name: string, loop = true, speed = 1) {
|
|
if (!this.clips.has(name)) return;
|
|
if (this.currentAnim === name) return;
|
|
// Stop the previous animation so it doesn't blend with the new one
|
|
if (this.currentAnim) {
|
|
const prev = this.clips.get(this.currentAnim);
|
|
if (prev) prev.stop();
|
|
}
|
|
this.currentAnim = name;
|
|
const action = this.clips.get(name)!;
|
|
action.reset();
|
|
action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
|
|
action.clampWhenFinished = !loop;
|
|
action.setEffectiveTimeScale(speed);
|
|
action.weight = 1;
|
|
action.play();
|
|
return action;
|
|
}
|
|
|
|
stopAnimation(name: string) {
|
|
const action = this.clips.get(name);
|
|
if (action) action.stop();
|
|
}
|
|
|
|
getCurrentAnimation(): string {
|
|
return this.currentAnim;
|
|
}
|
|
|
|
updateAnimations(dt: number) {
|
|
if (this.mixer) this.mixer.update(dt);
|
|
if (this.hurtCooldown > 0) this.hurtCooldown -= dt;
|
|
if (this.slowTimer > 0) this.slowTimer -= dt;
|
|
if (this.stunTime > 0) this.stunTime -= dt;
|
|
if (this.rootTime > 0) this.rootTime -= dt;
|
|
if (this.rootCooldown > 0) this.rootCooldown -= dt;
|
|
|
|
// Stun-Sterne animieren
|
|
this.stunStars.visible = this.stunTime > 0;
|
|
if (this.stunStars.visible) {
|
|
this.stunStars.rotation.y += dt * 5;
|
|
for (const star of this.stunStars.children) {
|
|
star.rotation.x += dt * 8;
|
|
star.rotation.y += dt * 6;
|
|
star.position.y = 1.35 + Math.sin(this.stunTime * 10) * 0.08;
|
|
}
|
|
}
|
|
|
|
let color: THREE.Color;
|
|
if (this.flashTime > 0) {
|
|
this.flashTime -= dt;
|
|
const t = Math.max(0, this.flashTime) / Player.HURT_FLASH_TIME;
|
|
color = new THREE.Color(Player.BASE_COLOR).lerp(
|
|
new THREE.Color(Player.HURT_COLOR), t
|
|
);
|
|
} else if (this.slowTimer > 0) {
|
|
color = new THREE.Color(Player.BASE_COLOR).lerp(
|
|
new THREE.Color(Player.SLOW_COLOR), 0.55
|
|
);
|
|
} else {
|
|
color = new THREE.Color(Player.BASE_COLOR);
|
|
}
|
|
for (const mat of this.bodyMaterials) mat.color.copy(color);
|
|
}
|
|
|
|
setGame(game: Game) {
|
|
this.game = game;
|
|
}
|
|
|
|
jump() {
|
|
if (this.onGround) {
|
|
this.jumpVelocity = 8;
|
|
this.onGround = false;
|
|
this.playAnimation('jump', false);
|
|
}
|
|
}
|
|
|
|
heal(amount: number) {
|
|
this.health = Math.min(this.health + amount, this.stats.maxHealth);
|
|
}
|
|
|
|
damage(amount: number, sourcePos?: THREE.Vector3) {
|
|
if (this.shieldHp > 0) {
|
|
const absorbed = Math.min(this.shieldHp, amount);
|
|
this.shieldHp -= absorbed;
|
|
amount -= absorbed;
|
|
this.shieldTimer = this.stats.shieldRechargeDelay;
|
|
}
|
|
this.health -= amount;
|
|
if (amount > 0 && this.hurtCooldown <= 0) {
|
|
this.hurtCooldown = 0.25;
|
|
this.flashTime = Player.HURT_FLASH_TIME;
|
|
if (this.game) {
|
|
this.game.onPlayerHurt(sourcePos ?? this.body.position);
|
|
}
|
|
}
|
|
}
|
|
|
|
updatePassives(dt: number) {
|
|
if (this.stats.regen > 0) {
|
|
this.heal(this.stats.regen * dt);
|
|
}
|
|
if (this.stats.shield > 0) {
|
|
if (this.shieldHp < this.stats.shield) {
|
|
this.shieldTimer -= dt;
|
|
if (this.shieldTimer <= 0) {
|
|
this.shieldHp = this.stats.shield;
|
|
}
|
|
} else {
|
|
this.shieldTimer = 0;
|
|
}
|
|
}
|
|
if (this.backShield) {
|
|
this.backShield.visible = this.stats.shield > 0 && this.shieldHp > 0;
|
|
}
|
|
}
|
|
|
|
applySkillSystem(skills: SkillSystem) {
|
|
const oldMax = this.stats.maxHealth;
|
|
const l = (id: string) => skills.getLevel(id);
|
|
|
|
this.stats = {
|
|
maxHealth: 100 + 25 * l('maxHp'),
|
|
regen: 1.5 * l('regen'),
|
|
lifesteal: 0.05 * l('lifesteal'),
|
|
shield: 25 * l('shield'),
|
|
shieldRechargeDelay: 8,
|
|
moveSpeed: 7.5 + l('speed'),
|
|
swordDamage: 50 + 25 * l('swordDamage'),
|
|
swordRange: 2.2 + 0.6 * l('swordRange'),
|
|
thornDamage: 15 * l('thorns'),
|
|
};
|
|
|
|
if (this.stats.maxHealth > oldMax) {
|
|
this.health = this.stats.maxHealth;
|
|
} else {
|
|
this.health = Math.min(this.health, this.stats.maxHealth);
|
|
}
|
|
if (this.stats.shield > 0 && this.shieldHp <= 0) {
|
|
this.shieldHp = this.stats.shield;
|
|
}
|
|
if (this.backShield) {
|
|
this.backShield.visible = this.stats.shield > 0 && this.shieldHp > 0;
|
|
}
|
|
}
|
|
|
|
die() {
|
|
this.playAnimation('die', false);
|
|
}
|
|
|
|
reset() {
|
|
this.health = 100;
|
|
this.shieldHp = 0;
|
|
this.shieldTimer = 0;
|
|
this.slowTimer = 0;
|
|
this.pullTime = 0;
|
|
this.stunTime = 0;
|
|
this.rootTime = 0;
|
|
this.rootCooldown = 0;
|
|
this.body.position.set(0, 0.5, 0);
|
|
this.velocity.set(0, 0, 0);
|
|
this.jumpVelocity = 0;
|
|
this.onGround = true;
|
|
this.playAnimation('stand');
|
|
}
|
|
}
|