引言
丰富的关卡与场景是充实游戏的魔法圣器,时而穿过云霄,时而坠入大海,就算是陆地同样可以云雾缭绕、山峦叠嶂;作为玩家,游戏玩累了休息时聆听的可以不仅仅是音乐,作为游戏设计者,你有责任将此时疲惫的他们带进梦幻空间:登上紫禁之颠、长城尽头,潜入亚特兰蒂斯深处与美人鱼结伴嬉戏,尝试一次惬意舒心的休憩之旅又未尝不可?虚幻的游戏同样可以给玩家带来真切的感受,华丽莫测的场景变换开启了这扇通往意念领域的大门。
8.1游戏中场景切换实现(交叉参考:地图间的传送与切换 梦幻西游(Demo) 之 “天人合一”① )
传统游戏两个场景之间切换往往通过呈现一幅游戏相关的宣传背景作为过度,并更新地图、角色、模型等目标场景所必须的一切资源读取加载完毕后才算完成。通过Silverlight开发基于Web的MMORPG网页游戏则可简化这一过程,动态按需下载技术使得我们在进入新场景前仅需下载该场景的配置文件及缩略地图等少部分资源即可。
按照该思路,我们首先创建一个名为Transition的过场类:
/// 过场控件
/// </summary>
public sealed class Transition : Canvas {
int _Code = -1;
/// <summary>
/// 获取或设置代号
/// </summary>
public int Code {
get { return _Code; }
set {
if (_Code != value) {
_Code = value;
this.Background = new ImageBrush() {
ImageSource = Global.GetProjectImage(string.Format("Transition/{0}.jpg", value))
};
}
}
}
/// <summary>
/// 获取或设置X、Y坐标
/// </summary>
public Point Coordinate {
get { return new Point(Canvas.GetLeft(this) + Center.X, Canvas.GetTop(this) + Center.Y); }
set { Canvas.SetLeft(this, value.X - Center.X); Canvas.SetTop(this, value.Y - Center.Y); }
}
/// <summary>
/// 获取或设置Z层次深度
/// </summary>
public int Z {
get { return Canvas.GetZIndex(this); }
set { Canvas.SetZIndex(this, value); }
}
/// <summary>
/// 获取或设置中心
/// </summary>
public Point Center { get; set; }
/// <summary>
/// 适应游戏窗口尺寸
/// </summary>
public void AdaptToWindowSize() {
this.Width = Application.Current.Host.Content.ActualWidth;
this.Height = Application.Current.Host.Content.ActualHeight;
}
public Transition() {
this.CacheMode = new BitmapCache();
}
}
并在场景类中定义两个事件ChangeStart和ChangeEnd分别放在场景代号改变时及Mini地图下载完毕后:
/// <summary>
/// 获取或设置代号
/// </summary>
public int Code {
get { return _Code; }
set {
if (_Code != value) {
_Code = value;
if (ChangeStart != null) { ChangeStart(this, null); }
teleports.Clear(); //清空传送点集合
ClearMasks(); //清空遮挡物
ClearSprites(); //清空精灵
ClearAnimations(); //清空动画
Downloader configDownloader = new Downloader() { TargetCode = value };
configDownloader.Completed += new EventHandler<DownloaderEventArgs>(configDownloader_Completed);
configDownloader.Download(Global.WebPath(string.Format("Scene/{0}/Info.xml", value)));
}
}
}
/// <summary>
/// Mini地图背景下载完毕
/// </summary>
void miniMapDownloader_Completed(object sender, DownloaderEventArgs e) {
Downloader miniMapDownloader = sender as Downloader;
miniMapDownloader.Completed -= miniMapDownloader_Completed;
int code = miniMapDownloader.TargetCode;
//用缩略图填充地图背景(如果异步与同步一致)
if (miniMapDownloader.Index == index) { map.Source = Global.GetWebImage(string.Format("Scene/{0}/MiniMap.jpg", code)); }
//下载实际地图
Downloader realMapDownloader = new Downloader() { TargetCode = code, Index = miniMapDownloader.Index };
realMapDownloader.Completed += new EventHandler<DownloaderEventArgs>(realMapDownloader_Completed);
realMapDownloader.Download(Global.WebPath(string.Format("Scene/{0}/RealMap.jpg", code)));
if (ChangeEnd != null) { ChangeEnd(this, null); }
}
/// <summary>
/// 实际地图背景下载完毕
/// </summary>
void realMapDownloader_Completed(object sender, DownloaderEventArgs e) {
Downloader realMapDownloader = sender as Downloader;
realMapDownloader.Completed -= realMapDownloader_Completed;
int code = realMapDownloader.TargetCode;
//如果异步与同步一致
if (realMapDownloader.Index == index) {
//呈现实际地图背景
map.Source = Global.GetWebImage(string.Format("Scene/{0}/RealMap.jpg", code));
//加载遮挡物
string key = string.Format("Scene{0}", code);
IEnumerable<XElement> iMask = Global.ResInfos[key].Element("Masks").Elements();
for (int i = 0; i < iMask.Count(); i++) {
XElement xMask = iMask.ElementAt(i);
Mask mask = new Mask() {
Source = Global.GetWebImage(string.Format("Scene/{0}/Mask/{1}.png", code, xMask.Attribute("Code").Value)),
Opacity = (double)xMask.Attribute("Opacity"),
Coordinate = new Point((double)xMask.Attribute("X") - Offset.X, (double)xMask.Attribute("Y") - Offset.Y),
Z = (int)xMask.Attribute("Z") - Offset.Y
};
AddMask(mask);
}
//加载动画
IEnumerable<XElement> iAnimation = Global.ResInfos[key].Element("Animations").Elements();
for (int i = 0; i < iAnimation.Count(); i++) {
XElement xAnimation = iAnimation.ElementAt(i);
Animation animation = new Animation() {
Code = (int)xAnimation.Attribute("Code"),
Opacity = (double)xAnimation.Attribute("Opacity"),
Coordinate = new Point((double)xAnimation.Attribute("X") - Offset.X, (double)xAnimation.Attribute("Y") - Offset.Y),
Z = (int)xAnimation.Attribute("Z") - Offset.Y,
Tip = xAnimation.Attribute("Tip").Value,
};
AddAnimation(animation);
}
}
}
大家需要特别注意场景切换时由于动态下载Mini地图和Real地图,因此逻辑上需要和精灵一样加入异步与同步的协调。
接着在MainPage中为游戏场景注册这两个事件,分别编写以下逻辑:
/// 游戏窗口尺寸改变
/// </summary>
void Content_Resized(object sender, EventArgs e) {
hero_CoordinateChanged(hero, new DependencyPropertyChangedEventArgs());
if (transition.Visibility == Visibility.Visible) { transition.AdaptToWindowSize(); }
}
/// <summary>
/// 场景切换开始
/// </summary>
void scene_ChangeStart(object sender, EventArgs e) {
hero.CoordinateChanged -= hero_CoordinateChanged;
transition.Code = 0;
transition.AdaptToWindowSize();
transition.Visibility = Visibility.Visible;
LayoutRoot.Children.Add(transition);
}
/// <summary>
/// 场景切换结束
/// </summary>
void scene_ChangeEnd(object sender, EventArgs e) {
hero.CoordinateChanged += hero_CoordinateChanged;
hero.TeleportTo(teleport.ToCoordinate, (SpriteDirection)teleport.ToDirection);
transition.Visibility = Visibility.Collapsed;
LayoutRoot.Children.Remove(transition);
}
本节Demo中我仅以最简单的形式来实现场景切换过场效果,即开始->结束,过程中也只是通过一张可以自适应(填充)浏览器尺寸的背景图片作为呈现。实际游戏开发中大家完全可以具体到场景代号改变时的每一个环节;比如配置文件下载完成,Mini地图下载完成,基本素材下载完成,NPC下载完成等位置放置事件并在MainPage中触发,为Transition过场类添加相应控件以显示进度及描述文字。
接下来是如何实现RPG游戏中由主角所触发的场景切换?
当然踩地雷的形式来得最为直接而简单。通过在场景配置文件中添加上相应的传送点信息描述实现,里面记录下会触发传送的坐标,并指明该传送的目的地等信息:
<Teleports>
<Teleport Code="10" ToScene="1" ToX="15" ToY="30" ToDirection="3" Terrain="84_39,85_39,86_39,86_38,85_38,84_38"/>
</Teleports>
......
</Scene>
此时场景类中也需要添加对它内部所包含传送点的解析:
IEnumerable<XElement> iTeleport = xScene.Element("Teleports").Elements();
for (int i = 0; i < iTeleport.Count(); i++) {
XElement xTeleport = iTeleport.ElementAt(i);
Teleport teleport = new Teleport() {
Code = (int)xTeleport.Attribute("Code"),
ToScene = (int)xTeleport.Attribute("ToScene"),
ToCoordinate = new Point((double)xTeleport.Attribute("ToX"), (double)xTeleport.Attribute("ToY")),
ToDirection = (SpriteDirection)(int)xTeleport.Attribute("ToDirection"),
};
teleports.Add(teleport);
string[] teleportTerrain = xTeleport.Attribute("Terrain").Value.Split(',');
for (int j = 0; j < teleportTerrain.Count(); j++) {
if (teleportTerrain[j] != "") {
string[] position = teleportTerrain[j].Split('_');
TerrainMatrix[Convert.ToByte(position[0]), Convert.ToByte(position[1])] = (byte)teleport.Code;
}
}
}
最后是在主角坐标改变事件中判断是否踩到了场景的传送点坐标进而触发传送:
/// 主角坐标改变时触发场景相反移动以实现镜头跟随效果
/// </summary>
void hero_CoordinateChanged(Sprite sprite, DependencyPropertyChangedEventArgs e) {
//进行场景相对偏移
scene.RelativeOffsetTo(sprite.Coordinate);
......
//判断是否采到传送点
Teleport tempTeleport = scene.InTeleport(sprite.Coordinate);
if (tempTeleport != null) {
teleport = tempTeleport;
scene.Code = teleport.ToScene;
}
}
其中的InTeleport方法如下:
/// 是否在传送点内
/// </summary>
/// <param name="p">目标点(游戏坐标系)</param>
/// <returns>所处传送点</returns>
public Teleport InTeleport(Point p) {
if (TerrainMatrix == null) { return null; }
int code = TerrainMatrix[(byte)p.X, (byte)p.Y];
if (code >= 10) {
return teleports.Single(X => X.Code == code);
} else {
return null;
}
}
这里我硬性的规定在场景的地形数组TerrainMatrix中只要是>=10的都被用做传送点,该数字对应传送点的Code属性。补充说明一下,这样的方式对于A*寻路会有一定影响,我们可添加一个新的名为teleportMatrix的传送矩阵来保存这些传送点,独立于地形数组,也不必强迫Code值从10开始,当然这就意味着场景类中需要多维护一个与TerrainMatrix一样维度的矩阵,综合利弊,在下一节中我将改用方式。
到此我们就完成了场景的传送功能,以0号场景为例,它包含这样的传送点信息:
<Teleport Code="10" ToScene="1" ToX="15" ToY="30" ToDirection="3" Terrain="84_39,85_39,86_39,86_38,85_38,84_38"/>
那么它将意味着只要主角走到(84,39)、(85,39)、(86,39)、(86,38)、(85,38)、(84,38)这6个坐标(它们对应场景中TerrainMatrix的值均为Code,比如TerrainMatrix[84,39]=10)中任意一个时,都会被传送到1号场景的(15,30)坐标,朝向东南。
离完美的传送效果似乎还有一定距离,遗漏了些什么?
是的,我们仅仅是从逻辑上实现了相应功能,我们还缺少一个传送装置,缺少传送时那华丽的光环萦绕一身的效果。
同样都是动画的表现形式,那么首先我们还得从创建动画控件出发:
/// 动画控件
/// </summary>
public class Animation : Canvas {
#region 属性
#region 动态
#region 封装代号逻辑
int _Code = -1;
/// <summary>
/// 获取或设置代号
/// </summary>
public int Code {
get { return _Code; }
set {
if (_Code != value) {
_Code = value;
Downloader configDownloader = new Downloader() { TargetCode = value };
configDownloader.Completed += new EventHandler<DownloaderEventArgs>(configDownloader_Completed);
configDownloader.Download(Global.WebPath(string.Format("Animation/{0}/Info.xml", value)));
}
}
}
/// <summary>
/// 配置文件下载完毕
/// </summary>
void configDownloader_Completed(object sender, DownloaderEventArgs e) {
Downloader configDownloader = sender as Downloader;
configDownloader.Completed -= configDownloader_Completed;
int code = configDownloader.TargetCode;
string key = string.Format("Animation{0}", code);
if (e.Stream != null) { Global.ResInfos.Add(key, XElement.Load(e.Stream)); }
//通过LINQ2XML解析配置文件
XElement xAnimation = Global.ResInfos[key].DescendantsAndSelf("Animation").Single();
//加载动画参数
FullName = xAnimation.Attribute("FullName").Value;
Center = new Point((double)xAnimation.Attribute("CenterX"), (double)xAnimation.Attribute("CenterY"));
frameNum = (int)xAnimation.Attribute("FrameNum");
dispatcherTimer.Interval = TimeSpan.FromMilliseconds((int)xAnimation.Attribute("Interval"));
format = Global.GetFileFormat((FileFormat)((int)xAnimation.Attribute("Format")));
Kind = (AnimationKind)(int)xAnimation.Attribute("Kind");
//解析各帧偏移
IEnumerable<XElement> iFrame = Global.ResInfos[key].Elements();
frameOffset = new Point2D[iFrame.Count()];
foreach (XElement element in iFrame) {
frameOffset[(int)element.Attribute("ID")] = new Point2D() {
X = (int)element.Attribute("OffsetX"),
Y = (int)element.Attribute("OffsetY"),
};
}
Coordinate = new Point(Coordinate.X + 0.000001, Coordinate.Y);
dispatcherTimer.Start();
}
#endregion
/// <summary>
/// 获取或设置坐标(关联属性,又称:依赖属性)
/// </summary>
public Point Coordinate {
get { return (Point)GetValue(CoordinateProperty); }
set { SetValue(CoordinateProperty, value); }
}
public static readonly DependencyProperty CoordinateProperty = DependencyProperty.Register(
"Coordinate",
typeof(Point),
typeof(Animation),
new PropertyMetadata(ChangeCoordinateProperty)
);
static void ChangeCoordinateProperty(DependencyObject d, DependencyPropertyChangedEventArgs e) {
Animation animation = d as Animation;
Point p = (Point)e.NewValue;
Canvas.SetLeft(animation, p.X - animation.Center.X);
Canvas.SetTop(animation, p.Y - animation.Center.Y);
}
/// <summary>
/// 设置提示内容
/// </summary>
public string Tip {
set {
if (value != "") {
ToolTipService.SetToolTip(this, value);
}
}
}
#endregion
/// <summary>
/// 获取或设置名称
/// </summary>
public string FullName { get; set; }
/// <summary>
/// 获取或设置类型
/// </summary>
public AnimationKind Kind { get; set; }
/// <summary>
/// 获取或设置Z层次深度
/// </summary>
public int Z {
get { return Canvas.GetZIndex(this); }
set { Canvas.SetZIndex(this, value); }
}
/// <summary>
/// 获取或设置中心
/// </summary>
public Point Center { get; set; }
#endregion
#region 事件
/// <summary>
/// 仅当Kind == AnimationKinds.OnceToDispose时触发
/// </summary>
public event EventHandler Disposed;
#endregion
#region 构造
int currentFrame, frameNum;
Point2D[] frameOffset;
string format = string.Empty;
Image body = new Image();
DispatcherTimer dispatcherTimer = new DispatcherTimer();
public Animation() {
//ObjectTracker.Track(this);
this.CacheMode = new BitmapCache();
this.Children.Add(body);
dispatcherTimer.Tick += new EventHandler(dispatcherTimer_Tick);
}
#endregion
#region 方法
void dispatcherTimer_Tick(object sender, EventArgs e) {
if (currentFrame == frameNum) {
switch (Kind) {
case AnimationKind.Once:
currentFrame = 0;
dispatcherTimer.Stop();
break;
case AnimationKind.OnceToDispose:
Dispose();
if (Disposed != null) { Disposed(this, new EventArgs()); }
return;
case AnimationKind.Loop:
currentFrame = 0;
break;
}
}
body.Source = Global.GetWebImage(string.Format(@"Animation/{0}/{1}{2}", Code, currentFrame, format));
Canvas.SetLeft(body, frameOffset[currentFrame].X);
Canvas.SetTop(body, frameOffset[currentFrame].Y);
currentFrame++;
}
/// <summary>
/// 销毁
/// </summary>
public void Dispose() {
dispatcherTimer.Stop();
dispatcherTimer.Tick -= dispatcherTimer_Tick;
}
#endregion
}
虽然动画控件的原理与精灵切图类似,在此之上新引入了基于具体帧的偏移(从而不再需要每张图都同样尺寸节省资源空间),比如以0号动画为例,它的配置如下:
<Animation FullName="传送点" CenterX="66" CenterY="38" FrameNum="7" Interval="160" Format="1" Kind="2">
<Frame ID="0" OffsetX="0" OffsetY="0" />
<Frame ID="1" OffsetX="0" OffsetY="0" />
<Frame ID="2" OffsetX="0" OffsetY="0" />
<Frame ID="3" OffsetX="0" OffsetY="0" />
<Frame ID="4" OffsetX="0" OffsetY="0" />
<Frame ID="5" OffsetX="0" OffsetY="0" />
<Frame ID="6" OffsetX="0" OffsetY="0" />
</Animation>
另外赋予动画控件的三种常用模式以满足可能的需求:
/// 动画类型
/// </summary>
public enum AnimationKind {
/// <summary>
/// 仅播放一次后回到第一帧静止
/// </summary>
Once = 0,
/// <summary>
/// 播放一次结束后自动移除
/// </summary>
OnceToDispose = 1,
/// <summary>
/// 一直循环播放
/// </summary>
Loop = 2,
}
动画每播放到结束帧时通过判断是哪种模式进而触发相应逻辑:
if (currentFrame == frameNum) {
switch (Kind) {
case AnimationKind.Once:
currentFrame = 0;
dispatcherTimer.Stop();
break;
case AnimationKind.OnceToDispose:
Dispose();
if (Disposed != null) { Disposed(this, new EventArgs()); }
return;
case AnimationKind.Loop:
currentFrame = 0;
break;
}
}
body.Source = Global.GetWebImage(string.Format(@"Animation/{0}/{1}{2}", Code, currentFrame, format));
Canvas.SetLeft(body, frameOffset[currentFrame].X);
Canvas.SetTop(body, frameOffset[currentFrame].Y);
currentFrame++;
}
/// <summary>
/// 销毁
/// </summary>
public void Dispose() {
dispatcherTimer.Stop();
dispatcherTimer.Tick -= dispatcherTimer_Tick;
}
另外大家是否有注意到如果为动画控件赋了Tip值,那么动画将会附加ToolTip提示效果,配合上前面的3种模式,该动画控件能适用的范围更加广泛,且能以此为基类继续向下衍生出比如魔法、装饰等控件。
本课小结:本节我向大家讲解了如何实现游戏中场景切换(传送)及动画效果。这也是对游戏框架整体合理性的一次综合考验,在合理封装的游戏设计规范下,仅仅需要改动丁点的代码即可完成复杂的游戏功能拓展,这也是C#开发Silverlight-MMORPG网页游戏给我们所带来的面向对象高效率开发模式所赋予的益处。
本课源码:点击进入目录下载
参考资料:中游在线[WOWO世界] 之 Silverlight C# 游戏开发:游戏开发技术
教程Demo在线演示地址:http://silverfuture.cn