FIT ČVUT

Adam Vesecký

NI-APH
Přednáška 5

Patterny

Pokud se herní vývojáři kdy zaobírali návrhovými vzory, málokdy vytvořili něco komplexnějšího než singleton. Kniha Design Patterns se ve své původní podobě na hry vůbec nehodí.Robert Nystrom

Návrhové vzory

Návrhové vzory v aplikacích

Návrhové vzory ve hrách

Procesní návrhové vzory

Data-Passing Komponenty

  • ~vizuální programování
  • hlavním aspektem je modelování přenosu dat a signálů z jedné komponenty do druhé
  • každá komponenta má porty, na které je možno připojit daný stream
  • vyžaduje vizuální editor
  • vhodné pro zpracování dynamických dat (shadery, animace, jednoduché AI)

Unreal Blueprinty

Unity Visual Scripting

Příklad: Godot Editor (zrušen ve verzi 4)

Event System

  • hry jsou obecně postavené na událostech

Co potřebujeme

  • event emitter (knihovna, která řeší posílání událostí)
  • seznam klíčových událostí, které se ve hře odehrávají
  • u každé komponenty seznam událostí, na které má reagovat a které může sama posílat

Procesy a akce

  • Proces - zpravidla něco, co se odehrává po dobu více než 1 framu
    • prakticky vše, co je nějakým způsobem navázané na animace

Příklad: Pacman

Chain

  • Chain - vzor, který umožňuje provést v daném pořadí sekvenci událostí
  • Implementace

    • callback chaining - dostupné prakticky v každém jazyku, nedostatečná robustnost
    • iterator blocks - C#
    • async/await - JavaScript a C#
    • generátory - JavaScript
    • coroutine - Kotlin, Ruby, Lua,...

