如何在照顾婴儿的同时编写 13 KB 以下的游戏

2025-05-28

如何在照顾婴儿的同时编写 13 KB 以下的游戏

本文最初发布barbarianmeetscoding.com。😊

哇!好久不见了!过去的一年精彩纷呈,也同样艰难。作为平等的伴侣,生儿育女、照顾宝宝,既令人兴奋,又温暖人心,而且……非常疲惫。所以,过去一年里,你们很少听到我的消息。就让这篇文章和js13kgames比赛成为我的回归之作吧。

在下面的段落中,您将了解到在 13Kb 以下开发游戏的感觉,我如何应对挑战,从规划到开发游戏机制、粒子系统、生成算法、音乐、星系、神秘外星人舰队,以及我如何得到类似游戏的东西:曾经的地球(输入视频)

如何用不到 13 KB 编写一个游戏?

我不知道自己是怎么发现js13kgames的。它莫名其妙地出现在我的推特上,我一边想着“嗯……真棒……”,一边往下翻,看到了下一条 JavaScript 新闻。直到几天后,这个想法才在我的脑海深处扎根发酵,我决定:“搞什么?这绝对是重新点燃我编程热情、在工作之余做一些很酷的事情的好方法。 ”

事情就是这样开始的。最终说服这位犹豫不决的父亲的是,他学习了一个很棒的教程,教他如何使用kontra.js构建小行星,并意识到它有多么有趣。

设定目标并选择主题

所以我打算参加 GameJam,但我的游戏应该以什么为主题呢?我想从这次 GameJam 的经历中收获什么?对我来说,最重要的是学习游戏开发享受乐趣,并完成游戏。考虑到这一点,我决定尽可能简化流程,并延续教程中的太空射击主题。我经常发现,学习新事物的最佳方法是将事情分解,并减少每次处理的复杂程度。

为了节省宝贵的 13Kb,我会使用经验法则,优先使用程序生成地图、图像、音乐等...而不是手动工作,并且由于我的时间限制,目标是从第一原则开始以最简单的形式让一切工作(没有太多时间投入研究)。

机会主义编码,或者在没有时间编码时如何编码

下一个难题是找时间开发这个游戏。作为一个带着小宝宝的父亲,我的时间和注意力都集中在他身上,只有当他睡着的时候,我才能找到时间和宁静去做一些除了照顾孩子之外的事情。这里有一些建议,无论是否已经当爸爸,都适用于我的宝贝们:

  1. 告别多任务。专注于手头的一项任务。专注于手头的一个项目。一次只做一件事。持续不断地反复努力解决单一问题,最终会结出惊人的硕果。
  2. 行动产生动力。如果你感到疲惫不堪,那就打开电脑,开始编码吧。你会惊讶地发现,仅仅几分钟的编码之后,你就能很快恢复精神,充满活力。

设置 Gamedev 环境

对于开发环境,我会使用当今 Web 开发人员熟悉的工具,例如WebpackTypeScriptVisual Studio Code。运行如下代码:

$ npm start
Enter fullscreen mode Exit fullscreen mode

会设置我的游戏开发环境并启用实时刷新功能。并且:

$ npm run build
Enter fullscreen mode Exit fullscreen mode

会生成经过优化的“二进制”产品,用于提交比赛。这是一个非常方便的设置,TypeScript帮助我更快地发现和修复某些错误。

为了优化 JS 代码使其大小控制在 13 KB 以下,我尝试了一段时间tsickle闭包编译器,但最终还是选择了 uglifyJS,因为它与 Webpack 的集成度更高。(说实话,我当时时间有限,没法让闭包正常工作,而 UglifyJS 已经足够好了)。

编写游戏

编写游戏乐趣无穷。我最喜欢编程的一点是,它是一种创造的艺术:什么都没有;你写点代码,然后轰隆隆!一切就从虚无中诞生了。游戏开发在这方面尤其强大,因为你有能力创造世界。这在我看来简直太酷了。领域模型绝对胜过我之前开发过的任何应用程序,无论SpaceShip何时令人赞叹。PlanetBulletElderPurchaseOrder

