티스토리 뷰

728x90
SMALL

강의 내용

컴마 연산자

1. 함수의 인자를 구분하는 구분자

2. 지연 연산자. a,b,c의 결과는 c

3. var, let, const에서 구분자

테트리스

패널을 어떻게 나눌것인지.

자바스크립트는 안정화가 끝나면 c로 내려간다. 결국 표준을 쓰는게 이기는거다. ex) proxy, 생성자함수

즉 for문이 forEach보다 빠르니까 for문을 쓴다?? 말도 안되는 소리다. 지금 우리 레벨에서는 그 둘의 성능을 고려한 프로젝트를 하지도 않고 결국 내부 엔진이 발전되면 표준(foreach)이 빠르다.

map쓰면 객체만드니까 느리다고?? 그냥 써라. 어차피 표준이 결국 빠르다. 그리고 너넨 이거 고민할 레벨이 아니다.(라고 강사님이 말했다)

스터디

일단 나는 객체지향에 대해서 생각했다. 각자 스터디에 임하는 마음가짐과 목표는 다르지만 나는 객체지향을 이해하는 것이다. 그래서 강사님이 왜 이렇게 코드를 만들었고 객체지향적으로 어떻게 짠건지 이런 관점에서 전체 코드를 다시봤다. 그리고 변수명을 너무 대충지어서 도저히 알아볼 수 없어서 내가 변수명이나 자잘한부분을 수정했다.

먼저 사용하는 유틸함수를 적어두겠다. 강의에 나온것들이고 굳이 이렇게 사용하지 않아도 상관없다.

export const prop = (target, value) => Object.assign(target, value);
export const createElement = (element) => document.createElement(element);
export const setBackgroundColor = (style, value) => (style.backgroundColor = value);
export const sel = (str) => document.querySelector(str);

처음으로 돌아가서 테트리스를 어떻게 만들지 생각해보자. 게임이 어떻게 돌아가지?? 1초마다 블록이 떨어지고 회전이 가능하고 블록 이동이 가능하고 점수도 있고 스테이지도 있다. 그리고 시작화면, 끝화면, 랭킹화면 등도 있다. 이렇게 일단 해당 도메인을 분석해야 한다. 이미 알고있는 게임 혹은 웹사이트라고 할지라도 분석을 하고 만드는 것과 분석하지 않고 생각나는 것부터 만드는 것은 큰 차이가 있다. 이렇게 분석하다보면 테트리스에는 스테이지, 점수, 블록, 화면(시작화면, 끝화면 등등)(패널), 실제 게임 등등으로 나눌 수 있는 것을 알 수 있다. 게임로직은 당연하게도 복잡하다. 우리는 간단한것부터 처리해야한다. 스코어, 스테이지는 간단할 것 같다. 간단한 것부터 처리하면 TDD도 가능하다. 요즘 웹에서는 작은 컴포넌트부터 큰 컴포넌트로 확장해서 만든다. 이때 스토리북정도만 도입해도(jest, react testing library를 도입하면 더 좋다) 개발이 굉장히 편해진다. 만약 실제 테트리스 로직부터 무턱대로 만든다면 바꾸는게 골치아프고 테스트코드를 짜는것도 쉽지 않다.

 

스코어, 스테이지

stage.js

import { prop } from './utils';

export const Stage = class {
  constructor(last, minSpeed, maxSpeed, listener) {
    prop(this, { last, minSpeed, maxSpeed, listener });
  }
  clear() {
    this.stage = 0;
    this.next();
  }
  _speed() {
    const rate = (this.stage - 1) / (this.last - 1);
    this.speed = this.minSpeed + (this.maxSpeed - this.minSpeed) * (1 - rate);
    // this.speed = 500 - (450 * this.stage) / this.last;
  }
  _count() {
    this.count = 10 + 3 * this.stage;
  }
  next() {
    if (this.stage++ < this.last) {
      this._speed();
      this._count();
      this.listener();
    }
  }
  // score(line) {
  //   return parseInt(this.stage * 5 * 2 ** line);
  // }
  [Symbol.toPrimitive]() {
    return this.stage;
  }
};

여러가지 방법으로 사용될 수 있어서 그런부분은 주석처리를 해두었다. 까먹고 지우지 않은게 아니다.
스테이지에서는 this._speed()를 통해 this.speed 값을 변경하고 this._count()를 통해 this.count 값을 변경한다. 많이 사용되는 패턴이라고 한다. 사실 어디선가 본적이 있다. 그리고 Symbol.toPrimitive라는 조금은 생소한 용어가 있다.

