Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b168d71
docs: Edit README.md to include functional requirements
KimYjoo Oct 24, 2025
f2b17ca
feat: Add user input functions of car and attempt
KimYjoo Oct 25, 2025
cec3017
feat: Add function of user input process
KimYjoo Oct 25, 2025
d828264
docs: Edit README.md to update checklist
KimYjoo Oct 25, 2025
b7e96df
feat: Add function to validate car Input String
KimYjoo Oct 25, 2025
867b7f7
feat: Add function to validate attempt input Number
KimYjoo Oct 25, 2025
985493a
feat: Add core function related to game progress
KimYjoo Oct 25, 2025
0bf1270
feat: Add function to get race winner
KimYjoo Oct 25, 2025
a9b29bd
feat: Edit game progress function
KimYjoo Oct 26, 2025
dc2ed9d
refactor: Rename variables and functions to be more meaningful
KimYjoo Oct 26, 2025
67f2372
refactor: Modify judgeWinner function logic to use JS syntax more eff…
KimYjoo Oct 26, 2025
2f7d85a
refactor: relocate functions related to the car class
KimYjoo Oct 27, 2025
d2c381f
refactor: Separate I/O related functions
KimYjoo Oct 27, 2025
3b2e389
refactor: Restructure main logic into a class and Move I/O logic into…
KimYjoo Oct 27, 2025
dcf5304
chore: Remove unused files and obsolete code
KimYjoo Oct 27, 2025
04dc010
refactor: Separate random number generation logic from Car class
KimYjoo Oct 27, 2025
c4a8101
refactor(Car, RacingGame): Add Private fields and getter to enhance …
KimYjoo Oct 27, 2025
55b2d12
refactor: Replace hardcoded values with constants
KimYjoo Oct 27, 2025
1cb89a1
style: format code using Prettier for consistent convention
KimYjoo Oct 27, 2025
d48f7ac
style(validation.js): Add annotation on each validation
KimYjoo Oct 27, 2025
95089da
refactor(RacingGame): Conver carStatus getter function into a class m…
KimYjoo Oct 27, 2025
9701a15
feat: Add validation to prevent decimal input in attempt
KimYjoo Oct 27, 2025
fd9c1ee
feat: Add validation to prevent huge input
KimYjoo Oct 27, 2025
63793cd
feat(RacingGame): Add validations function for domain logic
KimYjoo Oct 27, 2025
d4fcfbe
docs(README.md): Edit README.md to add implementation process
KimYjoo Oct 27, 2025
a4232a6
refactor: Reduce indentation by function extraction
KimYjoo Oct 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,96 @@
# javascript-racingcar-precourse

# 자동차 경주 🚗

## 과제 개요 및 정리

사용자가 `,` 로 구분된 자동차 이름들과 5이하의 시도 횟수를 입력하여 자동차 경주 게임을 진행한다.<br>
각 시도에서 자동차 마다 `0에서 9사이의 랜덤값`을 생성하여 각 자동차의 전진 여부를 결정한다.<br>
랜덤 값이 `4이상일 경우에 전진`한다.<br>
모든 시도가 종료될 때 가장 많이 전진한 자동차가 우승한다.

### 🏃🏻‍➡️ 기능 흐름

1. `경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)` 라는 입력문을 출력한다.
2. `,` 로 구분된 경주할 자동차 이름 문자열을 입력한다.
3. `시도할 횟수는 몇 회인가요?` 라는 입력문을 출력한다.
4. 시도할 횟수를 5이하의 숫자로 입력한다.
5. `실행 결과` 를 출력한다.
6. 한번의 시도에서 각 자동차의 전진도를 한줄씩 `[자동차 이름] :` 의 형식으로 출력하고 전진도만큼 `-` 기호를 붙인다.
7. 한번의 시도에서 각 자동차에 배정될 0에서 9사이의 랜덤 값을 구한다. 4이상일 경우 전진한다.
8. 사용자 입력만큼 시도하고, 모두 종료되면 우승자를 `최종 우승자 : [우승자 이름]` 의 형식으로 출력한다.
9. 공동 우승자가 있을 경우, `최종 우승자 : [우승자 이름], [우승자 이름]` 의 형식으로 출력한다.

