玩家状态机 采用了状态机模式,将玩家的不同状态(如闲置和移动)封装到不同的类中,通过状态机来管理状态的切换。
1. Player.cs 内容及作用
这是玩家角色的核心脚本,负责管理玩家的基本属性、状态机以及一些基本的行为。
属性:
moveSpeed:玩家的移动速度。
wallCheck、wallCheckDistance、groundCheck、groundCheckDistance、whatIsGround:用于检测碰撞,如墙壁和地面。
facingRight、facingDir:控制玩家的朝向。
anim、rb:分别是玩家的动画控制器和刚体组件。
stateMachine、IdleState、MoveState、jumpState、airState:用于管理玩家的状态。
方法:
Awake():初始化状态机和各种状态。
Start():获取动画控制器和刚体组件,并将玩家初始状态设置为 IdleState。
Update():每帧更新当前状态。
SetVelocity():设置玩家的速度,并根据速度控制玩家的朝向。
IsGroundDetected():检测玩家是否在地面上。
Filp():翻转玩家的朝向。
FlipController():根据玩家的移动方向控制玩家的朝向。
2. PlayerState.cs 内容及作用
这是玩家状态的基类,定义了玩家状态的基本结构和行为。
属性:
xInput、yInput:玩家的输入。
rb:玩家的刚体组件。
stateMachine:玩家的状态机。
player:玩家对象。
animBoolName:动画的布尔参数名称。
方法:
PlayerState():构造函数,初始化状态机、玩家对象和动画参数名称。
Enter():进入状态时调用,设置动画参数并获取刚体组件。
Update():每帧更新玩家的输入,并设置动画的速度参数。
Exit():退出状态时调用,重置动画参数。
3. PlayerIdleState.cs 内容及作用
继承自 PlayerGroundState,表示玩家的空闲状态。
方法:
Update():如果玩家有水平输入,则切换到 MoveState。
4. PlayerMoveState.cs 内容及作用
继承自 PlayerGroundState,表示玩家的移动状态。
方法:
Update():根据玩家的水平输入设置玩家的速度,如果没有水平输入,则切换到 IdleState。
5. PlayerJumpState.cs 内容及作用
继承自 PlayerState,表示玩家的跳跃状态。
方法:
Enter():进入跳跃状态时,给玩家一个向上的速度。
Update():如果玩家的垂直速度小于 0,则切换到 airState。
6. PlayerAirState.cs 内容及作用
继承自 PlayerState,表示玩家在空中的状态。
方法:
Update():如果玩家检测到地面,则切换到 IdleState。
7. PlayerGroundState.cs 内容及作用
继承自 PlayerState,表示玩家在地面上的状态。
方法:
Update():如果玩家按下空格键且在地面上,则切换到 jumpState。
8. PlayerStateMachine.cs 内容及作用
这是玩家的状态机,负责管理玩家的状态转换。
属性:
方法:
Initialize():初始化状态机的初始状态。
ChangeState():切换到新的状态。
9. ParallacBackGround.cs 内容及作用
这是用于实现背景视差效果的脚本。
属性:
camTF:主摄像机的变换组件。
lastFrameCameraPos:上一帧摄像机的位置。
LengthX、LengthY:背景图片的长度。
parallaxFacotr:视差因子。
lockX、lockY:是否锁定 X 轴和 Y 轴。
方法:
Start():初始化摄像机的变换组件和上一帧的位置,并计算背景图片的长度。
Update():根据摄像机的移动更新背景的位置,并根据锁定条件调整背景的位置。
各脚本之间的联系
Player.cs 是核心脚本,它创建并管理 PlayerStateMachine 和各种状态对象(IdleState、MoveState 等)。
PlayerStateMachine 负责管理玩家的状态转换,通过 Initialize() 和 ChangeState() 方法控制状态的切换。
PlayerState 是所有状态类的基类,定义了状态的基本结构和行为。
PlayerIdleState、PlayerMoveState、PlayerJumpState 和 PlayerAirState 继承自 PlayerState 或 PlayerGroundState,实现了具体的状态逻辑。
ParallacBackGround.cs 与玩家脚本没有直接的逻辑联系,它是一个独立的脚本,用于实现背景的视差效果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 using System.Collections;using System.Collections.Generic;using UnityEngine;public class PlayerStateMachine : MonoBehaviour { public PlayerState currentState { get ; private set ; } public void Initialize (PlayerState _startState ) { currentState = _startState; currentState.Enter(); } public void ChangeState (PlayerState _newState ) { currentState.Exit(); currentState = _newState; currentState.Enter(); } void Update () { } }
相机的视角跟随以及背景无线滚动 Enemy状态机 技能实现 在技能的实现模块,我们使用Skill类 作为使用技能类的基础,提供了每个技能公共的基本属性(冷却)和行为框架,在通过SKillManager类 进行全局的管理技能,负责集中管理和协调所有具体技能实例,通过全局访问点,便于技能的管理以及扩展。
SKill类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 public class Skill : MonoBehaviour { [SerializeField ] public float cooldown; public float cooldownTimer; protected Player player; protected virtual void Start () { player = PlayerManager.instance.player; CheckUnlock(); } protected virtual void Update () { cooldownTimer -= Time.deltaTime; } protected virtual void CheckUnlock () { } public virtual bool CanUseSkill () { if (cooldownTimer < 0 ) { UseSkill(); cooldownTimer = cooldown; return true ; } player.playerFx.CreatePopUpText("冷却中" ); return false ; } public virtual void UseSkill () { } protected virtual Transform FindCloseEnemy (Transform _checkTransform ) { Collider2D[] colliders = Physics2D.OverlapCircleAll(_checkTransform.position, 25 ); float closeDistance = Mathf.Infinity; Transform closestEnemy = null ; foreach (var hit in colliders) { if (hit.GetComponent<Enemy>() != null ) { float distanceToEnemy = Vector2.Distance(_checkTransform.position, hit.transform.position); if (distanceToEnemy < closeDistance) { closeDistance = distanceToEnemy; closestEnemy = hit.transform; } } } return closestEnemy; } }
SkillManager类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 public class SkillManager : MonoBehaviour { public static SkillManager instance; public Dash_Skill dash{ get ; private set ; } public Clone_Skill clone{ get ; private set ; } public Sword_Skill sword{ get ; private set ; } public Blackhole_Skill blackhole{ get ; private set ; } public Crystal_Skill crystal{ get ; private set ; } public Parry_Skill parry{ get ; private set ; } public Dodge_Skill dodge{ get ; private set ; } private void Awake () { if (instance != null ) Destroy(instance.gameObject); else instance = this ; } private void Start () { dash = GetComponent<Dash_Skill>(); clone = GetComponent<Clone_Skill>(); sword = GetComponent<Sword_Skill>(); blackhole = GetComponent<Blackhole_Skill>(); crystal = GetComponent<Crystal_Skill>(); parry = GetComponent<Parry_Skill>(); dodge = GetComponent<Dodge_Skill>(); }
Dash Crystal BlackHole技能的实现 技能相关类以及功能描述
Blackhole_Skill类:包含该技能的一系列属性,以及继承自Skill类的方法,负责技能的整体管理,在该类中包含技能是否能够使用的方法(继承自Skill)和能够使用后黑洞预制体的实现,并且技能完成状态的判断
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 using System.Collections;using System.Collections.Generic;using UnityEngine;public class Blackhole_Skill : Skill { [SerializeField ] private float maxSize; [SerializeField ] private float growSpeed; [SerializeField ] private float shrinkSpeed; [SerializeField ] private GameObject blackholePrefab; [Space ] [SerializeField ] private float blackholeDuration; [SerializeField ] int amountOfAttacks = 4 ; [SerializeField ] float cloneAttackCooldown = .3 f; BlackHole_Skill_Controller currentBlackhole; public override bool CanUseSkill () { return base .CanUseSkill(); } public override void UseSkill () { base .UseSkill(); GameObject newBlackhole = Instantiate(blackholePrefab, player.transform.position, Quaternion.identity); currentBlackhole = newBlackhole.GetComponent<BlackHole_Skill_Controller>(); currentBlackhole.SetupBlackhole(maxSize, growSpeed, shrinkSpeed, amountOfAttacks, cloneAttackCooldown, blackholeDuration); } protected override void Start () { base .Start(); } protected override void Update () { base .Update(); } public bool SkillCompleted () { if (currentBlackhole == null ) return false ; if (currentBlackhole.playerCanExitState) { return true ; } else { return false ; } } public float GetBlackholeRadius () { return maxSize / 2 ; } }

Sword技能的实现
实体属性(PlayerAndEnemy) 在每一个类银河恶魔城中的游戏,都会存在实体属性这个模块,用于实现各种实体的基本属性,包括生命值,力量等等。通过使用Stats类管理所有的数值属性,通过Character_Stats类定义属性作为PlayerStats和EnemyStats类的基类。这个就是基本的属性框架。
Stats类 :在该类中,我们定义了一个可供Inspector面板进行修改的基础值,同时为这个基础值提供一个列表来在基础值上进行修改值,比如装备提供的属性等等。同时提供2个往列表中进行修改的方法,分别是AddModifier和ReMoveModifier方法,分别往列表添加值,提供GetValue()方法,在该方法中,使用foreach方法,在基础值上遍历列表查看是否有属性值的修改。总结如下:
基础值 :每个属性有一个在Unity编辑器里可以直接设置的基础数值(比如攻击力基础值是50)
修改列表 :维护一个记录所有临时加减值的列表(比如+10的武器加成,-5的debuff效果都记在这个列表里)
三个核心功能 :
AddModifier:往列表里加一个数值(比如获得装备+5攻击力)
RemoveModifier:从列表里移除一个数值(比如卸下装备-5攻击力)
GetValue():计算最终数值 = 基础值 + 列表里所有数值的总和
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public class Stat { [SerializeField ] private int baseValue; public List<int > modifiers; public int GetValue () { int finalValue = baseValue; foreach (int modifier in modifiers) { finalValue += modifier; } return finalValue; } public void SetDefaultValue (int _value ) { baseValue = _value; } public void AddModifers (int _modifiers ) { modifiers.Add(_modifiers); } public void RemoveModifers (int _modifiers ) { modifiers.Remove(_modifiers); } }
Character_Stats类 :在这个类中,我们定义了Stats类型的属性值,定义Stats类型的属性值我们可以灵活修改属性值,因为Stats类型中,我们定义了提供属性修改的方法,以便装备武器后增加临时的修改值,同时提供了统一的管理属性的方案,在需要进行修改,直接在Stat类进行扩展。
同时在这个类中,不仅提供了基础的属性值,也包括状态异常系统,燃烧,冻结,眩晕。以及核心的战斗系统部分,在战斗系统中,包含不同的伤害,在物理伤害中,我们进行攻击后需要判断是否暴击,如果暴击,则将伤害乘以对于的暴击伤害 -敌人的护甲。没有则直接在基础上减去敌人的护甲。对于魔法伤害,有单独的伤害逻辑,在元素伤害的基础上+智力-目标的魔抗。对于状态异常,我们采用造成的元素伤害类型的最高值施加4s。同时,还提供了特殊的效果,包括无敌的状态,易伤的状态以及攻击闪避机制(基于敏捷和闪避属性)
属性系统
采用Stat类型封装属性值,支持动态修改(装备/技能等临时加成)
包含基础四维属性(力量/敏捷/智力/活力)和衍生属性(攻击/防御/魔法)
战斗系统
物理伤害 :基础伤害+力量,计算暴击(暴击率×暴伤)后扣除目标护甲
魔法伤害 :元素伤害+智力扣除目标魔抗,按最高元素类型附加对应异常状态(燃烧/冻结/眩晕)持续4秒
特殊机制
状态异常:燃烧(持续伤)、冻结(减速降甲)、眩晕(降命中)
战斗效果:无敌(免伤)、易伤(承伤+10%)、闪避(基于敏捷/闪避属性)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 using System.Collections;using System.Collections.Generic;using Unity.VisualScripting.Antlr3.Runtime.Misc;using UnityEngine;public enum AttackType{ Fire, Ice, Lightning } public enum StatType{ strength, agility, intelegence, vitality, damage, critChance, critPower, health, armor, evasion, magicRes, fireDamage, iceDamage, lightingDamage } public class Character_Stats : MonoBehaviour { private EntityFx fx; public Vector2 offest = new Vector2(1f , 1f ); [Header("角色属性" ) ] public Stat strength; public Stat agility; public Stat intelligence; public Stat vitality; [Header("防御 " ) ] public Stat maxHealth; public Stat armor; public Stat evasion; public Stat magicResistance; [Header("攻击" ) ] public Stat damage; public Stat critChance; public Stat critPower; [Header("魔法" ) ] public Stat iceDamage; public Stat fireDamage; public Stat lightningDamage; public bool isIgnited; public bool isChilled; public bool isShocked; [SerializeField ] private float ailmentsDuration = 4 ; private float ignitedTimer; private float chilledTimer; private float shockedTimer; private float ignitedDamageCooldown = .3 f; private float ignitedDamageTimer; [SerializeField ] private GameObject shockStrikePrefab; private int shockDamage; private int igniteDamage; public System.Action onHealthChanged; public bool isDead { get ; private set ; } public bool IsInvincible { get ; private set ; } public int currentHealth; private bool isVulnerable; protected virtual void Start () { critPower.SetDefaultValue(150 ); currentHealth = GetMaxHealthValue(); fx = GetComponent<EntityFx>(); } protected virtual void Update () { ignitedTimer -= Time.deltaTime; shockedTimer -= Time.deltaTime; chilledTimer -= Time.deltaTime; ignitedDamageTimer -= Time.deltaTime; if (ignitedTimer < 0 ) { isIgnited = false ; } if (chilledTimer < 0 ) { isChilled = false ; } if (shockedTimer < 0 ) { isShocked = false ; } if (isIgnited) ApplyIgniteDamage(); } public void MakeVulnerableFor (float _duration ) => StartCoroutine(VulnerableCorutine(_duration)); private IEnumerator VulnerableCorutine (float _duration ) { isVulnerable = true ; yield return new WaitForSeconds (_duration ) ; isVulnerable = false ; } public virtual void IncreaseStatBy (int _modifier, float _duration, Stat _statToModeify ) { StartCoroutine(StatModCorotien(_modifier, _duration, _statToModeify)); } private IEnumerator StatModCorotien (int _modifier, float _duration, Stat _statToModeify ) { _statToModeify.AddModifers(_modifier); yield return new WaitForSeconds (_duration ) ; _statToModeify.RemoveModifers(_modifier); } public virtual void DoDamage (Character_Stats _targetStats ) { bool criticalStrike = false ; if (canAvoidAttack(_targetStats)) return ; if (_targetStats.IsInvincible) return ; _targetStats.GetComponent<Entity>().SetupKnockBackDir(transform); int totalDamage = damage.GetValue() + strength.GetValue(); if (CanCrit()) { totalDamage = CalculateCriticalDamage(totalDamage); criticalStrike = true ; } fx.CreateHitFx(_targetStats.transform, criticalStrike); totalDamage = CheckTargetArmor(_targetStats, totalDamage); _targetStats.TakeDamage(totalDamage); DoMagicalDamage(_targetStats); } protected virtual void Die () { isDead = true ; } public void KillEntity () { if (!isDead) Die(); } public void MakeInvincible (bool _invincible ) { IsInvincible = _invincible; } public virtual void TakeDamage (int _damage ) { if (IsInvincible) return ; DecreaseHealthy(_damage); fx.StartCoroutine("FlashFX" ); GetComponent<Entity>().DamageImapct(); if (currentHealth < 0 && !isDead) Die(); } public virtual void IncreaseHealthBy (int _amount ) { currentHealth += _amount; if (currentHealth > GetMaxHealthValue()) currentHealth = GetMaxHealthValue(); if (onHealthChanged != null ) onHealthChanged(); } protected virtual void DecreaseHealthy (int _damage ) { if (isVulnerable) _damage = Mathf.RoundToInt(_damage * 1.1f ); currentHealth -= _damage; if (_damage > 0 ) { if (CanCrit()) fx.CreatePopUpText("<color=yellow>" + _damage.ToString() + "</color>" ); else fx.CreatePopUpText(_damage.ToString()); } if (onHealthChanged != null ) onHealthChanged(); } #region 魔法伤害和负面效果 private void ApplyIgniteDamage () { if (ignitedDamageTimer < 0 ) { DecreaseHealthy(igniteDamage); if (currentHealth < 0 && !isDead) Die(); ignitedDamageTimer = ignitedDamageCooldown; } } public virtual void DoMagicalDamage (Character_Stats _targetStats ) { int _fireDamage = fireDamage.GetValue(); int _iceDamage = iceDamage.GetValue(); int _lightningDamage = lightningDamage.GetValue(); int totalMagicDamage = _fireDamage + _iceDamage + _lightningDamage + intelligence.GetValue(); totalMagicDamage = CheckTargetResistance(_targetStats, totalMagicDamage); _targetStats.TakeDamage(totalMagicDamage); if (Mathf.Max(_fireDamage, _iceDamage, _lightningDamage) <= 0 ) return ; AttemptToApplyAilments(_targetStats, _fireDamage, _iceDamage, _lightningDamage); } private void AttemptToApplyAilments (Character_Stats _targetStats, int _fireDamage, int _iceDamage, int _lightningDamage ) { bool canApplyIgnite = _fireDamage > _iceDamage && _fireDamage > _lightningDamage; bool canApplyChill = _iceDamage > _fireDamage && _iceDamage > _lightningDamage; bool canApplyShock = _lightningDamage > _fireDamage && _lightningDamage > _iceDamage; while (!canApplyChill && !canApplyShock && !canApplyIgnite) { int result = Random.Range(1 , 4 ); if (result == 1 && _fireDamage > 0 ) { canApplyIgnite = true ; _targetStats.ApplyAilments(canApplyIgnite, canApplyChill, canApplyShock); return ; } if (result == 2 && _iceDamage > 0 ) { canApplyChill = true ; _targetStats.ApplyAilments(canApplyIgnite, canApplyChill, canApplyShock); return ; } if (result == 3 && _lightningDamage > 0 ) { canApplyShock = true ; _targetStats.ApplyAilments(canApplyIgnite, canApplyChill, canApplyShock); return ; } } if (canApplyIgnite) _targetStats.SetupIgniteDamage(Mathf.RoundToInt(_fireDamage * .2 f)); if (canApplyShock) _targetStats.SetupShockStrikeDamage(Mathf.RoundToInt(_lightningDamage * .1 f)); _targetStats.ApplyAilments(canApplyIgnite, canApplyChill, canApplyShock); } public void ApplyAilments (bool _ignite, bool _chill, bool _shock ) { bool canApplyIgnite = !isIgnited && !isChilled && !isShocked; bool canApplyChill = !isIgnited && !isChilled && !isShocked; bool canApplyShock = !isIgnited && !isChilled; if (_ignite && canApplyIgnite) { isIgnited = _ignite; ignitedTimer = ailmentsDuration; fx.IgniteFxfor(ailmentsDuration); } if (_chill && canApplyChill) { chilledTimer = ailmentsDuration; isChilled = _chill; float slowPercentage = .2 f; GetComponent<Entity>().SlowEntityBy(slowPercentage, ailmentsDuration); fx.ChillFxfor(ailmentsDuration); } if (_shock && canApplyShock) { if (!isShocked) { ApplyShock(_shock); } else { if (GetComponent<Player>() != null ) return ; HitNearestTargetWithShockStrike(); } } } public void ApplyShock (bool _shock ) { if (isShocked) return ; shockedTimer = ailmentsDuration; isShocked = _shock; fx.ShockFxfor(ailmentsDuration); } private void HitNearestTargetWithShockStrike () { Collider2D[] colliders = Physics2D.OverlapCircleAll(transform.position, 25 ); float closeDistance = Mathf.Infinity; Transform closestEnemy = null ; foreach (var hit in colliders) { if (hit.GetComponent<Enemy>() != null && Vector2.Distance(transform.position, hit.transform.position) > 1 ) { float distanceToEnemy = Vector2.Distance(transform.position, hit.transform.position); if (distanceToEnemy < closeDistance) { closeDistance = distanceToEnemy; closestEnemy = hit.transform; } } if (closestEnemy == null ) closestEnemy = transform; } if (closestEnemy != null ) { GameObject newShockStrike = Instantiate(shockStrikePrefab, transform.position, Quaternion.identity); newShockStrike.GetComponent<ShockStrike_Controller>().SetUp(shockDamage, closestEnemy.GetComponent<Character_Stats>()); } } public void SetupIgniteDamage (int _damage ) => igniteDamage = _damage; public void SetupShockStrikeDamage (int _damage ) => shockDamage = _damage; #endregion #region Stat calculations private int CheckTargetResistance (Character_Stats _targetStats, int totalMagicDamage ) { totalMagicDamage -= _targetStats.magicResistance.GetValue() + (_targetStats.intelligence.GetValue() * 3 ); totalMagicDamage = Mathf.Clamp(totalMagicDamage, 0 , int .MaxValue); return totalMagicDamage; } public int CheckTargetArmor (Character_Stats _targetStats, int totalDamage ) { if (_targetStats.isChilled) totalDamage -= Mathf.RoundToInt(_targetStats.armor.GetValue() * .8 f); else totalDamage -= _targetStats.armor.GetValue(); totalDamage -= _targetStats.armor.GetValue(); totalDamage = Mathf.Clamp(totalDamage, 0 , int .MaxValue); return totalDamage; } public virtual void OnEvasion () { } public bool canAvoidAttack (Character_Stats _targetStats ) { int totalEvasion = _targetStats.evasion.GetValue() + _targetStats.agility.GetValue(); if (isShocked) totalEvasion += 20 ; if (Random.Range(0 , 100 ) < totalEvasion) { _targetStats.OnEvasion(); return true ; } return false ; } public bool CanCrit () { int totalCritcakChance = critChance.GetValue() + agility.GetValue(); if (Random.Range(0 , 100 ) <= totalCritcakChance) { return true ; } return false ; } public int CalculateCriticalDamage (int _Damage ) { float totalCritPower = (critPower.GetValue() + strength.GetValue()) * .01 f; float critDamage = _Damage * totalCritPower; return Mathf.RoundToInt(critDamage); } public int GetMaxHealthValue () { return maxHealth.GetValue() + vitality.GetValue() * 5 ; } #endregion public Stat GetType (StatType _StatType ) { if (_StatType == StatType.strength) return strength; else if (_StatType == StatType.agility) return agility; else if (_StatType == StatType.intelegence) return intelligence; else if (_StatType == StatType.vitality) return vitality; else if (_StatType == StatType.damage) return damage; else if (_StatType == StatType.critChance) return critChance; else if (_StatType == StatType.critPower) return critPower; else if (_StatType == StatType.health) return maxHealth; else if (_StatType == StatType.armor) return armor; else if (_StatType == StatType.evasion) return evasion; else if (_StatType == StatType.magicRes) return magicResistance; else if (_StatType == StatType.fireDamage) return fireDamage; else if (_StatType == StatType.iceDamage) return iceDamage; else if (_StatType == StatType.lightingDamage) return lightningDamage; return null ; } }
PlayerStats类 该类继承Character_Stats类,重写了父类的DoDamage(),Take Damage(),Die(),Decrease Healthy(),OnEvasion()方法,同时实现了特有的在释放clone后造成的攻击
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 public class PlayerStats : Character_Stats { private Player player; protected override void Start () { base .Start(); player = GetComponent<Player>(); } public override void DoDamage (Character_Stats _targetStats ) { base .DoDamage(_targetStats); } public override void TakeDamage (int _damage ) { base .TakeDamage(_damage); } protected override void Die () { base .Die(); player.Die(); GameManager.instance.lostCurrencyAmount = PlayerManager.instance.currency; PlayerManager.instance.currency = 0 ; GetComponent<PlayerItemDrop>()?.GenerateDrop(); } protected override void DecreaseHealthy (int _damage ) { base .DecreaseHealthy(_damage); if (_damage > GetMaxHealthValue() * .3 f) { player.SetupKnocwbackPower(new Vector2(10 , 7 )); player.playerFx.ScreenShake(player.playerFx.shakeHighDamage); int randomSound = Random.Range(31 , 32 ); AudioManager.instance.PlaySFX(randomSound, null ); } ItemData_Equipment currentArmor = Inventory.Instance.GetEquipment(EquipmentType.armor); if (currentArmor != null ) { currentArmor.Effect(player.transform); } } public override void OnEvasion () { player.skill.dodge.CreateMirageOnDodge(); } public void CloneDoDamage (Character_Stats _targetStats, float _multipie ) { if (canAvoidAttack(_targetStats)) return ; int totalDamage = damage.GetValue() + strength.GetValue(); if (_multipie > 0 ) totalDamage = Mathf.RoundToInt(totalDamage *_multipie); if (CanCrit()) { totalDamage = CalculateCriticalDamage(totalDamage); } totalDamage = CheckTargetArmor(_targetStats, totalDamage); _targetStats.TakeDamage(totalDamage); DoMagicalDamage(_targetStats); } }
EnemyStats类 在该类中,实现敌人的属性随level的等级按几何增长的方法,以及重写了DoDamage(),Take Damage(),Die(),以实现enemy独特的攻击以及死亡方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 public class EnemyStats : Character_Stats { private Enemy enemy; private ItemDrop myDropSystem; public Stat soulDropAmout; [Header("Level details" ) ] [SerializeField ] private int level = 1 ; [Range(0f, 1f) ] [SerializeField ] private float percentageModifier = .4 f; protected override void Start () { soulDropAmout .SetDefaultValue(100 ); ApplyLevelModifers(); base .Start(); enemy = GetComponent<Enemy>(); myDropSystem = GetComponent<ItemDrop>(); } private void ApplyLevelModifers () { Modify(strength); Modify(agility); Modify(intelligence); Modify(vitality); Modify(damage); Modify(maxHealth); Modify(armor); Modify(evasion); Modify(magicResistance); Modify(fireDamage); Modify(fireDamage); Modify(lightningDamage); Modify(soulDropAmout); } private void Modify (Stat _stat ) { for (int i = 0 ; i < level; i++) { float modifer = _stat.GetValue() * percentageModifier; _stat.AddModifers(Mathf.RoundToInt(modifer)); } } public override void DoDamage (Character_Stats _targetStats ) { base .DoDamage(_targetStats); } public override void TakeDamage (int _damage ) { base .TakeDamage(_damage); } protected override void Die () { base .Die(); enemy.Die(); PlayerManager.instance.currency += soulDropAmout.GetValue(); myDropSystem.GenerateDrop(); Destroy(gameObject,5f ); }
背包系统 核心数据结构
物品数据(ItemData 及子类)
作为物品的基础数据模板,包含物品 ID(itemId)、名称、图标(icon)、类型(itemType,如装备 / 材料)、掉落概率(dropChance)等通用属性。
装备类物品(ItemData_Equipment)继承自 ItemData,额外包含装备类型(EquipmentType:武器 /armor/ 饰品等)、冷却时间(ItemCooldown)、效果逻辑(Effect())等。
背包物品实例(InventoryItem)
用于记录背包中实际存在的物品,关联对应的 ItemData 模板,并记录堆叠数量(stackSize)。
提供堆叠增减方法(AddStack()/RemoveStack()),处理物品数量变化。
1 2 3 4 5 6 public class InventoryItem { public ItemData data; public int stackSize; public void AddStack () => stackSize++; public void RemoveStack () => stackSize--; }
背包容器管理(Inventory 类) 通过多个容器 区分物品类型(装备 / 背包 / 仓库),并使用字典 + 列表 双重结构实现高效查询与遍历:
容器类型
inventory/inventoryDictionary:玩家背包(装备类物品)。
stash/stashDictionary:仓库(材料类物品)。
equipment/equipmentDictionary:已装备物品(按 EquipmentType 区分槽位)。
核心功能
添加物品(AddItem()):根据物品类型(装备 / 材料)分别加入背包或仓库,支持同类型物品自动堆叠。
1 2 3 4 public void AddItem (ItemData _item ) { if (_item.itemType == ItemType.Equipment) AddToInventory(_item); else if (_item.itemType == ItemType.Material) AddToStash(_item); }
移除物品(RemoveItem()) :减少堆叠数量,数量为 0 时从容器中删除。
装备管理(EquipItem()/UnequipItem()) :装备物品时替换同类型旧装备,更新属性(AddModifiers()/RemoveModifiers())。
UI 同步(UpdateSlotUI()) :物品变动后刷新背包、仓库、装备槽的 UI 显示。
UI 交互逻辑
槽位组件
UI_ItemSlot:背包 / 仓库的物品槽,负责显示物品图标和堆叠数量(UpdateSlot())。
UI_EquipmentSlot:装备槽,按 EquipmentType 绑定对应装备(如武器槽只显示武器)。
制作系统(UI_CraftList)
通过 craftEquipment 列表配置可制作的装备,动态生成制作槽(UI_CraftSlot)。
制作时调用 Inventory.CanCraft() 检查材料是否充足,消耗材料后添加成品到背包。
数据持久化 通过实现 ISavedManager 接口,确保背包数据在游戏重启后不丢失:
保存逻辑(SaveData())
将背包、仓库中物品的 itemId 和堆叠数量存入 GameData.inventory 字典。
将已装备物品的 itemId 存入 GameData.equipmentId 列表。
1 2 3 4 5 6 public void SaveData (ref GameData gameData ) { foreach (var pair in inventoryDictionary) gameData.inventory.Add(pair.Key.itemId, pair.Value.stackSize); foreach (var pair in equipmentDictionary) gameData.equipmentId.Add(pair.Key.itemId); }
加载逻辑(LoadData())
从 GameData 中读取物品 ID 和数量,通过 itemDataBase 查找对应 ItemData,重建背包和装备状态。
扩展功能
物品掉落(ItemDrop/PlayerItemDrop)
敌人或玩家死亡时,通过 GenerateDrop() 随机掉落物品,实例化 ItemObject 作为场景中的可拾取物体,手动装入掉落物品,自定义设置。
玩家拾取时调用 Inventory.AddItem() 加入背包。
物品使用
消耗品(如药水 Flask)通过 UsedFlask() 触发效果,受冷却时间限制。
背包系统模块之间的联系 与物品数据系统(ItemData 及子类)的连接
数据关联
背包系统通过 itemDataBase 列表存储所有物品的基础数据模板(ItemData 及其子类,如装备 ItemData_Equipment、材料等),用于物品实例化和属性查询。
例如:当玩家拾取物品时,Inventory.AddItem(ItemData) 会根据物品类型(装备 / 材料)调用不同逻辑,装备类物品存入 inventory 列表,材料存入 stash 列表。
装备属性联动
装备类物品(ItemData_Equipment)包含属性加成(如攻击力、防御力),背包系统在 EquipItem() 时调用 AddModifiers() 方法,将属性应用到玩家 stats 系统(PlayerStats);卸载时调用 RemoveModifiers() 移除加成。
1 2 newEquipment.AddModifiers();
与 UI 系统的连接
槽位组件绑定
背包系统通过 inventorySlotParent、stashSlotParent、equipmentSlotParent 等变量,绑定 UI 中的物品槽父节点,动态生成或更新槽位显示(UI_ItemSlot、UI_EquipmentSlot)。
例如:UpdateSlotUI() 方法会遍历背包 / 仓库 / 装备列表,调用每个槽位的 UpdateSlot(InventoryItem) 方法,刷新图标、数量等显示。
制作系统交互
制作界面(UI_CraftList)通过 craftEquipment 列表配置可制作装备,点击制作时调用 Inventory.CanCraft() 检查材料是否充足。
材料足够则消耗材料(RemoveItem())并添加成品到背包(AddItem()),同时更新制作界面和背包 UI。
与玩家系统(Player)的连接
物品拾取与掉落
玩家拾取场景中的物品(ItemObject)时,ItemObject.PickUpItem() 调用 Inventory.AddItem(itemData) 将物品加入背包,同时销毁场景中的物品实体。
玩家死亡时,PlayerItemDrop.GenerateDrop() 会随机从背包和仓库中掉落物品,通过 DropItem(item.data) 实例化场景物品,并从背包中移除(RemoveItem() 或 UnequipItem())。
状态与能力影响
背包中的消耗品(如药水 Flask)使用时,Inventory.UsedFlask() 会触发玩家状态恢复,并受冷却时间(flaskCooldown)限制,冷却状态通过 UI 同步显示。
与数据持久化系统(Save And Load)的连接
数据保存
背包系统实现 ISavedManager 接口,SaveData() 方法将物品 ID 和堆叠数量存入 GameData 的字典(inventory、equipmentId),确保关闭游戏后数据不丢失。
1 2 3 foreach (var pair in inventoryDictionary) gameData.inventory.Add(pair.Key.itemId, pair.Value.stackSize);
数据加载
加载游戏时,LoadData() 从 GameData 读取物品 ID 和数量,通过 itemDataBase 查找对应 ItemData,重建背包、仓库和装备状态(AddItem()、EquipItem())。
与技能系统的间接连接
部分技能的解锁或效果可能依赖背包中的物品(如消耗特定材料释放技能),通过 Inventory 提供的 GetStashList() 或 CheckItemExists() 方法(逻辑隐含)检查物品是否存在,实现技能与物品的联动。
装备效果 UI SaveManager 在项目中,实现游戏内容的保存使用依赖倒置的设计原则,并且通过不同的类来实现,具体如下。
1.GameData类 在GameData类中,定义了游戏数据的结构,包括库存,游戏货币,技能树,装备,音量的大小,检查点等字段,对于每种不同的字段类型采取不同的数据结构进行存储。
值类型存储 :
1 2 3 4 5 6 public int currency; public float lostCurrencyX;public float lostCurrencyY;public int lostCurrencyAmount;public string closetCheckPointId;
列表类型存储 :
1 public List<string > equipmentId;
字典类型
1 2 3 4 5 public Serializable_Dictionary<string , int > inventory;public Serializable_Dictionary<string , bool > skillTree;public Serializable_Dictionary<string , bool > checkpoints;public Serializable_Dictionary<string , float > volumeSettings;
在Unity中,默认是不支持字典类型的序列化的,所以采用Serializable_Dictionary类来实现Unity序列化字典类型。
2.Serializable_Dictionary类 实现该接口的 OnBeforeSerialize 和 OnAfterDeserialize 方法,在序列化前后转换字典和列表。Dictionary<TKey, TValue> :继承自标准字典类,扩展序列化功能。
在Serializable_Dictionary类,使用2个列表来分别存储字典的Key和Value值,在序列化前,首先清空2个列表的内容,之后使用foreach循环,遍历字典,将字典的Key和Value分别插入不同的列表。在序列化后,首先判断列表的Key和value的长度是否相同,不同则return。判断后,将2个列表的数据通过this关键字,重新构建成字典
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public class Serializable_Dictionary <TKey , TValue > : Dictionary <TKey , TValue >, ISerializationCallbackReceiver { [SerializeField ] private List<TKey> keys = new List<TKey>(); [SerializeField ] private List<TValue> values = new List<TValue>(); public void OnBeforeSerialize () { keys.Clear(); values.Clear(); foreach (KeyValuePair<TKey, TValue> kvp in this ) { keys.Add(kvp.Key); values.Add(kvp.Value); } } public void OnAfterDeserialize () { this .Clear(); if (keys.Count != values.Count) { Debug.Log("键数和值数不相等" ); return ; } for (int i = 0 ; i < keys.Count; i++) { this .Add(keys[i], values[i]); } } }
3.ISaveManager接口 在 SaveManager 和存档组件之间使用 ISavedManager 接口,是一种典型的 依赖倒置原则(Dependency Inversion Principle, DIP) 的应用,目的是 降低耦合度 ,提高系统的 可扩展性 和 可维护性 。确保每次新增保存或者修改存档的时候,只需要实现这个接口的内容则可以完成功能。在该接口中只存在2个方法,在保存内容和加载内容时,只需要实现接口的方法。
1 2 3 4 5 public interface ISavedManager { void LoadData (GameData gameData ) ; void SaveData (ref GameData gameData ) ; }
4.FindDataHandler类 负责文件的IO操作,包括数据的序列化(保存)和反序列化(加载),以及加密/解密功能。FindDataHandler 通过 Save 和 Load 方法直接操作 GameData 对象。Save 方法将 GameData 序列化为 JSON 并保存到文件;Load 方法从文件读取 JSON 并反序列化为 GameData。在保存和加载的过程中使用unity提供的 JSON 序列化工具,用于 GameData 和 JSON 字符串之间的转换。
在Save方法中,将文件的路径和文件名拼接组成完整的路径,提供path.GetDirectoryName(fullPath)方法创建目录,再将数据转换为Json格式,通过使用IO流将文件存入创建的目录中进行保存。对于Load方法中,直接操控GameData对象,同样拼接完整的路径,打开路径下的文件,使用Io流将文件读取出来,并且将json文件转为gameData格式类型数据进行返回。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 public class FindDataHandler { private string dataDirPath = "" ; private string dataFileName = "" ; private bool encryptData= false ; private string codeWord = "CoderJ" ; public FindDataHandler (string _dataDirPath, string _dataFileName,bool _encryptData ) { this .dataDirPath = _dataDirPath; this .dataFileName = _dataFileName; this .encryptData = _encryptData; } public void Save (GameData _data ) { string fullPath = Path.Combine(dataDirPath, dataFileName); try { Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); string dataToStore = JsonUtility.ToJson(_data,true ); if (encryptData) dataToStore = EncryptDescrypt(dataToStore); using (FileStream stream = new FileStream(fullPath, FileMode.Create)) { using (StreamWriter writer = new StreamWriter(stream)) { writer.Write(dataToStore); } } } catch (Exception e) { Debug.LogError("Error on trying to save data to file" + fullPath + "\n" +e); } } public GameData Load () { string fullPath = Path.Combine(dataDirPath,dataFileName); GameData loadData = null ; if (File.Exists(fullPath)) { try { string dataToLoad = "" ; using (FileStream stream = new FileStream(fullPath, FileMode.Open)) { using (StreamReader reader = new StreamReader(stream)) { dataToLoad = reader.ReadToEnd(); } } loadData = JsonUtility.FromJson<GameData>(dataToLoad); if (encryptData) dataToLoad = EncryptDescrypt(dataToLoad); } catch (Exception e) { Debug.LogError("Error on trying on load data form file " + fullPath+ "\n" +e); } } return loadData; } public void Delete () { string fullpath = Path.Combine(dataDirPath,dataFileName); if (File.Exists(fullpath)) File.Delete(fullpath); } private string EncryptDescrypt (string _data ) { string modifiedData = "" ; for (int i = 0 ; i < _data.Length; i++) { modifiedData += (char )_data[i]^codeWord[i % codeWord.Length]; } return modifiedData; } }
5.SaveManager类 存档系统的核心管理类,负责协调数据加载、保存、初始化等流程。保存和加载数据都是通过Sava Manager进行操作,在Awake方法中调用 private List FindAllSaveManger()方法,通过反射以及LINQ查找到所有实现 ISaveManager 的组件(如背包、技能树等),将其返回实现了ISavedManager接口的组件实例列表 。
在LoadData方法中,直接加载游戏数据,如果游戏数据不为空,证明不存在游戏数据,直接开启新游戏。存在则,则将加载的数据传递给所有已经实现ISaveManager接口的组件的实例(也就是外部的gameobject物体挂载已经实现IsavaManager接口的类)。
在SavaData方法中,将实现ISavaManager接口的组件的存入列表中,使用FindDataHandler类提供的Save方法进行存储
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 public class SaveManager : MonoBehaviour { public static SaveManager instance; private GameData gameData; [SerializeField ] private bool encryptData; [SerializeField ] private string fileName; private List<ISavedManager> savedManagers = new List<ISavedManager>(); private FindDataHandler dataHandler; [ContextMenu("删除已经保存的文件" ) ] public void DeleteSaveData () { dataHandler = new FindDataHandler(Application.persistentDataPath, fileName, encryptData); dataHandler.Delete(); } private void Awake () { if (instance!=null ) Destroy(instance.gameObject); else instance = this ; dataHandler = new FindDataHandler(Application.persistentDataPath, fileName, encryptData); savedManagers = FindAllSaveManger(); LoadGame(); } public void Start () { } public void NewGame () { gameData = new GameData(); } public void LoadGame () { gameData = dataHandler.Load(); if (this .gameData == null ) { Debug.Log("无存档数据,创建新游戏" ); NewGame(); } foreach (ISavedManager savedManager in savedManagers) { savedManager.LoadData(gameData); } } public void SaveGame () { foreach (ISavedManager saveManager in savedManagers) { saveManager.SaveData(ref gameData); } dataHandler.Save(gameData); } private void OnApplicationQuit () { SaveGame(); } private List<ISavedManager> FindAllSaveManger () { IEnumerable<ISavedManager> savedManagers = FindObjectsOfType<MonoBehaviour>(true ).OfType<ISavedManager>(); return new List<ISavedManager>(savedManagers); } public bool HasSavedData () { if (dataHandler.Load()!=null ) return true ; return false ; } }
上述5个类是实现保存的基本模块,如果需要保存数据,则实现ISaveManager接口的方法就可以保存数据,
类关系图(简化) 1 2 3 4 5 6 7 8 9 10 SaveManager (MonoBehaviour) │ ├── Manages → FindDataHandler (文件IO、加密) │ └── Operates → GameData (游戏数据结构) │ └── Contains → Serializable_Dictionary (可序列化字典) │ ├── Collects → List<ISavedManager> (存档组件接口) │ └── Implemented by various MonoBehaviour components │ └── Uses → JsonUtility, System.IO, LINQ
关键交互流程
初始化 :
SaveManager.Awake 创建 FindDataHandler,查找所有 ISavedManager 组件,调用 LoadGame。
LoadGame 通过 dataHandler.Load 加载 GameData,若不存在则调用 NewGame 初始化。
加载完成后,通知所有 ISavedManager 组件(savedManager.LoadData)。
保存游戏 :
SaveManager.SaveGame 收集所有 ISavedManager 组件的数据(saveManager.SaveData)。
调用 dataHandler.Save 将 GameData 序列化为 JSON 并加密(可选),写入文件。
数据序列化 :
Serializable_Dictionary 在序列化前将字典拆分为两个列表(OnBeforeSerialize)。
反序列化后从列表重建字典(OnAfterDeserialize)。
加密 :
FindDataHandler.EncryptDescrypt 在保存/加载时对 JSON 字符串进行异或加密/解密。
实现的流程
数据加载流程 (LoadGame):
SaveManager 通过 FindAllSaveManger() 找到所有实现 ISaveManager 的组件(如背包、技能树等)。
调用每个组件的 LoadData(gameData),将反序列化后的 GameData 分发到各个组件,由组件自行处理数据分配(如更新UI、初始化状态)。
1 2 3 foreach (ISavedManager savedManager in savedManagers) { savedManager.LoadData(gameData); }
数据保存流程 (SaveGame):
SaveManager 遍历所有 ISaveManager 组件,调用其 SaveData(ref gameData)。
每个组件将自身数据写入到传入的 GameData 对象中(如更新 gameData.inventory)。
最终由 FindDataHandler 将 GameData 序列化为JSON并保存到文件。
1 2 3 4 foreach (ISavedManager saveManager in savedManagers) { saveManager.SaveData(ref gameData); } dataHandler.Save(gameData);
MainScene 在项目,分为Meun场景和Main场景,Menu场景则是用户进入游戏的菜单场景,包括继续游戏,新游戏以及退出的模块。同时在进入主场景的时候采用淡入的方式进入。以下是具体的实现:
在实现进入Menu场景淡入的效果,采用了动画的方式进行,在动画的第一帧打上全黑的图片,将透明度设置为不透明,在动画的最后一帧将全黑的图片的透明度降低为0,则显示Menu场景的内容。同时在玩家死亡,也采用此效果,在玩家死亡后,屏幕逐渐变黑,并且在1s后出现死亡的文本,同时2.5s后出现重新开始的按钮。
UI_Fade_Scene类 :在此类中,只有Fadeout和FadeIn方法进行动画的播放,如果需要播放则调用
1 2 3 4 5 6 7 8 9 10 11 public class UI_FadeScene : MonoBehaviour { private Animator anim; private void Start () { anim = GetComponent<Animator>(); } public void FadeOut () => anim.SetTrigger("FadeOut" ); public void FadeIn () => anim.SetTrigger("FadeIn" ); }
UI类 :在该类中实现了死亡弹出文本以及重新开始按钮的内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public class UI : MonoBehaviour ,ISavedManager { [Header("Dead" ) ] [SerializeField ] private GameObject endText; [SerializeField ] private UI_FadeScene fadeScene; [SerializeField ] private GameObject RestartButton; [Space ] private void Awake () { fadeScene.gameObject.SetActive(true ); } public void SwitchOnEndScreen () { fadeScene.FadeOut(); StartCoroutine(EndScreenCorutione()); } IEnumerator EndScreenCorutione () { yield return new WaitForSeconds (1f ) ; endText.SetActive(true ); yield return new WaitForSeconds (1.5f ) ; RestartButton.SetActive(true ); } public void RestartGameButton () =>GameManager.instance.RestartScene(); }
PlayeDeadState类 :在玩家死亡后,调用SwitchOnEndScreen()方法,实现黑屏以及弹出文本
1 2 3 4 5 6 7 8 public class PlayerDeadState : PlayerState { public override void Enter () { base .Enter(); GameObject.Find("Canvas" ).GetComponent<UI>().SwitchOnEndScreen(); } }
在Canvas中,ReStartButton按钮是默认关闭的,在按钮中添加监听事件,当检查到事件触发,则将按钮开启
CheckPoint模块 CheckPoint也就是复活点,在项目中我们通过采用动画来播放CheckPoint的默认状态,以及点亮后的状态,通过CheckPoint脚本来控制动画。具体实现:首先创建一个空物体,并且挂载上CheckPoint脚本,与此同时给该物体添加BoxColider以及Animator组件,以便通过脚本来实现动画的播放。在CheckPoint脚本中,使用OnTriggerEnter2D来检测与玩家的碰撞,当玩家走到检查点的碰撞器范围时,OnTriggerEnter2D检测到Player,开始点亮CheckPoint,并且播放动画,因为没有退出动画所以一直播放,使用的是Trigger参数,而非bool
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public class CheckPoint : MonoBehaviour { private Animator anim; public bool activated; private void Awake () { anim = GetComponent<Animator>(); } private void OnTriggerEnter2D (Collider2D collision ) { if (collision.GetComponent<Player>() != null ) { ActivateCheckPoint(); SaveManager.instance.SaveGame(); } } public void ActivateCheckPoint () { if (activated == false ) { AudioManager.instance.PlaySFX(5 , null ); } activated = true ; anim.SetBool("Active" , true ); } }
在实现点亮的基本逻辑后,因为在游戏的给出,肯定不止一个检测点,一般为最近点亮的检测点为复活点,所以为此我们需要给每一个检测点添加一个Id,以便找到最近的检查点。具体逻辑为:采用[ContextMenu(“创建复活点Id”)]这个属性为脚本中的方法在InSpector中自定义一个上下文菜单项,手动的生成一个随机的Id给检查点。
定义一个FindClosestCheckpoint()方法来实现寻找最近的检查点,(**前提需要在GameData中定义一个 public Serializable_Dictionary<string, bool> checkpoints 的检查点字典将所有的检查点保存,使用foreach变量字典checkpoints,并且使用自带的Add方法,将每个检查点的Id和激活状态存入 **),定义一个float类型的变量closestDistance以及一个CheckPoint的变量closestCheckpoint来找到最近的距离和检查点,再次去遍历checkpoints,并且在遍历的过程中使用Vector2自带的Distance方法找到每个检查点与玩家间的距离,并且赋值给distanceToCheckpoint这个变量,在通过if判断distanceToCheckpoint是否小于closestDistance 并且 已经激活,如果是则将distanceToCheckpoint赋值给closestDistance,将此检查点赋值给closestCheckpoint。此为最近的检查点并且进行返回,同时将最近的检查点Id赋值给GameData中的closetCheckPointId。
在给每个检测点赋值Id后,开始寻找最近的检查点,实现定义一个能储存CheckPoint类的数组,定义数组后,需要将所有的CheckPoint类进行存入数组,通过实现ISaveManager接口中的SaveData方法(自定义的保存和加载的接口)来实现存入以及保存,保存成功。
下一步加载最近的检查点,使用LoadClosetCheckPoint()方法加载,首先判断是否存在closetCheckPointId,不存在,则说明不存在最近的检查点。存在则将最近检查点ID存入变量closestCheckpointId(定义在GameManager中),然后变量CheckPoint类数组,判断每一个遍历的检查点的Id是否与最近的Id相同,如果相同,则将最近检查点的Id赋值给玩家(将玩家传送到检查点)
GameData类
1 2 public Serializable_Dictionary<string , bool > checkpoints;public string closetCheckPointId;
GameManager类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 [SerializeField ] private CheckPoint[] checkPoints; [SerializeField ] public string closestCheckpointId; public void SaveData (ref GameData gameData ) { if (FindClosestCheckpoint() != null ) gameData.closetCheckPointId = FindClosestCheckpoint().Id; gameData.checkpoints.Clear(); foreach (CheckPoint checkPoint in checkPoints) { gameData.checkpoints.Add(checkPoint.Id,checkPoint.activated); } } public void LoadCheckpoint (GameData gameData ) { foreach (KeyValuePair<string , bool > pair in gameData.checkpoints) { foreach (CheckPoint check in checkPoints) { if (check.Id == pair.Key) { if (pair.Value == true ) check.ActivateCheckPoint(); } } } } private void LoadClosetCheckPoint (GameData _data ) { if (_data.closetCheckPointId == null ) return ; closestCheckpointId = _data.closetCheckPointId; ; foreach (CheckPoint checkPoint in checkPoints) { if (closestCheckpointId == checkPoint.Id) player.position = checkPoint.transform.position; } } public CheckPoint FindClosestCheckpoint () { float closestDistance = Mathf.Infinity; CheckPoint closestCheckpoint = null ; foreach (var checkpoint in checkPoints) { float distanceToCheckpoint = Vector2.Distance(player.position, checkpoint.transform.position); if (distanceToCheckpoint < closestDistance && checkpoint.activated == true ) { closestDistance = distanceToCheckpoint; closestCheckpoint = checkpoint; } } return closestCheckpoint; }
1.检查点ID生成
每个检查点(CheckPoint)在Unity编辑器中提供一个按钮,点击后生成一个唯一ID(GUID) ,用于标识该检查点。
这个ID在游戏保存时用于记录检查点的状态(是否激活)。
2. 检查点激活
当玩家触碰到检查点时:
如果该检查点未被激活 ,则播放音效和动画,并标记为已激活。
立即触发游戏保存,记录当前所有检查点的状态。
3. 查找最近的检查点
遍历所有已激活 的检查点,计算它们与玩家当前位置的距离。
找出距离最近 的检查点,并返回该检查点。
如果所有检查点均未激活,则返回null。
4. 游戏数据保存
最近检查点:
所有检查点状态:
保存每个检查点的ID及其激活状态(true/false)。
5. 游戏数据加载
加载最近检查点:
如果存档中有最近检查点ID,则找到对应的检查点,并将玩家传送至该位置。
如果没有最近检查点(如首次游戏),则不做处理。
加载检查点状态:
遍历所有检查点,如果存档中记录为已激活,则播放激活动画。
AudioManager 错误分析 在最开始我准备采用ScriptableObject进行音频的管理,便于扩展,所以需要2个带实现,一个是Audio Setting类,一个是AudioManager类,前者通过继承ScriptableObject类,进行数据的存储管理,后者直接加载和播放音频,包含背景音乐和音效的播放和暂停,采用单例模式提高全局的一个入口进行使用。
AudioSetting类
1 2 3 4 5 6 7 8 9 10 11 12 13 using System.Collections;using System.Collections.Generic;using UnityEngine;[CreateAssetMenu(fileName = "AudioSettings" , menuName = "Audio/Audio Settings" , order = 1) ] public class AudioSetting : ScriptableObject { [SerializeField ] public float sfxMinimumDistance; [SerializeField ] public AudioSource[] sfx; [SerializeField ] public AudioSource[] bgm; }
AudioManager类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 using System.Collections;using System.Collections.Generic;using Unity.VisualScripting;using UnityEngine;public class AudioManager : MonoBehaviour { public static AudioManager instance; public AudioSetting audioSetting; public bool playbgm; private int bgmIndex; private void Awake () { if (instance != null ) Destroy(instance.gameObject); else instance = this ; } private void Update () { if (!playbgm) StopAllBGM(); else { if (!audioSetting.bgm[bgmIndex].isPlaying) PlayBGM(bgmIndex); } } public void PlaySFX (int _sfxIndex, Transform _source ) { if (audioSetting.sfx[_sfxIndex].isPlaying) return ; if (_source != null && Vector2.Distance(PlayerManager.instance.player.transform.position, _source.position) > audioSetting.sfxMinimumDistance) return ; if (_sfxIndex < audioSetting.sfx.Length) { audioSetting.sfx[_sfxIndex].pitch = Random.Range(0.85f , 1.1f ); audioSetting.sfx[_sfxIndex].Play(); } } public void StopSFX (int _sfxIndex ) => audioSetting.sfx[_sfxIndex].Stop(); public void PlayRandomBGM () { bgmIndex = Random.Range(0 , audioSetting.bgm.Length); PlayBGM(bgmIndex); } public void PlayBGM (int _bgmIndex ) { bgmIndex = _bgmIndex; StopAllBGM(); audioSetting.bgm[bgmIndex].Play(); } public void StopAllBGM () { for (int i = 0 ; i < audioSetting.bgm.Length; i++) { audioSetting.bgm[i].Stop(); } } }
代码完成后,首先在Inspector面板创建一个空的Game object对象,并且挂载相应的脚本,在Asset中创建一个Audio Setting的资产对象,在AudioSetting中存在2个Audio source数组,分别是sfx和bgm,当我从Asset资产直接拖拽音频文件给sfx数组和bgm数组时,发现无法进行拖拽,当我重新创建一个空物体并且给其添加AudioSource组件,在进行拖拽时出现类型不匹配的问题
最后的原因如下 :
AudioSource是一个组件(Component),而音频文件(AudioClip)是资源(Asset),它们是不同的类型。
你的AudioSetting脚本中定义的sfx和bgm数组是AudioSource[]类型,但你想拖入的是AudioClip类型。
于是改变Audio Setting中的bgm和sfx的类型为AudioClip,然后在AudioManager中动态创建AudioSource
具体如下
AudioSetting
1 2 3 4 5 6 7 8 9 10 [CreateAssetMenu(fileName = "AudioSettings" , menuName = "Audio/Audio Settings" , order = 1) ] public class AudioSetting : ScriptableObject { [SerializeField ] public float sfxMinimumDistance; [SerializeField ] public AudioClip[] sfx; [SerializeField ] public AudioClip[] bgm; }
AudioManager
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 using System.Collections;using System.Collections.Generic;using Unity.VisualScripting;using UnityEngine;public class AudioManager : MonoBehaviour { public static AudioManager instance; public AudioSetting audioSetting; public bool playbgm; private int bgmIndex; private AudioSource bgmSource; private AudioSource sfxSource; private void Awake () { if (instance != null ) Destroy(instance.gameObject); else instance = this ; bgmSource = gameObject.AddComponent<AudioSource>(); sfxSource = gameObject.AddComponent<AudioSource>(); } private void Update () { if (!playbgm) StopAllBGM(); else { if (!bgmSource.isPlaying&&audioSetting.bgm.Length > 0 ) PlayBGM(bgmIndex); } } public void PlaySFX (int _sfxIndex, Transform _source ) { if (_sfxIndex >= audioSetting.sfx.Length || audioSetting.sfx[_sfxIndex] == null ) return ; if (_source != null && Vector2.Distance(PlayerManager.instance.player.transform.position, _source.position) > audioSetting.sfxMinimumDistance) return ; if (_sfxIndex < audioSetting.sfx.Length) { sfxSource.pitch = Random.Range(0.85f , 1.1f ); sfxSource.PlayOneShot(audioSetting.sfx[_sfxIndex]); } } public void StopSFX (int _sfxIndex ) => sfxSource.Stop(); public void PlayRandomBGM () { bgmIndex = Random.Range(0 , audioSetting.bgm.Length); PlayBGM(bgmIndex); } public void PlayBGM (int _bgmIndex ) { bgmIndex = _bgmIndex; StopAllBGM(); bgmSource.clip = audioSetting.bgm[bgmIndex]; bgmSource.loop = true ; bgmSource.Play(); } public void StopAllBGM () { for (int i = 0 ; i < audioSetting.bgm.Length; i++) { bgmSource.Stop(); } } }
即可成功进行拖拽
具体实现 在类银河恶魔城这个项目,采用单例模式进行管理音频,确保全局只存在一个实例 ,在这个项目中存在两组音频,分别是音效以及背景音乐,在Audio Setting类中通过sfx,bgm这两个AudioClip变量进行管理整个音频文件。
1 2 3 4 [SerializeField ] public AudioClip[] sfx; [SerializeField ] public AudioClip[] bgm;
同样使用2组函数进行管理音频的播放和暂停,同时使用int变量bgmIndex 记录当前的播放的bgm,便于管理
提供播放/停止音效的方法
提供播放/停止背景音乐的方法,包括随机播放功能
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 private void Update () { if (!playbgm) StopAllBGM(); else { if (!bgmSource.isPlaying&&audioSetting.bgm.Length > 0 ) PlayBGM(bgmIndex); } } public void PlaySFX (int _sfxIndex, Transform _source ) { if (_sfxIndex >= audioSetting.sfx.Length || audioSetting.sfx[_sfxIndex] == null ) return ; if (_source != null && Vector2.Distance(PlayerManager.instance.player.transform.position, _source.position) > audioSetting.sfxMinimumDistance) return ; if (_sfxIndex < audioSetting.sfx.Length) { sfxSource.pitch = Random.Range(0.85f , 1.1f ); sfxSource.PlayOneShot(audioSetting.sfx[_sfxIndex]); } } public void StopSFX (int _sfxIndex ) => sfxSource.Stop(); public void PlayRandomBGM () { bgmIndex = Random.Range(0 , audioSetting.bgm.Length); PlayBGM(bgmIndex); } public void PlayBGM (int _bgmIndex ) { bgmIndex = _bgmIndex; StopAllBGM(); bgmSource.clip = audioSetting.bgm[bgmIndex]; bgmSource.loop = true ; bgmSource.Play(); } public void StopAllBGM () { for (int i = 0 ; i < audioSetting.bgm.Length; i++) { bgmSource.Stop(); } }
在实现音效效果后,同时需要对音频的播放距离进行限制,也就是离人物越远,音效效果越小。这样可以减少混乱,比如给skeleton添加走路音效,但是不进行检查,这样player很远距离就可以听见,造成混乱。在PlaySFX方法添加判断条件,如果玩家当前与目标的距离小于自定义的最小距离值(外部设置),则直接退出播放,不产生声音。同时/如果音乐正在播放,则退出,不影响原有的播放
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 [SerializeField ] private float sfxMInimumDistance; public void PlaySFX (int _sfxIndex, Transform _source ) { if (_sfxIndex >= audioSetting.sfx.Length || audioSetting.sfx[_sfxIndex] == null ) return ; if (_source != null && Vector2.Distance(PlayerManager.instance.player.transform.position, _source.position) > audioSetting.sfxMinimumDistance) return ; if (_sfxIndex < audioSetting.sfx.Length) { sfxSource.pitch = Random.Range(0.85f , 1.1f ); sfxSource.PlayOneShot(audioSetting.sfx[_sfxIndex]); } }
音量保存 音量控制 在此之前,需要在窗口中(Windows)找到音频混合器,并创建一个混合器以及所需要的2个组,在此项目中为SFX和BGM,并将其暴露给脚本进行控制。因为我使用的是AudioClip而非AudioSource,所有需要在AudioManager中进行修改,修改如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 using UnityEngine.Audio; public class AudioManager : MonoBehaviour { [SerializeField ] private AudioMixer mainMixer; [SerializeField ] private AudioMixerGroup bgmMixerGroup; [SerializeField ] private AudioMixerGroup sfxMixerGroup; private void Awake () { bgmSource = gameObject.AddComponent<AudioSource>(); sfxSource = gameObject.AddComponent<AudioSource>(); bgmSource.outputAudioMixerGroup = bgmMixerGroup; sfxSource.outputAudioMixerGroup = sfxMixerGroup; } }
绑定slider 并且将创建的Audio Mixer资源拖拽到AudioManager脚本的mainMixer字段,将对应的混合器组(BGM和SFX)拖拽到相应的字段。这样即是将音频混合器与项目的音量进行控制。之后通过UI_volumeSlider类实现将UI_Options中的Slider与调动音量的大小进行绑定。将创建好的脚本挂载到Canvas中的SFX和BGM中,并且将相应的资源拖拽到对应脚本的变量中实现滑块控制音量大小
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class UI_volumeSlider : MonoBehaviour { public Slider slider; public string parameter; [SerializeField ] private AudioMixer audioMixer; [SerializeField ] private float multiplier; public void SliderValue (float _value ) =>audioMixer.SetFloat(parameter, Mathf.Log10(_value) * multiplier); public void LoadSlider (float _value ) { if (_value >= 0.001f ) { slider.value = _value; } } }
音量保存 首先理清需要保存的内容,分别是音频混合器组的名字和对应的slider.value,两者是相互对应的,此时需要字典来进行保存,在GameData(游戏数据的结构)中声明
1 2 3 4 5 6 7 public class GameData { public Serializable_Dictionary<string , float > volumeSettings; public GameData () { volumeSettings = new Serializable_Dictionary<string , float >(); } }
声明完成后,需要实现相应的的保存逻辑,在UI类中首先声明一个UI_volumeSlider类的数组(名字为volumeSettings),在实现保存接口ISaveManager的两个函数,
包括SaveData和LoadData,其中SaveData的逻辑为 使用foreach循环遍历volumeSettings数组的每一个UI_volumeSlider组件实例(包含SFX和BGM),针对每个组件实例,将其parameter字段作为键,slider.value也就就是滑块的大小作为值,添加到gameData.volumeSettings字典中。
而LoadData函数使用嵌套的foreach循环,外层循环遍历gameData.volumeSettings中的每个键值对,也就是保存的音量值和parameter,内层循环遍历volumeSettings数组中的每一个UI_volumeSlider(在项目中总共2个SFX,BGM),对于每个UI_volumeSlider实例,检查其parameter字段是否和当前遍历到的键值对的键相等。若相等,就调用item.LoadSlider(pair.Value)方法,把对应的音量值加载到该组件上。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public class UI { [SerializeField ] private UI_volumeSlider[] volumeSettings; public void LoadData (GameData gameData ) { foreach (KeyValuePair<string ,float > pair in gameData.volumeSettings) { foreach (UI_volumeSlider item in volumeSettings) { if (item.parameter == pair.Key) item.LoadSlider(pair.Value); } } } public void SaveData (ref GameData gameData ) { gameData.volumeSettings.Clear(); foreach (UI_volumeSlider item in volumeSettings) { gameData.volumeSettings.Add(item.parameter, item.slider.value ); } } }
AreaSound AreaSound区域,在你进入的时候,会播放音效,当你退出区域的范围后,声音会变小,为了实现该功能,需要使用box colider2D这个组件,并且勾选触发器,通过碰撞器检测到Player来管理以及播放声音。定义变量areaSoundIndex
来旋转播放的音效的Id。因为退出的时候声音是逐渐淡出的效果,为了实现这个功能,采用协程控制的方法,并且定义一个变量为声音的初始值,以便退出区域,将声音恢复到最初的强度。使用while循环进行控制,当声音的大小>0.1f时,进入循环,每次基于当前音量减少20%,同时使用 yield return new WaitForSeconds(.25f); //每0.25秒调整暂停协程,以达到淡出的效果,最后判断声音是否<.1f,如果小于,则直接退出播放,并且将之前以及赋值过的变量 赋值个给声音的大小
AreaSound类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 [SerializeField ] private int areaSoundIndex; private bool isPlaying = false ;private void OnTriggerEnter2D (Collider2D collision ) { if (collision.GetComponent<Player>() != null && !isPlaying) { isPlaying = true ; AudioManager.instance.PlaySFX(areaSoundIndex,null ); } } private void OnTriggerExit2D (Collider2D collision ) { if (collision.GetComponent<Player>() != null && isPlaying) isPlaying = false ; AudioManager.instance.StopSFXwithVolume(areaSoundIndex); }
AudioManager类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public void StopSFXwithVolume (int _index ){ sfxSource.clip = audioSetting.sfx[_index]; StartCoroutine(DecreaseVolume(sfxSource)); } private IEnumerator DecreaseVolume (AudioSource audio ){ float defaultVolum = audio.volume; while (audio.volume > .1 f) { audio.volume -=audio.volume *.2 f; yield return new WaitForSeconds (.25 f ) ; if (audio.volume <= .1 f) { audio.Stop(); audio.volume = defaultVolum; break ; } } }
粒子系统 攻击状态实现 玩家的基础攻击通过状态机管理(如PlayerPrimaryAttackState),进入攻击状态时:
重置角色速度(SetZeroVelocity),确保攻击时不会移动。
根据连招计数器(comboCounter)播放对应动画(anim.SetInteger("ComboCounter", comboCounter)),并设置攻击方向(基于输入或角色朝向)。
攻击结束后通过状态机切换回 idle 状态,同时更新连招计时器(lastTimeAttacked),控制连招窗口(comboWindow = 2秒)
在播放攻击的动画时,添加帧事件,这个帧事件用来检测攻击范围(圆形范围检测)内的所有碰撞器,则存入碰撞体数组中,遍历这个碰撞体数组,如果其中的碰撞体携带敌人组件,获取敌人的stat组件(实体属性),调用玩家的dodamage方法,对敌人造成伤害,如果存在武器,则调用武器效果释放,
克隆体攻击(Clone Skill)
克隆体由Clone_Skill_Controller控制,通过SetupClone初始化位置、攻击目标和持续时间。
攻击时检测范围内敌人,调用PlayerStats.CloneDoDamage并可能触发武器特效(weaponData.Effect)或克隆体复制(CreateClone)。
黑洞技能(BlackHole Skill)
黑洞检测黑洞范围的碰撞器的所有碰撞体,冻结碰撞器中所有包含enemy的碰撞体,释放时生成克隆体或水晶攻击目标(CloneAttackLogic),随机选择目标并生成攻击偏移(xoffSet)。
剑技(Sword Skill)
剑技可反弹攻击多个敌人(Sword_Skill_Controller),通过bounceAmount控制反弹次数,每次命中后切换目标并减少反弹次数,最终返回玩家。
技能树 技能树的基础结构 技能树通过UI_SkillTreeSlot类实现单个技能节点,每个节点代表一个可解锁的技能或技能强化效果,整体通过 UI 层级组织为树状结构。
单个技能节点的属性UI_SkillTreeSlot:
基础信息:技能名称(skillName)、描述(skillDescription)、解锁消耗(skillcost)。
状态标识:unLocked(是否已解锁)。
依赖关系:shouldBeUnlocked(解锁前需先解锁的前置节点)、shouldBeLocked(互斥的技能节点,不能同时解锁)。
视觉反馈:通过skillImage颜色区分状态(锁定时为lockedSkillColor,解锁后为白色)。
技能解锁的核心逻辑 在外部拖入每个技能解锁需要满足的前置条件和互斥技能。
检查前置依赖 若当前技能节点存在前置节点(shouldBeUnlocked数组),必须所有前置节点均已解锁(unLocked == true),否则无法解锁当前技能。
1 2 3 4 5 6 for (int i = 0 ; i < shouldBeUnlocked.Length; i++){ if (shouldBeUnlocked[i].unLocked == false ) return ; }
检查互斥技能 若当前技能节点存在互斥节点(shouldBeLocked数组),则所有互斥节点必须处于未解锁状态(unLocked == false),否则无法解锁当前技能。
1 2 3 4 5 6 for (int i = 0 ; i < shouldBeLocked.Length; i++){ if (shouldBeLocked[i].unLocked == true ) return ; }
消耗与状态更新 满足依赖条件后,检查玩家是否拥有足够资源(如货币),若满足则标记技能为已解锁(unLocked = true),并更新 UI 颜色为白色。
1 2 3 4 5 6 if (PlayerManager.instance.HaveEnoughMOney(skillcost)){ unLocked = true ; skillImage.color = Color.white; }
技能与技能树的关联 在每个技能类中添加声明一个技能树的变量用于获取UI_SkillTreeSlot,建立技能与技能树节点的关联,技能类通过该变量访问对应 UI_SkillTreeSlot 的 unLocked 属性,判断技能是否已解锁。技能类会监听该 UI_SkillTreeSlot 节点的点击事件(通过 GetComponent<Button>().onClick.AddListener),当玩家在技能树中点击解锁该节点时,触发技能的解锁逻辑(如上述 UnlockBlackHole 方法)
技能类中的解锁检查 技能类(继承自Skill)在初始化时(Start或CheckUnlock),通过读取对应UI_SkillTreeSlot的unLocked状态,决定是否启用技能功能。 例如,冲刺技能(Dash_Skill)的解锁逻辑:
1 2 3 4 5 private void UnlockDash (){ if (dashUnlockedButton.unLocked && !dashUnlocked) dashUnlocked = true ; }
技能使用权限控制 技能使用前会检查解锁状态,例如 UI 中快捷键触发技能时:
1 2 3 if (Input.GetKeyDown(KeyCode.LeftShift) && skills.dash.dashUnlocked) SetCooldownOf(dashImage);
ScriptObject 在 Unity 中,使用 List+ Dictionary 的形式管理 ScriptableObject武器数据,可以结合 灵活遍历(List) 和 快速查找(Dictionary) 的优势,特别适合需要频繁按名称或ID查找武器的游戏(如FPS、RPG等)。因为Dictionary的查找效率高
创建 WeaponSO类(ScriptableObject) 首先,定义一个 WeaponSO类,用于存储单把武器的配置信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 using UnityEngine;[CreateAssetMenu(fileName = "New Weapon" , menuName = "Weapons/Weapon Data" ) ] public class WeaponSO : ScriptableObject { public string weaponID; public string weaponName; public Sprite icon; public int damage = 10 ; public float fireRate = 0.5f ; }
📌 使用方法:
•在 Unity 中 右键 → Create → Weapons → Weapon Data ,即可创建一个新的武器配置(.asset文件)。
•在 Inspector 面板调整数值,如 damage、fireRate等。
2. 创建 WeaponDatabaseSO存储所有武器(List) 为了管理多个武器,我们可以创建一个 WeaponDatabaseSO,用 List<WeaponSO>存储所有武器数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 using UnityEngine;using System.Collections.Generic;[CreateAssetMenu(fileName = "Weapon Database" , menuName = "Weapons/Weapon Database" ) ] public class WeaponDatabaseSO : ScriptableObject { public List<WeaponSO> weapons = new List<WeaponSO>(); private Dictionary<string , WeaponSO> _weaponDict; public void Initialize () { _weaponDict = new Dictionary<string , WeaponSO>(); foreach (WeaponSO weapon in weapons) { if (!_weaponDict.ContainsKey(weapon.weaponID)) { _weaponDict.Add(weapon.weaponID, weapon); } else { Debug.LogError($"Duplicate weapon ID: {weapon.weaponID} " ); } } } public WeaponSO GetWeaponByID (string weaponID ) { if (_weaponDict == null ) { Debug.LogError("Weapon dictionary not initialized!" ); return null ; } if (_weaponDict.TryGetValue(weaponID, out WeaponSO weapon)) { return weapon; } Debug.LogError($"Weapon not found: {weaponID} " ); return null ; } public void AddWeapon (WeaponSO weapon ) { if (!weapons.Contains(weapon)) { weapons.Add(weapon); _weaponDict.Add(weapon.weaponID, weapon); } } }
📌 使用方法:
•在 Unity 中 右键 → Create → Weapons → Weapon Database ,创建一个武器数据库。
•在 Inspector 面板的 weapons列表中添加所有武器(如 Pistol, Rifle, Shotgun等)。
3. 在游戏中使用武器数据 现在,我们可以创建一个 WeaponManager(MonoBehaviour)来加载并使用这些武器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 using UnityEngine;public class WeaponManager : MonoBehaviour { [SerializeField ] private WeaponDatabaseSO weaponDatabase; private WeaponSO _currentWeapon; void Awake () { weaponDatabase.Initialize(); _currentWeapon = weaponDatabase.GetWeaponByID("weapon_pistol" ); if (_currentWeapon != null ) { Debug.Log($"Equipped: {_currentWeapon.weaponName} " ); } } public void SwitchWeapon (string weaponID ) { WeaponSO newWeapon = weaponDatabase.GetWeaponByID(weaponID); if (newWeapon != null ) { _currentWeapon = newWeapon; Debug.Log($"Switched to: {_currentWeapon.weaponName} " ); } } }