个人战斗demo设计思路(探究动作游戏与音游融合向)
本文探讨了动作游戏与音游融合的设计思路与技术实现。作者基于《绝区零》和《Hi-Fi Rush》的分析,设计了一个将音乐节奏融入战斗循环的demo,通过音游轨道获取战斗资源而非强制节拍同步,降低操作门槛。技术实现上采用Unity引擎,包含角色移动、攻击连招、BOSS AI和音游判定等模块,通过C#脚本实现核心功能。
demo演示视频:个人战斗demo演示视频(探究动作游戏与音游融合向)_游戏热门视频
本文分享关于我对demo的设计、思考以及技术栈。
一、前言
最开始,我的设计灵感来源于我在日常游玩动作二游时重复地听游戏的背景音乐会觉得枯燥,所以会关闭背景音乐选择自己的歌单边听边玩。但我觉得音乐也是属于游戏的重要内容,因为游戏音乐能够提高玩家在游戏中的沉浸感,于是思考能否将游戏音乐也变成玩家操作中重要的一环。
根据我之前的《绝区零》战斗系统拆解分析案-CSDN博客里的”循环“思路,我认为将游戏音乐变成玩家操作中重要一环的方法就是把游戏音乐融合进战斗循环中,并以它为核心增加正反馈机制,拓展消耗战斗资源的策略性和玩法深度。
所以我设计、融合了一个战斗循环机制,并想围绕这个战斗思路设计1-3个不同战斗风格或音游玩法的角色:
但在实际开发过程中,因为demo工程量、对代码的掌握程度、上班时间、素材限制等等原因,我不得不缩减我的demo工程量。最终在上图循环的基础上进行削减,只完成了一个角色和简单的战斗循环机制:
二、寻找灵感
在制作demo之前以及过程中我深度体验了《hifi rush》这款游戏。在此对这款游戏的主被动反应频率进行分析。
《hifi rush》的主动反应频率
|
战斗系统特点 |
具体设计 |
设计目的 |
|
深度的动作系统 |
唯一可操作角色拥有复杂的出招表 |
拓展动作系统深度供玩家练习探索 |
|
动作帧短、无敌帧少 |
所有的动作连段皆会被怪物攻击打断 |
鼓励玩家高速进攻,即时关注战况选择搓出合适的连招 |
|
精准闪避 |
精准闪避只用来规避敌人攻击,并且会打断连招。但是在关卡探索和跑酷中至关重要 |
降低闪避在战斗中的收益,引导玩家在合适的时机打出连招,避免主动打断连招 |
|
敌人空窗期较长 |
敌人两次攻击间间隔较长 |
敌人攻击频率低,玩家可以积极主动进攻 |
《hifi rush》的被动反应频率
|
战斗系统特点 |
具体设计 |
设计目的 |
|
敌人攻击欲望较低 |
场上不会拥有较多敌人同时进行攻击,且敌人很少连续发动攻击 |
玩家无需过多关注敌人动作状态,更多关注自身的攻击连段 |
|
敌人攻击动作前摇长且伴随红光提示 |
敌人单次攻击动作前摇较长,攻击模式辨识度高易于记忆 |
玩家接收提示,拥有充足的时间对敌人的攻击动作做出反应,避免受击打断自身操作 |
总结
《hifi rush》是动作游戏中较为常见的高主动反应频率、低被动反应频率的高速动作游戏。这类游戏更加强调动作的流畅性,并有意降低敌人攻击对于玩家操作的影响,重视战斗的爽快感。一般来说都有着易上手、难精通的特点。
《hifi rush》巧妙地将音乐节拍融合进了玩家战斗操作中。它的基础战斗操作重点在于“节拍同步”,掌握连续攻击、闪避的时间间隔和轻重攻击的组合。我站在战斗设计的角度深度游玩之后,认为这样的战斗设计有很明显的优缺点。
优点:
-
根据攻击间隔和组合可设计出多种连段攻击动作。
-
只从攻击、闪避的节拍上做限制,尽可能的减少融合音游对操作自由度层面的限制。
-
错误的节拍和组合不会影响玩家的伤害和战略资源的获取。
缺点:
-
只要节拍不对或者按键输入错误就会影响攻击连段,需要从头开始按照正确的节拍和组合攻击影响玩家操作节奏。
-
对音乐节奏感较弱的玩家很难体验到战斗的快感和打击感,沉浸门槛较高。
-
《hifi rush》战斗观设定只可自由操控男主,且男主所有战斗均与节拍有关,在战斗层面上进行玩法拓展较困难。
横向对比
我的demo与《hifi rush》在音游融合方面最大的不同点在于我的音游部分强绑定音游轨道的音符,而《hifi rush》则绑定音乐节拍。(其实我想设计多名角色具有不同的音游玩法,这点在下文会详细提到)。
绑定音游轨道的好处在于:
-
玩家不会因为没有在音游轨道击中音符而影响操作的流畅性。
-
对音乐节奏感较弱的玩家在音游轨道的音符滑动强提示下能够逐渐适应游戏节奏。
-
没有击中音符不会影响攻击伤害,只会影响战略资源的获取。
坏处也很明显:
-
玩家既需要关注音游轨道也需要关注boss攻击动作,容易分散注意力。
-
音乐节奏感较弱的玩家无法体验到战斗过程中的更多内容,体验门槛较高。
四、思考解决
1.思考心得
在寻找灵感、开发和工作的过程中我得出一个有意思的结论,在此提出欢迎探讨。
我认为类似于《hifi rush》这样战斗与音游融合的游戏,“音游性”和“动作性”为同一维度,“即时性”和“策略性”为同一维度。在同一维度下,对一方面进行偏向,另一方向的“游戏性”就会减弱。
结合上图探究《hifi rush》。《hifi rush》在战斗操作方面的音乐属性仅为对应音乐节拍并尽可能的减少操作自由度的限制,其本质上还是为动作游戏,所以在y轴更偏向于动作性。
这里x轴上的策略性与即时性放在了同一维度,所以我们只考虑战斗中的策略性,排除战斗外的如穿戴装备,技能选择等。
在战斗过程中,策略维度仅包含了:能量足够释放技能、释放后台角色协战技能、按键顺序出招。玩家更多体验的还是战斗过程中的即时的动作操作,所以在x轴上更偏向于即时性。
由此看出,战斗与音游融合的游戏在设计时需要在各自维度的基础上对两者做出平衡。
2.解决问题
我以上述思考为基础得出的方法来改善或减少我在”横向对比“中所提到的两点坏处对玩家操作的影响。
-
玩家既需要关注音游轨道也需要关注boss攻击动作,容易分散注意力。
在这个问题上,我认为玩家应该重点关注音游轨道而非boss的攻击动作,所以我选择模仿《绝区零》的怪物攻击预警提示,强烈闪光及音效提醒玩家做出闪避并预留充足的时间供玩家反应。我还在boss脚本上削弱其多动性,让它”笨笨的“,更容易挨打。
《hifi rush》是随时随地能够根据节拍打出对应战斗连段,我的demo则需要根据音游轨道攻击击中音符积攒战斗资源,所以我的demo动作性比《hifi rush》弱,音游性比《hifi rush》强,游戏性在y轴居中。
-
音乐节奏感较弱的玩家无法体验到战斗过程中的更多内容,体验门槛较高。
这个问题的重点在于无法体验到战斗过程中的更多内容,这也关联到了我之前提到的我想做多个角色多个音游玩法的愿景。
我认为解决这个问题最好的方法就是将游戏的音游部分与动作游戏部分割开,比如我设想的下一位角色的战斗设计就能够解决这个问题:
首先我参照动作游戏的”削韧“机制,将战斗阶段分为的boss失衡值未满的“积累期”和打满失衡值的“爆发期”。
在“积累期”中玩家的操作与ARPG的操作一致,任何对boss的攻击都会积攒失衡值。当失衡值满时,怪物进入失衡期处于瘫痪易伤状态,进入失衡期后玩家的操作会从ARPG变成各种音游玩法(每位角色对应一种音游),同时音乐进入高潮,伤害变成AOE,标志着游戏也进入高潮。失衡期结束后,boss恢复正常状态,玩家操作也从音游玩法回到正常的ARPG操作。