### 🤩 기능 요구사항

> #### 사용자 입력

- [x] `경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)` 출력과 함께 사용자가 문자열을 입력한다.
- [x] `시도할 횟수는 몇 회인가요?` 출력과 함께 사용자가 숫자를 입력한다.
> #### 입력값 검증

##### 자동차 입력값

- [x] 공란이면 에러
- [x] 분리된 이름이 공란이거나 스페이스 문자라면 에러
- [x] 이름에 특수문자가 포함되면 에러
- [x] 양쪽 끝에 `,` 문자가 있을 경우, 입력 형식 에러

##### 시도 횟수값

- [x] 시도 횟수값이 없으면 에러
- [x] 시도 횟수값이 숫자가 아니면 에러
- [x] 시도 횟수값이 6이상, 0이하이면 에러
> #### 입력 처리
- [x] 자동차 입력값 양쪽 공백을 제거한다.
- [x] 자동차 입력값을 `,`를 기준으로 분리한다.
- [x] 분리된 이름과 0으로 초기화된 전진도가 저장된 자동차 객체를 각 자동차만큼 생성하고 배열에 저장한다.
> #### 게임 진행
- [x] `실행 결과` 를 출력한다.
- [x] 각 시도 마다 자동차 객체 배열을 순회하면서, 0에서 9사이의 랜덤값을 추출하고 4이상일 경우 해당 자동차 객체의 전진도를 증가시킨다.
- [x] 자동차 객체 배열을 순회하면서 자동차의 전진도를 `[자동차 이름] :`와 함께 전진도만큼 `-`를 출력하여 해당 시도의 결과를 출력한다.
> #### 게임 결과
- [x] 자동차의 전진도 최대값을 구하여, 우승자 자동차를 배열에 수집한다.
- [x] 우승자 자동차 배열을 `,`로 구분된 문자열로 만든다.
- [x] 최종 우승자를 `최종 우승자 : [우승자 이름]` 혹은 `최종 우승자 : [우승자 이름], [우승자 이름]` 와 같은 형식으로 출력한다.

---

## 구현

### 폴더 구조

```
src
│ App.js
│ index.js
├─constants // 상수값
│ gameSettings.js
│ message.js
│ regex.js
├─domain // 자동차 경주 도메인
│ Car.js // 자동차 클래스 정의
│ RacingGame.js // 자동차 경주 클래스 정의
└─view // 입출력 처리
InputView.js
OutputView.js
Validation.js // 입력값 유효성 검사
```

### 구현 전 아이디어

객체 지향 프로그래밍의 MVC 패턴을 참고하여 입출력 관련 기능과 주요 도메인 관련 기능이 분리되도록 폴더 구조를 형성하였다.<br>
자바스크립트는 객체 지향이 아닌 함수 지향이라고 할 수 있지만 주어진 과제의 전체적인 구현 목표는 입력과 출력을 포함하는 기능을 구현하는 것이 목표이기 때문에 부작용이 발생할 수 있는 입출력 관련 기능을 따로 배치하는 것이 필요하다고 생각하였다.<br><br>
따라서 다음과 같이 폴더 구조를 생각하여 리팩토링을 진행했다.<br>

- App (컨트롤러) : 전체적인 기능들의 파이프라인으로써 자동차 경주의 기능 흐름을 정의한다.
- view (뷰) : 자동차 경주의 입력과 출력 관련 기능 집합
- domain (모델) : 자동차 경주의 진행 관련 기능의 집합

### 추가 아이디어

