본문 바로가기
Projects/RxJS로 산성비 만들기

RxJS로 산성비 만들기(4) - 게임 로직 만들기

by 슥짱 2023. 2. 20.

게임 만들기 프로젝트였는데 앞에서 힘을 다 빼느라 포스팅 시리즈 4회만에 게임 로직을 만들게 됐다. 바로 본론으로 들어가보자!

Game 클래스


getInstance()

Game은 단 하나의 인스턴스만 가질 수 있도록 싱글턴 패턴으로 작성되었다. 생성된 인스턴스가 없는 경우 새로운 인스턴스를 만들어 반환하고, 이미 만들어진 인스턴스가 있으면 그것을 반환한다.

Game.getInstance = function (textList, $canvas, $form) {
  if (!Game._instance) {
    const game = new Game(textList, $canvas, $form);
    Game._instance = game;
    return game;
  } else {
    return Game._instance;
  }
};

 

constructor()

생성자 함수를 살펴보면, Game은 아래와 같은 프로퍼티들을 갖는다.

function Game(textList, $canvas, $form) {
  this.textList = textList;

  this.$canvas = $canvas;
  this.ctx = $canvas.getContext("2d");
  this.ctx.font = "20px Verdana";

  this.$form = $form;

  this.score$ = new BehaviorSubject(0);
  this.life$ = new Life();
  this.life$.subscribe((life) => life || this.over());
  this.words = new WordDrops();

  this.hitEffect = hitEffect;
  
  this.subscriptions = [];
  
  this._onGameStart;
  this._onGameOver;
}

Game.prototype = {
  set onGameStart(cb) {
    this._onGameStart = cb;
  },

  set onGameOver(cb) {
    this._onGameOver = cb;
  },
};
  • textList: 게임의 단어로 사용될 낱말들
  • $canvas, ctx: 게임을 페인팅할 canvas 엘리먼트와 컨텍스트
  • $form: 단어를 입력한 form 엘리먼트. form 보다는 input을 사용하는 편이 아무래도 적합할테지만, input의 keyboard 이벤트로 한글을 다룰 때 어려운 점이 있어, form을 사용하기로 했다. 이에 대한 설명은 다른 글에서 다루도록 하겠다.
  • score$: 점수를 담는 프로퍼티
  • life: 목숨을 담는 프로퍼티. 목숨이 0이되는 순간 `over` 메소드를 실행해 게임을 종료한다.
  • words: 화면에 보이는 단어들의 목록
  • hitEffect: 정답을 입력했을 때 실행할 오디오 객체
  • subscriptions: 게임 종료시 해제할 구독들을 담는 배열
  • _onGameStart_onGameOver: 게임의 실행과 종료 시 호출할 함수. 프로토타입의 setter를 지정해두었기 때문에 다음과 같이 게임 객체 외부에서 콜백을 전달할 수 있다.
const game = Game.getInstance(textList, $canvas, $form);
game.onGameStart = () => console.log('game start');
game.onGameOver = () => console.log('game over');

 

class 대신 function으로 작성한 것은 큰 이유는 없고, 단순히 프로토타입을 학습하기 위함이다. 당장은 이렇게 작성했지만, 코드의 통일성을 위해 이후에는 class로 바꾸는 편이 좋을 듯 하다.

start()

Game 객체를 생성했으면, start() 메소드를 호출해 게임을 실행할 차례다.

