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
@@ -0,0 +1,148 @@
[
{
"StepIndex": 0,
"StepName": "OpeningScene",
"ScoreWeight": 5,
"SubStepCount": 1,
"TipText": "Tip:观察周围环境是否有危险,做好防护措施。",
"DialogueText": "周围环境安全。我是救护志愿者,我可以帮助您吗?",
"DetailedContent": "",
"CompleteType": "ClickAnywhere",
"TargetTag": "",
"ExamQuestionText": "点击确认后,请按正确顺序进行环境评估与自我介绍",
"ExamOptions": [],
"CorrectOptionIndex": -1,
"ErrorFeedback": "环境评估流程不正确",
"IsSplintOnly": false,
"ShowPipVideo": false,
"VideoFileName": ""
},
{
"StepIndex": 1,
"StepName": "CheckInjury",
"ScoreWeight": 20,
"SubStepCount": 5,
"TipText": "Tip:请检查伤员受伤位置",
"DialogueText": "伤员左大腿疑似骨折,无伤口。",
"DetailedContent": "【伤情检查详细操作须知】\n1)通过视觉检查骨折部位是否存在明显肿胀、变形或异常角度,初步判断骨折类型(闭合性/开放性)及移位程度。\n2)观察骨折部位肿胀与畸形,询问患者疼痛程度并测试患肢主动/被动活动能力,确认是否存在神经血管损伤或肌肉撕裂等并发症。\n3)评估疼痛与功能障碍,重点排查开放性骨折的伤口污染情况,记录创面大小、深度及出血量。\n4)监测生命体征,包括血压、脉搏频率及节律,关注是否出现休克前兆。\n5)神经系统状态评估,排除颅脑损伤或脊髓损伤可能。",
"CompleteType": "SequenceClick",
"TargetTag": "InjuryLeg",
"ClickSequenceTags": ["InjuryVisual", "InjurySwelling", "InjuryPain", "InjuryVitalSigns", "InjuryNeuro"],
"ExamQuestionText": "点击确认后,请按正确顺序检查伤情",
"ExamOptions": [],
"CorrectOptionIndex": -1,
"ErrorFeedback": "伤情检查步骤不完整或不正确",
"IsSplintOnly": false,
"ShowPipVideo": false,
"VideoFileName": ""
},
{
"StepIndex": 2,
"StepName": "RemoveShoes",
"ScoreWeight": 10,
"SubStepCount": 1,
"TipText": "Tip:请将伤员鞋袜脱掉并保存好,观察末端血液循环、运动及感觉",
"DialogueText": "",
"DetailedContent": "",
"CompleteType": "ClickObject",
"TargetTag": "Shoe",
"ExamQuestionText": "请正确操作脱鞋袜并观察末梢循环",
"ExamOptions": [],
"CorrectOptionIndex": -1,
"ErrorFeedback": "脱鞋袜操作不正确",
"IsSplintOnly": false,
"ShowPipVideo": false,
"VideoFileName": ""
},
{
"StepIndex": 3,
"StepName": "PlaceBandage",
"ScoreWeight": 10,
"SubStepCount": 1,
"TipText": "Tip:请在正确的位置穿宽度约10cm的带子",
"DialogueText": "",
"DetailedContent": "",
"CompleteType": "MultipleChoice",
"TargetTag": "BandagePos1",
"ExamQuestionText": "请选择合适的带子宽度",
"ExamOptions": ["5cm", "10cm", "20cm"],
"CorrectOptionIndex": 1,
"ErrorFeedback": "应选择10cm宽度的带子",
"IsSplintOnly": false,
"ShowPipVideo": true,
"VideoFileName": "bandage_demo.mp4"
},
{
"StepIndex": 4,
"StepName": "LimbFixation",
"ScoreWeight": 10,
"SubStepCount": 1,
"TipText": "Tip:请在两下肢间加衬垫,移动健肢,将双下肢轻轻并拢",
"DialogueText": "",
"DetailedContent": "",
"CompleteType": "DragToTarget",
"TargetTag": "PaddingPos",
"ExamQuestionText": "请选择正确的放带子顺序",
"ExamOptions": [],
"CorrectOptionIndex": -1,
"ErrorFeedback": "放带子顺序不正确",
"IsSplintOnly": false,
"ShowPipVideo": false,
"VideoFileName": ""
},
{
"StepIndex": 5,
"StepName": "KneeFixation",
"ScoreWeight": 20,
"SubStepCount": 3,
"TipText": "Tip:请在未受伤腿分别固定骨折近心端(上端)、骨折远心端(下端),然后固定膝关节下,结打在右侧。",
"DialogueText": "",
"DetailedContent": "",
"CompleteType": "SequenceClick",
"TargetTag": "",
"ClickSequenceTags": ["BandagePos1", "BandagePos2", "BandagePos3"],
"ExamQuestionText": "请选择正确的系带子顺序",
"ExamOptions": [],
"CorrectOptionIndex": -1,
"ErrorFeedback": "系带子顺序不正确,应是近心端→远心端→膝关节下",
"IsSplintOnly": false,
"ShowPipVideo": false,
"VideoFileName": ""
},
{
"StepIndex": 6,
"StepName": "AnkleFixation",
"ScoreWeight": 15,
"SubStepCount": 1,
"TipText": "Tip:踝关节\"8\"字固定,结打在两足背之间。",
"DialogueText": "",
"DetailedContent": "",
"CompleteType": "ClickObject",
"TargetTag": "Ankle",
"ExamQuestionText": "请选择踝关节打结位置",
"ExamOptions": ["足底", "足背"],
"CorrectOptionIndex": 1,
"ErrorFeedback": "应选择足背位置打结——踝关节8字固定结打在两足背之间",
"IsSplintOnly": false,
"ShowPipVideo": false,
"VideoFileName": ""
},
{
"StepIndex": 7,
"StepName": "FinalCheck",
"ScoreWeight": 10,
"SubStepCount": 1,
"TipText": "Tip:包扎完毕请检查伤肢血液循环,运动及感觉。",
"DialogueText": "伤员一切正常,包扎完毕。",
"DetailedContent": "",
"CompleteType": "ClickAnywhere",
"TargetTag": "",
"ExamQuestionText": "包扎完毕,请确认并结束实验",
"ExamOptions": [],
"CorrectOptionIndex": -1,
"ErrorFeedback": "",
"IsSplintOnly": false,
"ShowPipVideo": false,
"VideoFileName": ""
}
]
@@ -0,0 +1,102 @@
using UnityEngine;
/// <summary>
/// 角色动画控制器:管理伤者和施救者的动画状态切换。
/// 挂在角色根 GameObject 上,引用其 Animator 组件。
/// </summary>
public class CharacterAnimController : MonoBehaviour
{
[Header("角色类型")]
public enum CharacterRole { Patient, Rescuer }
public CharacterRole Role = CharacterRole.Patient;
[Header("Animator 引用")]
[SerializeField] private Animator _animator;
// Animator 参数名(按项目实际命名约定调整)
private static readonly int ParamRun = Animator.StringToHash("IsRunning");
private static readonly int ParamJump = Animator.StringToHash("Jump");
private static readonly int ParamFall = Animator.StringToHash("Fall");
private static readonly int ParamLying = Animator.StringToHash("IsLying");
private static readonly int ParamCrouch = Animator.StringToHash("Crouch");
private static readonly int ParamCheck = Animator.StringToHash("Check");
private static readonly int ParamBandage = Animator.StringToHash("Bandage");
private static readonly int ParamRemoveShoe = Animator.StringToHash("RemoveShoe");
private void Awake()
{
if (_animator == null) _animator = GetComponent<Animator>();
}
#region
public void PlayRun()
{
if (_animator == null) return;
_animator.SetBool(ParamRun, true);
}
public void StopRun()
{
if (_animator == null) return;
_animator.SetBool(ParamRun, false);
}
public void TriggerJump()
{
_animator?.SetTrigger(ParamJump);
}
public void TriggerFall()
{
_animator?.SetTrigger(ParamFall);
_animator?.SetBool(ParamLying, true);
}
public void SetLying(bool lying)
{
_animator?.SetBool(ParamLying, lying);
}
#endregion
#region
public void PlayCrouch()
{
_animator?.SetBool(ParamCrouch, true);
}
public void StopCrouch()
{
_animator?.SetBool(ParamCrouch, false);
}
public void TriggerCheck()
{
_animator?.SetTrigger(ParamCheck);
}
public void TriggerBandage()
{
_animator?.SetTrigger(ParamBandage);
}
public void TriggerRemoveShoe()
{
_animator?.SetTrigger(ParamRemoveShoe);
}
#endregion
#region
/// <summary>按名称触发任意 Animator 参数</summary>
public void TriggerByName(string triggerName)
{
if (_animator == null || string.IsNullOrEmpty(triggerName)) return;
_animator.SetTrigger(Animator.StringToHash(triggerName));
}
#endregion
}
@@ -0,0 +1,181 @@
using UnityEngine;
using System.Collections;
/// <summary>
/// 按步骤触发对应动画:开场动画、脱鞋、绑带、夹板放置等。
/// 挂在场景中有动画关联物体的 GameObject 上。
/// 由 ExperimentFlowCoordinator 绑定到 StepManager.OnStepChanged。
/// </summary>
public class StepAnimationHandler : MonoBehaviour
{
[Header("角色引用")]
[SerializeField] private CharacterAnimController _patient;
[SerializeField] private CharacterAnimController _rescuer;
[Header("道具引用")]
[SerializeField] private GameObject _shoeLeft; // 伤者左脚鞋
[SerializeField] private GameObject _shoeRight; // 伤者右脚鞋
[SerializeField] private GameObject[] _bandages; // 绑带物件列表
[SerializeField] private GameObject[] _splints; // 夹板物件列表
[SerializeField] private GameObject _padding; // 衬垫
[Header("夹板固定专用道具")]
[SerializeField] private GameObject _splintLeft;
[SerializeField] private GameObject _splintRight;
[Header("伤口高亮")]
[SerializeField] private GameObject _woundHighlight;
[Header("动画时长")]
[SerializeField] private float _shoeRemoveDuration = 1.5f;
[SerializeField] private float _bandageAppearDuration = 0.5f;
/// <summary>由 StepManager.OnStepChanged 调用</summary>
public void PlayStepAnimation(int stepIndex)
{
var sm = FindObjectOfType<StepManager>();
if (sm == null) return;
var step = sm.GetCurrentStep();
if (step == null) return;
StopAllCoroutines();
// 根据步骤名称匹配动画
switch (step.StepName)
{
case "OpeningScene":
StartCoroutine(PlayOpeningScene());
break;
case "RemoveShoes":
StartCoroutine(PlayRemoveShoes());
break;
case "PlaceBandage":
StartCoroutine(PlayPlaceBandage());
break;
case "LimbFixation":
StartCoroutine(PlayLimbFixation());
break;
case "SplintFixation":
StartCoroutine(PlaySplintFixation());
break;
case "AnkleFixation":
StartCoroutine(PlayAnkleFixation());
break;
case "FinalCheck":
break;
default:
Debug.Log($"StepAnimationHandler: 步骤 {step.StepName} 无专属动画");
break;
}
}
#region
private IEnumerator PlayOpeningScene()
{
// 伤者跑步 → 跳高 → 摔倒
_patient?.PlayRun();
yield return new WaitForSeconds(4f);
_patient?.TriggerJump();
yield return new WaitForSeconds(1f);
_patient?.TriggerFall();
yield return new WaitForSeconds(1.5f);
_patient?.StopRun();
}
private IEnumerator PlayRemoveShoes()
{
// 鞋子弹开/移出
if (_shoeLeft != null)
{
Vector3 target = _shoeLeft.transform.position + Vector3.right * 0.5f + Vector3.up * 0.2f;
yield return StartCoroutine(MoveToPosition(_shoeLeft, target, _shoeRemoveDuration));
_shoeLeft.SetActive(false);
}
if (_shoeRight != null)
{
Vector3 target = _shoeRight.transform.position + Vector3.right * 0.5f + Vector3.up * 0.2f;
yield return StartCoroutine(MoveToPosition(_shoeRight, target, _shoeRemoveDuration));
_shoeRight.SetActive(false);
}
}
private IEnumerator PlayPlaceBandage()
{
// 显示绑带
foreach (var b in _bandages)
{
if (b != null) b.SetActive(true);
}
yield return null;
}
private IEnumerator PlayLimbFixation()
{
// 显示衬垫 + 激活绑带
if (_padding != null) _padding.SetActive(true);
yield return new WaitForSeconds(0.5f);
// 依次显示绑带
for (int i = 0; i < _bandages.Length; i++)
{
if (_bandages[i] != null)
{
_bandages[i].SetActive(true);
yield return new WaitForSeconds(_bandageAppearDuration);
}
}
}
private IEnumerator PlaySplintFixation()
{
// 显示夹板
if (_splintLeft != null) _splintLeft.SetActive(true);
if (_splintRight != null) _splintRight.SetActive(true);
yield return new WaitForSeconds(0.5f);
foreach (var s in _splints)
{
if (s != null) s.SetActive(true);
yield return new WaitForSeconds(_bandageAppearDuration);
}
}
private IEnumerator PlayAnkleFixation()
{
// 8 字绑带动画 - 激活脚踝处绑带
if (_bandages.Length > 0 && _bandages[_bandages.Length - 1] != null)
{
_bandages[_bandages.Length - 1].SetActive(true);
}
yield return null;
}
#endregion
#region
private IEnumerator MoveToPosition(GameObject obj, Vector3 target, float duration)
{
if (obj == null) yield break;
Vector3 start = obj.transform.position;
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
obj.transform.position = Vector3.Lerp(start, target, elapsed / duration);
yield return null;
}
obj.transform.position = target;
}
/// <summary>显示伤口高亮</summary>
public void ShowWoundHighlight(bool show)
{
if (_woundHighlight != null) _woundHighlight.SetActive(show);
}
#endregion
}
+92
View File
@@ -0,0 +1,92 @@
using UnityEngine;
/// <summary>
/// 音频管理器:管理背景音乐和步骤语音解说。
/// 挂在场景 GameObject 上,通过 AudioSource 组件播放。
/// </summary>
[RequireComponent(typeof(AudioSource))]
public class AudioManager : MonoBehaviour
{
public static AudioManager Instance { get; private set; }
[Header("音频源")]
[SerializeField] private AudioSource _bgmSource;
[SerializeField] private AudioSource _voiceSource;
[Header("背景音乐")]
public AudioClip BGMClip;
[Range(0f, 1f)] public float BGMVolume = 0.3f;
private void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
if (_bgmSource == null) _bgmSource = GetComponent<AudioSource>();
if (_voiceSource == null)
{
_voiceSource = gameObject.AddComponent<AudioSource>();
_voiceSource.playOnAwake = false;
_voiceSource.loop = false;
}
}
private void Start()
{
PlayBGM();
}
public void PlayBGM()
{
if (_bgmSource == null || BGMClip == null) return;
_bgmSource.clip = BGMClip;
_bgmSource.volume = BGMVolume;
_bgmSource.loop = true;
_bgmSource.Play();
}
public void StopBGM()
{
if (_bgmSource != null) _bgmSource.Stop();
}
/// <summary>播放一步语音解说,自动打断前一条</summary>
public void PlayVoice(AudioClip clip, float volume = 1f)
{
if (_voiceSource == null || clip == null) return;
_voiceSource.Stop();
_voiceSource.clip = clip;
_voiceSource.volume = volume;
_voiceSource.Play();
}
/// <summary>停止语音</summary>
public void StopVoice()
{
if (_voiceSource != null) _voiceSource.Stop();
}
/// <summary>按文件名从 StreamingAssets 加载并播放语音</summary>
public void PlayVoiceFromFile(string fileName)
{
if (string.IsNullOrEmpty(fileName)) return;
StartCoroutine(LoadAndPlayVoice(fileName));
}
private System.Collections.IEnumerator LoadAndPlayVoice(string fileName)
{
string path = System.IO.Path.Combine(Application.streamingAssetsPath, "Voice", fileName);
using var www = UnityEngine.Networking.UnityWebRequestMultimedia.GetAudioClip(path, AudioType.WAV);
yield return www.SendWebRequest();
if (www.result == UnityEngine.Networking.UnityWebRequest.Result.Success)
{
var clip = UnityEngine.Networking.DownloadHandlerAudioClip.GetContent(www);
PlayVoice(clip);
}
else
{
Debug.LogWarning($"AudioManager: 加载语音失败 {path}");
}
}
}
@@ -0,0 +1,100 @@
using UnityEngine;
/// <summary>
/// 实验场景的总协调器:挂载在 Experiment 场景的根 GameObject 上,
/// 负责将 StepManager / InteractionSystem / UIManager 串联在一起。
/// 考核模式以 proportion0~1)计分。
/// </summary>
public class ExperimentFlowCoordinator : MonoBehaviour
{
[Header("核心组件引用")]
[SerializeField] private StepManager _stepManager;
[SerializeField] private InteractionSystem _interactionSystem;
[SerializeField] private UIManager _uiManager;
[SerializeField] private StepAnimationHandler _animationHandler;
[SerializeField] private AudioManager _audioManager;
[Header("配置")]
[SerializeField] private ExperimentConfig _defaultConfig;
private void Start()
{
if (_interactionSystem != null && _stepManager != null)
{
_interactionSystem.OnObjectClicked.AddListener(OnObjectClicked);
_interactionSystem.OnAnywhereClicked.AddListener(OnAnywhereClicked);
}
if (_stepManager != null && _animationHandler != null)
{
_stepManager.OnStepChanged.AddListener(_animationHandler.PlayStepAnimation);
}
if (_defaultConfig != null)
{
_stepManager.Config = _defaultConfig;
}
_stepManager.InitializeFromGameManager();
}
#region
private void OnObjectClicked(string tag)
{
var step = _stepManager.GetCurrentStep();
if (step == null) return;
switch (step.CompleteType)
{
case StepCompleteType.ClickObject:
if (tag == step.TargetTag)
{
Debug.Log($"ExperimentFlowCoordinator: 正确点击了 {tag}");
_stepManager.OnStepInteractionComplete(1f);
}
else
{
Debug.Log($"ExperimentFlowCoordinator: 点击了 {tag},需要的是 {step.TargetTag}");
if (_stepManager.Mode == ExperimentMode.Exam)
_stepManager.OnStepInteractionComplete(0f, "点击位置不正确");
}
break;
case StepCompleteType.SequenceClick:
// 按子项命中比例计分
bool isComplete;
bool isCorrect = _interactionSystem.TrySequenceClick(tag, out isComplete);
if (isComplete)
{
// 取当前序列已命中数 / 总子项数
int hit = _interactionSystem.GetSequenceHitCount();
int total = step.ClickSequenceTags?.Length ?? step.SubStepCount;
float proportion = total > 0 ? (float)hit / total : 1f;
string fb = proportion < 1f ? $"顺序部分正确({hit}/{total} 步正确)" : "";
_stepManager.OnStepInteractionComplete(proportion, fb);
}
else if (!isCorrect)
{
_stepManager.OnStepInteractionComplete(0f, "点击顺序不正确");
}
break;
default:
break;
}
}
private void OnAnywhereClicked()
{
var step = _stepManager.GetCurrentStep();
if (step == null) return;
if (step.CompleteType == StepCompleteType.ClickAnywhere)
{
_stepManager.OnStepInteractionComplete(1f);
}
}
#endregion
}
+38
View File
@@ -0,0 +1,38 @@
// ============================================================
// 实验类型、模式、固定方式的枚举定义
// ============================================================
/// <summary>五大急救实验类型</summary>
public enum ExperimentType
{
CPR, // 心肺复苏
FractureFixation, // 骨折固定
SprainBandaging, // 扭伤包扎
BleedingBandaging, // 出血包扎
CasualtyTransport // 伤员搬运
}
/// <summary>运行模式</summary>
public enum ExperimentMode
{
Learn, // 学习模式
Exam // 考核模式
}
/// <summary>骨折固定方式(仅骨折固定实验使用)</summary>
public enum FixationMethod
{
LimbFixation, // 肢体固定
SplintFixation // 夹板固定
}
/// <summary>单个步骤完成的判定类型</summary>
public enum StepCompleteType
{
None, // 无需交互,自动进入下一步
ClickObject, // 点击指定 3D 物体
DragToTarget, // 拖拽物体到目标位置
ClickAnywhere, // 点击任意空白处
MultipleChoice, // 选择题(考核模式)
SequenceClick // 按顺序点击多个物体(考核模式)
}
+75
View File
@@ -0,0 +1,75 @@
using UnityEngine;
/// <summary>
/// 全局单例管理器:持有当前实验的类型、模式、固定方式与加权分数。
/// 挂在场景第一个 GameObject 上,设置 DontDestroyOnLoad。
/// </summary>
public class GameManager : MonoBehaviour
{
public static GameManager Instance { get; private set; }
[Header("当前实验设置(运行时赋值)")]
public ExperimentType CurrentExperimentType = ExperimentType.FractureFixation;
public ExperimentMode CurrentMode = ExperimentMode.Learn;
public FixationMethod CurrentFixationMethod = FixationMethod.LimbFixation;
[Header("加权评分")]
public float TotalWeightedScore; // 实际得分(加权累加)
public float MaxWeightedScore = 100f; // 总分
[Header("考核记录")]
public float[] StepScores; // 每步实际得分(含权重)
public float[] StepMaxScores; // 每步满分(权重值)
public string[] StepFeedback; // 每题错误反馈文本
public bool[] StepCorrect; // 每题是否有任何得分
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
/// <summary>初始化考核记录数组</summary>
public void InitExamRecord(int stepCount)
{
StepScores = new float[stepCount];
StepMaxScores = new float[stepCount];
StepFeedback = new string[stepCount];
StepCorrect = new bool[stepCount];
TotalWeightedScore = 0f;
}
/// <summary>
/// 记录一个考核步骤的结果。
/// proportion 为 0~1,表示得分比例(SequenceClick 类型部分正确时用到)。
/// </summary>
public void RecordExamResult(int stepIndex, float maxScore, float proportion, string feedback)
{
if (StepScores == null || stepIndex >= StepScores.Length) return;
StepMaxScores[stepIndex] = maxScore;
float earned = maxScore * Mathf.Clamp01(proportion);
StepScores[stepIndex] = earned;
StepFeedback[stepIndex] = feedback;
StepCorrect[stepIndex] = proportion > 0f;
TotalWeightedScore += earned;
}
/// <summary>按百分比返回考核分数</summary>
public float GetExamPercentage()
{
if (MaxWeightedScore <= 0f) return 0f;
return TotalWeightedScore / MaxWeightedScore * 100f;
}
/// <summary>获取总分(整数)</summary>
public int GetExamScoreRounded()
{
return Mathf.RoundToInt(TotalWeightedScore);
}
}
+172
View File
@@ -0,0 +1,172 @@
using UnityEngine;
using UnityEngine.Events;
/// <summary>
/// 实验流程状态机——整个项目的核心控制器。
/// 负责步骤推进、模式切换、UI 更新、加权考核判分调度。
/// 挂在一个持久化的 GameObject 上。
/// </summary>
public class StepManager : MonoBehaviour
{
[Header("配置")]
public ExperimentConfig Config;
[Header("事件(拖入场景对象或通过代码绑定)")]
public UnityEvent<int> OnStepChanged; // 步骤切换时触发,传新步骤索引
public UnityEvent<string> OnTipChanged; // Tip 文案更新
public UnityEvent<string> OnDialogueChanged; // 角色对话更新
public UnityEvent<StepData> OnExamPopup; // 考核弹窗触发,传当前 StepData
public UnityEvent<string> OnDetailedContent; // 详细操作说明更新(甲方补充)
public UnityEvent OnExperimentComplete; // 实验全部完成
public UnityEvent<string, bool> OnVideoToggle; // 画中画视频开关(文件名, 是否激活)
public UnityEvent<bool> OnModeUIUpdate; // 通知 UI 刷新模式按钮状态
// 私有状态
private StepData[] _activeSteps;
private int _currentStepIndex = -1;
private ExperimentMode _mode;
private FixationMethod _fixation;
public int CurrentStepIndex => _currentStepIndex;
public int TotalSteps => _activeSteps != null ? _activeSteps.Length : 0;
public ExperimentMode Mode => _mode;
public FixationMethod Fixation => _fixation;
/// <summary>用 GameManager 的当前设置初始化实验流程</summary>
public void InitializeFromGameManager()
{
var gm = GameManager.Instance;
if (gm == null) { Debug.LogError("StepManager: GameManager.Instance is null!"); return; }
Initialize(gm.CurrentExperimentType, gm.CurrentMode, gm.CurrentFixationMethod);
}
/// <summary>显式指定参数初始化</summary>
public void Initialize(ExperimentType type, ExperimentMode mode, FixationMethod fixation)
{
_mode = mode;
_fixation = fixation;
Config = Resources.Load<ExperimentConfig>($"Configs/{type}Config");
if (Config == null)
{
Debug.LogError($"StepManager: 找不到 ExperimentConfig for {type},请在 Resources/Configs/ 下创建");
return;
}
_activeSteps = Config.GetStepsFor(mode, fixation);
if (_activeSteps == null || _activeSteps.Length == 0)
{
Debug.LogError($"StepManager: 实验 {type} 模式 {mode} 固定方式 {fixation} 无步骤数据!");
return;
}
_currentStepIndex = -1;
if (mode == ExperimentMode.Exam)
GameManager.Instance.InitExamRecord(_activeSteps.Length);
OnModeUIUpdate?.Invoke(mode == ExperimentMode.Exam);
GoToStep(0);
}
/// <summary>跳转到指定步骤(含边界检查)</summary>
public void GoToStep(int index)
{
if (_activeSteps == null) return;
index = Mathf.Clamp(index, 0, _activeSteps.Length - 1);
if (index == _currentStepIndex) return;
_currentStepIndex = index;
var step = _activeSteps[index];
if (_mode == ExperimentMode.Learn)
{
OnTipChanged?.Invoke(step.TipText);
OnDialogueChanged?.Invoke(step.DialogueText);
OnDetailedContent?.Invoke(step.DetailedContent);
if (step.ShowPipVideo)
OnVideoToggle?.Invoke(step.VideoFileName, true);
else
OnVideoToggle?.Invoke(null, false);
}
else
{
OnTipChanged?.Invoke("");
OnDialogueChanged?.Invoke("");
OnDetailedContent?.Invoke("");
OnVideoToggle?.Invoke(null, false);
if (!string.IsNullOrEmpty(step.ExamQuestionText))
OnExamPopup?.Invoke(step);
}
OnStepChanged?.Invoke(_currentStepIndex);
}
/// <summary>下一步</summary>
public void NextStep()
{
if (_activeSteps == null) return;
if (_currentStepIndex >= _activeSteps.Length - 1)
{
CompleteExperiment();
return;
}
GoToStep(_currentStepIndex + 1);
}
/// <summary>上一步</summary>
public void PreviousStep()
{
if (_activeSteps == null || _currentStepIndex <= 0) return;
GoToStep(_currentStepIndex - 1);
}
/// <summary>获取当前步骤数据(只读)</summary>
public StepData GetCurrentStep()
{
if (_activeSteps == null || _currentStepIndex < 0 || _currentStepIndex >= _activeSteps.Length)
return null;
return _activeSteps[_currentStepIndex];
}
/// <summary>
/// 当前步骤互动完成后的回调。
/// proportion: 得分比例 0~1SequenceClick 按子项命中比例,其余全对=1,全错=0)。
/// </summary>
public void OnStepInteractionComplete(float proportion = 1f, string feedback = "")
{
var step = GetCurrentStep();
if (step == null) return;
if (_mode == ExperimentMode.Exam)
{
string fb = string.IsNullOrEmpty(feedback) ? step.ErrorFeedback : feedback;
GameManager.Instance.RecordExamResult(_currentStepIndex, step.ScoreWeight, proportion, fb);
}
if (_mode == ExperimentMode.Learn)
NextStep();
}
/// <summary>实验完成</summary>
private void CompleteExperiment()
{
Debug.Log("StepManager: 实验流程结束");
OnExperimentComplete?.Invoke();
}
/// <summary>切换模式(学习 ↔ 考核),从头开始</summary>
public void SwitchMode(ExperimentMode newMode)
{
GameManager.Instance.CurrentMode = newMode;
Initialize(GameManager.Instance.CurrentExperimentType, newMode, GameManager.Instance.CurrentFixationMethod);
}
/// <summary>切换固定方式,从头开始</summary>
public void SwitchFixation(FixationMethod newFixation)
{
GameManager.Instance.CurrentFixationMethod = newFixation;
Initialize(GameManager.Instance.CurrentExperimentType, GameManager.Instance.CurrentMode, newFixation);
}
}
+38
View File
@@ -0,0 +1,38 @@
using UnityEngine;
/// <summary>
/// 单个实验类型的完整配置:包含学习模式与考核模式的所有步骤。
/// 在 Unity Editor 中为每种实验类型各创建一份 Asset。
/// </summary>
[CreateAssetMenu(fileName = "ExperimentConfig", menuName = "VFS/Experiment Config")]
public class ExperimentConfig : ScriptableObject
{
[Header("实验标识")]
public ExperimentType Type;
[Header("实验名称")]
public string DisplayName;
[Header("骨折固定专属 - 固定方式对应的步骤覆盖")]
public FixationMethod DefaultFixationMethod = FixationMethod.LimbFixation;
[Header("学习模式步骤列表(肢体固定)")]
public StepData[] LearnStepsLimb;
[Header("学习模式步骤列表(夹板固定)")]
public StepData[] LearnStepsSplint;
[Header("考核模式步骤列表(肢体固定)")]
public StepData[] ExamStepsLimb;
[Header("考核模式步骤列表(夹板固定)")]
public StepData[] ExamStepsSplint;
public StepData[] GetStepsFor(ExperimentMode mode, FixationMethod fixation)
{
if (mode == ExperimentMode.Learn)
return fixation == FixationMethod.SplintFixation ? LearnStepsSplint : LearnStepsLimb;
else
return fixation == FixationMethod.SplintFixation ? ExamStepsSplint : ExamStepsLimb;
}
public int StepCount(ExperimentMode mode, FixationMethod fixation)
{
var steps = GetStepsFor(mode, fixation);
return steps != null ? steps.Length : 0;
}
}
+63
View File
@@ -0,0 +1,63 @@
using UnityEngine;
/// <summary>
/// 单个实验步骤的数据配置(ScriptableObject)。
/// 在 Unity Editor 中为每个实验创建一份 StepData 列表。
/// </summary>
[CreateAssetMenu(fileName = "StepData", menuName = "VFS/Step Data")]
public class StepData : ScriptableObject
{
[Header("=== 评分 ===")]
[Tooltip("本步骤满分(考核模式下使用,总分 100 分)")]
[Range(0f, 100f)]
public float ScoreWeight = 10f;
[Tooltip("本步骤的子项数量(如伤情检查有 5 个子项,用于 SequenceClick 按比例计分)")]
public int SubStepCount = 1;
[Header("基础信息")]
[Tooltip("步骤序号,从 0 开始")]
public int StepIndex;
[Tooltip("步骤名称(内部标识)")]
public string StepName;
[Header("学习模式文案")]
[TextArea(2, 4)]
public string TipText; // 顶部 Tip 提示框文案
[TextArea(2, 4)]
public string DialogueText; // 角色对话气泡文案
[Header("学习模式 - 详细操作说明(甲方补充内容)")]
[TextArea(3, 8)]
public string DetailedContent; // 伤情检查/夹板固定等详细操作须知
[Header("考核模式文案")]
[TextArea(2, 4)]
public string ExamQuestionText; // 考核弹窗题目文案
[Header("考核选项")]
public string[] ExamOptions; // 选择题选项(如 {"5cm", "10cm", "20cm"}
public int CorrectOptionIndex; // 正确选项的索引(-1 表示非选择题)
public string ErrorFeedback; // 答错时的反馈文案
[Header("交互配置")]
public StepCompleteType CompleteType = StepCompleteType.ClickObject;
[Tooltip("交互目标的 Tag(如 InjuryLeg, Shoe, Bandage")]
public string TargetTag;
[Tooltip("考核模式点击顺序(SequenceClick 类型时使用)")]
public string[] ClickSequenceTags;
[Header("额外配置")]
[Tooltip("是否为夹板固定专属步骤")]
public bool IsSplintOnly;
[Tooltip("是否需要播放画中画视频")]
public bool ShowPipVideo;
[Tooltip("画中画视频文件名(StreamingAssets 下)")]
public string VideoFileName;
}
@@ -0,0 +1,54 @@
using UnityEngine;
using UnityEngine.Events;
/// <summary>
/// 挂载在可拖拽的 3D 物体上(如绷带、夹板)。
/// 配合 InteractionSystem 使用。
/// </summary>
public class DraggableObject : MonoBehaviour
{
[Header("拖拽状态")]
public bool IsDraggable = true;
[Header("吸附目标")]
public Transform SnapTarget;
[Header("事件")]
public UnityEvent OnDragStartEvent;
public UnityEvent OnDragEndEvent;
public UnityEvent OnSnappedEvent; // 吸附成功
private Vector3 _originalPosition;
private Quaternion _originalRotation;
private void Start()
{
_originalPosition = transform.position;
_originalRotation = transform.rotation;
}
public void OnDragStart()
{
OnDragStartEvent?.Invoke();
}
public void OnDragEnd()
{
OnDragEndEvent?.Invoke();
}
public void OnSnapped()
{
Debug.Log($"DraggableObject: {name} 已吸附到目标");
OnSnappedEvent?.Invoke();
IsDraggable = false; // 吸附后不可再拖动
}
/// <summary>重置到初始位置</summary>
public void ResetPosition()
{
transform.position = _originalPosition;
transform.rotation = _originalRotation;
IsDraggable = true;
}
}
@@ -0,0 +1,83 @@
using UnityEngine;
/// <summary>
/// 物体高亮效果:鼠标悬停时切换 Emission 或轮廓材质。
/// 挂在可交互的 3D 物体上。
/// </summary>
public class HighlightEffect : MonoBehaviour
{
[Header("高亮设置")]
[SerializeField] private Color _highlightColor = Color.yellow;
[SerializeField] private float _highlightIntensity = 0.5f;
[Header("可选 - 使用 Outline 组件")]
[SerializeField] private bool _useOutline = true;
[SerializeField] private float _outlineWidth = 0.02f;
private Renderer[] _renderers;
private Material[] _originalMaterials;
private Material[] _highlightMaterials;
private bool _isHighlighted;
[Header("考核高亮 - 始终显示")]
public bool AlwaysHighlighted;
private void Start()
{
_renderers = GetComponentsInChildren<Renderer>();
}
private void OnMouseEnter()
{
if (_isHighlighted) return;
SetHighlight(true);
}
private void OnMouseExit()
{
if (AlwaysHighlighted) return;
SetHighlight(false);
}
public void SetHighlight(bool on)
{
_isHighlighted = on;
if (_renderers == null) return;
foreach (var r in _renderers)
{
if (r == null) continue;
foreach (var mat in r.materials)
{
if (on)
{
mat.EnableKeyword("_EMISSION");
mat.SetColor("_EmissionColor", _highlightColor * _highlightIntensity);
}
else
{
mat.DisableKeyword("_EMISSION");
}
}
}
}
/// <summary>闪烁效果,用于提示用户点击</summary>
public void Flash(float duration = 0.5f)
{
StopAllCoroutines();
StartCoroutine(FlashRoutine(duration));
}
private System.Collections.IEnumerator FlashRoutine(float duration)
{
float half = duration / 2f;
SetHighlight(true);
yield return new WaitForSeconds(half);
SetHighlight(false);
yield return new WaitForSeconds(half);
SetHighlight(true);
yield return new WaitForSeconds(half);
SetHighlight(false);
}
}
@@ -0,0 +1,168 @@
using UnityEngine;
using UnityEngine.Events;
/// <summary>
/// 核心交互系统:射线检测 + 点击/拖拽处理。
/// 挂在主相机或专用 GameObject 上。
/// </summary>
public class InteractionSystem : MonoBehaviour
{
[Header("射线配置")]
[SerializeField] private Camera _mainCamera;
[SerializeField] private LayerMask _interactableMask = ~0;
[SerializeField] private float _maxRayDistance = 100f;
[Header("事件 - 由 StepManager 绑定")]
public UnityEvent<string> OnObjectClicked; // 点击到 Tag 时触发,传 Tag
public UnityEvent OnAnywhereClicked; // 点击空白处触发
[Header("拖拽配置")]
[SerializeField] private float _dragPlaneDistance = 2f;
[SerializeField] private float _snapDistance = 0.3f;
private GameObject _draggedObject;
private Vector3 _dragOffset;
private float _dragZ;
private bool _isDragging;
// 考核模式序列点击
private int _sequenceClickCount;
private string[] _expectedSequence;
private void Awake()
{
if (_mainCamera == null)
_mainCamera = Camera.main;
}
private void Update()
{
HandleClick();
HandleDrag();
}
#region
private void HandleClick()
{
if (!Input.GetMouseButtonDown(0)) return;
if (UnityEngine.EventSystems.EventSystem.current != null &&
UnityEngine.EventSystems.EventSystem.current.IsPointerOverGameObject())
return;
Ray ray = _mainCamera.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out RaycastHit hit, _maxRayDistance, _interactableMask))
{
string tag = hit.collider.tag;
if (!string.IsNullOrEmpty(tag))
{
Debug.Log($"InteractionSystem: 点击到物体 Tag={tag}");
OnObjectClicked?.Invoke(tag);
return;
}
}
OnAnywhereClicked?.Invoke();
}
#endregion
#region
private void HandleDrag()
{
if (Input.GetMouseButtonDown(0))
{
if (UnityEngine.EventSystems.EventSystem.current != null &&
UnityEngine.EventSystems.EventSystem.current.IsPointerOverGameObject())
return;
Ray ray = _mainCamera.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out RaycastHit hit, _maxRayDistance, _interactableMask))
{
var draggable = hit.collider.GetComponent<DraggableObject>();
if (draggable != null && draggable.IsDraggable)
{
_draggedObject = hit.collider.gameObject;
_dragZ = _mainCamera.WorldToScreenPoint(_draggedObject.transform.position).z;
_isDragging = true;
draggable.OnDragStart();
}
}
}
if (Input.GetMouseButton(0) && _isDragging && _draggedObject != null)
{
Vector3 screenPos = new Vector3(Input.mousePosition.x, Input.mousePosition.y, _dragZ);
Vector3 worldPos = _mainCamera.ScreenToWorldPoint(screenPos);
_draggedObject.transform.position = worldPos;
}
if (Input.GetMouseButtonUp(0) && _isDragging && _draggedObject != null)
{
var draggable = _draggedObject.GetComponent<DraggableObject>();
if (draggable != null && draggable.SnapTarget != null)
{
float dist = Vector3.Distance(_draggedObject.transform.position, draggable.SnapTarget.position);
if (dist <= _snapDistance)
{
_draggedObject.transform.position = draggable.SnapTarget.position;
_draggedObject.transform.rotation = draggable.SnapTarget.rotation;
draggable.OnSnapped();
}
}
draggable?.OnDragEnd();
_draggedObject = null;
_isDragging = false;
}
}
#endregion
#region
public void BeginSequenceCheck(string[] expectedTags)
{
_expectedSequence = expectedTags;
_sequenceClickCount = 0;
}
public bool TrySequenceClick(string clickedTag, out bool isComplete)
{
isComplete = false;
if (_expectedSequence == null || _sequenceClickCount >= _expectedSequence.Length)
return false;
if (clickedTag == _expectedSequence[_sequenceClickCount])
{
_sequenceClickCount++;
if (_sequenceClickCount >= _expectedSequence.Length)
{
isComplete = true;
_expectedSequence = null;
return true;
}
return true;
}
return false;
}
/// <summary>获取当前序列已正确命中的步数(用于 proportion 计分)</summary>
public int GetSequenceHitCount()
{
return _sequenceClickCount;
}
#endregion
#region
public void SetInteractionEnabled(bool enabled)
{
this.enabled = enabled;
}
#endregion
}
+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);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "VFS.Core",
"rootNamespace": "VFS",
"references": [
"Unity.TextMeshPro"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}