From 8065104d8a32980a4aaeac41d4c38eedeb2c756f Mon Sep 17 00:00:00 2001 From: nico Date: Fri, 21 Aug 2026 11:25:50 +0200 Subject: [PATCH] Rework necromancer, archer, shaman and golem models with procedural animations --- web/src/Archer.ts | 237 ++++++++++++++++++++++++++++++++++------- web/src/Brute.ts | 41 ++++++- web/src/Golem.ts | 141 +++++++++++++++++++++++- web/src/Necromancer.ts | 191 ++++++++++++++++++++++++++------- web/src/Shaman.ts | 185 ++++++++++++++++++++++++++++---- 5 files changed, 692 insertions(+), 103 deletions(-) diff --git a/web/src/Archer.ts b/web/src/Archer.ts index f02fadc..185e742 100644 --- a/web/src/Archer.ts +++ b/web/src/Archer.ts @@ -17,6 +17,14 @@ export class Archer extends Enemy { private boneMat!: THREE.MeshToonMaterial; private darkMat!: THREE.MeshToonMaterial; private bowGroup!: THREE.Group; + private legL!: THREE.Group; + private legR!: THREE.Group; + private walkPhase = 0; + private walkAmp = 0; + private handL!: THREE.Mesh; + private arrowNock!: THREE.Mesh; + private draw = 0; // 0 = Bogen entspannt, 1 = voll ausgezogen + private recoil = 0; // klingt nach dem Abschuss ab private shootTimer = 1 + Math.random(); private telegraphs: { mesh: THREE.Mesh; mat: THREE.MeshBasicMaterial; timer: number }[] = []; private time = Math.random() * Math.PI * 2; @@ -36,53 +44,177 @@ export class Archer extends Enemy { this.boneMat = new THREE.MeshToonMaterial({ color: 0xe8dcc0 }); this.darkMat = new THREE.MeshToonMaterial({ color: 0x8a7a5a }); + const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff4422 }); - // Torso - const torso = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.55, 0.35), this.boneMat); - torso.position.y = 1.0; - torso.castShadow = true; - this.registerFadeMesh(torso); - this.model.add(torso); + // --- Skelett (Blickrichtung +Z) --- - // Beine - for (const side of [-1, 1]) { - const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 0.7, 6), this.boneMat); - leg.position.set(side * 0.15, 0.35, 0); - this.registerFadeMesh(leg); - this.model.add(leg); + // Becken + Gürtel + const pelvis = new THREE.Mesh(new THREE.SphereGeometry(0.16, 10, 8), this.boneMat); + pelvis.scale.set(1.2, 0.7, 0.9); + pelvis.position.y = 0.78; + this.registerFadeMesh(pelvis); + this.model.add(pelvis); + const belt = new THREE.Mesh(new THREE.TorusGeometry(0.17, 0.03, 6, 12), this.darkMat); + belt.rotation.x = Math.PI / 2; + belt.position.y = 0.82; + this.registerFadeMesh(belt); + this.model.add(belt); + + // Wirbelsäule + Rippenbögen + const spine = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.045, 0.55, 6), this.boneMat); + spine.position.y = 1.08; + this.registerFadeMesh(spine); + this.model.add(spine); + for (const [y, r] of [[1.02, 0.2], [1.14, 0.23], [1.26, 0.19]] as const) { + const rib = new THREE.Mesh(new THREE.TorusGeometry(r, 0.025, 6, 12), this.boneMat); + rib.rotation.x = Math.PI / 2; + rib.scale.set(1, 1, 0.8); + rib.position.y = y; + this.registerFadeMesh(rib); + this.model.add(rib); } - // Kopf (Schädel) - const head = new THREE.Mesh(new THREE.SphereGeometry(0.22, 10, 8), this.boneMat); - head.position.y = 1.5; - this.registerFadeMesh(head); - this.model.add(head); - - // Augen (+Z, rot) - const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff4422 }); + // Schädel mit Kiefer, Augenhöhlen und roten Augen (+Z) + const skull = new THREE.Mesh(new THREE.SphereGeometry(0.22, 12, 10), this.boneMat); + skull.scale.set(0.95, 1.05, 1); + skull.position.y = 1.52; + skull.castShadow = true; + this.registerFadeMesh(skull); + this.model.add(skull); + const jaw = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.09, 0.16), this.boneMat); + jaw.position.set(0, 1.38, 0.1); + this.registerFadeMesh(jaw); + this.model.add(jaw); for (const side of [-1, 1]) { - const eye = new THREE.Mesh(new THREE.SphereGeometry(0.05, 6, 5), eyeMat); - eye.position.set(side * 0.1, 1.53, 0.2); + const socket = new THREE.Mesh(new THREE.SphereGeometry(0.055, 8, 6), this.darkMat); + socket.position.set(side * 0.09, 1.55, 0.17); + this.registerFadeMesh(socket); + this.model.add(socket); + const eye = new THREE.Mesh(new THREE.SphereGeometry(0.035, 6, 5), eyeMat); + eye.position.set(side * 0.09, 1.55, 0.2); this.model.add(eye); } - // Bogen (hängt an Gruppe, wird beim Schuss angehoben) + // Schultern + Arme (rechts hält den Bogen, links zieht die Sehne) + for (const side of [-1, 1]) { + const shoulder = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), this.boneMat); + shoulder.position.set(side * 0.28, 1.34, 0); + this.registerFadeMesh(shoulder); + this.model.add(shoulder); + } + const upperArmR = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.05, 0.32, 6), this.boneMat); + upperArmR.position.set(0.34, 1.26, 0.08); + upperArmR.rotation.z = -1.0; + this.registerFadeMesh(upperArmR); + this.model.add(upperArmR); + const foreArmR = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.04, 0.3, 6), this.boneMat); + foreArmR.position.set(0.4, 1.18, 0.25); + foreArmR.rotation.x = -1.2; + this.registerFadeMesh(foreArmR); + this.model.add(foreArmR); + const handR = new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 6), this.boneMat); + handR.position.set(0.42, 1.15, 0.4); + this.registerFadeMesh(handR); + this.model.add(handR); + + const upperArmL = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.05, 0.32, 6), this.boneMat); + upperArmL.position.set(-0.34, 1.24, 0.05); + upperArmL.rotation.z = 1.1; + this.registerFadeMesh(upperArmL); + this.model.add(upperArmL); + const foreArmL = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.04, 0.3, 6), this.boneMat); + foreArmL.position.set(-0.24, 1.25, 0.15); + foreArmL.rotation.x = -1.6; + foreArmL.rotation.z = 0.6; + this.registerFadeMesh(foreArmL); + this.model.add(foreArmL); + this.handL = new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 6), this.boneMat); + this.handL.position.set(-0.1, 1.3, 0.28); + this.registerFadeMesh(this.handL); + this.model.add(this.handL); + + // Beine mit Kniegelenken und Füßen (Gruppen mit Hüft-Pivot für Laufanimation) + for (const side of [-1, 1]) { + const leg = new THREE.Group(); + leg.position.set(side * 0.15, 0.72, 0); + this.model.add(leg); + const thigh = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.07, 0.4, 6), this.boneMat); + thigh.position.y = -0.17; + this.registerFadeMesh(thigh); + leg.add(thigh); + const knee = new THREE.Mesh(new THREE.SphereGeometry(0.06, 8, 6), this.boneMat); + knee.position.y = -0.37; + this.registerFadeMesh(knee); + leg.add(knee); + const shin = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 0.35, 6), this.boneMat); + shin.position.y = -0.54; + this.registerFadeMesh(shin); + leg.add(shin); + const foot = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.06, 0.22), this.boneMat); + foot.position.set(0, -0.68, 0.06); + this.registerFadeMesh(foot); + leg.add(foot); + if (side < 0) { + this.legL = leg; + } else { + this.legR = leg; + } + } + + // Zerfetzter Umhang auf dem Rücken + const cape = new THREE.Mesh(new THREE.ConeGeometry(0.32, 0.75, 8, 1, true), this.darkMat); + cape.position.set(0, 1.0, -0.2); + cape.scale.set(1.1, 1, 0.5); + this.registerFadeMesh(cape); + this.model.add(cape); + for (const [x, z] of [[-0.15, -0.28], [0, -0.32], [0.15, -0.28]] as const) { + const tatter = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.25, 4), this.darkMat); + tatter.rotation.x = Math.PI; + tatter.position.set(x, 0.62, z); + this.registerFadeMesh(tatter); + this.model.add(tatter); + } + + // Köcher mit Pfeilen am Rücken + const quiver = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.11, 0.5, 8), this.darkMat); + quiver.position.set(0.12, 1.15, -0.28); + quiver.rotation.x = 0.5; + this.registerFadeMesh(quiver); + this.model.add(quiver); + for (const dx of [-0.03, 0.02, 0.06]) { + const qArrow = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.28, 5), this.boneMat); + qArrow.position.set(0.12 + dx, 1.42, -0.4); + qArrow.rotation.x = 0.5; + this.registerFadeMesh(qArrow); + this.model.add(qArrow); + } + + // Bogen (hängt an Gruppe, wird beim Schuss angehoben): + // Halbkreis-Bogen mit Sehne und eingelegtem Pfeil this.bowGroup = new THREE.Group(); - this.bowGroup.position.set(0.4, 1.05, 0.15); + this.bowGroup.position.set(0.42, 1.15, 0.45); this.model.add(this.bowGroup); - const bow = new THREE.Mesh(new THREE.TorusGeometry(0.28, 0.03, 6, 14, Math.PI * 1.6), this.darkMat); - bow.rotation.y = -Math.PI / 2; - bow.rotation.z = -0.8; + const bowOrient = new THREE.Group(); + bowOrient.rotation.y = -Math.PI / 2; // Bogenebene in Blickrichtung + this.bowGroup.add(bowOrient); + const bow = new THREE.Mesh(new THREE.TorusGeometry(0.4, 0.028, 6, 16, Math.PI), this.darkMat); + bow.rotation.z = -Math.PI / 2; // Bogenbauch zeigt nach +Z this.registerFadeMesh(bow); - this.bowGroup.add(bow); - const string = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.5, 0.02), this.darkMat); - string.position.set(0.02, 0, 0); + bowOrient.add(bow); + const string = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.8, 5), this.darkMat); this.registerFadeMesh(string); - this.bowGroup.add(string); - const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.06, 0.5, 6), this.boneMat); - arm.position.set(0.02, -0.1, 0); - this.registerFadeMesh(arm); - this.bowGroup.add(arm); + bowOrient.add(string); + // Pfeil sitzt auf Sehne und Bogen: Schaft entlang lokaler X-Achse + // (zeigt nach Welt-+Z), Nocke an der Sehne + this.arrowNock = new THREE.Mesh(new THREE.CylinderGeometry(0.014, 0.014, 0.75, 5), this.boneMat); + this.arrowNock.rotation.z = -Math.PI / 2; + this.arrowNock.position.x = 0.28; + this.registerFadeMesh(this.arrowNock); + bowOrient.add(this.arrowNock); + const arrowHead = new THREE.Mesh(new THREE.ConeGeometry(0.035, 0.1, 6), this.darkMat); + arrowHead.position.y = 0.42; + this.registerFadeMesh(arrowHead); + this.arrowNock.add(arrowHead); } playAnim() { @@ -112,6 +244,7 @@ export class Archer extends Enemy { toPlayer.y = 0; const dist = toPlayer.length(); + let moved = false; if (dist > 0.1) { const dir = toPlayer.normalize(); this.body.lookAt( @@ -138,9 +271,20 @@ export class Archer extends Enemy { this.body.position.x += move.x * dt; this.body.position.z += move.z * dt; game.clampToArena(this.body.position); + moved = true; } } + // Laufanimation: Beine gegengleich schwingen, Amplitude weich ein-/ausblenden + this.walkAmp += ((moved ? 0.55 : 0) - this.walkAmp) * Math.min(1, dt * 8); + if (this.walkAmp > 0.01) { + this.walkPhase += dt * 11; + } + const swing = Math.sin(this.walkPhase) * this.walkAmp; + this.legL.rotation.x = swing; + this.legR.rotation.x = -swing; + this.model.position.y = Math.abs(Math.cos(this.walkPhase)) * this.walkAmp * 0.12; + // Schuss mit Telegraf this.shootTimer -= dt; if (dist < SHOOT_RANGE && this.shootTimer <= 0 && game.player.health > 0) { @@ -163,8 +307,23 @@ export class Archer extends Enemy { } } - // Bogen-Schwung - this.bowGroup.rotation.z = Math.sin(this.time * 2) * 0.1; + // Schießanimation: während des Telegrafs Bogen heben und Sehne spannen, + // beim Abschuss Recoil-Kick und schnelles Entspannen + const aiming = this.telegraphs.length > 0; + const drawRate = aiming ? 6 : this.recoil > 0 ? 25 : 10; + this.draw += ((aiming ? 1 : 0) - this.draw) * Math.min(1, dt * drawRate); + this.recoil = Math.max(0, this.recoil - dt); + const aim = this.draw; + this.bowGroup.position.set( + 0.42 - aim * 0.17, + 1.15 + aim * 0.2, + 0.45 + aim * 0.1 + ); + this.bowGroup.rotation.x = -aim * 0.15; + this.bowGroup.rotation.z = + Math.sin(this.time * 2) * 0.1 * (1 - aim) - this.recoil * 2; + this.handL.position.z = 0.28 - aim * 0.16; + this.arrowNock.position.x = 0.28 - aim * 0.14; } private startShot(game: Game) { @@ -192,8 +351,8 @@ export class Archer extends Enemy { const target = targetPos.clone(); target.y += 0.6; game.spawnEnemyProjectile(from, target, ARROW_DAMAGE); - // Bogen anheben als Recoil - this.bowGroup.rotation.z = -0.6; + // Recoil-Kick, klingt in update() ab + this.recoil = 0.25; } override takeDamage( diff --git a/web/src/Brute.ts b/web/src/Brute.ts index 3f6812b..7a0a64a 100644 --- a/web/src/Brute.ts +++ b/web/src/Brute.ts @@ -4,7 +4,12 @@ import { Enemy } from './Enemy'; export class Brute extends Enemy { protected model: THREE.Group; + protected legL!: THREE.Group; + protected legR!: THREE.Group; + protected armL: THREE.Group | null = null; + protected armR: THREE.Group | null = null; private stepTime = 0; + private walkAmp = 0; constructor() { super(); @@ -21,7 +26,11 @@ export class Brute extends Enemy { const rockMat = new THREE.MeshToonMaterial({ color: 0x6a6a78 }); const darkMat = new THREE.MeshToonMaterial({ color: 0x3c3c46 }); - // Torso + // Becken + Torso + const pelvis = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.35, 0.55), darkMat); + pelvis.position.y = 0.62; + this.registerFadeMesh(pelvis); + this.model.add(pelvis); const torso = new THREE.Mesh(new THREE.BoxGeometry(1.3, 1.2, 0.95), rockMat); torso.position.y = 0.95; torso.castShadow = true; @@ -61,6 +70,26 @@ export class Brute extends Enemy { this.registerFadeMesh(eye); this.model.add(eye); } + + // Beine (Gruppen mit Hüft-Pivot für die Laufanimation) + for (const side of [-1, 1]) { + const leg = new THREE.Group(); + leg.position.set(side * 0.35, 0.6, 0); + this.model.add(leg); + const thigh = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.18, 0.35, 6), rockMat); + thigh.position.y = -0.17; + this.registerFadeMesh(thigh); + leg.add(thigh); + const foot = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.12, 0.4), darkMat); + foot.position.set(0, -0.5, 0.06); + this.registerFadeMesh(foot); + leg.add(foot); + if (side < 0) { + this.legL = leg; + } else { + this.legR = leg; + } + } } playAnim() { @@ -81,6 +110,7 @@ export class Brute extends Enemy { .subVectors(game.player.body.position, this.body.position); toPlayer.y = 0; const dist = toPlayer.length(); + let moved = false; if (dist > 0.1) { const dir = toPlayer.normalize(); this.body.lookAt( @@ -96,7 +126,16 @@ export class Brute extends Enemy { this.body.position.x += moveDir.x * dt; this.body.position.z += moveDir.z * dt; game.clampToArena(this.body.position); + moved = true; } + + // Laufanimation: Beine gegengleich im Takt der Schritte, Arme leicht mit + this.walkAmp += ((moved ? 0.5 : 0) - this.walkAmp) * Math.min(1, dt * 8); + const swing = Math.sin(this.stepTime * 3) * this.walkAmp; + this.legL.rotation.x = swing; + this.legR.rotation.x = -swing; + if (this.armL) this.armL.rotation.x = -swing * 0.4; + if (this.armR) this.armR.rotation.x = swing * 0.4; } protected onDeath(_game: Game) { diff --git a/web/src/Golem.ts b/web/src/Golem.ts index 321d15b..41c0db6 100644 --- a/web/src/Golem.ts +++ b/web/src/Golem.ts @@ -15,15 +15,152 @@ export class Golem extends Brute { this.contactRadius = 3.4; this.guaranteedDrop = true; + // Brute-Standardmodell verwerfen und als Fels-Golem neu aufbauen + this.model.clear(); + + const rockMat = new THREE.MeshToonMaterial({ color: 0x6a6a78 }); + const darkMat = new THREE.MeshToonMaterial({ color: 0x3c3c46 }); + const mossMat = new THREE.MeshToonMaterial({ color: 0x4a8a4a }); + const eyeMat = new THREE.MeshBasicMaterial({ color: 0xff6622 }); + // Facettierte Fels-Optik: flache Normalen auf der unindizierten Geometrie + const rockGeom = (r: number) => { + const g = new THREE.DodecahedronGeometry(r); + g.computeVertexNormals(); + return g; + }; + + // Becken + Rumpf (facettierte Felsbrocken) + const pelvis = new THREE.Mesh(rockGeom(0.42), darkMat); + pelvis.scale.set(1.2, 0.8, 0.9); + pelvis.position.y = 0.75; + this.registerFadeMesh(pelvis); + this.model.add(pelvis); + const torso = new THREE.Mesh(rockGeom(0.72), rockMat); + torso.scale.set(1.25, 1.05, 0.95); + torso.position.y = 1.4; + torso.castShadow = true; + this.registerFadeMesh(torso); + this.model.add(torso); + + // Glühender Kern in der Brust (+Z) + const core = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.36, 0.08), eyeMat); + core.position.set(0, 1.45, 0.66); + this.model.add(core); + + // Kopf mit Stirnplatte, Augen und Kiefer (+Z) + const head = new THREE.Mesh(rockGeom(0.38), rockMat); + head.position.y = 2.15; + head.castShadow = true; + this.registerFadeMesh(head); + this.model.add(head); + const brow = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.1, 0.12), darkMat); + brow.position.set(0, 2.3, 0.3); + this.registerFadeMesh(brow); + this.model.add(brow); + for (const side of [-1, 1]) { + const eye = new THREE.Mesh(new THREE.BoxGeometry(0.13, 0.09, 0.05), eyeMat); + eye.position.set(side * 0.15, 2.15, 0.33); + this.model.add(eye); + } + const jaw = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.12, 0.2), darkMat); + jaw.position.set(0, 1.93, 0.25); + this.registerFadeMesh(jaw); + this.model.add(jaw); + + // Felsstacheln auf dem Rücken + for (const side of [-1, 1]) { + const spike = new THREE.Mesh(new THREE.ConeGeometry(0.14, 0.45, 5), darkMat); + spike.position.set(side * 0.3, 1.75, -0.6); + spike.rotation.x = 0.6; + this.registerFadeMesh(spike); + this.model.add(spike); + } + + // Moosflecken + const mossSpots: [number, number, number, number][] = [ + [-0.9, 2.15, 0.1, 0.12], + [0.5, 1.95, 0.3, 0.1], + [0.3, 2.4, -0.1, 0.09], + ]; + for (const [x, y, z, r] of mossSpots) { + const moss = new THREE.Mesh(rockGeom(r), mossMat); + moss.position.set(x, y, z); + this.registerFadeMesh(moss); + this.model.add(moss); + } + + // Schultern + gegliederte Arme (Schulter-Pivot für Laufanimation) + for (const side of [-1, 1]) { + const shoulder = new THREE.Mesh(rockGeom(0.48), rockMat); + shoulder.position.set(side * 0.95, 1.75, 0); + shoulder.castShadow = true; + this.registerFadeMesh(shoulder); + this.model.add(shoulder); + + const arm = new THREE.Group(); + arm.position.set(side * 0.95, 1.7, 0); + this.model.add(arm); + const upperArm = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.2, 0.55, 6), rockMat); + upperArm.position.y = -0.3; + this.registerFadeMesh(upperArm); + arm.add(upperArm); + const elbow = new THREE.Mesh(rockGeom(0.18), darkMat); + elbow.position.y = -0.6; + this.registerFadeMesh(elbow); + arm.add(elbow); + const foreArm = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.18, 0.5, 6), darkMat); + foreArm.position.y = -0.85; + this.registerFadeMesh(foreArm); + arm.add(foreArm); + const fist = new THREE.Mesh(rockGeom(0.24), rockMat); + fist.scale.set(1, 0.8, 1.1); + fist.position.y = -1.15; + this.registerFadeMesh(fist); + arm.add(fist); + if (side < 0) { + this.armL = arm; + } else { + this.armR = arm; + } + } + + // Beine (Hüft-Pivot für Laufanimation) + for (const side of [-1, 1]) { + const leg = new THREE.Group(); + leg.position.set(side * 0.4, 0.75, 0); + this.model.add(leg); + const thigh = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.24, 0.4, 6), rockMat); + thigh.position.y = -0.18; + this.registerFadeMesh(thigh); + leg.add(thigh); + const knee = new THREE.Mesh(rockGeom(0.16), darkMat); + knee.position.y = -0.38; + this.registerFadeMesh(knee); + leg.add(knee); + const shin = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.18, 0.35, 6), darkMat); + shin.position.y = -0.52; + this.registerFadeMesh(shin); + leg.add(shin); + const foot = new THREE.Mesh(new THREE.BoxGeometry(0.32, 0.16, 0.5), rockMat); + foot.position.set(0, -0.67, 0.08); + this.registerFadeMesh(foot); + leg.add(foot); + if (side < 0) { + this.legL = leg; + } else { + this.legR = leg; + } + } + this.model.scale.setScalar(2.1); - // Crown + // Krone (Boss-Markierung) const crownMat = new THREE.MeshToonMaterial({ color: 0xffcc33 }); const crown = new THREE.Mesh( new THREE.ConeGeometry(0.3, 0.4, 8, 1, true), crownMat ); - crown.position.y = 2.25; + crown.position.y = 2.55; crown.castShadow = true; this.registerFadeMesh(crown); this.model.add(crown); diff --git a/web/src/Necromancer.ts b/web/src/Necromancer.ts index 21914e9..4020f0c 100644 --- a/web/src/Necromancer.ts +++ b/web/src/Necromancer.ts @@ -90,21 +90,66 @@ export class Necromancer extends Enemy { this.darkMat = new THREE.MeshToonMaterial({ color: 0x2a1445 }); this.glowMat = new THREE.MeshBasicMaterial({ color: 0xbb66ff }); this.eyeMat = new THREE.MeshBasicMaterial({ color: 0xdd88ff }); + const boneMat = new THREE.MeshToonMaterial({ color: 0xf0e6cf }); + const faceMat = new THREE.MeshBasicMaterial({ color: 0x0a0614 }); + let boneFadeRegistered = false; + const registerBone = (mesh: THREE.Mesh) => { + // Knochen-Material einmal fürs Fade-Death registrieren (wird geteilt) + if (!boneFadeRegistered) { + this.registerFadeMesh(mesh); + boneFadeRegistered = true; + } + }; - // Robe (umgedrehter Kegel, schwingt beim Schweben) - const robe = new THREE.Mesh(new THREE.ConeGeometry(0.9, 1.8, 12, 1, true), this.robeMat); - robe.position.y = -0.2; - robe.rotation.x = Math.PI; + // --- Robe (Blickrichtung +Z, schwebt: unten offen und zerfetzt) --- + const robeProfile = [ + new THREE.Vector2(0.75, -1.15), // Saum + new THREE.Vector2(0.62, -0.75), + new THREE.Vector2(0.45, -0.2), + new THREE.Vector2(0.38, 0.25), + new THREE.Vector2(0.5, 0.55), // Schulteransatz + new THREE.Vector2(0.32, 0.78), // Hals + ]; + const robe = new THREE.Mesh(new THREE.LatheGeometry(robeProfile, 14), this.robeMat); robe.castShadow = true; this.registerFadeMesh(robe); this.model.add(robe); - // Rocksaum - const hem = new THREE.Mesh(new THREE.CircleGeometry(0.95, 12), this.darkMat); - hem.rotation.x = -Math.PI / 2; - hem.position.y = -1.05; - this.registerFadeMesh(hem); - this.model.add(hem); + // Innenschatten im Saum (von unten sichtbar) + const hemInner = new THREE.Mesh(new THREE.CircleGeometry(0.7, 14), this.darkMat); + hemInner.rotation.x = Math.PI / 2; + hemInner.position.y = -1.08; + this.registerFadeMesh(hemInner); + this.model.add(hemInner); + + // Zerfetzte Saumfetzen + for (let i = 0; i < 8; i++) { + const a = (i / 8) * Math.PI * 2; + const tatter = new THREE.Mesh( + new THREE.ConeGeometry(0.08, 0.4, 4), + i % 2 === 0 ? this.robeMat : this.darkMat + ); + tatter.rotation.x = Math.PI; + tatter.position.set(Math.sin(a) * 0.68, -1.28, Math.cos(a) * 0.68); + this.registerFadeMesh(tatter); + this.model.add(tatter); + } + + // Gürtelkordel + Quaste + const belt = new THREE.Mesh(new THREE.TorusGeometry(0.42, 0.035, 6, 16), this.darkMat); + belt.rotation.x = Math.PI / 2; + belt.position.y = 0.12; + this.registerFadeMesh(belt); + this.model.add(belt); + const tasselCord = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.28, 5), this.darkMat); + tasselCord.position.set(-0.3, -0.05, 0.36); + this.registerFadeMesh(tasselCord); + this.model.add(tasselCord); + const tassel = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.14, 5), boneMat); + tassel.rotation.x = Math.PI; + tassel.position.set(-0.3, -0.24, 0.38); + registerBone(tassel); + this.model.add(tassel); // Oberkörper const body = new THREE.Mesh(new THREE.SphereGeometry(0.55, 16, 12), this.robeMat); @@ -114,46 +159,116 @@ export class Necromancer extends Enemy { this.registerFadeMesh(body); this.model.add(body); - // Kapuze - const hood = new THREE.Mesh(new THREE.ConeGeometry(0.42, 0.8, 12, 1, true), this.darkMat); - hood.position.set(0, 1.1, 0.1); - hood.rotation.x = 0.15; + // Kragen + const collar = new THREE.Mesh(new THREE.TorusGeometry(0.22, 0.045, 6, 14), this.darkMat); + collar.rotation.x = Math.PI / 2 - 0.2; + collar.position.set(0, 0.85, 0.08); + this.registerFadeMesh(collar); + this.model.add(collar); + + // Schulterpanzer mit Spitzen + for (const side of [-1, 1]) { + const pauldron = new THREE.Mesh(new THREE.SphereGeometry(0.26, 12, 8), this.darkMat); + pauldron.scale.set(1.25, 0.65, 1.25); + pauldron.position.set(side * 0.45, 0.78, 0); + this.registerFadeMesh(pauldron); + this.model.add(pauldron); + const spike = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.22, 5), this.darkMat); + spike.position.set(side * 0.55, 0.95, 0); + spike.rotation.z = side * -0.5; + this.registerFadeMesh(spike); + this.model.add(spike); + } + + // Kapuze mit Krempe und schwarzem Gesichts-Leerraum + const hood = new THREE.Mesh(new THREE.ConeGeometry(0.5, 0.95, 12, 1, true), this.darkMat); + hood.position.set(0, 1.18, 0.02); + hood.rotation.x = 0.22; + hood.castShadow = true; this.registerFadeMesh(hood); this.model.add(hood); - - // Kapuzen-Öffnung (+Z, dunkel) - const hoodFace = new THREE.Mesh(new THREE.CircleGeometry(0.36, 12), this.darkMat); - hoodFace.position.set(0, 1.02, 0.42); - hoodFace.rotation.y = Math.PI / 2 - 0.1; - hoodFace.rotation.x = -0.35; - this.registerFadeMesh(hoodFace); - this.model.add(hoodFace); + const hoodRim = new THREE.Mesh(new THREE.TorusGeometry(0.34, 0.05, 8, 16), this.darkMat); + hoodRim.rotation.x = -0.5; + hoodRim.position.set(0, 1.05, 0.34); + this.registerFadeMesh(hoodRim); + this.model.add(hoodRim); + const face = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 10), faceMat); + face.scale.set(1, 1.1, 0.8); + face.position.set(0, 1.04, 0.26); + this.registerFadeMesh(face); + this.model.add(face); // Leuchtende Augen in der Kapuze (+Z) for (const side of [-1, 1]) { - const eye = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), this.eyeMat); - eye.position.set(side * 0.13, 1.05, 0.44); + const eye = new THREE.Mesh(new THREE.SphereGeometry(0.075, 8, 6), this.eyeMat); + eye.position.set(side * 0.12, 1.08, 0.48); this.model.add(eye); } - // Glühender Amulett-Kern + // Amulett + Runen auf der Robe (glühen, färben sich im Enrage) const amulet = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 6), this.glowMat); - amulet.position.set(0, 0.62, 0.52); + amulet.position.set(0, 0.6, 0.5); this.model.add(amulet); + const runeSpots: [number, number, number][] = [ + [0, -0.15, 0.5], + [-0.2, -0.5, 0.5], + [0.2, -0.5, 0.5], + ]; + for (const [x, y, z] of runeSpots) { + const rune = new THREE.Mesh(new THREE.OctahedronGeometry(0.06), this.glowMat); + rune.scale.set(1, 1.7, 0.5); + rune.position.set(x, y, z); + this.model.add(rune); + } - // Arm mit Stab - const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.12, 0.6, 8), this.darkMat); - arm.position.set(0.55, 0.7, 0.15); - arm.rotation.z = -0.6; - this.registerFadeMesh(arm); - this.model.add(arm); + // Rechter Arm: Ärmel zum Stab + const sleeveR = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.16, 0.55, 8), this.robeMat); + sleeveR.position.set(0.65, 0.84, 0.14); + sleeveR.rotation.z = 1.2; + this.registerFadeMesh(sleeveR); + this.model.add(sleeveR); + const handR = new THREE.Mesh(new THREE.SphereGeometry(0.1, 8, 6), boneMat); + handR.position.set(0.85, 0.9, 0.23); + registerBone(handR); + this.model.add(handR); - const staff = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 2.0, 8), this.darkMat); - staff.position.set(0.85, 1.3, 0.25); - staff.rotation.z = 0.35; - staff.rotation.x = 0.2; + // Linker Arm: Ärmel nach vorn (Beschwörungs-Geste) + Energiekugel + const sleeveL = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.18, 0.6, 8), this.robeMat); + sleeveL.position.set(-0.55, 0.75, 0.32); + sleeveL.rotation.x = -1.25; + sleeveL.rotation.z = -0.35; + this.registerFadeMesh(sleeveL); + this.model.add(sleeveL); + const handL = new THREE.Mesh(new THREE.SphereGeometry(0.1, 8, 6), boneMat); + handL.position.set(-0.68, 0.72, 0.62); + registerBone(handL); + this.model.add(handL); + const castOrb = new THREE.Mesh(new THREE.SphereGeometry(0.09, 8, 6), this.glowMat); + castOrb.position.set(-0.7, 0.92, 0.68); + this.model.add(castOrb); + + // Stab mit Manschetten, Totenkopf und schwebendem Orb + const staff = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 2.4, 8), this.darkMat); + staff.position.set(0.8, 1.2, 0.28); + staff.rotation.z = 0.18; + staff.rotation.x = 0.15; this.registerFadeMesh(staff); this.model.add(staff); + for (const [x, y, z] of [[0.88, 0.76, 0.21], [0.96, 0.31, 0.15]] as const) { + const collarM = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.07, 8), this.darkMat); + collarM.position.set(x, y, z); + this.registerFadeMesh(collarM); + this.model.add(collarM); + } + const skull = new THREE.Mesh(new THREE.SphereGeometry(0.15, 10, 8), boneMat); + skull.position.set(0.58, 2.42, 0.47); + registerBone(skull); + this.model.add(skull); + for (const side of [-1, 1]) { + const socket = new THREE.Mesh(new THREE.SphereGeometry(0.03, 6, 4), this.darkMat); + socket.position.set(0.58 + side * 0.05, 2.44, 0.6); + this.model.add(socket); + } const orbMat = new THREE.MeshBasicMaterial({ color: 0xbb66ff, @@ -162,8 +277,8 @@ export class Necromancer extends Enemy { blending: THREE.AdditiveBlending, depthWrite: false, }); - const orb = new THREE.Mesh(new THREE.SphereGeometry(0.16, 10, 8), orbMat); - orb.position.set(1.2, 1.95, 0.35); + const orb = new THREE.Mesh(new THREE.SphereGeometry(0.14, 10, 8), orbMat); + orb.position.set(0.58, 2.66, 0.47); this.model.add(orb); this.orbMat = orbMat; this.orb = orb; diff --git a/web/src/Shaman.ts b/web/src/Shaman.ts index f602456..6e6ec43 100644 --- a/web/src/Shaman.ts +++ b/web/src/Shaman.ts @@ -33,40 +33,149 @@ export class Shaman extends Enemy { this.robeMat = new THREE.MeshToonMaterial({ color: 0x33aa55, emissive: 0x0a1f10 }); this.darkMat = new THREE.MeshToonMaterial({ color: 0x1c5c2e }); + const boneMat = new THREE.MeshToonMaterial({ color: 0xe8dcc0 }); + const faceMat = new THREE.MeshBasicMaterial({ color: 0x06120a }); + let boneFadeRegistered = false; + const registerBone = (mesh: THREE.Mesh) => { + // Knochen-Material einmal fürs Fade-Death registrieren (wird geteilt) + if (!boneFadeRegistered) { + this.registerFadeMesh(mesh); + boneFadeRegistered = true; + } + }; - // Robe (Kegel) - const robe = new THREE.Mesh(new THREE.ConeGeometry(0.45, 1.1, 10, 1, true), this.robeMat); - robe.position.y = 0.5; + // --- Robe (Blickrichtung +Z, bis zum Boden) --- + const robeProfile = [ + new THREE.Vector2(0.52, 0.02), // Saum + new THREE.Vector2(0.44, 0.35), + new THREE.Vector2(0.32, 0.7), + new THREE.Vector2(0.3, 0.95), + new THREE.Vector2(0.38, 1.1), // Schulteransatz + new THREE.Vector2(0.24, 1.25), // Hals + ]; + const robe = new THREE.Mesh(new THREE.LatheGeometry(robeProfile, 12), this.robeMat); robe.castShadow = true; this.registerFadeMesh(robe); this.model.add(robe); - // Oberkörper - const body = new THREE.Mesh(new THREE.SphereGeometry(0.3, 12, 10), this.robeMat); - body.position.y = 1.0; - this.registerFadeMesh(body); - this.model.add(body); + // Zerfetzter Saum + for (let i = 0; i < 6; i++) { + const a = (i / 6) * Math.PI * 2; + const tatter = new THREE.Mesh( + new THREE.ConeGeometry(0.06, 0.28, 4), + i % 2 === 0 ? this.robeMat : this.darkMat + ); + tatter.rotation.x = Math.PI; + tatter.position.set(Math.sin(a) * 0.48, -0.02, Math.cos(a) * 0.48); + this.registerFadeMesh(tatter); + this.model.add(tatter); + } - // Kopf mit Kapuze - const hood = new THREE.Mesh(new THREE.ConeGeometry(0.26, 0.5, 10, 1, true), this.darkMat); - hood.position.set(0, 1.4, 0.08); + // Gürtel mit hängenden Knochen-Amuletten + const belt = new THREE.Mesh(new THREE.TorusGeometry(0.33, 0.03, 6, 14), this.darkMat); + belt.rotation.x = Math.PI / 2; + belt.position.y = 0.75; + this.registerFadeMesh(belt); + this.model.add(belt); + for (const [x, z] of [[-0.14, 0.3], [0, 0.34], [0.14, 0.3]] as const) { + const charm = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.16, 5), boneMat); + charm.position.set(x, 0.62, z); + registerBone(charm); + this.model.add(charm); + } + + // Brust + Fellkragen mit Zacken + const chest = new THREE.Mesh(new THREE.SphereGeometry(0.28, 12, 10), this.robeMat); + chest.position.y = 1.12; + chest.scale.set(1, 1.15, 1); + this.registerFadeMesh(chest); + this.model.add(chest); + const collar = new THREE.Mesh(new THREE.TorusGeometry(0.26, 0.09, 8, 14), this.darkMat); + collar.rotation.x = Math.PI / 2; + collar.scale.set(1, 1, 0.8); + collar.position.y = 1.28; + this.registerFadeMesh(collar); + this.model.add(collar); + for (let i = 0; i < 8; i++) { + const a = (i / 8) * Math.PI * 2; + const fur = new THREE.Mesh(new THREE.ConeGeometry(0.04, 0.16, 4), this.darkMat); + fur.position.set(Math.sin(a) * 0.28, 1.36, Math.cos(a) * 0.24); + fur.rotation.x = -0.4; + fur.rotation.y = a; + this.registerFadeMesh(fur); + this.model.add(fur); + } + + // Knochenkette mit Zähnen auf der Brust + const necklace = new THREE.Mesh(new THREE.TorusGeometry(0.16, 0.02, 6, 12), this.darkMat); + necklace.rotation.x = Math.PI / 2 - 0.3; + necklace.position.set(0, 1.2, 0.2); + this.registerFadeMesh(necklace); + this.model.add(necklace); + for (const [x, z] of [[-0.08, 0.32], [0, 0.35], [0.08, 0.32]] as const) { + const tooth = new THREE.Mesh(new THREE.ConeGeometry(0.03, 0.1, 4), boneMat); + tooth.rotation.x = Math.PI; + tooth.position.set(x, 1.1, z); + registerBone(tooth); + this.model.add(tooth); + } + + // Kapuze mit Krempe, schwarzem Gesicht und grünen Augen (+Z) + const hood = new THREE.Mesh(new THREE.ConeGeometry(0.32, 0.6, 10, 1, true), this.darkMat); + hood.position.set(0, 1.55, 0.02); + hood.rotation.x = 0.2; + hood.castShadow = true; this.registerFadeMesh(hood); this.model.add(hood); - - // Leuchtende Augen (+Z) + const hoodRim = new THREE.Mesh(new THREE.TorusGeometry(0.22, 0.04, 8, 14), this.darkMat); + hoodRim.rotation.x = -0.5; + hoodRim.position.set(0, 1.44, 0.22); + this.registerFadeMesh(hoodRim); + this.model.add(hoodRim); + const face = new THREE.Mesh(new THREE.SphereGeometry(0.2, 12, 10), faceMat); + face.scale.set(1, 1.1, 0.8); + face.position.set(0, 1.44, 0.16); + this.registerFadeMesh(face); + this.model.add(face); const eyeMat = new THREE.MeshBasicMaterial({ color: 0x66ff88 }); for (const side of [-1, 1]) { - const eye = new THREE.Mesh(new THREE.SphereGeometry(0.05, 6, 5), eyeMat); - eye.position.set(side * 0.11, 1.32, 0.26); + const eye = new THREE.Mesh(new THREE.SphereGeometry(0.055, 6, 5), eyeMat); + eye.position.set(side * 0.08, 1.47, 0.35); this.model.add(eye); } - // Stab mit Glühmund - const staff = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 1.2, 6), this.darkMat); - staff.position.set(0.45, 0.8, 0.1); - staff.rotation.z = 0.35; - this.registerFadeMesh(staff); - this.model.add(staff); + // Federn an der Kapuze (hängen nach hinten) + for (const side of [-1, 1]) { + const feather = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.4, 4), boneMat); + feather.rotation.x = Math.PI + side * 0.15; + feather.scale.set(1, 1, 0.3); + feather.position.set(side * 0.14, 1.35, -0.22); + registerBone(feather); + this.model.add(feather); + } + + // Rechter Arm zum Stab + const sleeveR = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.12, 0.45, 8), this.robeMat); + sleeveR.position.set(0.32, 1.05, 0.1); + sleeveR.rotation.z = 1.0; + this.registerFadeMesh(sleeveR); + this.model.add(sleeveR); + const handR = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), boneMat); + handR.position.set(0.45, 0.95, 0.18); + registerBone(handR); + this.model.add(handR); + + // Linker Arm erhoben mit kleiner Energiekugel + const sleeveL = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.12, 0.45, 8), this.robeMat); + sleeveL.position.set(-0.34, 1.12, 0.18); + sleeveL.rotation.x = -1.1; + sleeveL.rotation.z = -0.5; + this.registerFadeMesh(sleeveL); + this.model.add(sleeveL); + const handL = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), boneMat); + handL.position.set(-0.4, 1.22, 0.34); + registerBone(handL); + this.model.add(handL); const orbMat = new THREE.MeshBasicMaterial({ color: 0x66ff88, @@ -75,9 +184,39 @@ export class Shaman extends Enemy { blending: THREE.AdditiveBlending, depthWrite: false, }); - const orb = new THREE.Mesh(new THREE.SphereGeometry(0.1, 8, 6), orbMat); - orb.position.set(0.62, 1.45, 0.12); + const castOrb = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), orbMat); + castOrb.position.set(-0.42, 1.38, 0.38); + this.model.add(castOrb); + + // Totem-Stab: Schädel mit Klauenkrone, Glüh-Orb, hängender Anhänger + const staff = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.04, 1.7, 6), this.darkMat); + staff.position.set(0.5, 0.85, 0.2); + staff.rotation.z = 0.15; + staff.rotation.x = 0.1; + this.registerFadeMesh(staff); + this.model.add(staff); + const skull = new THREE.Mesh(new THREE.SphereGeometry(0.11, 10, 8), boneMat); + skull.position.set(0.37, 1.72, 0.29); + registerBone(skull); + this.model.add(skull); + for (const side of [-1, 1]) { + const claw = new THREE.Mesh(new THREE.ConeGeometry(0.03, 0.22, 5), boneMat); + claw.position.set(0.37 + side * 0.1, 1.84, 0.29); + claw.rotation.z = side * -0.5; + registerBone(claw); + this.model.add(claw); + const socket = new THREE.Mesh(new THREE.SphereGeometry(0.025, 6, 4), this.darkMat); + socket.position.set(0.37 + side * 0.04, 1.74, 0.39); + this.model.add(socket); + } + const orb = new THREE.Mesh(new THREE.SphereGeometry(0.09, 8, 6), orbMat); + orb.position.set(0.37, 1.94, 0.29); this.model.add(orb); + const dangle = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.015, 0.14, 5), boneMat); + dangle.position.set(0.44, 1.58, 0.27); + dangle.rotation.z = 0.4; + registerBone(dangle); + this.model.add(dangle); // Heil-Aura-Ring am Boden this.healRingMat = new THREE.MeshBasicMaterial({