[JavaScript Toy Project] 공 튀기기

Park Yeongseo·2022년 8월 22일
0

Project

목록 보기
1/4
post-thumbnail

(구글 크롬에 맞춰서 제작되었습니다.)
여기서 해볼 수 있어요 (모바일 크롬 추천)
소스코드는 여기에서

🔸만들게 된 계기

Interactive Developer 김종민님의 유튜브에 올라왔던 한 영상을 보고 자바스크립트에 익숙해질 겸 토이 프로젝트로 만들어 보았다.
이미 해당 영상에 코드가 올라와 있고, Velog에도 위 영상을 바탕으로 만들어 본 글이 있기에, 이 글에서는 변경점들과 그것들을 다루면서 마주친 문제들에 대해서 다루도록 하겠다.

🔸변경점들과 마주친 문제들

주요 변경점들은 다음과 같다.

1) 김종민님의 튜토리얼 영상에서는 공이 같은 속도로 움직인다. -> 중력의 영향을 받도록 바꿔보자

중력을 구현하기 위해 처음에는 Bead 클래스에 아래와 같은 gravity()메소드를 따로 만들어서 적용시켰다.

    gravity(){
        if (this.y <= this.stageHeight){ 
            let height = this.stageHeight - this.y;
            this.y += (GRAVITY * ((new Date().getTime() - this.time) ** 2)) ;
        }
    }

코드에서도 볼 수 있듯, 바닥과 충돌하지 않을 때 y좌표에 속도(=1/2gt^2)를 더해줘서 중력을 구현했다. 공이 잘 튀어오르기는 했지만 문제는 공이 바닥에 부딪히고 다시 튀어오를 때였다.

위와 같이 속도를 구하려면 새로운 Bead 클래스의 인스턴스가 생성될 때 초기화된 this.time과 현재 시간new Date().getTime()의 차이 t를 알아야 했다.

그런데 이렇게 구현하면 (정지된 상태에서 한 번 떨어질 때는 잘 될지 몰라도) 공이 바닥에 부딪히고 다시 튀어오르게 됐을 때. 어느 시점을 기준으로 시간 t를 계산해야 할지를 알지 못하게 된다.

이에 대해서 공대생에게 도움을 구했더니 다음과 같이 유한차분법을 이용하면 된다고 했다.

v = dx / dt
vold = (xnew - xold) / dt
xnew = xold + vold * dt

a= dv / dt
aold = (vnew - vold) / dt
vnew = vold + aold * dt

몇 번의 수정을 거쳐 만들어진 결과는 다음과 같다.

bead.js

	update(){
        if (this.makingAim){
            return;
        }
        let newTime = new Date().getTime();
        this.dt = this.time - newTime; 
        this.xv = (this.xv + this.xa * this.dt * TIMEUNIT) * (1 - AIRRESIST);
        this.yv = (this.yv + this.ya * this.dt * TIMEUNIT) * (1 - AIRRESIST);
        this.newY = this.y + this.yv * this.dt * TIMEUNIT;
        this.newX = this.x + this.xv * this.dt * TIMEUNIT;
		
        this.wallCollision();

        this.time = newTime;
        this.x = this.newX;
        this.y = this.newY;
    }

우선 현재 공의 좌표와 속도, 가속도를 고려해 공의 다음 좌표 this.newXthis.newY를 구한다.

이렇게 this.newXthis.newY를 구했다면 wallCollision() 메소드를 통해 공과 바닥, 벽면의 충돌을 계산해 최종적으로 공의 좌표를 확정한 다음, 시간 및 좌표 값들을 갱신해준다.

wallCollision()의 각 케이스의 첫째 줄(예: this.newY = this.stageHeight - this.radius;)은 공이 정확히 벽면에 닿지도 않았는데 튕겨나는 경우가 생길 수 있어 추가했다.

bead.js

    wallCollision(){
        if (this.newY >= this.stageHeight - this.radius){
            this.newY = this.stageHeight - this.radius;
            if (Math.abs(this.yv) > SOUNDNORM) this.playSound();
            this.yv *= -COR;
            this.xv *= (1 - FRICTION);
        }
        else if (this.newY < this.radius){//천장 충돌
            this.newY = this.radius;
            if (Math.abs(this.yv) > SOUNDNORM) this.playSound();
            this.yv *= -COR;
            this.xv *= (1 - FRICTION);
        }

        if (this.newX >= this.stageWidth - this.radius){
            this.newX = this.stageWidth - this.radius;
            if (Math.abs(this.xv) > SOUNDNORM) this.playSound();
            this.xv *= -COR;
            this.yv *= (1 - FRICTION);
        }
        else if (this.newX < this.radius){
            this.newX = this.radius;
            if (Math.abs(this.xv) > SOUNDNORM) this.playSound();
            this.xv *= -COR;
            this.yv *= (1 - FRICTION);
        }
    }