Game.prototype.start = function () {
  this.ctx.clearRect(0, 0, this.$canvas.clientWidth, this.$canvas.clientHeight);
  this.textList.sort(() => Math.random() - 0.5);
  this.life$.reset();
  this.score$.next(0);

  const pause$ = new Subject();
  const interval$ = timer(randomBetween(700, 1800)).pipe(
    repeat(),
    scan((prev) => prev + 1, 0),
    share(),
    pausable(pause$)
  );

  const words$ = interval$.pipe(
    map((n) => ({
      $canvas: this.$canvas,
      text: this.textList[n],
      count: n,
      hitEffect: this.hitEffect,
    })),
    scan((words, data) => {
      const rand = Math.random();
      let newWord;

      if (rand < 0.5) newWord = new WordDrop(data);
      else if (rand < 0.98)
        newWord = new BlueWordDrop({ words, pause$, ...data });
      else if (rand < 1)
        newWord = new GoldenWordDrop({ life$: this.life$, ...data });
        
      return words.add(newWord.text, newWord);
    }, new WordDrops())
  );

  const submitEvent$ = fromEvent(this.$form, "submit");

  const wordsHit$ = submitEvent$.pipe(
    tap((e) => e.preventDefault()),
    map((e) => e?.target?.querySelector(".input").value),
    withLatestFrom(words$),
    map(([input, words]) => words.hit(input)),
    tap((score) => {
      this.score$.next(this.score$.getValue() + score);
      this.$form.reset();
    })
  );

  const render$ = animationFrames().pipe(combineLatestWith(words$));

  const wordsHitSubscription = wordsHit$.subscribe();
  const renderSubscription = render$.subscribe(([_, words]) => {
    this.ctx.clearRect(
      0,
      0,
      this.$canvas.clientWidth,
      this.$canvas.clientHeight
    );

    words.draw(this.ctx);
    words.update(this.life$);
  });

  this.subscriptions.push(wordsHitSubscription, renderSubscription);

  this._onGameStart && this._onGameStart();
};

 

코드가 조금 길다. 조금씩 나누어 살펴보자.

Game.prototype.start = function () {
  this.ctx.clearRect(0, 0, this.$canvas.clientWidth, this.$canvas.clientHeight);
  this.textList.sort(() => Math.random() - 0.5);
  this.life.reset();
  this.score$.next(0);

  // ...
};

가장 먼저, 게임 재시작 시에 이전 게임의 화면이 남아있을 수 있으므로 화면을 한 번 지우고, 프로퍼티들을 초기화 해준다. 단어가 항상 같은 순서로 나타나면 재미 없으니 게임 실행시마다 단어의 순서를 랜덤으로 정렬하고, 목숨과 점수를 0으로 만든다.

 

단어 생성 주기 역시 재미를 위해 랜덤하게 생성하도록 했다. 그 구현은 다음과 같다.

Game.prototype.start = function () {
  // skip ...
  const pause$ = new Subject();
  const interval$ = timer(randomBetween(700, 1800)).pipe(
    repeat(),
    scan((prev) => prev + 1, 0),
    pausable(pause$)
    share(),
  );
  // skip ...
};

보통 어떤 일을 같은 주기마다 실행해야할 경우 interval()을 사용할테지만, 위 코드에선 랜덤한 주기마다 생성하기 위해 timer()에 랜덤한 값을 넣어주고, repeat()로 무한 반복하도록 했다.

 

randomBetween() 함수는 두 인수 사이의 랜덤한 값을 반환하는 함수로, 다음과 같이 간단히 작성했다. 

const randomBetween = (min, max) => {
  return min + Math.random() * (max - min);
};

 

scan()에 누적된 값은 단어의 인덱스로, 위에서 정렬한 단어를 순서대로 출력함과 동시에 시간이 지남에 따라 단어의 속도를 높이기 위해 사용된다.

 

위에서 가장 중요한 것은 pausable()이다. pausable()은 RxJS의 기본 연산자는 아니고, 타이머를 임의로 중단했다가 재개할 수 있도록 하는 커스텀 연산자이다.

function pausable(pause$) {
  return (source) => {
    return new Observable((observer) => {
      let paused = false;

      const subscription = source.subscribe((val) => {
        if (paused) return;
        observer.next(val);
      });

      const pauseSubscription = pause$.subscribe(
        (pausedValue) => (paused = pausedValue)
      );

      return () => {
        subscription.unsubscribe();
        pauseSubscription.unsubscribe();
      };
    });
  };
}

pausable()은 정확히는 연산자를 반환하는 고차 함수이다. 그리고 연산자는 이후 스트림에서 연결할 수 있는 새로운 옵저버블을 반환한다. 여러 함수가 겹쳐있어 조금 복잡해보이는데, Netanel Basal의 블로그를 읽어보면 금방 이해할 수 있을 것이다.

 

