我决定在游戏实际完成之前提早写这篇文章,因为我可能会稍后忘记一些细节。因此,这是第1部分,因为游戏完全提交后可能会进行后续。
对于上下文,这是为JS13K游戏创建的游戏(https://js13kgames.com/)。果酱从8月13日至9月13日运行。我们距离截止日期几个星期。由于我目前正在忙于其他任务,所以我正在藏着我的工作,然后在9月13日提交之前,我还要花几天的时间进行工作。
今年的主题是:
13世纪
我开始于8月19日左右(我不记得确切地记得,但这是我最古老的文件的年龄)。我只需要练习JavaScript采访,然后想:嘿,有什么比加入游戏果酱更好的训练JavaScript面试的方法,而挑战是使整个游戏适合13k!
原来我错了,最好只是练习解决leetcode问题。
但是,无论如何,要开始,我需要找到一些灵感。您在13世纪发现了什么?中世纪的城堡,农民,骑士...当我不断地从阿比亚奇2中挖掘想法时,我偶然发现了成吉思汗。这让我想起了我喜欢使用Keshik in Civ5的方式。因此,这是决定的,我会做一个有关这些的游戏。
早期原型
我已经想到了一个由大汗(Great Khan)领导的蒙古平原上的马弓箭手。因此,我从原型开始这种体验开始,只需跟随主要角色。
early prototype看起来很糟糕,特别是以下方面。如果我让追随者直接朝着领导者融合,他们会汇聚在一起,对马来说看起来很奇怪。如果我尝试引入马匹之间的碰撞检测,我不知道我是否可以在不到13k的情况下进行编码并保持效率。我把这个想法藏在一边,基本上评论了整个部分,并探索了游戏的新方面。
我仍在决定如何展示这匹马,并且仍在争论我是否应该让马表情符号跳来跳去,以节省图形上的空间。这将是我的选择:ðð。我做到了吗,游戏的心情会完全不同!
,但我真正想要的似乎不可能适合13KB:
一匹完全动画的马穿过平原。
,但无论如何都尝试了。从1878年起,有一个可追溯到1878年的horse in motion experiment,非常适合用作模型。考虑到这一点,我在矢量图形编辑器上工作,该编辑器可以让我追踪马精灵片。
事实证明,从事该工具的工作是完全自由的,而不必担心将其适合13k。我知道这只是用于资产的创造,但我仍然试图使享受艺术制作过程看起来非常好。该工具结果很好,很容易绘制形状:只需拖动角落,然后从侧面拖动以创建一个新的角落,然后将其放回擦除该角落。在底部,我能够看到并选择每个形状的每个帧,并以不同的方式标记它们,以便我可以将它们归因于不同的精灵。
花一些时间在压缩上
因此,我想出了一种很好的方法来压缩矢量图形的数据,并花了很多时间对其进行调整。事实证明,通过将初始JSON压缩为拉链,我几乎可以节省很多。但是,嘿,这是游戏的一部分,所以我仍然要谈论它!
要存储所有图像,我们只需要命令来绘制路径和分离器之间的分离器。
- 首先,我们降低了每个点的精度。基本上,将所有坐标除以10和圆形。这似乎是我可以降低质量并仍然保持不错动画的极限。
- 我们从[0,0]开始。
- 每个命令存储从一个点到下一个命令。因此,[1,1]表示移动到x = 1,y = 1。然后[2,1]之后意味着从x = 1,y = 1到x = 3,y = 2。
- 每个点的大小是一个字节,这意味着每个x,y值的范围为16个值,从-8到7。为了绘制一条长线,将使用多个小线。 >
- 一个不移动笔的命令是一个分离器,表示将笔向上或向上或切换到新形状。
从那里,我设法制作了一个非常紧凑的文件,约为3KB,当压缩时,该文件大约要小5%,因此毕竟没有那么有用。但是我认为它仍然击败了几个字节的原始json。
好吧,一切都很好,但是我还没有游戏,所以来了...
游戏玩法
我正在争论是否将游戏变成侧面滚动射击游戏或冒险游戏。我认为制作与Magic Survival相似的东西是更简单的。
首先,从马运动开始,运动必须展示一些动力,以使它真正感觉就像您在骑马一样。为此,我使用以下过程:
- 键盘控制加速度(AX,AY)。
- 动作(DX,DY)会因加速度而改变,但随后使用制动器封盖。
const da = Math.sqrt(ax*ax + ay * ay);
sprite.dx = (sprite.dx + ax / da * speed) * brake;
sprite.dy = (sprite.dy + ay / da * speed / 2) * brake;
请注意,ax
和ay
除以DA(加速度向量的长度),以避免马比水平的对角线更快。
接下来,我们希望这匹马以现场为中心,所以我们需要让现场跟随马。
sh[0] += hero.dt / 20
* (hero.x - canvas.width/2 - sh[0] + headStart * 80 )
* .1;
sh[1] += hero.dt / 20
* (hero.y - canvas.height/2 - sh[1] + hero.dy * 50)
* .1;
这个怪异的公式执行以下操作:
- 使sh,“换档”坐标,远离自己的位置(
-sh[0]
),转向英雄的位置被中心的位置抵消(hero.x - canvas.width / 2
)。 - 请确保向弓箭手射击的方向增加一些空间(
headStart
),并向马移动的方向(分为headStart
和hero.dy
)。 - 使过渡平稳,而不是立即(
* .1
)。
现在仍然缺少一个很大的作品。只有马在屏幕上,光滑的滚动就明显没有效果。我们需要的是:
滚动装饰
为了表明在移动中,我们需要在地面上放置一些装饰。有两个装饰元素移动:树木和草。事实证明,每个方法都采用了不同的方式。
在网格中展示草
一条简单的线用于显示草,但是要使它看起来自然,它需要有点随机。为此,使用了种子随机性:
- 世界分为细胞。在任何时间点,我们在骑手周围显示40x30个单元。每个细胞都有一条草。
- 草(x,y)被随机数所抵消。该数字由单元格的(x,y)坐标播种,这意味着即使单元格消失,也将始终相同的偏移量。这些坐标也不需要存储任何地方。
- 因此,如果骑手向右行驶,然后回来,可以破坏一个牢房,然后重新创建,而草皮将保持原样。
现在,这种技术使我们可以创建非重复的模式,并确保在一个地方的装饰永远不会改变,即使您离开场景然后回来。虽然我确定没有人会注意到草地的位置是否改变,但该细节在那里。
通过在滑动窗口中回收树木来显示树木
使用完全不同的算法实现树木。原因是我稍后想到了这一点,意识到这要简单得多,但是现在我懒得重新成真草。 (如果事实证明我可能会用完空间,以适合13k)。
。基本上,所有树木首先是随机放置的(不必将其播种随机性)。
然后,将以下代码应用于树精灵:
const hx = sprite.x - hero.x;
const hy = sprite.y - hero.y;
if (hx > repeatCond * 2) {
sprite.x -= repeatDistance * 2;
sprite.cellX--;
} else if (hx < -repeatCond * 2) {
sprite.x += repeatDistance * 2;
sprite.cellX++;
}
if (hy > repeatCond) {
sprite.y -= repeatDistance;
sprite.cellY--;
} else if (hy < -repeatCond) {
sprite.y += repeatDistance;
sprite.cellY++;
}
如果树也是从英雄那里来的,它会被“重复”向英雄带动,这使树在英雄前面。基本上,我们在滑动窗口外面重复使用同一树并将其移动。
在这种情况下,滑动窗口比使用更可取,两种技术都有优点和缺点:
-
滑动窗口:
- 专利:实施更易。可以使用纯随机性。
- 缺点:引起重复模式(DEJA-VU)。需要存储树的位置。
-
网格细胞带有种子随机性:
- 优点:避免重复模式。没有数据可存储。
- 缺点:更复杂。需要种子随机函数。添加计算
现在我们在一个空旷的世界中获得了装饰和英雄,是时候添加了:
敌人
从骑兵的战士开始,我一开始就让他们走向英雄。但是我遇到了与融合的马相同的问题,看起来很丑。因此,我改变了行为,如下:
- 敌人有一个目标。这个目标是在英雄背后的随机位置设定的。因此,敌人经常尝试通过英雄,并增加了一些随机的偏移,以使其变得不那么可预测。
- 如果敌人离英雄太远,或者他们达到目标,请重设目标。
if (sprite.gdist < 100 || hdist > (sprite.soldier ? 500 : 3000)) {
sprite.goal[0] = hero.x + (hero.x - sprite.x) + (Math.random()-.5) * (sprite.soldier ? 200 : 300);
sprite.goal[1] = hero.y + (hero.y - sprite.y) + (Math.random()-.5) * (sprite.soldier ? 200 : 300);
}
士兵具有相同的算法,但是对他们的参数进行了调整,使他们更直接地朝着英雄。
由于敌人和英雄具有相同的显示机制,所以我所有的敌人都将英雄本身用作模板,并覆盖了一些属性(例如,敌人将如何移动的过程属性)。
。还有其他一些有趣的技巧和技巧,用于构建此游戏,我将在下面概述:
动态属性
我在处理精灵属性时发现的另一种技术是具有可以是值或函数的属性。然后,我使用evaluate
函数来读取这些属性:
function evaluate(value, sprite) {
if (typeof(value) === "object") {
if (Array.isArray(value)) {
return value.map(v => evaluate(v, sprite));
} else {
const o = {};
for (let i in value) {
o[i] = evaluate(value[i], sprite);
}
return o;
}
}
return typeof(value) === "function" ? value(sprite) : value;
}
这使更新属性变得轻而易举。我经常进行硬码值,然后突然需要使动态化。我只是将其转换为可以在Sprite中查找其他属性以计算自身的函数,而不是挠头并弄清楚在哪里更改属性。
精灵缓存
当游戏在我的计算机上运行良好时,我想起了构建Flash游戏的日子,反复绘制矢量图形是昂贵的。 JS13K的Discord渠道的讨论也使我想起了。
缓存的游戏包括将绘制形状的框架保存到画布中,然后当我需要显示一个精灵时,我将画布复制到主人中,而不是在画布上的上下文上重复拉动呼叫。这就是我能够在屏幕上展示100个敌人而不会太大放缓的方式。
if (cache) {
const tag = `${animation}-${frame}-${color}-${dir}-${width}-${height}`;
if (!cacheBox[tag]) {
cacheBox[tag] = {
canvas: document.createElement("canvas"),
};
cacheBox[tag].canvas.width = width;
cacheBox[tag].canvas.height = height;
cacheBox[tag].canvas.getContext("2d").lineWidth = 6;
cacheBox[tag].canvas.getContext("2d").strokeStyle = "black";
showFrame(cacheBox[tag].canvas.getContext("2d"),
dir < 0 ? width : 0,
height < 0 ? -height : 0,
width * dir,
height,
frame,
anim,
color, 0, 0);
}
ctx.drawImage(cacheBox[tag].canvas,
x - hotspot[0] * width - sh[0],
y - (height < 0 ? 0 : hotspot[1] * height) - sh[1]+shake);
return;
}
首先抓住代表一个图像的标签。标签将取决于动画,框架,选择的颜色,方向以及宽度和高度。 (可能不需要方向,宽度和高度,我可能会在重构时将其删除)。
如果标记了用于缓存的精灵,则分配了偏移画布,则首次出现时将其绘制精灵。然后将偏移画布吸入主帆布。
请注意,缓存用于敌人和树木,但不是主要英雄。原因是因为英雄的精灵在垂直移动时向上 /向下倾斜。< / p>
好吧,这就是验尸的第1部分。请回来检查第2部分,当然,一旦发布游戏。
下面是一个视频,显示了现在的状态。我希望我能在发布之前使它变得更好:
https://www.youtube.com/watch?v=JBmQgz7r2Hc
源代码:
https://github.com/jacklehamster/khan-js13k/