Front-End/JavaScript

[JavaScript] 자바스크립트로 숫자야구 프로그램 만들기

Olivia Kim 2023. 4. 7. 22:11
반응형

 

문제

  • 컴퓨터는 0과 9 사이의 서로 다른 숫자 3개를 무작위로 뽑습니다.  ex. 123, 579
  • 사용자는 컴퓨터가 뽑은 숫자를 맞추기 위해 시도합니다.
  • 컴퓨터는 사용자가 입력한 세 자리 숫자에 대해서, 아래의 규칙대로 스트라이크(S)와 볼(B)을 알려줍니다.
     - 숫자의 값과 위치가 모두 일치하면 S
     - 숫자의 값은 일치하지만 위치가 틀렸으면 B
  • 기회는 무제한이며, 몇 번의 시도 후에 맞췄는지 기록됩니다.
  • 숫자 3개를 모두 맞춘 경우 게임을 종료합니다.
  • 조건문, 반복문을 활용하여 해결합니다.

 

 

진행 방식

컴퓨터가 숫자를 생성하였습니다. 답을 맞춰보세요!
1번째 시도 : 134
0B0S
2번째 시도 : 238
1B1S
3번째 시도 : 820
2B1S
4번째 시도 : 028
3B
5번째 시도 : 280
3S
4번만에 맞히셨습니다. 
게임을 종료합니다.

 

 

 


내가 작성한 답안

랜덤한 세 자리 수의 답안 생성하기

처음에 '서로 다른 숫자'라는 부분을 보지 못하고 for문으로 풀었다가 코드를 다시 작성했다. 다른 코드는 다 작성했는데 일치하지 않는 세 가지 숫자를 뽑아내는 부분에서 막혔었는데, 페어 프로그래밍을 진행한 팀원분이 while문을 사용하셨다고 하셔서 구현할 수 있었다. 평소에 while문을 사용할 일이 거의 없었어서 생각하지 못했다..!

 

 

random값을 만드는 법에 대해서 while문이 아니라 다른 방법으로도 구현할 수 있는지 찾아보았고, 아래와 같은 세 가지 방법으로 random값을 생성할 수 있었다.

 

 

1. while문을 이용한 random값 생성

let answer = '';
while (answer.length < 3) {
const randomNum = Math.floor(Math.random() * 10);
if(!answer.includes(randomNum)) answer += randomNum;
}

 

answer 문자열이 세자리일 경우 length는 2로 측정되므로 answer.length가 3이 되기 전까지 while문을 돌린다. 만약 answer에 뽑힌 randomNum을 이미 가지고 있다면 다시 while문을 돌려 랜덤값을 뽑고, 가지고 있지 않다면 answer에 해당 randomNum값을 집어넣는다. 출력값은 랜덤한 세 자릿수의 문자열이 나온다.

 

 

2. set 객체를 이용한 random값 생성

let randomNum = new Set();
while (randomNum.size < 3) randomNum.add(Math.floor(Math.random() * 10));
const answer = [...randomNum];
console.log(answer)

 

중복을 허용하지 않는 set 객체를 이용해 중복되지 않는 랜덤값을 만든다. new Set()을 이용해 객체를 만들고, 동일하게 3이 되기 전까지 while문을 돌린다. set 객체는 중복값을 허용하지 않으므로, 랜덤값을 add해도 이미 동일한 값이 있다면 add가 되지 않는다. 랜덤한 세 자리를 만든 후에는 스프레드 연산자를 이용해 객체를 배열로 변환시킨다. 출력값은 랜덤한 세 자릿수의 배열이 나온다.

 

 

