• [CSS 3D] 인터랙티브 웹 효과 구현하기 (2)

    2019. 8. 12.

    by. 나나 (nykim)

    + 이 글의 내용은 인프런의 [인터랙티브 웹 개발 제대로 시작하기] 강좌의 '종합예제' 부분 내용을 담고 있습니다.

    + 앞부분의 내용은 [CSS 3D] 인터랙티브 웹 효과 구현하기 (1) 에 있어요!

     


     

    1. 스크롤에 맞춰 캐릭터 움직이기

     

    지금은 캐릭터가 가만히 있어도 허우적거리고 있는데, 이유는 .running 클래스를 붙인 상태로 만들었기 때문입니다.

    이 클래스가 스크롤에 맞춰 유동적으로 붙도록 하면서, 방향 설정도 함께 하겠습니다.

    스크롤을 내리면 캐릭터가 벽쪽을 향하는 거고, 스크롤을 올리면 캐릭터가 내쪽을 바라보는 거죠.

    우선 Character() 생성자 함수 부분에서 .char에 붙어있던 .running 클래스를 제거해주세요.

     

    자! 캐릭터의 움직임은 메서드로 만들어 제어할 건데요.

    생성자 객체로 만들어진 인스턴스 객체가 공통으로 사용하는 속성이나 메서드는 어디에 만드냐,

    바로 프로토타입 객체입니다.

     

    잠깐 짚고 넘어가면,

    자바스크립트에서 모든 객체는 자신의 부모 역할을 하는 객체(=프로토타입 객체)와 연결되어 있습니다.

    그래서 프로토타입 객체가 갖는 속성을 자기 것처럼 쓸 수 있습니다.

    어떤 속성을 썼는데, 그게 자기한테 없다면 부모의 부모의 부모를 거슬러 올라가 그 속성을 찾거든요.

     

    원래 존재하는 프로토타입 객체에 메서드를 추가하려면 요렇게 쓰면 됩니다.

     

    Character.prototype.xxxxx = function(){};

     

    하지만 이렇게 추가할 수도 있습니다.

     

    Character.prototype = {
      constructor: Character,
      xxxxx: function(){};
    };

     

    이건 프로토타입 객체를 아예 재정의하면서,

    원래 갖고 있던 constructor 속성을 다시 정의해준 것입니다.

    (모든 프로토타입 객체는 constructor 속성을 갖고 있어요!)

     

    강의는 후자의 방법대로 진행됐기 때문에 그대로 따라하겠습니다.

    (사실... 아직 이해를 못했슴당... 😭)

     

    function Character(info) {
      this.mainElem = document.createElement("div");
      this.mainElem.classList.add("char");
      this.mainElem.innerHTML = `
      <div class="char">
          <div class="char__con char__head">
            <div class="char__face char__head-face face-front"></div>
            <div class="char__face char__head-face face-back"></div>
          </div>
          <div class="char__con char__torso">
            <div class="char__face char__torso-face face-front"></div>
            <div class="char__face char__torso-face face-back"></div>
          </div>
          <div class="char__con char__arm char__armRight">
            <div class="char__face char__arm-face face-front"></div>
            <div class="char__face char__arm-face face-back"></div>
          </div>
          <div class="char__con char__arm char__armLeft">
            <div class="char__face char__arm-face face-front"></div>
            <div class="char__face char__arm-face face-back"></div>
          </div>
          <div class="char__con char__leg char__legRight">
            <div class="char__face char__leg-face face-front"></div>
            <div class="char__face char__leg-face face-back"></div>
          </div>
          <div class="char__con char__leg char__legLeft">
            <div class="char__face char__leg-face face-front"></div>
            <div class="char__face char__leg-face face-back"></div>
          </div>
        </div>
    `;
    
      document.querySelector(".stage").appendChild(this.mainElem);
      this.mainElem.style.left = info.xPos + "%";
      this.init();
    }
    
    Character.prototype = {
      constructor: Character,
      init: function() {
        window.addEventListener("scroll", function() {
          this.console.log("스크롤!");
        });
      }
    };
    

     

    init()라는 메서드를 정의하고, Character 함수 내에서 this.init()로 호출했습니다.

    그리고 스크롤에 대한 이벤트리스너도 만들어줬고요.

     

    이제 스크롤할 때 캐릭터가 달려야하니, .running 클래스를 붙여줍시다.

     

    Character.prototype = {
      constructor: Character,
      init: function() {
        window.addEventListener("scroll", function() {
          this.mainElem.classList.add("running");
        });
      }
    };

     

    그리고 테스트해보면... 띠용.

    "Cannot read property 'classList' of undefined" 에러가 뜨면서 제대로 실행되지 않습니다.

    왜냐면, 이 함수 내에서 this는 이벤트가 실행된 주체, 그러니까 window를 가리키기 때문입니다.

    우리가 원하는 건 생성자 함수로 만들어진 인스턴스 객체였지 window가 아니잖아요?

     

    따라서 이 이벤트핸들러 밖에서 미리 변수 안에다 넣어줍니다.

     

    Character.prototype = {
      constructor: Character,
      console.log(this); //여기서의 this는 인스턴스 객체를 가리킨다
      init: function() {
        window.addEventListener("scroll", function() {
          //...
        });
      }
    };

     

    이벤트리스너 밖에서 this를 콘솔에 찍어보면 생성된 인스턴스 객체를 가리킨다는 걸 알 수 있습니다.

    그러니 얘를 변수에다 쓱 집어넣은 다음, 이벤트리스너 안에서 꺼내쓰면 되겠죠.

     

    Character.prototype = {
      constructor: Character,
      init: function () {
        const self = this; //변수명은 self 또는 that이라고 많이들 쓴다
        window.addEventListener("scroll", function () {
          self.mainElem.classList.add("running");
        });
      }
    };

     

    이제 캐릭터를 소환 후 스크롤하면 .running 클래스가 잘 붙는 걸 볼 수 있습니다.

    그럼 스크롤을 멈췄을 때 클래스를 떼주는 작업도 해야겠죠?

     

    스크롤 중인지 아닌지를 체크하기 위해 변수 하나를 만들겠습니다.

     

    function Character(info) {
      ...
    
      this.scrollState = false;
    
      ...
    }

     

    그리고 이 값이 false일 때 클래스가 붙도록 바꿉니다.

     

    Character.prototype = {
      constructor: Character,
      init: function () {
        const self = this;
        window.addEventListener("scroll", function () {
          if (!self.scrollState) {
            self.mainElem.classList.add("running");
            this.console.log("scrollState:", self.scrollState);
          }
        });
      }
    };

     

    자, 그럼 스크롤이 멈추면, .running 클래스를 떼줘야하는데... 스크롤 멈춘 걸 어떻게 알 수 있을까요?

    방법은 여러 가지가 있겠지만- 여기서는 setTimeout을 써보겠습니다.

     

    스크롤이 발생하고 0.5초 뒤에 .running이 제거되도록 하는 거죠.

    사용자가 두 번 스크롤하면? running 클래스 제거가 두 번 일어날 거에요.

     

    Character.prototype = {
      constructor: Character,
      init: function () {
        window.addEventListener("scroll", function () {
    
          if (!self.scrollState) {
            self.mainElem.classList.add("running");
            this.console.log("running 클래스 붙임");
          }
    
          this.setTimeout(function () {
            self.mainElem.classList.remove("running");
            this.console.log("running 클래스 제거");
          }, 500);
        });
      }
    };

     

     

    그럼 스크롤 이벤트가 발생한 횟수만큼 클래스가 붙고 제거되는 걸 볼 수 있습니다.

    하지만 이러면 클래스를 도대체 몇 번이나 붙였다 떼었다 해야하는 걸까요... 

    코드를 좀더 효율적으로 바꿔보겠습니다.

     

    바로... setTimeout을 변수에 넣어버리는 거죠!

    어디냐면 scrollState입니다 (뜨든)

    Character.prototype = {
      constructor: Character,
      init: function () {
        window.addEventListener("scroll", function () {
    
          if (!self.scrollState) {
            self.mainElem.classList.add("running");
            this.console.log("running 클래스 붙임");
          }
    
          self.scrollState = this.setTimeout(function () {
            self.mainElem.classList.remove("running");
            this.console.log("scrollState:", self.scrollState);
          }, 500);
        });
      }
    };

     

    이렇게 작성하고 테스트해봤더니...

    1) 콘솔에 찍혀나오는 scrollState 값이 숫자인데 스크롤할 때마다 점점 올라간다.

    2) 스크롤을 멈췄다 다시 하면 running 클래스가 안 붙는다.

    라는 2가지 사실을 알 수 있습니다.

     

    이유가 뭔가 봤더니,

    scrollState가 TRUE라 그렇습니다.

     

    setTimeout에 대해 MDN한테 물어보면,

    "반환되는 timeoutID는 숫자이고, setTimeout()을 호출하여 만들어진 타이머를 식별할 수 있는 0이 아닌 값 입니다.

    이 값은 타이머를 취소시키기 위해 WindowTimers.clearTimeout()에 전달할 수도 있습니다."이라고 친절히 알려주네요.

     

    결국 0이 아닌 숫자가 리턴되어 setTimeout에 들어갔으니 불린에서 TRUE 취급을 받겠죠.

    결국!self.scrollState에 해당하지 않으니 classList.add가 발생하지 않은 것입니다.

    따라서 우리가 직접 scrollState를 false로 만들어줘야 합니다.

     

    Character.prototype = {
      constructor: Character,
      init: function () {
        const self = this; //변수명은 self 또는 that이라고 많이들 쓴다
        window.addEventListener("scroll", function () {
    
          if (!self.scrollState) {
            self.mainElem.classList.add("running");
            this.console.log("running 클래스 붙임");
          }
    
          self.scrollState = this.setTimeout(function () {
            self.scrollState = false;
            self.mainElem.classList.remove("running");
            this.console.log("running 클래스 제거");
          }, 500);
        });
      }
    };

     

    하지만 클래스가 계속 붙었다 떼었다하는 건 여전합니다.

    이걸 해결하는 간단한 방법은?!

     

    Character.prototype = {
      constructor: Character,
      init: function () {
        const self = this; //변수명은 self 또는 that이라고 많이들 쓴다
        window.addEventListener("scroll", function () {
        
          this.clearTimeout(self.scrollState);
    
          if (!self.scrollState) {
            self.mainElem.classList.add("running");
            this.console.log("running 클래스 붙임");
          }
    
          self.scrollState = this.setTimeout(function () {
            self.scrollState = false;
            self.mainElem.classList.remove("running");
            this.console.log("running 클래스 제거");
          }, 500);
        });
      }
    };

     

    'this.clearTimeout(self.scrollState);' <-이것만 맨 위에 넣어주면 됩니다.

    그럼 running 클래스가 최초에만 붙었다가, 스크롤이 발생하지 않으면 그때만 사라지는 걸 볼 수 있습니다.

     

    이 과정이 조금 복잡한데,

    글로 적으면 대충 이렇습니다.

     

     

    [최초 스크롤이 일어났다!]

    1. 스크롤 이벤트 발생

    2. clearTimeout 발생 -> 하지만 취소할 것도 없네 ㅇㅅaㅇ

    3. scrollState 체크 -> false네? running 클래스 추가!

    4. 1번 타이머 생성 -> 0.5초 뒤 scrollState를 false처리하고 running 클래스를 떼어버리겠어

     

    [다시 스크롤이 일어났다!]

    1. 스크롤 이벤트 발생

    2. clearTimeout 발생 -> 1번 타이머 취소!!

    3. scrollState 체크 -> 들어있는 값이 1이니까 true네? 클래스 안 붙여!

    4. 2번 타이머 생성

     

    [또 스크롤이 일어났다!]

    1. 스크롤 이벤트 발생

    2. clearTimeout 발생 -> 2번 타이머 취소!!

    3. scrollState 체크 -> 들어있는 값이 2니까 true네? 클래스 안 붙여!

    4. 3번 타이머 생성

     

    [스크롤이 멈췄다..]

    1. 스크롤 이벤트 발생하지 않음

    2. clearTimeout 이 발생하지 않았으므로 3번 타이머 작동 -> 0.5초 뒤 scrollState를 false처리하고 running 클래스 제거!

     

    위와 같이 이벤트가 발생하기 때문에, 클래스를 딱 한 번씩만 추가하고 제거하게 됩니다.

    그리고 스크롤이 끝나고 0.5초 뒤에 캐릭터가 멈추기 때문에 좀 더 자연스럽게 보이게 되죠.

     

    후후후 참 재밌네요

    😙

     

     


     

     

    2. 캐릭터 방향 설정하기

     

     

    이번에는 스크롤이 올라갔나/내려갔나에 따라 캐릭터 방향을 설정할 차례입니다.

    어떻게 하냐구요? 마지막 스크롤 지점을 기억하고 있으면 될 것 같은 느낌적인 느낌?!

     

    새롭게 스크롤 된 지점이 더 크다면 스크롤을 내린 거고,

    새롭게 스크롤 된 지점이 더 작다면 스크롤을 올린 거겠죠!

     

    얼른 변수를 만들어서 실험해보자구요!

    변수 이름은 lastScrollTop이고, 생성자 함수 내에 작성합니다. 

     

    function Character(info) {
      ...
      this.lastScrollTop = 0;
      ...
    }

     

    그리고 pageYOffset 값을 여기에다 저장합니다.

    이 값이 어떻게 변하는지 확인을 위해 변수 저장 전후로 콘솔에 찍어보겠습니다.

     

    Character.prototype = {
      constructor: Character,
      init: function () {
        const self = this; 
        window.addEventListener("scroll", function () {
    
          ...
    
          this.console.log("1: ", self.lastScrollTop);
          self.lastScrollTop = this.pageYOffset;
          this.console.log("2: ", pageYOffset);
          
        });
      }
    };

     

    자, 페이지 최상단에서 스크롤 이벤트가 일어났을 때의 상황을 생각해봅시다.

     

    lastScrollTop의 초기값을 0으로 해놨으니 값은 당연히 0이겠죠?

    그런데 스크롤 이벤트가 일어났으니 pageYOffset은 바뀌게 됩니다.

    그리고 그 바뀐 값이 lastScrollTop 안에 들어가게 되고요. 그래서 1이 되었습니다.

     

    여기서 다시 스크롤을 내린다면 lastScrollTop값은 점점 커질 것입니다.

    반대로 스크롤을 올린다면 값은 줄어들겠죠. 바로 아래 스크린샷처럼요.

     

    (좌) 스크롤 내림 / (우) 스크롤 올림

     

    결국 두 값을 비교해서 스크롤 업인지 다운인지 알 수 있다는 거죠!

     

    ...
    
    if (self.lastScrollTop > pageYOffset) {
       this.console.log("스크롤 올림");
    } else {
       this.console.log("스크롤 내림");
    }
    
    ...

     

    넵, 여기에 맞춰 data-attribute를 바꿔주시면 됩니다.

     

    ...
    
    if (self.lastScrollTop > pageYOffset) {
      this.console.log("스크롤 올림");
      self.mainElem.setAttribute("data-direction", "bakward");
    
    } else {
      this.console.log("스크롤 내림");
      self.mainElem.setAttribute("data-direction", "forward");
    }
    
    ...

     

    귀여워 (흐뭇)

     

     

    앞뒤를 해줬으니 좌우도 설정해줍시다!

    이건 키보드의 좌우키 입력에 맞춰서 data 속성을 바꾸면 됩니다.

     

    생성자 함수 내에서 새로운 이벤트리스너를 달아줍니다. 바로 keydown 입니다.

     

    Character.prototype = {
      constructor: Character,
      init: function () {
        ...
    
        window.addEventListener("keydown", function (e) {
          this.console.log(e.keyCode);
        });
        
      }
    };

     

     

    이벤트 객체를 넘겨받은 뒤 keyCode를 확인해봅시다.

    왼쪽 키는 37, 오른쪽 키는 39라고 나오네요.

    이것도 if else 문으로 작성해주면 되겠습니다.

     

     

    오오, 왼쪽 오른쪽 키보드 방향에 맞춰 잘 쳐다봅니다.

    하지만 쳐다보기만 할 뿐 나아갈 수는 없어요 (힝)

    키보드를 꾹 누르고 있으면 왼쪽 오른쪽으로 이동하는 것도 설정해보겠습니다.

     

    일단 달려야하니까 .running 클래스를 붙여주죠.

     

    ...
    
    if (e.keyCode == 37) {
       self.mainElem.setAttribute('data-direction', 'left');
       self.mainElem.classList.add("running");
    } else if (e.keyCode == 39) {
       self.mainElem.setAttribute('data-direction', 'right');
       self.mainElem.classList.add("running");
    }
    
    ...

     

    하지만 키보드에서 손을 떼면 달리는 걸 멈춰야 합니다.

    이에 따른 keyup 이벤트도 작성합니다.

     

    ...
    
    window.addEventListener("keyup", function (e) {
      self.mainElem.classList.remove("running");
    });
    
    ...

     

    각각의 인스턴스 객체는 저마다 고유의 스피드로 달릴 거라서,

    미리 speed 속성도 만들어줍니다.

     

    function Character(info) {
      ...
      this.speed = 5;
      ...
    }

     

    자, 이 캐릭터가 좌우로 움직이려면 뭘 변경해주면 될까요?

    바로 left 값입니다.

    이 left 값이 어디에서 정해지는지 찾아보니까 info.xPos 였습니다.

     

    this.mainElem.style.left = info.xPos + "%";

     

    그리고 xPos는 이렇게 값이 넘어오고 있었네요.

     

    stageElem.addEventListener("click", function (e) {
      new Character({
        xPos: (e.clientX / window.innerWidth) * 100
      });
    });

     

    그럼 이 info.xPos를 인스턴스 객체의 속성으로 등록시켜 쓰기 편하도록 만듭니다.

     

    this.xPos = info.xPos;

     

    이렇게 해두면 keydown 이벤트 내에서 self.xPos... 와 같이 사용할 수 있겠죠?

    캐릭터가 왼쪽으로 움직이게 하려면, xPos의 값이 점점 줄어들면 됩니다.

    오른쪽으로 움직인다면 xPos의 값이 점점 늘어나면 되고요.

     

    그래서 xPos의 값을 저 위에서 만들어두었던 self.speed만큼 줄어게끔 하겠습니다.

    그리고 이 값을 style.left로 반영시켜줍니다.

     

    ...
    
    window.addEventListener("keydown", function (e) {
      if (e.keyCode == 37) {
        self.mainElem.setAttribute('data-direction', 'left');
        self.mainElem.classList.add("running");
        self.xPos -= self.speed;
        self.mainElem.style.left = self.xPos + "%";
      }
      ...
    });
    
    ...

     

    이제 왼쪽 키를 눌러보면 캐릭터의 left 값이 바뀌면서 이동하는 것처럼 보이게 됩니다.

    하지만 뭔가 뚜둑뚜둑거리며 끊기는 느낌이 듭니다. (프레임 저하인가!)

    이 동작을 부드럽게 바꿔보겠습니다. 아, 그 전에 오른쪽 부분도 작성해 주고 넘어가요!

     

    ...
    
    window.addEventListener("keydown", function (e) {
      this.console.log(e.keyCode);
      if (e.keyCode == 37) {
        self.mainElem.setAttribute('data-direction', 'left');
        self.mainElem.classList.add("running");
        self.xPos -= self.speed; //left값 변경
        self.mainElem.style.left = self.xPos + "%"; //변경된 left값 반영
      } else if (e.keyCode == 39) {
        self.mainElem.setAttribute('data-direction', 'right');
        self.mainElem.classList.add("running");
        self.xPos += self.speed;
        self.mainElem.style.left = self.xPos + "%";
      }
    });
    
    ...

    룰루랄라

     

    왜 이렇게 느린가! 하면 초당 움직이는 속도가 느리기 때문입니다.

    speed값을 1로 줄인 다음,

    keydown 이벤트가 일어날 때마다 콘솔에 찍어볼게요.

     

    ...
    this.speed = 1;
    ...
    
    window.addEventListener("keydown", function (e) {
       console.log("Keydown");
     });

     

    네... 너무 느려요...

    초당 2~30 프레임은 되어야 부드럽게 움직이는데

    얘는 기껏해야 1초에 10번 정도 움직이거든요.

     

    그래서 keydown에 의존하는 대신,

    requestAnimationFrame()을 활용하여 이벤트를 다시 작성하겠습니다.

    기존의 xPos 변경과 left값 변경 부분은 우선 주석처리합니다.

     

    window.addEventListener("keydown", function (e) {
      if (e.keyCode == 37) {
        self.mainElem.setAttribute('data-direction', 'left');
        self.mainElem.classList.add("running");
        // self.xPos -= self.speed; //left값 변경
        // self.mainElem.style.left = self.xPos + "%"; //변경된 left값 반영
      } else if (e.keyCode == 39) {
        self.mainElem.setAttribute('data-direction', 'right');
        self.mainElem.classList.add("running");
        // self.xPos += self.speed;
        // self.mainElem.style.left = self.xPos + "%";
      }
    });

     

    지금까지는 init 이벤트에서 작성하고 있었는데,

    이제 새로운 메서드를 생성할 거에요. 이름은 run 이라고 합시다.

     

    Character.prototype = {
      constructor: Character,
      init: function () {
        ...
      },
      run : function(){
        //여기에 작성
      }
    };

     

    엇, 하지만 run 메소드 내에서는 캐릭터의 방향을 알 방법이 없습니다.

    그럼? keydown 이벤트 내에서 keyCode 값으로 판단한 다음 변수에 넣어 내보내면 되겠죠.

     

    function Character(info) {
       ...
       this.direction = "";
       ...
    }
    
    Character.prototype = {
      constructor: Character,
      init: function () {
        window.addEventListener("keydown", function (e) {
          if (e.keyCode == 37) {
            self.direction = "left";
            ...
          } else if (e.keyCode == 39) {
            self.direction = "right";
            ...
          }
        });
      },
      run: function(){
      }
     }

     

    이제 run 메소드 내에서 if로 판단합시다.

    아, 물론 얘도 const self = this 처리해서 쓰면 돼요.

     

    run: function () {
      const self = this;
      if(self.direction == "left") {
        //왼쪽
      } else if(self.direction == "right") {
        //오른쪽
      }
    }

     

    그리고 아까 주석처리 했던 부분을 살짝 넣어줍니다.

     

    ...
    run: function () {
      const self = this;
      if (self.direction == "left") {
        self.xPos -= self.speed;
      } else if (self.direction == "right") {
        self.xPos += self.speed;
      }
      self.mainElem.style.left = self.xPos + "%"; //변경된 값 반영
    }

     

    다음은 이 run을 반복시켜야겠죠.

     

    ...
    run: function () {
      ...
      requestAnimationFrame(self.run);
    }

     

    그리고 keydown 이벤트에 가서 이 메소드를 호출합니다.

     

    ...
    window.addEventListener("keydown", function (e) {
      if (e.keyCode == 37) {
        ...
        self.run();
      } else if (e.keyCode == 39) {
        s...
        self.run();
      }
    });
    ...

     

    그런 다음 테스트해보면...!

    두둥.

    Uncaught TypeError: Cannot read property 'style' of undefined at run 라며 에러가 뜹니다.

     

    띠용? self.mainElem... 잘 설정한 거 같은데요?!

    당황해하지말고 우선 콘솔에 self를 찍어 확인합니다.

     

    ...
      run: function () {
        const self = this;
        console.dir(self);
        if (self.direction == "left") {
          self.xPos -= self.speed; //left값 변경
        } else if (self.direction == "right") {
          self.xPos += self.speed;
        }
        self.mainElem.style.left = self.xPos + "%"; //변경된 값 반영
    
        requestAnimationFrame(self.run);
      }
    ...

     

     

    콘솔을 보니 처음엔 Character로 잘 나왔다가, 두 번째에는 Window가 되어버린 걸 볼 수 있습니다.

    자바스크립트의 this는 실행되는 컨텍스트에 따라 달라지는데,

    저 requestAnimationFrame이 실행되면서 this가 바뀌게 된 것입니다.

     

    그럼 우린 뭘 할 수 있죠?

    팝콘을 가져오...는 게 아니라, 저 run을 호출할 때 self를 인자로 넣어주면 되지 않을까요?

     

    requestAnimationFrame(function(){
       self.run(self);
    });

     

    그리고 이 self는 run 메서드로부터 받아와야죠.

    이렇게 되면 const self = this; <- 이 부분은 필요가 없어집니다.

     

      run: function (self) {
        if (self.direction == "left") {
          self.xPos -= self.speed; //left값 변경
        } else if (self.direction == "right") {
          self.xPos += self.speed;
        }
        self.mainElem.style.left = self.xPos + "%"; //변경된 값 반영
    
        requestAnimationFrame(function(){
          self.run(self);
        });
      }

     

    아, 키다운 이벤트 발생 시에 넣어주는 것도 잊지 말자구요.

     

    window.addEventListener("keydown", function (e) {
      if (e.keyCode == 37) {
        ...
        self.run(self);
      } else if (e.keyCode == 39) {
        ...
        self.run(self);
      }
    });

     

    그럼 부드럽게 움직이는 걸 볼 수 있습니다.

    어...근데 키를 계속 누르고 있으면 갑자기 쌩하니 화면 밖으로 달려나가버리네요.

    이걸 고쳐보도록 하겠습니다.

     


     

    4. 캐릭터 움직임 수정하기

     

     

    현재 상태에서는 키보드를 단 한 번만 눌렀다 떼도 캐릭터가 그대로 움직여서 화면 밖으로 탈출합니다(ㅠㅠ)

     

    이유는...? requestAnimationFrame이 계속 동작하기 때문입니다.

    그런데 키를 계속 누르고 있으면 키다운 이벤트가 여러 번 발생하면서 중첩이 됩니다.

    그래서 속도가 점점 빨라지게 되는 거죠.

     

    그래서 이동을 멈추게 하는 제어가 필요합니다.

    불린 타입의 변수 하나를 만들어 주자고요.

     

    this.runningState = false;

     

    그리고 키다운 이벤트 최상단에 이 변수가 true라면 run이 발생하지 않도록 처리합니다.

     

      window.addEventListener("keydown", function (e) {
        if (self.runningState) return;
    
        ...
      });

     

    어차피 run()이 한 번 발생하면 requestAnimationFrame으로 반복될 테니 keydown이벤트는 딱 한 번만 발생하면 됩니다

    keyup이 일어나기 전까지 keydown이 한 번만 발생하도록 runningState를 true로 바꿔줍니다.

     

    if (e.keyCode == 37) {
      ...
      self.run(self);
      self.runningState = true;
    } else if (e.keyCode == 39) {
      ...
      self.run(self);
      self.runningState = true;
    }

     

    자, 테스트해보면 키를 꾹~ 누르고 있어도 캐릭터가 더 이상 육상선수처럼 튀어나가지 않습니다.

    하지만 키를 떼도 계속 이동하는 건 여전합니다.

    그러니 이제 keyup 이벤트를 작성해 줄 차례입니다.

     

    자, selfInterval()과 유사하게 reqeuestAnimationFrame()도 고유의 숫자값을 리턴합니다.

    그리고 이걸 가지고 애니메이션을 멈추게 할 수 있습니다.

     

    this.rafID;
    
    ...
    
    window.addEventListener("keyup", function (e) {
      self.mainElem.classList.remove("running");
      this.cancelAnimationFrame(self.rafID);
    });
    
    ...
    
    self.rafID = requestAnimationFrame(function () {
      self.run(self);
    });

     

    그리고 테스트를 위해 눌렀다가... 떼보면!

    캐릭터가 그자리에 우뚝 멈춰섭니다! (예에쓰)

     

    오호라 잘 되는구나~ 싶어서 다시 한 번 키를 눌러보지만...

    안 움직입니다.

    안 움직인다구요!!!! (`Δ´)

     

     

     

    ......어쩔 수 없죠... 다시 고쳐봅니다.

     

    자 이유가 뭘까 봤더니,

    keydown 이벤트 부분에 문제가 있었습니다.

    왜냐면 self.runningState가 계속 true였으니까요.

    false처리를 해줘야 run()이 다시 실행될 거에요.

     

    window.addEventListener("keyup", function (e) {
      self.mainElem.classList.remove("running");
      this.cancelAnimationFrame(self.rafID);
      self.runningState = false;
    });

     

    휴우, 드디어 잘 움직입니다........

    고 안심하긴 일렀죠!!

    캐릭터가 벽을 뚫거든요 (ఠ్ఠ ˓̭ ఠ్ఠ)

     

    따라서  xPos가 너무 작아지거나 커지지 않게끔 조정해줘야 합니다.

    console.log(self.xPos)로 확인해보면, 0~85 사이일 때 캐릭터가 잘 보인다는 걸 알 수 있습니다.

    따라서 1보다 작아진다면 1로 고정, 85보다 커진다면 85로 고정시켜줍니다.

     

    run: function (self) {
      if (self.xPos < 1) {
        self.xPos = 1; //왼쪽 튀어나감 방지
      }
      if (self.xPos > 85) {
        self.xPos = 85; //오른쪽 튀어나감 방지
      }
      if (self.direction == "left") {
        self.xPos -= self.speed;
      } else if (self.direction == "right") {
        self.xPos += self.speed;
      }
      self.mainElem.style.left = self.xPos + "%";
    
      self.rafID = requestAnimationFrame(function () {
        self.run(self);
      });
    }

     

    그런데 또! 문제가 있습니다.

    인스턴스 객체를 여러 개 만든 다음, 한 구석으로 몰아넣으면

    얘네의 xPos가 똑같아지기 때문에 합체...가 됩니다.

     

    합체된 일분이 삼형제

     

    그래서 캐릭터마다 속도를 다르게 해주는 작업을 해줘야합니다.

    인스턴스 객체가 생성될 때 speed값이 현재는 1로 고정이 되어 있는데,

    이걸 인자인 info로부터 랜덤으로 받도록 하겠습니다.

     

    this.speed = info.speed;

     

    그리고 캐릭터를 생성할 때 저 speed값을 넘겨주자고요.

     

    stageElem.addEventListener("click", function (e) {
      new Character({
        xPos: (e.clientX / window.innerWidth) * 100,
        speed: Math.random()
      });
    });

     

    이 값은 랜덤으로 설정할 거라 Math.random() 메서드를 사용합니다.

    그럼 객체가 생성될 때마다 그 값이 0~1 중 랜덤으로 정해지겠죠?

    콘솔에 찍어 확인합니다.

     

    init: function () {
        const self = this; 
        console.log(self.speed);
    	...
    }

     

    Math.random()

     

    캐릭터를 5개 만들었더니 어떤 애는 0.1, 어떤 애는 0.9의 스피드를 갖네요.

    만약 캐릭터들 간의 스피드차이를 줄이고 싶다면, Math.random()에 0 미만의 숫자를 곱해주면 됩니다.

     

    speed: Math.random() * 0.5

     

    Math.random()*0.5

    0.5를 곱하면, 0.1~0.5사이의 값이 나오는 걸 볼 수 있죠.

    0.2를 곱한다면 0.1~0.2사이의 값이 나올 테고요.

     

    엇, 그럼 캐릭터들간의 속도 차이는 줄이되 전체적으로 좀 빠르게 움직였으면 할 때는요?

    그럴 땐 그냥 원하는 값만큼 더해주면 됩니다.

     

    speed: Math.random() * 0.5 + 0.5

     

    요러케 설정하면 0.6~1 사이의 값이 나오겠죠!

     

    Math.random()*0.5+0.5

     

     


     

     

    5. 캐릭터/테마 추가하기

     

     

    대망의!! 마지막 작업!!! 입니다.

    캐릭터 선택 버튼을 클릭하면 라면소녀라는 새로운 캐릭터로 변경되게끔 하려고 합니다.

     

    마크업으로 선택 버튼을 만들어줍니다.

     

      <div class="char-select">
        <div class="char-select__btn char-select__btn--ilbuni" data-char="ilbuni"></div>
        <div class="char-select__btn char-select__btn--ragirl" data-char="ragirl"></div>
      </div>

     

    CSS도 작성합니다.

     

    // character button
    
    .char-select {
      display: flex;
      align-items: center;
      position: fixed;
      right: 10px;
      top: 10px;
      z-index: 100;
    
      &__btn {
        width: 40px;
        height: 40px;
        margin-left: 5px;
        border: 0;
        background-color: transparent;
        background-repeat: no-repeat;
        background-position: 50% 50%;
        background-size: contain;
        cursor: pointer;
        transition: 0.5s;
    
        &--ilbuni {
          background-image: url('../images/ilbuni_head_front.png');
          background-size: 36px auto;
        }
    
        &--ragirl {
          background-image: url('../images/ragirl_head_front.png');
          opacity: 0.5;
        }
      }
    }
    
    
    body[data-char='ragirl'] .char-select__btn--ilbuni {
      opacity: 0.5;
    }
    
    body[data-char='ilbuni'] .char-select__btn--ilbuni,
    body[data-char='ragirl'] .char-select__btn--ragirl {
      opacity: 1;
    }

    (대충 캐릭터 선택 버튼 만드는 스타일)

     

    body {
      margin: 0;
      padding: 0;
      min-height: 100%;
      height: 5000px; //스크롤 가능케 처리
      background: #49b293;
      -webkit-overflow-scrolling: touch;
    
      &[data-char="ragirl"] {
        background: orange;
      }
    }

    (대충 body 색깔 변경하는 스타일)

     

    body[data-char='ragirl'] & {
         ...
    }

    (많이 대충 일분이 이미지를 라면소녀로 변경하는 스타일)

     

     

    그리고 마지막으로!! 클릭 시 body의 data-char을 변경하는 스크립트를 작성합니다.

     

    const charSelectElem = document.querySelector(".char-select");
    
    charSelectElem.addEventListener("click", function (e) {
       const value = e.target.getAttribute("data-char");
       console.log(value);
       document.body.setAttribute("data-char", value);
    });

     

    그럼 끝!

    진짜 끝!!!!

     

     

    이예이 (/^▽^)/

    댓글 0