diff --git a/source/Component/OfflineRender/OfflineRender.scss b/source/Component/OfflineRender/OfflineRender.scss new file mode 100644 index 0000000..3986c6d --- /dev/null +++ b/source/Component/OfflineRender/OfflineRender.scss @@ -0,0 +1,8 @@ +@import "../Theme/Theme.scss"; + +div.offline-render-popup { + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 10px; +} \ No newline at end of file diff --git a/source/Component/OfflineRender/OfflineRender.tsx b/source/Component/OfflineRender/OfflineRender.tsx new file mode 100644 index 0000000..d93bd64 --- /dev/null +++ b/source/Component/OfflineRender/OfflineRender.tsx @@ -0,0 +1,124 @@ +import { Component, ReactNode } from "react"; +import { Popup } from "@Context/Popups"; +import { useStatusWithEvent, IMixinStatusProps } from "@Context/Status"; +import { Localization } from "@Component/Localization/Localization"; +import { AttrInput } from "@Input/AttrInput/AttrInput"; +import { Message } from "@Input/Message/Message"; +import { ConfirmContent } from "@Component/ConfirmPopup/ConfirmPopup"; +import { ProcessPopup } from "@Component/ProcessPopup/ProcessPopup"; +import { Emitter } from "@Model/Emitter"; +import "./OfflineRender.scss"; + +interface IOfflineRenderProps { + close?: () => any; +} + +interface IOfflineRenderState { + time: number; + fps: number; + name: string; +} + +class OfflineRender extends Popup { + + public minWidth: number = 250; + public minHeight: number = 150; + public width: number = 400; + public height: number = 300; + + public maskForSelf: boolean = true; + + public onRenderHeader(): ReactNode { + return + } + + public render(): ReactNode { + return { + this.close(); + }}/> + } +} + +@useStatusWithEvent() +class OfflineRenderComponent extends Component { + + public constructor(props: IOfflineRenderProps & IMixinStatusProps) { + super(props); + this.state = { + name: this.props.status?.getNewClipName() ?? "", + time: 10, + fps: 60 + } + } + + public render(): ReactNode { + return { + + // 获取新实例 + let newClip = this.props.status?.newClip(); + + if (newClip) { + newClip.name = this.state.name; + this.props.status?.actuator.offlineRender(newClip, this.state.time, this.state.fps); + + // 开启进度条弹窗 + this.props.status?.popup.showPopup(ProcessPopup, {}); + } + + // 关闭这个弹窗 + this.props.close && this.props.close(); + } + }]} + > + + + + { + this.setState({ + name: val + }); + }} + /> + + { + this.setState({ + time: parseFloat(val) + }); + }} + /> + + { + this.setState({ + fps: parseFloat(val) + }); + }} + /> + + + } +} + +export { OfflineRender }; \ No newline at end of file diff --git a/source/Component/ProcessPopup/ProcessPopup.scss b/source/Component/ProcessPopup/ProcessPopup.scss new file mode 100644 index 0000000..380bf32 --- /dev/null +++ b/source/Component/ProcessPopup/ProcessPopup.scss @@ -0,0 +1,42 @@ +@import "../Theme/Theme.scss"; + +div.process-popup { + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 10px; + + div.ms-ProgressIndicator { + transform: none; + + div.ms-ProgressIndicator-progressTrack { + transform: none; + } + + div.ms-ProgressIndicator-progressBar { + transform: none; + } + } +} + +div.confirm-root.dark div.ms-ProgressIndicator { + + div.ms-ProgressIndicator-progressTrack { + background-color: $lt-bg-color-lvl3-dark; + } + + div.ms-ProgressIndicator-progressBar { + background-color: $lt-font-color-normal-dark; + } +} + +div.confirm-root.light div.ms-ProgressIndicator { + + div.ms-ProgressIndicator-progressTrack { + background-color: $lt-bg-color-lvl3-light; + } + + div.ms-ProgressIndicator-progressBar { + background-color: $lt-font-color-normal-light; + } +} \ No newline at end of file diff --git a/source/Component/ProcessPopup/ProcessPopup.tsx b/source/Component/ProcessPopup/ProcessPopup.tsx new file mode 100644 index 0000000..8a5500f --- /dev/null +++ b/source/Component/ProcessPopup/ProcessPopup.tsx @@ -0,0 +1,89 @@ +import { Component, ReactNode } from "react"; +import { Popup } from "@Context/Popups"; +import { Localization } from "@Component/Localization/Localization"; +import { ConfirmContent } from "@Component/ConfirmPopup/ConfirmPopup"; +import { useStatusWithEvent, IMixinStatusProps } from "@Context/Status"; +import { ProgressIndicator } from "@fluentui/react"; +import { ActuatorModel } from "@Model/Actuator"; +import "./ProcessPopup.scss"; + +interface IProcessPopupProps { + close?: () => void; +} + +class ProcessPopup extends Popup { + + public minWidth: number = 400; + public minHeight: number = 150; + public width: number = 400; + public height: number = 150; + + public maskForSelf: boolean = true; + + public onClose(): void {} + + public onRenderHeader(): ReactNode { + return + } + + public render(): ReactNode { + return this.close()}/> + } +} + +@useStatusWithEvent("offlineLoop", "actuatorStartChange", "recordLoop") +class ProcessPopupComponent extends Component { + + public render(): ReactNode { + + let current = this.props.status?.actuator.offlineCurrentFrame ?? 0; + let all = this.props.status?.actuator.offlineAllFrame ?? 0; + + const isRendering = this.props.status?.actuator.mod === ActuatorModel.Offline; + let i18nKey = ""; + let color: undefined | "red"; + let onClick = () => {}; + + if (isRendering) { + i18nKey = "Popup.Offline.Render.Input.End"; + color = "red"; + onClick = () => { + this.props.status?.actuator.endOfflineRender(); + this.forceUpdate(); + } + } + + else { + i18nKey = "Popup.Offline.Render.Input.Finished"; + onClick = () => { + this.props.close && this.props.close(); + } + } + + return + + + + + + + } +} + +export { ProcessPopup }; \ No newline at end of file diff --git a/source/Context/Status.tsx b/source/Context/Status.tsx index 2ac2e14..868ea03 100644 --- a/source/Context/Status.tsx +++ b/source/Context/Status.tsx @@ -37,6 +37,7 @@ interface IStatusEvent { renderLoop: number; physicsLoop: number; recordLoop: number; + offlineLoop: number; mouseModChange: void; focusObjectChange: void; focusLabelChange: void; @@ -129,6 +130,7 @@ class Status extends Emitter { // 循环事件 this.actuator.on("loop", (t) => { this.emit("physicsLoop", t) }); this.actuator.on("record", (t) => { this.emit("recordLoop", t) }); + this.actuator.on("offline", (t) => { this.emit("offlineLoop", t) }); // 对象变化事件 this.model.on("objectChange", () => this.emit("objectChange")); @@ -424,7 +426,7 @@ class Status extends Emitter { return label; } - public newClip() { + public getNewClipName() { let searchKey = I18N(this.setting.language, "Object.List.New.Clip", { id: "" }); let nextIndex = 1; this.model.clipPool.forEach((obj) => { @@ -432,11 +434,13 @@ class Status extends Emitter { obj.name, searchKey )); }); - const clip = this.model.addClip( - I18N(this.setting.language, "Object.List.New.Clip", { - id: nextIndex.toString() - }) - ); + return I18N(this.setting.language, "Object.List.New.Clip", { + id: nextIndex.toString() + }); + } + + public newClip() { + const clip = this.model.addClip(this.getNewClipName()); return clip; } diff --git a/source/Localization/EN-US.ts b/source/Localization/EN-US.ts index 03d0a57..2c9b0f6 100644 --- a/source/Localization/EN-US.ts +++ b/source/Localization/EN-US.ts @@ -72,6 +72,16 @@ const EN_US = { "Popup.Delete.Clip.Confirm": "Are you sure you want to delete this clip? The clip cannot be restored after deletion.", "Popup.Restore.Behavior.Confirm": "Are you sure you want to reset all parameters of this behavior? This operation cannot be recalled.", "Popup.Setting.Title": "Preferences setting", + "Popup.Offline.Render.Title": "Offline rendering", + "Popup.Offline.Render.Process.Title": "Rendering progress", + "Popup.Offline.Render.Message": "Rendering Parameters", + "Popup.Offline.Render.Input.Name": "Clip name", + "Popup.Offline.Render.Input.Time": "Duration (s)", + "Popup.Offline.Render.Input.Fps": "FPS (f/s)", + "Popup.Offline.Render.Input.Start": "Start rendering", + "Popup.Offline.Render.Input.End": "Terminate rendering", + "Popup.Offline.Render.Input.Finished": "Finished", + "Popup.Offline.Render.Process": "Number of frames completed: {current} / {all}", "Popup.Load.Save.Title": "Load save", "Popup.Load.Save.confirm": "Got it", "Popup.Load.Save.Overwrite": "Overwrite and continue", diff --git a/source/Localization/ZH-CN.ts b/source/Localization/ZH-CN.ts index f1ad359..d01f108 100644 --- a/source/Localization/ZH-CN.ts +++ b/source/Localization/ZH-CN.ts @@ -72,6 +72,16 @@ const ZH_CN = { "Popup.Delete.Clip.Confirm": "你确定删除这个剪辑片段,剪辑片段删除后将无法恢复。", "Popup.Restore.Behavior.Confirm": "你确定要重置此行为的全部参数吗?此操作无法撤回。", "Popup.Setting.Title": "首选项设置", + "Popup.Offline.Render.Title": "离线渲染", + "Popup.Offline.Render.Process.Title": "渲染进度", + "Popup.Offline.Render.Message": "渲染参数", + "Popup.Offline.Render.Input.Name": "剪辑名称", + "Popup.Offline.Render.Input.Time": "时长 (s)", + "Popup.Offline.Render.Input.Fps": "帧率 (f/s)", + "Popup.Offline.Render.Input.Start": "开始渲染", + "Popup.Offline.Render.Input.End": "终止渲染", + "Popup.Offline.Render.Input.Finished": "完成", + "Popup.Offline.Render.Process": "完成帧数: {current} / {all}", "Popup.Load.Save.Title": "加载存档", "Popup.Load.Save.confirm": "我知道了", "Popup.Load.Save.Overwrite": "覆盖并继续", diff --git a/source/Model/Actuator.ts b/source/Model/Actuator.ts index 003696d..20314c4 100644 --- a/source/Model/Actuator.ts +++ b/source/Model/Actuator.ts @@ -13,6 +13,7 @@ interface IActuatorEvent { startChange: boolean; record: number; loop: number; + offline: number; } /** @@ -234,6 +235,107 @@ class Actuator extends Emitter { } } + /** + * 离线渲染参数 + */ + public offlineAllFrame: number = 0; + public offlineCurrentFrame: number = 0; + private offlineRenderTickTimer?: number; + + /** + * 关闭离线渲染 + */ + public endOfflineRender() { + + // 清除 timer + clearTimeout(this.offlineRenderTickTimer); + + this.recordClip && (this.recordClip.isRecording = false); + this.recordClip = undefined; + + // 设置状态 + this.mod = ActuatorModel.View; + + // 激发结束事件 + this.start(false); + this.emit("record", 0); + } + + /** + * 离线渲染 tick + */ + private offlineRenderTick(dt: number) { + + if (this.mod !== ActuatorModel.Offline) { + return; + } + + if (this.offlineCurrentFrame >= this.offlineAllFrame) { + return this.endOfflineRender(); + } + + // 更新模型 + this.model.update(dt); + + // 录制 + this.recordClip?.record(dt); + + // 限制更新频率 + if (this.offlineCurrentFrame % 10 === 0) { + this.emit("offline", dt); + } + + this.offlineCurrentFrame++ + + if (this.offlineCurrentFrame <= this.offlineAllFrame) { + + // 下一个 tick + this.offlineRenderTickTimer = setTimeout(() => this.offlineRenderTick(dt)) as any; + + } else { + this.endOfflineRender(); + } + } + + /** + * 离线渲染 + */ + public offlineRender(clip: Clip, time: number, fps: number) { + + // 记录录制片段 + this.recordClip = clip; + clip.isRecording = true; + + // 如果仿真正在进行,停止仿真 + if (this.start()) this.start(false); + + // 如果正在录制,阻止 + if (this.mod === ActuatorModel.Record || this.mod === ActuatorModel.Offline) { + return; + } + + // 如果正在播放,暂停播放 + if (this.mod === ActuatorModel.Play) { + this.pausePlay(); + } + + // 设置状态 + this.mod = ActuatorModel.Offline; + + // 计算帧数 + this.offlineCurrentFrame = 0; + this.offlineAllFrame = Math.round(time * fps) - 1; + let dt = time / this.offlineAllFrame; + + // 第一帧渲染 + clip.record(0); + + // 开启时钟 + this.offlineRenderTick(dt); + + this.emit("record", dt); + } + /** * 播放时钟 */ diff --git a/source/Panel/ClipPlayer/ClipPlayer.tsx b/source/Panel/ClipPlayer/ClipPlayer.tsx index efa5a0a..9d27929 100644 --- a/source/Panel/ClipPlayer/ClipPlayer.tsx +++ b/source/Panel/ClipPlayer/ClipPlayer.tsx @@ -6,6 +6,7 @@ import { Message } from "@Input/Message/Message"; import { Clip } from "@Model/Clip"; import { ActuatorModel } from "@Model/Actuator"; import { ConfirmPopup } from "@Component/ConfirmPopup/ConfirmPopup"; +import { OfflineRender } from "@Component/OfflineRender/OfflineRender" import "./ClipPlayer.scss"; @useStatusWithEvent("clipChange", "focusClipChange", "actuatorStartChange") @@ -50,6 +51,7 @@ class ClipPlayer extends Component { }} add={() => { this.isInnerClick = true; + this.props.status?.popup.showPopup(OfflineRender, {}); }} click={(clip) => { this.isInnerClick = true;