前段时间晚上把小孩哄睡后带着老婆体验了一把《星辰变》,让我印象较深的可怜只有其副本系统,这里想说并不是《星辰变》的副本有多么有趣;相反,其枯燥到了无生趣可言,几乎你每天都得花费2个小时用在那重复重复再重复,屈指可数那3-5个一成不变的副本任务上,所以没几天我们便厌倦了。自从《魔兽世界》开始侵噬中华网游大地那刻,一款网游“副本系统”设计的坏往往被商家定位成事关整个游戏品质的极重要环节。为什么地下城模式的副本总能让玩家遐想连篇、无限回味,每一次的进入都能感受新鲜如初?国产网游所谓的副本却永远若脱离不了具有中国特色的任务模式,能不悲哀?
副本系统设计很难吗?
如果你玩过那些带副本的游戏,相信在你脑海中对副本这个概念已不陌生。作为策划而言,副本赋予更多的含义是“团队协作”、“独立性”与“探险精神”,回报更加丰富而神秘,让人向往。在我看来,游戏副本是基于一个特殊的场景搭建的独立空间,辅以诸多规则、限制与达成、触发条件等,配上一个计时器统和而成。网游副本,其最初存在的目的是为了弥补多玩家交互而导致的单个/私人领域体验的缺失,即融入更多单机游戏的特性到网游中。如果将网游的主线任务看做是一个故事的线索,那么副本便是游戏的分支剧情,它通常描述着许多精简却非常饱满而完整故事情节。
话说编写具体副本类实在是太过瘾,用代码书写故事剧本,感觉贼带劲。如前文所述,副本,我们可以看做是特殊的场景空间配上一些UI(阶段描述,任务叙述,倒计时等文字/图形界面)。
于是首先第一步还是创建一个基于ObjectBase的副本基类 – InstanceBase:
/// <summary>
/// 副本类型
/// </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来描述它的故事脉络),这里我用到了中文编码,事实证明了这对于灵活性极大的副本设计来说非常有利于拓展、阅读及维护:
/// <summary>副本阶段(用本土语言枚举非常有利于拓展维护及阅读)</summary>
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.副本完成通过传送门离开, "副本完成,从传送门离开"},
};
副本的故事基于阶段性发展,即完成一个阶段故事才会向下一阶段延续,直到达成该阶段完成头条为止,此时就涉及到副本系统规则的设定、达成判定等处理。我的做法是通过计时器状态机配合倒计时器及角色坐标改变事件来处理所有的逻辑判断,这样的框架性效比极好且能实现任意的副本设计需求:
public Steps Step { get; private set; }
Steps countdownStep;
int secondsRemaining;
public HuntingSpiderKind() {
title.Text = "副本【猎杀蜘蛛魔王】";
TriggerOrgan =