MOODUL 07 · TASE 3 — KESKMINE

🏗️ Mängu Arhitektuur & Disainimustrid

ECS (Entity Component System), State Machine, Observer — mustrid mis hoiavad su koodi puhtana ka 10 000 rida koodi juures.

⏱️ ~6-8 tundi 📝 6 harjutust 🟠 Keskmine

🧠 Samm 1 — State Machine (Olekumasin)

Olekumasin haldab objekti käitumist (idle, patrolling, attacking). Iga olek teab mida teha ja millal vahetada.

StateMachine.ts
interface State {
  name: string;
  enter(): void;
  update(dt: number): void;
  exit(): void;
}

class StateMachine {
  private states = new Map<string, State>();
  private current: State | null = null;

  add(state: State): void {
    this.states.set(state.name, state);
  }

  change(name: string): void {
    if (this.current) this.current.exit();
    this.current = this.states.get(name) || null;
    if (this.current) this.current.enter();
  }

  update(dt: number): void {
    if (this.current) this.current.update(dt);
  }

  get currentState(): string {
    return this.current?.name || 'none';
  }
}

// Vaenlase olekud
class IdleState implements State {
  name = 'idle';
  private enemy: Enemy;
  private timer = 0;

  constructor(enemy: Enemy) { this.enemy = enemy; }

  enter() { this.timer = 0; }

  update(dt: number) {
    this.timer += dt;
    // Mängija lähedal? → jahtimine!
    const dist = distance(this.enemy.pos, player.pos);
    if (dist < 200) {
      this.enemy.fsm.change('chase');
    }
    // Igav? → patrulli
    if (this.timer > 3) {
      this.enemy.fsm.change('patrol');
    }
  }

  exit() {}
}

class ChaseState implements State {
  name = 'chase';
  private enemy: Enemy;

  constructor(enemy: Enemy) { this.enemy = enemy; }

  enter() { /* äratuse animatsioon */ }

  update(dt: number) {
    // Liigu mängija suunas
    const dir = normalize(subtract(player.pos, this.enemy.pos));
    this.enemy.pos.x += dir.x * this.enemy.speed * dt;
    this.enemy.pos.y += dir.y * this.enemy.speed * dt;

    // Piisavalt lähedal? → ründa!
    const dist = distance(this.enemy.pos, player.pos);
    if (dist < 40) {
      this.enemy.fsm.change('attack');
    }
    // Liiga kaugel? → tagasi idle
    if (dist > 400) {
      this.enemy.fsm.change('idle');
    }
  }

  exit() {}
}

🧱 Samm 2 — Entity Component System (ECS)

ECS lahutab andmed (Components) käitumisest (Systems). Entiteedid on lihtsalt ID-d.

ecs.ts — Lihtne ECS
// Komponendid — puhas andmed, mitte loogika!
interface PositionComponent {
  type: 'position';
  x: number;
  y: number;
}

interface VelocityComponent {
  type: 'velocity';
  vx: number;
  vy: number;
}

interface SpriteComponent {
  type: 'sprite';
  image: HTMLImageElement;
  width: number;
  height: number;
}

interface HealthComponent {
  type: 'health';
  current: number;
  max: number;
}

interface ColliderComponent {
  type: 'collider';
  width: number;
  height: number;
  layer: string; // 'player' | 'enemy' | 'bullet'
}

type Component = PositionComponent | VelocityComponent | SpriteComponent
  | HealthComponent | ColliderComponent;

// Entity — lihtsalt ID + komponentide kogu
type EntityId = number;

class World {
  private nextId: EntityId = 0;
  private entities = new Map<EntityId, Map<string, Component>>();
  private systems: System[] = [];

  createEntity(): EntityId {
    const id = this.nextId++;
    this.entities.set(id, new Map());
    return id;
  }

  addComponent(entity: EntityId, component: Component): void {
    this.entities.get(entity)?.set(component.type, component);
  }

  getComponent<T extends Component>(entity: EntityId, type: string): T | undefined {
    return this.entities.get(entity)?.get(type) as T | undefined;
  }

  // Leia kõik entiteedid kellel on nõutud komponendid
  query(...types: string[]): EntityId[] {
    const result: EntityId[] = [];
    for (const [id, components] of this.entities) {
      if (types.every(t => components.has(t))) {
        result.push(id);
      }
    }
    return result;
  }

  removeEntity(entity: EntityId): void {
    this.entities.delete(entity);
  }

  addSystem(system: System): void {
    this.systems.push(system);
  }

  update(dt: number): void {
    for (const system of this.systems) {
      system.update(this, dt);
    }
  }
}

// Süsteemid — loogika!
interface System {
  update(world: World, dt: number): void;
}

class MovementSystem implements System {
  update(world: World, dt: number): void {
    const entities = world.query('position', 'velocity');
    for (const id of entities) {
      const pos = world.getComponent<PositionComponent>(id, 'position')!;
      const vel = world.getComponent<VelocityComponent>(id, 'velocity')!;
      pos.x += vel.vx * dt;
      pos.y += vel.vy * dt;
    }
  }
}

class RenderSystem implements System {
  constructor(private ctx: CanvasRenderingContext2D) {}

  update(world: World, dt: number): void {
    const entities = world.query('position', 'sprite');
    for (const id of entities) {
      const pos = world.getComponent<PositionComponent>(id, 'position')!;
      const spr = world.getComponent<SpriteComponent>(id, 'sprite')!;
      this.ctx.drawImage(spr.image, pos.x, pos.y, spr.width, spr.height);
    }
  }
}