游戏设计,是不是很酷?

由于我开发这款游戏的主要目标是学习如何开发游戏,所以我采取了一种非常开放和探索性的方法:我称之为“如果……会不会很酷”的游戏设计。我知道我想做一款太空射击游戏,因为我觉得这比其他类型的游戏简单得多,但我并没有花太多时间去规划游戏。我只是开始编写不同的独立机制,并问自己:如果……会不会很酷……

  • 这些小行星的纹理很漂亮吗?
  • 它们有不同的形状和大小吗?
  • 当船只被摧毁时,他们会丢弃资源来给船只充电或修理吗?
  • 船舶推进器会排放颗粒物吗?
  • 有几个派系拥有不同的船只和目标?
  • 有神秘而又极其危险的外星人在周围游荡?
  • 当没有可用能源时,游戏中的各种船舶系统会停止工作吗?
  • 你能宣称拥有行星吗?
  • 您可以拥有这些星球的经济并建造防御工事、船舶等吗?
  • 你可以使用不同的武器系统和方式向敌人发动攻击并进行毁灭吗?
  • 就这样一直持续下去……

虽然开发游戏很有趣,但这意味着到了比赛最后一天,我只能做出一堆几乎孤立的机制,却无法完成一款游戏。游戏中有飞船、小行星、行星、太阳、星区、星系、外星人,但却没有任何东西可以将它们组合成一个类似游戏的东西。

因此,在最后一天,我和儿子 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()
      }
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

除了这些游戏对象(它们只是工厂的扩展kontra.sprite({...}),代表游戏中可见且可交互的任何对象)之外,我还创建了一些抽象:Scene以及游戏对象Game本身。场景非常有助于表示游戏的不同部分,并以有意义的方式对游戏对象进行分组(例如开放场景、太空场景、游戏结束场景等),同时游戏提供了一种集中状态管理、控制游戏音乐、预加载资源以及在场景之间切换的方法。

生成编程

我大部分时间都在做两件事:

  1. 我绞尽脑汁去研究牛顿物理学和三角学的基本原理,
  2. 设计简单的算法来生成纹理、粒子、名称和星系。

让我们仔细看看第二个问题,你可能会更感兴趣。总的来说,在开发这些算法时,我遵循了以下几条规则:

  1. 尽快完成工作并进行迭代
  2. 想想第一原则。你会如何从零开始做到这一点?

像素化纹理

对于行星的纹理,我希望达到一种像素艺术感,但又不至于看起​​来太糟糕(所以期望值很低 :D)。我一开始选择了三种类型的行星:红色、绿色和蓝色,并构思用这些单独的颜色生成完整的调色板。