객체를 해당 Symbol.toPrimitive기본 값으로 변환하기 위해 호출되는 함수 값 속성을 지정하는 기호입니다. - mdn

즉 쉽게 this.stage에 접근할 수 있다. this.stager가 number이기 때문에(clear가 실행됐을 때) number로 사용하는 경우 stage.stage가 아니라 stage로 접근할 수 있다.

 

또한 score를 어디서 stage에서 관리해야하는지 score에서 관리해야하는지 정말 고민을 많이 했다. 생각이 몇번씩이나 바뀌었고 글을 쓰는 지금도 생각이 바뀔려고 한다. 심지어 강사님도 2회차수업에서는 score에서 관리하고 3회차수업에서는 stage에서 관리한다. 어떤 방법도 좋을수있고 나쁠수도 있다. 지금은 어디서 관리를 하던 별 상관이 없지만 실제 업무에서는 다르다. 이걸 어디서 관리하느냐에 따라 큰 파장이 올수도 있다. 그리고 내생각에 그건 나 혼자서 쉽게 알 수 없다. 지금은 맞지만 1년뒤에는 틀릴수도 있다. 그건 아무도 모른다. 물론 그렇다고 해서 아무생각 없이 대충 쓰라는 말은 아니다. 일단 나는 score에서 관리하기로 했다.

import { prop } from './utils';

export const Score = class {
  constructor(stage, listener) {
    prop(this, { stage, listener });
  }
  clear() {
    this.score = this.total = 0;
  }
  add(line) {
    // const score = this.stage.score(line);
    const score = parseInt(this.stage * 5 * 2 ** line);
    this.score += score;
    this.total += score;
    this.listener();
  }
  [Symbol.toPrimitive]() {
    return `${this.score},${this.total}`;
  }
};

score는 저번시간에도 설명했고 크게 어려운게 없다. 위에서 말한 score를 만드는 함수를 어디에 저장할 것인지 그게 문제다.

 

stage와 score가 결합되어 있다는 것은 조금 아쉽다. 하지만 이정도 결합은 봐줄만하다고 생각한다. 그리고 테트리스와 완전히 독립적이다. 점수 관련된 로직을 수정하고 싶다면 score를 스테이지 관련된 로직을 수정하거나 추가하고 싶다면 stage만 수정하면 끝이다. 여기에 간단한 jest로 테스트를 하면 더 좋을것같다. 미래의 내가 하겠지 ㅎㅎ..

 

Block

import { prop } from './utils';

export const Block = (() => {
  const makeBlock = (color, block) =>
    class extends Block {
      constructor() {
        super(color, block);
      }
    };
  const Block = class {
    static block() {
      return new this.blocks[parseInt(Math.random() * this.blocks.length)]();
    }
    constructor(color, blocks) {
      prop(this, { color, blocks, rotate: 0, count: blocks.length - 1 });
    }
    left() {
      if (--this.rotate < 0) this.rotate = this.count;
    }
    right() {
      if (++this.rotate > this.count) this.rotate = 0;
    }
    get block() {
      return this.blocks[this.rotate];
    }
  };
  Block.blocks = [
    {
      color: '#00C3ED',
      blocks: [[[1], [1], [1], [1]], [[1, 1, 1, 1]]],
    },
    {
      color: '#FBD72B',
      blocks: [
        [
          [1, 1],
          [1, 1],
        ],
      ],
    },
    {
      color: '#B84A9C',
      blocks: [
        [
          [0, 1, 0],
          [1, 1, 1],
        ],
        [
          [1, 0],
          [1, 1],
          [1, 0],
        ],
        [
          [1, 1, 1],
          [0, 1, 0],
        ],
        [
          [0, 1],
          [1, 1],
          [0, 1],
        ],
      ],
    },
    {
      color: '#00FF24',
      blocks: [
        [
          [0, 1, 1],
          [1, 1],
        ],
        [
          [1, 0],
          [1, 1],
          [0, 1],
        ],
        [
          [0, 1, 1],
          [1, 1, 0],
        ],
        [
          [1, 0],
          [1, 1],
          [0, 1],
        ],
      ],
    },
    {
      color: '#FF1920',
      blocks: [
        [
          [1, 1, 0],
          [0, 1, 1],
        ],
        [
          [0, 1],
          [1, 1],
          [1, 0],
        ],
        [
          [1, 1, 0],
          [0, 1, 1],
        ],
        [
          [0, 1],
          [1, 1],
          [1, 0],
        ],
      ],
    },
    {
      color: '#2900FC',
      blocks: [
        [
          [1, 0, 0],
          [1, 1, 1],
        ],
        [
          [1, 1],
          [1, 0],
          [1, 0],
        ],
        [
          [1, 1, 1],
          [0, 0, 1],
        ],
        [
          [0, 1],
          [0, 1],
          [1, 1],
        ],
      ],
    },
    {
      color: '#FD7C31',
      blocks: [
        [
          [0, 0, 1],
          [1, 1, 1],
        ],
        [
          [1, 0],
          [1, 0],
          [1, 1],
        ],
        [
          [1, 1, 1],
          [1, 0, 0],
        ],
        [
          [1, 1],
          [0, 1],
          [0, 1],
        ],
      ],
    },
  ].map(({ color, blocks }) => makeBlock(color, blocks));
  return Block;
})();
// new Block.blocks[0]으로 접근