사용한 상수들에 대한 설명

  • COR, FRICTION, AIRRESIST 각각 반발계수, 벽면의 마찰력, 공기저항.
  • TIMEUNIT this.timenewTime사이의 시간 차 this.dt를 적절히 사용하기 위해 설정한 단위(?)
  • SOUNDNORM 공이 벽면에 부딪히면 소리가 나게 했는데, 단순히 collision이 감지되고 있을 때 소리가 나게 하면 공이 벽면에 붙어 정지되어 있을 때에도 계속 소리가 나게 된다. 공이 기준보다 빠르게 벽면에 부딪혔을 때에만 소리가 날 수 있게 설정했다.

2) 공을 직접 쏠 수 있도록 만들기. 공을 누른 상태에서 드래그하고 놓으면 드래그 거리에 비례한 강도로 공을 쏠 수 있게 만들자.

공을 쏘는 액션을 가능하게 만들기 위해서는 다음이 필요했다.

  • App 클래스에 누르고, 드래그하고, 놓는 이벤트들을 감지하는 이벤트 리스너를 추가
  • Bead클래스에 changeAim(), shoot() 메소드를 추가
  • 공을 쏘는 방향과 세기를 가시적으로 보이게 하기 위한 Aim 클래스도 추가

처음에는 마우스를 이용할 것만 고려해서 pointerdown/move/up만 사용했지만, 이후에 모바일에서 터치로도 가능하게 `touchstart/move/end도 추가했다.

app.js

	init(){
    ...
      this.canvas.addEventListener('pointerdown', this.onDown.bind(this),false);
      this.canvas.addEventListener('pointermove', this.onMove.bind(this),false);
      this.canvas.addEventListener('pointerup', this.onUp.bind(this),false);
      
      this.canvas.addEventListener('touchstart', this.onTouchStart.bind(this),false);
      this.canvas.addEventListener('touchmove', this.onTouchMove.bind(this),false);
      this.canvas.addEventListener('touchend', this.onTouchEnd.bind(this),false);
    ...
    }
    
    onDown(e){
        this.mousePos.x = e.clientX;
        this.mousePos.y = e.clientY;
        
        if (this.bead.collide(this.mousePos)){
            this.makingAim = true;
            this.bead.changeAim(this.mousePos);
        }
        else this.makingAim = false;
    }

    onMove(e){
        this.mousePos.x = e.clientX;
        this.mousePos.y = e.clientY;
        
        if (this.makingAim) this.bead.changeAim(this.mousePos);
    }

    onUp(e){
        this.mousePos.x = e.clientX;
        this.mousePos.y = e.clientY;

        if (this.makingAim) this.bead.shoot(this.mousePos);
        this.makingAim = false;
    }

    onTouchStart(e){
        this.mousePos.x = e.changedTouches[0].clientX;
        this.mousePos.y = e.changedTouches[0].clientY;
        
        if (this.bead.collide(this.mousePos)){
            this.makingAim = true;
            this.bead.changeAim(this.mousePos);
        }
        else this.makingAim = false;
    }

    onTouchMove(e){
        this.mousePos.x = e.changedTouches[0].clientX;
        this.mousePos.y = e.changedTouches[0].clientY;
        
        if (this.makingAim) this.bead.changeAim(this.mousePos);
    }

    onTouchEnd(e){
        this.mousePos.x = e.changedTouches[0].clientX;
        this.mousePos.y = e.changedTouches[0].clientY;
        if (this.makingAim) this.bead.shoot(this.mousePos);
        this.makingAim = false;
    }

Bead 클래스에서는 간단히 현재 공의 좌표와 드래그 후 놓은 포인트의 좌표의 차이에다 SHOOTSPEED를 곱해 공의 속도로 삼았다.

bead.js

	changeAim(point){
        this.makingAim = true;
        this.aim.change(this.x, this.y, point);
    }
    
    shoot(point){
        this.time = new Date().getTime();
        this.makingAim = false;
        this.aim.setRadius(0);
        this.yv = (this.y - point.y) * SHOOTSPEED;
        this.xv = (this.x - point.x) * SHOOTSPEED;
    }

App 클래스는 조준 방향으로 화살표를 나타낼 수 있게 만들었다. 설명은 패스.

여기까지 해서 PC와 모바일 크롬에서는 잘 작동하게 됐는데, 옆으로 누워서 휴대폰을 하다보니 '실제 중력이 반영되면 더 낫지 않을까?'라는 생각이 들었다.

3) 모바일로 할 때는 실제 중력이 작용하도록 하자

처음에는 DeviceOrientationEvent를 이용해서 구현하려고 했다. (이 글을 참고)

스마트폰이 책상 위에 놓여있을 때 z축 방향으로 중력이 작용한다고 가정하고, {x: 0, y : 0, z : GRAVITY}를 휴대폰의 회전에 따라 3차원 좌표축 변환 행렬을 통해 변환한 후, 이걸 다시 2차원에 투영하는 방법이었다. (사실 이게 제대로 될 것인지는 아직 모름)

그런데 그럴 필요없이 DeviceMotionEventaccelerationIncludingGravity 프로퍼티를 쓰면 간단하게 중력의 영향을 알 수 있다는 것을 찾아서 쉽게 구현할 수 있었다.

app.js

	init(){
    	...
        window.addEventListener('devicemotion', this.onDeviceMotion.bind(this), false);
        ...
    }
  	onDeviceMotion(e){
        this.bead.deviceMotion(e);
    }

bead.js

    deviceMotion(e){
        let accGravity = e.accelerationIncludingGravity;
        
        let xa = (accGravity.x || 0) / 9.8;
        let ya = (accGravity.y || 0) / 9.8;

        if (!(xa || ya)) {
            this.xa = 0;
            this.ya = GRAVITY;
            return;
        }
        this.xa = -xa * GRAVITY;
        this.ya = ya * GRAVITY;
    }

DeviceMotionEvent를 받아오지 못하는 경우에는 아래쪽으로 중력을 적용시켜 PC 웹에서도 잘 돌아갈 수 있게 했다.

또한 중력의 방향을 잘 확인할 수 있게 화면 정중앙에는 추도 하나 그려줬다.

bead.js

    draw(ctx){
		...
        
        ctx.beginPath();
        ctx.moveTo(this.stageWidth/2, this.stageHeight/2);
        let gravX = this.stageWidth/2 + this.xa * 2000000;
        let gravY = this.stageHeight/2 + this.ya * 2000000;
        ctx.lineTo(gravX, gravY);
        ctx.strokeStyle = "#feb139"
        ctx.lineWidth = 2;
        ctx.stroke();

        ctx.beginPath();
        ctx.fillStyle = "#feb139";
        ctx.arc(gravX, gravY, 5, 0, 2* Math.PI);
        ctx.fill();

        this.update();
    }

4) 공이 벽면에 부딪히면 소리가 나게 하자

공이 벽면에 부딪혔는데 아무 소리도 안 나니 심심해서 마지막으로 추가했다.

bead.js

export class Bead {
	constructor(x, y, radius, stageWidth, stageHeight, makingAim){
    	...
        
        this.soundArray = [];
        for (let i = 0; i < 10; i++){
            const sound = new Audio();
            sound.src = "/js_ball_bounce/src/Blop.mp3";
            sound.addEventListener('ended', () => {
                if (window.chrome){sound.load();}
                sound.muted = true;
                sound.pause();  
            })
            this.soundArray.push(sound);
        }
    }
    ...
	wallCollision(){
    	...
        if (Math.abs(this.yv) > SOUNDNORM) this.playSound();
        ...
    playSound(){
        for (let i = 0; i < 10; i++){
            if (this.soundArray[i].paused){
                this.soundArray[i].play();
                this.soundArray[i].muted = false;
                break;
            }
        }
    }
}

이 글을 참고했다.

문제는 크롬에서 아무 인터랙션이 없을 때 미디어 컨텐츠가 자동으로 재생되고 있는 걸 막고 있다는 것이었다. 따로 사이트 설정을 통해서 소리 자동 재생을 할 수는 있다지만, 굳이 그렇게 하기보다 처음에 자연스럽게 클릭을 유도하도록 해서 소리가 재생될 수 있게 했다.

app.js

export class App{
    constructor(){
		...
        this.isInit = false;
        this.canvas.addEventListener('click', this.init.bind(this), { once: true});
		...
    }
    ...
    animate(){
        window.requestAnimationFrame(this.animate.bind(this));
        this.ctx.clearRect(0, 0, this.stageWidth, this.stageHeight);
        if(!this.isInit) {
            this.ctx.fillStyle = 'white';
            this.ctx.font = "normal normal 40px Bungee";
            this.ctx.textAlign = "center";
            this.ctx.textBaseline = "middle";
            this.ctx.fillText("Click to Bounce!", this.stageWidth/2, this.stageHeight/2);
        }
        if (this.isInit) this.bead.draw(this.ctx);        
    }
	...
}

간단하게 this.isInit의 값을 false로 초기화하고 "Click to Bounce!"만 화면에 뜨게 하다가, 단 한 번 클릭을 감지하도록 이벤트 리스너를 추가해 클릭이 감지되었을 때 비로소 나머지 필요한 것들을 초기화(init())하도록 했다. 간단한 필수 인터랙션을 추가해서 크롬에서도 소리가 잘 나게 되었다.

🔸소감

이 글을 쓰면서 코드를 다시 보면서도 여기 저기에 여러 변수들이 불필요하게 남아있다는 느낌이 들었다. 그때 그때 생각난 요소들을 추가하다보니 코드의 구조도 꼬이기 쉽게 바뀌어 버렸는데, 다음에 다른 프로젝트를 하게 된다면 보다 깔끔하게 짤 수 있도록 해야겠다고 느꼈다.

profile
박가 영서라 합니다

0개의 댓글