인수로 받은 pause$의 상태 변화에 따라, 옵저버블의 중단 여부가 결정된다. pause$ 값이 변경되면, 이를 구독하는 pauseSubscription에 의해 paused의 값을 바꾼다. 이렇게 pausedtrue가 되면, source.subscribe()에서다음 값을 발행하지 않게 된다.

 

마지막으로 클린업 함수에서 구독을 모두 취소해 메모리 누수가 발생하지 않도록 한다.

 

이제 단어 객체를 만들어보자.

Game.prototype.start = function () {
  // skip...
  const words$ = interval$.pipe(
    map((n) => ({
      $canvas: this.$canvas,
      text: this.textList[n],
      count: n,
      hitEffect: this.hitEffect,
    })),
    scan((words, data) => {
      const rand = Math.random();
      let newWord;

      if (rand < 0.96) newWord = new WordDrop(data);
      else if (rand < 0.98)
        newWord = new BlueWordDrop({ words, pause$, ...data });
      else if (rand < 1)
        newWord = new GoldenWordDrop({ life$: this.life$, ...data });

      return words.add(newWord.text, newWord);
    }, new WordDrops())
  );
  // skip...
}

interval$ 스트림을 위와 같이 piping하고, 랜덤한 확률로 다른 단어 객체(WordDrop)을 만들어 WordDrops 객체에 저장한다.

값이 바뀔 수 있는 newWord라는 변수를 적으니 뭔가 죄지은 느낌이지만, 괜히 얼리 리턴 방식으로 하려하면 코드가 더 지저분해져서 이대로 두었다. 부수효과가 없으니 괜찮은 것 같기도하고... 함수형에 익숙치 않아 판단이 잘 서지 않는다. 더 좋은 방식이 있다면 누구든 피드백 해줬으면 한다.

 

이번엔 입력이 들어온 단어를 체크할 차례다. form이 제출되면, withLatestForm() 연산자를 사용해 words$ 스트림의 마지막 누적 값을 가져온 뒤, hit() 메소드를 사용해 입력 값과 같은 이름의 단어가 있으면 이를 제거하고 점수를 반환 받는다. 마지막으로 받환받은 점수를 누적하고, 유저가 바로 다음 입력을 할 수 있도록 form을 reset 해준다.

  const submitEvent$ = fromEvent(this.$form, "submit");

  const wordsHit$ = submitEvent$.pipe(
    tap((e) => e.preventDefault()),
    map((e) => e?.target?.querySelector(".input").value),
    withLatestFrom(words$),
    map(([input, words]) => words.hit(input)),
    tap((score) => {
      this.score$.next(this.score$.getValue() + score);
      this.$form.reset();
    })
  );
  const wordsHitSubscription = wordsHit$.subscribe();

 

이제 마지막으로 화면을 렌더링할 차례다. RxJS의 animationFrame() 함수를 사용해 animationFrame 스트림을 만들고, combinLatest()를 사용해 words$ 스트림의 마지막 누적 값을 가져온다. 그리고 render$를 구독해, 매 프레임마다 화면을 갱신해주면 된다.

  const render$ = animationFrames().pipe(combineLatestWith(words$));
  const renderSubscription = render$.subscribe(([_, words]) => {
    this.ctx.clearRect(
      0,
      0,
      this.$canvas.clientWidth,
      this.$canvas.clientHeight
    );

    words.draw(this.ctx);
    words.update(this.life);
  });

  this.subscriptions.push(wordsHitSubscription, renderSubscription);

  this._onGameStart && this._onGameStart();

start() 메소드에서 사용된 구독들은 subscriptions에 담아둔다. 게임이 종료되면 over() 메소드에서 이를 순회하며 구독을 해제할 것이다.

over(), destory()

게임이 종료되면 over() 메소드에서 모든 구독을 해제하고, 외부에서 받은 _onGameOver() 메소드를 호출한다. destroy()는 게임 객체를 파괴하는 메소드이다.

Game.prototype.over = function () {
  this.subscriptions.forEach((sub) => sub.unsubscribe());
  this._onGameOver && this._onGameOver();
};

Game.prototype.destroy = function () {
  this.life.complete();
  this.score$.complete();
  this.subscriptions.forEach((sub) => sub.unsubscribe());
  Game._instance = null;
};

getLifeStream(), getScoreStream()

게임 외부 UI에서 게임 내 목숨과 점수에 접근할 수 있도록 getter를 두었다.

Game.prototype.getLifeStream = function () {
  return this.life;
};

Game.prototype.getScoreStream = function () {
  return this.score$;
};

 

전체 코드

마지막으로 Game 클래스의 전체 코드와 WordDrop, WordDrops는 더보기로 남겨놓는다.

더보기
function Game(textList, $canvas, $form) {
  this.textList = textList;
  this.subscriptions = [];

  this.$canvas = $canvas;
  this.ctx = $canvas.getContext("2d");
  this.ctx.font = "20px Verdana";

  this.$form = $form;

  this.score$ = new BehaviorSubject(0);
  this.life$ = new Life();
  this.life$.subscribe((life) => life || this.over());

  this.words = new WordDrops();

  this.hitEffect = hitEffect;

  this._onGameStart;
  this._onGameOver;
}

Game.prototype = {
  set onGameStart(cb) {
    this._onGameStart = cb;
  },

  set onGameOver(cb) {
    this._onGameOver = cb;
  },
};

Game.prototype.start = function () {
  this.ctx.clearRect(0, 0, this.$canvas.clientWidth, this.$canvas.clientHeight);
  this.textList.sort(() => Math.random() - 0.5);
  this.life$.reset();
  this.score$.next(0);

  const pause$ = new Subject();
  const interval$ = timer(randomBetween(700, 1800)).pipe(
    repeat(),
    scan((prev) => prev + 1, 0),
    share(),
    pausable(pause$)
  );

  const words$ = interval$.pipe(
    map((n) => ({
      $canvas: this.$canvas,
      text: this.textList[n],
      count: n,
      hitEffect: this.hitEffect,
    })),
    scan((words, data) => {
      const rand = Math.random();
      let newWord;

      if (rand < 0.5) {
        newWord = new WordDrop(data);
      } else if (rand < 0.98) {
        newWord = new BlueWordDrop({ words, pause$, ...data });
      } else if (rand < 1) {
        newWord = new GoldenWordDrop({ life$: this.life$, ...data });
      }

      return words.add(newWord.text, newWord);
    }, new WordDrops())
  );

  const submitEvent$ = fromEvent(this.$form, "submit");

  const wordsHit$ = submitEvent$.pipe(
    tap((e) => e.preventDefault()),
    map((e) => e?.target?.querySelector(".input").value),
    withLatestFrom(words$),
    map(([input, words]) => words.hit(input)),
    tap((score) => {
      this.score$.next(this.score$.getValue() + score);
      this.$form.reset();
    })
  );
  const wordsHitSubscription = wordsHit$.subscribe();

  const render$ = animationFrames().pipe(combineLatestWith(words$));
  const renderSubscription = render$.subscribe(([_, words]) => {
    this.ctx.clearRect(
      0,
      0,
      this.$canvas.clientWidth,
      this.$canvas.clientHeight
    );

    words.draw(this.ctx);
    words.update(this.life$);
  });

  this.subscriptions.push(wordsHitSubscription, renderSubscription);

  this._onGameStart && this._onGameStart();
};

Game.prototype.over = function () {
  this.words.clear();
  this.subscriptions.forEach((sub) => sub.unsubscribe());
  this._onGameOver && this._onGameOver();
};

Game.prototype.getLifeStream = function () {
  return this.life$;
};

Game.prototype.getScoreStream = function () {
  return this.score$;
};

Game.prototype.destroy = function () {
  this.life$.complete();
  this.score$.complete();
  this.pause$.complete();
  this.subscriptions.forEach((sub) => sub.unsubscribe());
  Game._instance = null;
};
function WordDrop({ $canvas, text, count, hitEffect }) {
  this.$canvas = $canvas;
  this.ctx = $canvas.getContext("2d");

  this.text = text;
  this.count = count;
  this.x = WordDrop.x({
    $canvas,
    fontSize: 20,
    textLength: text.length,
  });
  this.y = 0;
  this.ogSpeed = WordDrop.speed(count);
  this.crntSpeed = this.ogSpeed;

  this.color = "white";
  this.fontSize = 20;

  this.hitEffect = hitEffect;
}

WordDrop.x = function ({ $canvas, fontSize, textLength }) {
  return Math.random() * ($canvas.width - textLength * fontSize);
};

WordDrop.speed = function (count) {
  const speed = Math.random() + WordDrop.MIN_SPEED * 1.02 ** count;
  return speed > WordDrop.maxSpeed ? WordDrop.maxSpeed : speed;
};

WordDrop.prototype.hit = function () {
  this.skill();
  this.hitEffect.play();
  return Math.ceil(this.getScore());
};

WordDrop.prototype.skill = function () {};

WordDrop.prototype.getScore = function () {
  return this.ogSpeed * 40 + this.text.length * 20 + 20 * 1.1 ** this.count;
};

WordDrop.prototype.pause = function () {
  this.crntSpeed = 0;
  return;
};

WordDrop.prototype.resume = function () {
  this.crntSpeed = this.ogSpeed;
};

WordDrop.prototype.draw = function () {
  this.ctx.save();
  this.ctx.fillStyle = this.color;
  this.ctx.fillText(this.text, this.x, this.y);
  this.ctx.restore();
};

WordDrop.prototype.update = function () {
  this.y += this.crntSpeed;
};

WordDrop.prototype.isAlive = function () {
  if (this.y <= this.$canvas.height + this.fontSize) return true;
  return false;
};

class GoldenWordDrop extends WordDrop {
  color = "#ffaf24";

  constructor({ life$, ...props }) {
    super(props);
    this.life$ = life$;
  }

  skill() {
    this.life$.increase();
  }
}

class BlueWordDrop extends WordDrop {
  color = "#097cfb";

  constructor({ words, pause$, ...props }) {
    super(props);
    this.words = words;
    this.pause$ = pause$;
  }

  skill() {
    const timer$ = this.pause$.pipe(
      filter((val) => val),
      switchMap(() => timer(WordDrop.PAUSE_TIME))
    );

    const subscription = timer$.subscribe(() => {
      this.words.resume();
      this.pause$.next(false);
      subscription.unsubscribe();
    });

    this.words.pause();
    this.pause$.next(true);
  }
}

WordDrop.MIN_SPEED = 2.5;
WordDrop.MAX_SPEED = 6.5;

WordDrop.COMMON = "common";
WordDrop.GOLD = "gold";
WordDrop.BLUE = "blue";

WordDrop.PAUSE_TIME = 1500;

;BlueWordDrop skill()에 대해서만 간단히 설명하면, skill()는 간단히 단어들의 이동과, 단어 생성 주기를 정해진 시간 동안 얼리는 함수다. 만약 시간 내에 또 다른 BlueWordDrop skill()이 호출되어 pause$에서 true가 발행되면, switchMap을 통해 이전 타이머를 없애고 새로운 타이머가 동작하도록 한다.

BlueWordDrop과 GoldenWordDrop에서 wordspause$life$는 Game 객체의 책임하에 있는 값들이므로, 이들 클래스의 생성자로 받아 프로퍼티로 삼는 것은 좋은 방식은 아닌 것 같다. GameGame의 인스턴스를 넘겨받고 Game에서 위 값들을 주는 API를 만드는 것도 방법인것 같은데... 잘 모르겠다. 실력이 부족하니 코드에서 구린내가 나는 것을 알아도 수정 하지 못하는 상황. 클린 코드를 작성하기 위해 객체지향 설계 공부도 꾸준히 해야겠다. 

 

function WordDrops() {
  this.drops = new Map();
}

WordDrops.prototype.add = function (text, drop) {
  this.drops.set(text, drop);
  return this;
};

WordDrops.prototype.remove = function (text) {
  this.drops.delete(text);
  return this;
};

WordDrops.prototype.hit = function (input) {
  if (!this.drops.has(input)) return 0;

  const drop = this.drops.get(input);
  const score = drop.hit();
  this.drops.delete(input);

  return score;
};

