自打17年左右开始玩Unturned就一直好奇作为《未转变者》(Unturned)的独立开发者Nelson当初是如何打造这款沙盒游戏的 自己在github上乱逛发现了这个 直接逆向工程了 也能看到用的是自己写的反编译工具 (用到了Assembly-CSharp.dll) 虽然经过了Nelson本人的许可 也打算在近年公开Unturned的部分源码 但是反编译这种行为在大多数软件的EULA协议应该是明令禁止的 既然Nelson本人加上SDG都许可了 What Can I Say?
挑了几个之前比较好奇的游戏机制看看实现原理
姜丝(zombie)的攻击方式
private enum EAbilityChoice
{
ThrowBoulder, // 扔巨石
SpitAcid, // 吐酸
Stomp, // 重踏地面产生冲击
BreatheFire, // 吐火
ElectricShock, // 电击
Flashbang // 闪光
}
扔石头
public void askThrow()
{
if (!isDead)
{
lastSpecial = Time.time;
isThrowingBoulder = true;
isPlayingBoulder = true;
if (!Dedicator.IsDedicatedServer)
{
animator.Play("Boulder_0");
AudioClip clip = ZombieManager.roars[UnityEngine.Random.Range(0, 16)];
OneShotAudioParameters oneShotAudioParameters = new OneShotAudioParameters(base.transform, clip);
oneShotAudioParameters.volume = 0.5f;
oneShotAudioParameters.pitch = GetRandomPitch();
oneShotAudioParameters.SetLinearRolloff(1f, 32f);
oneShotAudioParameters.Play();
}
boulderItem = ((GameObject)UnityEngine.Object.Instantiate(Resources.Load("Characters/Mega_Boulder_Item"))).transform;
boulderItem.name = "Boulder";
boulderItem.parent = rightHook;
boulderItem.localPosition = Vector3.zero;
boulderItem.localRotation = Quaternion.Euler(0f, 0f, 90f);
boulderItem.localScale = Vector3.one;
UnityEngine.Object.Destroy(boulderItem.gameObject, 2f);
}
}
private void StopThrowingBoulder()
{
if (isThrowingBoulder)
{
isThrowingBoulder = false;
if (boulderItem != null)
{
UnityEngine.Object.Destroy(boulderItem.gameObject);
}
if (Provider.isServer)
{
seeker.canMove = true;
}
}
}
public void askBoulder(Vector3 origin, Vector3 direction)
{
if (!isDead)
{
Transform obj = ((GameObject)UnityEngine.Object.Instantiate(Resources.Load(Dedicator.IsDedicatedServer ? "Characters/Mega_Boulder_Projectile_Server" : "Characters/Mega_Boulder_Projectile_Client"))).transform;
obj.name = "Boulder";
EffectManager.RegisterDebris(obj.gameObject);
obj.position = origin;
obj.rotation = Quaternion.LookRotation(direction) * Quaternion.Euler((float)UnityEngine.Random.Range(0, 2) * 180f, (float)UnityEngine.Random.Range(0, 2) * 180f, (float)UnityEngine.Random.Range(0, 2) * 180f);
obj.localScale = Vector3.one * 1.75f;
obj.GetComponent<Rigidbody>().AddForce(direction * 1500f);
obj.GetComponent<Rigidbody>().AddRelativeTorque(UnityEngine.Random.Range(-500f, 500f), UnityEngine.Random.Range(-500f, 500f), UnityEngine.Random.Range(-500f, 500f), ForceMode.Force);
obj.Find("Trap").gameObject.AddComponent<Boulder>();
UnityEngine.Object.Destroy(obj.gameObject, 8f);
}
}
askThrow是在准备巨石 在非服务端添加些随机的僵尸音效 巨石本身的一些状态初始化 加载素材、相对僵尸的位置(rightHook)、石头本身的大小和旋转角度
而真正投掷的逻辑在askBoulder 在确认僵尸未死亡的情况下绘制投掷物(巨石) 注册巨石为碎片类 方便管理清除、巨石投掷前随机的旋转、石头变大(1.75倍)、 给它一个力的大小和方向(AddForce)、巨石投掷期间自身的滚动(AddRelativeTorque)
最后是StopThrowingBoulder 停止投掷巨石的状态 确保巨石类被销毁、让僵尸寻路机制恢复(能动)
寻路机制
public void tick()
{
// 判断是否在刚刚reset后(重置)的状态
if (needsTickForPlacement)
{
needsTickForPlacement = false; // 去除临时标志位
setTicking(wantsToTick: false); // 取消计时
GetComponent<CharacterController>().Move(Vector3.down); // 保证接触地面
return;
}
float num = Time.time - lastTick; // 上次计时流逝秒数
lastTick = Time.time; // 更新最近计时时间
lastPull = Time.time; // 更新最近吸引时间
// 判断是否昏厥状态
if (isStunned)
{
return;
}
undergroundTestTimer -= num; // (这里undergroundTestTimer最初设置的是10) 检查流逝秒数
// 如果定时器触发情况下对僵尸进行是否越界判断
if (undergroundTestTimer < 0f)
{
undergroundTestTimer = UnityEngine.Random.Range(30f, 60f); // 重新设置更大点的检查秒数间隔
// 越界判断
if (!UndergroundAllowlist.IsPositionWithinValidHeight(base.transform.position))
{
ZombieManager.teleportZombieBackIntoMap(this); // 重新传送回地图内
return;
}
}
// 狩猎目标为玩家时
if (huntType == EHuntType.PLAYER)
{
// 若最近玩家不存在时 停止寻路
if (player == null)
{
stop();
return;
}
}
// 狩猎目标为固定地图点、当前处于未移动状态、距上次狩猎时间至现在超出3秒时 停止寻路
else if (huntType == EHuntType.POINT && !isMoving && Time.time - lastHunted > 3f)
{
stop();
return;
}
// 当前存在狩猎玩家
if (player != null)
{
// 玩家已经挂了的情况下 慢速撤离
if (player.life.isDead)
{
leave(quick: false);
return;
}
// 在玩家超出导航识别范围或者玩家在水中游泳且僵尸不在水中 快速撤离
if (player.movement.nav == byte.MaxValue || (player.stance.stance == EPlayerStance.SWIM && !WaterUtility.isPointUnderwater(base.transform.position)))
{
leave(quick: true);
return;
}
}
// 识别阻挡车辆 若阻挡车辆失效(打爆炸了) 清除标识
if (targetObstructionVehicle != null && targetObstructionVehicle.isDead)
{
targetObstructionVehicle = null;
}
// 识别玩家正在驾驶车辆 若车辆失效(离开/打爆了) 清除标识
if (targetPassengerVehicle != null && targetPassengerVehicle.isDead)
{
targetPassengerVehicle = null;
}
// 识别是否有可攻击的可交互物体 若物体失效(无生命值) 清除标识
if (targetObject != null && targetObject.isAllDead())
{
targetObject = null;
}
// 卡滞状态检测 大概每0.25秒对环境检测一遍 看看有没有可攻击的目标
if (isStuck)
{
float num2 = Time.time - lastStuck; // 计算距上次卡住时间秒数
if (num2 > 0.75f) // 短时间卡滞
{
stuckSearchTimer += num; // 卡滞累计时间
if (stuckSearchTimer > 0.25f)
{
stuckSearchTimer = 0f; // 计时清零
// 重新寻找可攻击目标
if (targetBarricade == null && targetStructure == null && targetObstructionVehicle == null && targetPassengerVehicle == null && targetObject == null)
{
findTargetWhileStuck();
}
}
}
else
{
stuckSearchTimer = 0f;
}
// 长时间卡滞且没有攻击 状态清零 若在当前区域有尸潮召唤器可直接传送
if (num2 > 5f && zombieRegion.hasBeacon && Time.time - lastAttack > 10f)
{
lastStuck = Time.time;
stuckSearchTimer = 0f;
ZombieManager.teleportZombieBackIntoMap(this);
return;
}
}
// ...
这里的tick方法是在ZombieManager中的Update方法调用的 相当于是游戏实时计算会用到的状态检测功能 后面的代码属实是长… 可以看到Unity开发游戏绝对不是什么轻松事
That hurts!
public void askDamage(byte amount, Vector3 newRagdoll, EDeathCause newCause, ELimb newLimb, CSteamID newKiller, out EPlayerKill kill)
{
askDamage(amount, newRagdoll, newCause, newLimb, newKiller, out kill, trackKill: false, ERagdollEffect.NONE, canCauseBleeding: true);
}
public void askDamage(byte amount, Vector3 newRagdoll, EDeathCause newCause, ELimb newLimb, CSteamID newKiller, out EPlayerKill kill, bool trackKill = false, ERagdollEffect newRagdollEffect = ERagdollEffect.NONE)
{
askDamage(amount, newRagdoll, newCause, newLimb, newKiller, out kill, trackKill, newRagdollEffect, canCauseBleeding: true);
}
public void askDamage(byte amount, Vector3 newRagdoll, EDeathCause newCause, ELimb newLimb, CSteamID newKiller, out EPlayerKill kill, bool trackKill = false, ERagdollEffect newRagdollEffect = ERagdollEffect.NONE, bool canCauseBleeding = true)
{
askDamage(amount, newRagdoll, newCause, newLimb, newKiller, out kill, trackKill, newRagdollEffect, canCauseBleeding, bypassSafezone: false);
}
/// <param name="bypassSafezone">Should damage be dealt even while inside safezone?</param>
public void askDamage(byte amount, Vector3 newRagdoll, EDeathCause newCause, ELimb newLimb, CSteamID newKiller, out EPlayerKill kill, bool trackKill = false, ERagdollEffect newRagdollEffect = ERagdollEffect.NONE, bool canCauseBleeding = true, bool bypassSafezone = false)
{
kill = EPlayerKill.NONE;
if (bypassSafezone || InternalCanDamage())
{
doDamage(amount, newRagdoll, newCause, newLimb, newKiller, out kill, trackKill, newRagdollEffect, canCauseBleeding);
}
}
private void doDamage(byte amount, Vector3 newRagdoll, EDeathCause newCause, ELimb newLimb, CSteamID newKiller, out EPlayerKill kill, bool trackKill = false, ERagdollEffect newRagdollEffect = ERagdollEffect.NONE, bool canCauseBleeding = true)
{
kill = EPlayerKill.NONE; // 初始化攻击来源
// 判断攻击点数是否无效或者玩家已死亡
if (amount == 0 || isDead || !IsAlive)
{
return;
}
// 致死情况
if (amount >= health)
{
_health = 0;
}
// 减血量
else
{
_health -= amount;
}
ragdoll = newRagdoll; // 布娃娃肢体效果
ragdollEffect = newRagdollEffect; // 布娃娃肢体材质
// 非致命伤且数值大于3阈值后触发镜头抖动
if (_health > 0 && amount > 3)
{
SendDamagedEvent.Invoke(GetNetId(), ENetReliability.Reliable, base.channel.GetOwnerTransportConnection(), amount, newRagdoll.normalized);
}
// 生命值更新 镜头血腥颜色渐变
SendHealth.Invoke(GetNetId(), ENetReliability.Reliable, base.channel.GetOwnerTransportConnection(), health);
// 没太清楚这里的功能 貌似项目中没有找到定义的地方...
OnTellHealth_Global?.Invoke(this);
// PVP打架的逻辑
if (newCause == EDeathCause.GUN || newCause == EDeathCause.MELEE || newCause == EDeathCause.PUNCH || newCause == EDeathCause.ROADKILL || newCause == EDeathCause.GRENADE || newCause == EDeathCause.MISSILE || newCause == EDeathCause.CHARGE)
{
recentKiller = newKiller;
lastTimeTookDamage = Time.realtimeSinceStartup;
Player player = PlayerTool.getPlayer(recentKiller);
if (player != null)
{
// 记录最近攻击时间
player.life.lastTimeCausedDamage = Time.realtimeSinceStartup;
// 处理好战标记逻辑
// 冷却期内直接标记
if (Time.realtimeSinceStartup - player.life.lastTimeAggressive < COMBAT_COOLDOWN)
{
player.life.markAggressive(force: true);
}
// 潜在攻击 这里的逻辑其实有些看的云里雾里...
else if ((player.life.recentKiller == CSteamID.Nil || Time.realtimeSinceStartup - player.life.lastTimeTookDamage > COMBAT_COOLDOWN) && Time.realtimeSinceStartup - lastTimeCausedDamage > COMBAT_COOLDOWN)
{
player.life.markAggressive(force: true);
}
}
}
if (health == 0)
{
// 死亡延时奖励
try
{
base.player.quests.InterruptDelayedQuestRewards(EDelayedQuestRewardsInterruption.Death);
}
catch (Exception e)
{
UnturnedLog.exception(e, "Caught exception interrupting delayed quest rewards on death:");
}
if (recentKiller != CSteamID.Nil && recentKiller != base.channel.owner.playerID.steamID && Time.realtimeSinceStartup - lastTimeTookDamage < COMBAT_COOLDOWN)
{
// 击杀玩家声望机制(Hero/Bandit)
Player player2 = PlayerTool.getPlayer(recentKiller);
if (player2 != null)
{
// 根据玩家是否好战决定声望赏罚 最小1点 最大25点
int value = Mathf.Abs(base.player.skills.reputation);
value = Mathf.Clamp(value, 1, 25);
if (player2.life.isAggressor)
{
value = -value;
}
player2.skills.askRep(value);
}
}
// 记录击杀类型为玩家
kill = EPlayerKill.PLAYER;
// 判断是不是PVP打架导致死亡
wasPvPDeath = newCause == EDeathCause.GUN || newCause == EDeathCause.MELEE || newCause == EDeathCause.PUNCH || newCause == EDeathCause.ROADKILL || newCause == EDeathCause.GRENADE || newCause == EDeathCause.MISSILE || newCause == EDeathCause.CHARGE || newCause == EDeathCause.SENTRY;
PlayerLife.OnPreDeath.TryInvoke("OnPreDeath", this); // 死亡预处理 貌似也没找到对应的方法...
base.player.movement.forceRemoveFromVehicle(); // 强制从驾驶车辆移除
RocketLegacyOnDeath.TryInvoke("RocketLegacyOnDeath", this, newCause, newLimb, newKiller); // 可能和Rocket插件定义的内容相关
try
{
// 死亡统计
SendDeath.Invoke(GetNetId(), ENetReliability.Reliable, base.channel.GetOwnerTransportConnection(), newCause, newLimb, newKiller);
// 死亡效果
SendDead.InvokeAndLoopback(GetNetId(), ENetReliability.Reliable, Provider.GatherRemoteClientConnections(), ragdoll, ragdollEffect);
}
catch (Exception e2)
{
UnturnedLog.warn("Exception during tellDeath or tellDead:");
UnturnedLog.exception(e2);
}
// 加载新的生成点
if (spawnpoint == null || (newCause != EDeathCause.SUICIDE && newCause != EDeathCause.BREATH) || Time.realtimeSinceStartup - lastSuicide > 60f)
{
spawnpoint = LevelPlayers.getSpawn(isAlt: false);
}
// 更新最近自杀时间
if (newCause == EDeathCause.SUICIDE || newCause == EDeathCause.BREATH)
{
lastSuicide = Time.realtimeSinceStartup;
}
// 击杀任务统计
if (trackKill)
{
// 遍历当前服务器上的所有玩家
for (int i = 0; i < Provider.clients.Count; i++)
{
SteamPlayer steamPlayer = Provider.clients[i];
// 需要当前玩家处于有效状态后再进行统计
if (!(steamPlayer.player == null) && !(steamPlayer.player.movement == null) && !(steamPlayer.player.life == null) && !steamPlayer.player.life.isDead && steamPlayer != base.channel.owner && (steamPlayer.player.transform.position - base.transform.position).sqrMagnitude < 90000f)
{
steamPlayer.player.quests.trackPlayerKill(base.player);
}
}
}
// 死亡信息广播(有可能用插件做一些酷炫的提示?)
broadcastPlayerDied(this, newCause, newLimb, newKiller);
// 死亡原因窗口显示
if (CommandWindow.shouldLogDeaths)
{
switch (newCause)
{
case EDeathCause.BLEEDING:
CommandWindow.Log(Provider.localization.format("Bleeding", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.BONES:
CommandWindow.Log(Provider.localization.format("Bones", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.FREEZING:
CommandWindow.Log(Provider.localization.format("Freezing", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.BURNING:
CommandWindow.Log(Provider.localization.format("Burning", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.FOOD:
CommandWindow.Log(Provider.localization.format("Food", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.WATER:
CommandWindow.Log(Provider.localization.format("Water", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.GUN:
case EDeathCause.MELEE:
case EDeathCause.PUNCH:
case EDeathCause.ROADKILL:
case EDeathCause.GRENADE:
case EDeathCause.MISSILE:
case EDeathCause.CHARGE:
case EDeathCause.SPLASH:
{
SteamPlayer steamPlayer2 = PlayerTool.getSteamPlayer(newKiller);
string text;
string text2;
if (steamPlayer2 != null)
{
text = steamPlayer2.playerID.characterName;
text2 = steamPlayer2.playerID.playerName;
}
else
{
text = "?";
text2 = "?";
}
string text3 = "";
switch (newLimb)
{
case ELimb.LEFT_FOOT:
case ELimb.LEFT_LEG:
case ELimb.RIGHT_FOOT:
case ELimb.RIGHT_LEG:
text3 = Provider.localization.format("Leg");
break;
case ELimb.LEFT_HAND:
case ELimb.LEFT_ARM:
case ELimb.RIGHT_HAND:
case ELimb.RIGHT_ARM:
text3 = Provider.localization.format("Arm");
break;
case ELimb.SPINE:
text3 = Provider.localization.format("Spine");
break;
case ELimb.SKULL:
text3 = Provider.localization.format("Skull");
break;
}
switch (newCause)
{
case EDeathCause.GUN:
CommandWindow.Log(Provider.localization.format("Gun", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName, text3, text, text2));
break;
case EDeathCause.MELEE:
CommandWindow.Log(Provider.localization.format("Melee", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName, text3, text, text2));
break;
case EDeathCause.PUNCH:
CommandWindow.Log(Provider.localization.format("Punch", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName, text3, text, text2));
break;
case EDeathCause.ROADKILL:
CommandWindow.Log(Provider.localization.format("Roadkill", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName, text, text2));
break;
case EDeathCause.GRENADE:
CommandWindow.Log(Provider.localization.format("Grenade", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName, text, text2));
break;
case EDeathCause.MISSILE:
CommandWindow.Log(Provider.localization.format("Missile", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName, text, text2));
break;
case EDeathCause.CHARGE:
CommandWindow.Log(Provider.localization.format("Charge", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName, text, text2));
break;
case EDeathCause.SPLASH:
CommandWindow.Log(Provider.localization.format("Splash", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName, text, text2));
break;
}
break;
}
case EDeathCause.ZOMBIE:
CommandWindow.Log(Provider.localization.format("Zombie", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.ANIMAL:
CommandWindow.Log(Provider.localization.format("Animal", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.SUICIDE:
CommandWindow.Log(Provider.localization.format("Suicide", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.INFECTION:
CommandWindow.Log(Provider.localization.format("Infection", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.BREATH:
CommandWindow.Log(Provider.localization.format("Breath", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
default:
switch (newCause)
{
case EDeathCause.ZOMBIE:
CommandWindow.Log(Provider.localization.format("Zombie", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.VEHICLE:
CommandWindow.Log(Provider.localization.format("Vehicle", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.SHRED:
CommandWindow.Log(Provider.localization.format("Shred", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.LANDMINE:
CommandWindow.Log(Provider.localization.format("Landmine", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.ARENA:
CommandWindow.Log(Provider.localization.format("Arena", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.SENTRY:
CommandWindow.Log(Provider.localization.format("Sentry", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.ACID:
CommandWindow.Log(Provider.localization.format("Acid", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.BOULDER:
CommandWindow.Log(Provider.localization.format("Boulder", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.BURNER:
CommandWindow.Log(Provider.localization.format("Burner", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.SPIT:
CommandWindow.Log(Provider.localization.format("Spit", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
case EDeathCause.SPARK:
CommandWindow.Log(Provider.localization.format("Spark", base.channel.owner.playerID.characterName, base.channel.owner.playerID.playerName));
break;
}
break;
}
}
}
// 流血状态同步
else if (Provider.modeConfigData.Players.Can_Start_Bleeding && canCauseBleeding && amount >= 20)
{
serverSetBleeding(newBleeding: true);
}
// 受伤效果(可能是插件内定义)
this.onHurt?.Invoke(base.player, amount, newRagdoll, newCause, newLimb, newKiller);
}
这个方法是玩家受伤的核心机制 基本上包括了状态检测、死亡判断、声望累计、流血效果、任击杀务统计、击杀条件广播等
最后
项目的代码设计的实在是复杂(说明这是Nelson迭代了很多次后的方案)加上我对Unity本身不是很懂行 所以没多摘代码来看
回头看 自17年到现在这个我已经玩了242个小时的游戏经反编译后 没想到这个不起眼的沙盘游戏竟是这样一个Monolith 不过有句话说得好 “罗马建成非一日之功” 如果不是Nelson对游戏开发的热情和SDG的大力支持 Unturned也不会像现在这样 这是值得我去学习的
评论区