diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 000000000..c5624c4e5 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,20 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 12, + "sourceType": "module" + }, + "plugins": [ + "@typescript-eslint" + ], + "rules": { + } +} diff --git a/.gitignore b/.gitignore index 882ea6b5d..97b818cef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +package-lock.json +yarn.lock + # Created by https://www.toptal.com/developers/gitignore/api/node,vscode,intellij,webstorm # Edit at https://www.toptal.com/developers/gitignore?templates=node,vscode,intellij,webstorm @@ -192,7 +195,7 @@ typings/ # Nuxt.js build / generate output .nuxt -dist +# dist # Gatsby files .cache/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..dd75ac73f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "parser": "typescript", + "singleQuote": true, + "trailingComma": "all", + "printWidth": 120 + } \ No newline at end of file diff --git a/cypress.json b/cypress.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/cypress.json @@ -0,0 +1 @@ +{} diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json new file mode 100644 index 000000000..02e425437 --- /dev/null +++ b/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/cypress/integration/racingcar.spec.js b/cypress/integration/racingcar.spec.js new file mode 100644 index 000000000..39c545fde --- /dev/null +++ b/cypress/integration/racingcar.spec.js @@ -0,0 +1,139 @@ +import { ALERT, DELAY } from '../../dist/constants.js'; + +let carNamesSample = 'jwon, yeji, holee, yshin'; +let tryCountSample = 3; + +const carNameInputAndSubmit = (carNames) => { + if (carNames) { + cy.get('#car-names-input').type(carNames); + } + cy.get('#car-names-submit').click(); +}; + +const tryCountInputAndSubmit = (tryCount) => { + if (tryCount) { + cy.get('#racing-count-input').type(tryCount); + } + cy.get('#racing-count-submit').click(); +}; + +const catchAlertMessage = (alertMessage) => { + cy.on('window:alert', txt => { + expect(txt).to.contains(alertMessage); + }); +}; + +describe('0. 초기화면 로딩 테스트', () => { + beforeEach(() => { + cy.visit('http://localhost:5500'); + }); + + it('자동차 경주 게임을 실행하면, 인풋 섹션이 보이고 자동차 입력창이 활성화된다.=', () => { + cy.get('#input-section').should('be.visible'); + cy.get('#car-names-input').should('not.be.disabled'); + cy.get('#car-names-submit').should('not.be.disabled'); + }); +}); + +describe('1. 자동차 이름 입력 테스트', () => { + beforeEach(() => { + cy.visit('http://localhost:5500'); + }); + + it('자동차 이름을 입력하지 않고 확인 버튼을 클릭하면, 경고창을 띄운다', () => { + carNameInputAndSubmit(''); + catchAlertMessage(ALERT.CARNAME_NOTHING); + }); + + it('자동차 이름을 1개만 입력하고 확인 버튼을 클릭하면, 경고창을 띄운다', () => { + carNameInputAndSubmit('jwon'); + catchAlertMessage(ALERT.CARNAME_ALONE); + }); + + it('비어있는 자동차 이름을 입력하고 확인 버튼을 클릭하면, 경고창을 띄운다', () => { + carNameInputAndSubmit('jwon, , holee'); + catchAlertMessage(ALERT.CARNAME_EMPTY); + }); + + it('중복된 자동차 이름을 입력하고 확인 버튼을 클릭하면, 경고창을 띄운다', () => { + carNameInputAndSubmit('jwon, jwon, yechoi, holee, yshin'); + catchAlertMessage(ALERT.CARNAME_DOUBLE); + }); + + it('5글자 넘는 자동차 이름을 입력하고 확인 버튼을 클릭하면, 경고창을 띄운다', () => { + carNameInputAndSubmit('jwon, yechoi'); + catchAlertMessage(ALERT.CARNAME_LENGTH); + }); + + it('자동차 이름을 정상적으로 입력하고 확인 버튼을 클릭하면, 자동차 이름 입력칸과 클릭 버튼을 비활성화 하고 시도 횟수 입력칸과 시도 횟수 버튼을 활성화한다', () => { + carNameInputAndSubmit(carNamesSample); + cy.get('#car-names-input').should('be.disabled'); + cy.get('#car-names-submit').should('be.disabled'); + cy.get('#racing-count-input').should('not.be.disabled'); + cy.get('#racing-count-submit').should('not.be.disabled'); + }); + + it('자동차 이름을 정상적으로 입력하고 확인 버튼을 클릭하면, 진행상황 섹션이 로딩된다. ', () => { + carNameInputAndSubmit(carNamesSample); + tryCountInputAndSubmit(tryCountSample); + cy.get('#progress-section').should('be.visible'); + }); +}); + +describe('2. 시도 횟수 입력 테스트', () => { + beforeEach(() => { + cy.visit('http://localhost:5500'); + }); + + it('횟수를 입력하지 않고 확인 버튼을 클릭하면, 경고창을 띄운다', () => { + carNameInputAndSubmit(carNamesSample); + tryCountInputAndSubmit(); + catchAlertMessage(ALERT.TRYCOUNT_NOTHING); + }); + + it('음수를 입력하고 확인 버튼을 클릭하면, 경고창을 띄운다', () => { + carNameInputAndSubmit(carNamesSample); + tryCountInputAndSubmit(-5); + catchAlertMessage(ALERT.TRYCOUNT_UINT); + }); + + it('50 이상을 입력하고 확인 버튼을 클릭하면, 경고창을 띄운다', () => { + carNameInputAndSubmit(carNamesSample); + tryCountInputAndSubmit(51); + catchAlertMessage(ALERT.TRYCOUNT_TOO_BIG); + }); +}); + +describe('3. 진행 상황 출력 테스트', () => { + beforeEach(() => { + cy.visit('http://localhost:5500'); + }); + + it('자동차 이름과 시도 횟수를 정상적으로 입력하고 확인 버튼을 클릭하면, 게임 진행 후 결과 섹션이 로딩된다', () => { + carNameInputAndSubmit(carNamesSample); + tryCountInputAndSubmit(tryCountSample); + }); + + it('게임이 끝나면, 결과를 출력하고 축하 메세지를 띄운다', () => { + carNameInputAndSubmit(carNamesSample); + tryCountInputAndSubmit(tryCountSample); + cy.wait(2000); + catchAlertMessage(ALERT.CONGRATULATION); + }); +}); + +describe('4. 게임 재시작 테스트', () => { + beforeEach(() => { + cy.visit('http://localhost:5500'); + }); + + it('다시 시작하기 버튼을 클릭하면, 진행 섹션과 결과 섹션이 사라지고, 자동차 이름 입력칸이 빈 상태로 게임 시작 대기상태가 된다', () => { + carNameInputAndSubmit(carNamesSample); + tryCountInputAndSubmit(tryCountSample); + cy.wait(tryCountSample * DELAY.GAME_TURN + DELAY.GAME_END); + cy.get('#restart-button').click(); + cy.get('#progress-section').should('not.exist'); + cy.get('#result-section').should('not.exist'); + cy.get('#car-names-input').should('have.value', ''); + }); +}); diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js new file mode 100644 index 000000000..59b2bab6e --- /dev/null +++ b/cypress/plugins/index.js @@ -0,0 +1,22 @@ +/// +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +/** + * @type {Cypress.PluginConfig} + */ +// eslint-disable-next-line no-unused-vars +module.exports = (on, config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +} diff --git a/cypress/support/commands.js b/cypress/support/commands.js new file mode 100644 index 000000000..119ab03f7 --- /dev/null +++ b/cypress/support/commands.js @@ -0,0 +1,25 @@ +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) diff --git a/cypress/support/index.js b/cypress/support/index.js new file mode 100644 index 000000000..d68db96df --- /dev/null +++ b/cypress/support/index.js @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') diff --git a/dist/constants.js b/dist/constants.js new file mode 100644 index 000000000..7e06f9006 --- /dev/null +++ b/dist/constants.js @@ -0,0 +1,31 @@ +const DELAY = { + // millisecond(s) + GAME_TURN: 1000, + GAME_END: 2000, +}; +const RULE = { + // random(0~9) 값이 4 이상일 경우 전진, 3 이하의 값이면 전진하지 않음 + MIN_SCORE: 0, + MAX_SCORE: 9, + THRESHOULD_SCORE: 4, + // 자동차 이름은 5자 이하만 가능 + MAX_CARNAME_LENGTH: 5, + // 시도 횟수는 원활한 게임을 위해 자체적으로 제한 + MIN_TRYCOUNT: 1, + MAX_TRYCOUNT: 50, +}; +const ALERT = { + // for check valid car name + CARNAME_NOTHING: '자동차 이름을 입력해주세요', + CARNAME_ALONE: '자동차 이름을 2개 이상 입력해주세요', + CARNAME_EMPTY: '비어 있는 자동차 이름이 있습니다', + CARNAME_DOUBLE: '중복된 자동차 이름이 있습니다', + CARNAME_LENGTH: '자동차 이름 글자수가 초과되었습니다 (5자 이내)', + // for check valid try count + TRYCOUNT_NOTHING: '시도 횟수를 입력해주세요', + TRYCOUNT_UINT: '시도 횟수는 양의 정수를 입력해주세요 (50 이하)', + TRYCOUNT_TOO_BIG: '시도 횟수가 너무 많습니다 (50 이하)', + // etc. + CONGRATULATION: '님 우승을 축하합니다 👏', +}; +export { DELAY, RULE, ALERT }; diff --git a/dist/controller/gameController.js b/dist/controller/gameController.js new file mode 100644 index 000000000..1e222bd10 --- /dev/null +++ b/dist/controller/gameController.js @@ -0,0 +1,36 @@ +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +import { ALERT, DELAY } from '../constants.js'; +import { $ } from '../utils.js'; +import { Game } from '../model/Game.js'; +import { enableButton } from '../view/utils.js'; +import { renderArrowDiv, removeSpinnerDivs } from '../view/progressSectionRenderer.js'; +import { renderResultSection } from '../view/resultSectionRenderer.js'; +import { addRestartButtonEvent } from './restartController.js'; +const playOnce = (racingGame) => { + racingGame.play(); + racingGame.roundWinners.forEach((index) => { + renderArrowDiv(index); + }); + racingGame.initRoundWinners(); +}; +const startGame = (inputData) => __awaiter(void 0, void 0, void 0, function* () { + const racingGame = new Game(inputData.carNameArray); + for (let index = 0; index < inputData.tryCount; index += 1) { + yield racingGame.makeDelay(DELAY.GAME_TURN).then(() => playOnce(racingGame)); + } + racingGame.judgeFinalWinners(); + removeSpinnerDivs(); + renderResultSection(racingGame.finalWinners.join(', ').toLowerCase()); + yield racingGame.makeDelay(DELAY.GAME_END).then(() => alert(racingGame.finalWinners.join(', ') + ALERT.CONGRATULATION)); + enableButton($('#restart-button')); + addRestartButtonEvent(); +}); +export { startGame }; diff --git a/dist/controller/inputController.js b/dist/controller/inputController.js new file mode 100644 index 000000000..933b237e2 --- /dev/null +++ b/dist/controller/inputController.js @@ -0,0 +1,39 @@ +import { $ } from '../utils.js'; +import { startGame } from './gameController.js'; +import { InputData } from '../model/InputData.js'; +import { renderProgressSection } from '../view/progressSectionRenderer.js'; +import { clearInput, enableInput, disableInput, enableButton, disableButton } from '../view/utils.js'; +const getCarNames = (inputData) => { + const carNameInput = $('#car-names-input'); + const carNameArray = carNameInput === null || carNameInput === void 0 ? void 0 : carNameInput.value.split(',').map((x) => x.trim()); + if (inputData.checkCarNames(carNameInput, carNameArray)) { + renderProgressSection(carNameArray); + inputData.carNameArray = carNameArray; + disableInput(carNameInput); + disableButton($('#car-names-submit')); + enableInput($('#racing-count-input')); + enableButton($('#racing-count-submit')); + $('#racing-count-input').focus(); + return; + } + clearInput(carNameInput); +}; +const getTryCount = (inputData) => { + const tryCountInput = $('#racing-count-input'); + const tryCount = Number(tryCountInput === null || tryCountInput === void 0 ? void 0 : tryCountInput.value); + if (inputData.checkTryCount(tryCountInput)) { + inputData.tryCount = tryCount; + disableInput(tryCountInput); + disableButton($('#racing-count-submit')); + startGame(inputData); + return; + } + clearInput(tryCountInput); +}; +const addInputButtonsEvent = () => { + var _a, _b; + const inputData = new InputData(); + (_a = $('#car-names-submit')) === null || _a === void 0 ? void 0 : _a.addEventListener('click', () => getCarNames(inputData)); + (_b = $('#racing-count-submit')) === null || _b === void 0 ? void 0 : _b.addEventListener('click', () => getTryCount(inputData)); +}; +export { addInputButtonsEvent }; diff --git a/dist/controller/restartController.js b/dist/controller/restartController.js new file mode 100644 index 000000000..fc03bb5e2 --- /dev/null +++ b/dist/controller/restartController.js @@ -0,0 +1,14 @@ +import { $ } from '../utils.js'; +import { removeChildNodes } from '../view/utils.js'; +import { renderInputSection } from '../view/inputSectionRenderer.js'; +const init = () => { + removeChildNodes($('#app')); + renderInputSection(); + addRestartButtonEvent(); + $('#car-names-input').focus(); +}; +const addRestartButtonEvent = () => { + var _a; + (_a = $('#restart-button')) === null || _a === void 0 ? void 0 : _a.addEventListener('click', () => init()); +}; +export { addRestartButtonEvent }; diff --git a/src/css/index.css b/dist/css/index.css similarity index 100% rename from src/css/index.css rename to dist/css/index.css diff --git a/src/css/shared/button.css b/dist/css/shared/button.css similarity index 100% rename from src/css/shared/button.css rename to dist/css/shared/button.css diff --git a/src/css/shared/layout.css b/dist/css/shared/layout.css similarity index 100% rename from src/css/shared/layout.css rename to dist/css/shared/layout.css diff --git a/src/css/shared/sizing.css b/dist/css/shared/sizing.css similarity index 100% rename from src/css/shared/sizing.css rename to dist/css/shared/sizing.css diff --git a/src/css/shared/typhography.css b/dist/css/shared/typhography.css similarity index 100% rename from src/css/shared/typhography.css rename to dist/css/shared/typhography.css diff --git a/src/css/ui/spinner.css b/dist/css/ui/spinner.css similarity index 100% rename from src/css/ui/spinner.css rename to dist/css/ui/spinner.css diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 000000000..c8bceb728 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,7 @@ +import { renderInputSection } from './view/inputSectionRenderer.js'; +import { addInputButtonsEvent } from './controller/inputController.js'; +const app = () => { + renderInputSection(); + addInputButtonsEvent(); +}; +app(); diff --git a/dist/model/Car.js b/dist/model/Car.js new file mode 100644 index 000000000..ab30135a0 --- /dev/null +++ b/dist/model/Car.js @@ -0,0 +1,11 @@ +class Car { + constructor(name, index) { + this.name = name; + this.position = 0; + this.index = index; + } + moveForward() { + this.position += 1; + } +} +export { Car }; diff --git a/dist/model/Game.js b/dist/model/Game.js new file mode 100644 index 000000000..cff8bc76c --- /dev/null +++ b/dist/model/Game.js @@ -0,0 +1,59 @@ +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +import { Car } from './Car.js'; +import { RULE } from '../constants.js'; +class Game { + constructor(carNameArray) { + this.cars = []; + this.finalWinners = []; + this.roundWinners = []; + this.maxPosition = 0; + this.setCarsObject(carNameArray); + } + play() { + this.cars.forEach((car) => { + if (Math.floor(Math.random() * RULE.MAX_SCORE) + RULE.MIN_SCORE >= RULE.THRESHOULD_SCORE) { + car.moveForward(); + this.roundWinners.push(car.index); + } + }); + this.updateMaxPosition(); + } + setCarsObject(carNameArray) { + carNameArray.forEach((car, index) => { + this.cars.push(new Car(car, index)); + }); + } + ; + makeDelay(ms) { + return __awaiter(this, void 0, void 0, function* () { + return new Promise((r) => setTimeout(r, ms)); + }); + } + ; + updateMaxPosition() { + this.cars.forEach((car) => { + if (car.position > this.maxPosition) { + this.maxPosition = car.position; + } + }); + } + initRoundWinners() { + this.roundWinners = []; + } + judgeFinalWinners() { + this.cars.forEach((car) => { + if (car.position === this.maxPosition) { + this.finalWinners.push(car.name); + } + }); + } +} +export { Game }; diff --git a/dist/model/InputData.js b/dist/model/InputData.js new file mode 100644 index 000000000..0d0357cc7 --- /dev/null +++ b/dist/model/InputData.js @@ -0,0 +1,74 @@ +import { RULE, ALERT } from '../constants.js'; +class InputData { + constructor() { + this.carNameArray = []; + this.tryCount = 0; + } + checkEmptyInput(input) { + return input.value.trim() === ''; + } + ; + checkArrayHasEmptyElement(array) { + return array.some((x) => x === ''); + } + checkArrayHasOneElement(array) { + return array.length < 2; + } + ; + checkArrayDupElements(array) { + return array.some((x) => { + return array.indexOf(x) !== array.lastIndexOf(x); + }); + } + ; + checkArrayElementsLength(array) { + for (let index = 0; index < array.length; index += 1) { + if (array[index].length > RULE.MAX_CARNAME_LENGTH) { + return true; + } + } + return false; + } + ; + checkCarNames(carNameInput, carNameArray) { + if (this.checkEmptyInput(carNameInput)) { + alert(ALERT.CARNAME_NOTHING); + return false; + } + else if (this.checkArrayHasOneElement(carNameArray)) { + alert(ALERT.CARNAME_ALONE); + return false; + } + else if (this.checkArrayHasEmptyElement(carNameArray)) { + alert(ALERT.CARNAME_EMPTY); + return false; + } + else if (this.checkArrayDupElements(carNameArray)) { + alert(ALERT.CARNAME_DOUBLE); + return false; + } + else if (this.checkArrayElementsLength(carNameArray)) { + alert(ALERT.CARNAME_LENGTH); + return false; + } + return true; + } + ; + checkTryCount(tryCountInput) { + if (this.checkEmptyInput(tryCountInput)) { + alert(ALERT.TRYCOUNT_NOTHING); + return false; + } + else if (Number(tryCountInput.value) < RULE.MIN_TRYCOUNT) { + alert(ALERT.TRYCOUNT_UINT); + return false; + } + else if (Number(tryCountInput.value) > RULE.MAX_TRYCOUNT) { + alert(ALERT.TRYCOUNT_TOO_BIG); + return false; + } + return true; + } + ; +} +export { InputData }; diff --git a/dist/utils.js b/dist/utils.js new file mode 100644 index 000000000..bfea08cd6 --- /dev/null +++ b/dist/utils.js @@ -0,0 +1,3 @@ +const $ = (selector) => document.querySelector(selector); +const $$ = (selector) => document.querySelectorAll(selector); +export { $, $$ }; diff --git a/dist/view/inputSectionRenderer.js b/dist/view/inputSectionRenderer.js new file mode 100644 index 000000000..08c7b1cde --- /dev/null +++ b/dist/view/inputSectionRenderer.js @@ -0,0 +1,31 @@ +import { $ } from '../utils.js'; +const inputSection = () => { + return ` +
+
+
+