Příklad: Chain (C#)

1
2 public async Task EnterDoorAction(Door door) {
3 this.Context.Player.BlockInput();
4 await new DoorAnimation(door).Open();
5 await new WalkAnimation(this.Context.Player).Walk(this.Context.Player.direction);
6 this.Context.Player.Hide(); // hide the sprite once it approaches the house
7 await new DoorAnimation(door).Close();
8 await Delay(500); // wait for 500 ms
9 }
10
11 .......
12
13 public async Task OnPlayerDoorApproached(Door door) {
14 await new EnterDoorAction(door);
15 await new SceneLoader(door.TargetScene);
16 }

Příklad: Chain (COLFIO knihovna)

1 this.owner.addComponent(new ChainComponent()
2 .call(() => player.blockInput())
3 .waitFor(new DoorAnimComponent(DoorActions.OPEN))
4 .waitFor(new WalkAnim(player, direction))
5 .call(() => player.hide())
6 .waitFor(new DoorAnimComponent(DoorActions.CLOSE))
7 .waitTime(500));

Delayed Invocation

  • volání, které má nastat až za nějaký čas
  • vždy je potřeba preferovat featury doporučené enginem nad built-in funkcemi skriptovacího jazyka
    • např. setTimeout() v JavaScriptu je navázaný na event loopu platformy, ne na game loopu JS enginů
  • příklad: Unity Delayed Invocation
    1 IEnumerator Spawn () {
    2 // Create a random wait time before the prop is instantiated.
    3 float waitTime = Random.Range(minTimeBetweenSpawns, maxTimeBetweenSpawns);
    4 // Wait for the designated period.
    5 yield return new WaitForSeconds(waitTime);
    6
    7 // Instantiate the prop at the desired position.
    8 Rigidbody2D propInstance = Instantiate(backgroundProp, spawnPos, Quaternion.identity);
    9 // Restart the coroutine to spawn another prop.
    10 StartCoroutine(Spawn());
    11 }

Delayed Invocation příklad (COLFIO knihovna)

1 // wait for 2 seconds and load another scene
2 this.sendMessage(Messages.PAUSE);
3 this.scene.callWithDelay(2000, () => {
4 Factory.loadScene(Scenes.MAIN_MENU);
5 });
6 this.finish();

Delayed Invocation příklad (KKD)

1 void Item::SpawnDeferred(const std::function<void(ItemEntity&)>;& OnSpawned, const QuatT& transform) {
2 ExecuteDeferred([this, OnSpawned, transform]() {
3 auto* spawnedItem = entitySystem->SpawnUnsafeItem(transform);
4 if (spawnedItem) {
5 OnSpawned(*spawnedItem);
6 }
7 });
8 }
9 //=========================================================================
10 Deferrable::~Deferrable() {
11 GetDeferredSystem().CancelAllDeferred(*this);
12 }
13
14 //=========================================================================
15 template <class Fn>
16 bool Deferrable::ExecuteDeferred(Fn&& fn) const {
17 const auto ret = GetDeferredSystem().ExecuteDeferred(std::forward<Fn>(fn), *this);
18 return ret;
19 }

Separation of concerns

  • obvyklý problém je zpracování komplexních událostí na jednom místě
  • řešení: použít události a delegovat zpracování jinam
  • na jednom místě
    1 if(asteroid.position.distance(rocket.position) <= MIN_PROXIMITY) { // detect proximity
    2 rocket.runAnimation(ANIM_EXPLOSION); // react instantly and handle everything
    3 asteroid.runAnimation(ANIM_EXPLOSION);
    4 playSound(SOUND_EXPLOSION);
    5 asteroid.destroy();
    6 rocket.destroy();
    7 }
  • odděleně
    1 // collision-system.ts
    2 let collisions = this.collisionSystem.checkProximity(allGameObjects);
    3 collisions.forEach(colliding => this.sendEvent(COLLISION_TRIGGERED, colliding));
    4 // rocket-handler.ts
    5 onCollisionTriggered(colliding) {
    6 this.destroy();
    7 this.sendEvent(ROCKET_DESTROYED);
    8 }
    9 // sound-component.ts
    10 onGameObjectDestroyed() {
    11 this.playSound(SOUND_EXPLOSION);
    12 }

Antipattern: Quake death script

1 void() PlayerDie = {
2 DropBackpack();
3 self.weaponmodel="";
4 self.view_ofs = '0 0 -8';
5 self.deadflag = DEAD_DYING;
6 self.solid = SOLID_NOT;
7 self.flags = self.flags - (self.flags & FL_ONGROUND);
8 self.movetype = MOVETYPE_TOSS;
9
10 if (self.velocity_z < 10)
11 self.velocity_z = self.velocity_z + random()*300;
12
13 DeathSound();
14
15 if (self.weapon == IT_AXE) {
16 player_die_ax1 ();
17 return;
18 }
19
20 i = 1 + floor(random()*6);
21 if (i == 1)
22 player_diea1();
23 else if (i == 2)
24 player_dieb1();
25 else player_diec1();
26 };

Responsibility ownership

  • určuje, která komponenta by měla být zodpovědná za daný scope/akci/rozhodnutí
  • vícero způsobů; mělo by být konzistentní napříč hrou
  • pokud se daný problém týká pouze jedné entity, měla by ten problém řešit jedna z jejích komponent
    • příklad: dělník jde do lesa pro dřevo
  • pokud se daný problém týká vícero entit, měl by se v rámci ECS řešit v systémech (či v komponentě rodičovských uzlů)
    • příklad: controller bitevní formace

Individuální jednotky

Bitevní formace

Optimalizační vzory

Ukládání dat

Náhodně

Sekvenčně

Flyweight

  • způsob, jak pomocí sdílených dat uchovávat velké množství objektů
  • příklady: instanced rendering, particle systémy, staty jednotek v RTS
  • zde přesuneme pozice a index dlaždice (Sprite) do pole

Execution Order

  • stavy herních objektů jsou konzistentní na začátku a na konci iterace herní smyčky
  • snadno se během updatu dostanou do nekonzistentního stavu - one-frame-off lag
  • možná řešení: bucket update, script execution order (Unity), process priority (Godot)

Objekt A čte předchozí stav objektu B a objekt B čte předchozí stav objektu C

Dirty Flag

  • označí objekty, které byly změnou uvedeny do nekonzistentního stavu
  • příklad: animace, fyzika, transformace (nejčastější)
  • je potřeba flag nastavit vždy, když se změní jakýkoliv závislý atribut

Clean-up

  • Když potřebujeme číst aktuální stav
    • pokud aktuální stav nepotřebujeme, vyhneme se zbytečné rekalkulaci
    • pokud aktuální stav potřebujeme příliš často, hra může zamrznout
  • Dávkové zpracování v předem daném bodě
    • mnohem lepší kontrola
    • stejně může hra zamrznout, pokud je dirty flagů příliš mnoho
  • Na pozadí
    • lépe se rozdrobí výpočetní výkon
    • nebezpečí race-condition

Příklad: Godot Cache

1 void AnimationCache::_clear_cache() {
2 while (connected_nodes.size()) {
3 connected_nodes.front()->get()
4 ->disconnect("tree_exiting", callable_mp(this, &AnimationCache::_node_exit_tree));
5 connected_nodes.erase(connected_nodes.front());
6 }
7 path_cache.clear();
8 cache_valid = false;
9 cache_dirty = true;
10 }
11
12 void AnimationCache::_update_cache() {
13 cache_valid = false;
14
15 for (int i = 0; i < animation->get_track_count(); i++) {
16 // ... 100 lines of code
17 }
18
19 cache_dirty = false;
20 cache_valid = true;
21 }
22

Strukturální vzory

Dvoufázová inicializace

  • obchází nutnost inicializovat objekt přes konstruktor
  • konstruktor vytvoří objekt, metoda init method ho inicializuje
  • objekt můžeme inicializovat vícekrát
  • objekt můžeme alokovat předem, než jej inicializujeme
1 class Brainbot extends Unit {
2
3 private damage: number;
4 private currentWeapon: WeaponType;
5
6 constructor() {
7 super(UnitType.BRAIN_BOT);
8 }
9
10 init(damage: number, currentWeapons: WeaponType) {
11 this.damage = damage;
12 this.currentWeapon = currentWeapons;
13 }
14 }

Context

Context (Blackboard)

  • sdílená datová struktura pro daný scope (nebo celou hru)
  • může obsahovat prakticky cokoliv
  • příklad: score hráče, virtuální peníze, počet životů
  • používá se často v behaviorálních stromech
1 public void OnTriggerEvent(Event evt, GameContext ctx) {
2
3 if(evt.Key == "LIFE_LOST") {
4 ctx.Inventory.clear();
5 ctx.Boosts.clear();
6 ctx.Player.Lives--; // access the context
7 if(ctx.Player.Lives <= 0) {
8 this.FireEvent("GAME_OVER");
9 }
10 }
11 }

Null komponenta

  • Null komponenta či Dummy komponenta
  • navenek je identifikovatelná jako skutečná komponenta, uvnitř ale nic nedělá
  • používá se v případě, kdy je přítomnost skutečné komponenty žádaná, ale její funkcionalita nikoliv (např. pokud vypneme zvuky)
  • příklad: instantní animace pro debugging
    1 class NullAnimComponent extends Component {
    2
    3 constructor() {
    4 super('AnimComponent')
    5 }
    6
    7 onUpdate() {
    8 // immediately end
    9 this.finish();
    10 }
    11 }

Selector

  • funkce která vrací hodnotu
  • centralizuje způsob přístupu k objektu
  • může tvořit hierarchii
1 const getPlayer(scene: Scene) => scene.findObjectByName('player');
2
3 const getAllUnits(scene: Scene) => scene.findObjectsByTag('unit_basic');
4
5 const getAllUnitsWithinRadius(scene: Scene, pos: Vector, radius: number) => {
6 return getAllUnits(scene).filter(unit => unit.pos.distance(pos) <= radius);
7 }
8
9 const getAllExits(scene: Scene) => {
10 const doors = scene.findObjectsByTag('door');
11 return doors.filter(door => !door.locked);
12 }

Stavové vzory

Mutability

  • immutabilní stav je luxus, který si můžou dovolit jen jednoduché hry
  • většina struktur je zkrátka mutabilních
  • selektor nám umožní přístup k atributům, které jsou v hlubší hierarchii
  • dirty flag nám pomůže zjistit, zda se objekt změnil během update
  • transmuter nám pomůže centralizovat komplexní modifikace (o tom za chvíli)
  • messaging nám pomůže zjistit, co se událo za změnu ve hře

Flag

  • bitové pole, které obsahuje binární vlastnosti herního objektu
  • můžeme použít i pro dotazy (např. najdi všechny objekty s flagem MOVABLE)
  • podobné jako stavový automat, jen se liší v použití
  • pokud jsou flagy všech objektů snadno přístupné, je hledání velmi rychlé

Příklad: Flag Table

Numerický stav

  • nejjednodušší stav herního objektu
  • umožňuje implementovat jednoduchý stavový automat
1 // stateless, the creature will jump each frame
2 updateCreature() {
3 if(eventSystem.isPressed(KeyCode.UP)) {
4 this.creature.jump();
5 }
6 }
7
8 // introduction of a state
9 updateCreature() {
10 if(eventSystem.isPressed(KeyCode.UP) && this.creature.state !== STATE_JUMPING) {
11 this.creature.changeState(STATE_JUMPING);
12 eventSystem.handleKey(KeyCode.UP);
13 this.creature.jump();
14 }
15 }

Sestavovací vzory

Builder

  • šablona, která obsahuje atributy, na základě kterých sestaví nový objekt
  • implementuje "chainable" princip - každá metoda vrací zpět samotný builder
1 class Builder {
2 private _position: Vector;
3 private _scale: Vector;
4
5 position(pos: Vector) {
6 this.position = pos;
7 return this;
8 }
9
10 scale(scale: Vector) {
11 this.scale = scale;
12 return this;
13 }
14
15 build() {
16 return new GameObject(this._position, this._scale);
17 }
18 }
19
20 new Builder().position(new Vector(12, 54)).scale(new Vector(2, 1)).build();

Builder: příklad (COLFIO)

1 new Builder(scene)
2 .localPos(this.engine.app.screen.width / 2, this.engine.app.screen.height / 2)
3 .anchor(0.5)
4 .withParent(scene.stage)
5 .withComponent(
6 new FuncComponent('rotation')
7 .doOnUpdate((cmp, delta, absolute) => cmp.owner.rotation += 0.001 * delta))
8 .asText('Hello World', new PIXI.TextStyle({ fill: '#FF0000', fontSize: 80}))
9 .build();

Prototyp

  • Builder vytváří nový objekty na základě vnitřních atributů, Prototyp je vytváří zkopírováním sebe sama
  • v některých implementacích je prototyp reaktivní - pokud jej změníme, změní se všechny instance
  • např. linked prefabs v Unity

Prefabs v Unity

Transmuter

  • modifikuje stav a chování objektu
  • užitečný, pokud změna není triviální
  • můžeme přesunout modifikační proces z komponent do separátních funkcí
1 const superBallTransmuter = (entity: GameObject) => {
2 entity.removeComponent<BallBehavior>();
3 entity.addComponent(new SuperBallBehavior());
4 entity.state.speed = SUPER_BALL_SPEED;
5 entity.state.size = SUPER_BALL_SIZE;
6 return entity;
7 }

Factory

  • Builder sestavuje objekty, Factory orchestruje sestavení
1 class UnitFactory {
2
3 private pikemanBuilder: Builder; // preconfigured to build pikemans
4 private musketeerBuilder: Builder; // preconfigured to build musketeers
5 private archerBuilder: Builder; // preconfigured to build archers
6
7 public spawnPikeman(position: Vector, faction: FactionType): GameObject {
8 return this.pikeman.position(position).faction(faction).build();
9 }
10
11 public spawnMusketeer(position: Vector, faction: FactionType): GameObject {
12 return this.musketeerBuilder.position(position).faction(faction).build();
13 }
14
15 public spawnArcher(position: Vector, faction: FactionType): GameObject {
16 return this.archerBuilder.position(position).faction(faction).build();
17 }
18 }

Simulační vzory

Sandbox

  • plná simulace se odehrává v bezprostřední blízkosti hráče (influence sphere)
  • prostředí dál od hráče se buďto nesimuluje vůbec nebo značně zjednodušeně
  • používá se především v závodních hrách a open-world hrách

Replay

  • umožňuje reprodukovat libovolný stav hry v libovolném čase
  • všechny herní objekty musí mít reprodukovatelné chování (podobné multiplayeru)
  • Řešení a)
    • uložit všechny vstupní události od hráče a přehrát je ve stejném pořadí
    • použito ve hře Doom
    • hra musí být plně deterministická, což dnes skoro nikdy neplatí
  • Řešení b)
    • mít každou akci, která modifikuje herní stav, reverzibilní
    • značně komplikuje náhodný přístup
  • Řešení c)
    • vytvářet snapshot každých X framů
    • použito ve hře Braid

