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 = 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'); } }