五、技术栈
demo的开发引擎为Unity,脚本语言为C#。
以下展示各个游戏对象脚本中实现具体功能的代码片段。
1.角色移动
①WASD输入处理 HandleWASDInput()
void HandleWASDInput()
{
isMoving = false;
moveDirection = Vector3.zero;
// 基于相机方向计算移动向量
Vector3 cameraForward = followCamera.transform.forward;
Vector3 cameraRight = followCamera.transform.right;
cameraForward.y = 0;
cameraRight.y = 0;
cameraForward.Normalize();
cameraRight.Normalize();
// 组合输入方向
if (Input.GetKey(KeyCode.W)) { moveDirection += cameraForward; isMoving = true; }
if (Input.GetKey(KeyCode.S)) { moveDirection += -cameraForward; isMoving = true; }
if (Input.GetKey(KeyCode.A)) { moveDirection += -cameraRight; isMoving = true; }
if (Input.GetKey(KeyCode.D)) { moveDirection += cameraRight; isMoving = true; }
if (isMoving) moveDirection.Normalize();
}
③闪避移动 HandleDodgeMovement()
void HandleDodgeMovement()
{
float dodgeProgress = (Time.time - dodgeStartTime) / dodgeDuration;
if (dodgeProgress < 1.0f)
{
// 使用平方缓出效果
float t = 1 - (dodgeProgress * dodgeProgress);
Vector3 step = dodgeDirection * (dodgeDistance * t / dodgeDuration) * Time.deltaTime;
controller.Move(step);
}
else
{
isDodging = false;
if (animator != null)
animator.SetBool(isDodgingParameter, false);
}
}
2.角色攻击
①攻击输入处理 HandleMouseAttackInput()
void HandleMouseAttackInput()
{
// 检查音符系统优先级
if (noteSpawner != null && noteSpawner.HasNotesInRange())
return;
if (Input.GetMouseButtonDown(0) && !isAttackInputBlocked && !isHit && !isSkillCasting)
{
// 自动朝向BOSS
if (bossTransform != null && IsBossInRange())
FaceBoss();
// 开始连击或继续连击
if (currentAttackIndex == -1)
{
StartCoroutine(BlockAttackInputTemporarily(0.1f));
currentAttackIndex = 1;
UpdateAttackAnimation();
isWaitingForNextAttack = false;
}
else if (isWaitingForNextAttack && currentAttackIndex < 4)
{
StartCoroutine(BlockAttackInputTemporarily(0.1f));
currentAttackIndex++;
UpdateAttackAnimation();
isWaitingForNextAttack = false;
}
}
}
②技能系统逻辑HandleSkillInput()
void HandleSkillInput()
{
if (skillCooldownTimer > 0) return;
if (Input.GetKeyDown(KeyCode.E) && !isHit && !isDodging && !isSkillInputLocked && isSkillActive)
{
switch (currentSkillIndex)
{
case 0: // 释放技能1
if (currentMana >= skill1ManaCost)
{
currentSkillIndex = 1;
StartCoroutine(CastSkillWithLock(1));
}
break;
case 1: // 连段技能2
if (Time.time <= skillComboEndTime && !isSkillCasting)
{
if (currentMana >= skill2ManaCost)
{
currentSkillIndex = 2;
StartCoroutine(CastSkillWithLock(2));
}
}
break;
}
}
}
3.相机视角
①初始化相机位置InitializeCamera()
void InitializeCamera()
{
// 设置相机在角色后方
Vector3 direction = -target.forward;
// 将角色初始朝向转换为角度
currentX = Mathf.Atan2(direction.x, direction.z) * Mathf.Rad2Deg;
currentY = 0f; // 水平视角
// 立即更新相机位置
UpdateCameraImmediately();
}
② 相机位置计算核心 UpdateCameraSmoothly() / UpdateCameraImmediately()
void UpdateCameraSmoothly()
{
// 1. 根据当前角度计算旋转四元数
Quaternion rotation = Quaternion.Euler(currentY, currentX, 0);
// 2. 计算目标点(角色位置 + 偏移)
Vector3 targetPosition = target.position + targetOffset;
// 3. 计算相机期望位置:从目标点沿着相机朝向的反方向后退一定距离
Vector3 desiredPosition = targetPosition - rotation * Vector3.forward * distance;
// 4. 平滑移动相机位置
transform.position = Vector3.SmoothDamp(
transform.position,
desiredPosition,
ref positionSmoothVelocity,
smoothTime
);
// 5. 让相机始终看向目标点
transform.LookAt(targetPosition);
}
// 立即更新版本(无平滑)
void UpdateCameraImmediately()
{
Quaternion rotation = Quaternion.Euler(currentY, currentX, 0);
Vector3 targetPosition = target.position + targetOffset;
Vector3 desiredPosition = targetPosition - rotation * Vector3.forward * distance;
transform.position = desiredPosition; // 直接设置位置
transform.LookAt(targetPosition);
}
4.BOSSAI
①战斗状态判断HandleCombatLogic()
void HandleCombatLogic(float distance)
{
// 动态进入/退出战斗状态
if (!isInCombat)
{
// 玩家进入范围则进入战斗
if (distance <= wakeUpDistance && distance <= loseInterestDistance)
isInCombat = true;
else
{
// 完全重置待机状态
if (animator != null)
{
animator.SetFloat(MoveSpeed, 0f);
animator.SetBool(IsPlayerNear, false);
animator.SetBool(IsPlayerInAttackRange, false);
animator.SetInteger(AttackType, -1);
}
return;
}
}
// 三种距离状态的处理
if (distance <= attackDistance && canAttack) // 攻击距离内
{
animator.SetFloat(MoveSpeed, 0f);
int attackType = DetermineAttackType();
animator.SetInteger(AttackType, attackType);
StartCoroutine(PerformAttack(attackType));
}
else if (distance > attackDistance && distance <= loseInterestDistance) // 追击距离
{
MoveTowardsPlayer();
}
else if (distance > loseInterestDistance) // 超出警戒范围
{
// 重置所有状态并退出战斗
if (animator != null)
{
animator.SetFloat(MoveSpeed, 0f);
animator.SetBool(IsPlayerNear, false);
animator.SetBool(IsPlayerInAttackRange, false);
animator.SetInteger(AttackType, -1);
}
ResetAttackHistory();
isInCombat = false; // 退出战斗状态
}
}
②攻击决策逻辑 DetermineAttackType()
int DetermineAttackType()
{
// 智能攻击决策:确保三种攻击都会被使用
if (hasUsedAttack1 && hasUsedAttack2)
return Random.Range(0, 3); // 0=右手攻击,1=左手攻击,2=AoE攻击
else if (!hasUsedAttack1)
return 0; // 优先使用未使用过的攻击1
else if (!hasUsedAttack2)
return 1; // 优先使用未使用过的攻击2
return 0;
}
③向玩家移动逻辑MoveTowardsPlayer()
void MoveTowardsPlayer()
{
Vector3 direction = (hoshi.position - transform.position).normalized;
direction.y = 0; // 保持水平移动
transform.position += direction * moveSpeed * Time.deltaTime;
if (animator != null)
animator.SetFloat(MoveSpeed, moveSpeed);
}
5.音游部分
①音符生成逻辑
void Update()
{
if (bgmController == null || noteSpherePrefab == null || bossAI == null) return;
// 检查BGM是否在播放且BOSS是否存活
bool shouldSpawn = IsBGMPlaying() && !bossAI.isDead;
// 控制音符生成状态机
if (shouldSpawn && !isSpawning)
{
StartSpawning();
}
else if (!shouldSpawn && isSpawning)
{
StopSpawning();
}
// 输入检测
if (CanProcessInput())
{
CheckMouseInput();
}
// 移动所有活跃音符
MoveAllNotes();
}
②音符移动和销毁
void MoveAllNotes()
{
for (int i = activeNotes.Count - 1; i >= 0; i--)
{
GameObject note = activeNotes[i];
if (note == null)
{
activeNotes.RemoveAt(i);
continue;
}
// 水平移动(向左侧移动)
Vector3 newLocalPosition = note.transform.localPosition;
newLocalPosition.x += moveSpeed * Time.deltaTime;
note.transform.localPosition = newLocalPosition;
// 超出销毁边界时移除
if (note.transform.localPosition.x <= destroyX)
{
Destroy(note);
activeNotes.RemoveAt(i);
}
}
}
③音符判定系统
void CheckNoteHit()
{
if (activeNotes.Count == 0) return;
bool hitSuccess = false;
GameObject hitNote = null;
int hitIndex = -1;
// 查找所有在判定范围内的音符
List<(GameObject note, int index, float distance)> validNotes =
new List<(GameObject, int, float)>();
for (int i = 0; i < activeNotes.Count; i++)
{
GameObject note = activeNotes[i];
if (note == null) continue;
float noteX = note.transform.localPosition.x;
float distance = Mathf.Abs(noteX - hitZoneX);
// 在完美判定范围内
if (distance <= perfectRange)
{
validNotes.Add((note, i, distance));
}
}
// 选择最接近判定线的音符(处理多个音符同时到达的情况)
if (validNotes.Count > 0)
{
validNotes.Sort((a, b) => a.distance.CompareTo(b.distance));
hitNote = validNotes[0].note;
hitIndex = validNotes[0].index;
StartCoroutine(PlayHitEffectAndDestroy(hitNote, hitIndex));
hitSuccess = true;
}
}
④BGM状态管理
void Update()
{
if (bossAI == null || player == null) return;
// 计算与BOSS的距离
float distanceToBoss = Vector3.Distance(player.position, bossAI.transform.position);
bool isInBossRange = distanceToBoss <= bossAI.attackDistance;
// 进入BOSS范围时触发BGM(只触发一次)
if (isInBossRange && !isBossBGMPlaying && !bossAI.isDead && !isFadingOut && !hasTriggeredBossBattle)
{
PlayBossBGM();
hasTriggeredBossBattle = true; // 防止重复触发
}
// BOSS死亡时淡出音乐
if (bossAI.isDead && isBossBGMPlaying && !isFadingOut)
StartFadeOut();
// 处理淡出效果
if (isFadingOut)
HandleFadeOut();
}更多推荐


所有评论(0)