我立刻想到,HSL颜色模型是生成这些调色板的绝佳候选。HSL代表HueSaturationLightness英文意思是如果我上下改变亮度,就会得到一个调色板。我就是这么做的。我的第一个算法使用单一颜色,并构建了一个包含 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}`
}
Enter fullscreen mode Exit fullscreen mode

由于每次需要时都创建一个图案的开销比较大,所以我用相同的颜色和大小对每个图案进行了记忆。通俗地说,记忆就是保存函数调用的结果和一些参数,这样我就不用重复处理相同的结果了。在这里,它指的是纹理创建后就保存起来,以便反复使用。

这里还有很大的改进空间,我很乐意进行更多实验,并能够生成陆地、云层等。不过结果已经相当不错了,我很喜欢我的行星的外观。: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
}
Enter fullscreen mode Exit fullscreen mode

核心在于计算画布中游戏对象位置的函数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
}
Enter fullscreen mode Exit fullscreen mode

名字

我最初的想法是探索一个无限大的星系,手动给每个星系、恒星和行星命名显然行不通。我的想象力最多只能想出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
}
Enter fullscreen mode Exit fullscreen mode

粒子

我爱粒子!我觉得它们能增添一种难以言喻的魅力,让游戏的观感和体验都更加出色。在编写粒子引擎的时候(虽然“引擎”这个词对于一些功能来说有点过于宏大了),我问了自己什么是粒子?这引发了一场关于生命、宇宙以及一切终极问题的有趣对话。不过,我不会费心去解释细节……最终,我总结道:粒子是从源头以不同方向、速度和加速度涌现的小精灵,它们会随着时间的推移逐渐消散。所以我的粒子引擎需要:

  • 创建从原点发芽的粒子
  • 给定方向和速度(我没有考虑加速度,我敢打赌那将是一件很棒的事情)
  • 粒子的存活时间会有所不同
  • 随着时间的推移,粒子会逐渐变小,最终消失
  • 粒子会有不同的颜色,你可以配置

差不多就是这样了。这是用于子弹的粒子示例,最终看起来像彗星的尾巴:

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)
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

星系

正如我前几节所说,我最初的想法是生成一个看似无限的星系供玩家探索。我当时想,如果游戏难度足够高,足够有挑战性,玩家在厌倦探索宇宙之前就会死去。我本来很想探索在玩家探索过程中生成星系的想法,但最终,随着截止日期的临近,我选择了 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,
    }),
  ]
}
Enter fullscreen mode Exit fullscreen mode

远古长老种族

我想给游戏加点儿辣味,比如辣椒或者辣味胡椒,让它更有挑战性,也更有趣。因为我没太多时间去思考和构思游戏的深度背景,所以我选择了一个科幻奇幻的比喻——“长老种族”

我希望玩家能够对抗至少三种不同类型的敌人:

  • 超快、短程、弱小但极具攻击性的飞行器:无人机
  • 一个中等规模的单位,相当坚固,可以在行星和恒星周围巡逻:哨兵
  • 一艘巨大、坚固、威力巨大、罕见的、可以随意运输和喷射无人机的战舰:母舰

我的想法是让这些种族以不同的规模在不同的星系中定居,并建立一个中央星系,在那里驻扎所有舰队,并拥有所有舰队的母舰。游戏初期,我并不确定这个古老种族的角色或最终目标是什么,但后来我确定他们是最后一颗适合人类生存的星球的守护者,因此也是游戏的最终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
}
Enter fullscreen mode Exit fullscreen mode

该接口由一组任意属性组成,BehaviorProperties行为本身需要这些属性才能运行,以及updaterender自然生命周期挂钩的方法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)
      }
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

我用这个函数来编写这个普通函数的方式SpritecomposeBehavior

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

其中beforeafter是效用函数:

/* 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))
  }
}
Enter fullscreen mode Exit fullscreen mode

因此,利用这种行为组合,我可以定义一系列行为并将它们附加到不同的长老船上,如下所示:

// 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...
Enter fullscreen mode Exit fullscreen mode

这很好,因为它节省了 Kb,并允许我随意配置和附加行为,对长老,以及将来可能对其他 AI 控制的派系。

像素艺术

我热爱像素画,但我只是一个彻头彻尾的业余像素艺术家。为了打造这款游戏,我至少想手绘一艘酷炫的宇宙飞船。为了获得漂亮的像素效果,我选择了 32x32 的精灵图,像素大小为 2x2,并且调色板有限。我使用了Piskel,这是一款非常优秀的网页像素画创作应用。下面是一些我制作的不同飞船以及 Piskel 编辑器的示例:

红色船的不同版本的像素艺术

Piskel 编辑器中的红船

Piskel 编辑器中的绿色飞船

Piskel 编辑器中的蓝色飞船

音乐

音乐是游戏中至关重要的元素。它能提升游戏的沉浸感,为玩家提供反馈,营造合适的氛围,并激发玩家的情绪(兴奋、恐惧、紧张、平静等等)。由于 13KB 的限制,我立即想到了生成音乐(我在 Twitter 上听到过很多关于它的讨论)以及使用Web Audio API。但我遇到了两个障碍:

  • 我对音乐一无所知,无论以何种方式、形式或形式
  • 我不知道 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()
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

结论

这真是太有趣了!!!如果你以前没参加过 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
PREV
职业发展的最佳途径
NEXT
如何构思出色的副业