feat: MCP Unity 场景优化 — 完整交互体系搭建
- 删除重复 Ground - 创建 [Managers] 空物体,挂载 GameManager + StepManager + ExperimentFlowCoordinator + AudioManager - Main Camera 挂载 InteractionSystem - 伤者 InjectedPerson 下创建 5 个交互子物体 (InjuryVisual/Swelling/Pain/VitalSigns/Neuro) - 创建绑带位置、踝关节、衬垫、夹板位置等空物体 - 道具摆放到位:伤者(2,0.05,5) 施救者(3,0,5) 鞋(2.7,0.15,5.5) 绷带/夹板/急救箱在旁 - InjuryLeg Tag 修正,Ground Tag 修正 - 全部新脚本同步导入 (ExperimentTypes/StepData/ExperimentConfig/StepManager/GameManager/ExperimentFlowCoordinator/InteractionSystem/DraggableObject/HighlightEffect/UIManager/ExamPopup/ScoreDisplay/VideoController/AudioManager/CharacterAnimController/StepAnimationHandler) - 旧脚本 (ExperimentController/ConfigLoader/AnimationController/HUDController/VideoPlayerController/PopupSystem/ModeSelectionUI) 全部兼容修复或 stub - 编译通过,0 错误 - Canvas 挂载 UIManager
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e185e7ef2b2bc41a3952d401ed28b894
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,3 @@
|
||||
using UnityEngine;
|
||||
/// <summary>Legacy — replaced by StepAnimationHandler.</summary>
|
||||
public class AnimationController : MonoBehaviour { }
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 05b8d5cbfd0e74474a937b3cc7ad120c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,87 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 角色动画控制器:管理伤者和施救者的动画状态切换。
|
||||
/// 挂在角色根 GameObject 上,引用其 Animator 组件。
|
||||
/// </summary>
|
||||
public class CharacterAnimController : MonoBehaviour
|
||||
{
|
||||
public enum CharacterRole { Patient, Rescuer }
|
||||
public CharacterRole Role = CharacterRole.Patient;
|
||||
|
||||
[Header("Animator 引用")]
|
||||
[SerializeField] private 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>();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public void TriggerByName(string triggerName)
|
||||
{
|
||||
if (_animator == null || string.IsNullOrEmpty(triggerName)) return;
|
||||
_animator.SetTrigger(Animator.StringToHash(triggerName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 23fb87b6a5b9544d3aed3b307715504c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bbe25e570b15b41d98e77259d82a8f1d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0501e3f1ed8cc4ec3ba149c9cd7b20af
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 55eccc8daa89f482193b0fcb1aa0f2bc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4d8a4c89bbd0d457494bb8c6130ff34b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,3 @@
|
||||
using UnityEngine;
|
||||
/// <summary>Legacy — replaced by ExperimentConfig ScriptableObject.</summary>
|
||||
public class ConfigLoader : MonoBehaviour { }
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bda8b3bd9c4a14051b7821859e6095f6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,3 @@
|
||||
using UnityEngine;
|
||||
/// <summary>Legacy — replaced by ExperimentFlowCoordinator.</summary>
|
||||
public class ExperimentController : MonoBehaviour { }
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5d499f2451702432ebbf25bfccbe1fa9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e3dd6cb15ae1a4f0e92e2826f64ee6cf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 13ab85c5e00ee4765918090eeac6de36
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c57598038a100447ab7f5396d0f0e49c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a2535da3ddf9046e686f9b0d2ce5f569
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 49013f1e509fe47b18f6e8480e546996
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,34 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Defines an exam question with multiple choices.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "ExamQuestion", menuName = "VFS/Exam Question")]
|
||||
public class ExamQuestionData : ScriptableObject
|
||||
{
|
||||
[Header("Question Info")]
|
||||
public int questionIndex;
|
||||
[TextArea(2, 4)]
|
||||
public string questionText;
|
||||
|
||||
[Header("Choices")]
|
||||
public string[] choices;
|
||||
public int correctChoiceIndex;
|
||||
|
||||
[Header("Feedback")]
|
||||
[TextArea(2, 4)]
|
||||
public string correctFeedback;
|
||||
[TextArea(2, 4)]
|
||||
public string incorrectFeedback;
|
||||
|
||||
[Header("Interaction")]
|
||||
public QuestionType questionType;
|
||||
public string targetTag;
|
||||
|
||||
public enum QuestionType
|
||||
{
|
||||
MultipleChoice,
|
||||
ClickTarget,
|
||||
SequenceOrder
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1ac09a60490764c18ba4d4b5616370e8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 24d3a8cba581943e1a2b2c167c7dd259
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dac314898f2b743efa29d166b0ed2980
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aa42089b59739481481ee9e714b88438
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cbf0e86605f7b485f8ff25c5810d30c0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 289a67afae9f64a8fbff818821d6ea91
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1de9da6001ff949cf9bedcd003c48487
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2f65bedef5c694798a8ddf6bcaad27ee
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1b7f4e3b0a85d4c75860bf9060f8c3a8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,6 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy HUD controller — replaced by UIManager. Kept for backward compatibility.
|
||||
/// </summary>
|
||||
public class HUDController : MonoBehaviour { }
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ded61052e4f7c449ca21983fdaa9060b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,38 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
/// <summary>
|
||||
/// Main menu UI — assign all fields in Inspector.
|
||||
/// </summary>
|
||||
public class MainMenuUI : MonoBehaviour
|
||||
{
|
||||
[Header("Text")]
|
||||
public Text titleText;
|
||||
public Text versionText;
|
||||
|
||||
[Header("Buttons")]
|
||||
public Button cprButton;
|
||||
public Button fractureButton;
|
||||
public Button sprainButton;
|
||||
public Button bleedingButton;
|
||||
public Button transportButton;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (titleText != null) titleText.text = "运动伤害现场急救处理虚拟仿真实验";
|
||||
if (versionText != null) versionText.text = $"v{Application.version}";
|
||||
|
||||
if (cprButton != null) cprButton.onClick.AddListener(() => OnSelect("CPR"));
|
||||
if (fractureButton != null) fractureButton.onClick.AddListener(() => OnSelect("FractureFixation"));
|
||||
if (sprainButton != null) sprainButton.onClick.AddListener(() => OnSelect("SprainBandage"));
|
||||
if (bleedingButton != null) bleedingButton.onClick.AddListener(() => OnSelect("BleedingBandage"));
|
||||
if (transportButton != null) transportButton.onClick.AddListener(() => OnSelect("CasualtyTransport"));
|
||||
}
|
||||
|
||||
private void OnSelect(string experimentId)
|
||||
{
|
||||
PlayerPrefs.SetString("SelectedExperiment", experimentId);
|
||||
SceneManager.LoadScene("ModeSelection");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7fca7ea321dc34ab19b4bea56e90e8a2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,72 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
public class ModeSelectionUI : MonoBehaviour
|
||||
{
|
||||
[Header("Tip")]
|
||||
public Text tipText;
|
||||
|
||||
[Header("Fixation Mode")]
|
||||
public Button limbFixationButton;
|
||||
public Button splintFixationButton;
|
||||
public GameObject limbFixationCheck;
|
||||
public GameObject splintFixationCheck;
|
||||
|
||||
[Header("Run Mode")]
|
||||
public Button learnModeButton;
|
||||
public Button examModeButton;
|
||||
public GameObject learnModeCheck;
|
||||
public GameObject examModeCheck;
|
||||
|
||||
[Header("Actions")]
|
||||
public Button startButton;
|
||||
public Button backButton;
|
||||
|
||||
private FixationMethod selectedFixation = FixationMethod.LimbFixation;
|
||||
private ExperimentMode selectedRunMode = ExperimentMode.Learn;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
tipText.text = "Tip:请选择实验模式";
|
||||
|
||||
limbFixationButton.onClick.AddListener(() => SelectFixation(FixationMethod.LimbFixation));
|
||||
splintFixationButton.onClick.AddListener(() => SelectFixation(FixationMethod.SplintFixation));
|
||||
learnModeButton.onClick.AddListener(() => SelectRunMode(ExperimentMode.Learn));
|
||||
examModeButton.onClick.AddListener(() => SelectRunMode(ExperimentMode.Exam));
|
||||
startButton.onClick.AddListener(OnStart);
|
||||
backButton.onClick.AddListener(() => SceneManager.LoadScene("main"));
|
||||
|
||||
UpdateSelectionVisuals();
|
||||
}
|
||||
|
||||
private void SelectFixation(FixationMethod mode)
|
||||
{
|
||||
selectedFixation = mode;
|
||||
UpdateSelectionVisuals();
|
||||
}
|
||||
|
||||
private void SelectRunMode(ExperimentMode mode)
|
||||
{
|
||||
selectedRunMode = mode;
|
||||
UpdateSelectionVisuals();
|
||||
}
|
||||
|
||||
private void UpdateSelectionVisuals()
|
||||
{
|
||||
limbFixationCheck.SetActive(selectedFixation == FixationMethod.LimbFixation);
|
||||
splintFixationCheck.SetActive(selectedFixation == FixationMethod.SplintFixation);
|
||||
learnModeCheck.SetActive(selectedRunMode == ExperimentMode.Learn);
|
||||
examModeCheck.SetActive(selectedRunMode == ExperimentMode.Exam);
|
||||
}
|
||||
|
||||
private void OnStart()
|
||||
{
|
||||
if (GameManager.Instance != null)
|
||||
{
|
||||
GameManager.Instance.CurrentFixationMethod = selectedFixation;
|
||||
GameManager.Instance.CurrentMode = selectedRunMode;
|
||||
}
|
||||
SceneManager.LoadScene("Experiment");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2106746edd5ca43288aff94f664b16ae
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,3 @@
|
||||
using UnityEngine;
|
||||
/// <summary>Legacy — replaced by ExamPopup + ScoreDisplay.</summary>
|
||||
public class PopupSystem : MonoBehaviour { }
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c0eab9dd709f74ddf9f6728ef6bb4edf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8315bbe42105640af89ceda53b039e65
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 11edd93e0776546a3b8545535d7dd5e1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3a31c697647c74734811ac323080d78a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,3 @@
|
||||
using UnityEngine;
|
||||
/// <summary>Legacy — replaced by VideoController.</summary>
|
||||
public class VideoPlayerController : MonoBehaviour { }
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4c7b16cd23ebf4299be89ef8311ed579
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user