任务是贯穿游戏剧情发展的核心线索,具有极强的多元性、组合性、循环性与随机性;它的设计原则浓缩起来便是:触发-执行-完成。别小看这短短6个字,里面的学问可大了,由什么触发、如何触发的,因素很多;怎样执行、什么样的过程,一切随便;怎么算完成,完成后的奖励是啥,什么都行。而不同的故事背景、不同的操作玩法,在任务设计方面都会大相径庭。比如RPG游戏,角色扮演即是虚拟人生,需要还原一个完整而虚幻的世界,因此它的任务系统通常会被设计得极其丰富,可以比喻:人生有多复杂,RPG的任务系统就有多庞大;又比如SLG游戏,大致以独立的场景为线索进行剧情串联,因而任务系统设计起来便相对简单得多;除此之外还有RTS、FPS等类型游戏,它们的侧重点在于策略和战斗操控,通常只需面对面的对话、走到指定地点或杀掉某个对象便可触发/完成剧情任务,被视为最简单的任务模式之一。由此可见,不同类型游戏,所强调的核心玩法及需要表达的世界观都不同,作为游戏进展的纽带,任务系统也需酌情设计。
因此,MMORPG的任务系统确实非常非常复杂,一款成功的MMORPG离不开其背后优秀的任务系统,我们需要不断借鉴与微创新,比如《任务系统设计思路》、《游戏任务多样化分析》等在分析魔兽世界的任务系统方面都具有很好的参考价值,网上还能搜罗到相当多的类似资料,作为策划的主要工作,我们暂且就不掺和了。
本节,我只想将其中一个微小却又极具传统韵味的分支:任务剧情功能实现呈现给大家;通过它,最终将游戏中的所有角色都关联了起来,作为庞大而复杂的任务系统的开端,今后若有充足时间我会陆续一一补充。
剧情任务通常是游戏的主线,尤其在单机游戏中。玩家可能不会记得为哪个工匠打了把刀,为哪个NPC杀了些怪,但是肯定会记住游戏中那些贯穿始终的儿女情长,乱世纷争。于是模仿经典的武侠网游《剑侠世界》的剧情功能设计,我们同样可以在Silverlight中制作出独具特色的剧情写实功能:
以上是最终效果截图,我将剧情对话控件由上至下分成功能窗口、特写窗口和剧情窗口;通过一些渐进渐出的动画,每次与NPC对话时触发剧情都会平缓的隐藏掉UI并突出剧情界面,此类特写便是让玩家更加重视主线内容的重要手法。剧情的呈现作为单独的一个控件,其中集成了阻断向下路由的鼠标操作,同时也需要处理好游戏画面的层次感让玩家更有身临其境之感觉,即便是在战斗中,如触发了剧情,游戏世界的时间(循环)依旧运转而不会因为你的私事造成一丝滞留:
/// 剧情对话板
/// </summary>
public sealed class DramaDialogue : UIBase {
/// <summary>
/// 显示时触发
/// </summary>
public event EventHandler Showing;
/// <summary>
/// 消失时触发
/// </summary>
public event EventHandler Hiding;
Rectangle topRect = new Rectangle() { Fill = new ImageBrush() { ImageSource = GlobalMethod.GetImage("UI/DramaLine.jpg", UriType.Project), Stretch = Stretch.Fill } };
Rectangle middleRect = new Rectangle() { Fill = new ImageBrush() { ImageSource = GlobalMethod.GetImage("UI/GrayRect.png", UriType.Project), Stretch = Stretch.Fill } };
Rectangle bottomRect = new Rectangle() { Fill = new ImageBrush() { ImageSource = GlobalMethod.GetImage("UI/DramaLine.jpg", UriType.Project), Stretch = Stretch.Fill } };
Canvas topCanvas = new Canvas();
Canvas bottomCanvas = new Canvas();
EntityObject closer = new EntityObject() { Source = GlobalMethod.GetImage("UI/EndDialogue.png", UriType.Project) };
TextBlock tip = new TextBlock() { Text = "【按Esc退出对话】", Foreground = new SolidColorBrush(Colors.Orange), FontSize = 18, TextWrapping = TextWrapping.Wrap };
TextBlock content = new TextBlock() { Foreground = new SolidColorBrush(Colors.White), FontSize = 24, TextWrapping = TextWrapping.Wrap };
EntityObject avatar = new EntityObject() { Source = GlobalMethod.GetImage("UI/Avatar0.png", UriType.Project) };
DispatcherTimer timer = new DispatcherTimer() { Interval = TimeSpan.FromMilliseconds(30) };
/// <summary>
/// 是否正在显示
/// </summary>
public bool IsShowing { get; private set; }
/// <summary>
/// 获取或设置剧情播放速度
/// </summary>
public int DialogSpeed {
get { return timer.Interval.Milliseconds; }
set { timer.Interval = TimeSpan.FromMilliseconds(value); }
}
public DramaDialogue() {
this.Children.Add(topCanvas);
topCanvas.Children.Add(topRect);
topCanvas.Children.Add(closer); Canvas.SetTop(closer, 10);
this.Children.Add(middleRect);
this.Children.Add(bottomCanvas);
bottomCanvas.Children.Add(bottomRect);
bottomCanvas.Children.Add(tip); Canvas.SetLeft(tip, 15); Canvas.SetTop(tip, 65);
bottomCanvas.Children.Add(content); Canvas.SetTop(content, 10);
bottomCanvas.Children.Add(avatar); Canvas.SetLeft(avatar, 10);
timer.Tick += delegate {
countText++;
if (countText > drama.Content[countDrama].Length) {
if (timer.IsEnabled) {
timer.Stop();
countDrama++;
}
} else {
content.Text = drama.Content[countDrama].Substring(0, countText);
}
};
this.MouseLeftButtonDown += (s, e) => {
if (IsShowing) {
Point p = e.GetPosition(closer);
if (p.X >= 0 && p.X <= closer.RealWidth && p.Y >= 0 && p.Y <= closer.RealHeight) { Hide(); e.Handled = true; return; }
if (timer.IsEnabled) {
content.Text = drama.Content[countDrama];
timer.Stop();
countDrama++;
} else {
if (countDrama == drama.Content.Count) {
Hide();
} else {
countText = 0;
avatar.Source = GlobalMethod.GetImage(string.Format("UI/Avatar{0}.png", drama.Avatar[countDrama]), UriType.Project);
timer.Start();
}
}
}
e.Handled = true;
};
}
Drama drama;
int countText = 0;
int countDrama = 0;
public void Show(Drama drama) {
IsShowing = true;
if (Showing != null) { Showing(this, null); }
this.drama = drama;
avatar.Source = GlobalMethod.GetImage(string.Format("UI/Avatar{0}.png", drama.Avatar[countDrama]), UriType.Project);
Storyboard storyboard = new Storyboard();
Duration duration = TimeSpan.FromMilliseconds(500);
PowerEase powerEase = new PowerEase() { EasingMode = EasingMode.EaseOut };
storyboard.Children.Add(GlobalMethod.CreateDoubleAnimation(topCanvas, "(Canvas.Top)", -topRect.Height, 0, duration, powerEase));
storyboard.Children.Add(GlobalMethod.CreateDoubleAnimation(middleRect, "Opacity", 0, 1, duration, powerEase));
storyboard.Children.Add(GlobalMethod.CreateDoubleAnimation(bottomCanvas, "(Canvas.Top)", Application.Current.Host.Content.ActualHeight, Application.Current.Host.Content.ActualHeight * 4 / 5, duration, powerEase));
storyboard.Children.Add(GlobalMethod.CreateDoubleAnimation(avatar, "Opacity", 0, 1, duration, powerEase));
EventHandler handler = null;
storyboard.Completed += handler = delegate {
storyboard.Completed -= handler;
timer.Start();
};
storyboard.Begin();
}
public void Hide() {
IsShowing = false;
Storyboard storyboard = new Storyboard();
Duration duration = TimeSpan.FromMilliseconds(500);
PowerEase powerEase = new PowerEase() { EasingMode = EasingMode.EaseOut };
storyboard.Children.Add(GlobalMethod.CreateDoubleAnimation(topCanvas, "(Canvas.Top)", 0, -topRect.Height, duration, powerEase));
storyboard.Children.Add(GlobalMethod.CreateDoubleAnimation(middleRect, "Opacity", 1, 0, duration, powerEase));
storyboard.Children.Add(GlobalMethod.CreateDoubleAnimation(bottomCanvas, "(Canvas.Top)", Application.Current.Host.Content.ActualHeight * 4 / 5, Application.Current.Host.Content.ActualHeight, duration, powerEase));
storyboard.Children.Add(GlobalMethod.CreateDoubleAnimation(avatar, "Opacity", 1, 0, duration, powerEase));
EventHandler handler = null;
storyboard.Completed += handler = delegate {
storyboard.Completed -= handler;
countText = 0;
countDrama = 0;
content.Text = string.Empty;
timer.Stop();
if (Hiding != null) { Hiding(this, null); }
};
storyboard.Begin();
}
public override void AdaptiveWindowSize() {
try {
topRect.Width = middleRect.Width = bottomRect.Width = Application.Current.Host.Content.ActualWidth;
topRect.Height = Application.Current.Host.Content.ActualHeight / 14;
middleRect.Height = Application.Current.Host.Content.ActualHeight;
bottomRect.Height = Application.Current.Host.Content.ActualHeight * 1 / 5;
tip.Width = Application.Current.Host.Content.ActualWidth * 1 / 5 - 15;
content.Width = Application.Current.Host.Content.ActualWidth * 4 / 5 - 15;
Canvas.SetLeft(closer, Application.Current.Host.Content.ActualWidth - closer.RealWidth -15);
Canvas.SetLeft(content, Application.Current.Host.Content.ActualWidth * 1 / 5);
Canvas.SetTop(avatar, -avatar.RealHeight);
Canvas.SetTop(bottomCanvas, Application.Current.Host.Content.ActualHeight * 4 / 5);
} catch { }
}
}
到此,本系列Demo的编写已告一段落。有些技巧和功能太小不便作为单独章节详细讲述了,比如地图的视口缓动、地形的预测与碰撞处理、角色的特殊动画体系(如弹开、击飞、颤栗等)处理等;当然也还存在很多可拓展元素,比如《博得之门》中的游戏暂停、录像功能;格斗游戏中增强体验的烟尘、声效和印记效果等。代码方面除还可能进一步优化的小部分外,整体框架在反复的思考与重构后已近乎成熟;相比两年前的QXGameEngine,可谓翻天覆地的变化。
最后剩下几节,我将为本系列Demo焊接上游戏登陆模块,赋予它一个相对完整的商业产品流程。之前写过的两篇《动态资源》和《多国语言本地化》便是其开头,重新整编入本系列作为章节。
本系列源码请到目录中下载
在线演示地址:http://silverfuture.cn