3. 배열 메서드를 이용한 random값 생성

  let answer = '';
  let num = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
  while (answer.length < 3) {
    const randomNum = Math.floor(Math.random() * num.length);
    answer += num.splice(randomNum, 1)[0];

 

문제 조건인 0부터 9까지의 수를 num이라는 배열에 담고, 답을 담을 변수인 answer도 생성한다. answer의 length가 3이 되기 전까지 while문을 돌리고, 랜덤 값을 뽑아낸다. 그 후 원본 배열 자체를 수정하는 메서드인 splice를 이용하는 방법이다. 0부터 9까지 담겨있는 num배열에서 randomNum과 일치하는 인덱스를 잘라낸다. randomNum이 1이 나왔고 splice를 이용해 num에서 0번 인덱스 1개를 잘라내면 [1]이라는 값이 나온다. (인덱스는 0부터 시작하므로)

 

이때, answer에 [1] 값을 그대로 넣게되면 [[1]]과 같이 이중배열 형태로 저장되므로 [0]을 붙여 0번째 인덱스인 해당 '값'만 answer에 넣는다. 이렇게 하여 랜덤값을 answer에 넣으면서 num 원본 배열 자체를 수정하게 되므로 두 번째로 while문을 돌 때는 num = [0, 2, 3, 4, 5, 6, 7, 8, 9]와 같은 형태로 진행하게 된다.

 

 

답안 생성 코

function setAnswer() {
  let answer = '';
  while (answer.length < 3) {
    const randomNum = Math.floor(Math.random() * 10);
    if(!answer.includes(randomNum)) answer += randomNum;
  }
  let count = 0;
  console.log(`컴퓨터가 숫자를 생성하였습니다. 답을 맞춰보세요!`);
  getValue(answer, count);
}

 

랜덤값을 뽑아낼때는 0 ~ 1 사이의 유한소수 랜덤값을 출력하는 Math.random() 함수에 10을 곱하고, 우리가 필요한 값은 정수이므로 소수점을 내림하는 Math.floor를 이용한다. (→ 이 부분에 대해서는 하단 [트러블슈팅] 섹션에 자세히 적어두었다.)

 

사용자가 입력하기 전의 최초 count는 0이므로 해당 값을 사용자가 입력한 값과 답안을 비교하는 getValue 함수에 인자로 같이 넘겨준다. setAnswer() 함수 내에서 count를 0으로 선언해 값을 넘겨준 이유는 사용자가 오답일 때는 값을 비교하는 getValue()를 재귀함수(자기 자신을 다시 호출해 작업을 수행하는 함수)로 처리할 것이기 때문이다. 정답을 맞힐 때까지 getValue 함수를 무한반복하게 되는데 그 안에서 let count = 0;을 선언한다면 함수를 다시 돌 때마다 count값이 누적되지 않고 다시 0으로 만들어진다.

 

 

 

사용자에게 입력값을 받고 검증하기

prompt를 이용해 간단하게 입력값을 받을 수 있도록 설정해두었기 때문에, form 내의 input처럼 html의 max-length를 설정할 수 없어 별도의 유효성 검증을 진행해야 했다. 유효성 검증은 사용자가 prompt 창의 '취소' 버튼을 눌렀을 경우 (=== null), 사용자가 숫자가 아닌 값을 입력했을 경우, 사용자가 입력한 값이 3자리보다 클 경우로 설정했다.

 

 

function checkValue() {
  let value = prompt(`정답을 입력하세요.`);
  if(value === null) {
    alert(`입력을 취소하셨습니다.\n새로고침을 눌러 다시 시도해주세요.`);
    return false;
  } else if(value.match(/\D/)) {
    alert(`3자리 수의 숫자만 입력해야 합니다.\n다시 입력해주세요.`);
    return checkValue();
  } else if(value.length > 3) {
    alert(`입력한 값의 길이는 3을 넘을 수 없습니다.\n다시 입력해주세요.`);
    return checkValue();
  }
  return value;
}

 

prompt에 사용자가 입력한 값이 value에 담기고, 각각 검증을 진행한다.

 

취소버튼을 눌러 null값일 때는 새로고침을 눌러 다시 입력을 진행하라는 alert를 띄우고 false를 리턴한다. 그냥 return이 아니라 false를 return한 이유는 답안을 맞춰보는 별도의 함수에서 checkValue() 함수를 호출해 그 값을 받아가기 때문이다.

 

사용자가 입력한 값에 '숫자'만 들어가있는지 확인하기 위해 정규표현식을 사용했고 숫자가 아닌 값이 있는지를 검증하는 \D와, 해당 값이 있을 경우 그 값만 배열로 return하는 match 메서드를 사용했다. 따라서 사용자가 숫자가 아닌 값을 포함해 입력했을 경우 3자리 숫자만 입력해야 한다는 alert를 띄우고, 유효한 정답이 아니므로 count 값을 증가하지 않고 값만 다시 입력하도록 설정했다.

 

사용자가 입력한 값이 숫자만 있으나 3자리 수를 초과할 경우에도 alert를 띄우고 유효한 정답이 아니므로 count값을 증가시키지 않고 값만 다시 입력하도록 checkValue() 함수를 다시 호출했다.

 

 

 

입력값과 답안을 비교하기

function getValue(answer, count) {
  let value = checkValue();
  if(!value) return;
  count++;
  console.log(`${count}번째 시도 : ${value}`);

  let s = 0, b = 0;
  let str = '';
  value.split('').forEach((e, idx) => {
    if(answer.indexOf(e) === idx) s++;
    else if(answer.split('').includes(e)) b++;
  })

  if(s === 3) str = `${s}S`;
  else if(b === 3) str = `${b}B`;
  else str = `${b}B${s}S`;
  console.log(str);
  answer !== value ? getValue(answer, count) : console.log(`${count}번만에 맞히셨습니다.\n게임을 종료합니다.`);
}

 

사용자가 입력하는 값을 받고, 검증한 뒤 유효한 값일 경우 해당 값을 return하는 checkValue() 함수를 호출해 그 값을 변수에 담는다. 이때 사용자가 취소버튼을 눌렀을 경우에는 false를 return하도록 했으므로 value값에 false가 들어오면 함수를 종료하도록 했다. 유효한 값이라면 사용자가 도전한 횟수인 count를 증가시키고, 문제의 조건과 같이 몇 번째 시도이고 어떤 값을 입력했는지 console로 보여준다.

 

스트라이크와 볼을 각각 s, b 변수에 0으로 담고, 문제 조건을 보면 s, b 횟수에 따라 출력되는 console값이 달랐으므로 (1S2B, 3S, 3B. 3S나 3B일 경우에는 0인 다른 값을 보여주지 않았다.) 사용자에게 보여줄 안내 문구를 담을 str 변수를 선언한다.

 

사용자가 입력한 값을 배열로 잘라 forEach문을 돌려 각 값과 해당 값의 인덱스를 뽑아낸다. 먼저 정답 answer에 e라는 값이 있고, 그 값이 forEach의 인덱스 값과 같다면 숫자와 자릿수 모두 일치하는 것이므로 s값을 증가시킨다. 만약 정답이 123이고 사용자가 입력한 값이 199, forEach로 돌고 있는 e = 1, idx = 0 (199에서 1 값의 인덱스는 0) 이라면, answer.indexOf(e)를 통해 123에 1이 있는지를 찾는다. 123에서 0번째에 1이 있으므로 0 값이 나온다. e의 인덱스값도 0이므로 0 === 0이므로 숫자와 자릿수가 모두 일치하다는 것.

 

또는 indexOf(e)를 했을때 인덱스 값이 같지 않다면, 값은 같지만 인덱스만 다른 것인지 아니면 값 자체가 다른 것인지 확인하기 위해 answer에 e값을 포함하고 있는지 includes 메서드로 확인한다. includes는 Array 메서드이므로 split('')으로 answer를 배열로 만든다. (지금 생각해보니 answer나 value 자체를 string으로 만들기보다 아예 array로 만들어버리는 게 코드를 조금이라도 줄이는 측면에서 더 나았을까 싶은데?) includes값이 true라면 해당 값은 있으나 자리수만 다르다는 뜻이므로 (이미 위의 if문에서 자리수가 다르다는 게 걸러져 나왔기 때문이다.) b값을 증가시킨다.

 

마지막으로 사용자에게 s, b값을 console로 안내하기 위해 s, b의 개수에 따라 문자열을 다르게 만들고 정답 answer와 사용자가 입력한 값 value값이 다르다면 getValue() 함수를 다시 호출해 다시 입력값을 받고 count를 증가하고 답안 검증을 무한반복, 답을 맞혔다면 몇 번만에 맞췄는지 보여주고 함수를 끝낸다.

 

 

 


실행 화면

 

 


전체 답안 보기

(▼ 더보기 클릭)

더보기
setAnswer();

// 답안 생성
function setAnswer() {
  let answer = '';
  while (answer.length < 3) {
    const randomNum = Math.floor(Math.random() * 10);
    if(!answer.includes(randomNum)) answer += randomNum;
  }
  let count = 0;
  console.log(`컴퓨터가 숫자(${answer})를 생성하였습니다. 답을 맞춰보세요!`);
  getValue(answer, count);
}

// 사용자에게 입력값 받기
function checkValue() {
  let value = prompt(`정답을 입력하세요.`);
  if(value === null) {
    alert(`입력을 취소하셨습니다.\n새로고침을 눌러 다시 시도해주세요.`);
    return false;
  } else if(value.match(/\D/)) {
    alert(`3자리 수의 숫자만 입력해야 합니다.\n다시 입력해주세요.`);
    return checkValue();
  } else if(value.length > 3) {
    alert(`입력한 값의 길이는 3을 넘을 수 없습니다.\n다시 입력해주세요.`);
    return checkValue();
  }
  return value;
}

// 입력값과 답안 비교
function getValue(answer, count) {
  let value = checkValue();
  if(!value) return;
  count++;
  console.log(`${count}번째 시도 : ${value}`);

  let s = 0, b = 0;
  let str = '';
  value.split('').forEach((e, idx) => {
    if(answer.indexOf(e) === idx) s++;
    else if(answer.split('').includes(e)) b++;
  })

  if(s === 3) str = `${s}S`;
  else if(b === 3) str = `${b}B`;
  else str = `${b}B${s}S`;
  console.log(str);
  answer !== value ? getValue(answer, count) : console.log(`${count}번만에 맞히셨습니다.\n게임을 종료합니다.`);
}

 

 

 

 


트러블슈팅

 ReferenceError: can't access lexical declaration`X' before initialization 

해당 오류는 별도의 게시글로 작성하였다. 초기에 작성한 코드로 현재 코드와 다소 차이가 있다!

 

 

 

[JavaScript] Uncaught ReferenceError: Cannot access 'X' before initialization

오류 발생 경로 // 입력값 받기 let value = prompt('답을 맞춰보세요!'); if(value === '' || value === null) alert('입력된 값이 없습니다.'); else numberBaseball(value); // 입력값 콘솔에 찍기 const numberBaseball = value => {

oliviakim.tistory.com

 

 

 

 랜덤한 값을 어떻게 만들 것인지에 대한 문제 

페어 프로그래밍을 하며 팀원분께 while을 이용하면 된다는 말을 듣기 전에 시도해 봤던 메서드들이다.

 

 

1. fill로 정답을 채워보기

const num = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const randomNum = Math.floor(Math.random() * (num.length - 1));
let answer = [0, 0, 0];
answer.fill(randomNum, 0)
console.log(answer)
// -> 똑같은 값으로밖에 못채움.

 

Array.prototype.fill()

배열의 시작 인덱스부터 끝 인덱스의 이전까지 정적인 값 하나로 채운다.

 

fill 메서드를 이용했을 때는 randomNum으로 뽑아낸 하나의 값만으로 배열을 채웠다.

 

 

2. for문을 돌려서 정답을 채워보기

// i가 4, 4, 6이 나왔을 경우 4, 6만 들어감
for(let i = 0; i < 3; i ++) {
  let randomNum = Math.floor(Math.random() * 9);
  console.log(a);
  if(!answer.includes(randomNum )) answer.push(randomNum );
}
console.log(answer);

 

for문이 세 번밖에 돌지 않기 때문에 기존에 answer에 있는 값과 randomNum이 동일할 때는 세 자리 수가 모두 채워지지 않았다.

 

위와 같이 다양한 시도를 해보며 fill이 하나의 값만으로만 배열을 채운다는 것을 알 수 있었고, 내가 의도한 바를 구현하기 위해서는 값을 순차적으로 돌며 채울 것이 아니라 계속 돌아야 한다는 것을 깨달을 수 있었다.

 

 

 

 Math.random() 범위를 잘못 알고 있던 문제 

팀 내에서 코드 리뷰를 하다가 팀원분들이 발견해주신 오류이다. 이전에는 랜덤값을 뽑아낼 때 아래와 같이 코드를 작성했다.

 

Math.floor(Math.random() * 9);

 

9가 아니라 10이어야 한다는 말을 듣고 0부터 9까지의 수를 뽑아야 하는데 왜 9를 곱하는지 이해하지 못했다. 다시 찾아보니 내가 Math.random()의 범위를 잘못 기억하고 있기 때문이었다!

 

 

Math.random은 0 이상 1 미만의 랜덤 소수값을 반환한다. ex. 0.12314124... 나는 0 이상 1 이하인 줄 알고 0~9 값을 뽑아내기 위해 * 9를 했는데, 미만에 해당하는 값을 뽑으려면 * 10을 해야 맞다. 이와 같이 Math.random()으로 뽑아낸 값에 * 9를 하면 해당 코드를 실행할 때마다 0.xxx, 1.xxx, 2.xxx, ... 9.xxx의 소수점값 중에서 랜덤으로 하나의 수가 반환된다. 이 중에서 정수만 필요하기 때문에 소수점을 내림하는 Math.floor를 이용해 정수를 만들어주면 최종적으로 0~9 사이의 랜덤 정수가 나오게 된다.

 

 

 

 변수가 초기화되지 않고 누적되는 문제 

function checkValue() {
  let value = prompt(`정답을 입력하세요.`);
  if(value === null) {
    alert(`입력을 취소하셨습니다.\n새로고침을 눌러 다시 시도해주세요.`);
    return false;
  } else if(value.match(/\D/)) {
    alert(`3자리 수의 숫자만 입력해야 합니다.\n다시 입력해주세요.`);
    checkValue();
  } else if(value.length > 3) {
    alert(`입력한 값의 길이는 3을 넘을 수 없습니다.\n다시 입력해주세요.`);
    checkValue();
  }
  console.log(`if를 뚫고 나온 value : ${value}`);
  return value;
}

 

맨 처음 작성했던 입력값 검증 코드는 위와 같았다. 이렇게 작성하고 함수를 실행해보면, checkValue를 다시 호출해 새로운 값을 받았음에도 불구하고 이전에 입력했던 틀린 값이 return value 처리되었다. checkValue()를 다시 호출하기 전에 value = '';를 작성해도 현상은 동일했다. 심지어 이전에 입력된 값이 누적된 채로 if-else문까지 뚫어버리고 값이 넘어가버렸다..!

 

 

이후에 새로운 값을 다시 입력했음에도 불구하고 이전의 유효하지 않은 값이 return 처리되었다.

 

어느 부분이 원인인지 보이지 않아 한참을 헤매다 기술매니저님께 질문드렸는데, 문제는 checkValue를 다시 호출하는 부분이었다! 함수를 재귀처리할 때 그냥 checkValue()라고 적었기 때문에 이전의 잘못 입력된 값이 담긴 함수가 종료되지 않고 계속 값이 쌓여버린 것! (그럼 변수에 먼저 입력된 값이 먼저 나가는건 변수에 값이 큐 구조로 쌓이는 건가?)

 

 

function checkValue() {
  let value = prompt(`정답을 입력하세요.`);
  if(value === null) {
    alert(`입력을 취소하셨습니다.\n새로고침을 눌러 다시 시도해주세요.`);
    return false;
  } else if(value.match(/\D/)) {
    alert(`3자리 수의 숫자만 입력해야 합니다.\n다시 입력해주세요.`);
    return checkValue();
  } else if(value.length > 3) {
    alert(`입력한 값의 길이는 3을 넘을 수 없습니다.\n다시 입력해주세요.`);
    return checkValue();
  }
  return value;
}

 

자바스크립트에서 함수를 종료하려면 return 명령문을 사용해야 한다. return 명령문은 함수 실행을 종료하고, return 옆의 값을 반환한다. 위와 같이 return 명령문을 사용해 해당 함수를 종료하며 checkValue()를 불러내면, 이전에 잘못 입력한 값은 그대로 종료되고 새로운 값만 value에 담기게 된다.

 

 

 


[참고 자료]

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Math/random

 

Math.random() - JavaScript | MDN

Math.random() 함수는 0 이상 1 미만의 구간에서 근사적으로 균일한(approximately uniform) 부동소숫점 의사난수를 반환하며, 이 값은 사용자가 원하는 범위로 변형할 수 있다. 난수 생성 알고리즘에 사용

developer.mozilla.org

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/return

 

return - JavaScript | MDN

return 명령문은 함수 실행을 종료하고, 주어진 값을 함수 호출 지점으로 반환합니다.

developer.mozilla.org

 

 

 

반응형