🏎️ 자동차 경주 게임

+

+ 5자 이하의 자동차 이름을 콤마로 구분하여 입력해주세요.
+ 예시) EAST, WEST, SOUTH, NORTH +

+
+ + +
+
+
+

시도할 횟수를 입력해주세요.

+
+ + +
+
+
+
+ `; +}; +const renderInputSection = () => { + $('#app').innerHTML = inputSection(); +}; +export { renderInputSection }; diff --git a/dist/view/progressSectionRenderer.js b/dist/view/progressSectionRenderer.js new file mode 100644 index 000000000..f2b0a01f4 --- /dev/null +++ b/dist/view/progressSectionRenderer.js @@ -0,0 +1,50 @@ +import { $, $$ } from '../utils.js'; +const progressSection = () => { + return ` +
+
+
+
+ `; +}; +const carNameDiv = (carName) => { + return ` +
+
${carName}
+
+ `; +}; +const spinnerDiv = () => { + return ` +
+
+ +
+
+ `; +}; +const arrowDiv = () => { + return ` +
⬇️️
+ `; +}; +const renderProgressSection = (carNameArray) => { + $('#app').insertAdjacentHTML('beforeend', progressSection()); + carNameArray.forEach((carName) => { + $('#progress-section .mt-4').insertAdjacentHTML('beforeend', carNameDiv(carName)); + }); + $$('#progress-section .mr-2').forEach((element) => { + element.insertAdjacentHTML(`beforeend`, spinnerDiv()); + }); +}; +const renderArrowDiv = (roundWinnerIndex) => { + $$('#progress-section .car-player').forEach((element, index) => { + index === roundWinnerIndex ? element.insertAdjacentHTML(`afterend`, arrowDiv()) : null; + }); +}; +const removeSpinnerDivs = () => { + $$('#progress-section .mt-3').forEach((element) => { + element.remove(); + }); +}; +export { renderProgressSection, renderArrowDiv, removeSpinnerDivs }; diff --git a/dist/view/resultSectionRenderer.js b/dist/view/resultSectionRenderer.js new file mode 100644 index 000000000..494468012 --- /dev/null +++ b/dist/view/resultSectionRenderer.js @@ -0,0 +1,17 @@ +import { $ } from '../utils.js'; +const resultSection = (winners) => { + return ` +
+
+