// Kasutamine:
// const world = new World();
// world.addSystem(new MovementSystem());
// world.addSystem(new RenderSystem(ctx));
// const player = world.createEntity();
// world.addComponent(player, { type: 'position', x: 100, y: 200 });
// world.addComponent(player, { type: 'velocity', vx: 0, vy: 0 });

👀 Samm 3 — Observer Muster

observer.ts — Sündmuste süsteem
// Observer — objektid kuulavad sündmusi
class EventEmitter<Events extends Record<string, any>> {
  private listeners = new Map<string, Set<Function>>();

  on<K extends keyof Events>(event: K, fn: (data: Events[K]) => void): () => void {
    if (!this.listeners.has(event as string))
      this.listeners.set(event as string, new Set());
    this.listeners.get(event as string)!.add(fn);

    // Tagasta unsubscribe funktsioon
    return () => this.off(event, fn);
  }

  off<K extends keyof Events>(event: K, fn: Function): void {
    this.listeners.get(event as string)?.delete(fn);
  }

  emit<K extends keyof Events>(event: K, data: Events[K]): void {
    this.listeners.get(event as string)?.forEach(fn => fn(data));
  }
}

// Mängu sündmused
interface Events {
  'score:add': number;
  'player:hit': { damage: number };
  'enemy:die': { x: number; y: number; reward: number };
  'level:up': { level: number };
}

const events = new EventEmitter<Events>();

// UI kuulab skoori muutusi
events.on('score:add', (points) => {
  scoreDisplay.textContent = `Skoor: ${totalScore += points}`;
});

// Audio kuulab vaenlase surma
events.on('enemy:die', (data) => {
  audio.play('explosion');
  spawnParticles(data.x, data.y);
});

// Mänguloogika emiteerib sündmusi
function killEnemy(enemy: Enemy) {
  enemy.destroy();
  events.emit('enemy:die', {
    x: enemy.pos.x, y: enemy.pos.y, reward: 10
  });
  events.emit('score:add', 10);
}

🏭 Samm 4 — Scene Manager

SceneManager.ts
interface Scene {
  name: string;
  init?(data?: any): void;
  update(dt: number): void;
  draw(ctx: CanvasRenderingContext2D): void;
  destroy?(): void;
}

class SceneManager {
  private scenes = new Map<string, Scene>();
  private stack: Scene[] = [];

  register(scene: Scene): void {
    this.scenes.set(scene.name, scene);
  }

  // Vaheta stseeni (eelmine kaob)
  goto(name: string, data?: any): void {
    const current = this.stack.pop();
    if (current?.destroy) current.destroy();

    const scene = this.scenes.get(name);
    if (scene) {
      if (scene.init) scene.init(data);
      this.stack.push(scene);
    }
  }

  // Lisa stseen peale (pause overlay)
  push(name: string, data?: any): void {
    const scene = this.scenes.get(name);
    if (scene) {
      if (scene.init) scene.init(data);
      this.stack.push(scene);
    }
  }

  // Eemalda ülemine stseen
  pop(): void {
    const scene = this.stack.pop();
    if (scene?.destroy) scene.destroy();
  }

  update(dt: number): void {
    // Ainult ülemine stseen saab update'i
    const top = this.stack[this.stack.length - 1];
    if (top) top.update(dt);
  }

  draw(ctx: CanvasRenderingContext2D): void {
    // Joonista kõik stseenid (alumised nähtavad pause ajal)
    for (const scene of this.stack) {
      scene.draw(ctx);
    }
  }
}

// const scenes = new SceneManager();
// scenes.register(new MenuScene());
// scenes.register(new GameScene());
// scenes.register(new PauseScene());
// scenes.goto('menu');
// // Kui vajutad ESC:
// scenes.push('pause');
// // Kui jätkad:
// scenes.pop();

📝 Harjutused

  • State Machine vaenlasele

    Implementeeri StateMachine ja loo vaenlane kolme olekuga: Idle (seisab), Patrol (liigub edasi-tagasi), Chase (jälitab mängijat). Visualiseeri olek värviga.

  • ECS süsteemiga mäng

    Ehita lihtne ECS — World, Entity, Component, System. Loo MovementSystem, RenderSystem, CollisionSystem. 50 entiteeti mis liiguvad ja põrkavad.

  • Observer sündmuste süsteem

    Ehita EventEmitter ja integreeri mängu: collision → sound + particles + score. Eraldi moodulid ei tea teineteisest — ainult sündmustest!

  • Scene Manager

    Implementeeri SceneManager stack'iga. Loo 4 stseeni: Menu, Game, Pause (overlay), GameOver. Andmete edastamine stseenide vahel (skoor, tase).

  • Object Pool muster

    Implementeeri ObjectPool kuulide ja partiklite jaoks. Võrdlus: mõõda FPS 1000 partikli puhul pooliga ja ilma (new/destroy). Visualiseeri erinevus.

  • Kombineeri kõik mustrid

    Ehita väike mäng mis kasutab kõiki mustreid: ECS entiteetide jaoks, StateMachine AI-le, Observer sündmuste jaoks, SceneManager stseenidele, ObjectPool kuulidele.

← Moodul 06b Moodul 07b: Algoritmid →