유효성 검사를 초기엔 입력 부분에 대해서만 진행했다. 이 부분에 관해서 chatGPT에게 물어본 결과 자동차 이름 관련한 형식의 유효성 검사는 입력 부분에서 진행하는것이 옳고, 자동차 이름의 규칙을 정하는 것은 도메인 내부의 유효성 검사로 넣는 것이 옳다는 결과를 얻었다.<br><br>
그 이유는 현재는 콘솔을 통한 입력으로 자동차 이름을 받고 있지만 이후 확장으로 파일 입력으로 자동차 이름을 받을 때, 기존 입력에서 이름 규칙 유효성 검사를 진행하면 OCP 규칙에 위반된다. 파일입력 관련 기능을 추가 했지만, 코드 중복을 피하기 위해 기존 입력에서 이름 규칙 유효성 검사를 따로 빼야하는 수정이 불가피하기 때문이다.
28 changes: 27 additions & 1 deletion src/App.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
import * as InputView from "./view/InputView.js";
import * as OutputView from "./view/OutputView.js";
import RacingGame from "./domain/RacingGame.js";

class App {
async run() {}
async run() {
try {
await this.#playSingleGame();
} catch (error) {
OutputView.printErrorMessage(error);
throw error;
}
}
async #playSingleGame() {
const carList = await InputView.readCarNames();
const attemptCount = await InputView.readAttemptCount();

const raceGame = new RacingGame(carList);

OutputView.printRaceHeader();
for (let i = 0; i < attemptCount; i++) {
raceGame.runSingleAttempt();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

입력된 횟수만큼 라운드를 진행하는 로직을 한 번에 처리하지 않으시고 따로 왜runSingleAttempt로 별도의 메소드를 두시고 여러번 호출하셨는지 궁금합니다! 걸리는 시간은 비슷할 것 같은데 특별한 이유가 있으셨는지 궁금합니다.

OutputView.printSingleAttemptResult(raceGame.getCarStatusList());
}
const raceWinner = raceGame.judgeWinner();

OutputView.printWinners(raceWinner);
}
}

export default App;
14 changes: 14 additions & 0 deletions src/constants/gameSettings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const GameSettings = Object.freeze({
MAX_INPUT: 1000,

MAX_NAME: 100,

MAX_ATTEMPT: 5,
MIN_ATTEMPT: 1,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

게임 진행 횟수와 관련해서 최솟값 제한을 둔 부분이 인상 깊습니다!
다만, 최대 값에 대해서는 제한을 하지 않아도 될 것 같은데 제한 하셨다는 점에서 의문이 있습니다. 혹시 특별한 이유가 있으셨을까요?

PROGRESS_VISUAL: "-",
VISUAL_SPACE: "",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 VISUAL_SPACE는 무슨 역할을 하는 상수인가요? 필요한 상수로는 보이지 않는데, 선언하신 이유가 궁금합니다!


CAR_NAME_DELIMITER: ",",
STRINGIZE_DELIMITER: ", ",
});
26 changes: 26 additions & 0 deletions src/constants/message.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { GameSettings } from "./gameSettings.js";

export const Message = Object.freeze({
INPUT_CAR: "경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)\n",
INPUT_ATTEMPT: "시도할 횟수는 몇 회인가요?\n",
RACE_HEADER: "실행 결과",
PREFIX_WINNER: "최종 우승자 : ",
});

