This commit is contained in:
yinsx
2026-01-05 09:41:23 +08:00
commit 2ad9f27457
30 changed files with 3044 additions and 0 deletions

17
src/apis/axios.ts Normal file
View File

@ -0,0 +1,17 @@
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
export type HttpClient = {
baseURL: string;
headers: Record<string, string>;
get: <T = unknown>(url: string, options?: AxiosRequestConfig) => Promise<AxiosResponse<T>>;
post: <T = unknown>(url: string, data: unknown, options?: AxiosRequestConfig) => Promise<AxiosResponse<T>>;
};
export const httpClient: HttpClient = {
baseURL: 'http://localhost:3000',
headers: {
'Content-Type': 'application/json'
},
get: (url, options) => axios.get(url, options),
post: (url, data, options = {}) => axios.post(url, data, options)
};

82
src/apis/services.ts Normal file
View File

@ -0,0 +1,82 @@
/**
* @file services.js
* @description API服务模块 - 提供与后端交互的各种接口
*/
import { httpClient } from "./axios";
import configurator from "../components/conf";
import auth from "../components/auth";
type AssetFetchResult = {
value: Uint8Array;
timestamp: string;
};
/**
* 获取资源数据的通用方法
* @param {string} url - 资源基础URL
* @param {string} obj - 资源路径
* @returns {Promise<{value: Uint8Array, timestamp: string}|null>}
*/
const fetchAssetData = async (url: string, obj: string): Promise<AssetFetchResult | null> => {
try {
// 获取当前时间戳
const currentTimestamp = Date.now().toString();
const info = auth.load();
if (!info) return null;
// 将参数拼接到URL中
const fullUrl = `${url}/${encodeURI(obj)}`;
return await httpClient.get(fullUrl, {
responseType: 'arraybuffer',
headers: {
Timestamp: currentTimestamp,
Token: info.token ?? '',
UUID: info.uid ?? ''
}
})
.then(response => ({
value: new Uint8Array(response.data as ArrayBuffer),
timestamp: currentTimestamp,
}))
.catch(error => {
console.error('Axios GET request error:', error);
return null;
});
} catch (error) {
console.error('Fetch asset data error:', error);
return null;
}
};
/**
* 获取GLB模型数据
* @param {string} obj - 模型文件路径
* @returns {Promise<Uint8Array|null>} 模型二进制数据
*/
export const fetchGlbModel = async (obj: string): Promise<Uint8Array | null> => {
const result = await fetchAssetData(configurator.modelAssetDir, obj);
return result?.value ?? null;
};
/**
* 用户登录
* @param {object} obj - 登录请求参数
* @param {object} obj.content - 登录内容
* @param {string} obj.content.obj1 - 用户名
* @returns {Promise<object>} 登录响应数据
*/
export const userLogin = async (obj: unknown): Promise<any> => {
const data = await httpClient.post(`${httpClient.baseURL}/api/Terminal/ThreeJSToLogin`, obj);
return data.data;
}
/**
* 获取动画数据
* @param {string} obj - 动画文件路径
* @returns {Promise<object>} 动画数据
*/
export const fetchAnim = async (obj: string): Promise<any> => {
const result = await httpClient.get(`${configurator.animAssetDir}/${obj}`);
return result.data;
}

View File

@ -0,0 +1,52 @@
import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera';
import { Vector3 } from '@babylonjs/core/Maths/math.vector';
import { Tools } from '@babylonjs/core/Misc/tools';
import { Monobehiver } from '../base/Monobehiver';
/**
* 相机控制类- 负责创建和控制弧形旋转相机
*/
export class AppCamera extends Monobehiver {
object: ArcRotateCamera | null;
constructor(mainApp: any) {
super(mainApp);
this.object = null;
}
/** 初始化相机 */
Awake(): void {
const scene = this.mainApp.appScene.object;
const canvas = this.mainApp.appDom.renderDom;
if (!scene || !canvas) return;
// 创建弧形旋转相机水平角70度垂直角80度距离5目标点(0,1,0)
this.object = new ArcRotateCamera('Camera', Tools.ToRadians(70), Tools.ToRadians(80), 5, new Vector3(0, 0, 0), scene);
this.object.attachControl(canvas, true);
this.object.minZ = 0.01; // 近裁剪面
this.object.wheelPrecision =999999; // 滚轮缩放精度
this.object.panningSensibility = 0;
this.object.position = new Vector3(-0, 0, 100);
this.setTarget(0, 2, 0);
}
/** 设置相机目标点 */
setTarget(x: number, y: number, z: number): void {
if (this.object) {
this.object.target = new Vector3(x, y, z);
}
}
/** 重置相机到默认位置 */
reset(): void {
if (!this.object) return;
this.object.radius = 2; this.setTarget(0, 0, 0);
this.object.position = new Vector3(0, 1.5, 2);
}
update(): void {
}
}

