2D Game Engine

Obiekty i komponenty

GameObject, transformacje, tagi, hierarchia i komponenty z fazami systemowymi. Jak budować zachowania bez ECS.

GameObject#

GameObject to encja domenowa: transform lokalny, tagi, grupy, hierarchia, komponenty oraz deskryptory render i body. Nie trzyma instancji Phasera.

ts
const player = this.createSprite('hero', { x: 100, y: 200 })

player
  .setPosition(120, 220)
  .setScale(2)
  .setDepth(10)
  .setAlpha(0.9)
  .addTag('player')
  .addTag('damageable')

API#

ts
get destroyed(); get x(); get y(); get alpha(); get depth()
get rotation(); get scaleX(); get scaleY(); get world(): Transform

setPosition(x, y); translate(dx, dy); setRotation(rad)
setScale(x, y?); setVisible(bool); setActive(bool)
setAlpha(a); setDepth(d); setScrollFactor(f); setTint(color | null)
setRender(render | null); setBody(body | null)

addTag(tag); removeTag(tag); hasTag(tag); hasAnyTag(tags); hasAllTags(tags)
addChild(child); removeChild(child)
add<T>(component): T; get<T>(ctor); has(ctor); remove(ctor); getComponents()
destroy(): void
markWorldDirty(); markRenderDirty()

Settery są chainable i oznaczają obiekt jako brudny — renderer dostanie go w najbliższej klatce.

Transformacje i hierarchia#

ts
const turret = this.createRect(24, 24, { x: 0, y: -20, fill: '#8899aa' })
const tank = this.createSprite('tank', { x: 400, y: 300 })
tank.addChild(turret)

tank.setPosition(500, 300)
turret.world.x
  • world to transform po złożeniu z rodzicami — odczyt O(1) po przeliczeniu.
  • Zmiana transformacji rodzica unieważnia tylko jego poddrzewo.
  • setScrollFactor(0) przykleja obiekt do ekranu (HUD).

Tagi i wyszukiwanie#

ts
enemy.addTag('enemy').addTag('flying')

this.findByTag('enemy')
this.findByTags(['enemy', 'flying'], 'all')
this.find((obj) => obj.hasTag('enemy') && obj.x > 800)

Indeks tagów jest aktualizowany przy addTag/removeTag, więc wyszukiwanie nie skanuje wszystkich obiektów.

Komponenty#

Komponent to klasa z fazą systemową i cyklami życia:

ts
import { Component, type GameObject } from '@engine'

export class PlayerController extends Component {
  phase = 'input' as const
  private speed = 240

  onUpdate(dt: number) {
    const input = this.owner.getInput()
    let vx = 0
    let vy = 0
    if (input.isActionDown('move-left')) vx -= this.speed
    if (input.isActionDown('move-right')) vx += this.speed
    if (input.isActionDown('move-up')) vy -= this.speed
    if (input.isActionDown('move-down')) vy += this.speed
    this.owner.translate(vx * dt, vy * dt)
  }
}
ts
player.add(new PlayerController())
const controller = player.get(PlayerController)
controller.enabled = false
player.remove(PlayerController)

Fazy systemowe#

FazaKiedy w klatceTypowe zastosowanie
inputPrzed Scene.updateSterowanie
behaviorPo Scene.updateAI, logika zachowań
physicsPo behaviorSiły, prędkości
animationPo physicsKlatki animacji
renderNa końcuPrzygotowanie renderu

Cykl życia komponentu#

ts
class Bobbing extends Component {
  phase = 'behavior' as const
  private baseY = 0
  private t = 0

  onAttach() {
    this.baseY = this.owner.y
  }

  onUpdate(dt: number) {
    this.t += dt
    this.owner.setPosition(this.owner.x, this.baseY + Math.sin(this.t * 3) * 6)
  }

  onDetach() {}
  onDestroy() {}
}

Deskryptory render i body#

ts
player.setRender({ type: 'sprite', textureId: 'hero', frame: 0 })
player.setBody({ type: 'arcade', width: 24, height: 32, immovable: false })

Deskryptor to dane, nie instancja runtime. Adapter tłumaczy je na obiekty Phasera i synchronizuje tylko gdy są brudne.

Dlaczego nie ECS?

Prototyp ECS został oceniony i odrzucony (ADR 0003). Komponenty z fazami dają przewidywalną kolejność i prostszy model mentalny przy zachowaniu wydajności: 0,72 ms/klatkę dla 20 000 obiektów.

Usuwanie obiektów#

ts
this.destroyObject(bullet)
this.destroyObject('hud-temp')
this.destroyAllObjects()

Usuwanie jest kolejkowane (pendingDestroy) i wykonywane na końcu klatki: potomkowie liść-pierwszy → onDestroy komponentów → usunięcie z indeksu → render.destroy. Nigdy nie mutujesz listy obiektów w trakcie iteracji.