feat: VFS 虚拟仿真实验核心框架 v2

- 加权评分系统:每步独立 ScoreWeight,满分 100,SequenceClick 按子项比例计分
- StepData 新增 DetailedContent(学习模式详细操作说明)和 SubStepCount
- 伤情检查步骤扩展为 5 子项序列(视觉检查→肿胀→疼痛→生命体征→神经评估)
- 夹板固定模式完整评分(选择夹板→放置衬垫→夹板固定→固定后检查)
- Doc/ 文件夹归档三份文档:项目大纲、文案、评分标准
- README 更新评分权重表和夹板固定流程
This commit is contained in:
yangbear
2026-06-24 02:07:43 +08:00
commit a11e003fcd
23 changed files with 2377 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System;
/// <summary>
/// 考核弹窗:选择题 + 操作确认。
/// 挂在弹窗 Panel 上,由 UIManager 调用 Show()。
/// 回调返回 proportion (0~1 得分比例) 和 feedback。
/// </summary>
public class ExamPopup : MonoBehaviour
{
[Header("UI 组件")]
[SerializeField] private GameObject _popupRoot;
[SerializeField] private TMP_Text _questionText;
[SerializeField] private Toggle[] _optionToggles;
[SerializeField] private TMP_Text[] _optionLabels;
[SerializeField] private Button _confirmButton;
[SerializeField] private TMP_Text _feedbackText;
[Header("操作题 UI(点击序列)")]
[SerializeField] private GameObject _operationPanel;
[SerializeField] private TMP_Text _operationHint;
private StepData _currentStep;
private Action<float, string> _onAnswerCallback;
private int _selectedIndex = -1;
private void Awake()
{
_confirmButton?.onClick.AddListener(OnConfirm);
if (_popupRoot != null) _popupRoot.SetActive(false);
}
public void Show(StepData step, Action<float, string> onAnswer)
{
_currentStep = step;
_onAnswerCallback = onAnswer;
_selectedIndex = -1;
if (_feedbackText != null) _feedbackText.text = "";
if (_popupRoot != null) _popupRoot.SetActive(true);
_questionText?.SetText(step.ExamQuestionText);
switch (step.CompleteType)
{
case StepCompleteType.MultipleChoice:
SetupMultipleChoice(step);
break;
case StepCompleteType.ClickObject:
SetupClickObject(step);
break;
case StepCompleteType.SequenceClick:
SetupSequenceClick(step);
break;
default:
SetupDefault(step);
break;
}
}
#region
private void SetupMultipleChoice(StepData step)
{
if (_operationPanel != null) _operationPanel.SetActive(false);
int optionCount = step.ExamOptions?.Length ?? 0;
for (int i = 0; i < _optionToggles.Length; i++)
{
bool active = i < optionCount;
_optionToggles[i].gameObject.SetActive(active);
_optionToggles[i].isOn = false;
_optionToggles[i].onValueChanged.RemoveAllListeners();
int idx = i;
_optionToggles[i].onValueChanged.AddListener((val) => { if (val) _selectedIndex = idx; });
if (active) _optionLabels[i]?.SetText(step.ExamOptions[i]);
}
_confirmButton?.gameObject.SetActive(true);
}
private void SetupClickObject(StepData step)
{
if (_operationPanel != null) _operationPanel.SetActive(true);
if (_optionToggles != null)
foreach (var t in _optionToggles) t.gameObject.SetActive(false);
_confirmButton?.gameObject.SetActive(true);
_operationHint?.SetText($"请点击场景中的 {step.TargetTag} 物体");
}
private void SetupSequenceClick(StepData step)
{
if (_operationPanel != null) _operationPanel.SetActive(true);
if (_optionToggles != null)
foreach (var t in _optionToggles) t.gameObject.SetActive(false);
_confirmButton?.gameObject.SetActive(true);
_operationHint?.SetText("请按正确顺序点击物体,完成后按确定");
}
private void SetupDefault(StepData step)
{
if (_operationPanel != null) _operationPanel.SetActive(false);
if (_optionToggles != null)
foreach (var t in _optionToggles) t.gameObject.SetActive(false);
_confirmButton?.gameObject.SetActive(true);
}
#endregion
#region
private void OnConfirm()
{
if (_currentStep == null) return;
float proportion = 1f;
string feedback = "";
switch (_currentStep.CompleteType)
{
case StepCompleteType.MultipleChoice:
bool correct = _selectedIndex == _currentStep.CorrectOptionIndex;
proportion = correct ? 1f : 0f;
feedback = correct ? "" : _currentStep.ErrorFeedback;
if (!correct && _feedbackText != null)
_feedbackText.text = feedback;
break;
default:
proportion = 1f;
break;
}
if (proportion <= 0f && !string.IsNullOrEmpty(feedback))
{
_onAnswerCallback?.Invoke(0f, feedback);
Hide();
}
else
{
_onAnswerCallback?.Invoke(proportion, feedback);
Hide();
}
}
public void Hide()
{
if (_popupRoot != null) _popupRoot.SetActive(false);
_currentStep = null;
_onAnswerCallback = null;
}
#endregion
}
+117
View File
@@ -0,0 +1,117 @@
using UnityEngine;
using UnityEngine.UI;
using TMPro;
/// <summary>
/// 主菜单:实验类型选择 + 模式选择 + 固定方式选择。
/// 挂在主菜单场景的 Canvas 上。
/// </summary>
public class MainMenu : MonoBehaviour
{
[Header("主标题")]
[SerializeField] private TMP_Text _titleText;
[Header("实验选择按钮 - 底部图标")]
[SerializeField] private Button _btnCPR;
[SerializeField] private Button _btnFracture;
[SerializeField] private Button _btnSprain;
[SerializeField] private Button _btnBleeding;
[SerializeField] private Button _btnTransport;
[Header("模式选择")]
[SerializeField] private TMP_Text _tipText;
[SerializeField] private Button _btnLearnMode;
[SerializeField] private Button _btnExamMode;
[SerializeField] private TMP_Text _selectedModeLabel;
[Header("固定方式选择(仅骨折固定)")]
[SerializeField] private GameObject _fixationPanel;
[SerializeField] private Button _btnLimbFixation;
[SerializeField] private Button _btnSplintFixation;
[SerializeField] private TMP_Text _selectedFixationLabel;
[Header("开始按钮")]
[SerializeField] private Button _btnStart;
[Header("提示")]
[SerializeField] private TMP_Text _selectionTip;
// 当前选择
private ExperimentType _selectedType = ExperimentType.FractureFixation;
private ExperimentMode _selectedMode = ExperimentMode.Learn;
private FixationMethod _selectedFixation = FixationMethod.LimbFixation;
private void Start()
{
BindExperimentButtons();
_btnLearnMode?.onClick.AddListener(() => SelectMode(ExperimentMode.Learn));
_btnExamMode?.onClick.AddListener(() => SelectMode(ExperimentMode.Exam));
_btnLimbFixation?.onClick.AddListener(() => SelectFixation(FixationMethod.LimbFixation));
_btnSplintFixation?.onClick.AddListener(() => SelectFixation(FixationMethod.SplintFixation));
_btnStart?.onClick.AddListener(StartExperiment);
// 默认选中骨折固定
SelectExperiment(ExperimentType.FractureFixation);
SelectMode(ExperimentMode.Learn);
SelectFixation(FixationMethod.LimbFixation);
}
private void BindExperimentButtons()
{
_btnCPR?.onClick.AddListener(() => SelectExperiment(ExperimentType.CPR));
_btnFracture?.onClick.AddListener(() => SelectExperiment(ExperimentType.FractureFixation));
_btnSprain?.onClick.AddListener(() => SelectExperiment(ExperimentType.SprainBandaging));
_btnBleeding?.onClick.AddListener(() => SelectExperiment(ExperimentType.BleedingBandaging));
_btnTransport?.onClick.AddListener(() => SelectExperiment(ExperimentType.CasualtyTransport));
}
private void SelectExperiment(ExperimentType type)
{
_selectedType = type;
// 骨折固定显示固定方式面板
bool isFracture = type == ExperimentType.FractureFixation;
if (_fixationPanel != null) _fixationPanel.SetActive(isFracture);
if (_selectionTip != null)
_selectionTip.text = isFracture ? "Tip:请选择固定方式" : "";
UpdateStartButton();
}
private void SelectMode(ExperimentMode mode)
{
_selectedMode = mode;
if (_selectedModeLabel != null)
_selectedModeLabel.text = mode == ExperimentMode.Learn ? "学习模式" : "考核模式";
UpdateStartButton();
}
private void SelectFixation(FixationMethod fixation)
{
_selectedFixation = fixation;
if (_selectedFixationLabel != null)
_selectedFixationLabel.text = fixation == FixationMethod.LimbFixation ? "肢体固定" : "夹板固定";
UpdateStartButton();
}
private void UpdateStartButton()
{
if (_btnStart == null) return;
_btnStart.interactable = true;
}
private void StartExperiment()
{
var gm = GameManager.Instance;
if (gm == null)
{
Debug.LogError("MainMenu: GameManager.Instance 为空!请确保场景中有 GameManager。");
return;
}
gm.CurrentExperimentType = _selectedType;
gm.CurrentMode = _selectedMode;
gm.CurrentFixationMethod = _selectedFixation;
// 加载实验场景(所有实验共用一个场景,StepManager 根据配置区分)
UnityEngine.SceneManagement.SceneManager.LoadScene("Experiment");
}
}
+109
View File
@@ -0,0 +1,109 @@
using UnityEngine;
using UnityEngine.UI;
using TMPro;
/// <summary>
/// 实验结束后的结果展示面板。
/// 考核模式:显示加权成绩 + 错题分析列表(含每步得分/满分)。
/// 学习模式:显示注意事项。
/// </summary>
public class ScoreDisplay : MonoBehaviour
{
[Header("面板根节点")]
[SerializeField] private GameObject _panelRoot;
[Header("考核模式")]
[SerializeField] private GameObject _examResultRoot;
[SerializeField] private TMP_Text _scoreTitle;
[SerializeField] private TMP_Text _scoreValue;
[SerializeField] private Transform _analysisListParent;
[SerializeField] private GameObject _analysisItemPrefab;
[Header("学习模式 - 注意事项")]
[SerializeField] private GameObject _notesRoot;
[SerializeField] private TMP_Text _notesText;
[Header("底部按钮")]
[SerializeField] private Button _confirmButton;
private static readonly string NotesContent =
"1. 下肢损伤选择仰卧位;\n" +
"2. 骨折固定前后要脱掉鞋袜并检查下肢末梢循环、运动、感觉;\n" +
"3. 穿带子时应从健侧穿向伤侧;\n" +
"4. 骨折固定时先固定伤口的近心端,后固定远心端,再由上而下进行固定;\n" +
"5. 固定后尽可能抬高下肢。";
private void Awake()
{
_confirmButton?.onClick.AddListener(OnConfirm);
if (_panelRoot != null) _panelRoot.SetActive(false);
}
/// <summary>考核模式 - 显示加权成绩</summary>
public void ShowExamScore()
{
if (_panelRoot != null) _panelRoot.SetActive(true);
if (_examResultRoot != null) _examResultRoot.SetActive(true);
if (_notesRoot != null) _notesRoot.SetActive(false);
var gm = GameManager.Instance;
if (gm == null) return;
int score = gm.GetExamScoreRounded();
float pct = gm.GetExamPercentage();
if (_scoreTitle != null) _scoreTitle.text = "成绩显示栏";
if (_scoreValue != null) _scoreValue.text = $"考试成绩:{score} 分({pct:F0}%";
BuildAnalysisList();
}
/// <summary>学习模式 - 显示注意事项</summary>
public void ShowNotesOnly()
{
if (_panelRoot != null) _panelRoot.SetActive(true);
if (_examResultRoot != null) _examResultRoot.SetActive(false);
if (_notesRoot != null) _notesRoot.SetActive(true);
if (_notesText != null) _notesText.text = NotesContent;
}
private void BuildAnalysisList()
{
if (_analysisListParent == null || _analysisItemPrefab == null) return;
foreach (Transform child in _analysisListParent)
Destroy(child.gameObject);
var gm = GameManager.Instance;
if (gm == null || gm.StepScores == null) return;
for (int i = 0; i < gm.StepScores.Length; i++)
{
var item = Instantiate(_analysisItemPrefab, _analysisListParent);
var text = item.GetComponentInChildren<TMP_Text>();
if (text == null) continue;
float earned = gm.StepScores[i];
float max = gm.StepMaxScores[i];
bool fullScore = Mathf.Approximately(earned, max);
bool zeroScore = earned <= 0f;
string status;
if (fullScore) status = "<color=#00FF00>正确</color>";
else if (zeroScore) status = "<color=#FF0000>错误</color>";
else status = $"<color=#FFAA00>部分正确</color>";
string scorePart = $"{earned:F0}/{max:F0} 分)";
string fbPart = (!fullScore && !string.IsNullOrEmpty(gm.StepFeedback[i]))
? $"{gm.StepFeedback[i]}" : "";
text.text = $"第{i + 1}题:{status} {scorePart}{fbPart}";
}
}
private void OnConfirm()
{
if (_panelRoot != null) _panelRoot.SetActive(false);
UnityEngine.SceneManagement.SceneManager.LoadScene("MainMenu");
}
}
+221
View File
@@ -0,0 +1,221 @@
using UnityEngine;
using UnityEngine.UI;
using TMPro;
/// <summary>
/// UI 中央调度器:引用所有子面板,绑定 StepManager 事件。
/// 挂在 Canvas GameObject 上。
/// </summary>
public class UIManager : MonoBehaviour
{
[Header("管理器引用")]
[SerializeField] private StepManager _stepManager;
[SerializeField] private InteractionSystem _interactionSystem;
[Header("=== 顶部提示栏 ===")]
[SerializeField] private GameObject _tipPanel;
[SerializeField] private TMP_Text _tipText;
[Header("=== 详细操作说明(甲方补充 - 学习模式) ===")]
[SerializeField] private GameObject _detailPanel;
[SerializeField] private TMP_Text _detailText;
[Header("=== 对话气泡 ===")]
[SerializeField] private GameObject _dialogueBubble;
[SerializeField] private TMP_Text _dialogueText;
[Header("=== 画中画视频 ===")]
[SerializeField] private VideoController _videoController;
[Header("=== 右侧功能按钮 ===")]
[SerializeField] private Button _btnFullScreen;
[SerializeField] private Button _btnPrevious;
[SerializeField] private Button _btnNext;
[SerializeField] private Button _btnSwitchMode;
[SerializeField] private Button _btnReturn;
[SerializeField] private TMP_Text _btnSwitchModeLabel;
[Header("=== 底部进度条 ===")]
[SerializeField] private Slider _progressBar;
[SerializeField] private TMP_Text _progressText;
[Header("=== 弹窗 ===")]
[SerializeField] private ExamPopup _examPopup;
[SerializeField] private ScoreDisplay _scoreDisplay;
[Header("=== 底部主菜单图标 ===")]
[SerializeField] private Button _btnCPR;
[SerializeField] private Button _btnFracture;
[SerializeField] private Button _btnSprain;
[SerializeField] private Button _btnBleeding;
[SerializeField] private Button _btnTransport;
[Header("=== 固定方式选择(仅骨折固定) ===")]
[SerializeField] private GameObject _fixationPanel;
[SerializeField] private Button _btnLimbFixation;
[SerializeField] private Button _btnSplintFixation;
private void Start()
{
BindEvents();
BindButtons();
}
#region
private void BindEvents()
{
if (_stepManager == null) return;
_stepManager.OnStepChanged.AddListener(OnStepChanged);
_stepManager.OnTipChanged.AddListener(UpdateTip);
_stepManager.OnDialogueChanged.AddListener(UpdateDialogue);
_stepManager.OnDetailedContent.AddListener(UpdateDetailContent);
_stepManager.OnExamPopup.AddListener(ShowExamPopup);
_stepManager.OnExperimentComplete.AddListener(OnExperimentComplete);
_stepManager.OnVideoToggle.AddListener(ToggleVideo);
}
private void BindButtons()
{
_btnFullScreen?.onClick.AddListener(ToggleFullScreen);
_btnPrevious?.onClick.AddListener(OnPreviousClicked);
_btnNext?.onClick.AddListener(OnNextClicked);
_btnSwitchMode?.onClick.AddListener(OnSwitchModeClicked);
_btnReturn?.onClick.AddListener(OnReturnClicked);
_btnLimbFixation?.onClick.AddListener(() => _stepManager?.SwitchFixation(FixationMethod.LimbFixation));
_btnSplintFixation?.onClick.AddListener(() => _stepManager?.SwitchFixation(FixationMethod.SplintFixation));
}
#endregion
#region UI
private void OnStepChanged(int index)
{
UpdateProgressBar();
UpdateNavigationButtons();
}
private void UpdateTip(string text)
{
if (_tipPanel != null) _tipPanel.SetActive(!string.IsNullOrEmpty(text));
if (_tipText != null) _tipText.text = text;
}
private void UpdateDialogue(string text)
{
if (_dialogueBubble != null) _dialogueBubble.SetActive(!string.IsNullOrEmpty(text));
if (_dialogueText != null) _dialogueText.text = text;
}
private void UpdateDetailContent(string text)
{
if (_detailPanel != null) _detailPanel.SetActive(!string.IsNullOrEmpty(text));
if (_detailText != null) _detailText.text = text;
}
private void ToggleVideo(string fileName, bool active)
{
if (_videoController == null) return;
if (active && !string.IsNullOrEmpty(fileName))
_videoController.PlayVideo(fileName);
else
_videoController.StopVideo();
}
private void UpdateProgressBar()
{
if (_stepManager == null) return;
int cur = _stepManager.CurrentStepIndex + 1;
int total = _stepManager.TotalSteps;
if (_progressBar != null) _progressBar.value = (float)cur / total;
if (_progressText != null) _progressText.text = $"{cur}/{total}";
}
private void UpdateNavigationButtons()
{
if (_stepManager == null) return;
_btnPrevious.interactable = _stepManager.CurrentStepIndex > 0;
bool isLast = _stepManager.CurrentStepIndex >= _stepManager.TotalSteps - 1;
if (_btnNext != null)
{
var label = _btnNext.GetComponentInChildren<TMP_Text>();
if (label != null) label.text = isLast ? "完成" : "下一步";
}
}
#endregion
#region
private void ShowExamPopup(StepData step)
{
if (_examPopup == null) return;
_examPopup.Show(step, OnExamAnswer);
}
private void OnExamAnswer(float proportion, string feedback)
{
_stepManager?.OnStepInteractionComplete(proportion, feedback);
_stepManager?.NextStep();
}
#endregion
#region
private void OnExperimentComplete()
{
var gm = GameManager.Instance;
if (gm == null) return;
if (gm.CurrentMode == ExperimentMode.Exam)
{
_scoreDisplay?.ShowExamScore();
}
else
{
_scoreDisplay?.ShowNotesOnly();
}
}
#endregion
#region
private void ToggleFullScreen()
{
Screen.fullScreen = !Screen.fullScreen;
}
private void OnPreviousClicked()
{
_stepManager?.PreviousStep();
}
private void OnNextClicked()
{
if (_stepManager.Mode == ExperimentMode.Exam)
{
var step = _stepManager.GetCurrentStep();
if (step != null && step.CompleteType != StepCompleteType.MultipleChoice)
_stepManager.OnStepInteractionComplete(1f);
}
_stepManager.NextStep();
}
private void OnSwitchModeClicked()
{
var newMode = _stepManager.Mode == ExperimentMode.Learn
? ExperimentMode.Exam : ExperimentMode.Learn;
_stepManager.SwitchMode(newMode);
}
private void OnReturnClicked()
{
UnityEngine.SceneManagement.SceneManager.LoadScene("MainMenu");
}
#endregion
}
+83
View File
@@ -0,0 +1,83 @@
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Video;
/// <summary>
/// 画中画视频控制器:用 VideoPlayer 渲染到 RenderTexture,再赋给 RawImage。
/// 挂在包含 RawImage 和 VideoPlayer 的 GameObject 上。
/// </summary>
[RequireComponent(typeof(RawImage))]
public class VideoController : MonoBehaviour
{
[SerializeField] private VideoPlayer _videoPlayer;
[SerializeField] private RawImage _rawImage;
private RenderTexture _renderTexture;
private bool _isPlaying;
private void Awake()
{
if (_rawImage == null) _rawImage = GetComponent<RawImage>();
if (_videoPlayer == null) _videoPlayer = GetComponentInChildren<VideoPlayer>();
if (_videoPlayer != null)
{
_videoPlayer.loopPointReached += OnVideoEnd;
}
}
/// <summary>播放指定视频文件(StreamingAssets/Videos/ 下)</summary>
public void PlayVideo(string fileName)
{
if (_videoPlayer == null) return;
string path = System.IO.Path.Combine(Application.streamingAssetsPath, "Videos", fileName);
_videoPlayer.url = path;
// 创建 RenderTexture
if (_renderTexture == null)
{
int w = _videoPlayer.targetTexture != null ? _videoPlayer.targetTexture.width : 640;
int h = _videoPlayer.targetTexture != null ? _videoPlayer.targetTexture.height : 360;
_renderTexture = new RenderTexture(w, h, 0);
_videoPlayer.targetTexture = _renderTexture;
if (_rawImage != null) _rawImage.texture = _renderTexture;
}
_videoPlayer.Play();
_isPlaying = true;
gameObject.SetActive(true);
}
public void StopVideo()
{
if (_videoPlayer != null) _videoPlayer.Stop();
_isPlaying = false;
gameObject.SetActive(false);
}
public void PauseVideo()
{
if (_videoPlayer != null) _videoPlayer.Pause();
}
public void ResumeVideo()
{
if (_videoPlayer != null) _videoPlayer.Play();
}
private void OnVideoEnd(VideoPlayer vp)
{
// 真人演示视频播完自动循环
vp.Play();
}
private void OnDestroy()
{
if (_renderTexture != null)
{
_renderTexture.Release();
Destroy(_renderTexture);
}
}
}