View File

@ -0,0 +1,13 @@
type OptionalCallback = (() => void) | null | undefined;
type ErrorCallback = ((error?: unknown) => void) | null | undefined;
/**
* 共享运行时配置对象
*/
export const AppConfig = {
container: 'renderDom',
modelUrlList: [] as string[],
success: null as OptionalCallback,
error: null as ErrorCallback
};

21
src/babylonjs/AppDom.ts Normal file
View File

@ -0,0 +1,21 @@
import { AppConfig } from './AppConfig';
/**
* 负责获取渲染容器 DOM
*/
export class AppDom {
private _renderDom: HTMLCanvasElement | null;
constructor() {
this._renderDom = null;
}
get renderDom(): HTMLCanvasElement | null {
return this._renderDom;
}
Awake(): void {
const dom = document.getElementById(AppConfig.container) || document.querySelector('#renderDom');
this._renderDom = (dom as HTMLCanvasElement) ?? null;
}
}

38
src/babylonjs/AppEngin.ts Normal file
View File

@ -0,0 +1,38 @@
import { Engine } from '@babylonjs/core/Engines/engine';
import { Monobehiver } from '../base/Monobehiver';
/**
* 渲染引擎管理类 - 负责创建和管理3D渲染引擎
*/
export class AppEngin extends Monobehiver {
object: Engine | null;
canvas: HTMLCanvasElement | null;
constructor(mainApp: any) {
super(mainApp);
this.object = null;
this.canvas = null;
}
Awake(): void {
this.canvas = this.mainApp.appDom.renderDom;
if (!this.canvas) {
throw new Error('Render canvas not found');
}
this.object = new Engine(this.canvas, true, {
preserveDrawingBuffer: false, // 不保留绘图缓冲区
stencil: true, // 启用模板缓冲
alpha: true // 启用透明背景
});
this.object.setSize(window.innerWidth, window.innerHeight);
this.object.setHardwareScalingLevel(1); // 1:1像素比例
}
/** 处理窗口大小变化 */
handleResize(): void {
if (this.object) {
this.object.setSize(window.innerWidth, window.innerHeight);
this.object.resize();
}
}
}

58
src/babylonjs/AppEnv.ts Normal file
View File

@ -0,0 +1,58 @@
import { CubeTexture } from '@babylonjs/core/Materials/Textures/cubeTexture';
import { Monobehiver } from '../base/Monobehiver';
/**
* 环境管理类- 负责创建和管理HDR环境贴图
*/
export class AppEnv extends Monobehiver {
object: CubeTexture | null;
constructor(mainApp: any) {
super(mainApp);
this.object = null;
}
/** 初始化 - 创建默认HDR环境 */
Awake(): void {
this.createHDR();
}
/**
* 创建HDR环境贴图
* @param hdrPath HDR文件路径
*/
createHDR(hdrPath = '/hdr/sanGiuseppeBridge.env'): void {
const scene = this.mainApp.appScene.object;
if (!scene) return;
const reflectionTexture = CubeTexture.CreateFromPrefilteredData(hdrPath, scene);
scene.environmentIntensity = 1.5;
scene.environmentTexture = reflectionTexture;
this.object = reflectionTexture;
}
/**
* 修改HDR环境光强度
* @param intensity 强度值
*/
changeHDRIntensity(intensity: number): void {
if (this.mainApp.appScene.object) {
this.mainApp.appScene.object.environmentIntensity = intensity;
}
}
/**
* 旋转HDR环境贴图
* @param angle 旋转角度(弧度)
*/
rotateHDR(angle: number): void {
if (this.object) this.object.rotationY = angle;
}
/** 清理资源 */
clean(): void {
if (this.object) {
this.object.dispose();
this.object = null;
}
}
}

209
src/babylonjs/AppLight.ts Normal file
View File