export const ErrorMessage = Object.freeze({
PREFIX: "[ERROR]",

BIG_INPUT: `너무 큰 입력값이 들어왔습니다.(${GameSettings.MAX_INPUT}자 제한)`,

CAR_INPUT_NON: "자동차 이름 입력값이 없습니다.",
CAR_INPUT_SPECIAL_CHARACTER: "자동차 이름엔 특수문자가 포함될 수 없습니다.",
CAR_INPUT_FORM: "입력값의 입력 형식을 확인해주세요.",
CAR_NAME_DUPLICATION: "중복된 자동차 이름이 존재합니다.",
CAR_NAME_LIMIT: `너무 긴 이름입니다. (${GameSettings.MAX_NAME}자 제한)`,

ATTEMPT_INPUT_NON: "시도 횟수를 입력하지 않음",
ATTEMPT_INPUT_NAN: "시도 횟수 입력값이 숫자가 아닙니다.",
ATTEMPT_INPUT_OVER_LIMIT:
"입력된 시도 횟수값이 제한된 범위를 초과하였습니다. (1 ~ 5)",
ATTEMPT_INPUT_ISINTEGER: "시도 횟수 값이 소수입니다.",
});
4 changes: 4 additions & 0 deletions src/constants/regex.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const Regex = Object.freeze({
MATCH_CAR_SPECIAL_CHARACTER: /[!@#$%^&*\(\)_+~\`;:\"\'\{\}\[\]<>.\/?\\\-=|]/g,
CHECK_CAR_FORMAT: /,\s*,|^,|,$/g,
});
23 changes: 23 additions & 0 deletions src/domain/Car.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export default class Car {
#carName;
#progress;

constructor({ carName }) {
this.#carName = carName;
this.#progress = 0;
}

get carName() {
return this.#carName;
}
get progress() {
return this.#progress;
}
get carStatus() {
return { carName: this.#carName, progress: this.#progress };
}

move(randomNumber) {
if (randomNumber >= 4) this.#progress += 1;
}
}
59 changes: 59 additions & 0 deletions src/domain/RacingGame.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { MissionUtils, Console } from "@woowacourse/mission-utils";
import Car from "./Car.js";
import { ErrorMessage } from "../constants/message.js";
import { GameSettings } from "../constants/gameSettings.js";

export default class RacingGame {
#carObjects;

constructor(carNames) {
this.#validateCarNames(carNames);
this.#carObjects = carNames.map((name) => new Car({ carName: name }));
}

getCarStatusList() {
return this.#carObjects.map((car) => car.carStatus);
}

runSingleAttempt() {
this.#carObjects.forEach((car) =>
car.move(MissionUtils.Random.pickNumberInRange(0, 9))
);
}

judgeWinner() {
const maxProgress = this.#getMaxCarProgress();
const winnerList = this.#getRaceWinnerList(maxProgress);
return winnerList;
}
#validateCarNames(carNames) {
// 자동차 이름이 중복됐을 경우
const uniqueNames = new Set(carNames);

if (uniqueNames.size !== carNames.length)
throw new Error(
`${ErrorMessage.PREFIX} ${ErrorMessage.CAR_NAME_DUPLICATION}`
);
// 자동차 이름이 너무 길 경우
const hasInvalidLength = carNames.some(
(car) => car.length > GameSettings.MAX_NAME
);
if (hasInvalidLength)
throw new Error(`${ErrorMessage.PREFIX} ${ErrorMessage.CAR_NAME_LIMIT}`);
}
#getMaxCarProgress() {
const maxProgress = this.#carObjects.reduce((currMax, car) => {
return Math.max(currMax, car.progress);
}, -Infinity);
return maxProgress;
}
#getRaceWinnerList(maxProgress) {
const raceWinners = this.#carObjects.reduce((winner, car) => {
if (car.progress === maxProgress) {
winner.push(car.carName);
}
return winner;
}, []);
return raceWinners;
}
}
23 changes: 23 additions & 0 deletions src/view/InputView.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Console } from "@woowacourse/mission-utils";
import * as Validation from "./Validation.js";
import { Message } from "../constants/message.js";
import { GameSettings } from "../constants/gameSettings.js";