이제 블록을 보자. 테트리스에서는 정해진 몇개의 종류가 있다. 그리고 회전이 가능하다. 하나만 만들고 회전시켜서 만드는 방법과 미리 저장해두고 인덱스로 접근하는 방법이 있는데 모든 경우를 저장하게 만들었다. 이건 저번 리뷰때 설명해서 넘어간다. 결국 몇개의 블록만 만들면 된다. 코드의 형태가 조금 생소할 수는 있지만 크게 어렵지는 않아서 설명은 생략한다.

 

그리고 강사님의 의도는 듣지 못했지만 나는 메모리적으로도 이점이 잇는 코드라고 생각한다. 미리 블록을 만들고 랜덤으로 블록을 가져오기만 하면 된다. 매번 새롭게 new Block으로 만드는게 아니다.

 

Data, Renderer, Table Renderer

data.js

import { prop } from './util';

export const Data = class extends Array {
  constructor(row, col) {
    super();
    prop(this, { row, col });
  }
  makeCell(row, col, color, test) {
    if (row > this.row || col > this.col || row < 0 || col < 0 || color === '0') return this;
    const thisRow = this[row] || (this[row] = []);
    if (color && thisRow[col]) test.isIntersacted = true;
    thisRow[col] = color;
    return this;
  }
  makeRow(row, ...color) {
    return color.forEach((v, i) => this.makeCell(row, i, v)), this;
  }
  all(...rows) {
    return rows.forEach((v, i) => this.makeRow(i, ...v)), this;
  }
};

 

renderer.js

import { Data } from '../data';
import { prop } from '../utils';

export const Renderer = class {
  constructor(row, col) {
    prop(this, { row, col });
  }
  clear() {
    throw 'override';
  }
  render(data) {
    if (!(data instanceof Data)) throw 'invalide data';
    this._render(data);
  }
  _render(data) {
    throw 'override!';
  }
};

 

table-renderer.js

import { createElement, setBackgroundColor } from '../utils';
import { Renderer } from './renderer';

export const TableRenderer = class extends Renderer {
  constructor(row, col, backgroundColor) {
    super(row, col);
    this.backgroundColor = backgroundColor;
    this.base = createElement('table');
    while (row--) {
      const tr = base.appendChild(createElement('tr'));
      const curr = [];
      this.blocks.push(curr);
      let i = col;
      while (i--) curr.push(tr.appendChild(createElement('td')).style);
    }
    this.clear();
  }
  clear() {
    this.blocks.forEach((block) => block.forEach((s) => setBackgroundColor(s, this.backgroundColor)));
  }
  _render(v) {
    this.blocks.forEach((block, row) => block.forEach((s, col) => setBackgroundColor(s, v[row][col])));
  }
};

 

먼저 여기서 data와 renderer는 테트리스 게임화면(행렬)을 말한다. 모든것을 그려주는 곳이 아니다.

 

게임화면을 그려주는 것은 table renderer다. 그런데 왜 data나 renderer가 필요한걸까?? table renderer는 직접적으로 dom 조작을 하는 곳이다. html tag로 조작하는 것이 아니라 canvas로 조작하고 싶다면 전체를 수정해야 한다. 이걸 조금 분리할수는 없을까?? renderer라는 추상클래스를 만들고 이 클래스를 상속받는 클래스들을 만들수있다. ex) table renderer, canvas renderer

 

이제 추상클래스도 만들었으니 renderer에 직접 접근하면 될까?? 아니다. 중간 프로토콜이 필요하다. 게임에서 블록을 가져오고 블록을 데이터에 넣고 데이터에서 렌더러에 접근하는 과정이 필요하다. 그리고 이 과정에서 instanceof로 타입체킹도 가능하다.

 

Panel

import { prop } from './util';

