diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..0051b4b26 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +# don't ever lint node_modules +node_modules +# don't lint build output (make sure it's set to your correct build folder name) +dist diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 000000000..5ab990a65 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,36 @@ +{ + "env": { + "browser": true, + "es2021": true, + "jest": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "prettier", + "plugin:cypress/recommended" + ], + "plugins": [ + "prettier" + ], + "parser": "@babel/eslint-parser", + "parserOptions": { + "requireConfigFile": false + }, + "rules": { + "prettier/prettier": [ + "error", + { + "singleQuote": true, + "semi": true, + "useTabs": false, + "tabWidth": 2, + "trailingComma": "all", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "avoid", + "endOfLine": "auto" + } + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..75e8ef465 --- /dev/null +++ b/.gitignore @@ -0,0 +1,108 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# dependencies +package-lock.json +yarn-lock.json \ No newline at end of file diff --git a/cypress.json b/cypress.json new file mode 100644 index 000000000..0e7b77ba9 --- /dev/null +++ b/cypress.json @@ -0,0 +1,5 @@ +{ + "testFiles": "*.test.js", + "screenshotOnRunFailure": false, + "video": false +} diff --git a/cypress/integration/app.test.js b/cypress/integration/app.test.js new file mode 100644 index 000000000..5cca1524b --- /dev/null +++ b/cypress/integration/app.test.js @@ -0,0 +1,137 @@ +import { ERROR_MESSAGE, MAX_GAME_TRY_COUNT } from '../../src/js/constants.js'; +import { inputCarNamesParsing } from '../../src/js/infrastructure/actions/inputSection.action.js'; + +const BASE_URL = '../../index.html'; + +// TODO: https://glebbahmutov.com/blog/form-validation-in-cypress/ + +describe('Racing Car Game', () => { + beforeEach(() => { + cy.visit(BASE_URL); + }); + + describe('최초 렌더링 시', () => { + it('자동차 이름 입력창만 보여야 한다.', () => { + cy.$('[name="car-names-field"]').should('be.visible'); + cy.$('[name="game-try-count-field"]').should('not.be.visible'); + cy.$('[name="game-section"]').should('not.be.visible'); + cy.$('[name="result-section"]').should('not.be.visible'); + }); + it('자동차 이름 입력창의 값은 비어 있어야 한다.', () => { + cy.$('[name="car-names"]').should('have.text', ''); + }); + }); + + describe('자동차 이름을 입력한 뒤 확인 버튼을 누른다.', () => { + it('자동차 이름 입력창이 비어 있다면 "자동차 이름을 입력해주세요!" 경고창을 출력한다.', () => { + const alertStub = cy.stub(); + cy.on('window:alert', alertStub); + cy.get('[name="car-names-confirm"]') + .click() + .then(() => { + expect(alertStub).to.be.calledWith(ERROR_MESSAGE.REQUIRED_NAME); + }); + }); + it('자동차 이름은 쉼표로 구분된다.', () => { + const carNames = 'EAST, WEST, SOUTH, NORTH'; + cy.inputCarNames(carNames).then(() => { + expect(inputCarNamesParsing(carNames)).to.have.length(4); + }); + }); + it('자동차 이름의 처음과 마지막에 쉼표가 존재하면 제거한다.', () => { + const carNames = ',EAST, WEST, SOUTH, NORTH,'; + cy.inputCarNames(carNames).then(() => { + expect(inputCarNamesParsing(carNames)).to.have.length(4); + }); + }); + + describe('입력된 자동차 이름들이 유효하지 않으면 에러를 출력한다.', () => { + it('자동차 이름이 하나라도 비어 있다면 "자동차 이름을 입력해주세요!" 경고창을 출력한다.', () => { + const carNames = 'EAST,, SOUTH, NORTH'; + const alertStub = cy.stub(); + cy.on('window:alert', alertStub); + + cy.inputCarNames(carNames).then(() => { + expect(alertStub).to.be.calledWith(ERROR_MESSAGE.REQUIRED_NAME); + }); + }); + it('자동차 이름이 5자를 초과하면 "자동차 이름은 5자 이하여야만 해요!" 경고창을 출력한다.', () => { + const carNames = 'EAST, WEST, SOUTH, NORTH2222'; + const alertStub = cy.stub(); + cy.on('window:alert', alertStub); + + cy.inputCarNames(carNames).then(() => { + expect(alertStub).to.be.calledWith(ERROR_MESSAGE.MUST_LESS_THAN); + }); + }); + it('중복된 자동차 이름이 존재하면 "자동차 이름은 중복될 수 없어요!" 경고창을 출력한다.', () => { + const carNames = 'EAST, EAST, SOUTH, NORTH'; + const alertStub = cy.stub(); + cy.on('window:alert', alertStub); + + cy.inputCarNames(carNames).then(() => { + expect(alertStub).to.be.calledWith(ERROR_MESSAGE.NOT_ACCEPT_DUPLICATED); + }); + }); + }); + + describe('입력된 자동차 이름들이 유효한 경우 시도 횟수 입력창을 표시한다.', () => { + it('시도 횟수 입력창이 보여야 한다.', () => { + cy.inputCarNames('EAST, WEST, SOUTH, NORTH'); + cy.$('[name="game-try-count-field"]').should('be.visible'); + }); + }); + }); + + describe('시도 횟수를 입력한 뒤 확인 버튼을 누른다.', () => { + beforeEach(() => { + cy.inputCarNames('EAST, WEST, SOUTH, NORTH'); + }); + + describe('입력된 시도 횟수가 유효하지 않으면 에러를 출력한다.', () => { + it('시도 횟수가 공백일 경우 "숫자를 입력해주세요!" 경고창을 출력한다.', () => { + const alertStub = cy.stub(); + cy.on('window:alert', alertStub); + + cy.get('[name="game-try-count-confirm"]') + .click() + .then(() => { + expect(alertStub).to.be.calledWith(ERROR_MESSAGE.REQUIRED_DIGIT); + }); + }); + it('시도 횟수가 음수일 경우 "시도 횟수는 0보다 커야 해요!" 경고창을 출력한다.', () => { + const alertStub = cy.stub(); + cy.on('window:alert', alertStub); + + cy.inputGameTryCount(0).then(() => { + expect(alertStub).to.be.calledWith(ERROR_MESSAGE.MUST_MORE_THAN_ONE); + }); + }); + it('시도 횟수가 문자일 경우 "숫자를 입력해주세요!" 경고창을 출력한다.', () => { + const alertStub = cy.stub(); + cy.on('window:alert', alertStub); + + cy.inputGameTryCount('오잉?!😳').then(() => { + expect(alertStub).to.be.calledWith(ERROR_MESSAGE.REQUIRED_DIGIT); + }); + }); + it(`시도 횟수가 ${MAX_GAME_TRY_COUNT}를 초과하면 "시도 횟수는 ${MAX_GAME_TRY_COUNT}보다 낮아야 해요!" 경고창을 출력한다.`, () => { + const alertStub = cy.stub(); + cy.on('window:alert', alertStub); + + cy.inputGameTryCount(MAX_GAME_TRY_COUNT + 1).then(() => { + expect(alertStub).to.be.calledWith(ERROR_MESSAGE.MUST_LESS_THAN_MAX_GAME_TRY_COUNT); + }); + }); + }); + }); + + describe('주어진 시도 횟수 동안 n대의 자동차는 난수 값에 따라 전진/또는 멈출 수 있다.', () => { + it('난수 값이 4 이상인 경우 전진하고 3 이하의 값이라면 움직이지 않는다.', () => {}); + }); + + describe('주어진 시도 횟수가 소진된 경우 가장 많이 전진한 자동차가 우승한다.', () => { + it('자동차가 중복인 경우 공동 우승한다.', () => {}); + it('자동차가 하나인 경우 단독 우승한다.', () => {}); + }); +}); diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js new file mode 100644 index 000000000..8229063ad --- /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..2bf9400d2 --- /dev/null +++ b/cypress/support/commands.js @@ -0,0 +1,13 @@ +Cypress.Commands.add('$', selector => { + return cy.get(selector); +}); + +Cypress.Commands.add('inputCarNames', names => { + cy.get('[name="car-names-input"]').type(names); + cy.get('[name="car-names-confirm-button"]').click(); +}); + +Cypress.Commands.add('inputGameTryCount', count => { + cy.get('[name="game-try-count-input"]').type(count); + cy.get('[name="game-try-count-confirm-button"]').click(); +}); diff --git a/cypress/support/index.js b/cypress/support/index.js new file mode 100644 index 000000000..37a498fb5 --- /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/index.html b/index.html index e44956aeb..2542919b5 100644 --- a/index.html +++ b/index.html @@ -1,71 +1,15 @@ - - - 🏎️ 자동차 경주 게임 - - - -
-
-
-
-

🏎️ 자동차 경주 게임

-

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

-
- - -
-
-
-

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

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

🏆 최종 우승자: EAST, WEST 🏆

-
- -
-
-
-
- - + + + + 🏎️ 자동차 경주 게임 + + + + + + + + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 000000000..c9cbe7bb5 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "js-racingcar", + "version": "1.0.0", + "main": "./src/js/index.js", + "license": "MIT", + "scripts": { + "test": "cypress run --browser chrome" + }, + "devDependencies": { + "@babel/eslint-parser": "^7.17.0", + "cypress": "^9.5.3", + "eslint": "^8.12.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-cypress": "^2.12.1", + "eslint-plugin-prettier": "^4.0.0", + "prettier": "^2.6.1" + } +} diff --git a/src/css/index.css b/src/css/index.css index 4b1abf762..6c21a245a 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -35,3 +35,20 @@ fieldset { border: none; padding: 0; } + +:is(section, form) { + transition: 0.3s ease-in-out; +} + +.hidden { + opacity: 0; + visibility: hidden; +} + +.d-none { + display: none; +} + +.btn:disabled { + cursor: not-allowed; +} \ No newline at end of file diff --git a/src/css/shared/button.css b/src/css/shared/button.css index 8ac62188f..dd0da7422 100644 --- a/src/css/shared/button.css +++ b/src/css/shared/button.css @@ -13,6 +13,6 @@ border-color: #00bcd4 !important; } -.btn.btn-cyan:hover { +.btn.btn-cyan:hover:not([disabled]) { background-color: #018c9e !important; } diff --git a/src/js/App.js b/src/js/App.js new file mode 100644 index 000000000..005607c4a --- /dev/null +++ b/src/js/App.js @@ -0,0 +1,47 @@ +import './components/index.js'; +import ComponentHandler from './ComponentHandler.js'; +import { $element, $setAttributes } from './helpers/index.js'; + +const template = /*html*/ ` + + + + +`; + +export default class App extends ComponentHandler { + #removeHandler; + + constructor() { + super(); + this.insertAdjacentElement('afterbegin', $element(template)); + } + + connectedCallback() { + this.#removeHandler = this.bindHandler([ + { + type: 'inputted', + callback: this.inputtedHandler, + }, + { + type: 'reset', + callback: () => this.firstElementChild.replaceWith($element(template)), + }, + ]); + } + + disconnectedCallback() { + this.#removeHandler(); + } + + inputtedHandler = ({ detail }) => { + const attributes = [ + ['car-names', detail.carNames], + ['try-count', detail.tryCount], + ]; + + $setAttributes('game-section', attributes); + }; +} + +customElements.define('racing-app', App); diff --git a/src/js/ComponentHandler.js b/src/js/ComponentHandler.js new file mode 100644 index 000000000..7fa35f53f --- /dev/null +++ b/src/js/ComponentHandler.js @@ -0,0 +1,13 @@ +export default class ComponentHandler extends HTMLElement { + bindHandler(events) { + events.forEach(({ type, callback }) => this.addEventListener(type, callback)); + return () => { + events.forEach(({ type, callback }) => this.removeEventListener(type, callback)); + }; + } + + dispatch(type, detail) { + this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true })); + return this; + } +} diff --git a/src/js/components/GameSection.js b/src/js/components/GameSection.js new file mode 100644 index 000000000..05885a7a3 --- /dev/null +++ b/src/js/components/GameSection.js @@ -0,0 +1,56 @@ +import ComponentHandler from '../ComponentHandler.js'; +import { CONTROLL_KEY } from '../constants.js'; +import { pipeline } from '../factory/index.js'; +import { $element } from '../helpers/index.js'; + +const template = /*html*/ ` +`; + +// prettier-ignore +const panel = cars => /*html*/ ` +
${cars.map(car => ` +
+
${car.name}
+ ${spinner} +
`).join('')} +
`; + +const spinner = /*html*/ ` +
+
+ +
+
`; + +export default class GameSection extends ComponentHandler { + #cars; + + constructor() { + super(); + this.insertAdjacentElement('afterbegin', $element(template)); + } + + start() { + const tryCount = this.getAttribute('try-count'); + const cars = this.#cars; + + pipeline(CONTROLL_KEY.GAME, { tryCount, cars }); + } + + static get observedAttributes() { + return ['try-count']; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (!newValue) return this.firstElementChild.classList.add('hidden'); + + this.#cars = pipeline(CONTROLL_KEY.GAME_BEFORE, this.getAttribute('car-names')); + + this.firstElementChild.classList.remove('hidden'); + this.firstElementChild.insertAdjacentElement('afterbegin', $element(panel(this.#cars))); + + this.start(); + } +} + +customElements.define('game-section', GameSection); diff --git a/src/js/components/InputSection.js b/src/js/components/InputSection.js new file mode 100644 index 000000000..134b9a387 --- /dev/null +++ b/src/js/components/InputSection.js @@ -0,0 +1,86 @@ +import ComponentHandler from '../ComponentHandler.js'; +import { CONTROLL_KEY, MAX_GAME_TRY_COUNT } from '../constants.js'; +import { pipeline } from '../factory/index.js'; +import { isNull, $element, $focus } from '../helpers/index.js'; + +const template = /*html*/ ` +
+
+
+

