前段时间晚上把小孩哄睡后带着老婆体验了一把《星辰变》,让我印象较深的可怜只有其副本系统,这里想说并不是《星辰变》的副本有多么有趣;相反,其枯燥到了无生趣可言,几乎你每天都得花费2个小时用在那重复重复再重复,屈指可数那3-5个一成不变的副本任务上,所以没几天我们便厌倦了。自从《魔兽世界》开始侵噬中华网游大地那刻,一款网游“副本系统”设计的好坏往往被商家定位成事关整个游戏品质的极重要环节。为什么地下城模式的副本总能让玩家遐想连篇、无限回味,每一次的进入都能感受新鲜如初?国产网游所谓的副本却永远若脱离不了具有中国特色的任务模式,能不悲哀?
副本系统设计很困难吗?
如果你玩过那些带副本的游戏,相信在你脑海中对于副本这个概念已不陌生。作为策划而言,副本赋予更多的含义是“团队协作”、“独立性”与“探险精神”,回报更加丰富而神秘,让人向往。在我看来,游戏副本是基于一个特殊的场景搭建的独立空间,辅以诸多规则、限制与达成、触发条件等,配上计时器统和而成。网游副本,其最初存在的目的是为了弥补多玩家交互而导致的单个/私人领域体验的缺失,即融入更多单机游戏的元素/特性到网游中。如果将网游的主线任务看做是一个故事的线索,那么副本便是游戏的分支剧情,它通常描述着许多精简却非常饱满而完整的故事情节。
话说编写具体副本类实在是太过瘾,用代码书写故事剧本,感觉贼带劲。如前文所述,副本,我们可以看做是特殊的场景空间配上一些UI(阶段描述,任务叙述,倒计时等文字/图形界面)。
于是首先第一步还是创建一个基于ObjectBase的副本基类 – InstanceBase:
/// 副本类型
/// </summary>
public enum InstanceTypes {
/// <summary>
/// 无
/// </summary>
None = -1,
/// <summary>
/// 猎杀蜘蛛魔王
/// </summary>
HuntingSpiderKind = 0,
/// <summary>
/// 神邸秘境
/// </summary>
GodFam = 1,
}
public class AddRolesEventArgs : EventArgs {
public int Num { get; set; }
public int Mode { get; set; }
public States State { get; set; }
public Professions Profession { get; set; }
public TacticAIs TacticAI { get; set; }
}
public class LeaveEventArgs : EventArgs {
public Teleport Destination { get; set; }
}
/// <summary>
/// 副本基类
/// </summary>
public abstract class InstanceBase : ObjectBase {
/// <summary>
/// 添加角色(测试用)
/// </summary>
public event EventHandler<AddRolesEventArgs> AddRoles;
/// <summary>
/// 脱离副本
/// </summary>
public event EventHandler<LeaveEventArgs> Leave;
protected Grid grid = new Grid();
protected TextBlock title = new TextBlock() { Foreground = new SolidColorBrush(Colors.Red), FontSize = 24 };
protected TextBlock description = new TextBlock() { Foreground = new SolidColorBrush(Colors.White), FontSize = 22, TextWrapping = TextWrapping.Wrap };
protected TextBlock additionalInformation = new TextBlock() { Foreground = new SolidColorBrush(Colors.Orange), FontSize = 20 };
protected DispatcherTimer checkTimer = new DispatcherTimer() { Interval = TimeSpan.FromMilliseconds(500) };
protected DispatcherTimer countdownTimer = new DispatcherTimer() { Interval = TimeSpan.FromSeconds(1) };
///<summary>副本所属空间</summary>
protected Space space;
///<summary>参与副本的所有玩家</summary>
protected List<RoleBase> players;
public InstanceBase() {
this.IsHitTestVisible = false;
RowDefinition row = new RowDefinition();
grid.RowDefinitions.Add(row);
grid.Children.Add(title); Grid.SetRow(title, 0); title.HorizontalAlignment = HorizontalAlignment.Center;
row = new RowDefinition();
grid.RowDefinitions.Add(row);
grid.Children.Add(description); Grid.SetRow(description, 1); description.HorizontalAlignment = HorizontalAlignment.Center;
row = new RowDefinition();
grid.RowDefinitions.Add(row);
grid.Children.Add(additionalInformation); Grid.SetRow(additionalInformation, 2); additionalInformation.HorizontalAlignment = HorizontalAlignment.Center;
this.Children.Add(grid); Canvas.SetTop(grid, 85);
checkTimer.Tick += new EventHandler(StepCheck);
countdownTimer.Tick += new EventHandler(Countdown);
AdaptiveWindowSize();
}
/// <summary>
/// 获取或设置是否触发各阶段机关
/// </summary>
public bool[] TriggerOrgan { get; set; }
protected void AddRolesEvent(AddRolesEventArgs e) {
if (AddRoles != null) { AddRoles(this, e); }
}
protected void LeaveEvent(LeaveEventArgs e) {
if (Leave != null) { Leave(this, e); }
}
protected abstract void StepCheck(object sender, EventArgs e);
protected abstract void Countdown(object sender, EventArgs e);
/// <summary>
/// 触发/运行
/// </summary>
public abstract void Run(List<RoleBase> players, Space space);
/// <summary>
/// 设置动画描述文本
/// </summary>
/// <param name="value"></param>
protected void SetDescription(string value) {
if (description.Text != value) {
description.Text = value;
GlobalMethod.RunEffectAnimation(description, new RadialBlur(), false, false, "Progress", 0, 100, TimeSpan.FromMilliseconds(1800), new ExponentialEase() { EasingMode = EasingMode.EaseIn }, true);
}
}
/// <summary>
/// 自适应游戏窗口尺寸
/// </summary>
public virtual void AdaptiveWindowSize() {
grid.Width = Application.Current.Host.Content.ActualWidth;
}
/// <summary>
/// 离开
/// </summary>
protected void Exit(string titleText) {
Dispose(this, null);
title.Text = titleText;
description.Text = "5秒后自动离开副本";
GlobalMethod.RunEffectAnimation(title, new Ripple(), false, false, "Progress", 0, 100, TimeSpan.FromMilliseconds(2000), new ExponentialEase() { EasingMode = EasingMode.EaseOut }, true);
GlobalMethod.SetTimeout(delegate {
//退出
LeaveEvent(new LeaveEventArgs() { Destination = new Teleport() { Instance = InstanceTypes.None, ToSpace = 0, ToDirection = Directions.SouthEast, ToCoordinate = new Point(57, 28) } });
}, 5000);
}
}
以本节Demo源码中的副本-【猎杀蜘蛛魔王】(HuntingSpiderKind.cs)为例,首先是定义其中的阶段(用Enum来描述它的故事脉络),这里我用到了中文编码,事实证明了本土语言枚举在副本实现中拥有极高的灵活性,且非常有利于拓展、阅读与维护(前提是还未使用脚本来描述副本):
public enum Steps {
设置右键魔法8级连锁闪电或6级石封箭 = 0,
十二秒内到达传送点 = 1,
等待4秒翼族来袭 = 2,
消灭所有翼族 = 3,
进入传送点到达山的彼岸 = 4,
设置右键魔法为7级陨石坠落 = 5,
等待3秒刺客来袭 = 6,
消灭所有刺客 = 7,
骑上马并移动到59_60附近开启封印 = 8,
在23秒内为武器附上烟火粒子 = 9,
在180秒内消灭蜘蛛魔王 = 10,
副本完成通过传送门离开 = 11,
}
Dictionary<Steps, string> stepExplanation = new Dictionary<Steps, string>() {
{Steps.设置右键魔法8级连锁闪电或6级石封箭, "切换到【主角】菜单,将右键魔法设置为8级【连环闪电】或6级【石封箭】"},
{Steps.十二秒内到达传送点, "传送点已开启,<紧急>请在12秒内穿过石门找到传送点并进入"},
{Steps.等待4秒翼族来袭, "危险!4秒后【翼族】来袭,准备好你的家伙"},
{Steps.消灭所有翼族, "用【连环闪电】或【石封箭】干掉他们"},
{Steps.进入传送点到达山的彼岸, "通过传送点到达山的彼岸"},
{Steps.设置右键魔法为7级陨石坠落, "注意!大规模【守护刺客】将至,将右键魔法设置为7级【陨石坠落】"},
{Steps.等待3秒刺客来袭, "危险!3秒后敌军来袭!!"},
{Steps.消灭所有刺客, "来一杀一,用【陨石坠落】干掉他们"},
{Steps.骑上马并移动到59_60附近开启封印, "【骑上马】并移动到坐标【59,60】附近,点击【主角】菜单中的【开启封印】释放【蜘蛛魔王】!"},
{Steps.在23秒内为武器附上烟火粒子, "<紧急>【蜘蛛魔王】23秒后将出现!快速点击【主角】菜单,为【武器】附上【烟火】粒子效果"},
{Steps.在180秒内消灭蜘蛛魔王, "3分钟内必须消灭【蜘蛛魔王】,否则世界将被瞬间毁灭!!"},
{Steps.副本完成通过传送门离开, "副本完成,从传送门离开"},
};
副本的故事基于阶段性发展,即完成一个阶段故事才会向下一阶段延续,直到达成该阶段完成条件为止,此时就涉及到副本系统规则的设定、达成判定等处理。我的做法是通过计时器状态机配合倒计时器及角色坐标改变事件来处理所有的逻辑判断,这样的框架性效比极好且能实现任意的副本设计需求:
Steps countdownStep;
int secondsRemaining;
public HuntingSpiderKind() {
title.Text = "副本【猎杀蜘蛛魔王】";
TriggerOrgan = new bool[stepExplanation.Count];
}
/// <summary>
/// 开始
/// </summary>
public override void Run(List<RoleBase> players, Space space) {
this.space = space;
this.players = players;
players[0].PositionChanged += new DependencyPropertyChangedEventHandler(player0_PositionChanged);
checkTimer.Start();
//离开的传送门(测试用)
space.AddAnimation(new AnimationBase() {
ID = 0,
Code = 81,
Position = new Point(-1320, 1970),
Z = 1970,
Tip = "离开副本",
Loop = true,
});
space.RunWeather(WeatherTypes.Cloud, 50);
}
///<summary>进度检查(网络版中为所有玩家都要检测,目前单机只检测players[0]即主角)</summary>
protected override void StepCheck(object sender, EventArgs e) {
SetDescription(string.Format("Step {0}: {1}", (int)Step, stepExplanation[Step]));
switch (Step) {
case Steps.设置右键魔法8级连锁闪电或6级石封箭:
if ((players[0].CurrentMagic.Level == 8 && players[0].CurrentMagic.Code == 9) || (players[0].CurrentMagic.Level == 6 && players[0].CurrentMagic.Code == 12)) {
space.AddAnimation(new AnimationBase() {
ID = 0,
Code = 82,
Position = new Point(-583, 1695),
Z = 1695,
Tip = "传送点",
Loop = true,
});
space.Terrain.AddTeleport(new Teleport() {
Code = 0,
ToSpace = space.Code,
Instance = InstanceTypes.None,
ToCoordinate = new Point(46, 72),
ToDirection = Directions.SouthEast
}, "45_68_0,46_68_0,");
StartCountdown(Steps.十二秒内到达传送点, 12);
}
break;
case Steps.消灭所有翼族:
if (space.AllRoles().Count == players.Count) { //这样的检测方案只是暂定的,很可能存在BUG
Step = Steps.进入传送点到达山的彼岸;
space.AddAnimation(new AnimationBase() {
ID = 0,
Code = 82,
Position = new Point(-238, 2018),
Z = 2018,
Tip = "传送点",
Loop = true,
});
space.Terrain.AddTeleport(new Teleport() {
Code = 1,
ToSpace = space.Code,
Instance = InstanceTypes.None,
ToCoordinate = new Point(73, 25),
ToDirection = Directions.SouthWest
}, "63_71_0,62_70_0,62_72_0,");
}
break;
case Steps.设置右键魔法为7级陨石坠落:
if (players[0].CurrentMagic.Level == 7 && players[0].CurrentMagic.Code == 7) {
StartCountdown(Steps.等待3秒刺客来袭, 3);
}
break;
case Steps.消灭所有刺客:
if (space.AllRoles().Count == players.Count) {
Step = Steps.骑上马并移动到59_60附近开启封印;
}
break;
case Steps.骑上马并移动到59_60附近开启封印:
if (TriggerOrgan[(int)Step]) {
StartCountdown(Steps.在23秒内为武器附上烟火粒子, 23);
}
break;
case Steps.在23秒内为武器附上烟火粒子:
if (players[0].DisplayWeaponParticle) {
StartCountdown(Steps.在180秒内消灭蜘蛛魔王, 180);
Monster boss = new Monster(space.Terrain) {
ID = 3120000,
//AttachID = 4100000,
Code = 2,
FullName = "蜘蛛魔王",
LearnedMagic = new Dictionary<int, int>() { { 0, 6 }, { 1, 6 }, { 2, 6 }, { 3, 6 }, { 4, 6 }, { 5, 6 }, { 6, 6 }, { 7, 6 }, { 8, 6 }, { 9, 6 }, { 10, 6 }, { 11, 6 }, { 12, 6 }, { 13, 6 }, { 14, 6 }, { 15, 6 }, { 16, 6 }, { 17, 6 }, { 18, 6 } },
Profession = Professions.Monster,
ArmorCode = 16,
Direction = Directions.SouthEast,
State = States.Walking,
Camp = Camps.Eval,
TacticAI = TacticAIs.GoalLeader,
ActionAI = ActionAIs.Persistent,
LifeMax = 640000,
Life = 640000,
ATK = 24059,
DEF = 1500,
MAG = 500,
DEX = ObjectBase.RandomSeed.Next(0, 30),
Coordinate = new Point(49, 59)
};
space.AddRole(boss, new RoleAddedEventArgs() {
RegisterDisposedEvent = true,
RegisterIntervalTriggerEvent = true,
RegisterActionTriggerEvent = true,
RegisterDoAttackEvent = true,
RegisterDoCastingEvent = true,
RegisterPositionChangedEvent = true,
RegisterLifeChangedEvent = true,
});
space.MusicUri = "Boss";
}
break;
case Steps.在180秒内消灭蜘蛛魔王:
if (space.AllRoles().Count == players.Count) {
Step = Steps.副本完成通过传送门离开;
space.MusicUri = "100";
space.AddAnimation(new AnimationBase() {
ID = 0,
Code = 81,
Position = new Point(-300, 1591),
Z = 1591,
Tip = "传送到【废墟秘境】",
Loop = true,
});
}
break;
}
}
/// <summary>
/// 开始倒计时
/// </summary>
void StartCountdown(Steps step, int remaining) {
countdownStep = Step = step;
secondsRemaining = remaining;
countdownTimer.Start();
}
/// <summary>
/// 倒计时
/// </summary>
protected override void Countdown(object sender, EventArgs e) {
if (Step != countdownStep) {
countdownTimer.Stop();
additionalInformation.Text = "";
} else {
additionalInformation.Text = string.Format("倒计时: {0}", secondsRemaining);
if (secondsRemaining == 0) {
additionalInformation.Text = "";
switch (Step) {
case Steps.十二秒内到达传送点:
case Steps.在23秒内为武器附上烟火粒子:
case Steps.在180秒内消灭蜘蛛魔王:
Exit("副本【猎杀蜘蛛魔王】 战斗失败");
break;
case Steps.等待4秒翼族来袭:
Step = Steps.消灭所有翼族;
AddRolesEvent(new AddRolesEventArgs() { Num = 10, Mode = 1, Profession = Professions.Archer, State = States.Riding, TacticAI = TacticAIs.GoalLeader });
break;
case Steps.等待3秒刺客来袭:
Step = Steps.消灭所有刺客;
AddRolesEvent(new AddRolesEventArgs() { Num = 30, Mode = 2, Profession = Professions.Assassin, State = States.Walking, TacticAI = TacticAIs.GoalLeader });
break;
}
}
secondsRemaining--;
}
}
void player0_PositionChanged(object sender, DependencyPropertyChangedEventArgs e) {
Point p = (Point)(e.NewValue);
int x = (int)p.X;
int y = (int)p.Y;
if ((x == 41 && y == 90) || (x == 40 && y == 91)) {
LeaveEvent(new LeaveEventArgs() { Destination = new Teleport() { Instance = InstanceTypes.None, ToSpace = 0, ToDirection = Directions.SouthEast, ToCoordinate = new Point(57, 28) } });
Dispose(this, null);
} else if (Step == Steps.十二秒内到达传送点 && x == 46 && y == 72) {
StartCountdown(Steps.等待4秒翼族来袭, 4);
} else if (Step == Steps.进入传送点到达山的彼岸 && x == 73 && y == 25) {
Step = Steps.设置右键魔法为7级陨石坠落;
} else if (Step == Steps.副本完成通过传送门离开 && ((x == 48 && y == 59) || (x == 48 && y == 58) || (x == 47 && y == 58))) {
LeaveEvent(new LeaveEventArgs() { Destination = new Teleport() { Instance = InstanceTypes.GodFam, ToSpace = 101, ToDirection = Directions.NorthWest, ToCoordinate = new Point(60, 44) } });
Dispose(this, null);
}
}
本节源码中我为大家提供了两个副本做为参考:【猎杀蜘蛛魔王】和【神邸秘境】,它们可以独立进行,同时当杀掉【猎杀蜘蛛魔王】副本中的BOSS后,通过传送门同样可以进入到下一副本【神邸秘境】,这样就构成了副本系统中的连锁副本设计,从而形成无线连通的副本世界,能够满足所有一切的副本设计需求。另外每个副本都包含有10几个阶段,比如让玩家改变属性、移动到指定坐标、进入传送点、杀光怪、倒计时行为、开启封印(使用道具,物品道具都应有相关属性来记录它的各类效果或触发事件)、杀掉BOSS、定时刷怪、跟随移动、对话、守护等等,基于本节副本系统框架制作的副本只有想不到的,没有做不到的(功能实现暂时是硬编码,以后有机会再进一步完善,细节/方法还可以再优化)。
做为商业游戏开发,副本系统可以基于脚本构建,同时副本编辑器也必不可少,设计起来并不难,关键点本文已讲述得很清楚了。本文,我以副本功能的实现让大家对副本系统的设计有个入门概念,如何将其制定成像场景一样可动态编辑的对象,这个需要预先对游戏产品中所有副本可能需要拥有最极限的功能需求进行分析,然后在制作好副本编辑器后配以脚本和xml描述文件最终实现最完美的副本系统。另外,如果你体验过本节的Demo相信应该还会有这样的领悟:这副本咋和新手指导(入门演示)如此相似?没错,其实它们本就同根同源,想想原理便一清二楚了。
副本的最后一个要素便是场景的渲染和背景音乐烘托,在云雾缭绕的秘境中伴随着激荡人心的音乐与BOSS华丽一战,相信那样的体验足够你珍惜与回味。
深思呀,国产网游;副本不仅仅代表着财宝与经验,韵味深长的用户体验才是至尊之道。
本系列源码请到目录中下载
在线演示地址:http://silverfuture.cn