export const Panel = class {
  static get(game, selectBase, _render) {
    const p = new Panel();
    p.init(game, selectBase(game), _render);
    return p;
  }
  init(game, base, _render) {
    prop(this, { game, base, _render });
  }
  render(v) {
    this._render?.(this.game, v);
  }
};

 

판넬. 렌더러와 어떻게 보면 렌더러라고 할 수도 있다. 지금 말하는 판넬은 게임, 시작, 끝, 랭킹, 스테이지 로딩 같은 화면을 얘기한다. panel.base를 통해 각 판넬의 베이스에 접근할 수 있고(table renderer인 경우 html 엘리먼트에 접근) 특정 경우에 panel.render를 실행킨다. 판넬에는 게임 인스턴스가 직접적으로 들어가기 때문에 게임과 연관지어서 설명을 해야한다. 스코어, 스테이지와 마찬가지로 결합이 좀 쌘편이다. 다만 게임클래스 하나로 이루어져 있는 것을 판넬로 분리했기 때문에 이경우도 괜찮다고 생각한다. 

 

Game

import { Panel } from './panel';
import { Score } from './score';
import { Stage } from './stage';
import { Data } from './data';
import { prop, sel } from './utils';

const TState = {};
'title,stageIntro,play,dead,stageClear,clear,ranking'.split(',').forEach((v) => (TState[v] = Symbol()));
const Game = class {
  constructor(base, row, col, ...v) {
    const stage = new Stage(10, 1, 10);
    prop(this, { base, row, col, state: {}, curr: 'title', score: new Score(stage), stage });
    v.forEach(({ game, selectBase, render }) => {
      this.state[game] = Panel.get(this, selectBase, render);
    });
  }
  // ex) state : Game.stageIntro
  setState(state) {
    if (!Object.values(TState).includes(state)) throw 'invalid';
    this.curr = state;
    // constructor의 while문에서 Panel.get을 했기 때문에 Panel의 base값
    const {
      state: {
        [this.curr]: { base: pannelBase },
      },
    } = this;
    // 아래 세줄은 base객체에 clear 메서드 넣어서 초기화시켜야함.
    // 지금은 이해를 돕기위해 도메인, 네이티브 분리를 안함.
    this.base.innerHTML = '';
    this.base.appendChild(pannelBase);
    pannelBase.style.display = 'block';
    // 현재 심볼에 해당하는 메서드 실행
    this[this.curr]();
  }
  _render(v) {
    const {
      state: { [this.curr]: pannelBase },
    } = this;
    pannelBase.render(v);
  }
  [TState.title]() {
    this.stage.clear();
    this.score.clear();
  }
  [TState.stageIntro]() {
    this._render(this.stage);
  }
  [TState.play]() {
    const data = new Data(this.row, this.col);
    // TODO ....
    this._render(data); // update
    this._render(Block.block()); // next
  }
  [TState.stageClear]() {}
  [TState.dead]() {}
  [TState.clear]() {}
  [TState.ranking]() {}
};
Object.entries(TState).forEach(([str, symbol]) => (Game[str] = symbol));
Object.freeze(Game);

const game = new Game(
  sel('body'),
  20,
  10,
  {
    game: Game.title,
    selectBase: (game) => {
      sel('#title .btn').onclick = () => game.setState(Game.stageIntro);
    },
    render: null,
  },
  {
    game: Game.stageIntro,
    selectBase: (game) => sel('#stageIntro'),
    render: (game, v) => {
      sel('#stageIntro .stage').innerHTML = v;
      setTimeout((_) => game.setState(Game.play), 500);
    },
  },
  {
    game: Game.play,
    selectBase: (game) => {
      const t = new TableRenderer(game.row, game.col, '#000');
      sel('#play').appendChild(t.base);
      sel('#play').renderer = t;
      return sel('#play');
    },
    render: (v) => {
      switch (true) {
        case v instanceof Data:
          sel('#play').renderer.render(v);
          break;
        case v instanceof Block:
          v = v.block;
          const t = new TableRenderer(
            v.reduce((p, v) => (v.length > p ? v.length : p), 0),
            v.length,
            'rgba(0,0,0,0)',
          );
          t.base.style.cssText = 'width:100px;height:100px;border:0px;border-spacing:0;border-collapse:collapse';
          sel('#play .next').innerHTML = '';
          sel('#play .next').appendChild(t.base);
          t.render(new Data(5, 5).all(...v.map((v) => (v == '0' ? '0' : v.color))));
          break;
      }
    },
  },
);

 

