如何在照顾婴儿的同时编写 13 KB 以下的游戏
本文最初发布于barbarianmeetscoding.com。😊
哇!好久不见了!过去的一年精彩纷呈,也同样艰难。作为平等的伴侣,生儿育女、照顾宝宝,既令人兴奋,又温暖人心,而且……非常疲惫。所以,过去一年里,你们很少听到我的消息。就让这篇文章和js13kgames比赛成为我的回归之作吧。
在下面的段落中,您将了解到在 13Kb 以下开发游戏的感觉,我如何应对挑战,从规划到开发游戏机制、粒子系统、生成算法、音乐、星系、神秘外星人舰队,以及我如何得到类似游戏的东西:曾经的地球。(输入视频)
如何用不到 13 KB 编写一个游戏?
我不知道自己是怎么发现js13kgames的。它莫名其妙地出现在我的推特上,我一边想着“嗯……真棒……”,一边往下翻,看到了下一条 JavaScript 新闻。直到几天后,这个想法才在我的脑海深处扎根发酵,我决定:“搞什么?这绝对是重新点燃我编程热情、在工作之余做一些很酷的事情的好方法。 ”
事情就是这样开始的。最终说服这位犹豫不决的父亲的是,他学习了一个很棒的教程,教他如何使用kontra.js构建小行星,并意识到它有多么有趣。
设定目标并选择主题
所以我打算参加 GameJam,但我的游戏应该以什么为主题呢?我想从这次 GameJam 的经历中收获什么?对我来说,最重要的是学习游戏开发,享受乐趣,并完成游戏。考虑到这一点,我决定尽可能简化流程,并延续教程中的太空射击主题。我经常发现,学习新事物的最佳方法是将事情分解,并减少每次处理的复杂程度。
为了节省宝贵的 13Kb,我会使用经验法则,优先使用程序生成地图、图像、音乐等...而不是手动工作,并且由于我的时间限制,目标是从第一原则开始以最简单的形式让一切工作(没有太多时间投入研究)。
机会主义编码,或者在没有时间编码时如何编码
下一个难题是找时间开发这个游戏。作为一个带着小宝宝的父亲,我的时间和注意力都集中在他身上,只有当他睡着的时候,我才能找到时间和宁静去做一些除了照顾孩子之外的事情。这里有一些建议,无论是否已经当爸爸,都适用于我的宝贝们:
- 告别多任务。专注于手头的一项任务。专注于手头的一个项目。一次只做一件事。持续不断地反复努力解决单一问题,最终会结出惊人的硕果。
- 行动产生动力。如果你感到疲惫不堪,那就打开电脑,开始编码吧。你会惊讶地发现,仅仅几分钟的编码之后,你就能很快恢复精神,充满活力。
设置 Gamedev 环境
对于开发环境,我会使用当今 Web 开发人员熟悉的工具,例如Webpack、TypeScript和Visual Studio Code。运行如下代码:
$ npm start
会设置我的游戏开发环境并启用实时刷新功能。并且:
$ npm run build
会生成经过优化的“二进制”产品,用于提交比赛。这是一个非常方便的设置,TypeScript帮助我更快地发现和修复某些错误。
为了优化 JS 代码使其大小控制在 13 KB 以下,我尝试了一段时间tsickle和闭包编译器,但最终还是选择了 uglifyJS,因为它与 Webpack 的集成度更高。(说实话,我当时时间有限,没法让闭包正常工作,而 UglifyJS 已经足够好了)。
编写游戏
编写游戏乐趣无穷。我最喜欢编程的一点是,它是一种创造的艺术:什么都没有;你写点代码,然后轰隆隆!一切就从虚无中诞生了。游戏开发在这方面尤其强大,因为你有能力创造世界。这在我看来简直太酷了。领域模型绝对胜过我之前开发过的任何应用程序,无论SpaceShip
何时都令人赞叹。Planet
Bullet
Elder
PurchaseOrder
游戏设计,是不是很酷?
由于我开发这款游戏的主要目标是学习如何开发游戏,所以我采取了一种非常开放和探索性的方法:我称之为“如果……会不会很酷”的游戏设计。我知道我想做一款太空射击游戏,因为我觉得这比其他类型的游戏简单得多,但我并没有花太多时间去规划游戏。我只是开始编写不同的独立机制,并问自己:如果……会不会很酷……
- 这些小行星的纹理很漂亮吗?
- 它们有不同的形状和大小吗?
- 当船只被摧毁时,他们会丢弃资源来给船只充电或修理吗?
- 船舶推进器会排放颗粒物吗?
- 有几个派系拥有不同的船只和目标?
- 有神秘而又极其危险的外星人在周围游荡?
- 当没有可用能源时,游戏中的各种船舶系统会停止工作吗?
- 你能宣称拥有行星吗?
- 您可以拥有这些星球的经济并建造防御工事、船舶等吗?
- 你可以使用不同的武器系统和方式向敌人发动攻击并进行毁灭吗?
- 就这样一直持续下去……
虽然开发游戏很有趣,但这意味着到了比赛最后一天,我只能做出一堆几乎孤立的机制,却无法完成一款游戏。游戏中有飞船、小行星、行星、太阳、星区、星系、外星人,但却没有任何东西可以将它们组合成一个类似游戏的东西。
因此,在最后一天,我和儿子 Teo 一起进行了头脑风暴(趁他睡觉的时候),想出了一个可以在一天之内将所有这些元素联系在一起的想法:
一艘飞船盘旋在濒死地球的轨道上,地球是人类最后的希望,蕴藏着跨越星辰的新人类文明的种子。唯一缺少的是一个能够容纳人类残存子民的新地球。地球曾经是,但它可以再次存在。
太深奥了。
使用 Kontra
Kontra.js是一个极简的 2D 游戏库,非常适合 js13k 挑战。它提供了开发 2D 游戏所需的所有基础功能:一个用于更新游戏状态并将其渲染到画布中的游戏循环;一种用于在游戏中呈现元素(例如飞船、小行星或子弹)的方法;一种用于加载资源和处理输入的方法;以及瓷砖地图、带有动画的精灵图等等。它的优点在于模块化设计,你可以自由选择使用哪些部分,从而节省宝贵的 KB 空间。缺点(取决于你的偏好和开发环境)是它不支持 ESM,而 ESM 对于 tree-shaking 来说非常有用。
Kontra 的 API 非常喜欢工厂函数,所以我用工厂函数而不是类来建模所有领域对象,因为这样感觉更自然、对称,也更合适。例如,这是一个子弹-导弹-射弹的东西:
export interface Bullet extends Sprite {
damage: number
owner: Sprite
color: RGB
}
const numberOfParticles = 2
export default function createBullet(
position: Position,
velocity: Velocity,
angle: number,
cameraPosition: Position,
scene: Scene,
owner: Sprite,
damage: number = 10,
color: RGB = { r: 255, g: 255, b: 255 }
): Bullet {
const cos = Math.cos(degreesToRadians(angle))
const sin = Math.sin(degreesToRadians(angle))
return kontra.sprite({
type: SpriteType.Bullet,
// start the bullet at the front of the ship
x: position.x + cos * 12,
y: position.y + sin * 12,
// move the bullet slightly faster than the ship
dx: velocity.dx + cos * 5,
dy: velocity.dy + sin * 5,
// damage can vary based on who shoots the missile
damage,
// avoid friendly fire
owner,
ttl: 50,
width: 2,
height: 2,
color,
update() {
this.advance()
this.addParticles()
},
addParticles() {
let particles = callTimes(numberOfParticles, () =>
Particle(
{ x: this.x, y: this.y },
{ dx: this.dx, dy: this.dy },
cameraPosition,
angle,
{ color }
)
)
particles.forEach(p => scene.addSprite(p))
},
render() {
let position = getCanvasPosition(this, cameraPosition)
Draw.fillRect(
this.context,
position.x,
position.y,
this.width,
this.height,
Color.rgb(this.color)
)
if (Config.debug && Config.showPath) {
this.context.save()
this.context.translate(position.x, position.y)
Draw.drawLine(this.context, 0, 0, this.dx, this.dy, 'red')
this.context.restore()
}
if (Config.debug && Config.renderCollisionArea) {
this.context.save()
this.context.translate(position.x, position.y)
Draw.drawCircle(this.context, 0, 0, this.width / 2, 'red')
this.context.restore()
}
},
})
}
除了这些游戏对象(它们只是工厂的扩展kontra.sprite({...})
,代表游戏中可见且可交互的任何对象)之外,我还创建了一些抽象:Scene
以及游戏对象Game
本身。场景非常有助于表示游戏的不同部分,并以有意义的方式对游戏对象进行分组(例如开放场景、太空场景、游戏结束场景等),同时游戏提供了一种集中状态管理、控制游戏音乐、预加载资源以及在场景之间切换的方法。
生成编程
我大部分时间都在做两件事:
- 我绞尽脑汁去研究牛顿物理学和三角学的基本原理,
- 设计简单的算法来生成纹理、粒子、名称和星系。
让我们仔细看看第二个问题,你可能会更感兴趣。总的来说,在开发这些算法时,我遵循了以下几条规则:
- 尽快完成工作并进行迭代
- 想想第一原则。你会如何从零开始做到这一点?
像素化纹理
对于行星的纹理,我希望达到一种像素艺术感,但又不至于看起来太糟糕(所以期望值很低 :D)。我一开始选择了三种类型的行星:红色、绿色和蓝色,并构思用这些单独的颜色生成完整的调色板。
我立刻想到,HSL
颜色模型是生成这些调色板的绝佳候选。HSL
代表Hue
,Saturation
而Lightness
英文意思是如果我上下改变亮度,就会得到一个调色板。我就是这么做的。我的第一个算法使用单一颜色,并构建了一个包含 2 个深色和 2 个浅色的调色板。后来,我以不同的比例应用这些颜色来生成一个图案,然后用该图案填充行星表面。后来,我尝试了图案不同部分的不同比例、透明度以及在调色板中包含更多颜色。
最终的算法使用了基色和强调色,如下所示:
// A way to represent HSL colors
export interface HSL {
h: number
s: number
l: number
}
// An offscreen canvas to create textures
// in the background
export class OffscreenCanvas {
// more codes here...
// but here's the interesting part
private savedPatterns: Map<string, CanvasPattern> = new Map<
string,
CanvasPattern
>()
getPatternBasedOnColors(
primary: HSL,
secondary: HSL,
width: number = 16,
height: number = 16,
pixelSize: number = 2
) {
// memoize
// TODO: extract to higher-order function
if (
this.savedPatterns.has(twocolorkey(primary, secondary, width, height))
) {
return this.savedPatterns.get(
twocolorkey(primary, secondary, width, height)
)
}
this.canvas.width = width
this.canvas.height = height
// 1. define color theme
let p = primary
let s = secondary
// Functions that return colors with different
// alpha values. I ended up only using completely solid colors
let baseColor = (a: number) => Color.hsla(p.h, p.s, p.l, a)
let lightShade = (a: number) => Color.hsla(p.h, p.s, p.l + 10, a)
let darkShade = (a: number) => Color.hsla(p.h, p.s, p.l - 10, a)
let accent = (a: number) => Color.hsla(s.h, s.s, s.l, a)
// This defines the color distribution
// e.g. 40% base color, 20% lighter shade, 20% darker shade
// and 20% accent color
let buckets = [
baseColor,
baseColor,
baseColor,
baseColor,
lightShade,
lightShade,
darkShade,
darkShade,
accent,
accent,
]
// 3. distribute randomly pixel by pixel see how it looks
for (let x = 0; x < this.canvas.width; x += pixelSize) {
for (let y = 0; y < this.canvas.height; y += pixelSize) {
let pickedColor = pickColor(buckets)
this.context.fillStyle = pickedColor
this.context.fillRect(x, y, pixelSize, pixelSize)
}
}
let pattern = this.context.createPattern(this.canvas, 'repeat')
this.savedPatterns.set(
twocolorkey(primary, secondary, width, height),
pattern
)
return pattern
}
}
function pickColor(buckets: any) {
let index = Math.round(getValueInRange(0, 9))
let alpha = 1
return buckets[index](alpha)
}
function twocolorkey(
primary: HSL,
secondary: HSL,
width: number,
height: number
) {
let key1 = key(primary.h, primary.s, primary.l, width, height)
let key2 = key(secondary.h, secondary.s, secondary.l, width, height)
return `${key1}//${key2}`
}
由于每次需要时都创建一个图案的开销比较大,所以我用相同的颜色和大小对每个图案进行了记忆。通俗地说,记忆就是保存函数调用的结果和一些参数,这样我就不用重复处理相同的结果了。在这里,它指的是纹理创建后就保存起来,以便反复使用。
这里还有很大的改进空间,我很乐意进行更多实验,并能够生成陆地、云层等。不过结果已经相当不错了,我很喜欢我的行星的外观。:D
美丽的星星
当游戏发生在太空中,周围一片漆黑时,玩家很难看清飞船移动的效果。所以我想创造一个星空背景,并实现某种视差效果,让玩家清楚地了解太空中的运动。
为了做到这一点,我设计了一种算法,该算法会考虑以下因素:
- 飞船周围的背景永远布满星星。
- 当飞船移动时,我们会将星星从飞船后方移到飞船前方,从而产生一切都被星星覆盖的幻觉。
- 星星与飞船的距离各不相同。有些距离很远,有些距离较近。
- 远处的恒星看起来比近处的恒星更暗、更小
- 随着飞船的移动,远处的恒星比近处的恒星移动得慢
本身Star
是一个非常简单的游戏对象:
export interface StarBuilder extends SpriteBuilder {}
export interface Star extends Sprite {
distance: number
color: string
}
export function Star({ x, y, cameraPosition }: StarBuilder): Star {
let distance: number = parseFloat(getValueInRange(0, 1).toFixed(2))
let alpha: number = 1 - 3 * distance / 4
let color: string = Color.get(alpha)
let size: number = 2.5 + (1 - distance)
return kontra.sprite({
// create some variation in positioning
x: getNumberWithVariance(x, x / 2),
y: getNumberWithVariance(y, y / 2),
type: SpriteType.Star,
dx: 0,
dy: 0,
ttl: Infinity,
distance,
color,
size,
render() {
// the more distant stars appear dimmer
// limit alpha between 1 and 0.75
// more distant stars are less affected by the camera position
// that is, they move slower in reaction to the camera changing
// this should work as a parallax effect of sorts.
let position = getCanvasPosition(this, cameraPosition, this.distance)
this.context.fillStyle = this.color
this.context.fillRect(position.x, position.y, this.size, this.size)
},
})
}
export function getNumberWithVariance(n: number, variance: number): number {
return n + Math.random() * variance
}
核心在于计算画布中游戏对象位置的函数getCanvasPosition
,并考虑摄像机位置以及摄像机变化时距离的影响:
// Get position of an object within the canvas by taking into account
// the position of the camera
export function getCanvasPosition(
objectPosition: Position,
cameraPosition: Position,
distance: number = 0
): Position {
// distance affects how distant objects react to the camera changing
// distant objects move slower that close ones (something like parallax)
// that is, moving the ship will have less effect on distant objects
// than near ones
// distance is a value between 0 (close) and 1 (far)
// at most the deviation factor will be 0.8
let deviationFactor = 1 - distance * 0.2
// include canvasSize / 2 because the camera is always pointing
// at the middle of the canvas
let canvasPosition: Position = {
x:
objectPosition.x -
(cameraPosition.x * deviationFactor - Config.canvasWidth / 2),
y:
objectPosition.y -
(cameraPosition.y * deviationFactor - Config.canvasHeight / 2),
}
return canvasPosition
}
名字
我最初的想法是探索一个无限大的星系,手动给每个星系、恒星和行星命名显然行不通。我的想象力最多只能想出5到7个名字。所以我基于以下原则编写了一个名称生成器:
- 生成 1 至 3 个字母的音节。
- 1 个字母的音节将是元音
- 2 个和 3 个字母的音节以辅音开头
- 将 2 至 4 个音节组合在一起形成一个单词
我希望通过连接音节而不是随机字符来生成更易辨认、更可信的名字,我认为我确实做到了。算法如下:
export function generateName() {
let numberOfSyllabes = getIntegerInRange(2, 4)
let name = ''
for (let i = 0; i < numberOfSyllabes; i++) {
name += `${generateSyllable()}`
}
return name
}
let vocals = ['a', 'e', 'i', 'o', 'u', 'ä', 'ö', 'å']
let minCharCode = 97 // a
let maxCharCode = 122 // z
function generateSyllable() {
let syllableSize = getIntegerInRange(1, 3)
if (syllableSize === 1) return getVocal()
else if (syllableSize === 2) return `${getConsonant()}${getVocal()}`
else return `${getConsonant()}${getVocal()}${getConsonant()}`
}
function getVocal() {
return getRandomValueOf(vocals)
}
function getConsonant() {
let consonant = ''
while (!consonant) {
let code = getIntegerInRange(minCharCode, maxCharCode)
let letter = String.fromCharCode(code)
if (!vocals.includes(letter)) consonant = letter
}
return consonant
}
粒子
我爱粒子!我觉得它们能增添一种难以言喻的魅力,让游戏的观感和体验都更加出色。在编写粒子引擎的时候(虽然“引擎”这个词对于一些功能来说有点过于宏大了),我问了自己什么是粒子?这引发了一场关于生命、宇宙以及一切终极问题的有趣对话。不过,我不会费心去解释细节……最终,我总结道:粒子是从源头以不同方向、速度和加速度涌现的小精灵,它们会随着时间的推移逐渐消散。所以我的粒子引擎需要:
- 创建从原点发芽的粒子
- 给定方向和速度(我没有考虑加速度,我敢打赌那将是一件很棒的事情)
- 粒子的存活时间会有所不同
- 随着时间的推移,粒子会逐渐变小,最终消失
- 粒子会有不同的颜色,你可以配置
差不多就是这样了。这是用于子弹的粒子示例,最终看起来像彗星的尾巴:
export interface Particle extends Sprite {}
export interface ParticleOptions {
ttl?: number
color?: RGB
magnitude?: number
}
// particle that takes into account camera position
export function Particle(
position: Position,
velocity: Velocity,
cameraPosition: Position,
// angle for the particles
particleAxis: number,
{ ttl = 30, color = { r: 255, g: 255, b: 255 } }: ParticleOptions = {}
): Particle {
let ParticleAxisVariance = getValueInRange(-5, 5)
let cos = Math.cos(degreesToRadians(particleAxis + ParticleAxisVariance))
let sin = Math.sin(degreesToRadians(particleAxis + ParticleAxisVariance))
return kontra.sprite({
type: SpriteType.Particle,
// particles originate from a single point
x: position.x,
y: position.y,
// variance so that different particles will have
// slightly different trajectories
dx: velocity.dx - cos * 4,
dy: velocity.dy - sin * 4,
// each particle with have a slightly
// different lifespan
ttl: getValueInRange(20, ttl),
dt: 0,
width: 2,
update() {
this.dt += 1 / 60
this.advance()
},
render() {
let position = getCanvasPosition(this, cameraPosition)
// as time passes the alpha increases until particles disappear
let frames = this.dt * 60
let alpha = 1 - frames / ttl
let size = (1 + 0.5 * frames / ttl) * this.width
this.context.fillStyle = Color.rgba(color.r, color.g, color.b, alpha)
this.context.fillRect(position.x, position.y, size, size)
},
})
}
星系
正如我前几节所说,我最初的想法是生成一个看似无限的星系供玩家探索。我当时想,如果游戏难度足够高,足够有挑战性,玩家在厌倦探索宇宙之前就会死去。我本来很想探索在玩家探索过程中生成星系的想法,但最终,随着截止日期的临近,我选择了 v0 版本,只创建了一个 10x10 的扇形星系。所以:
- 该星系由 10x10 个扇区组成
- 一个星区基本上是一个恒星系统,有一颗中心恒星和 1 到 5 颗行星围绕它运行(除了我们的恒星系统,它拥有所有你能想到的行星。抱歉冥王星,没有矮行星)。
- 这些扇区将占据 10000x10000 像素的表面,使可探索星系成为 100Kx100K 的空间。
- 玩家将开始围绕地球运行的游戏,太阳系恰好位于银河系的中心。
这是一些针对强大部门的示例代码:
export interface Sector extends Position {
name: string
planets: Planet[]
sun: Sun
bodies: Sprite[]
asteroids?: Asteroid[]
}
export function Sector(
scene: Scene,
position: Position,
cameraPosition: Position,
name = generateName()
): Sector {
// HAXOR
let isSunSystem = name === 'sun'
let isOrion = name === 'orion'
let sun = createSectorSun(position, cameraPosition, name)
let planets = createPlanets(sun, scene, cameraPosition, {
isSunSystem,
isOrion,
})
return {
// this position represents the
// top-left corner of the sector
x: position.x,
y: position.y,
name,
sun,
planets,
bodies: [sun, ...planets],
}
}
function createSectorSun(
sectorPosition: Position,
cameraPosition: Position,
name: string
) {
let centerOfTheSector = {
x: sectorPosition.x + SectorSize / 2,
y: sectorPosition.y + SectorSize / 2,
}
let sunSize = getValueInRange(125, 175)
let sun = createSun({ ...centerOfTheSector }, sunSize, cameraPosition, name)
return sun
}
function createPlanets(
sun: Sun,
scene: Scene,
cameraPosition: Position,
{ isSunSystem = false, isOrion = false }
) {
if (isSunSystem) return createSunSystemPlanets(sun, scene, cameraPosition)
if (isOrion) return createOrionSystemPlanets(sun, scene, cameraPosition)
let numberOfPlanets = getIntegerInRange(1, 5)
let planets = []
let planetPosition: Position = { x: sun.x, y: sun.y }
for (let i = 0; i < numberOfPlanets; i++) {
let additiveOrbit = getValueInRange(500, 1000)
planetPosition.x = planetPosition.x + additiveOrbit
let radius = getValueInRange(50, 100)
let planet = createPlanet(
sun,
/* orbit */ planetPosition.x - sun.x,
radius,
cameraPosition,
scene
)
planets.push(planet)
}
return planets
}
interface PlanetData {
orbit: number
radius: number
name: string
type: PlanetType
angle?: number
claimedBy?: Faction
}
function createSunSystemPlanets(
sun: Sun,
scene: Scene,
cameraPosition: Position
) {
let planets: PlanetData[] = [
{ orbit: 300, radius: 30, name: 'mercury', type: PlanetType.Barren },
{ orbit: 500, radius: 70, name: 'venus', type: PlanetType.Desert },
{
orbit: 700,
radius: 50,
name: '*earth*',
type: PlanetType.Paradise,
angle: 40,
claimedBy: Faction.Blue,
},
{ orbit: 900, radius: 40, name: 'mars', type: PlanetType.Red },
{ orbit: 1500, radius: 150, name: 'jupiter', type: PlanetType.GasGiant },
{ orbit: 2100, radius: 130, name: 'saturn', type: PlanetType.GasGiant },
{ orbit: 2700, radius: 110, name: 'uranus', type: PlanetType.Blue },
{ orbit: 3500, radius: 110, name: 'neptune', type: PlanetType.Blue },
]
return planets.map(p =>
createPlanet(sun, p.orbit, p.radius, cameraPosition, scene, {
name: p.name,
type: p.type,
startingAngle: p.angle,
claimedBy: p.claimedBy,
})
)
}
function createOrionSystemPlanets(
sun: Sun,
scene: Scene,
cameraPosition: Position
) {
return [
createPlanet(sun, 700, 100, cameraPosition, scene, {
name: 'orion',
type: PlanetType.Paradise,
}),
]
}
远古长老种族
我想给游戏加点儿辣味,比如辣椒或者辣味胡椒,让它更有挑战性,也更有趣。因为我没太多时间去思考和构思游戏的深度背景,所以我选择了一个科幻奇幻的比喻——“长老种族”。
我希望玩家能够对抗至少三种不同类型的敌人:
- 超快、短程、弱小但极具攻击性的飞行器:无人机
- 一个中等规模的单位,相当坚固,可以在行星和恒星周围巡逻:哨兵
- 一艘巨大、坚固、威力巨大、罕见的、可以随意运输和喷射无人机的战舰:母舰。
我的想法是让这些种族以不同的规模在不同的星系中定居,并建立一个中央星系,在那里驻扎所有舰队,并拥有所有舰队的母舰。游戏初期,我并不确定这个古老种族的角色或最终目标是什么,但后来我确定他们是最后一颗适合人类生存的星球的守护者,因此也是游戏的最终Boss。
当我实现这些远古飞船时,我想开发一个系统,可以定义……姑且称之为……AI行为(再次强调,“AI”这个词对于非常基础的算法来说太过宏大了),然后随意将它们组合在一起。这样,我们就可以实现诸如“跟随这个目标”、“向它射击”、“巡逻这个区域”或“在你无事可做时遵循这个路线”之类的功能。
该系统由一系列Mixins组成,这些 Mixins 公开了以下接口:
export interface Behavior {
type: BehaviorType
properties: BehaviorProperties
update(dt?: number): void
render?(): void
}
export interface BehaviorProperties {
// any property
[key: string]: any
}
该接口由一组任意属性组成,BehaviorProperties
行为本身需要这些属性才能运行,以及update
与render
自然生命周期挂钩的方法Sprite
。
一个行为示例如下,Shoot
它通过让游戏对象在目标靠近时向目标射击来实现该接口(< 300
):
export function Shoot(scene: Scene, target: Position): Behavior {
return {
type: BehaviorType.Shoot,
properties: {
dts: 0,
damage: 1,
color: { r: 255, g: 255, b: 255 },
},
update(dt?: number) {
this.dts += 1 / 60
let distanceToShip = Vector.getDistanceMagnitude(this, target)
if (this.dts > 0.5 && distanceToShip < 300) {
this.dts = 0
let angle = radiansToDegrees(Math.atan2(this.dy, this.dx))
let bullet = createBullet(
this,
this,
angle,
target,
scene,
/*owner*/ this,
this.damage,
this.color
)
scene.addSprite(bullet)
}
},
}
}
我用这个函数来编写这个普通函数的方式Sprite
是composeBehavior
:
export function composeBehavior(sprite: Sprite, behavior: Behavior) {
// only add properties if they're not already there
Object.keys(behavior.properties).forEach(k => {
if (sprite[k] === undefined) {
sprite[k] = behavior.properties[k]
}
})
sprite.update = before(sprite.update, behavior.update).bind(sprite)
if (behavior.render) {
sprite.render = after(sprite.render, behavior.render).bind(sprite)
}
}
其中before
和after
是效用函数:
/* Call a function before another function */
export function before(func: any, beforeFunc: any) {
return function(...args: any[]) {
beforeFunc.apply(this, args)
func.apply(this, args)
}
}
/* Call a function after another function */
export function after(func: any, ...afterFuncs: any[]) {
return function(...args: any[]) {
func.apply(this, args)
afterFuncs.forEach((f: any) => f.apply(this, args))
}
}
因此,利用这种行为组合,我可以定义一系列行为并将它们附加到不同的长老船上,如下所示:
// some code...
if (this.elderType === ElderType.Sentry) {
// patrol around target following an orbit of 200
// (it'll be a planet setup later on)
composeBehavior(elder, PatrolAroundTarget(PatrolType.Orbit, /* orbit */ 200))
// if the player's ship comes near (<300) follow it steady
composeBehavior(elder, FollowSteadyBehavior(this.ship, 300))
// if the player's ship is near (<300) shoot at it
composeBehavior(elder, Shoot(scene, this.ship))
}
// more code...
这很好,因为它节省了 Kb,并允许我随意配置和附加行为,对长老,以及将来可能对其他 AI 控制的派系。
像素艺术
我热爱像素画,但我只是一个彻头彻尾的业余像素艺术家。为了打造这款游戏,我至少想手绘一艘酷炫的宇宙飞船。为了获得漂亮的像素效果,我选择了 32x32 的精灵图,像素大小为 2x2,并且调色板有限。我使用了Piskel,这是一款非常优秀的网页像素画创作应用。下面是一些我制作的不同飞船以及 Piskel 编辑器的示例:
音乐
音乐是游戏中至关重要的元素。它能提升游戏的沉浸感,为玩家提供反馈,营造合适的氛围,并激发玩家的情绪(兴奋、恐惧、紧张、平静等等)。由于 13KB 的限制,我立即想到了生成音乐(我在 Twitter 上听到过很多关于它的讨论)以及使用Web Audio API。但我遇到了两个障碍:
- 我对音乐一无所知,无论以何种方式、形式或形式
- 我不知道 Web Audio API 的工作原理
在游戏的其他部分,我满足于脱离基本原理思考和解决问题。然而,在音乐方面,我真的需要研究、阅读和学习他人的经验。以下是一些我认为在游戏中添加音乐时非常有用的文章:
- @teropa的这一系列关于网络音频的文章真是太棒了。它们对理解 Web Audio API 的工作原理以及如何利用它制作音乐有很大帮助。
- 他对生成音乐的实验(以及更多实验)也非常棒。虽然在开发这款游戏时,这些实验对我来说太过先进,但它们在未来的几个月里可能会派上用场,或许我能在未来的游戏开发大赛中吸收这些知识。
- @mcfunkypants为procjam提供的程序化音乐生成教程也非常棒,给了我很多想法。
- 最后,阅读@kevincennis实现 TinyMusic 的历程并查看源代码是一次很棒的学习体验,它教会了我如何使用 Web Audio API 创建音符序列。
最后,我编写了一个小型音乐引擎,从TinyMusic和@teropa 关于网络音频的文章中汲取了很多灵感。可惜的是,在提交比赛前的最后 13000 个小时里,我不得不把它从游戏中移除。我唯一保留的是一个我认为与游戏氛围相符的节拍效果。如果你像我一周前一样不熟悉“节拍”这个词,它指的是将频率非常接近的波混合在一起,当它们同相时相互加强,当它们异相时相互抵消,从而产生不断变化的类音乐音符。
function Oscillator(ac: AudioContext, freq = 0) {
let osc = ac.createOscillator()
osc.frequency.value = freq
return osc
}
function Gain(ac: AudioContext, gainValue: number) {
let gain = ac.createGain()
gain.gain.value = gainValue
return gain
}
interface Connectable {
connect(n: AudioNode): void
}
function Beating(
ac: AudioContext,
freq1: number,
freq2: number,
gainValue: number
) {
let osc1 = Oscillator(ac, freq1)
let osc2 = Oscillator(ac, freq2)
let gain = Gain(ac, gainValue)
osc1.connect(gain)
osc2.connect(gain)
return {
connect(n: AudioNode) {
gain.connect(n)
},
start(when = 0) {
osc1.start(when)
osc2.start(when)
},
stop(when = 0) {
osc1.stop(when)
osc2.stop(when)
},
}
}
function Connect({ to }: { to: AudioNode }, ...nodes: Connectable[]) {
nodes.forEach(n => n.connect(to))
}
interface MusicTrack {
start(): void
stop(): void
}
function GameOpeningMusic(ac: AudioContext): MusicTrack {
let b1 = Beating(ac, 330, 330.2, 0.5)
let b2 = Beating(ac, 440, 440.33, 0.5)
let b3 = Beating(ac, 587, 587.25, 0.5)
let masterGain = Gain(ac, 0.1)
Connect({ to: masterGain }, b1, b2, b3)
masterGain.connect(ac.destination)
return {
start() {
b1.start()
b2.start()
b3.start()
},
stop() {
b1.stop()
b2.stop()
b3.stop()
},
}
}
export interface GameMusic {
play(track: Track): void
stop(): void
currentTrack: MusicTrack
}
export function GameMusic(): GameMusic {
let ac = new AudioContext()
return {
currentTrack: undefined,
play(track: Track) {
if (this.currentTrack) {
this.currentTrack.stop()
}
let musicTrack = Tracks[track]
this.currentTrack = musicTrack(ac)
this.currentTrack.start()
},
stop() {
this.currentTrack.stop()
},
}
}
结论
这真是太有趣了!!!如果你以前没参加过 Game Jam,我强烈推荐你参加。我不知道是不是所有的 Game Jam 都像js13k那样。但这次活动持续了整整一个月,我可以抽出时间,不用太赶时间,这点很棒。而且,使用 JavaScript 和开放的 Web 技术也让入门变得更容易。你只需要一个编辑器和一个浏览器就可以了(或者你甚至可以使用基于浏览器的编辑器 :D)。
我还学到了很多关于游戏开发和 Web 音频 API 的知识。我有很多不同的小话题想关注,也想体验游戏开发、生成式编程、音乐和像素艺术等其他方面的知识。
总而言之,我觉得我完成了这次比赛的目标。如果可以改变一件事,我希望能够多花点时间规划,更明确地确定自己的目标。这样我就能集中精力,最终提交一款更精致的游戏。
接下来的几周我会继续更新游戏,并将其打磨到我满意的水平。我认为它会成为测试新游戏机制和完善生成算法的完美平台。
还有你!保重,考虑参加游戏开发大赛!:D
附言:你可以在这里玩原版游戏!试试看,然后告诉我你的想法!:D
文章来源:https://dev.to/vintharas/how-to-write-a-game-in-under-13-kb-while-take-care-of-a-baby-4160