[2D 메타볼 애니메이션 구현] 6. 메타볼 움직임 구현하기

young_pallete·2023년 3월 27일
0
post-thumbnail

🔗 이 포스트에 대해 더 궁금하신가요? 다음 주소를 참고해주세요!

🗒️ 이 글의 수정 내역 (마지막 수정 일자: 없음)

지난 주는 강아지가 내내 아파서 개발하기가 곤란했어요. 🥲
늦었지만, 부랴부랴 움직임 구현에 대한 글을 써보고자 합니다.

움직임 정의

지난 글까지 잘 살펴보셨다면, 이제 animation을 캔버스에서 동작시키는 로직까지 정상적으로 동작할 거에요!

그렇다면, 이 animation이 어떤 것을 연속적으로 보여주는 건지 생각해봅시다.
우리가 이 애니메이션을 동작시킴으로써 달성하려 하는 것은 무엇일까요? 바로 메타볼이 움직이는 거겠죠?

오늘은 이 움직임을 살펴보고자 합니다.
자. 우리가 Metaball에서 x, y, r 등을 다양하게 설정했는데요.
여기서 메타볼의 캔버스 내에서의 position을 담당하고 있는 것은 바로 x, y입니다.

그러니 매우 간단해요. 그냥 x 값과 y값을 변화시켜주면 돼요.
이 과정을 담당하는 메서드를 move라 하겠습니다.

Metaball.ts

export class DynamicMetaball implements Metaball {
  // ...

  move() {
    this.setX(this.x + this.vx);
    this.setY(this.y + this.vy);
    this.draw();
  }
}

그리고 우리가 값을 변경했으니, 이에 맞는 결과값을 캔버스에 그려내주어야 합니다.
이것을 draw라고 하겠습니다.

export class DynamicMetaball implements Metaball {
  // ...

  draw() {
    this.ctx.save();

    this.ctx.beginPath();

    this.ctx.fillStyle = '#f7f711';
    this.ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2);
    this.ctx.fill();

    this.ctx.closePath();

    this.ctx.restore();
  }
}

과연 이것이 최선일까

그런데 말이죠. 이것이 과연 최선일까요?
지금처럼 하게 된다면 어떤 문제가 발생할 수 있을지 다음 리스트를 보며 고민해봅시다.

  • DynamicMetaballmove는 항상 똑같이 정의될까
  • DynamicMetaballdraw는 항상 똑같이 정의될까
  • 그렇지 않다면 SOLID 원칙에 위배되지는 않는가

저는 이번에 이전 로직들의 퍼포먼스를 비교하는 과정에 있어 이러한 문제들을 유념해야 한다는 것을 여실히 깨달았어요. 그리고 현재의 메서드들을 그대로 사용한다면, 현재의 Metaball을 그대로 사용하기는 곤란하죠.

그렇다면 여기서 파생되는 문제는 어떤 게 있을까요? 👀

  • DynamicMetaball을 그대로 확장해버리면 draw move 메서드에 대해 리스코프 치환의 원칙을 위배할 가능성이 높아요.
  • DynamicMetaball의 움직임을 그렇다고 다시 또 정의한다는 것은 개방-폐쇄 원칙에 위배되죠.

즉, 확장에 있어서도, 재정의에 있어서도 유연하지가 않아요. 이는 좋은 설계라 보기 힘들었어요.

따라서 우리는 이러한 문제들을 해결할 방법이 필요하겠군요! 🙇🏻‍♂️

전략 패턴을 사용하자

사실 이러한 문제를 해결할 수 있는 방안들이 몇 개가 있습니다.
데코레이터 패턴을 사용할 수도 있을 것 같고, 전략 패턴 등 다양한데요.

그 중 저는 전략패턴을 사용해보려 합니다.
전략패턴을 사용하려는 이유는 다음과 같아요.

  • 상속을 통한 오버라이딩으로부터 알고리즘을 분리할 수 있어요. 계속해서 메서드를 오버라이딩하는 과정은 리스코프 치환의 원칙을 위배할 확률이 높습니다. 이것이 항상 나쁜 것은 아니나, 언젠가 유지보수를 할 때 원인추적을 쉽게하기 힘든 문제가 있죠.
  • 또한, 추후 전략에 대해 문제를 수정할 때도 마치 모듈처럼 해당 전략에 대한 객체만 수정해주면 되니 개방-폐쇄 원칙을 만족하죠. 즉 유지보수가 쉬워집니다.

Strategy.ts

이제 전략을 만들어볼까요?

abstract class Strategy {
  abstract exec(...args: unknown[]): void;

  abstract before?: (...args: unknown[]) => void;

  abstract after?: (...args: unknown[]) => void;
}

export class MoveStrategy implements Strategy {
  before?: (...args: unknown[]) => void;

  after?: (...args: unknown[]) => void;

  constructor() {}

  setBefore(callback: (...args: unknown[]) => void) {
    this.before = callback.bind(this);
  }

  setAfter(callback: (...args: unknown[]) => void) {
    this.after = callback.bind(this);
  }