🏆 최종 우승자: ${winners} 🏆

+
+ +
+
+
+ `; +}; +const renderResultSection = (winners) => { + $('#app').insertAdjacentHTML('beforeend', resultSection(winners)); +}; +export { renderResultSection }; diff --git a/dist/view/utils.js b/dist/view/utils.js new file mode 100644 index 000000000..4567fa02d --- /dev/null +++ b/dist/view/utils.js @@ -0,0 +1,27 @@ +const clearInput = (element) => { + element.value = ''; + element.focus(); +}; +const enableInput = (element) => { + element.disabled = false; +}; +const disableInput = (element) => { + element.disabled = true; +}; +const enableButton = (element) => { + element.disabled = false; +}; +const disableButton = (element) => { + element.disabled = true; +}; +const removeChildNodes = (element) => { + if (!element) { + return; + } + while (element.hasChildNodes()) { + if (element.lastChild) { + element.removeChild(element.lastChild); + } + } +}; +export { clearInput, enableInput, disableInput, enableButton, disableButton, removeChildNodes }; diff --git a/index.html b/index.html index ea5891942..846cbd5b4 100644 --- a/index.html +++ b/index.html @@ -3,69 +3,11 @@ 🏎️ 자동차 경주 게임 - +
-
-
-
-

🏎️ 자동차 경주 게임

-

- 5자 이하의 자동차 이름을 콤마로 구분하여 입력해주세요.
- 예시) EAST, WEST, SOUTH, NORTH -

-
- - -
-
-
-

시도할 횟수를 입력해주세요.

-
- - -
-
-
-
-
-
-
-
EAST
-
⬇️️
-
⬇️️
-
-
-
WEST
-
⬇️️
-
-
-
SOUTH
-
-
- -
-
-
-
-
NORTH
-
-
- -
-
-
-
-
-
-
-

🏆 최종 우승자: EAST, WEST 🏆

-
- -
-
-
+ diff --git a/package.json b/package.json new file mode 100644 index 000000000..2180aecc3 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "dependencies": { + "cypress": "^7.5.0", + "typescript": "^4.3.2" + }, + "name": "javascript-racingcar", + "version": "1.0.0", + "main": "index.js", + "repository": "https://github.com/jwon42/javascript-racingcar.git", + "author": "jwon42 ", + "license": "MIT", + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^4.26.0", + "@typescript-eslint/parser": "^4.26.0", + "eslint": "^7.28.0", + "eslint-config-airbnb-base": "^14.2.1", + "eslint-plugin-import": "^2.23.4" + }, + "scripts": { + "cypress:open": "./node_modules/.bin/cypress open", + "cypress:run": "./node_modules/.bin/cypress run", + "tsc": "tsc", + "lint": "eslint ." + } +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 000000000..45716dabd --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,38 @@ +const DELAY = { + // millisecond(s) + GAME_TURN: 1000, + GAME_END: 2000, +}; + +const RULE = { + // random(0~9) 값이 4 이상일 경우 전진, 3 이하의 값이면 전진하지 않음 + MIN_SCORE: 0, + MAX_SCORE: 9, + THRESHOULD_SCORE: 4, + + // 자동차 이름은 5자 이하만 가능 + MAX_CARNAME_LENGTH: 5, + + // 시도 횟수는 원활한 게임을 위해 자체적으로 제한 + MIN_TRYCOUNT: 1, + MAX_TRYCOUNT: 50, +}; + +const ALERT = { + // for check valid car name + CARNAME_NOTHING: '자동차 이름을 입력해주세요', + CARNAME_ALONE: '자동차 이름을 2개 이상 입력해주세요', + CARNAME_EMPTY: '비어 있는 자동차 이름이 있습니다', + CARNAME_DOUBLE: '중복된 자동차 이름이 있습니다', + CARNAME_LENGTH: '자동차 이름 글자수가 초과되었습니다 (5자 이내)', + + // for check valid try count + TRYCOUNT_NOTHING: '시도 횟수를 입력해주세요', + TRYCOUNT_UINT: '시도 횟수는 양의 정수를 입력해주세요 (50 이하)', + TRYCOUNT_TOO_BIG: '시도 횟수가 너무 많습니다 (50 이하)', + + // etc. + CONGRATULATION: '님 우승을 축하합니다 👏', +}; + +export { DELAY, RULE, ALERT } \ No newline at end of file diff --git a/src/controller/gameController.ts b/src/controller/gameController.ts new file mode 100644 index 000000000..1a85b6efe --- /dev/null +++ b/src/controller/gameController.ts @@ -0,0 +1,32 @@ +import { ALERT, DELAY } from '../constants.js'; +import { $ } from '../utils.js'; +import { Game } from '../model/Game.js'; +import { enableButton } from '../view/utils.js'; +import { renderArrowDiv, removeSpinnerDivs } from '../view/progressSectionRenderer.js'; +import { renderResultSection } from '../view/resultSectionRenderer.js'; +import { addRestartButtonEvent } from './restartController.js'; +import { InputData } from '../model/InputData.js'; + +const playOnce = (racingGame: Game) => { + racingGame.play(); + racingGame.roundWinners.forEach((index) => { + renderArrowDiv(index); + }); + racingGame.initRoundWinners(); +}; + +const startGame = async (inputData: InputData): Promise => { + const racingGame: Game = new Game(inputData.carNameArray); + + for (let index = 0; index < inputData.tryCount; index += 1) { + await racingGame.makeDelay(DELAY.GAME_TURN).then(() => playOnce(racingGame)); + } + racingGame.judgeFinalWinners(); + removeSpinnerDivs(); + renderResultSection(racingGame.finalWinners.join(', ').toLowerCase()); + await racingGame.makeDelay(DELAY.GAME_END).then(() => alert(racingGame.finalWinners.join(', ') + ALERT.CONGRATULATION)); + enableButton($('#restart-button') as HTMLButtonElement); + addRestartButtonEvent(); +}; + +export { startGame }; diff --git a/src/controller/inputController.ts b/src/controller/inputController.ts new file mode 100644 index 000000000..40651c8e4 --- /dev/null +++ b/src/controller/inputController.ts @@ -0,0 +1,45 @@ +import { $ } from '../utils.js'; +import { startGame } from './gameController.js'; +import { InputData } from '../model/InputData.js'; +import { renderProgressSection } from '../view/progressSectionRenderer.js'; +import { clearInput, enableInput, disableInput, enableButton, disableButton } from '../view/utils.js'; + +const getCarNames = (inputData: InputData): void => { + const carNameInput: HTMLInputElement = $('#car-names-input') as HTMLInputElement; + const carNameArray: Array = carNameInput?.value.split(',').map((x) => x.trim()); + + if (inputData.checkCarNames(carNameInput, carNameArray)) { + renderProgressSection(carNameArray); + inputData.carNameArray = carNameArray; + disableInput(carNameInput); + disableButton($('#car-names-submit') as HTMLButtonElement); + enableInput($('#racing-count-input') as HTMLInputElement); + enableButton($('#racing-count-submit') as HTMLButtonElement); + ($('#racing-count-input') as HTMLInputElement).focus(); + return; + } + clearInput(carNameInput); +}; + +const getTryCount = (inputData: InputData): void => { + const tryCountInput: HTMLInputElement = $('#racing-count-input') as HTMLInputElement; + const tryCount = Number(tryCountInput?.value); + + if (inputData.checkTryCount(tryCountInput)) { + inputData.tryCount = tryCount; + disableInput(tryCountInput); + disableButton($('#racing-count-submit') as HTMLButtonElement); + startGame(inputData); + return; + } + clearInput(tryCountInput); +}; + +const addInputButtonsEvent = (): void => { + const inputData: InputData = new InputData(); + + $('#car-names-submit')?.addEventListener('click', () => getCarNames(inputData)); + $('#racing-count-submit')?.addEventListener('click', () => getTryCount(inputData)); +}; + +export { addInputButtonsEvent }; diff --git a/src/controller/restartController.ts b/src/controller/restartController.ts new file mode 100644 index 000000000..248505aea --- /dev/null +++ b/src/controller/restartController.ts @@ -0,0 +1,16 @@ +import { $ } from '../utils.js'; +import { removeChildNodes } from '../view/utils.js'; +import { renderInputSection } from '../view/inputSectionRenderer.js'; + +const init = (): void => { + removeChildNodes($('#app')); + renderInputSection(); + addRestartButtonEvent(); + ($('#car-names-input') as HTMLInputElement).focus(); +}; + +const addRestartButtonEvent = (): void => { + $('#restart-button')?.addEventListener('click', () => init()); +}; + +export { addRestartButtonEvent }; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 000000000..417aed623 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,9 @@ +import { renderInputSection } from './view/inputSectionRenderer.js'; +import { addInputButtonsEvent } from './controller/inputController.js'; + +const app = (): void => { + renderInputSection(); + addInputButtonsEvent(); +}; + +app(); diff --git a/src/model/Car.ts b/src/model/Car.ts new file mode 100644 index 000000000..a3f6da704 --- /dev/null +++ b/src/model/Car.ts @@ -0,0 +1,17 @@ +class Car { + name: string; + position: number; + index: number; + + constructor(name: string, index: number) { + this.name = name; + this.position = 0; + this.index = index; + } + + moveForward(): void { + this.position += 1; + } +} + +export { Car }; diff --git a/src/model/Game.ts b/src/model/Game.ts new file mode 100644 index 000000000..cbe115332 --- /dev/null +++ b/src/model/Game.ts @@ -0,0 +1,59 @@ +import { Car } from './Car.js'; +import { RULE } from '../constants.js'; + +class Game { + cars: Array; + finalWinners: Array; + roundWinners: Array; + maxPosition: number; + + constructor(carNameArray: Array) { + this.cars = []; + this.finalWinners = []; + this.roundWinners = []; + this.maxPosition = 0; + this.setCarsObject(carNameArray); + } + + play(): void { + this.cars.forEach((car) => { + if (Math.floor(Math.random() * RULE.MAX_SCORE) + RULE.MIN_SCORE >= RULE.THRESHOULD_SCORE) { + car.moveForward(); + this.roundWinners.push(car.index); + } + }); + this.updateMaxPosition(); + } + + setCarsObject(carNameArray: Array): void { + carNameArray.forEach((car, index) => { + this.cars.push(new Car(car, index)); + }); + }; + + async makeDelay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); + }; + + updateMaxPosition(): void { + this.cars.forEach((car) => { + if (car.position > this.maxPosition) { + this.maxPosition = car.position; + } + }); + } + + initRoundWinners(): void { + this.roundWinners = []; + } + + judgeFinalWinners(): void { + this.cars.forEach((car) => { + if (car.position === this.maxPosition) { + this.finalWinners.push(car.name); + } + }); + } +} + +export { Game }; diff --git a/src/model/InputData.ts b/src/model/InputData.ts new file mode 100644 index 000000000..e8558dc55 --- /dev/null +++ b/src/model/InputData.ts @@ -0,0 +1,75 @@ +import { RULE, ALERT } from '../constants.js'; + +class InputData { + carNameArray: Array; + tryCount: Number; + + constructor() { + this.carNameArray = []; + this.tryCount = 0; + } + + checkEmptyInput(input: HTMLInputElement): boolean { + return input.value.trim() === ''; + }; + + checkArrayHasEmptyElement(array: Array): boolean { + return array.some((x) => x === ''); + } + + checkArrayHasOneElement(array: Array): boolean { + return array.length < 2; + }; + + checkArrayDupElements(array: Array): boolean { + return array.some((x) => { + return array.indexOf(x) !== array.lastIndexOf(x); + }); + }; + + checkArrayElementsLength(array: Array): boolean { + for (let index = 0; index < array.length; index += 1) { + if (array[index].length > RULE.MAX_CARNAME_LENGTH) { + return true; + } + } + return false; + }; + + checkCarNames(carNameInput: HTMLInputElement, carNameArray: Array): boolean { + if (this.checkEmptyInput(carNameInput)) { + alert(ALERT.CARNAME_NOTHING); + return false; + } else if (this.checkArrayHasOneElement(carNameArray)) { + alert(ALERT.CARNAME_ALONE); + return false; + } else if (this.checkArrayHasEmptyElement(carNameArray)) { + alert(ALERT.CARNAME_EMPTY); + return false; + } else if (this.checkArrayDupElements(carNameArray)) { + alert(ALERT.CARNAME_DOUBLE); + return false; + } else if (this.checkArrayElementsLength(carNameArray)) { + alert(ALERT.CARNAME_LENGTH); + return false; + } + return true; + }; + + checkTryCount(tryCountInput: HTMLInputElement): boolean { + if (this.checkEmptyInput(tryCountInput)) { + alert(ALERT.TRYCOUNT_NOTHING); + return false; + } else if (Number(tryCountInput.value) < RULE.MIN_TRYCOUNT) { + alert(ALERT.TRYCOUNT_UINT); + return false; + } else if (Number(tryCountInput.value) > RULE.MAX_TRYCOUNT) { + alert(ALERT.TRYCOUNT_TOO_BIG); + return false; + } + return true; + }; + +} + +export { InputData } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 000000000..147a8b8af --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,4 @@ +const $ = (selector: string): Element | null => document.querySelector(selector); +const $$ = (selector: string): NodeListOf => document.querySelectorAll(selector); + +export { $, $$ }; diff --git a/src/view/inputSectionRenderer.ts b/src/view/inputSectionRenderer.ts new file mode 100644 index 000000000..a065d2958 --- /dev/null +++ b/src/view/inputSectionRenderer.ts @@ -0,0 +1,34 @@ +import { $ } from '../utils.js'; + +const inputSection = (): string => { + return ` +
+
+
+