WordDrops.prototype.pause = function () {
  this.drops.forEach((drop) => drop.pause());
};

WordDrops.prototype.resume = function () {
  this.drops.forEach((drop) => drop.resume());
};

WordDrops.prototype.draw = function (ctx) {
  this.drops.forEach((drop) => {
    drop.draw(ctx);
  });
};

WordDrops.prototype.update = function (life) {
  this.drops.forEach((drop) => {
    drop.update();
    if (drop.isAlive()) return;

    this.drops.delete(drop.text);
    life.decrease();
  });
};

WordDrops.prototype.clear = function () {
  this.drops.clear();
};

 

 

마무리


pausable() 연산자에 대한 고민

pause$라는 외부 변수를 사용하는 것이 좋은 방법은 아닌 것 같아, 옵저버블을 확장한 클래스를 만들어pause(), resume() 메소드를 갖도록 만들려 시도했지만, 내부적으로 이를 막고있는 듯 했다.

class PausableObservable extends Observable {
  pause() {...}
  resume() {...}
}

function pausable(pause$) {
  return (source) => {
    return new PausableObservable(...)
  };
}

const interval$ = timer(randomBetween(700, 1800)).pipe(
  repeat(),
  scan((prev) => prev + 1, 0),
  share(),
  pausable()
);

// PausableObservable에 있던 pause(), resum() 메소드가 사라져있다!!
interval$.pause();
interval$.resume();

 

물론 아래와 같이 static 메소드로 만들면 원하는 바를 달성할 수 있었지만, 이런 방식은 뭔가 치팅하는 듯한 기분이 들어 결국 기존 방식을 사용하기로 했다. RxJS 코어 팀 리드인 Ben Lesh가 옵저버블을 확장하지 않을 것을 강력히 권고한다고 하니, 옵저버블을 확장해 여러 기능을 붙이기보다는, 여러 스트림을 결합하는 방식을 지향해야겠다.

function pausable() {
  return (source) => {
    let pause = false;
    const observable =  new Observable((observer) => {
      //...
    });

    observable.pause = () => {...}
    observable.resume = () => {...}

    return observable;
  };
}

const interval$ = timer(randomBetween(700, 1800)).pipe(
  repeat(),
  scan((prev) => prev + 1, 0),
  share(),
  pausable()
);

// 이건 가능!
interval$.pause();
interval$.resume();

짬뽕 프로그래밍 스타일 🍲🍲🍲

어쩌다보니 함수형과 객체지향이 버무려진 짬뽕 프로그래밍 스타일이 된것 같다. 사실 처음에는 게임의 로직의 대부분을 객체지향 방식으로 작성하고, 컴포넌트에서 작성한 것과 같이 같이 일부 클래스의 속성만 옵저버블을 두는 식으로 작성했다. 그렇게 코드 작성을 마치고 보니 프로젝트의 목적에서 너무 벗어나고, RxJS를 활용하는 코드가 너무 적어졌다고 생각이 들어, 결국 주요 로직의 일부를 RxJS를 활용한 반응형으로 재작성하게 됐다.

 

연산자에 대해 학습하는데에는 조금 시간이 걸렸지만, 이를 활용하고 코드를 작성하는 일은 훨씬 수월했고 체감상 코드량이 절반은 감소한 것 같다. 함수형  반응형의 선언적인 프로그래밍 방식의 위력이 세삼 체감됐다. 다만 일부 기능에 있어서는 클래스를 사용한 다형성을 포기할 수 없어 원래의 코드를 그대로 가져오는 바람에 짬뽕 스타일이 됐다.

 

초보의 식견에서 이런 식으로 두 가지 프로그래밍 방식을 적절히 버무려 양쪽의 장점을 취하는 것도 나쁘지 않은 것 같다. 

 

참고자료


https://netbasal.com/creating-custom-operators-in-rxjs-32f052d69457

 

Creating Custom Operators in RxJS

Operators are one of the building blocks of RxJS. The library comes with many operators, which can be used to deal with almost every…

netbasal.com

https://rxjs.dev/guide/observable

 

RxJS

 

rxjs.dev

 

댓글