@ -0,0 +1,209 @@
import { SpotLight } from '@babylonjs/core/Lights/spotLight';
import { ShadowGenerator } from '@babylonjs/core/Lights/Shadows/shadowGenerator';
import '@babylonjs/core/Lights/Shadows/shadowGeneratorSceneComponent';
import { Vector3, Quaternion } from '@babylonjs/core/Maths/math.vector';
import { Monobehiver } from '../base/Monobehiver';
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
import { Color3 } from '@babylonjs/core/Maths/math.color';
import { GizmoManager } from '@babylonjs/core/Gizmos/gizmoManager';
import { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh';
import { Mesh } from '@babylonjs/core/Meshes/mesh';
type DebugMarkers = {
marker: Mesh;
arrow: Mesh;
gizmoManager: GizmoManager;
onKey: (e: KeyboardEvent) => void;
};
/**
* 灯光管理类- 负责创建和管理场景灯光
*/
export class AppLight extends Monobehiver {
lightList: SpotLight[];
shadowGenerator: ShadowGenerator | null;
debugMarkers?: DebugMarkers;
coneMesh?: Mesh;
updateCone?: () => void;
constructor(mainApp: any) {
super(mainApp);
this.lightList = [];
this.shadowGenerator = null;
}
/** 初始化灯光并开启阴影 */
Awake(): void {
const light = new SpotLight(
"mainLight",
new Vector3(-0.6, 2.12, 2),
new Vector3(0, -0.5, -1),
Math.PI * 0.6, // angle 弧度
);
light.angle = 1.5;
light.innerAngle = 1;
light.exponent = 2;
light.diffuse = new Color3(1, 0.86, 0.80);
light.specular = new Color3(1, 1, 1);
light.intensity = 60;
light.shadowMinZ = 0.01;
light.shadowMaxZ = 100;
light.range = 5000;
const generator = new ShadowGenerator(4096, light);
generator.usePercentageCloserFiltering = true;
generator.filteringQuality = ShadowGenerator.QUALITY_HIGH;
generator.transparencyShadow = true;
this.lightList.push(light);
this.shadowGenerator = generator;
}
/** 将网格添加为阴影投射者 */
addShadowCaster(mesh: AbstractMesh): void {
if (this.shadowGenerator) {
this.shadowGenerator.addShadowCaster(mesh);
}
}
/** 设置主光源强度 */
setIntensity(intensity: number): void {
if (this.lightList[0]) this.lightList[0].intensity = intensity;
}
/** 创建灯光可视化调试器 - W键拖拽位置E键旋转方向 */
enableLightDebug(): void {
const scene = this.mainApp.appScene.object;
const light = this.lightList[0];
if (!light || !scene) return;
const marker = MeshBuilder.CreateSphere("lightMarker", { diameter: 0.3 }, scene);
marker.position = light.position.clone();
const mat = new StandardMaterial("lightMat", scene);
mat.emissiveColor = Color3.Yellow();
marker.material = mat;
const arrow = MeshBuilder.CreateCylinder("lightArrow", { height: 1, diameterTop: 0, diameterBottom: 0.1 }, scene);
arrow.parent = marker;
arrow.position.set(0, 0, 0.6);
arrow.rotation.x = Math.PI / 2;
const arrowMat = new StandardMaterial("arrowMat", scene);
arrowMat.emissiveColor = Color3.Red();
arrow.material = arrowMat;
const dir = light.direction.normalize();
marker.rotation.y = Math.atan2(dir.x, dir.z);
marker.rotation.x = -Math.asin(dir.y);
const gizmoManager = new GizmoManager(scene);
gizmoManager.attachableMeshes = [marker];
gizmoManager.usePointerToAttachGizmos = false;
gizmoManager.attachToMesh(marker);
scene.onBeforeRenderObservable.add(() => {
light.position.copyFrom(marker.position);
const forward = new Vector3(0, 0, 1);
const rotationMatrix = marker.getWorldMatrix().getRotationMatrix();
light.direction = Vector3.TransformNormal(forward, rotationMatrix).normalize();
});
const onKey = (e: KeyboardEvent) => {
if (e.key === 'w' || e.key === 'W') {
gizmoManager.positionGizmoEnabled = true;
gizmoManager.rotationGizmoEnabled = false;
} else if (e.key === 'e' || e.key === 'E') {
gizmoManager.positionGizmoEnabled = false;
gizmoManager.rotationGizmoEnabled = true;
}
};
window.addEventListener('keydown', onKey);
gizmoManager.positionGizmoEnabled = true;
this.debugMarkers = { marker, arrow, gizmoManager, onKey };
}
/** 隐藏灯光调试器 */
disableLightDebug(): void {
if (this.debugMarkers) {
window.removeEventListener('keydown', this.debugMarkers.onKey);
this.debugMarkers.gizmoManager.dispose();
this.debugMarkers.arrow.dispose();
this.debugMarkers.marker.dispose();
this.debugMarkers = undefined;
}
}
/** 创建聚光灯可视化Gizmo - 带光锥范围 */
createLightGizmo(): void {
const scene = this.mainApp.appScene.object;
const light = this.lightList[0];
if (!light || !scene) return;
const coneLength = 3;
const updateCone = () => {
if (this.coneMesh) this.coneMesh.dispose();
const radius = Math.tan(light.angle) * coneLength;
const cone = MeshBuilder.CreateCylinder("lightCone", {
height: coneLength,
diameterTop: radius * 2,
diameterBottom: 0
}, scene);
const mat = new StandardMaterial("coneMat", scene);
mat.emissiveColor = Color3.Yellow();
mat.alpha = 0.2;
mat.wireframe = true;
cone.material = mat;
cone.position = light.position.add(light.direction.scale(coneLength / 2));
const up = new Vector3(0, 1, 0);
const axis = Vector3.Cross(up, light.direction).normalize();
const angle = Math.acos(Vector3.Dot(up, light.direction.normalize()));
if (axis.length() > 0.001) cone.rotationQuaternion = Quaternion.RotationAxis(axis, angle);
this.coneMesh = cone;
};
updateCone();
this.updateCone = updateCone;
}
/** 创建angle和innerAngle调试滑动条 */
createAngleSliders(): void {
const light = this.lightList[0];
if (!light) return;
const container = document.createElement('div');
container.style.cssText = 'position:fixed;top:10px;right:10px;background:rgba(0,0,0,0.7);padding:10px;border-radius:5px;color:#fff;font-size:12px;z-index:1000';
const createSlider = (label: string, value: number, min: number, max: number, onChange: (v: number) => void) => {
const wrap = document.createElement('div');
wrap.style.marginBottom = '8px';
const lbl = document.createElement('div');
lbl.textContent = `${label}: ${value}`;
const slider = document.createElement('input');
slider.type = 'range';
slider.min = String(min);
slider.max = String(max);
slider.value = String(value);
slider.style.width = '150px';
slider.oninput = () => {
lbl.textContent = `${label}: ${slider.value}`;
onChange(Number(slider.value));
};
wrap.append(lbl, slider);
return wrap;
};
const toRad = (deg: number) => deg * Math.PI / 180;
const toDeg = (rad: number) => Math.round(rad * 180 / Math.PI);
container.append(
createSlider('angle', toDeg(light.angle), 1, 180, v => { light.angle = toRad(v); this.updateCone?.(); }),
createSlider('innerAngle', toDeg(light.innerAngle), 0, 180, v => { light.innerAngle = toRad(v); })
);
document.body.appendChild(container);
}
}

119
src/babylonjs/AppModel.ts Normal file
View File

@ -0,0 +1,119 @@
import { ImportMeshAsync } from '@babylonjs/core/Loading/sceneLoader';
import '@babylonjs/loaders/glTF';
import { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh';
import { Scene } from '@babylonjs/core/scene';
import { Monobehiver } from '../base/Monobehiver';
import { Dictionary } from '../utils/Dictionary';
import { AppConfig } from './AppConfig';
type LoadResult = {
success: boolean;
meshes?: AbstractMesh[];
skeletons?: unknown[];
error?: string;
};
/**
* 模型管理类- 负责加载、缓存和管理3D模型
*/
export class AppModel extends Monobehiver {
private modelDic: Dictionary<AbstractMesh[]>;
private loadedMeshes: AbstractMesh[];
private skeletonManager: any;
private outfitManager: any;
private isLoading: boolean;
private skeletonMerged: boolean;
constructor(mainApp: any) {
super(mainApp);
this.modelDic = new Dictionary<AbstractMesh[]>();
this.loadedMeshes = [];
this.skeletonManager = null;
this.outfitManager = null;
this.isLoading = false;
this.skeletonMerged = false;
}
/** 初始化子管理器(占位:实际实现已移除) */
initManagers(): void {
// 这里原本会初始化 SkeletonManager 和 OutfitManager已留空以避免恢复已删除的实现
}
/** 加载配置中的所有模型 */
async loadModel(): Promise<void> {
if (!AppConfig.modelUrlList?.length || this.isLoading) return;
this.isLoading = true;
try {
for (const url of AppConfig.modelUrlList) {
await this.loadSingleModel(url);
}
} finally {
this.isLoading = false;
}
}
/**
* 加载单个模型
* @param modelUrl 模型URL
*/
async loadSingleModel(modelUrl: string): Promise<LoadResult> {
try {
const cached = this.getCachedMeshes(modelUrl);
if (cached) return { success: true, meshes: cached };
const scene: Scene | null = this.mainApp.appScene.object;
if (!scene) return { success: false, error: '场景未初始化' };
// ImportMeshAsync的签名与当前调用不完全一致使用any规避编译报错
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const result: any = await (ImportMeshAsync as any)(modelUrl, scene);
if (!result?.meshes?.length) return { success: false, error: '未找到网格' };
this.modelDic.Set(modelUrl, result.meshes);
this.loadedMeshes.push(...result.meshes);
this.setupShadows(result.meshes as AbstractMesh[]);
return { success: true, meshes: result.meshes, skeletons: result.skeletons };
} catch (e: any) {
console.error(`模型加载失败: ${modelUrl}`, e);
return { success: false, error: e?.message };
}
}
/** 为网格设置阴影(投射和接收) */
setupShadows(meshes: AbstractMesh[]): void {
const appLight = this.mainApp.appLight;
if (!appLight) return;
meshes.forEach(mesh => {
if (mesh.getTotalVertices() > 0) {
appLight.addShadowCaster(mesh);
mesh.receiveShadows = true;
}
});
}
/** 获取缓存的网格 */
getCachedMeshes(url: string): AbstractMesh[] | undefined {
return this.modelDic.Get(url);
}
/** 清理所有资源 */
clean(): void {
this.modelDic.Clear();
this.loadedMeshes.forEach(m => m?.dispose());
this.loadedMeshes = [];
this.skeletonManager?.clean();
this.outfitManager?.clean();
this.isLoading = false;
this.skeletonMerged = false;
}
}

28
src/babylonjs/AppScene.ts Normal file
View File

@ -0,0 +1,28 @@
import { Scene } from '@babylonjs/core/scene';
import { ImageProcessingConfiguration } from '@babylonjs/core/Materials';
import { Color4 } from '@babylonjs/core/Maths/math.color';
import { Monobehiver } from '../base/Monobehiver';
/**
* 场景管理类- 负责创建和管理3D场景
*/
export class AppScene extends Monobehiver {
object: Scene | null;
constructor(mainApp: any) {
super(mainApp);
this.object = null;
}
/** 初始化场景 */
Awake(): void {
this.object = new Scene(this.mainApp.appEngin.object);
this.object.clearColor = new Color4(0, 0, 0, 0); // 透明背景
this.object.skipFrustumClipping = true; // 跳过视锥剔除优化性能
// 1. 开启色调映射(Tone mapping)
// this.object.imageProcessingConfiguration.toneMappingEnabled = true;
// 2. 设置色调映射类型为ACES
// this.object.imageProcessingConfiguration.toneMappingType = ImageProcessingConfiguration.TONEMAPPING_ACES;
}
}

83
src/babylonjs/MainApp.ts Normal file
View File

@ -0,0 +1,83 @@
/**
* @file MainApp.ts
* @description 主应用类,负责初始化和协调所有子模块
*/
import { AppDom } from './AppDom';
import { AppEngin } from './AppEngin';
import { AppScene } from './AppScene';
import { AppCamera } from './AppCamera';
import { AppLight } from './AppLight';
import { AppEnv } from './AppEnv';
import { AppModel } from './AppModel';
import { AppConfig } from './AppConfig';
/**
* 主应用类 - 3D场景的核心控制器
* 负责管理DOM、引擎、场景、相机、灯光、环境、模型和动画等子模块
*/
export class MainApp {
appDom: AppDom;
appEngin: AppEngin;
appScene: AppScene;
appCamera: AppCamera;
appModel: AppModel;
appLight: AppLight;
appEnv: AppEnv;
constructor() {
this.appDom = new AppDom();
this.appEngin = new AppEngin(this);
this.appScene = new AppScene(this);
this.appCamera = new AppCamera(this);
this.appModel = new AppModel(this);
this.appLight = new AppLight(this);
this.appEnv = new AppEnv(this);
window.addEventListener("resize", () => this.appEngin.handleResize());
}
/**
* 加载应用配置
* @param config 配置对象
*/
loadAConfig(config: any): void {
AppConfig.container = config.container || 'renderDom';
AppConfig.modelUrlList = config.modelUrlList || [];
AppConfig.success = config.success;
AppConfig.error = config.error;
}
loadModel(): void {
this.appModel.loadModel();
}
/** 唤醒/初始化所有子模块 */
async Awake(): Promise<void> {
this.appDom.Awake();
this.appEngin.Awake();
this.appScene.Awake();
this.appCamera.Awake();
this.appLight.Awake();
this.appEnv.Awake();
this.appModel.initManagers();
this.update();
}
/** 启动渲染循环 */
update(): void {
if (!this.appEngin.object) return;
this.appEngin.object.runRenderLoop(() => {
this.appScene.object?.render();
this.appCamera.update();
});
}
/** 销毁应用,释放所有资源 */
async dispose(): Promise<void> {
this.appModel?.clean();
this.appEnv?.clean();
}
}

14
src/base/Monobehiver.ts Normal file
View File

@ -0,0 +1,14 @@
/**
* 组件基础类 - 提供生命周期方法
*/
export class Monobehiver<TMainApp = any> {
protected mainApp: TMainApp;
constructor(mainApp: TMainApp) {
this.mainApp = mainApp;
}
/** 醒来/初始化,子类可重写 */
// eslint-disable-next-line @typescript-eslint/no-empty-function
Awake(): void {}
}

65
src/components/auth.ts Normal file
View File

@ -0,0 +1,65 @@
import { userLogin } from "../apis/services";
export type AuthCache = {
uid: string;
eid: string;
privilege: string;
token: string;
};
type LoginResponse = {
result?: number;
content?: string;
};
/**
* 认证模块 - 处理用户登录和凭证信息
*/
const auth = {
cache: null as AuthCache | null,
/** 保存认证信息 */
save(value: AuthCache): void {
this.cache = value;
},
/** 获取缓存的认证信息 */
load(): AuthCache | null {
return this.cache;
},
/**
* 用户登录
* @param name 用户名
*/
login: async function (name: string): Promise<AuthCache | null> {
const normalizedName = typeof name === 'string' ? name.trim() : '';
if (!normalizedName) {
console.error('登录用户名不能为空');
return null;
}
const loginReq = { content: { obj1: normalizedName } };
try {
const loginResp = await userLogin(loginReq) as LoginResponse;
if (loginResp?.result === 1 && typeof loginResp.content === 'string') {
const parsed = loginResp.content
.split(';')
.map((item) => item.split('=')[1]);
const [, uid, eid, privilege, token] = parsed;
if (uid && eid && privilege && token) {
const userData: AuthCache = { uid, eid, privilege, token };
this.save(userData);
return userData;
}
}
} catch (error) {
console.error(error);
}
return null;
}
};
export default auth;

79
src/components/conf.ts Normal file
View File

@ -0,0 +1,79 @@
import { httpClient } from "../apis/axios";
export type ConfiguratorParams = {
name: string;
network?: string;
modelName?: string;
testModelName?: boolean;
readLocalResource?: boolean;
modelDir?: boolean;
binDir?: boolean;
gltfDir?: boolean;
animDir?: boolean;
};
export type Configurator = {
record: boolean;
binAssetDir: string;
gltfAssetDir: string;
animAssetDir: string;
modelAssetDir: string;
isOnline: boolean;
modelName: string;
screenshot: boolean;
targetModelName?: string;
init: (params: ConfiguratorParams) => Promise<boolean>;
};
const configurator: Configurator = {
record: false,
binAssetDir: 'statics/glb/',
gltfAssetDir: 'statics/gltf',
animAssetDir: 'statics/anim',
modelAssetDir: 'statics/models',
isOnline: false,
modelName: '',
screenshot: false,
targetModelName: undefined,
/** 初始化应用配置 */
init: async function (params: ConfiguratorParams): Promise<boolean> {
if (typeof params !== 'object' || params === null) {
console.error("params must be an object");
return false;
}
if (typeof params.name !== 'string' || params.name.length < 1) {
console.error("params.name must be a string");
return false;
}
if (params.modelName !== undefined) {
configurator.modelName = params.modelName;
}
if (params.testModelName === true) {
configurator.targetModelName = params.modelName;
}
if (params.network !== undefined) {
httpClient.baseURL = params.network;
}
if (params.readLocalResource === false) {
configurator.isOnline = true;
configurator.modelAssetDir = `${httpClient.baseURL}/api/v2/assets/sign/latest/glb`;
configurator.binAssetDir = `${httpClient.baseURL}/api/v3/assets/sign/latest/glb/bin/`;
configurator.gltfAssetDir = `${httpClient.baseURL}/api/v3/assets/sign/latest/glb`;
configurator.animAssetDir = `${httpClient.baseURL}/api/v3/assets/sign/latest/glb/anim`;
} else {
if (params.modelDir === false) configurator.modelAssetDir = `${httpClient.baseURL}/api/v2/assets/sign/latest/glb`;
if (params.binDir === false) configurator.binAssetDir = `${httpClient.baseURL}/api/v3/assets/sign/latest/glb/bin/`;
if (params.gltfDir === false) configurator.gltfAssetDir = `${httpClient.baseURL}/api/v3/assets/sign/latest/glb`;
if (params.animDir === false) configurator.animAssetDir = `${httpClient.baseURL}/api/v3/assets/sign/latest/glb/anim`;
}
return true;
}
};
export default configurator;

74
src/main.ts Normal file
View File

@ -0,0 +1,74 @@
import { MainApp } from './babylonjs/MainApp';
import configurator, { ConfiguratorParams } from './components/conf';
import auth from './components/auth';
import { on } from './utils/event';
declare global {
interface Window {
faceSDK?: Record<string, unknown>;
yiyu?: Record<string, unknown>;
}
}
type InitParams = {
container?: string;
modelUrlList?: string[];
animationUrlList?: string[];
idleAnimationUrlList?: string[];
onSuccess?: () => void;
onError?: (error?: unknown) => void;
apiConfig?: ConfiguratorParams;
};
let mainApp: MainApp | null = null;
const kernel = {
/** 初始化应用 */
init: async function (params: InitParams): Promise<void> {
if (!params) { console.error('params is required'); return; }
if (params.apiConfig) {
await configurator.init(params.apiConfig);
if (params.apiConfig.name) {
const userInfo = await auth.login(params.apiConfig.name);
if (!userInfo) {
console.error('failed to fetch user');
return;
}
}
}
mainApp = new MainApp();
mainApp.loadAConfig({
container: params.container || 'renderDom',
modelUrlList: params.modelUrlList || [],
success: params.onSuccess ?? null,
error: params.onError ?? null
});
await mainApp.Awake();
await mainApp.loadModel();
this.debugMorphTargets();
}
};
if (!window.faceSDK) {
window.faceSDK = {};
}
window.faceSDK.kernel = kernel;
if (!window.yiyu) {
window.yiyu = {};
}
window.yiyu.kernel = kernel;
window.yiyu.onAppLoaded = () => { };
window.yiyu.onSingleSignFinished = (_text: string) => { };
window.yiyu.onSentenceFinished = (_text: string) => { };
window.yiyu.onSentenceChanged = (_data: unknown) => { };
window.yiyu.onGloss = (_gloss: unknown) => { };
window.onload = () => { };
export { kernel };

View File

@ -0,0 +1,19 @@
import { Monobehiver } from '../base/Monobehiver';
import { Dictionary } from '../utils/Dictionary';
/**
* 管理器基类 - 提供通用的初始化和缓存能力
*/
export class BaseManager<TMainApp = any> extends Monobehiver<TMainApp> {
protected meshCache = new Dictionary<unknown>();
protected isInitialized = false;
async initialize(): Promise<void> {
if (this.isInitialized) return;
this.isInitialized = true;
}
clean(): void {
this.meshCache.Clear();
}
}

3
src/types/js-md5.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
declare module 'js-md5' {
export default function md5(message: string | ArrayBuffer | ArrayBufferView): string;
}

50
src/utils/Dictionary.ts Normal file
View File

@ -0,0 +1,50 @@
/**
* @file Dictionary.js
* @description 字典工具类 - 键值对存储
*/
/**
* 字典类 - 提供类似Map的键值对存储功能
*/
export class Dictionary<T = unknown> {
private items: Record<string, T> = {};
/** 检查是否存在指定键 */
Has(key: string): boolean {
return key in this.items;
}
/** 设置键值对 */
Set(key: string, value: T): void {
this.items[key] = value;
}
/** 移除指定键 */
Remove(key: string): boolean {
if (this.Has(key)) {
delete this.items[key];
return true;
}
return false;
}
/** 获取指定键的值 */
Get(key: string): T | undefined {
return this.Has(key) ? this.items[key] : undefined;
}
/** 获取所有键 */
Keys(): string[] {
return Object.keys(this.items);
}
/** 获取所有值 */
Values(): T[] {
return Object.values(this.items);
}
/** 清空字典 */
Clear(): void {
this.items = {};
}
}

25
src/utils/compressor.ts Normal file
View File

@ -0,0 +1,25 @@
/**
* @file compressor.js
* @description 解压缩工具使用pako完成与GoBetterStudio相同的deflate/raw解码
*/
import pako from 'pako';
const decoder = new TextDecoder();
export const compressor = {
/** 压缩字符串或字节数据 */
compress(data: Uint8Array | string): Uint8Array {
const source = typeof data === 'string' ? new TextEncoder().encode(data) : data;
return pako.deflateRaw(source);
},
/** 解压缩字节数据并返回字符串 */
decompress(data: ArrayBuffer | Uint8Array): string {
const input = data instanceof Uint8Array ? data : new Uint8Array(data);
const uncompressed = pako.inflateRaw(input);
return decoder.decode(uncompressed);
}
};
export default compressor;

73
src/utils/cryptor.ts Normal file
View File

@ -0,0 +1,73 @@
import md5 from 'js-md5';
const KEY_SIZE = 256 / 32;
const ITERATIONS = 1000;
type EncryptedAsset = {
value: Uint8Array;
Timestamp: string;
};
type Credential = {
token: string;
uid: string;
};
type CryptoMaterial = {
obj: Uint8Array;
iv: Uint8Array;
salt: Uint8Array;
key: string;
};
function prepareCryptoMaterial(data: EncryptedAsset, info: Credential): CryptoMaterial {
const userIdPrefix = info.uid.slice(0, 16);
const iv = new TextEncoder().encode(userIdPrefix);
const obj = data.value.slice(7);
const saltHex = md5(`${info.token}${data.Timestamp}`);
const saltBytes = new Uint8Array((saltHex.match(/.{1,2}/g) ?? []).map(byte => parseInt(byte, 16)));
return { obj, iv, salt: saltBytes, key: info.token };
}
/** 从服务器返回的数据中异步解密出原始GLTF内容 */
export async function decryptAsync(data: EncryptedAsset, info: Credential): Promise<ArrayBuffer | null> {
const { obj, iv, salt, key } = prepareCryptoMaterial(data, info);
const derivedKey = await generateAesKeyAsync(key, salt, KEY_SIZE, ITERATIONS);
return aesDecryptAsync(obj, derivedKey, iv);
}
async function generateAesKeyAsync(secret: string, salt: Uint8Array, keySize: number, iterations: number): Promise<CryptoKey> {
const passwordBuffer = new TextEncoder().encode(secret);
const baseKey = await crypto.subtle.importKey(
'raw',
passwordBuffer,
{ name: 'PBKDF2' },
false,
['deriveBits', 'deriveKey']
);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations,
hash: 'SHA-1'
},
baseKey,
{ name: 'AES-CBC', length: keySize * 32 },
false,
['decrypt']
);
}
async function aesDecryptAsync(data: Uint8Array, key: CryptoKey, iv: Uint8Array): Promise<ArrayBuffer | null> {
try {
return await crypto.subtle.decrypt(
{ name: 'AES-CBC', iv },
key,
data
);
} catch (error) {
console.error('解密失败:', error);
return null;
}
}