  exec(metaball: DynamicMetaball) {
    // console.log('this: ', metaball, this);
    this.before?.();

    const {
      x,
      y,
      v: {x: vx, y: vy},
    } = metaball;

    metaball.setX(x + vx);
    metaball.setY(y + vy);

    metaball.draw();

    this.after?.();
  }
}

전략 객체에 대해 옵저버에 적용하기

그렇다면, 이제 우리는 옵저버로부터 이를 적용해주면 되겠죠?
한 번 적용해봅시다.

AnimationSubject

export class AnimationSubject implements Subject {
  // 이전 코드 중략... 
  
  public notifyUpdateMoveStrategy({
    moveStrategy,
    key,
  }: IDynamicMetaballMoveStrategy): void {
    this.observers.forEach(observer => {
      if (observer.key === key) {
        (observer as DynamicMetaballsObserver).updateMoveStrategy(moveStrategy);
        observer.update();
      }
    });
  }
}

AnimationObserver

export class DynamicMetaballsObserver implements MetaballsAnimationObserver {
  constructor(public metaballs: DynamicMetaballs, public key: string) {}

  updateMoveStrategy(moveStrategy: MoveStrategy) {
    this.metaballs.setMoveStrategy(moveStrategy);
  }
  
  // 이전 코드...
}

이렇게 하면 우리는 Subject를 통해 구독한 옵저버들에게 MoveStrategy에 대한 것을 전달하여 해당 알고리즘을 시킬 수 있죠.

이때, 나머지들의 로직을 구체화하여 move를 실제로 만들어봅시다!

App.ts


export class MetaballAnimation implements CanvasAnimation {
  // 이전 코드 생략...
  
  setDynamicMetaballMove({moveStrategy, key}: IDynamicMetaballMoveStrategy) {
    this.canvas.setDynamicMetaballMoveStrategy({moveStrategy, key});
  }
  
  // 이전 코드 생략...
}

const $target = document.body;

const app = new MetaballAnimation({
  canvas: new MetaballCanvas({
    gradients: ['#123141', '#235234'],
    width: window.innerWidth,
    height: window.innerHeight,
    type: ECanvasGradientType.linear,
    options: {
      autoplay: true,
    },
  }),
  dataset: {
    static: [{x: 30, y: 100, r: 20}],
    dynamic: [
      {
        x: 120,
        y: 60,
        r: 20,
        v: {x: 0.1, y: 0.1},
        vWeight: 1,
      },
    ],
  },
});

function main() {
  const moveStrategy = new MoveStrategy();

  app.setDynamicMetaballMove({
    moveStrategy,
    key: EMetaballObserverKeys.dynamic,
  });

  app.mount($target);
}

main();

Canvas.ts


export class MetaballCanvas implements GradientCanvas {
  // 이전 코드 생략...
  
  constructor({
    type,
    width,
    height,
    gradients,
    options,
  }: Omit<
    GradientCanvas,
    | '$canvas'
    | 'ctx'
    | 'render'
    | 'mount'
    | 'draw'
    | 'getLinearGradient'
    | 'getRadialGradient'
  >) {
    // 이전 코드 생략...

    this.init();
  }

  init() {
    this.$canvas.width = this.width;
    this.$canvas.height = this.height;
  }

  setDynamicMetaballMoveStrategy({
    moveStrategy,
    key,
  }: IDynamicMetaballMoveStrategy) {
    this.metaballAnimationSubject.notifyUpdateMoveStrategy({
      moveStrategy,
      key,
    });
  }
  
  draw(background: CanvasGradient) {
    this.ctx.clearRect(0, 0, this.width, this.height);

    this.ctx.fillStyle = background;

    this.ctx.fillRect(0, 0, this.width, this.height);
    
    // 기존의 컨텍스트 정보를 바뀌지 않도록 저장해줍시다.
    this.ctx.save();
  }
}

이렇게 하면 이제, 우리는 main을 통해 호출하여 원하는 moveStrategy를 바깥쪽에서 달아줄 수 있게 되었어요 😄

한 번 동작시켜볼까요?

잘 동작하는군요!

🎉 마치며

가장 힘든 주말을 보냈어요.
강아지가 아픈 바람에, 오늘까지 해야 할 개발도 제대로 하질 못했네요.

물론 지금도 전혀 낫지를 않아서 마음을 졸이고 있지만,
마음이 아픈 만큼 더 간절하게, 열심히 개발해야겠다는 생각을 갖게 되었어요.

이번에 꽤나 긴 글로 작성되었는데, 끝까지 읽어주셔서 감사드려요 🥰
전략 패턴은 생각보다 많은 곳에서 사용되는 패턴이니, 알아두면 정말 좋은 것 같아요.

다음에는, draw 로직에 대해 좀 더 재사용할 수 있게 할 수 있지 않을까 고민하며, 이를 리팩토링하는 시간을 갖도록 할게요. 이상!

profile
People are scared of falling to the bottom but born from there. What they've lost is nth. 😉

0개의 댓글