chore(controller): three-player-controller als Ordner statt Submodul

Das Submodul zeigte auf GitHub, der eingetragene Stand 028c652 lag aber nur
lokal vor: GitHub-Master ac39682 plus Showcase mit Figuren und launch.json.
Frische Klone konnten den Controller deshalb nicht laden. Jetzt liegt dieser
Stand als normaler Ordner im Repo, ohne Verweis auf GitHub.

dist/ und node_modules/ im Controller bleiben ignoriert und werden wie bisher
lokal gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-15 21:27:03 +02:00
1 parent 780e246fe5
commit fb38652e7f
153 files changed
+42561 -4

No files matched your search

@@ -0,0 +1,440 @@
import * as THREE from "three";
import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js";
export class ZombieEntity {
constructor(scene, id) {
// ==================== 场景引用 ====================
this._scene = scene;
this.id = id; // 唯一标识
// ==================== 场景对象 ====================
this._colliders = []; // 场景碰撞网格(CollisionWorld 查询结果)
this._model = null; // 模型根节点
this._capsule = null; // 碰撞胶囊体
this._capsuleInfo = null; // 胶囊描述(radius + segment)
this._mixer = null; // 动画混合器
// ==================== 动画动作 ====================
this._walkAction = null; // 行走
this._runAction = null; // 奔跑
this._idleAction = null; // 待机
this._punchAction = null; // 攻击
this._deathAction = null; // 死亡
this._currentAction = null; // 当前播放动作
this._punchLoopCb = null; // 攻击循环回调(用于 removeEventListener)
// ==================== 生命状态 ====================
this.hp = 100; // 血量
this.isDead = false; // 是否已死亡
this._deathTime = 0; // 死亡后累计时间(用于定时清除)
this._isRunning = Math.random() > 0.5; // 随机决定是否为跑者
this._state = "idle"; // 当前行为状态
this._isPunching = false; // 是否正在攻击
this._inAttackRange = false; // 是否在攻击范围内
// ==================== 物理参数 ====================
this._gravity = -2.4; // 重力加速度(load 时按 scale 重算)
this._speed = 0.12; // 移速(load 时按 scale 重算)
this._modelScale = 0.001; // 模型缩放
this._capsuleHeight = 180; // 胶囊高度(模型空间,load 时重算)
this._capsuleRadius = 45; // 胶囊半径(模型空间,load 时重算)
this._velocity = new THREE.Vector3(); // 当前速度(主要用于重力累积)
this._onGround = false; // 是否落地
// ==================== 复用向量 ====================
this._tempBox = new THREE.Box3();
this._tempMat = new THREE.Matrix4();
this._tempSeg = new THREE.Line3();
this._tempV1 = new THREE.Vector3();
this._tempV2 = new THREE.Vector3();
this._moveDir = new THREE.Vector3(); // 水平移动方向(单位向量)
this._lookTarget = new THREE.Vector3(); // 朝向目标(lookAt 复用)
this._raycaster = new THREE.Raycaster(
new THREE.Vector3(),
new THREE.Vector3(0, -1, 0),
);
this._raycaster.firstHitOnly = true; // 只取最近地面交点
}
// 加载模型、绑定动画、构建胶囊体,挂入场景
async load(gltfLoader, {
modelUrl,
colliders,
position,
scale = 0.001,
walkAnim,
runAnim,
idleAnim,
punchAnim,
deathAnim,
rotateY = Math.PI,
speed = 120,
}) {
this._colliders = colliders?.length ? colliders.slice() : [];
this._modelScale = scale;
this._gravity = -2400 * scale;
// 基础移速增加随机扰动 (±15%),使丧尸步频与步幅产生差异
this._speed = speed * scale * (0.85 + Math.random() * 0.3);
const gltf = await gltfLoader.loadAsync(modelUrl);
this._model = gltf.scene;
this._mixer = new THREE.AnimationMixer(this._model);
const clips = gltf.animations ?? [];
// 按名称或模糊匹配查找动画片段
const findClip = (hint) => {
if (!hint) return null;
return (
clips.find((clip) => clip.name === hint) ??
clips.find((clip) => clip.name.toLowerCase().includes(hint.toLowerCase())) ??
null
);
};
const walkClip = findClip(walkAnim) ?? findClip("walk") ?? clips[0] ?? null;
const runClip = findClip(runAnim) ?? findClip("run") ?? clips[0] ?? null;
const idleClip = findClip(idleAnim) ?? findClip("idle") ?? clips[0] ?? null;
const punchClip = findClip(punchAnim) ?? findClip("punch") ?? null;
const deathClip = findClip(deathAnim) ?? findClip("dying") ?? findClip("death") ?? findClip("die") ?? null;
// 创建循环动作并初始权重为 0(由 _playAnim 按需激活)
const makeAction = (clip) => {
if (!clip) return null;
const action = this._mixer.clipAction(clip);
action.setLoop(THREE.LoopRepeat, Infinity);
action.setEffectiveWeight(0);
action.enabled = true;
return action;
};
this._walkAction = makeAction(walkClip);
if (this._walkAction) {
// 随机播放速度 (±10%) 和 随机起始进度 (0-100%)
this._walkAction.setEffectiveTimeScale(1.5 * (0.9 + Math.random() * 0.2));
this._walkAction.time = Math.random() * walkClip.duration;
}
this._runAction = makeAction(runClip);
if (this._runAction) {
this._runAction.setEffectiveTimeScale(1.2 * (0.9 + Math.random() * 0.2));
this._runAction.time = Math.random() * runClip.duration;
}
this._idleAction = idleClip !== walkClip ? makeAction(idleClip) : this._walkAction;
if (this._idleAction) {
// 即使是待机动作也打乱起始时间
this._idleAction.time = Math.random() * (idleClip?.duration ?? 1);
}
if (punchClip) {
this._punchAction = this._mixer.clipAction(punchClip);
this._punchAction.setLoop(THREE.LoopRepeat, Infinity);
this._punchAction.setEffectiveTimeScale(1.5);
this._punchAction.setEffectiveWeight(0);
this._punchAction.enabled = true;
// 每次攻击循环结束时检查是否已离开攻击范围,若已离开则切回移动动画
this._punchLoopCb = (event) => {
if (event.action !== this._punchAction || !this._isPunching) return;
if (!this._inAttackRange) {
this._isPunching = false;
this._playAnim(this._walkAction ?? this._idleAction);
}
};
this._mixer.addEventListener("loop", this._punchLoopCb);
}
this._deathAction = makeAction(deathClip);
if (this._deathAction) {
this._deathAction.setLoop(THREE.LoopOnce, 1);
this._deathAction.clampWhenFinished = true; // 停在最后一帧
}
// 驱动一帧使骨骼到位,再计算包围盒以得到准确尺寸
this._mixer.update(0);
this._model.updateMatrixWorld(true);
const bbox = new THREE.Box3().setFromObject(this._model);
const size = new THREE.Vector3();
bbox.getSize(size);
// 将模型归一化到参考高度 180,再乘以 scale 得到世界尺寸
const refHeight = 180;
const modelScaleFactor = refHeight / size.y;
this._capsuleHeight = size.y * modelScaleFactor;
this._capsuleRadius = Math.min(size.x, size.z) * modelScaleFactor;
const radius = this._capsuleRadius * scale;
const height = this._capsuleHeight * scale;
// 胶囊体:透明网格,仅用于碰撞,不渲染
this._capsule = new THREE.Mesh(
new RoundedBoxGeometry(radius * 2, height, radius * 2, 1, 0.75),
new THREE.MeshStandardMaterial({
transparent: true,
opacity: 0,
depthWrite: false,
}),
);
this._capsule.geometry.translate(0, -height * 0.25, 0);
this._capsuleInfo = {
radius,
segment: new THREE.Line3(
new THREE.Vector3(),
new THREE.Vector3(0, -height * 0.5, 0),
),
};
this._capsule.name = "zombie_capsule";
this._capsule.userData.zombieId = this.id;
this._capsule.layers.enable(2); // layer 2:可被武器射线检测到
this._scene.add(this._capsule);
this._capsule.position.copy(position);
this._model.scale.setScalar(modelScaleFactor * scale);
this._model.position.set(0, -height * 0.75, 0);
this._model.rotation.y = rotateY;
this._model.traverse((child) => {
child.userData.zombieId = this.id;
child.castShadow = true;
child.receiveShadow = true;
});
this._capsule.add(this._model);
this._playAnim(this._idleAction ?? this._walkAction);
}
// ==================== 主循环 ====================
update(delta, playerPos) {
if (!this._capsule || !this._colliders.length) return;
delta = Math.min(delta, 1 / 40); // 防止帧间隔过大导致穿透
if (this.isDead) {
this._deathTime += delta;
this._mixer?.update(delta);
return;
}
// 重力累积
if (!this._onGround) {
this._velocity.y += delta * this._gravity;
}
this._capsule.position.addScaledVector(this._velocity, delta);
// 计算与玩家的水平距离,判断是否进入攻击范围
const pos = this._capsule.position;
const dx = playerPos.x - pos.x;
const dz = playerPos.z - pos.z;
const horizDist = Math.sqrt(dx * dx + dz * dz);
const stopRadius = this._capsuleInfo.radius * 2;
this._inAttackRange = horizDist <= stopRadius;
// 朝向玩家(lookAt 反向,使模型正面对着玩家)
this._lookTarget.set(
2 * pos.x - playerPos.x,
pos.y,
2 * pos.z - playerPos.z,
);
this._capsule.lookAt(this._lookTarget);
if (this._inAttackRange) {
this._moveDir.set(0, 0, 0);
if (!this._isPunching) {
this._isPunching = true;
this._state = "attack";
this._playAnim(this._punchAction ?? this._idleAction ?? this._walkAction);
}
} else {
// 只要不在攻击范围内,立即重置攻击标记以允许移动逻辑执行
this._isPunching = false;
if (horizDist > 1e-5) {
this._moveDir.set(dx / horizDist, 0, dz / horizDist);
this._state = this._isRunning ? "run" : "walk";
const moveAnim = this._isRunning ? (this._runAction ?? this._walkAction) : this._walkAction;
this._playAnim(moveAnim ?? this._idleAction);
} else {
this._moveDir.set(0, 0, 0);
this._state = "idle";
this._playAnim(this._idleAction ?? this._walkAction);
}
}
const currentSpeed = this._isRunning ? this._speed * 1.5 : this._speed;
this._applyEnvironmentCollision(delta, currentSpeed);
this._applyGrounding(delta);
this._mixer?.update(delta);
}
// ==================== 伤害 / 死亡 ====================
takeDamage(damage) {
if (this.isDead) return false;
this.hp -= damage;
if (this.hp <= 0) {
this.die();
return true; // 本次命中导致死亡
}
return false;
}
die() {
if (this.isDead) return;
this.isDead = true;
this._state = "die";
this._isPunching = false;
this._deathTime = 0;
this._velocity.set(0, 0, 0);
if (this._deathAction) {
this._playAnim(this._deathAction, 0.1);
}
// 禁用图层以便射线检测和自动瞄准忽略尸体
this._capsule.layers.disable(2);
this._model.traverse((child) => {
if (child.isMesh) {
child.layers.disable(2);
}
});
}
// ==================== Getter ====================
getPosition() { return this._capsule?.position ?? null; }
getCapsule() { return this._capsule; }
getCapsuleInfo() { return this._capsuleInfo; }
getDeathTime() { return this._deathTime; }
// ==================== 私有方法 ====================
// 切换动画,带淡入淡出
_playAnim(action, fade = 0.2) {
if (!action || this._currentAction === action) return;
const prev = this._currentAction;
action.reset().setEffectiveWeight(1).play();
if (prev) {
prev.fadeOut(fade);
action.fadeIn(fade);
} else {
action.fadeIn(fade);
}
this._currentAction = action;
}
// 分步移动 + BVH 环境碰撞(只处理竖直面,水平面由 _applyGrounding 负责)
_applyEnvironmentCollision(delta, speed) {
const ci = this._capsuleInfo;
const totalDist = speed * delta;
const maxStep = ci.radius * 0.8; // 每步最大移动量,防止高速穿透
const steps = Math.ceil(totalDist / maxStep) || 1;
const stepDist = totalDist / steps;
for (let i = 0; i < steps; i++) {
this._capsule.position.addScaledVector(this._moveDir, stepDist);
this._capsule.updateMatrixWorld();
for (const collider of this._colliders) {
this._tempBox.makeEmpty();
this._tempMat.copy(collider.matrixWorld).invert();
this._tempSeg.copy(ci.segment);
this._tempSeg.start
.applyMatrix4(this._capsule.matrixWorld)
.applyMatrix4(this._tempMat);
this._tempSeg.end
.applyMatrix4(this._capsule.matrixWorld)
.applyMatrix4(this._tempMat);
this._tempBox.expandByPoint(this._tempSeg.start).expandByPoint(this._tempSeg.end);
this._tempBox.expandByScalar(ci.radius);
collider.geometry?.boundsTree?.shapecast({
intersectsBounds: (box) => box.intersectsBox(this._tempBox),
intersectsTriangle: (tri) => {
const dist = tri.closestPointToSegment(
this._tempSeg,
this._tempV1,
this._tempV2,
);
if (dist >= ci.radius) return;
const normal = tri.getNormal(new THREE.Vector3());
if (Math.abs(normal.y) > 0.5) return; // 水平面交给 _applyGrounding 处理
const dir = this._tempV2.sub(this._tempV1).normalize();
const depth = ci.radius - dist;
this._tempSeg.start.addScaledVector(dir, depth);
this._tempSeg.end.addScaledVector(dir, depth);
},
});
const newPos = this._tempV1
.copy(this._tempSeg.start)
.applyMatrix4(collider.matrixWorld);
const deltaVec = this._tempV2.subVectors(newPos, this._capsule.position);
const offset = Math.max(0, deltaVec.length() - 1e-5);
if (offset > 0) {
this._capsule.position.add(deltaVec.normalize().multiplyScalar(offset));
this._capsule.updateMatrixWorld();
}
}
}
}
// 射线向下检测地面,吸附胶囊体到地面高度
_applyGrounding(delta) {
this._raycaster.ray.origin.copy(this._capsule.position);
const hits = this._raycaster.intersectObjects(this._colliders, false);
if (!hits.length) {
this._onGround = false;
return;
}
const groundY = hits[0].point.y;
const scale = this._modelScale;
const snapHeight = this._capsuleHeight * scale * 0.75; // 胶囊悬停高度
const maxHeight = this._capsuleHeight * scale * 0.9; // 超过此距离视为腾空
const dist = this._capsule.position.y - groundY;
if (dist >= maxHeight) {
this._onGround = false;
return;
}
this._velocity.set(0, 0, 0);
this._onGround = true;
if (dist >= snapHeight) {
// 平滑吸附(走上斜坡时避免抖动)
this._capsule.position.y = THREE.MathUtils.lerp(
this._capsule.position.y,
groundY + snapHeight,
Math.min(1, 15 * delta),
);
} else {
this._capsule.position.y = groundY + snapHeight;
}
}
// ==================== 销毁 ====================
destroy() {
if (this._mixer) {
if (this._punchLoopCb) {
this._mixer.removeEventListener("loop", this._punchLoopCb);
this._punchLoopCb = null;
}
this._mixer.stopAllAction();
this._mixer.uncacheRoot(this._model);
this._mixer = null;
}
if (this._capsule) {
this._scene.remove(this._capsule);
this._capsule.geometry?.dispose();
this._capsule.material?.dispose?.();
this._capsule = null;
}
this._model = null;
this._colliders = [];
}
}
@@ -0,0 +1,150 @@
import { Vector3 } from "three";
import { ZombieEntity } from "./ZombieEntity.js";
export class ZombieManager {
constructor(scene, options = {}) {
// ==================== 场景引用 ====================
this._scene = scene;
// ==================== 丧尸配置 ====================
this._loader = options.loader ?? null; // GLTF 加载器
this._colliders = options.colliders ?? []; // 场景碰撞网格
this._modelUrl = options.modelUrl ?? ""; // 模型路径
this._scale = options.scale ?? 0.01; // 模型缩放
this._rotateY = options.rotateY ?? Math.PI; // 模型初始朝向
this._speed = options.speed ?? 120; // 移速基准值
this._walkAnim = options.walkAnim ?? "walking"; // 行走动画名
this._runAnim = options.runAnim ?? "running"; // 奔跑动画名
this._idleAnim = options.idleAnim ?? "Idle"; // 待机动画名
this._punchAnim = options.punchAnim ?? "punching"; // 攻击动画名
this._deathAnim = options.deathAnim ?? "dying"; // 死亡动画名
// ==================== 运行状态 ====================
this._zombies = new Map(); // id → ZombieEntity
this._wave = 0; // 当前波次
this._nextId = 0; // 自增 id 计数器
}
// ==================== 波次 / 生成 ====================
// 按波次配置批量生成丧尸
async startWave(waveConfig = {}) {
if (!this._loader || !this._colliders.length || !this._modelUrl) {
console.warn("[ZombieManager] Missing loader, colliders or modelUrl");
return;
}
this._wave += 1;
const origin = waveConfig.origin?.clone?.() ?? waveConfig.origin ?? new Vector3();
const count = waveConfig.count ?? 5;
const radius = waveConfig.radius ?? 10; // 默认半径 10
const spawnHeight = waveConfig.spawnHeight ?? 0.5; // 对应参数 y
let spawnPoints = waveConfig.spawnPoints;
// 如果没有预设点,则根据半径和高度生成随机点
if (!spawnPoints) {
spawnPoints = [];
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const r = Math.sqrt(Math.random()) * radius; // 使用开方确保在圆盘内分布均匀
const x = Math.cos(angle) * r;
const z = Math.sin(angle) * r;
spawnPoints.push(new Vector3(origin.x + x, origin.y + spawnHeight, origin.z + z));
}
}
await Promise.all(spawnPoints.map((point) => this.spawnZombie(point)));
}
// 在指定位置生成单个丧尸,返回其 id
async spawnZombie(position) {
const id = `zombie_${this._nextId++}`;
const entity = new ZombieEntity(this._scene, id);
await entity.load(this._loader, {
modelUrl: this._modelUrl,
colliders: this._colliders,
position: position.clone?.() ?? new Vector3(position.x, position.y, position.z),
scale: this._scale,
walkAnim: this._walkAnim,
runAnim: this._runAnim,
idleAnim: this._idleAnim,
punchAnim: this._punchAnim,
deathAnim: this._deathAnim,
rotateY: this._rotateY,
speed: this._speed,
});
this._zombies.set(id, entity);
return id;
}
// ==================== 命中 ====================
// 武器命中回调,转发伤害给对应实体
onHit(id, damage) {
const entity = this._zombies.get(id);
if (!entity || entity.isDead) return;
entity.takeDamage(damage);
}
// ==================== 主循环 ====================
update(dt, playerPos) {
if (!playerPos) return;
for (const [id, entity] of this._zombies.entries()) {
entity.update(dt, playerPos);
// 如果丧尸已死亡且超过10秒,则彻底移除
if (entity.isDead && entity.getDeathTime() >= 10) {
entity.destroy();
this._zombies.delete(id);
}
}
this._resolveZombieOverlaps();
}
// 解决丧尸之间的胶囊重叠(两两推开,仅处理水平方向)
_resolveZombieOverlaps() {
const entities = Array.from(this._zombies.values()).filter(e => !e.isDead);
for (let i = 0; i < entities.length; i++) {
const a = entities[i];
const aCapsule = a.getCapsule();
const aInfo = a.getCapsuleInfo();
if (!aCapsule || !aInfo) continue;
for (let j = i + 1; j < entities.length; j++) {
const b = entities[j];
const bCapsule = b.getCapsule();
const bInfo = b.getCapsuleInfo();
if (!bCapsule || !bInfo) continue;
const dx = bCapsule.position.x - aCapsule.position.x;
const dz = bCapsule.position.z - aCapsule.position.z;
const distSq = dx * dx + dz * dz;
const minDist = aInfo.radius + bInfo.radius;
if (distSq >= minDist * minDist || distSq <= 1e-10) continue;
const dist = Math.sqrt(distSq);
const half = (minDist - dist) * 0.5; // 各推开一半
const nx = dx / dist;
const nz = dz / dist;
aCapsule.position.x -= nx * half;
aCapsule.position.z -= nz * half;
bCapsule.position.x += nx * half;
bCapsule.position.z += nz * half;
}
}
}
// ==================== 销毁 ====================
destroy() {
for (const entity of this._zombies.values()) {
entity.destroy();
}
this._zombies.clear();
}
}