feat: VFS 虚拟仿真实验核心框架 v2
- 加权评分系统:每步独立 ScoreWeight,满分 100,SequenceClick 按子项比例计分 - StepData 新增 DetailedContent(学习模式详细操作说明)和 SubStepCount - 伤情检查步骤扩展为 5 子项序列(视觉检查→肿胀→疼痛→生命体征→神经评估) - 夹板固定模式完整评分(选择夹板→放置衬垫→夹板固定→固定后检查) - Doc/ 文件夹归档三份文档:项目大纲、文案、评分标准 - README 更新评分权重表和夹板固定流程
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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 串联在一起。
|
||||
/// 考核模式以 proportion(0~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
|
||||
}
|
||||
@@ -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 // 按顺序点击多个物体(考核模式)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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~1(SequenceClick 按子项命中比例,其余全对=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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# VFS 项目原始大纲与资源清单
|
||||
|
||||
> 来源:甲方原始需求文档。本文件为归档用途,非实施文档。
|
||||
|
||||
## 一、项目定位
|
||||
|
||||
体育虚拟仿真(Virtual Simulation)教学项目,用于医学、急救、工程等领域的培训。
|
||||
|
||||
## 二、主界面与标题
|
||||
|
||||
- 主标题:运动伤害现场急救处理虚拟仿真实验
|
||||
- 底部功能图标:心肺复苏 | 骨折固定 | 扭伤包扎 | 出血包扎 | 伤员搬运
|
||||
|
||||
## 三、实验模式选择界面
|
||||
|
||||
- 顶部提示:Tip:请选择实验模式
|
||||
- 固定方式选项:肢体固定 | 夹板固定
|
||||
- 运行模式选项:学习模式 | 考核模式
|
||||
- 右侧常驻按钮:全屏模式 | 上一步 | 下一步 | 考核模式 | 返回
|
||||
|
||||
## 四、核心资源清单
|
||||
|
||||
### 3D模型 (Models)
|
||||
- 场景:室内田径馆(包含跑道、看台、跳高架、跳高垫)
|
||||
- 角色:伤者(男性,运动装,骨骼绑定)、施救者(女性,急救员装束,骨骼绑定)
|
||||
- 道具:急救箱(带红十字)、绷带/三角巾、夹板(木板)、鞋子/袜子
|
||||
|
||||
### 动画 (Animations)
|
||||
- 伤者:跑步循环、跳高起跳、空中姿态、落地摔倒、躺在地上
|
||||
- 施救者:站立、蹲下、检查动作、脱鞋动作、包扎动作
|
||||
- 动捕:EasyVirtualMotionCaptureForUnity https://github.com/gpsnmeajp/EasyVirtualMotionCaptureForUnity/releases/tag/v5.0b
|
||||
- 备选:Mixamo 基础动画 + Unity Animation 手动 K 帧
|
||||
|
||||
### UI素材
|
||||
- 图标:心肺复苏、骨折固定、扭伤包扎等 5 个主图标
|
||||
- 按钮:全屏、上一步、下一步、返回、确定
|
||||
- 背景图:半透明面板、提示框背景
|
||||
|
||||
### 视频素材
|
||||
- 真人演示视频(用于学习模式右下角的画中画)
|
||||
|
||||
### 音频
|
||||
- 背景音乐(可选)
|
||||
- 语音解说(TTS 或真人录音)
|
||||
|
||||
## 五、设计参考
|
||||
|
||||
- 即时设计协作链接:https://js.design/f/9ER6eB?mode=design&p=6KbkG4nYyl
|
||||
|
||||
## 六、项目阶段规划(原始)
|
||||
|
||||
1. 第一阶段:项目规划与资源准备 (Pre-production)
|
||||
2. 第二阶段:场景搭建与基础设置 (Scene Setup)
|
||||
3. 第三阶段:核心逻辑开发 (C# Scripting)
|
||||
4. 第四阶段:UI系统开发
|
||||
5. 第五阶段:动画与特效细节
|
||||
6. 第六阶段:具体步骤实现指南
|
||||
7. 第七阶段:打包与发布 (Build Settings: PC Standalone, 1920x1080)
|
||||
@@ -0,0 +1,116 @@
|
||||
# VFS 完整流程文案与 UI 文案
|
||||
|
||||
> 来源:甲方原始需求文档。本文件为归档用途。
|
||||
|
||||
---
|
||||
|
||||
## 一、主界面文案
|
||||
|
||||
- 主标题:运动伤害现场急救处理虚拟仿真实验
|
||||
- 底部图标:心肺复苏 | 骨折固定 | 扭伤包扎 | 出血包扎 | 伤员搬运
|
||||
|
||||
## 二、实验模式选择界面
|
||||
|
||||
| 元素 | 文案 |
|
||||
|------|------|
|
||||
| 顶部提示 | Tip:请选择实验模式 |
|
||||
| 固定方式 | 肢体固定 / 夹板固定 |
|
||||
| 运行模式 | 学习模式 / 考核模式 |
|
||||
| 右侧按钮 | 全屏模式 / 上一步 / 下一步 / 考核模式 / 返回 |
|
||||
|
||||
---
|
||||
|
||||
## 三、学习模式(肢体固定)完整流程文案
|
||||
|
||||
| 步骤 | Tip 提示文案 | 角色对话/系统反馈 |
|
||||
|------|-------------|------------------|
|
||||
| 1. 环境评估 | Tip:观察周围环境是否有危险,做好防护措施。 | 周围环境安全。我是救护志愿者,我可以帮助您吗? |
|
||||
| 2. 伤情检查 | Tip:请检查伤员受伤位置 | 伤员左大腿疑似骨折,无伤口。 |
|
||||
| 3. 脱鞋袜检查 | Tip:请将伤员鞋袜脱掉并保存好,观察末端血液循环、运动及感觉 | (执行脱鞋动画) |
|
||||
| 4. 放置衬带 | Tip:请在正确的位置穿宽度约10cm的带子 | (右下角同步播放真人演示视频) |
|
||||
| 5. 肢体并拢 | Tip:请在两下肢间加衬垫,移动健肢,将双下肢轻轻并拢 | (执行并腿动画) |
|
||||
| 6. 主干固定 | Tip:请在未受伤腿分别固定骨折近心端(上端)、骨折远心端(下端),然后固定膝关节下,结打在右侧。 | (执行绑带动画) |
|
||||
| 7. 踝关节固定 | Tip:踝关节"8"字固定,结打在两足背之间。 | (执行8字绑带动画) |
|
||||
| 8. 终末检查 | Tip:包扎完毕请检查伤肢血液循环,运动及感觉。 | 伤员一切正常,包扎完毕。 |
|
||||
|
||||
---
|
||||
|
||||
## 四、考核模式测试题与判分文案
|
||||
|
||||
| 考核点 | 弹窗题目 | 选项/交互提示 |
|
||||
|--------|---------|-------------|
|
||||
| 考核点一 | 点击确认后,请按正确顺序进行环境评估与自我介绍 | 按钮:确定 |
|
||||
| 考核点二 | 点击确认后,请按正确顺序检查伤情 | 按钮:确定 |
|
||||
| 考核点三 | 请选择合适的带子宽度 | □ 5cm ☑ 10cm □ 20cm |
|
||||
| 考核点四 | 请选择正确的放带子顺序 | 点击地面预设的带子位置排序 |
|
||||
| 考核点五 | 请选择正确的系带子顺序 | 点击绑带位置排序 |
|
||||
| 考核点六 | 请选择带子打结位置 | 点击腿部模型指定区域 |
|
||||
| 考核点七 | 请选择踝关节打结位置 | □ 足底 ☑ 足背 |
|
||||
|
||||
### 成绩显示栏
|
||||
|
||||
```
|
||||
标题:成绩显示栏
|
||||
分数:考试成绩:86分
|
||||
错题分析:
|
||||
第一题:正确
|
||||
第二题:正确
|
||||
第三题:错误:应选择10cm宽度的带子
|
||||
第四题:正确
|
||||
第五题:正确
|
||||
第六题:正确
|
||||
第七题:正确
|
||||
确定 → 实验结束!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、夹板固定模式专属 Tip 文案
|
||||
|
||||
| 步骤 | 替换后的 Tip 文案 |
|
||||
|------|------------------|
|
||||
| 放置衬垫 | Tip:请在骨折肢体加衬垫,并用夹板固定 |
|
||||
| 固定操作 | Tip:请用带子将夹板固定在腿上 |
|
||||
|
||||
> 注:检查、脱鞋、终末检查文案与肢体固定模式一致。
|
||||
|
||||
---
|
||||
|
||||
## 六、注意事项弹窗文案
|
||||
|
||||
```
|
||||
注意事项:
|
||||
1. 下肢损伤选择仰卧位;
|
||||
2. 骨折固定前后要脱掉鞋袜并检查下肢末梢循环、运动、感觉;
|
||||
3. 穿带子时应从健侧穿向伤侧;
|
||||
4. 骨折固定时先固定伤口的近心端,后固定远心端,再由上而下进行固定;
|
||||
5. 固定后尽可能抬高下肢。
|
||||
```
|
||||
|
||||
> 注:原文案中"上肢"已修正为"下肢"。
|
||||
|
||||
---
|
||||
|
||||
## 七、「伤情检查」详细操作须知(甲方补充)
|
||||
|
||||
1. 通过视觉检查骨折部位是否存在明显肿胀、变形或异常角度,初步判断骨折类型(闭合性/开放性)及移位程度。
|
||||
2. 观察骨折部位肿胀与畸形,询问患者疼痛程度并测试患肢主动/被动活动能力,确认是否存在神经血管损伤或肌肉撕裂等并发症。
|
||||
3. 评估疼痛与功能障碍,重点排查开放性骨折的伤口污染情况,记录创面大小、深度及出血量。
|
||||
4. 监测生命体征,包括血压、脉搏频率及节律,关注是否出现休克前兆。
|
||||
5. 神经系统状态评估,排除颅脑损伤或脊髓损伤可能。
|
||||
|
||||
## 八、「夹板固定」详细操作须知(甲方补充)
|
||||
|
||||
1. 根据骨折部位选择长度、硬度适宜的夹板,如四肢骨折需超过相邻两关节。
|
||||
2. 在骨突处(如踝部)加厚衬垫,分散压力。
|
||||
3. 固定完成后立即检查患肢末梢颜色、温度及脉搏,记录患者主观感受,必要时调整绷带松紧度或重新固定。
|
||||
4. 每小时评估固定效果,记录肢体肿胀程度、疼痛变化及感觉异常,及时应对并发症如骨筋膜室综合征。
|
||||
|
||||
### 左大腿骨折夹板固定示例
|
||||
|
||||
| 夹板位置 | 上端 | 下端 |
|
||||
|---------|------|------|
|
||||
| 内侧夹板 | 大腿根部 | 过足跟 |
|
||||
| 外侧夹板 | 腋窝 | 过足跟 |
|
||||
|
||||
关节处及空隙部位放置衬垫。
|
||||
@@ -0,0 +1,58 @@
|
||||
# VFS 考核评分标准
|
||||
|
||||
> 来源:甲方要求。本文件为评分逻辑的唯一依据。
|
||||
|
||||
---
|
||||
|
||||
## 一、骨折固定实验评分权重(8 步骤,满分 100 分)
|
||||
|
||||
| 步骤序号 | 步骤名称 | 满分 | 考核点 |
|
||||
|---------|---------|------|--------|
|
||||
| 1 | 环境评估 | 5 分 | 按正确顺序进行环境评估与自我介绍 |
|
||||
| 2 | 伤情检查 | 20 分 | 按正确顺序检查伤情(含视觉检查、肿胀畸形判断、疼痛评估、生命体征监测、神经系统评估 5 个子项) |
|
||||
| 3 | 脱鞋袜检查 | 10 分 | 正确操作脱鞋袜,观察末梢血液循环 |
|
||||
| 4 | 放置衬带 | 10 分 | 选择正确带子宽度(10cm)/ 正确放带子顺序 |
|
||||
| 5 | 肢体并拢 | 10 分 | 加衬垫、并拢下肢操作 |
|
||||
| 6 | 主干固定 | 20 分 | 固定顺序正确(近心端→远心端→膝关节下)、结打右侧 |
|
||||
| 7 | 踝关节固定 | 15 分 | 8 字固定正确、结打足背 |
|
||||
| 8 | 终末检查 | 10 分 | 检查血液循环、运动及感觉 |
|
||||
|
||||
> 总分 = 各步骤得分之和。每步骤内如有子项错误,按子项权重扣分。
|
||||
|
||||
## 二、伤情检查(步骤 2)子项评分细则(20 分满分)
|
||||
|
||||
| 子项 | 分值 | 内容 |
|
||||
|------|------|------|
|
||||
| 2.1 | 4 分 | 视觉检查骨折部位肿胀、变形、异常角度,判断骨折类型及移位程度 |
|
||||
| 2.2 | 4 分 | 观察肿胀畸形,询问疼痛程度,测试主动/被动活动能力 |
|
||||
| 2.3 | 4 分 | 评估疼痛与功能障碍,排查开放性伤口污染、创面大小/深度/出血量 |
|
||||
| 2.4 | 4 分 | 监测生命体征(血压、脉搏频率及节律),判断休克前兆 |
|
||||
| 2.5 | 4 分 | 神经系统状态评估,排除颅脑损伤或脊髓损伤 |
|
||||
|
||||
## 三、夹板固定评分权重(伤情检查 + 专有步骤)
|
||||
|
||||
夹板固定模式与肢体固定共享步骤 1-3、8,步骤 4-7 替换为夹板专用步骤。
|
||||
|
||||
| 步骤 | 名称 | 满分 | 考核点 |
|
||||
|------|------|------|--------|
|
||||
| 1 | 环境评估 | 5 分 | 同肢体固定 |
|
||||
| 2 | 伤情检查 | 20 分 | 同肢体固定(含 5 子项) |
|
||||
| 3 | 脱鞋袜检查 | 10 分 | 同肢体固定 |
|
||||
| 4 | 选择夹板 | 10 分 | 选择长度/硬度适宜的夹板,需超过相邻两关节 |
|
||||
| 5 | 放置衬垫 | 10 分 | 骨突处加厚衬垫,关节及空隙放衬垫 |
|
||||
| 6 | 夹板固定 | 20 分 | 内侧夹板上至大腿根部下过足跟,外侧夹板上至腋窝下过足跟 |
|
||||
| 7 | 固定后检查 | 15 分 | 检查末梢颜色/温度/脉搏,记录主观感受,调整松紧度 |
|
||||
| 8 | 终末检查 | 10 分 | 同肢体固定 |
|
||||
|
||||
## 四、交互评分映射规则
|
||||
|
||||
| 交互类型 | 全对 | 部分对 | 全错 |
|
||||
|---------|------|--------|------|
|
||||
| ClickAnywhere | 满分 | — | 0 |
|
||||
| ClickObject(单次) | 满分 | — | 0 |
|
||||
| MultipleChoice(单选) | 满分 | — | 0 |
|
||||
| SequenceClick(N 步序列) | 满分 | (正确步数/N)×满分 | 0 |
|
||||
| DragToTarget(拖拽吸附) | 满分 | — | 0 |
|
||||
|
||||
> 考核模式弹窗判定:选择正确 → 满分;选择错误 → 0 分 + 显示错误反馈。
|
||||
> SequenceClick 按子步骤命中比例计分。
|
||||
@@ -0,0 +1,131 @@
|
||||
# 运动伤害现场急救处理虚拟仿真实验 (VFS)
|
||||
|
||||
## 项目概述
|
||||
|
||||
Unity 虚拟仿真教学项目,用于运动伤害现场急救培训。
|
||||
涵盖五大实验:心肺复苏、骨折固定、扭伤包扎、出血包扎、伤员搬运。
|
||||
支持**学习模式**(含语音引导 + 画中画演示视频 + 详细操作说明)和**考核模式**(含加权自动判分 + 错题分析)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
Assets/
|
||||
Scripts/
|
||||
Core/ # 核心管理
|
||||
ExperimentTypes.cs
|
||||
GameManager.cs # 全局单例:实验/模式/固定方式/加权分数
|
||||
StepManager.cs # 流程状态机:步骤推进、事件派发
|
||||
ExperimentFlowCoordinator.cs # 场景协调器
|
||||
Data/
|
||||
StepData.cs # ScriptableObject:步骤数据(含评分权重)
|
||||
ExperimentConfig.cs # ScriptableObject:完整实验配置
|
||||
Interaction/
|
||||
InteractionSystem.cs # 射线检测/点击/拖拽/序列判定
|
||||
DraggableObject.cs # 拖拽+吸附
|
||||
HighlightEffect.cs # 悬停/考核高亮
|
||||
UI/
|
||||
UIManager.cs # UI 中央调度
|
||||
MainMenu.cs # 主菜单
|
||||
ExamPopup.cs # 考核弹窗(选择题/操作题)
|
||||
ScoreDisplay.cs # 加权成绩 + 错题分析 / 注意事项
|
||||
VideoController.cs # 画中画视频
|
||||
Animation/
|
||||
CharacterAnimController.cs # 角色动画
|
||||
StepAnimationHandler.cs # 步骤动画协程
|
||||
Audio/
|
||||
AudioManager.cs # BGM + 语音解说
|
||||
Resources/Configs/ # StepData / ExperimentConfig 存放
|
||||
StreamingAssets/Videos/ # 演示视频
|
||||
StreamingAssets/Voice/ # 语音解说
|
||||
Doc/ # 项目文档
|
||||
01_项目原始大纲与资源清单.md
|
||||
02_完整流程文案与UI文案.md
|
||||
03_考核评分标准.md
|
||||
```
|
||||
|
||||
## 架构要点
|
||||
|
||||
- **状态集中**:GameManager 单例持有实验类型/模式/固定方式/加权分数
|
||||
- **事件驱动**:StepManager 通过 UnityEvent 派发步骤变化,UI/动画/协调器订阅
|
||||
- **交互分离**:InteractionSystem 检测 → ExperimentFlowCoordinator 判定(含按比例计分)→ StepManager 推进
|
||||
- **数据驱动**:所有文案、选项、评分权重在 ScriptableObject 配置,无需改代码
|
||||
- **加权评分**:每步独立 ScoreWeight(满分 100),考核模式按 proportion(0~1)记录得分
|
||||
|
||||
## 评分系统
|
||||
|
||||
### 评分权重(骨折固定·肢体固定)
|
||||
|
||||
| 步骤 | 名称 | 满分 | 考核重点 |
|
||||
|------|------|------|---------|
|
||||
| 1 | 环境评估 | 5 分 | 环境评估与自我介绍 |
|
||||
| 2 | 伤情检查 | 20 分 | 视觉检查→肿胀畸形→疼痛评估→生命体征→神经评估(5 子项) |
|
||||
| 3 | 脱鞋袜检查 | 10 分 | 脱鞋袜、观察末梢循环 |
|
||||
| 4 | 放置衬带 | 10 分 | 选择正确带子宽度(10cm) |
|
||||
| 5 | 肢体并拢 | 10 分 | 加衬垫、并拢下肢 |
|
||||
| 6 | 主干固定 | 20 分 | 近心端→远心端→膝关节下(3 子项) |
|
||||
| 7 | 踝关节固定 | 15 分 | 8 字固定、结打足背 |
|
||||
| 8 | 终末检查 | 10 分 | 检查循环运动感觉 |
|
||||
|
||||
**总分 100 分**。SequenceClick 类型按子项命中比例计分。
|
||||
|
||||
### 评分权重(夹板固定模式)
|
||||
|
||||
| 步骤 | 名称 | 满分 | 考核重点 |
|
||||
|------|------|------|---------|
|
||||
| 1 | 环境评估 | 5 分 | 同肢体固定 |
|
||||
| 2 | 伤情检查 | 20 分 | 同肢体固定 |
|
||||
| 3 | 脱鞋袜检查 | 10 分 | 同肢体固定 |
|
||||
| 4 | 选择夹板 | 10 分 | 长度/硬度需超过相邻两关节 |
|
||||
| 5 | 放置衬垫 | 10 分 | 骨突处加厚衬垫,关节及空隙放衬垫 |
|
||||
| 6 | 夹板固定 | 20 分 | 内侧上至大腿根部下过足跟,外侧上至腋窝下过足跟 |
|
||||
| 7 | 固定后检查 | 15 分 | 末梢颜色/温度/脉搏,调整松紧度 |
|
||||
| 8 | 终末检查 | 10 分 | 同肢体固定 |
|
||||
|
||||
### 左大腿骨折夹板固定示例
|
||||
|
||||
- 内侧夹板:大腿根部 → 过足跟
|
||||
- 外侧夹板:腋窝 → 过足跟
|
||||
- 关节处及空隙部位放置衬垫
|
||||
|
||||
## 接入步骤
|
||||
|
||||
### 1. 创建 Unity 项目
|
||||
Unity 2021.3 LTS+,3D 模板。安装 TextMeshPro Essentials。
|
||||
|
||||
### 2. 导入脚本
|
||||
将 `Assets/Scripts/` 下所有 .cs 文件拖入项目对应文件夹。确保有 `VFS.Core.asmdef`。
|
||||
|
||||
### 3. 搭建场景
|
||||
|
||||
**MainMenu 场景:**
|
||||
- Canvas → 挂 `MainMenu.cs`
|
||||
- 5 个实验选择按钮 + 模式切换 + 固定方式面板 + 开始按钮
|
||||
- 空 GameObject → 挂 `GameManager.cs`
|
||||
|
||||
**Experiment 场景:**
|
||||
- 导入 3D 模型,给可交互物体加 Collider + Tag:
|
||||
- `InjuryLeg` `InjuryVisual` `InjurySwelling` `InjuryPain` `InjuryVitalSigns` `InjuryNeuro` — 伤情检查子项
|
||||
- `Shoe` `BandagePos1~3` `Ankle` `PaddingPos` `SplintLeft` `SplintRight` `Ground`
|
||||
- 空 GameObject `[Managers]` 挂 GameManager / StepManager / ExperimentFlowCoordinator / AudioManager
|
||||
- 主相机挂 `InteractionSystem.cs`
|
||||
- Canvas 挂 `UIManager.cs`,子面板挂 `ExamPopup` / `ScoreDisplay` / `VideoController`
|
||||
- 学习模式详细内容面板挂于 Canvas 下,在 `UIManager._detailPanel` 拖入
|
||||
|
||||
### 4. 配置步骤
|
||||
- Create → VFS → Step Data(逐步骤填入 ScoreWeight / SubStepCount / TipText / DetailedContent / 考核选项)
|
||||
- Create → VFS → Experiment Config(组装 LearnStepsLimb / ExamStepsLimb / LearnStepsSplint / ExamStepsSplint)
|
||||
- 拖入 `StepManager.Config`
|
||||
|
||||
### 5. 连线
|
||||
- `ExperimentFlowCoordinator` Inspector 拖入 StepManager / InteractionSystem / UIManager / StepAnimationHandler / AudioManager
|
||||
- `UIManager` Inspector 拖入各 UI 引用(Tip / Detail / Dialogue / ExamPopup / ScoreDisplay / VideoController / Buttons)
|
||||
|
||||
### 6. 视频/语音
|
||||
- 真人演示视频放入 `StreamingAssets/Videos/`
|
||||
- 语音解说放入 `StreamingAssets/Voice/`
|
||||
|
||||
## 依赖
|
||||
|
||||
- Unity 2021.3+
|
||||
- TextMeshPro Essentials
|
||||
- 无第三方插件
|
||||
Reference in New Issue
Block a user