From a11e003fcd79a97c7a3ca0655e7bd4c7504e4c26 Mon Sep 17 00:00:00 2001 From: yangbear Date: Wed, 24 Jun 2026 02:07:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20VFS=20=E8=99=9A=E6=8B=9F=E4=BB=BF?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E9=AA=8C=E6=A0=B8=E5=BF=83=E6=A1=86=E6=9E=B6?= =?UTF-8?q?=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 加权评分系统:每步独立 ScoreWeight,满分 100,SequenceClick 按子项比例计分 - StepData 新增 DetailedContent(学习模式详细操作说明)和 SubStepCount - 伤情检查步骤扩展为 5 子项序列(视觉检查→肿胀→疼痛→生命体征→神经评估) - 夹板固定模式完整评分(选择夹板→放置衬垫→夹板固定→固定后检查) - Doc/ 文件夹归档三份文档:项目大纲、文案、评分标准 - README 更新评分权重表和夹板固定流程 --- .../FractureFixation_Steps_Reference.json | 148 ++++++++++++ .../Animation/CharacterAnimController.cs | 102 ++++++++ .../Scripts/Animation/StepAnimationHandler.cs | 181 ++++++++++++++ Assets/Scripts/Audio/AudioManager.cs | 92 ++++++++ .../Scripts/Core/ExperimentFlowCoordinator.cs | 100 ++++++++ Assets/Scripts/Core/ExperimentTypes.cs | 38 +++ Assets/Scripts/Core/GameManager.cs | 75 ++++++ Assets/Scripts/Core/StepManager.cs | 172 ++++++++++++++ Assets/Scripts/Data/ExperimentConfig.cs | 38 +++ Assets/Scripts/Data/StepData.cs | 63 +++++ Assets/Scripts/Interaction/DraggableObject.cs | 54 +++++ Assets/Scripts/Interaction/HighlightEffect.cs | 83 +++++++ .../Scripts/Interaction/InteractionSystem.cs | 168 +++++++++++++ Assets/Scripts/UI/ExamPopup.cs | 154 ++++++++++++ Assets/Scripts/UI/MainMenu.cs | 117 ++++++++++ Assets/Scripts/UI/ScoreDisplay.cs | 109 +++++++++ Assets/Scripts/UI/UIManager.cs | 221 ++++++++++++++++++ Assets/Scripts/UI/VideoController.cs | 83 +++++++ Assets/Scripts/VFS.Core.asmdef | 16 ++ Doc/01_项目原始大纲与资源清单.md | 58 +++++ Doc/02_完整流程文案与UI文案.md | 116 +++++++++ Doc/03_考核评分标准.md | 58 +++++ README.md | 131 +++++++++++ 23 files changed, 2377 insertions(+) create mode 100644 Assets/Resources/Configs/FractureFixation_Steps_Reference.json create mode 100644 Assets/Scripts/Animation/CharacterAnimController.cs create mode 100644 Assets/Scripts/Animation/StepAnimationHandler.cs create mode 100644 Assets/Scripts/Audio/AudioManager.cs create mode 100644 Assets/Scripts/Core/ExperimentFlowCoordinator.cs create mode 100644 Assets/Scripts/Core/ExperimentTypes.cs create mode 100644 Assets/Scripts/Core/GameManager.cs create mode 100644 Assets/Scripts/Core/StepManager.cs create mode 100644 Assets/Scripts/Data/ExperimentConfig.cs create mode 100644 Assets/Scripts/Data/StepData.cs create mode 100644 Assets/Scripts/Interaction/DraggableObject.cs create mode 100644 Assets/Scripts/Interaction/HighlightEffect.cs create mode 100644 Assets/Scripts/Interaction/InteractionSystem.cs create mode 100644 Assets/Scripts/UI/ExamPopup.cs create mode 100644 Assets/Scripts/UI/MainMenu.cs create mode 100644 Assets/Scripts/UI/ScoreDisplay.cs create mode 100644 Assets/Scripts/UI/UIManager.cs create mode 100644 Assets/Scripts/UI/VideoController.cs create mode 100644 Assets/Scripts/VFS.Core.asmdef create mode 100644 Doc/01_项目原始大纲与资源清单.md create mode 100644 Doc/02_完整流程文案与UI文案.md create mode 100644 Doc/03_考核评分标准.md create mode 100644 README.md diff --git a/Assets/Resources/Configs/FractureFixation_Steps_Reference.json b/Assets/Resources/Configs/FractureFixation_Steps_Reference.json new file mode 100644 index 0000000..8b8e84c --- /dev/null +++ b/Assets/Resources/Configs/FractureFixation_Steps_Reference.json @@ -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": "" + } +] diff --git a/Assets/Scripts/Animation/CharacterAnimController.cs b/Assets/Scripts/Animation/CharacterAnimController.cs new file mode 100644 index 0000000..b6c47ac --- /dev/null +++ b/Assets/Scripts/Animation/CharacterAnimController.cs @@ -0,0 +1,102 @@ +using UnityEngine; + +/// +/// 角色动画控制器:管理伤者和施救者的动画状态切换。 +/// 挂在角色根 GameObject 上,引用其 Animator 组件。 +/// +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(); + } + + #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 通用 + + /// 按名称触发任意 Animator 参数 + public void TriggerByName(string triggerName) + { + if (_animator == null || string.IsNullOrEmpty(triggerName)) return; + _animator.SetTrigger(Animator.StringToHash(triggerName)); + } + + #endregion +} diff --git a/Assets/Scripts/Animation/StepAnimationHandler.cs b/Assets/Scripts/Animation/StepAnimationHandler.cs new file mode 100644 index 0000000..2c63439 --- /dev/null +++ b/Assets/Scripts/Animation/StepAnimationHandler.cs @@ -0,0 +1,181 @@ +using UnityEngine; +using System.Collections; + +/// +/// 按步骤触发对应动画:开场动画、脱鞋、绑带、夹板放置等。 +/// 挂在场景中有动画关联物体的 GameObject 上。 +/// 由 ExperimentFlowCoordinator 绑定到 StepManager.OnStepChanged。 +/// +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; + + /// 由 StepManager.OnStepChanged 调用 + public void PlayStepAnimation(int stepIndex) + { + var sm = FindObjectOfType(); + 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; + } + + /// 显示伤口高亮 + public void ShowWoundHighlight(bool show) + { + if (_woundHighlight != null) _woundHighlight.SetActive(show); + } + + #endregion +} diff --git a/Assets/Scripts/Audio/AudioManager.cs b/Assets/Scripts/Audio/AudioManager.cs new file mode 100644 index 0000000..1f71e74 --- /dev/null +++ b/Assets/Scripts/Audio/AudioManager.cs @@ -0,0 +1,92 @@ +using UnityEngine; + +/// +/// 音频管理器:管理背景音乐和步骤语音解说。 +/// 挂在场景 GameObject 上,通过 AudioSource 组件播放。 +/// +[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(); + if (_voiceSource == null) + { + _voiceSource = gameObject.AddComponent(); + _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(); + } + + /// 播放一步语音解说,自动打断前一条 + public void PlayVoice(AudioClip clip, float volume = 1f) + { + if (_voiceSource == null || clip == null) return; + _voiceSource.Stop(); + _voiceSource.clip = clip; + _voiceSource.volume = volume; + _voiceSource.Play(); + } + + /// 停止语音 + public void StopVoice() + { + if (_voiceSource != null) _voiceSource.Stop(); + } + + /// 按文件名从 StreamingAssets 加载并播放语音 + 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}"); + } + } +} diff --git a/Assets/Scripts/Core/ExperimentFlowCoordinator.cs b/Assets/Scripts/Core/ExperimentFlowCoordinator.cs new file mode 100644 index 0000000..bd6065c --- /dev/null +++ b/Assets/Scripts/Core/ExperimentFlowCoordinator.cs @@ -0,0 +1,100 @@ +using UnityEngine; + +/// +/// 实验场景的总协调器:挂载在 Experiment 场景的根 GameObject 上, +/// 负责将 StepManager / InteractionSystem / UIManager 串联在一起。 +/// 考核模式以 proportion(0~1)计分。 +/// +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 +} diff --git a/Assets/Scripts/Core/ExperimentTypes.cs b/Assets/Scripts/Core/ExperimentTypes.cs new file mode 100644 index 0000000..5ed765d --- /dev/null +++ b/Assets/Scripts/Core/ExperimentTypes.cs @@ -0,0 +1,38 @@ +// ============================================================ +// 实验类型、模式、固定方式的枚举定义 +// ============================================================ + +/// 五大急救实验类型 +public enum ExperimentType +{ + CPR, // 心肺复苏 + FractureFixation, // 骨折固定 + SprainBandaging, // 扭伤包扎 + BleedingBandaging, // 出血包扎 + CasualtyTransport // 伤员搬运 +} + +/// 运行模式 +public enum ExperimentMode +{ + Learn, // 学习模式 + Exam // 考核模式 +} + +/// 骨折固定方式(仅骨折固定实验使用) +public enum FixationMethod +{ + LimbFixation, // 肢体固定 + SplintFixation // 夹板固定 +} + +/// 单个步骤完成的判定类型 +public enum StepCompleteType +{ + None, // 无需交互,自动进入下一步 + ClickObject, // 点击指定 3D 物体 + DragToTarget, // 拖拽物体到目标位置 + ClickAnywhere, // 点击任意空白处 + MultipleChoice, // 选择题(考核模式) + SequenceClick // 按顺序点击多个物体(考核模式) +} diff --git a/Assets/Scripts/Core/GameManager.cs b/Assets/Scripts/Core/GameManager.cs new file mode 100644 index 0000000..505a46a --- /dev/null +++ b/Assets/Scripts/Core/GameManager.cs @@ -0,0 +1,75 @@ +using UnityEngine; + +/// +/// 全局单例管理器:持有当前实验的类型、模式、固定方式与加权分数。 +/// 挂在场景第一个 GameObject 上,设置 DontDestroyOnLoad。 +/// +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); + } + + /// 初始化考核记录数组 + public void InitExamRecord(int stepCount) + { + StepScores = new float[stepCount]; + StepMaxScores = new float[stepCount]; + StepFeedback = new string[stepCount]; + StepCorrect = new bool[stepCount]; + TotalWeightedScore = 0f; + } + + /// + /// 记录一个考核步骤的结果。 + /// proportion 为 0~1,表示得分比例(SequenceClick 类型部分正确时用到)。 + /// + 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; + } + + /// 按百分比返回考核分数 + public float GetExamPercentage() + { + if (MaxWeightedScore <= 0f) return 0f; + return TotalWeightedScore / MaxWeightedScore * 100f; + } + + /// 获取总分(整数) + public int GetExamScoreRounded() + { + return Mathf.RoundToInt(TotalWeightedScore); + } +} diff --git a/Assets/Scripts/Core/StepManager.cs b/Assets/Scripts/Core/StepManager.cs new file mode 100644 index 0000000..b6f3149 --- /dev/null +++ b/Assets/Scripts/Core/StepManager.cs @@ -0,0 +1,172 @@ +using UnityEngine; +using UnityEngine.Events; + +/// +/// 实验流程状态机——整个项目的核心控制器。 +/// 负责步骤推进、模式切换、UI 更新、加权考核判分调度。 +/// 挂在一个持久化的 GameObject 上。 +/// +public class StepManager : MonoBehaviour +{ + [Header("配置")] + public ExperimentConfig Config; + + [Header("事件(拖入场景对象或通过代码绑定)")] + public UnityEvent OnStepChanged; // 步骤切换时触发,传新步骤索引 + public UnityEvent OnTipChanged; // Tip 文案更新 + public UnityEvent OnDialogueChanged; // 角色对话更新 + public UnityEvent OnExamPopup; // 考核弹窗触发,传当前 StepData + public UnityEvent OnDetailedContent; // 详细操作说明更新(甲方补充) + public UnityEvent OnExperimentComplete; // 实验全部完成 + public UnityEvent OnVideoToggle; // 画中画视频开关(文件名, 是否激活) + public UnityEvent 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; + + /// 用 GameManager 的当前设置初始化实验流程 + 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); + } + + /// 显式指定参数初始化 + public void Initialize(ExperimentType type, ExperimentMode mode, FixationMethod fixation) + { + _mode = mode; + _fixation = fixation; + + Config = Resources.Load($"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); + } + + /// 跳转到指定步骤(含边界检查) + 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); + } + + /// 下一步 + public void NextStep() + { + if (_activeSteps == null) return; + if (_currentStepIndex >= _activeSteps.Length - 1) + { + CompleteExperiment(); + return; + } + GoToStep(_currentStepIndex + 1); + } + + /// 上一步 + public void PreviousStep() + { + if (_activeSteps == null || _currentStepIndex <= 0) return; + GoToStep(_currentStepIndex - 1); + } + + /// 获取当前步骤数据(只读) + public StepData GetCurrentStep() + { + if (_activeSteps == null || _currentStepIndex < 0 || _currentStepIndex >= _activeSteps.Length) + return null; + return _activeSteps[_currentStepIndex]; + } + + /// + /// 当前步骤互动完成后的回调。 + /// proportion: 得分比例 0~1(SequenceClick 按子项命中比例,其余全对=1,全错=0)。 + /// + 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(); + } + + /// 实验完成 + private void CompleteExperiment() + { + Debug.Log("StepManager: 实验流程结束"); + OnExperimentComplete?.Invoke(); + } + + /// 切换模式(学习 ↔ 考核),从头开始 + public void SwitchMode(ExperimentMode newMode) + { + GameManager.Instance.CurrentMode = newMode; + Initialize(GameManager.Instance.CurrentExperimentType, newMode, GameManager.Instance.CurrentFixationMethod); + } + + /// 切换固定方式,从头开始 + public void SwitchFixation(FixationMethod newFixation) + { + GameManager.Instance.CurrentFixationMethod = newFixation; + Initialize(GameManager.Instance.CurrentExperimentType, GameManager.Instance.CurrentMode, newFixation); + } +} diff --git a/Assets/Scripts/Data/ExperimentConfig.cs b/Assets/Scripts/Data/ExperimentConfig.cs new file mode 100644 index 0000000..0ef5e3e --- /dev/null +++ b/Assets/Scripts/Data/ExperimentConfig.cs @@ -0,0 +1,38 @@ +using UnityEngine; + +/// +/// 单个实验类型的完整配置:包含学习模式与考核模式的所有步骤。 +/// 在 Unity Editor 中为每种实验类型各创建一份 Asset。 +/// +[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; + } +} diff --git a/Assets/Scripts/Data/StepData.cs b/Assets/Scripts/Data/StepData.cs new file mode 100644 index 0000000..071b4db --- /dev/null +++ b/Assets/Scripts/Data/StepData.cs @@ -0,0 +1,63 @@ +using UnityEngine; + +/// +/// 单个实验步骤的数据配置(ScriptableObject)。 +/// 在 Unity Editor 中为每个实验创建一份 StepData 列表。 +/// +[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; +} diff --git a/Assets/Scripts/Interaction/DraggableObject.cs b/Assets/Scripts/Interaction/DraggableObject.cs new file mode 100644 index 0000000..9c31510 --- /dev/null +++ b/Assets/Scripts/Interaction/DraggableObject.cs @@ -0,0 +1,54 @@ +using UnityEngine; +using UnityEngine.Events; + +/// +/// 挂载在可拖拽的 3D 物体上(如绷带、夹板)。 +/// 配合 InteractionSystem 使用。 +/// +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; // 吸附后不可再拖动 + } + + /// 重置到初始位置 + public void ResetPosition() + { + transform.position = _originalPosition; + transform.rotation = _originalRotation; + IsDraggable = true; + } +} diff --git a/Assets/Scripts/Interaction/HighlightEffect.cs b/Assets/Scripts/Interaction/HighlightEffect.cs new file mode 100644 index 0000000..d85d474 --- /dev/null +++ b/Assets/Scripts/Interaction/HighlightEffect.cs @@ -0,0 +1,83 @@ +using UnityEngine; + +/// +/// 物体高亮效果:鼠标悬停时切换 Emission 或轮廓材质。 +/// 挂在可交互的 3D 物体上。 +/// +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(); + } + + 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"); + } + } + } + } + + /// 闪烁效果,用于提示用户点击 + 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); + } +} diff --git a/Assets/Scripts/Interaction/InteractionSystem.cs b/Assets/Scripts/Interaction/InteractionSystem.cs new file mode 100644 index 0000000..ca33b36 --- /dev/null +++ b/Assets/Scripts/Interaction/InteractionSystem.cs @@ -0,0 +1,168 @@ +using UnityEngine; +using UnityEngine.Events; + +/// +/// 核心交互系统:射线检测 + 点击/拖拽处理。 +/// 挂在主相机或专用 GameObject 上。 +/// +public class InteractionSystem : MonoBehaviour +{ + [Header("射线配置")] + [SerializeField] private Camera _mainCamera; + [SerializeField] private LayerMask _interactableMask = ~0; + [SerializeField] private float _maxRayDistance = 100f; + + [Header("事件 - 由 StepManager 绑定")] + public UnityEvent 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(); + 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(); + + 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; + } + + /// 获取当前序列已正确命中的步数(用于 proportion 计分) + public int GetSequenceHitCount() + { + return _sequenceClickCount; + } + + #endregion + + #region 公共方法 + + public void SetInteractionEnabled(bool enabled) + { + this.enabled = enabled; + } + + #endregion +} diff --git a/Assets/Scripts/UI/ExamPopup.cs b/Assets/Scripts/UI/ExamPopup.cs new file mode 100644 index 0000000..b322699 --- /dev/null +++ b/Assets/Scripts/UI/ExamPopup.cs @@ -0,0 +1,154 @@ +using UnityEngine; +using UnityEngine.UI; +using TMPro; +using System; + +/// +/// 考核弹窗:选择题 + 操作确认。 +/// 挂在弹窗 Panel 上,由 UIManager 调用 Show()。 +/// 回调返回 proportion (0~1 得分比例) 和 feedback。 +/// +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 _onAnswerCallback; + private int _selectedIndex = -1; + + private void Awake() + { + _confirmButton?.onClick.AddListener(OnConfirm); + if (_popupRoot != null) _popupRoot.SetActive(false); + } + + public void Show(StepData step, Action 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 +} diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs new file mode 100644 index 0000000..38f4308 --- /dev/null +++ b/Assets/Scripts/UI/MainMenu.cs @@ -0,0 +1,117 @@ +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +/// +/// 主菜单:实验类型选择 + 模式选择 + 固定方式选择。 +/// 挂在主菜单场景的 Canvas 上。 +/// +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"); + } +} diff --git a/Assets/Scripts/UI/ScoreDisplay.cs b/Assets/Scripts/UI/ScoreDisplay.cs new file mode 100644 index 0000000..97b75c8 --- /dev/null +++ b/Assets/Scripts/UI/ScoreDisplay.cs @@ -0,0 +1,109 @@ +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +/// +/// 实验结束后的结果展示面板。 +/// 考核模式:显示加权成绩 + 错题分析列表(含每步得分/满分)。 +/// 学习模式:显示注意事项。 +/// +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); + } + + /// 考核模式 - 显示加权成绩 + 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(); + } + + /// 学习模式 - 显示注意事项 + 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(); + 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 = "正确"; + else if (zeroScore) status = "错误"; + else status = $"部分正确"; + + 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"); + } +} diff --git a/Assets/Scripts/UI/UIManager.cs b/Assets/Scripts/UI/UIManager.cs new file mode 100644 index 0000000..66a7790 --- /dev/null +++ b/Assets/Scripts/UI/UIManager.cs @@ -0,0 +1,221 @@ +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +/// +/// UI 中央调度器:引用所有子面板,绑定 StepManager 事件。 +/// 挂在 Canvas GameObject 上。 +/// +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(); + 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 +} diff --git a/Assets/Scripts/UI/VideoController.cs b/Assets/Scripts/UI/VideoController.cs new file mode 100644 index 0000000..7e0530d --- /dev/null +++ b/Assets/Scripts/UI/VideoController.cs @@ -0,0 +1,83 @@ +using UnityEngine; +using UnityEngine.UI; +using UnityEngine.Video; + +/// +/// 画中画视频控制器:用 VideoPlayer 渲染到 RenderTexture,再赋给 RawImage。 +/// 挂在包含 RawImage 和 VideoPlayer 的 GameObject 上。 +/// +[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(); + if (_videoPlayer == null) _videoPlayer = GetComponentInChildren(); + + if (_videoPlayer != null) + { + _videoPlayer.loopPointReached += OnVideoEnd; + } + } + + /// 播放指定视频文件(StreamingAssets/Videos/ 下) + 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); + } + } +} diff --git a/Assets/Scripts/VFS.Core.asmdef b/Assets/Scripts/VFS.Core.asmdef new file mode 100644 index 0000000..9f91b8d --- /dev/null +++ b/Assets/Scripts/VFS.Core.asmdef @@ -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 +} diff --git a/Doc/01_项目原始大纲与资源清单.md b/Doc/01_项目原始大纲与资源清单.md new file mode 100644 index 0000000..6a00d91 --- /dev/null +++ b/Doc/01_项目原始大纲与资源清单.md @@ -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) diff --git a/Doc/02_完整流程文案与UI文案.md b/Doc/02_完整流程文案与UI文案.md new file mode 100644 index 0000000..e362e13 --- /dev/null +++ b/Doc/02_完整流程文案与UI文案.md @@ -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. 每小时评估固定效果,记录肢体肿胀程度、疼痛变化及感觉异常,及时应对并发症如骨筋膜室综合征。 + +### 左大腿骨折夹板固定示例 + +| 夹板位置 | 上端 | 下端 | +|---------|------|------| +| 内侧夹板 | 大腿根部 | 过足跟 | +| 外侧夹板 | 腋窝 | 过足跟 | + +关节处及空隙部位放置衬垫。 diff --git a/Doc/03_考核评分标准.md b/Doc/03_考核评分标准.md new file mode 100644 index 0000000..d85bb2e --- /dev/null +++ b/Doc/03_考核评分标准.md @@ -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 按子步骤命中比例计分。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f5552b --- /dev/null +++ b/README.md @@ -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 +- 无第三方插件