export async function readCarNames() {
const carNames = await Console.readLineAsync(Message.INPUT_CAR);
Validation.validateCarInput(carNames);
return inputCarProcess(carNames);
}
function inputCarProcess(rawCarString) {
const strippedString = rawCarString.trim();
const splittedArray = strippedString.split(GameSettings.CAR_NAME_DELIMITER);
return splittedArray;
}
export async function readAttemptCount() {
const attemptCount = await Console.readLineAsync(Message.INPUT_ATTEMPT);
Validation.validateAttemptInput(attemptCount);
return inputAttemptProcess(attemptCount);
}
function inputAttemptProcess(attempt) {
return Number(attempt);
}
27 changes: 27 additions & 0 deletions src/view/OutputView.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Console } from "@woowacourse/mission-utils";
import { Message } from "../constants/message.js";
import { GameSettings } from "../constants/gameSettings.js";

export function printRaceHeader() {
Console.print(Message.RACE_HEADER);
}
export function printWinners(winnerList) {
Console.print(`${Message.PREFIX_WINNER}${stringizeList(winnerList)}`);
}
function stringizeList(rawlist) {
return rawlist.join(GameSettings.STRINGIZE_DELIMITER);
}
export function printSingleAttemptResult(carList) {
carList.forEach((car) => {
Console.print(`${car.carName} : ${visualizeProgress(car.progress)}`);
});
}
function visualizeProgress(progress) {
return Array.from(
{ length: progress },
(v, k) => GameSettings.PROGRESS_VISUAL
).join(GameSettings.VISUAL_SPACE);
}
export function printErrorMessage(error) {
Console.print(error.message);
}
47 changes: 47 additions & 0 deletions src/view/Validation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Regex } from "../constants/regex.js";
import { ErrorMessage } from "../constants/message.js";
import { GameSettings } from "../constants/gameSettings.js";

export function validateCarInput(carInput) {
// 자동차 입력값이 없을 경우
if (!carInput)
throw Error(`${ErrorMessage.PREFIX} ${ErrorMessage.CAR_INPUT_NON}`);
// 자동차 입력값이 너무 큰 경우
if (carInput.length > GameSettings.MAX_INPUT)
throw Error(`${ErrorMessage.PREFIX} ${ErrorMessage.BIG_INPUT}`);
// 자동차 입력값에 특수 문자가 포함됐을 경우
if (Regex.MATCH_CAR_SPECIAL_CHARACTER.test(carInput))
throw Error(
`${ErrorMessage.PREFIX} ${ErrorMessage.CAR_INPUT_SPECIAL_CHARACTER}`
);
// 자동차 입력값에 이름이 공백인 경우나 양쪽 끝에 쉼표가 포함됐을 경우
if (Regex.CHECK_CAR_FORMAT.test(carInput))
throw Error(`${ErrorMessage.PREFIX} ${ErrorMessage.CAR_INPUT_FORM}`);
}

export function validateAttemptInput(attemptInput) {
// 시도 입력값이 없을 경우
if (!attemptInput)
throw Error(`${ErrorMessage.PREFIX} ${ErrorMessage.ATTEMPT_INPUT_NON}`);
// 시도 입력값이 너무 큰 경우
if (attemptInput.length > GameSettings.MAX_INPUT)
throw Error(`${ErrorMessage.PREFIX} ${ErrorMessage.BIG_INPUT}`);

const numberAttempt = Number(attemptInput);
// 시도 입력값이 숫자가 아닌 경우
if (!Number.isFinite(numberAttempt))
throw Error(`${ErrorMessage.PREFIX} ${ErrorMessage.ATTEMPT_INPUT_NAN}`);
// 시도 입력값이 소수인 경우
if (!Number.isInteger(numberAttempt))
throw Error(
`${ErrorMessage.PREFIX} ${ErrorMessage.ATTEMPT_INPUT_ISINTEGER}`
);
// 시도 입력값이 정해진 범위를 넘은 경우
if (
numberAttempt < GameSettings.MIN_ATTEMPT ||
numberAttempt > GameSettings.MAX_ATTEMPT
)
throw Error(
`${ErrorMessage.PREFIX} ${ErrorMessage.ATTEMPT_INPUT_OVER_LIMIT}`
);
}