🏎️ 자동차 경주 게임

+

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

+
+ + +
+
+ +
+
`; + +export default class InputSection extends ComponentHandler { + #carNames; + #removeHandler; + + constructor() { + super(); + this.insertAdjacentElement('afterbegin', $element(template)); + } + + checkInputCarNames = event => { + if (!event.target.matches('#car-names-form')) return; + event.preventDefault(); + + const parsedCarNames = pipeline( + CONTROLL_KEY.CAR_NAMES, + event.target.elements['car-names'].value, + ); + + if (isNull(parsedCarNames)) return; + + this.#carNames = parsedCarNames; + pipeline(CONTROLL_KEY.CAR_NAMES_AFTER); + }; + + checkInputTryCount = event => { + if (!event.target.matches('#game-try-count-form')) return; + event.preventDefault(); + + this.dispatch('inputted', { + carNames: this.#carNames, + tryCount: event.target.elements['game-try-count'].valueAsNumber, + }); + + pipeline(CONTROLL_KEY.TRY_COUNT_AFTER); + }; + + connectedCallback() { + this.#removeHandler = this.bindHandler([ + { + type: 'submit', + callback: this.checkInputCarNames, + }, + { + type: 'submit', + callback: this.checkInputTryCount, + }, + ]); + + $focus('#car-names'); + } + + disconnectedCallback() { + this.#removeHandler(); + } +} + +customElements.define('input-section', InputSection); diff --git a/src/js/components/ResultSection.js b/src/js/components/ResultSection.js new file mode 100644 index 000000000..487e6cebf --- /dev/null +++ b/src/js/components/ResultSection.js @@ -0,0 +1,58 @@ +import ComponentHandler from '../ComponentHandler.js'; +import { CONTROLL_KEY } from '../constants.js'; +import { pipeline } from '../factory/index.js'; +import { $element } from '../helpers/index.js'; + +const template = /*html*/ ` +`; + +export default class ResultSection extends ComponentHandler { + #removeHandler; + + constructor() { + super(); + this.insertAdjacentElement('afterbegin', $element(template)); + } + + finishGame() { + pipeline(CONTROLL_KEY.RESULT, this.getAttribute('winners')); + } + + gameReset = event => { + if (!event.target.matches('#game-reset')) return; + this.dispatch('reset'); + }; + + static get observedAttributes() { + return ['winners']; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (!newValue) return this.firstElementChild.classList.add('hidden'); + + this.firstElementChild.classList.remove('hidden'); + this.finishGame(); + } + + connectedCallback() { + this.#removeHandler = this.bindHandler([ + { + type: 'click', + callback: this.gameReset, + }, + ]); + } + + disconnectedCallback() { + this.#removeHandler(); + } +} + +customElements.define('result-section', ResultSection); diff --git a/src/js/components/index.js b/src/js/components/index.js new file mode 100644 index 000000000..5e3e53533 --- /dev/null +++ b/src/js/components/index.js @@ -0,0 +1,3 @@ +export { default as InputSection } from './InputSection.js'; +export { default as GameSection } from './GameSection.js'; +export { default as ResultSection } from './ResultSection.js'; diff --git a/src/js/constants.js b/src/js/constants.js new file mode 100644 index 000000000..e02ad3201 --- /dev/null +++ b/src/js/constants.js @@ -0,0 +1,28 @@ +export const CONTROLL_KEY = { + CAR_NAMES: 'car-names', + CAR_NAMES_AFTER: 'car-names-after', + TRY_COUNT_AFTER: 'try-count', + GAME_BEFORE: 'game-before', + GAME: 'game', + RESULT: 'result', +}; + +export const MAX_NAME_DIGITS = 5; +export const MAX_GAME_TRY_COUNT = 100; + +export const MOVE_CONDITION = 4; +export const MOVE_DECIDE_DELAY = 1000; + +export const RESULT_ALERT_DELAY = 1000; + +export const DICE_RANGE = { + MIN: 1, + MAX: 9, +}; + +export const ERROR_MESSAGE = { + INVALID_TYPE: '옳지 못한 타입입니다.', + NOT_EXISTS_KEY: '등록되지 않은 상태 키입니다.', + MUST_LESS_THAN: `자동차 이름은 ${MAX_NAME_DIGITS}자 이하여야만 해요!`, + NOT_ACCEPT_DUPLICATED: '자동차 이름은 중복될 수 없어요!', +}; diff --git a/src/js/factory/index.js b/src/js/factory/index.js new file mode 100644 index 000000000..c8e1191fe --- /dev/null +++ b/src/js/factory/index.js @@ -0,0 +1,55 @@ +import { CONTROLL_KEY } from '../constants.js'; +import { + pipe, + trim, + trimComma, + split, + removeSpace, + $show, + $focus, + $disabled, +} from '../helpers/index.js'; +import { checkValidations, racingWrapper, renderResetArea } from './service.js'; + +// prettier-ignore +const executor = { + [CONTROLL_KEY.CAR_NAMES]: pipe( + trim, + trimComma, + split, + removeSpace, + checkValidations, + ), + [CONTROLL_KEY.CAR_NAMES_AFTER]: pipe( + () => $show('#game-try-count-form'), + () => $disabled('#car-names'), + () => $disabled('#car-names-confirm'), + () => setTimeout(() => $focus('#game-try-count'), 100), + ), + [CONTROLL_KEY.TRY_COUNT_AFTER]: pipe( + () => $disabled('#game-try-count'), + () => $disabled('#game-try-count-confirm'), + ), + [CONTROLL_KEY.GAME_BEFORE]: pipe( + split, + carNames => carNames.map(carName => ({ name: carName, moveCount: 0 })), + ), + [CONTROLL_KEY.GAME]: pipe( + racingWrapper, + window.requestAnimationFrame, + ), + [CONTROLL_KEY.RESULT]: pipe( + trimComma, + renderResetArea, + ), +}; + +export const pipeline = (controllKey, params) => { + try { + const execute = executor[controllKey]; + return execute(params); + } catch (error) { + alert(error.message); + return null; + } +}; diff --git a/src/js/factory/service.js b/src/js/factory/service.js new file mode 100644 index 000000000..277c51742 --- /dev/null +++ b/src/js/factory/service.js @@ -0,0 +1,97 @@ +import { + ERROR_MESSAGE, + MAX_NAME_DIGITS, + MOVE_CONDITION, + RESULT_ALERT_DELAY, +} from '../constants.js'; +import { + pipe, + isDuplicatedArray, + generateRandomNumbers, + delay, + delayLoop, + $element, + $setAttributes, + $show, +} from '../helpers/index.js'; +import useStore from './store.js'; + +const store = useStore(); + +const renderWinners = ({ winners }) => { + const attributes = [['winners', winners]]; + + $setAttributes('result-section', attributes); +}; + +const parsedRacingGameWinner = cars => { + const maxMoveCount = store.getState('maxMoveCount'); + const winners = cars.reduce( + (result, { name, moveCount }) => (maxMoveCount === moveCount ? `${result}, ${name}` : result), + '', + ); + + return { cars, winners }; +}; + +const removeCarSpinner = ({ cars }) => { + cars.forEach(({ name }) => { + const $car = document.getElementById(name); + $car.lastElementChild.remove(); + }); + + return cars; +}; + +const renderCarMoveForward = selector => { + const $car = document.getElementById(selector); + const $moveArrow = $element(/*html*/ `
⬇️️
`); + $car.insertBefore($moveArrow, $car.lastElementChild); +}; + +const racing = ({ cars }) => { + const dice = generateRandomNumbers({ count: cars.length }); + + cars.forEach((car, index) => { + if (dice[index] >= MOVE_CONDITION) { + ++car.moveCount; + renderCarMoveForward(car.name); + } + + if (store.getState('maxMoveCount') >= car.moveCount) return; + store.setState('maxMoveCount', car.moveCount); + }); +}; + +export const checkValidations = carNames => { + const checkedLength = carNames.filter(carName => { + if (carName.length > MAX_NAME_DIGITS) throw new Error(ERROR_MESSAGE.MUST_LESS_THAN); + return carName; + }); + + if (isDuplicatedArray(checkedLength)) throw new Error(ERROR_MESSAGE.NOT_ACCEPT_DUPLICATED); + + return checkedLength; +}; + +export const racingWrapper = ({ tryCount, cars }) => { + store.initStore(); + + return delayLoop({ + limit: tryCount, + func: racing, + params: { cars }, + callback: pipe(removeCarSpinner, parsedRacingGameWinner, renderWinners), + }); +}; + +const congratulationsOnWinning = async winners => { + await delay(RESULT_ALERT_DELAY); + alert(`이번 레이싱 게임의 승자는\n\n${winners} 입니다!\n\n✨축하해요✨`); + $show('#game-reset-area'); +}; + +export const renderResetArea = winners => { + document.getElementById('winners').textContent = winners; + congratulationsOnWinning(winners); +}; diff --git a/src/js/factory/store.js b/src/js/factory/store.js new file mode 100644 index 000000000..e68a0869d --- /dev/null +++ b/src/js/factory/store.js @@ -0,0 +1,35 @@ +import { ERROR_MESSAGE } from '../constants.js'; + +const initState = { + winner: '', + maxMoveCount: 0, +}; + +const useStore = () => { + let localState = { ...initState }; + + const initStore = () => { + localState = { ...localState, ...initState }; + }; + + const setState = (key, value) => { + if (localState[key] === undefined) throw new ReferenceError(ERROR_MESSAGE.NOT_EXISTS_KEY); + + localState[key] = value; + }; + + const getState = key => { + if (key === undefined) return localState; + if (localState[key] === undefined) throw new ReferenceError(ERROR_MESSAGE.NOT_EXISTS_KEY); + + return localState[key]; + }; + + return { + initStore, + setState, + getState, + }; +}; + +export default useStore; diff --git a/src/js/helpers/dom.js b/src/js/helpers/dom.js new file mode 100644 index 000000000..d54e1162c --- /dev/null +++ b/src/js/helpers/dom.js @@ -0,0 +1,23 @@ +const $wrapper = document.createElement('template'); + +export const $element = html => { + $wrapper.replaceChildren(); + $wrapper.insertAdjacentHTML('afterbegin', html); + return $wrapper.firstElementChild; +}; + +export const $show = target => { + document.querySelector(target).classList.remove('hidden'); +}; + +export const $disabled = target => { + document.querySelector(target).setAttribute('disabled', true); +}; + +export const $focus = target => { + document.querySelector(target).focus(); +}; + +export const $setAttributes = (target, attributes) => { + attributes.forEach(([key, value]) => document.querySelector(target).setAttribute(key, value)); +}; diff --git a/src/js/helpers/index.js b/src/js/helpers/index.js new file mode 100644 index 000000000..5fd7b9b6b --- /dev/null +++ b/src/js/helpers/index.js @@ -0,0 +1,3 @@ +export * from './dom.js'; +export * from './valid.js'; +export * from './utils.js'; diff --git a/src/js/helpers/utils.js b/src/js/helpers/utils.js new file mode 100644 index 000000000..b1a887002 --- /dev/null +++ b/src/js/helpers/utils.js @@ -0,0 +1,58 @@ +import { DICE_RANGE, ERROR_MESSAGE } from '../constants.js'; + +// prettier-ignore +export const pipe = (...fns) => value => fns.reduce((_value, fn) => fn(_value), value); + +export const trim = value => { + if (typeof value !== 'string') throw new ReferenceError(ERROR_MESSAGE.INVALID_TYPE); + + return value.trim(); +}; + +export const trimComma = value => { + if (typeof value !== 'string') throw new ReferenceError(ERROR_MESSAGE.INVALID_TYPE); + + let parsed = value.startsWith(',') ? value.slice(1) : value; + return parsed.endsWith(',') ? parsed.slice(0, -1) : parsed; +}; + +export const split = (target, separator = ',') => { + if (typeof target !== 'string') throw new ReferenceError(`${ERROR_MESSAGE.INVALID_TYPE}?????`); + return target.split(separator); +}; + +export const removeSpace = targetArray => { + if (!(targetArray instanceof Array)) throw new ReferenceError(ERROR_MESSAGE.INVALID_TYPE); + return targetArray.map(element => element.replace(/\s/gi, '')); +}; + +export const generateRandomNumbers = ({ + count: length, + min = DICE_RANGE.MIN, + max = DICE_RANGE.MAX, +}) => { + if (typeof length !== 'number' || typeof min !== 'number' || typeof max !== 'number') + throw new ReferenceError(ERROR_MESSAGE.INVALID_TYPE); + + return Array.from({ length }).map(() => Math.floor(Math.random() * max) + min); +}; + +export const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +export const delayLoop = ({ + limit = 1, + delayMs = 1000, + func = console.log, + callback = console.log, + params = {}, +}) => { + return async () => { + console.log(`%cdelayLoop: %cstart`, 'color: gray', 'color: #0031d1; font-weight: bold'); + for (let index = 0; index < limit; index++) { + await delay(delayMs); + func(params); + } + console.log(`%cdelayLoop: %cend`, 'color: gray', 'color: #2ca9e8; font-weight: bold'); + callback(params); + }; +}; diff --git a/src/js/helpers/valid.js b/src/js/helpers/valid.js new file mode 100644 index 000000000..aae0a39fb --- /dev/null +++ b/src/js/helpers/valid.js @@ -0,0 +1,3 @@ +export const isNull = data => data === null; + +export const isDuplicatedArray = target => new Set(target).size !== target.length; diff --git a/src/js/index.js b/src/js/index.js new file mode 100644 index 000000000..aa64fc3c7 --- /dev/null +++ b/src/js/index.js @@ -0,0 +1 @@ +import './App.js';