시작하기에 앞서서 symbol에 대해서 알아야한다. 모든 symbol은 고유한 값을 가진다. 잘 모르겠다면 공식문서를 찾아보자

Tstate의 key에는 string을(ex title) value에는 고유한 symbol값을 넣는다.

const TState = {};
'title,stageIntro,play,dead,stageClear,clear,ranking'.split(',').forEach((v) => (TState[v] = Symbol()));

 

그리고 Game.title로 symbol값에 접근할 수 있게 만들어준다. 이렇게하면 마치 typescript를 사용하는 것처럼 안전하게 할 수 있다.

Object.entries(TState).forEach(([str, symbol]) => (Game[str] = symbol));
Object.freeze(Game);

 

constructor에서 base, row, col, panel 객체를 받는다. 그리고 게임에 필요한 값들을 this에 저장한다. 위에서 만든 stage, score 클래스가 game의 this에 할당된다. 그리고 위에서 만든 panel을 this.state에 저장한다.

  constructor(base, row, col, ...v) {
    const stage = new Stage(10, 1, 10);
    prop(this, { base, row, col, state: {}, curr: 'title', score: new Score(stage), stage });
    v.forEach(({ game, selectBase, render }) => {
      this.state[game] = Panel.get(this, selectBase, render);
    });
  }

 

setState에서는 위에서 만든 symbol로 가장 먼저 타입을 체킹한다. 

그리고 this.base(body라고 생각하면 편하다)를 초기화시키고 pannelBase(panel.base)를 base에 append해준다. 주석에 적어놨지만 지금은 도메인과 네이티브가 분리되어있지 않다. 당연하지만 다음 글에서는 이 코드가 사라질 것이다. 그리고 현재 시몰에 해당하는 메서드를 실행한다. 만약 Game.stageIntro라면 아래에 [TState.stageIntro]라는 메서드를 실행한다.

  // ex) state : Game.stageIntro
  setState(state) {
    if (!Object.values(TState).includes(state)) throw 'invalid';
    this.curr = state;
    // constructor의 while문에서 Panel.get을 했기 때문에 Panel의 base값
    const {
      state: {
        [this.curr]: { base: pannelBase },
      },
    } = this;
    // 아래 세줄은 base객체에 clear 메서드 넣어서 초기화시켜야함.
    // 지금은 이해를 돕기위해 도메인, 네이티브 분리를 안함.
    this.base.innerHTML = '';
    this.base.appendChild(pannelBase);
    pannelBase.style.display = 'block';
    // 현재 심볼에 해당하는 메서드 실행
    this[this.curr]();
  }

 

위와 마찬가지의 방법으로 pannelBase를 불러오고 render함수를 실행시킨다.

  _render(v) {
    const {
      state: { [this.curr]: pannelBase },
    } = this;
    pannelBase.render(v);
  }

 

이제 아래에서 게임 인스턴스를 만들면 된다.

 

정리

게임 클래스는 도메인만 들어가있다.(세줄있는건 어차피 지울거니까 제외하고) 그리고 게임 인스턴스에만 네이티브가 들어가있다. 그리고 지금까지 많은 클래스들을 만들었지만 table renderer와 게임 인스턴스말고는 네이티브 역할이 들어가있지 않다. 분리할 수 있는 최대한을 분리했다. 그리고 각자의 역할과 책임에 충실하게 만들었다. 내가 혼자서 테트리스를 만들었다면 이렇게 만들 수 있을까?? 하는 생각이 든다. 지금은 게임 로직이 전혀 들어가있지 않은데 말이다. 처음으로 객체지향적인 코드를 본 느낌이다. 아쉬운점이라면 html 직접 정보를 넣었다는점?? 하지만 그렇게 바꾸는것과 객체지향은 상관이 없다. 또한 다음 강의가 디자인패턴임을 봤을땐 이렇게 만든게 충분히 납득이 간다. 그리고 다음시간에는 strategy 패턴을 사용해본다고 한다. 오리 꽥꽥 하는 책에서만 본 디자인 패턴을 여기에 어떻게 적용할지 궁금하고 기대가 된다. 다음글에서는 강의 내용을 바탕으로 테트리스를 직접 만들어봐야겠다.

 

https://www.youtube.com/watch?v=Z8cvHrT9dIQ&list=PLBA53uNlbf-vuKTARH6Ka7a_Jp0OVT_AY&index=4&ab_channel=HikaMaeng

https://www.youtube.com/watch?v=swvlwrsKnUo&list=PLBA53uNlbf-vuKTARH6Ka7a_Jp0OVT_AY&index=3&ab_channel=HikaMaeng

728x90
LIST
댓글
공지사항