🏎️ 자동차 경주 게임

+

+ 5자 이하의 자동차 이름을 콤마로 구분하여 입력해주세요.
+ 예시) EAST, WEST, SOUTH, NORTH +

+
+ + +
+
+
+

시도할 횟수를 입력해주세요.

+
+ + +
+
+
+
+ `; +}; + +const renderInputSection = (): void => { + ($('#app') as HTMLDivElement).innerHTML = inputSection(); +}; + +export { renderInputSection }; diff --git a/src/view/progressSectionRenderer.ts b/src/view/progressSectionRenderer.ts new file mode 100644 index 000000000..c3af3f3d8 --- /dev/null +++ b/src/view/progressSectionRenderer.ts @@ -0,0 +1,58 @@ +import { $, $$ } from '../utils.js'; + +const progressSection = (): string => { + return ` +
+
+
+
+ `; +}; + +const carNameDiv = (carName: string): string => { + return ` +
+
${carName}
+
+ `; +}; + +const spinnerDiv = (): string => { + return ` +
+
+ +
+
+ `; +}; + +const arrowDiv = (): string => { + return ` +
⬇️️
+ `; +}; + +const renderProgressSection = (carNameArray: Array): void => { + ($('#app') as HTMLDivElement).insertAdjacentHTML('beforeend', progressSection()); + carNameArray.forEach((carName) => { + ($('#progress-section .mt-4') as HTMLDivElement).insertAdjacentHTML('beforeend', carNameDiv(carName)); + }); + ($$('#progress-section .mr-2') as NodeListOf).forEach((element) => { + element.insertAdjacentHTML(`beforeend`, spinnerDiv()); + }); +}; + +const renderArrowDiv = (roundWinnerIndex: number): void => { + ($$('#progress-section .car-player') as NodeListOf).forEach((element, index) => { + index === roundWinnerIndex ? element.insertAdjacentHTML(`afterend`, arrowDiv()) : null; + }); +}; + +const removeSpinnerDivs = (): void => { + $$('#progress-section .mt-3').forEach((element) => { + element.remove(); + }); +}; + +export { renderProgressSection, renderArrowDiv, removeSpinnerDivs }; diff --git a/src/view/resultSectionRenderer.ts b/src/view/resultSectionRenderer.ts new file mode 100644 index 000000000..70d88b4d2 --- /dev/null +++ b/src/view/resultSectionRenderer.ts @@ -0,0 +1,20 @@ +import { $ } from '../utils.js'; + +const resultSection = (winners: string): string => { + return ` +
+
+

