P.T.M.C.:景观与矿井





0/5 (0投票)
受《Descent》启发的2D射击游戏 - 旋转屏幕,隧道网络,多种机器人和武器 - 地图编辑器
数据文件 - 从我的GoogleDrive下载数据文件并解压到C:\根目录
更新:2019/10/12 - 源代码 - 有bug的编辑器稍有改善
P.T.M.C.
P.T.M.C. 是360°游戏系列《Descent》的一部分,该系列将你置身于一艘装备精良的PyroGX飞船内,能够沿着任意三个旋转轴进行旋转。这是一款令人兴奋的游戏……但我经常会考虑这款游戏中的一些“如果”。特别是,如果Descent的游戏世界是由太阳系组成的,你可以选择自由探索。在矿井、卫星、行星之间飞行?如果它是一个大规模多人游戏宇宙?如果你必须像《帝国时代》那样建造和保卫你的资源,同时还能随时选择第一人称射击模式来控制棋盘上的任何单位?买卖、交易或偷窃?扮演商人、雇佣兵、海盗或军阀?
好吧,你可能会问,这里提供的是这个吗?
不……很遗憾。这对我来说太庞大的项目了。
P.T.M.C.是我近十年前发布的但从未完成的先前项目《Mars Mission》的重制版。看看那些文章,它们仍然很酷。火星任务的概念很简单,构建一个太阳系的2D模拟模型,让玩家可以自由探索。它有大气化合物形式的资源,必须收集并转化为燃料。所有已知的行星和卫星都可以探索,并有随机生成但只有一条路径的隧道。你控制着宇航员,他们四处走动,进行舱外活动并驾驶你的飞船。我曾计划过矿产资源、卫星通信以及最终与敌对机器人进行的战斗游戏,但在我完成它之前,我被电脑“隔离”了大约七年,当我回来时,我决定重新开始编写。
探索太阳系的新鲜感很有趣,但很快就会消失,因为没有明确的目标,除了避免坠毁或燃料耗尽之外,对玩家来说没有真正的挑战。
这次,它首先是一款战斗游戏……
所以,我们开始吧……
背景
我在四月份开始着手P.T.M.C.,在那之前我刚结束了《Latin Crossword Puzzle》的项目。为此工作了几个月,然后花了一些时间抓取Merriam-Webster的字典并发布了该项目。所以,在这个P.T.M.C.的项目阶段,我感觉我已经做了很多,但还有更多工作要做,我正处在一个十字路口,我可以停下来(简要地)告诉你们我到目前为止所做的一切,然后继续下一阶段的游戏开发。
我等了很久才发布这个……它仍在进行中,但有太多东西要讲,我无法在一篇文章中讲完,也不会像有些人可能希望的那样详细解释。如果您希望我澄清或扩展任何内容,只需在底部给我留言,我会尽力而为。
让我们开始吧……
景观
“景观”是游戏世界,玩家在其中绕着行星或卫星的表面飞行,而不是绕着行星轨道飞行或从一颗行星飞往它的卫星。为了与《Descent》保持一致,玩家的飞船不受重力影响。这使得玩家有更多的机动性,并且可以解释为先进的自动飞船控制的好处,让玩家不必担心在激烈的战斗中降低高度。与《Mars Mission》一样,有多种飞船类型,其中一些飞船比其他飞船更容易受到伤害,但换来了其他能力,而当前版本尚未探索这一点。
你可以看上面的图片,对地球表面在游戏中的样子有一个大概的了解。左上角接近的方框区域是你的一个辅助屏幕,称为Left_HUD
,这里显示的是飞船的后视镜。在这张图片的底部中心,你可以更好地看到你的飞船,这样你就有足够的空间向前看。云是地标的例子,它们是动画精灵,绘制在玩家/机器人飞船之前或之后,使其成为前景或背景。下面列出的枚举类型可以给你一个想法
public enum enuLandmarkType
{
CautionRadiation,
Clouds00,
Clouds01,
DangerLookOut,
ElectricalHazardSign,
FanBlack,
GrillEnd,
Lava,
LavaEnd,
WaterFlow,
ConveyorBelt_Rocks,
BiohazardSymbol,
_num
};
这些只是我目前包含的。它们是精灵,所以它们依赖于我在我的SpriteEditor文章中提到的classSprite
。所有飞船、爆炸、UI闪烁和导弹都是精灵。
这是一个正在进行中的项目,所以它仍然存在一些问题,比如绘制了不应该绘制的、位于表面下方的物体,而玩家却在表面上方,就像上面图片中玩家能量HUD显示左侧洞穴下方绘制的机器人孵化器一样,但它已经足够稳定,可以玩和编辑地图了。我一直推迟写这篇文章的原因是总有别的事情要做……而且很可能一直都会这样。所以现在你只能忍受这个混乱了。
当你启动游戏时,你从矿井底部开始,需要一路战斗出去。地球的地下矿井结构并不复杂,可以单独游玩,作为最终产品的预览。你的飞船在你四处飞行时将停留在屏幕底部。默认情况下,鼠标是主要界面,飞船周围的环形是为了帮助你移动,通过将飞船滑动到鼠标方向,速度与鼠标与飞船中心之间的距离成比例(较小的内环是零速度)。使用滚轮旋转,鼠标按钮射击。
上下文菜单提供了许多选项,供你探索。
你可以关闭鼠标控制,使用键盘(我的偏好),其中
其余的键盘控制可以在formPTMC
的文本框事件处理程序中看到。(A和Z用于放大/缩小)
private void TxtInput_KeyDown(object sender, KeyEventArgs e)
{
switch (e.KeyCode)
{
#region thrusters
case Keys.NumPad7:
eKeyLastPressed = enuKeyStrokes.Turn_Left_Hard;
break;
case Keys.NumPad9:
eKeyLastPressed = enuKeyStrokes.Turn_Right_Hard;
break;
case Keys.NumPad4:
eKeyLastPressed = enuKeyStrokes.Turn_Left;
break;
txtInput
文本框在picMain
的keyUp
事件处理程序的开始时获得焦点,有时会失去焦点,导致键盘无用,直到屏幕被鼠标点击。当用户使用鼠标或通过alt-tab切换到另一个窗口,然后点击游戏但不是屏幕时,就会发生这种情况。我正在处理这个问题。
滚动的山丘的定义方式与我在前面提到的《火星任务》项目中使用的非常相似。在X轴上有等距的“单元格”或“柱子”,它们决定了在那个点上的地面高度。将它们集合起来就构成了景观。每个行星或卫星都有自己的景观,它在classLandscape
中定义。
碰撞检测
碰撞检测一直是制作视频游戏的首要考虑因素。火星任务的碰撞检测和P.T.M.C.的碰撞检测之间的相似性非常显著。
它们本质上非常相似
- 测量物体移动的单元格范围
- 将这些单元格(柱子之间的空间)的AABB(轴对齐最小边界框)与边界飞船运动的框(P.T.M.C.新增)进行比较
- 当这两个AABB发生碰撞时,使用与火星任务类似的逻辑进行更精细的文本检测
- 正在测试碰撞的景观线会绕着飞船的起始位置旋转,以执行与飞船沿正X轴运动的碰撞的数学测试
- 形成一个表示飞船将要行驶的区域的矩形(宽度=行驶距离 & 高度=飞船“球形”测试区域的直径)
- 测试检测线是否穿透了飞船运动的矩形
- 当发生这种情况时 - 计算飞船的偏转和预期的最终位置
- 然后它使用当前碰撞点作为新的起点和新的终点位置,并将这些数据再次通过算法运行,在有能量的情况下弹开墙壁,给你一个最终的终点位置和速度
由于算法需要测试所有墙壁才能决定飞船会先撞到哪面墙(如果有的话),它会列出所有碰撞,根据行驶距离重新排序,然后再次通过算法运行。
碰撞检测是整个项目的核心,花费了大约两个月时间才在表面上正确实现。其中大部分代码都可以转移到洞穴内的相同概念上。由于所有移动的物体都需要进行碰撞测试,碰撞检测是在classObject
中执行的,它保存着屏幕上几乎所有物体的当前位置和速度变量。按下escape键会将游戏置于碰撞检测测试模式(我还没有移除这个调试工具)。在这里,你可以看到隧道段的ID号以及飞船的最终位置,如果它能一步从当前位置飞到鼠标光标。你可以从这个短视频中看到它是如何蜿蜒穿过隧道的。在测试过程中,有时碰撞检测系统未能检测到应有的碰撞,这本可以改进,但在游戏时间内的步长永远不会像演示中任何但最短的测试那样大,因此那些失败的情况会被随后的测试捕捉到,将飞船推回游戏中。结果是,玩游戏不会导致灾难性的CD故障。在测试CD时点击屏幕会将飞船放置在你点击的任何位置,而不管是否检测到碰撞,并且由于CD调试工具只在MouseMove
事件触发时测试新的碰撞,改变缩放或旋转地图不会导致CD测试执行,从而“似乎”未能检测到碰撞。只需稍微移动鼠标,就会执行测试。
//
public bool DetectCollision(ref List<classMoveData> lstMoveData)
//
当玩家接近PowerUp或导弹击中目标时,会采用不同的方法。
在classMissile
中完成导弹是否击中目标或未击中目标的检测。
classLandscape
有一个classQuadTree_PTMC
,我从中改编了我为早期项目编写的四叉树。它通过在每个时钟周期的开始刷新内容来跟踪所有对象的位置。这肯定可以改进。目前它可以正常工作,因为玩家一次只在一个行星表面上,但当卫星允许玩家跟踪不同卫星上的其他飞船时,为每个行星或卫星重建整个QuadTrees
所消耗的时间可能会变得相当可观……那是以后再说。
因此,当导弹飞行时,它需要测试除了洞穴墙壁或行星表面之外的碰撞。为此,它查看QuadTree
并测试每个对象在笛卡尔(x,y)领域和隧道中是否位于同一区域(因为隧道网络会重叠,但不一定共享同一游戏空间)。
这在classMissile
的函数中完成
//
public classObject Detect_Collisions_Objects(ref PointF ptfCollision)
{
...
}
//
//
bool DetectCollision_Object_FineTest(ref classObject cObjAlt, ref PointF ptfCollision)
{
...
}
//
游戏计时器调用formPTMC
中的以下函数。
void detectMissileCollisions()
{
for (int intMissileCounter = 0;
intMissileCounter < cLandscape.lstMissiles.Count; intMissileCounter++)
{
classMissile cMissile = cLandscape.lstMissiles[intMissileCounter];
PointF ptfCollision = new PointF();
classObject cObjectCollidedWith =
cMissile.Detect_Collisions_Objects(ref ptfCollision);
if (cObjectCollidedWith != null)
{
classMath3.classRadialCoordinate cRadMissileMotion
= new classMath3.classRadialCoordinate
(cMissile.cObj.ptfLocation_Old, cMissile.cObj.ptfLocation);
cObjectCollidedWith.Impact(ptfCollision,
cRadMissileMotion,
cRadMissileMotion.Magnitude *
cMissile.cObj.dblMass / cObjectCollidedWith.dblMass);
classMath3.classMinMax cMM = new classMath3.classMinMax(10, 25);
classImpactAnimation.Launch(ref cLandscape,
ref cMissile.cObj.cTS_New,
cMissile.cObj.ptfLocation,
cMath.AddTwoPointFs(cObjectCollidedWith.ptfVelocity,
cRND.Get_Point(cMM, cMM)));
cMissile.Die();
}
}
}
它会扫描当前景观的所有导弹并进行碰撞测试。
当导弹与某物碰撞时,它会返回一个指向它击中的对象的指针,并将碰撞点设置为它作为参数接收的引用变量。有了这些信息,就会发生两件事。
- 被击中的对象在
cObjectCollideWith.Impact()
调用中被碰撞冲击 - 在碰撞点会在屏幕上播放“撞击”动画
你可以在下面的代码中看到碰撞点和导弹的运动信息是如何用来冲击被撞击的对象。
public void Impact(PointF ptfContact, classMath3.classRadialCoordinate cRadMissileMotion,
double dblForce)
{
classMath3.classRadialCoordinate cRadContact =
new classMath3.classRadialCoordinate(ptfLocation, ptfContact);
cRadContact.Radians -= cRadMissileMotion.Radians;
PointF ptfContactRotated = cRadContact.toPointF();
double dblSum_XY =1+Math.Abs( ptfContactRotated.X) +Math.Abs( ptfContactRotated.Y);
double dblPC_Move = Math.Abs(ptfContactRotated.X / dblSum_XY);
double dblPC_Rotate = Math.Abs(ptfContactRotated.Y / dblSum_XY);
int intSignRotate = 1;
if (ptfContactRotated.Y < 0)
{ // rotate clockwise (on screen)
intSignRotate = 1;
}
else
{ // rotate counter-clockwise
intSignRotate = -1;
}
// determine unit-vector of missile's velocity
classMath3.classRadialCoordinate cRadUnitVectorMissileVelocity =
new classMath3.classRadialCoordinate(cRadMissileMotion.Radians, 1);
PointF ptfUnitVectorMissileVelocity = cRadUnitVectorMissileVelocity.toPointF();
ptfVelocity.X += (float)(ptfUnitVectorMissileVelocity.X * dblForce * dblPC_Move);
ptfVelocity.Y += (float)(ptfUnitVectorMissileVelocity.Y * dblForce * dblPC_Move);
dblAngle += (float)(intSignRotate * (Math.PI / 8.0) * dblPC_Rotate);
}
它查看碰撞点相对于对象中心的位置。
然后它对该向量的X和Y分量的绝对值求和。然后使用此总和来评估推力(碰撞后作用在对象上的移动力)以及施加在该对象上的旋转量。通过将碰撞点旋转到与导弹运动方向对齐,旋转系统的碰撞点Y分量的符号决定了结果旋转角度的符号。
隧道
那么,我们开始吧。
《火星任务》中的隧道是简单的单端隧道,它们开口在地面上,没有分支。它们蜿蜒曲折,但只有一个入口和一个出口,仅此而已。这里,你有一个隧道网络。好多了。
这是游戏中地图编辑器的一个屏幕截图,该游戏空间在本文顶部视频PTMC_Terra_Start.mp4中可以看到。
你可以在橙色圆圈内的区域看到,两个隧道共享相同的笛卡尔空间,但在游戏中是两个独立且不同的位置。
地图编辑器允许你为游戏创建新地图(鉴于最近在土星发现了大约二十颗新卫星,看来我永远追不上为我们太阳系所有行星和卫星设计游戏地图的速度)。创建方法是将我称为骨干设计的独立隧道段连接在一起。在这里和那里放置骨干隧道段,然后将它们连接起来,你就得到了一个骨干。编辑器会自动填充它们之间的空白,沿着你添加的初始隧道段之间的连接线。在下面的屏幕截图中,你可以看到同一地图区域的骨干。
你可以在YouTube上观看一个简短的5分钟视频,展示创建新地图的速度有多快YouTube。该编辑器*大部分*是稳定的。我的意思是,*请经常保存*,否则你可能会后悔。
编辑器本身是一个持续的努力,我估计我将继续开发它,因为我需要为目前已知的太阳系中的201颗行星和卫星设计地图……这意味着我必须设计尽可能多的地图(除了气态巨行星土星和木星)。这是需要完成的繁重工作。
炮塔
由于要讲的东西太多,而且我认为我甚至不会试图覆盖所有内容,我将以介绍我最近添加的东西:炮塔来结束。游戏《Descent》没有炮塔,但它有一个“Gun Boy”,它被描述为“一种便携式激光炮塔。放下后,它会瞄准附近的敌人并发射。”这种便携式炮塔装在你的飞船货舱里,只有在隧道里的某个地方放下时才使用。P.T.M.C.中的炮塔要精炼得多。
- 有多种类型。
- 每种都有自己的装甲
- 弹药/能量可以在玩家的飞船和炮塔之间转移
- 玩家可以设置炮塔将要发射的武器类型
- 炮塔可以被拾起并安装在飞船的背板上
- 炮塔有阵营,可以通过船员电脑黑客技能进行转换(船员尚未实现)。
- 炮塔的视角可以显示在玩家的屏幕上。
这个YouTube视频简要演示了如何使用炮塔。大多数控件都在上下文菜单UI中,并且相当直接。狙击手控件也很基础:点击即可。你从上下文菜单中选择狙击手选项,将你的准星移近你想要瞄准的位置,然后点击屏幕。完成此操作后,你将看到目标放大视图,可用于更精确地瞄准。如果你认为你有机会射击,再次按下鼠标按钮将射击炮塔。
抬头显示:HUD
屏幕上有几个HUD,你可以用它们来查看不同的来源,包括炮塔的视角。要设置你的HUD,你需要通过上下文菜单中的HUD选项,并将左/右HUD设置为显示你选择的视角。每个HUD的尺寸都在classLandscape中定义。classLandscape
负责根据位置、缩放和角度通过Draw()
函数将屏幕绘制到位图。由于辅助HUD必须绘制在主屏幕HUD之上,因此当Draw()
被要求绘制主HUD时,它们是递归绘制的。该函数根据适用于该视图的参数输出左HUD图像,然后leftHUD
图像绘制在主HUD之上,然后返回最终发送到屏幕的图像。
有两个函数对于将游戏世界渲染到屏幕至关重要
第一个函数获取游戏世界中的一个点并计算其在屏幕上的位置。
public PointF Get_PointFInGameWorld
(PointF ptfScreen, ref classLandscapeDrawParameters cDrawParameters)
{
classMath3.classRadialCoordinate cRad_RelToCenter =
new classMath3.classRadialCoordinate(cDrawParameters.ptOutputCenter, ptfScreen);
cRad_RelToCenter.Radians += cDrawParameters.dblFocusAngle + Math.PI / 2;
PointF ptRelToFocus = cRad_RelToCenter.toPointF();
PointF ptRetVal = new PointF((cDrawParameters.ptfCenterScreenInGameWorld.X
+ intX_MaxGameWorld
+ (int)(ptRelToFocus.X / cDrawParameters.dblGameToScreen_Ratio)) %
intX_MaxGameWorld, cDrawParameters.ptfCenterScreenInGameWorld.Y
+ (int)(ptRelToFocus.Y / cDrawParameters.dblGameToScreen_Ratio));
return ptRetVal;
}
另一个函数获取屏幕中的一个点并找到其等效的游戏世界位置。
public PointF _Get_PointFOnScreen
(PointF ptGameWorld, ref classLandscapeDrawParameters cDrawParameters)
{
double dblDrawRotation = -cDrawParameters.dblFocusAngle - Math.PI / 2;
int intCenterTunnelSegment = (int)Math.Floor((double)
cDrawParameters.ptfCenterScreenInGameWorld.X / (double)conSurfaceCellWidth);
int intThisTunnelSegment = (int)Math.Floor((double)ptGameWorld.X /
(double)conSurfaceCellWidth);
int intDifference = (int)Math.Abs(intCenterTunnelSegment - intThisTunnelSegment);
if (intDifference > (lstSurface.Count / 2)) // assumes player cannot zoom-out
// further than view full landscape
{ // the seam is between these two PointFs
if (intCenterTunnelSegment > intThisTunnelSegment)
{
PointF ptUnrotated = new PointF((int)((intX_MaxGameWorld -
cDrawParameters.ptfCenterScreenInGameWorld.X + ptGameWorld.X) *
cDrawParameters.dblGameToScreen_Ratio),
(int)((ptGameWorld.Y - cDrawParameters.ptfCenterScreenInGameWorld.Y) *
cDrawParameters.dblGameToScreen_Ratio));
classMath3.classRadialCoordinate cRad =
new classMath3.classRadialCoordinate(ptUnrotated);
cRad.Radians += dblDrawRotation;
PointF ptRetVal = cMath.AddTwoPointFs
(cDrawParameters.ptOutputCenter, cRad.toPointF());
return ptRetVal;
}
else
{
PointF ptUnrotated = new PointF(-(int)
((cDrawParameters.ptfCenterScreenInGameWorld.X + intX_MaxGameWorld -
ptGameWorld.X) * cDrawParameters.dblGameToScreen_Ratio),
(int)((ptGameWorld.Y - cDrawParameters.ptfCenterScreenInGameWorld.Y) *
cDrawParameters.dblGameToScreen_Ratio));
classMath3.classRadialCoordinate cRad =
new classMath3.classRadialCoordinate(ptUnrotated);
cRad.Radians += dblDrawRotation;
PointF ptRetVal = cMath.AddTwoPointFs
(cDrawParameters.ptOutputCenter, cRad.toPointF());
return ptRetVal;
}
}
else if (intDifference == lstSurface.Count / 2)
{ // for-loop from 0 to < max ; 0 & max are special
int intX_MaxGameWorld = conSurfaceCellWidth * lstSurface.Count;
PointF ptUnrotated = new PointF((int)(((cDrawParameters.ptfCenterScreenInGameWorld.X >
ptGameWorld.X
? -intX_MaxGameWorld
: 0)
+ cDrawParameters.ptfCenterScreenInGameWorld.X - ptGameWorld.X) *
cDrawParameters.dblGameToScreen_Ratio),
(int)((ptGameWorld.Y -
cDrawParameters.ptfCenterScreenInGameWorld.Y) *
cDrawParameters.dblGameToScreen_Ratio));
classMath3.classRadialCoordinate cRad =
new classMath3.classRadialCoordinate(ptUnrotated);
cRad.Radians += dblDrawRotation;
PointF ptRetVal = cMath.AddTwoPointFs
(cDrawParameters.ptOutputCenter, cRad.toPointF());
return ptRetVal;
}
else
{ // seam is Not BETWEEN these two PointFs
PointF ptUnrotated =
new PointF((int)(-(cDrawParameters.ptfCenterScreenInGameWorld.X -
ptGameWorld.X) * cDrawParameters.dblGameToScreen_Ratio),
(int)((ptGameWorld.Y - cDrawParameters.ptfCenterScreenInGameWorld.Y) *
cDrawParameters.dblGameToScreen_Ratio));
classMath3.classRadialCoordinate cRad =
new classMath3.classRadialCoordinate(ptUnrotated);
cRad.Radians += dblDrawRotation;
PointF ptRetVal =
cMath.AddTwoPointFs(cDrawParameters.ptOutputCenter, cRad.toPointF());
return ptRetVal;
}
}
由于游戏世界比屏幕上显示的内容大得多,屏幕的角落被用来计算可见的游戏世界的角落。但玩家可以将飞船旋转到任何角度,这意味着屏幕的矩形很少能与水平矩形游戏世界对齐。因此,以屏幕的对角线作为基本测量,并使用略大于屏幕对角线的正方形游戏世界区域来搜索quadtree
。由于每个行星或卫星的表面都会环绕其主体,定义它的数据需要首尾相连。数据的低端(索引零)与数据的高端(索引=数组大小)相邻,但四叉树只定义为一个简单的矩形。因此,每当必须绘制地图两端之间的“接缝”时,就会要求quadtree
返回在四叉树矩形空间的相对两端的数据搜索结果,这两次搜索共同构成了接缝两侧的数据。
绘图算法本身必须考虑每个点并
- 将这些对象汇总
- 获取它们相对于
Draw()
函数焦点的位置 - 将笛卡尔向量转换为极坐标向量
- 将极坐标旋转到所需的输出角度
- 调整其幅度以反映输出缩放因子
- 将其转换回笛卡尔坐标
- 将其添加到焦点位置的屏幕位置
- 选择适合缩放和角度的精灵角度和大小
- 将其以步骤#7中确定的屏幕位置为中心,我们就完成了……
你可以在YouTube上观看一个简短的演示,展示如何设置HUD显示YouTube。
静态
由于通信是我打算让玩家努力争取的,因此我实现了一种简单的方法,可以在HUD图像上添加静态效果,以反映图像接收质量。游戏资源中有七个静态蒙版。这些蒙版是通过使用我从某个网站下载的通用静态图像生成的。然后,将每个像素的RBG值通过一个公共值向零调整。改变后为负的值设置为零。然后将所有这些值增加16
。每个蒙版的值最初都比前一个蒙版的值大,这样就有更多的暗像素被强制变为零,然后设置为16
。在使用这些蒙版之前,颜色(16, 16, 16
)会被设为透明。结果是七个静态蒙版,具有逐渐增多的透明孔,显示原始图像。静态0的孔最少,对视线影响最大,而静态7只有孔,对视线影响最小。为了确保它看起来更像静态而不是一个“静态”(我一定是累了)不变的屏幕,静态蒙版会在随机位置采样,然后再绘制到它们所干扰的图像上,这使其具有动画感。
目前,静态效果只是用户可以设置的东西,但通信和黑客技能很快就会发挥作用,使失真的视觉成为游戏不可或缺的一部分。
结束语
不,这还不是全部。我们才刚刚开始……
历史
- 2019年10月11日:初始版本