123
src/utils/event.ts Normal file
View File

@ -0,0 +1,123 @@
type Listener = {
callback: (...args: unknown[]) => void;
context?: unknown;
};
type EventMeta = {
type: string;
description: string;
listeners: Listener[];
};
/**
* 基础事件发射器
*/
export class Emitter {
private _events: Record<string, Listener[]> = {};
on(name: string, callback: (...args: unknown[]) => void, context?: unknown): this {
if (!this._events[name]) {
this._events[name] = [];
}
this._events[name].push({ callback, context });
return this;
}
once(name: string, callback: (...args: unknown[]) => void, context?: unknown): this {
const onceWrapper = (...args: unknown[]) => {
this.off(name, onceWrapper);
callback.apply(context, args);
};
return this.on(name, onceWrapper, context);
}
off(name?: string, callback?: (...args: unknown[]) => void): this {
if (!name) {
this._events = {};
return this;
}
if (!this._events[name]) return this;
if (!callback) {
delete this._events[name];
return this;
}
this._events[name] = this._events[name].filter(
listener => listener.callback !== callback
);
return this;
}
removeAllListeners(): this {
this._events = {};
return this;
}
emit(name: string, ...args: unknown[]): this {
if (!this._events[name]) return this;
this._events[name].forEach(listener => {
listener.callback.apply(listener.context, args);
});
return this;
}
listenerCount(name: string): number {
return this._events[name]?.length ?? 0;
}
}
/**
* 全局事件管理器
*/
export class EventManager extends Emitter {
private eventMap: Map<string, EventMeta>;
constructor() {
super();
this.eventMap = new Map();
}
registerEvent(type: string, description: string): void {
this.eventMap.set(type, { type, description, listeners: [] });
}
getRegisteredEvents(): EventMeta[] {
return Array.from(this.eventMap.values());
}
}
// 创建全局事件管理器实例
export const eventManager = new EventManager();
// 注册标准事件类型(描述使用英文,避免编码问题)
eventManager.registerEvent('load', 'resource load complete');
eventManager.registerEvent('load-progress', 'resource load progress');
eventManager.registerEvent('load-error', 'resource load error');
eventManager.registerEvent('animation-start', 'animation start');
eventManager.registerEvent('animation-end', 'animation end');
eventManager.registerEvent('animation-loop', 'animation loop');
eventManager.registerEvent('model-change', 'model change');
eventManager.registerEvent('camera-change', 'camera change');
eventManager.registerEvent('scene-ready', 'scene ready');
eventManager.registerEvent('dispose', 'component disposed');
// 导出便捷函数
export function on(eventName: string, callback: (...args: unknown[]) => void, context?: unknown): Emitter {
return eventManager.on(eventName, callback, context);
}
export function off(eventName?: string, callback?: (...args: unknown[]) => void): Emitter {
return eventManager.off(eventName, callback);
}
export function once(eventName: string, callback: (...args: unknown[]) => void, context?: unknown): Emitter {
return eventManager.once(eventName, callback, context);
}
export function emit(eventName: string, ...args: unknown[]): Emitter {
return eventManager.emit(eventName, ...args);
}
export function removeAllListeners(eventName?: string): Emitter {
if (eventName) return eventManager.off(eventName);
return eventManager.removeAllListeners();
}