🏆 최종 우승자: ${winners} 🏆

+
+ +
+
+
+ `; +}; + +const renderResultSection = (winners: string): void => { + ($('#app') as HTMLDivElement).insertAdjacentHTML('beforeend', resultSection(winners)); +}; + +export { renderResultSection }; diff --git a/src/view/utils.ts b/src/view/utils.ts new file mode 100644 index 000000000..19a4bc9b4 --- /dev/null +++ b/src/view/utils.ts @@ -0,0 +1,33 @@ +const clearInput = (element: HTMLInputElement): void => { + element.value = ''; + element.focus(); +}; + +const enableInput = (element: HTMLInputElement): void => { + element.disabled = false; +}; + +const disableInput = (element: HTMLInputElement): void => { + element.disabled = true; +}; + +const enableButton = (element: HTMLButtonElement): void => { + element.disabled = false; +}; + +const disableButton = (element: HTMLButtonElement): void => { + element.disabled = true; +}; + +const removeChildNodes = (element: Element | null): void => { + if (!element) { + return; + } + while (element.hasChildNodes()) { + if (element.lastChild) { + element.removeChild(element.lastChild); + } + } +}; + +export { clearInput, enableInput, disableInput, enableButton, disableButton, removeChildNodes }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..7e6a5a9ba --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,78 @@ +{ + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "node_modules" + ], + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Basic Options */ + // "incremental": true, /* Enable incremental compilation */ + "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */ + // "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ + "lib": ["dom","es6"], /* Specify library files to be included in the compilation. */ + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ + // "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + // "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + "outDir": "./dist", /* Redirect output structure to the directory. */ + "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + + /* Strict Type-Checking Options */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ + + /* Module Resolution Options */ + // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": ["cypress"], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + + /* Advanced Options */ + "skipLibCheck": true, /* Skip type checking of declaration files. */ + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ + } +}