import * as THREE from "three"; /** * 程序化 3D 卡通赛车工厂 * * 使用多个基础几何体组装出精致的 Low-Poly 风格赛车模型: * - 车身:圆润 BoxGeometry(边缘 round 处理)+ 车漆材质 * - 引擎盖:前部凸起 * - 座舱:深色框架 + 半透明蓝色挡风玻璃 * - 4 轮子:黑色轮胎 Cylinder + 银色金属轮毂 * - 前大灯:白色球体 emissive * - 尾灯:红色方块 emissive * - 后扰流板:赛车尾翼 + 支撑柱 * - 地面软阴影 * * 多色变体策略:传入不同 color hex,共享几何体缓存,独立材质实例。 * * 朝向约定:模型默认车头朝 -Z(即 "up" 方向对应 rotationY = 0)。 */ export interface CarStyle { /** 车身比例 (相对于 cellSize * scale) */ bodyRatio?: { w: number; h: number; l: number }; /** 轮子半径倍率 */ wheelRadius?: number; /** 是否有尾翼 */ hasSpoiler?: boolean; /** 是否有车灯 */ hasLights?: boolean; } const DEFAULT_STYLE: Required = { bodyRatio: { w: 0.42, h: 0.15, l: 0.72 }, wheelRadius: 0.08, hasSpoiler: true, hasLights: true, }; /** * 方向 → Y 轴旋转角度映射。 * 模型默认车头朝 -Z (ArrowDirection.Up = 0)。 */ export function directionToRotationY(direction: string): number { switch (direction) { case "up": return 0; case "right": return -Math.PI / 2; case "down": return Math.PI; case "left": return Math.PI / 2; default: return 0; } } export class ProceduralCar { // 共享几何体缓存 private static wheelGeo: THREE.CylinderGeometry | null = null; private static rimGeo: THREE.CylinderGeometry | null = null; private static headlightGeo: THREE.SphereGeometry | null = null; private static taillightGeo: THREE.BoxGeometry | null = null; private static spoilerGeo: THREE.BoxGeometry | null = null; private static spoilerPostGeo: THREE.CylinderGeometry | null = null; /** * 创建一辆 3D 卡通赛车。 * @param color 车身颜色 (hex number, 如 0xe74c3c) * @param direction 朝向 ("up"|"down"|"left"|"right"),默认 "up" * @param scale 整体缩放基准(对应 cellSize * carScale),默认 0.7 * @param style 可选风格覆盖 */ static create( color: number, direction: string = "up", scale: number = 0.7, style?: CarStyle, ): THREE.Group { const cfg = { ...DEFAULT_STYLE, ...style }; const group = new THREE.Group(); const s = scale; const bodyW = s * cfg.bodyRatio.w; const bodyH = s * cfg.bodyRatio.h; const bodyL = s * cfg.bodyRatio.l; const baseColor = new THREE.Color(color); const darkColor = baseColor.clone().multiplyScalar(0.6); const lightColor = baseColor.clone().lerp(new THREE.Color(0xffffff), 0.3); // ─── 车身 ─── const body = this.createBody(bodyW, bodyH, bodyL, baseColor); body.position.y = bodyH / 2 + s * 0.08; group.add(body); // ─── 引擎盖 ─── const hoodGeo = new THREE.BoxGeometry(bodyW * 0.7, bodyH * 0.3, bodyL * 0.2); const hoodMat = new THREE.MeshStandardMaterial({ color: lightColor, roughness: 0.3, metalness: 0.2 }); const hood = new THREE.Mesh(hoodGeo, hoodMat); hood.position.set(0, bodyH + s * 0.08, -bodyL * 0.28); hood.castShadow = true; group.add(hood); // ─── 座舱 ─── const cabinW = bodyW * 0.75; const cabinH = bodyH * 0.7; const cabinL = bodyL * 0.35; const cabin = this.createCabin(cabinW, cabinH, cabinL, darkColor); cabin.position.set(0, bodyH + cabinH / 2 + s * 0.08, bodyL * 0.05); group.add(cabin); // ─── 4 轮子 ─── const wheelR = s * cfg.wheelRadius; const wheelW = s * 0.05; const wheelOffsetX = bodyW / 2 + wheelW * 0.3; const wheelY = wheelR; const wheelFrontZ = -bodyL * 0.3; const wheelRearZ = bodyL * 0.28; const positions: [number, number, number][] = [ [-wheelOffsetX, wheelY, wheelFrontZ], [wheelOffsetX, wheelY, wheelFrontZ], [-wheelOffsetX, wheelY, wheelRearZ], [wheelOffsetX, wheelY, wheelRearZ], ]; for (const [wx, wy, wz] of positions) { const wheel = this.createWheel(wheelR, wheelW); wheel.position.set(wx, wy, wz); group.add(wheel); } // ─── 车灯 ─── if (cfg.hasLights) { const hlR = s * 0.035; const hlZ = -bodyL / 2 - hlR * 0.3; const hlY = bodyH * 0.7 + s * 0.08; const hlMat = new THREE.MeshStandardMaterial({ color: 0xffffee, emissive: 0xffffaa, emissiveIntensity: 0.6, roughness: 0.1, metalness: 0.3, }); const hlGeo = this.getHeadlightGeo(hlR); const hlL = new THREE.Mesh(hlGeo, hlMat); hlL.position.set(-bodyW * 0.3, hlY, hlZ); group.add(hlL); const hlRight = new THREE.Mesh(hlGeo, hlMat.clone()); hlRight.position.set(bodyW * 0.3, hlY, hlZ); group.add(hlRight); // 尾灯 const tlGeo = this.getTaillightGeo(s); const tlMat = new THREE.MeshStandardMaterial({ color: 0xff2200, emissive: 0xff0000, emissiveIntensity: 0.4, roughness: 0.2, }); const tlL = new THREE.Mesh(tlGeo, tlMat); tlL.position.set(-bodyW * 0.3, hlY * 0.9, bodyL / 2); group.add(tlL); const tlR = new THREE.Mesh(tlGeo, tlMat.clone()); tlR.position.set(bodyW * 0.3, hlY * 0.9, bodyL / 2); group.add(tlR); } // ─── 尾翼 ─── if (cfg.hasSpoiler) { const spoiler = this.createSpoiler(s, darkColor); spoiler.position.set(0, bodyH + cabinH * 0.4 + s * 0.08, bodyL * 0.38); group.add(spoiler); } // ─── 地面阴影 ─── const shadowGeo = new THREE.PlaneGeometry(bodyW * 1.2, bodyL * 1.1); const shadowMat = new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.2, depthWrite: false, }); const shadow = new THREE.Mesh(shadowGeo, shadowMat); shadow.rotation.x = -Math.PI / 2; shadow.position.y = 0.003; group.add(shadow); // ─── 方向旋转 ─── group.rotation.y = directionToRotationY(direction); group.userData = { type: "car", color }; return group; } /** * 设置阻挡红光效果 */ static setBlocked(car: THREE.Group, blocked: boolean): void { car.traverse((child) => { if (child instanceof THREE.Mesh) { const mats = Array.isArray(child.material) ? child.material : [child.material]; for (const mat of mats) { if (mat instanceof THREE.MeshStandardMaterial) { if (blocked) { mat.emissive.setHex(0xff0000); mat.emissiveIntensity = 0.6; } else { mat.emissive.setHex(0x000000); mat.emissiveIntensity = 0; } } } } }); } /** * 释放缓存的几何体 */ static dispose(): void { this.wheelGeo?.dispose(); this.wheelGeo = null; this.rimGeo?.dispose(); this.rimGeo = null; this.headlightGeo?.dispose(); this.headlightGeo = null; this.taillightGeo?.dispose(); this.taillightGeo = null; this.spoilerGeo?.dispose(); this.spoilerGeo = null; this.spoilerPostGeo?.dispose(); this.spoilerPostGeo = null; } // ─── 部件构建 ─── private static createBody(w: number, h: number, l: number, color: THREE.Color): THREE.Mesh { const geo = new THREE.BoxGeometry(w, h, l, 2, 1, 3); this.roundEdges(geo, Math.min(w, h, l) * 0.12); const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.3, metalness: 0.25 }); const mesh = new THREE.Mesh(geo, mat); mesh.castShadow = true; mesh.receiveShadow = true; return mesh; } private static createCabin(w: number, h: number, l: number, color: THREE.Color): THREE.Group { const g = new THREE.Group(); const frameGeo = new THREE.BoxGeometry(w, h, l); const frameMat = new THREE.MeshStandardMaterial({ color, roughness: 0.4, metalness: 0.2 }); const frame = new THREE.Mesh(frameGeo, frameMat); frame.castShadow = true; g.add(frame); const glassMat = new THREE.MeshStandardMaterial({ color: 0x88ccff, roughness: 0.05, metalness: 0.3, transparent: true, opacity: 0.6, }); const glassGeo = new THREE.PlaneGeometry(w * 0.85, h * 0.8); const front = new THREE.Mesh(glassGeo, glassMat); front.position.set(0, 0, -l / 2 - 0.001); front.rotation.x = -0.1; g.add(front); const rear = new THREE.Mesh(glassGeo, glassMat.clone()); rear.position.set(0, 0, l / 2 + 0.001); rear.rotation.y = Math.PI; g.add(rear); return g; } private static createWheel(radius: number, width: number): THREE.Group { const g = new THREE.Group(); const tireGeo = this.getWheelGeo(radius, width); const tireMat = new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.9, metalness: 0 }); const tire = new THREE.Mesh(tireGeo, tireMat); tire.rotation.z = Math.PI / 2; tire.castShadow = true; g.add(tire); const rimGeo = this.getRimGeo(radius * 0.6, width * 0.3); const rimMat = new THREE.MeshStandardMaterial({ color: 0xcccccc, roughness: 0.15, metalness: 0.8 }); const rim = new THREE.Mesh(rimGeo, rimMat); rim.rotation.z = Math.PI / 2; g.add(rim); return g; } private static createSpoiler(s: number, color: THREE.Color): THREE.Group { const g = new THREE.Group(); const wingGeo = this.getSpoilerGeo(s); const wingMat = new THREE.MeshStandardMaterial({ color, roughness: 0.3, metalness: 0.2 }); const wing = new THREE.Mesh(wingGeo, wingMat); wing.castShadow = true; g.add(wing); const postGeo = this.getSpoilerPostGeo(s); const postMat = new THREE.MeshStandardMaterial({ color: 0x444444, roughness: 0.4, metalness: 0.5 }); const pL = new THREE.Mesh(postGeo, postMat); pL.position.set(-s * 0.12, -s * 0.04, 0); g.add(pL); const pR = new THREE.Mesh(postGeo, postMat.clone()); pR.position.set(s * 0.12, -s * 0.04, 0); g.add(pR); return g; } // ─── 边缘圆角 ─── private static roundEdges(geo: THREE.BoxGeometry, radius: number): void { const pos = geo.attributes.position; const params = geo.parameters; const halfW = (params.width ?? 1) / 2; const halfH = (params.height ?? 1) / 2; const halfD = (params.depth ?? 1) / 2; for (let i = 0; i < pos.count; i++) { let x = pos.getX(i); let y = pos.getY(i); let z = pos.getZ(i); const fx = Math.abs(x) / halfW; const fy = Math.abs(y) / halfH; const fz = Math.abs(z) / halfD; const cornerness = fx * fy + fy * fz + fx * fz; if (cornerness > 1.5) { const shrink = 1 - radius * 0.5 * (cornerness - 1.5); x *= shrink; y *= shrink; z *= shrink; pos.setXYZ(i, x, y, z); } } pos.needsUpdate = true; geo.computeVertexNormals(); } // ─── 几何体缓存 ─── private static getWheelGeo(r: number, w: number): THREE.CylinderGeometry { if (!this.wheelGeo) this.wheelGeo = new THREE.CylinderGeometry(r, r, w, 12); return this.wheelGeo; } private static getRimGeo(r: number, w: number): THREE.CylinderGeometry { if (!this.rimGeo) this.rimGeo = new THREE.CylinderGeometry(r, r, w, 8); return this.rimGeo; } private static getHeadlightGeo(r: number): THREE.SphereGeometry { if (!this.headlightGeo) this.headlightGeo = new THREE.SphereGeometry(r, 8, 6); return this.headlightGeo; } private static getTaillightGeo(s: number): THREE.BoxGeometry { if (!this.taillightGeo) this.taillightGeo = new THREE.BoxGeometry(s * 0.06, s * 0.03, s * 0.02); return this.taillightGeo; } private static getSpoilerGeo(s: number): THREE.BoxGeometry { if (!this.spoilerGeo) this.spoilerGeo = new THREE.BoxGeometry(s * 0.38, s * 0.02, s * 0.06); return this.spoilerGeo; } private static getSpoilerPostGeo(s: number): THREE.CylinderGeometry { if (!this.spoilerPostGeo) this.spoilerPostGeo = new THREE.CylinderGeometry(s * 0.012, s * 0.012, s * 0.08, 6); return this.spoilerPostGeo; } }