(구글 크롬에 맞춰서 제작되었습니다.)
여기서 해볼 수 있어요 (모바일 크롬 추천)
소스코드는 여기에서
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 * dta= dv / dt
aold = (vnew - vold) / dt
vnew = vold + aold * dt
몇 번의 수정을 거쳐 만들어진 결과는 다음과 같다.
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.newX
와 this.newY
를 구한다.
이렇게 this.newX
와 this.newY
를 구했다면 wallCollision()
메소드를 통해 공과 바닥, 벽면의 충돌을 계산해 최종적으로 공의 좌표를 확정한 다음, 시간 및 좌표 값들을 갱신해준다.
wallCollision()
의 각 케이스의 첫째 줄(예: this.newY = this.stageHeight - this.radius;
)은 공이 정확히 벽면에 닿지도 않았는데 튕겨나는 경우가 생길 수 있어 추가했다.
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.time
과newTime
사이의 시간 차this.dt
를 적절히 사용하기 위해 설정한 단위(?)- SOUNDNORM 공이 벽면에 부딪히면 소리가 나게 했는데, 단순히 collision이 감지되고 있을 때 소리가 나게 하면 공이 벽면에 붙어 정지되어 있을 때에도 계속 소리가 나게 된다. 공이 기준보다 빠르게 벽면에 부딪혔을 때에만 소리가 날 수 있게 설정했다.
2) 공을 직접 쏠 수 있도록 만들기. 공을 누른 상태에서 드래그하고 놓으면 드래그 거리에 비례한 강도로 공을 쏠 수 있게 만들자.
공을 쏘는 액션을 가능하게 만들기 위해서는 다음이 필요했다.
App
클래스에 누르고, 드래그하고, 놓는 이벤트들을 감지하는 이벤트 리스너를 추가Bead
클래스에changeAim()
,shoot()
메소드를 추가- 공을 쏘는 방향과 세기를 가시적으로 보이게 하기 위한
Aim
클래스도 추가
처음에는 마우스를 이용할 것만 고려해서 pointerdown/move/up
만 사용했지만, 이후에 모바일에서 터치로도 가능하게 `touchstart/move/end
도 추가했다.
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
를 곱해 공의 속도로 삼았다.
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차원에 투영하는 방법이었다. (사실 이게 제대로 될 것인지는 아직 모름)
그런데 그럴 필요없이 DeviceMotionEvent
의 accelerationIncludingGravity
프로퍼티를 쓰면 간단하게 중력의 영향을 알 수 있다는 것을 찾아서 쉽게 구현할 수 있었다.
init(){
...
window.addEventListener('devicemotion', this.onDeviceMotion.bind(this), false);
...
}
onDeviceMotion(e){
this.bead.deviceMotion(e);
}
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 웹에서도 잘 돌아갈 수 있게 했다.
또한 중력의 방향을 잘 확인할 수 있게 화면 정중앙에는 추도 하나 그려줬다.
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) 공이 벽면에 부딪히면 소리가 나게 하자
공이 벽면에 부딪혔는데 아무 소리도 안 나니 심심해서 마지막으로 추가했다.
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;
}
}
}
}
이 글을 참고했다.
문제는 크롬에서 아무 인터랙션이 없을 때 미디어 컨텐츠가 자동으로 재생되고 있는 걸 막고 있다는 것이었다. 따로 사이트 설정을 통해서 소리 자동 재생을 할 수는 있다지만, 굳이 그렇게 하기보다 처음에 자연스럽게 클릭을 유도하도록 해서 소리가 재생될 수 있게 했다.
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()
)하도록 했다. 간단한 필수 인터랙션을 추가해서 크롬에서도 소리가 잘 나게 되었다.
이 글을 쓰면서 코드를 다시 보면서도 여기 저기에 여러 변수들이 불필요하게 남아있다는 느낌이 들었다. 그때 그때 생각난 요소들을 추가하다보니 코드의 구조도 꼬이기 쉽게 바뀌어 버렸는데, 다음에 다른 프로젝트를 하게 된다면 보다 깔끔하게 짤 수 있도록 해야겠다고 느꼈다.