Příklad: Braid

  • 40MB pro ~60 minut replaye, používá klíčové snímky pro interpolaci
  • ukládá se stav hry kromě particle emitorů
  • audio engine měl vlastní timer s 10% marginem

Příklad: Doom Demo Soubor

  • Lump (*.LMP)
  • fixní časová smyčka, 35 FPS (zpracováno příkazem tick)
  • soubor obsahuje pouze údaje z klávesnice při každém ticku
  • hra hraje sama, používajíc vstupní údaje ze souboru
  • 13B header + 4B data pro každý tick ~140B/s

Patterny - shrnutí

  • Builder používáme pro vytváření nových objektů
  • Factory používáme pro orchestraci vytváření objektů
  • Prototyp používáme pro klonování šablonových objektů
  • Chain synchronizuje komplexní procesy
  • Selektor centralizuje informaci, jak se dostat k nějakému objektu
  • Flag uchovává informaci o binárních vlastnostech objektu
  • Numerický stav implementuje jednoduchý stavový automat
  • Transmuter mění vlastnosti herního objektu
  • Context uchovává globální data

Co jsme si řekli

  • Něco o "responsibility ownership" patternu
  • Něco o flyweight patternu
  • Jak funguje selector pattern
  • Jak funguje flag pattern
  • Jak funguje state pattern
  • Jak funguje builder pattern
  • Jak funguje prototype pattern
  • Jak funguje factory pattern

Hláška na závěr

Tak já vám tady chci postavit letiště a vy mě nenecháte zbourat barák starý Mrázkový.Každý, kdo hrál OpenTTD