├── .editorconfig ├── .env ├── .env.local ├── .env.production ├── .eslintrc.json ├── .gitattributes ├── .github ├── actions │ ├── install │ │ └── action.yml │ └── next-cache │ │ └── action.yml └── workflows │ ├── build.yml │ ├── deploy.yml │ ├── main.yml │ ├── pull_request.yml │ └── release.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── media ├── social-media-preview.png └── tetromino-sample.gif ├── next.config.mjs ├── old ├── .editorconfig ├── .env ├── .env.production ├── .eslintrc.json ├── .githooks │ └── pre-commit ├── .gitignore ├── .prettierrc ├── .storybook │ ├── main.js │ └── preview.js ├── craco.config.js ├── package.json ├── public │ └── index.html ├── src │ ├── components │ │ ├── atoms │ │ │ ├── .eslintrc.json │ │ │ ├── app │ │ │ │ ├── AppBar.tsx │ │ │ │ ├── AppCopyright.tsx │ │ │ │ ├── AppKeyBinding.tsx │ │ │ │ ├── AppLogo.tsx │ │ │ │ ├── AppMenu.tsx │ │ │ │ └── AppTheme.tsx │ │ │ ├── design │ │ │ │ └── DesignCell.tsx │ │ │ └── game │ │ │ │ ├── GameAudio.tsx │ │ │ │ ├── GameAudioLoader.tsx │ │ │ │ ├── GameBlock.css │ │ │ │ ├── GameBlock.tsx │ │ │ │ ├── GameBlockIndex.tsx │ │ │ │ ├── GameDigits.tsx │ │ │ │ ├── GameDrop.tsx │ │ │ │ ├── GameHold.tsx │ │ │ │ ├── GameMove.tsx │ │ │ │ ├── GameRotate.tsx │ │ │ │ ├── GameSettings.tsx │ │ │ │ ├── GameTicker.tsx │ │ │ │ ├── GameTimer.tsx │ │ │ │ └── GameValue.tsx │ │ ├── molecules │ │ │ ├── .eslintrc.json │ │ │ ├── app │ │ │ │ ├── AppDarkMode.tsx │ │ │ │ └── AppDialog.tsx │ │ │ ├── design │ │ │ │ ├── DesignDisplay.tsx │ │ │ │ └── DesignEditor.tsx │ │ │ └── game │ │ │ │ ├── GameArrows.tsx │ │ │ │ ├── GameCredits.tsx │ │ │ │ ├── GameFinish.tsx │ │ │ │ ├── GameGrid.css │ │ │ │ ├── GameGrid.tsx │ │ │ │ ├── GameHighScores.tsx │ │ │ │ ├── GameMusic.tsx │ │ │ │ ├── GameNumber.tsx │ │ │ │ ├── GamePieces.tsx │ │ │ │ ├── GamePreloader.tsx │ │ │ │ ├── GameScore.tsx │ │ │ │ ├── GameSoundTracks.tsx │ │ │ │ └── GameToast.tsx │ │ ├── organisms │ │ │ ├── .eslintrc.json │ │ │ ├── app │ │ │ │ └── AppKeyPicker.tsx │ │ │ ├── dialogs │ │ │ │ ├── CreditsDialog.tsx │ │ │ │ ├── FinishDialog.tsx │ │ │ │ ├── HighScoresDialog.tsx │ │ │ │ ├── OptionsDialog.tsx │ │ │ │ └── PauseDialog.tsx │ │ │ ├── game │ │ │ │ ├── GameControls.tsx │ │ │ │ ├── GameEngine.tsx │ │ │ │ ├── GameNumbers.tsx │ │ │ │ └── GameOptions.tsx │ │ │ └── options │ │ │ │ ├── OptionsAudio.tsx │ │ │ │ ├── OptionsGame.tsx │ │ │ │ └── OptionsKeyBindings.tsx │ │ ├── particles │ │ │ ├── .eslintrc.json │ │ │ ├── audio.types.tsx │ │ │ ├── contexts │ │ │ │ └── UiThemeContext.tsx │ │ │ ├── generators │ │ │ │ └── animations.ts │ │ │ ├── hooks │ │ │ │ ├── useAudio.tsx │ │ │ │ ├── useDarkMode.tsx │ │ │ │ ├── useDocVisible.tsx │ │ │ │ ├── useInterval.tsx │ │ │ │ ├── useLetters.tsx │ │ │ │ ├── useLogger.tsx │ │ │ │ ├── useMaxDigits.tsx │ │ │ │ ├── useModalView.tsx │ │ │ │ ├── usePageView.tsx │ │ │ │ ├── usePersist.tsx │ │ │ │ ├── useSpaces.tsx │ │ │ │ ├── useTitle.tsx │ │ │ │ └── useZeros.tsx │ │ │ ├── key_bindings.types.ts │ │ │ ├── nulls │ │ │ │ ├── KeyPressed.tsx │ │ │ │ └── Repeater.tsx │ │ │ ├── particles.types.ts │ │ │ ├── strings.types.ts │ │ │ ├── ui │ │ │ │ ├── UiButton.css │ │ │ │ ├── UiButton.tsx │ │ │ │ ├── UiDialog.css │ │ │ │ ├── UiDialog.tsx │ │ │ │ ├── UiSelect.tsx │ │ │ │ └── UiToggle.tsx │ │ │ └── utilities.types.ts │ │ └── templates │ │ │ ├── App.tsx │ │ │ ├── GameDesktop.tsx │ │ │ ├── GameMobile.tsx │ │ │ ├── Providers.tsx │ │ │ └── Welcome.tsx │ ├── engine │ │ ├── game-buffer.ts │ │ ├── game-cell.ts │ │ ├── game-collision.ts │ │ ├── game-ghost.ts │ │ ├── game-pieces.ts │ │ ├── game-player.ts │ │ ├── game-reset.ts │ │ ├── game-row.ts │ │ ├── game-score.ts │ │ ├── game-screen.ts │ │ ├── game-sound.ts │ │ ├── game-tetrominos.ts │ │ ├── game-tick.ts │ │ └── game-transform.ts │ ├── environment │ │ └── environment.ts │ ├── fonts │ │ └── Segment7-4Gml.otf │ ├── index.css │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── setupTests.ts │ ├── store │ │ ├── app-store.ts │ │ ├── app │ │ │ ├── app-actions.ts │ │ │ ├── app-model.ts │ │ │ ├── app-selectors.ts │ │ │ └── app-state.ts │ │ ├── game │ │ │ ├── game-actions.ts │ │ │ ├── game-model.ts │ │ │ ├── game-selectors.ts │ │ │ └── game-state.ts │ │ ├── root-reducer.ts │ │ ├── select-root.ts │ │ └── snapshot-middleware.ts │ ├── stories │ │ ├── atoms │ │ │ ├── app │ │ │ │ └── AppLogo.stories.tsx │ │ │ └── game │ │ │ │ ├── GameBlock.stories.tsx │ │ │ │ ├── GameDigits.stories.tsx │ │ │ │ └── GameValue.stories.tsx │ │ ├── molecules │ │ │ └── game │ │ │ │ └── GameGrid.stories.tsx │ │ ├── organisms │ │ │ └── game │ │ │ │ ├── GameEngine.stories.tsx │ │ │ │ └── GameLevel.stories.tsx │ │ ├── particles │ │ │ ├── story-arg-types.ts │ │ │ ├── story-decorators.tsx │ │ │ └── story-meta.tsx │ │ ├── templates │ │ │ ├── GameDesktop.stories.tsx │ │ │ └── Welcome.stories.tsx │ │ └── utilities │ │ │ ├── Designer.stories.tsx │ │ │ └── Peices.stories.tsx │ └── tests │ │ ├── components │ │ ├── atoms │ │ │ ├── app │ │ │ │ └── AppLogo.spec.tsx │ │ │ └── game │ │ │ │ ├── GameBlock.spec.tsx │ │ │ │ ├── GameBlockIndex.spec.tsx │ │ │ │ ├── GameTicker.spec.tsx │ │ │ │ └── __snapshots__ │ │ │ │ └── GameBlock.spec.tsx.snap │ │ └── particles │ │ │ ├── hooks │ │ │ └── useInterval.spec.tsx │ │ │ └── nulls │ │ │ └── KeyPressed.spec.tsx │ │ ├── engine │ │ ├── game-buffer.spec.ts │ │ ├── game-cell.spec.ts │ │ ├── game-collision.spec.ts │ │ ├── game-pieces.spec.ts │ │ ├── game-player.spec.ts │ │ ├── game-row.spec.ts │ │ ├── game-screen.spec.ts │ │ ├── game-tick.spec.ts │ │ └── game-transform.spec.ts │ │ ├── expect-game-buffer.ts │ │ ├── expect-game-row.ts │ │ ├── expect-game-screen.ts │ │ ├── expect-utils.ts │ │ └── render-app.tsx ├── tailwind.config.js ├── tsconfig.json └── yarn.lock ├── package.json ├── postcss.config.mjs ├── public └── audio │ ├── music │ ├── 8-bit-perplexion.mp3 │ ├── arcade-puzzler.mp3 │ ├── bonkers-for-arcades.mp3 │ ├── its-raining-pixels.mp3 │ └── the-ice-cream-man.mp3 │ └── sounds │ ├── power-down-13.mp3 │ ├── retro-chip-power.mp3 │ ├── ui-quirky-19.mp3 │ ├── zapsplat_bambo_swoosh.mp3 │ └── zapsplat_level_up.mp3 ├── src ├── app │ ├── Segment7-4Gml.otf │ ├── apple-icon.png │ ├── favicon.ico │ ├── globals.css │ ├── icon.png │ ├── layout.tsx │ ├── manifest.json │ ├── page.tsx │ └── robots.txt ├── components │ ├── atoms │ │ ├── app │ │ │ ├── AppBar.tsx │ │ │ ├── AppCopyright.tsx │ │ │ ├── AppKeyBinding.tsx │ │ │ ├── AppLogo.tsx │ │ │ ├── AppMenu.tsx │ │ │ └── AppTheme.tsx │ │ ├── design │ │ │ └── DesignCell.tsx │ │ └── game │ │ │ ├── GameAudio.tsx │ │ │ ├── GameAudioLoader.tsx │ │ │ ├── GameBlock.css │ │ │ ├── GameBlock.tsx │ │ │ ├── GameBlockIndex.tsx │ │ │ ├── GameDigits.tsx │ │ │ ├── GameDrop.tsx │ │ │ ├── GameHold.tsx │ │ │ ├── GameMove.tsx │ │ │ ├── GameRotate.tsx │ │ │ ├── GameSettings.tsx │ │ │ ├── GameTicker.tsx │ │ │ ├── GameTimer.tsx │ │ │ └── GameValue.tsx │ ├── molecules │ │ ├── app │ │ │ ├── AppDarkMode.tsx │ │ │ └── AppDialog.tsx │ │ ├── design │ │ │ ├── DesignDisplay.tsx │ │ │ └── DesignEditor.tsx │ │ └── game │ │ │ ├── GameArrows.tsx │ │ │ ├── GameCredits.tsx │ │ │ ├── GameFinish.tsx │ │ │ ├── GameGrid.css │ │ │ ├── GameGrid.tsx │ │ │ ├── GameHighScores.tsx │ │ │ ├── GameMusic.tsx │ │ │ ├── GameNumber.tsx │ │ │ ├── GamePieces.tsx │ │ │ ├── GamePreloader.tsx │ │ │ ├── GameScore.tsx │ │ │ ├── GameSoundTracks.tsx │ │ │ └── GameToast.tsx │ ├── organisms │ │ ├── app │ │ │ └── AppKeyPicker.tsx │ │ ├── dialogs │ │ │ ├── CreditsDialog.tsx │ │ │ ├── FinishDialog.tsx │ │ │ ├── HighScoresDialog.tsx │ │ │ ├── OptionsDialog.tsx │ │ │ └── PauseDialog.tsx │ │ ├── game │ │ │ ├── GameControls.tsx │ │ │ ├── GameEngine.tsx │ │ │ ├── GameNumbers.tsx │ │ │ └── GameOptions.tsx │ │ └── options │ │ │ ├── OptionsAudio.tsx │ │ │ ├── OptionsGame.tsx │ │ │ └── OptionsKeyBindings.tsx │ ├── particles │ │ ├── audio.types.tsx │ │ ├── contexts │ │ │ └── UiThemeContext.tsx │ │ ├── generators │ │ │ └── animations.ts │ │ ├── hooks │ │ │ ├── useAudio.tsx │ │ │ ├── useDarkMode.tsx │ │ │ ├── useDocVisible.tsx │ │ │ ├── useInterval.tsx │ │ │ ├── useLetters.tsx │ │ │ ├── useLogger.tsx │ │ │ ├── useMaxDigits.tsx │ │ │ ├── useModalView.tsx │ │ │ ├── usePageView.tsx │ │ │ ├── usePersist.tsx │ │ │ ├── useSpaces.tsx │ │ │ ├── useTitle.tsx │ │ │ └── useZeros.tsx │ │ ├── key_bindings.types.ts │ │ ├── nulls │ │ │ ├── KeyPressed.tsx │ │ │ └── Repeater.tsx │ │ ├── particles.types.ts │ │ ├── strings.types.ts │ │ ├── ui │ │ │ ├── UiButton.css │ │ │ ├── UiButton.tsx │ │ │ ├── UiDialog.css │ │ │ ├── UiDialog.tsx │ │ │ ├── UiSelect.tsx │ │ │ └── UiToggle.tsx │ │ └── utilities.types.ts │ └── templates │ │ ├── App.tsx │ │ ├── GameDesktop.tsx │ │ ├── GameMobile.tsx │ │ ├── Providers.tsx │ │ └── Welcome.tsx ├── engine │ ├── game-buffer.ts │ ├── game-cell.ts │ ├── game-collision.ts │ ├── game-ghost.ts │ ├── game-pieces.ts │ ├── game-player.ts │ ├── game-reset.ts │ ├── game-row.ts │ ├── game-score.ts │ ├── game-screen.ts │ ├── game-sound.ts │ ├── game-tetrominos.ts │ ├── game-tick.ts │ └── game-transform.ts ├── environment │ └── environment.ts └── store │ ├── app-store.ts │ ├── app │ ├── app-actions.ts │ ├── app-model.ts │ ├── app-selectors.ts │ └── app-state.ts │ ├── game │ ├── game-actions.ts │ ├── game-model.ts │ ├── game-selectors.ts │ └── game-state.ts │ ├── root-reducer.ts │ ├── select-root.ts │ └── snapshot-middleware.ts ├── tailwind.config.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | max_line_length = off 14 | trim_trailing_whitespace = false 15 | 16 | [*.yaml] 17 | indent_size = 2 18 | 19 | [*.yml] 20 | indent_size = 2 21 | 22 | [*.sh] 23 | indent_size = 2 24 | 25 | [*.json] 26 | indent_size = 2 27 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_BRAND_NAME=Tetromino 2 | NEXT_PUBLIC_GITHUB=https://github.com/reactgular/tetromino 3 | NEXT_PUBLIC_STORAGE_KEY=tetromino 4 | NEXT_PUBLIC_ANALYTICS= 5 | -------------------------------------------------------------------------------- /.env.local: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_ANALYTICS= 2 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | # Please change to your Google Analytics ID 2 | NEXT_PUBLIC_ANALYTICS= 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################### 2 | # Git Line Endings # 3 | ############################### 4 | 5 | # Set default behaviour to automatically normalize line endings. 6 | * text text=auto eol=lf 7 | 8 | # Force batch scripts to always use CRLF line endings so that if a repo is accessed 9 | # in Windows via a file share from Linux, the scripts will work. 10 | *.{cmd,[cC][mM][dD]} text eol=crlf 11 | *.{bat,[bB][aA][tT]} text eol=crlf 12 | 13 | # Force bash scripts to always use LF line endings so that if a repo is accessed 14 | # in Unix via a file share from Windows, the scripts will work. 15 | *.sh text eol=lf 16 | -------------------------------------------------------------------------------- /.github/actions/install/action.yml: -------------------------------------------------------------------------------- 1 | name: "📥 Install" 2 | description: "📥 Install dependencies" 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - name: "💽 Restore node_modules cache" 8 | uses: actions/cache@v4 9 | id: cache-node-modules 10 | with: 11 | path: "node_modules" 12 | key: ${{ runner.os }}-${{ runner.arch }}-node-modules-${{ hashFiles('**/yarn.lock') }} 13 | restore-keys: | 14 | ${{ runner.os }}-${{ runner.arch }}-node-modules- 15 | 16 | - name: "📥 Install dependencies" 17 | if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' }} 18 | shell: bash 19 | run: yarn install --frozen-lockfile 20 | -------------------------------------------------------------------------------- /.github/actions/next-cache/action.yml: -------------------------------------------------------------------------------- 1 | name: "📥 NestJS cache" 2 | description: "📥 Cache NestJS dependencies" 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - name: "💽 Restore .next/cache cache" 8 | uses: actions/cache@v4 9 | with: 10 | path: ${{ github.workspace }}/.next/cache 11 | key: ${{ runner.os }}-${{ runner.arch }}-nextjs-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} 12 | restore-keys: | 13 | ${{ runner.os }}-${{ runner.arch }}-nextjs-${{ hashFiles('**/yarn.lock') }}- 14 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: "🚀 Deploy" 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | skipDeploy: 7 | description: "Skip deployment?" 8 | required: true 9 | type: boolean 10 | default: false 11 | workflow_call: 12 | inputs: 13 | skipDeploy: 14 | description: "Skip deployment?" 15 | type: boolean 16 | default: false 17 | 18 | concurrency: "deploy" 19 | 20 | jobs: 21 | build: 22 | uses: ./.github/workflows/build.yml 23 | with: 24 | testOnly: true 25 | 26 | artifacts: 27 | runs-on: ubuntu-latest 28 | needs: [ build ] 29 | steps: 30 | - name: "📥 Checkout code" 31 | uses: actions/checkout@v4 32 | 33 | - name: "🔧 Setup Node" 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: "20" 37 | cache: "yarn" 38 | 39 | - name: "📦 Install dependencies" 40 | uses: ./.github/actions/install 41 | 42 | - name: "💽 Restore .next/cache cache" 43 | uses: ./.github/actions/next-cache 44 | 45 | - name: "🔧 Setup Pages" 46 | uses: actions/configure-pages@v5 47 | with: 48 | static_site_generator: next 49 | 50 | - name: "🔨 Build" 51 | run: yarn build 52 | 53 | - name: "📦 Upload artifact" 54 | uses: actions/upload-pages-artifact@v3 55 | with: 56 | path: ./out 57 | 58 | deploy: 59 | permissions: 60 | pages: write 61 | id-token: write 62 | environment: 63 | name: github-pages 64 | url: ${{ steps.deployment.outputs.page_url }} 65 | runs-on: ubuntu-latest 66 | needs: [ artifacts ] 67 | if: ${{ github.event.inputs.skipDeploy == 'false' }} 68 | steps: 69 | - name: "🚀 Deploy to GitHub Pages" 70 | id: deployment 71 | uses: actions/deploy-pages@v4 72 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: "🚀 Main" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | concurrency: "main" 10 | 11 | jobs: 12 | build: 13 | uses: ./.github/workflows/build.yml 14 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: "🔄 Pull Request" 2 | 3 | on: 4 | pull_request: 5 | types: [ opened, synchronize, reopened, converted_to_draft, ready_for_review ] 6 | 7 | concurrency: 8 | group: "${{ github.workflow }}-${{ github.event.pull_request.number }}" 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | build: 13 | uses: ./.github/workflows/build.yml 14 | 15 | success: 16 | runs-on: ubuntu-latest 17 | needs: [ build] 18 | steps: 19 | - name: "✅ Success" 20 | run: echo "::notice title={Success}::This step triggers auto merge if all dependencies are met." 21 | outputs: 22 | success: "true" 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # vercel 29 | .vercel 30 | 31 | # typescript 32 | *.tsbuildinfo 33 | next-env.d.ts 34 | 35 | # IDEs and editors 36 | /.idea 37 | .project 38 | .classpath 39 | .c9/ 40 | *.launch 41 | .settings/ 42 | *.sublime-workspace 43 | 44 | # IDE - VSCode 45 | .vscode/* 46 | !.vscode/settings.json 47 | !.vscode/tasks.json 48 | !.vscode/launch.json 49 | !.vscode/extensions.json 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Nick Foscarini 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tetromino 2 | 3 | ![gameplay](media/tetromino-sample.gif) 4 | 5 | A tetris-style game created with [React](https://reactjs.org/), [Redux](https://react-redux.js.org/) and [TailwindCSS](https://tailwindcss.com/). 6 | 7 | ## What is it? 8 | 9 | For those of you not familiar with Tetris, it's a game where you drop tetromino shapes to create solid rows to score points. Blocks 10 | fall faster as you increase the level. If there is no more room to drop blocks, then the game is over. 11 | 12 | ## Why build it? 13 | 14 | Wanted to challenge myself to build a Tetris game using just DOM elements. 15 | There are no SVGs or `` graphics anywhere in the game. 16 | 17 | All graphics are rendered as React functional components, and the game logic is handled by a Redux reducer. 18 | 19 | ## Where is it? 20 | 21 | Online demo: [https://reactgular.github.io/tetromino/](https://reactgular.github.io/tetromino/) 22 | 23 | ## How to get it? 24 | 25 | Clone and run it locally. 26 | 27 | ```bash 28 | git clone https://github.com/reactgular/tetromino 29 | cd tetromino 30 | yarn install 31 | yarn start 32 | ``` 33 | -------------------------------------------------------------------------------- /media/social-media-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reactgular/tetromino/e53874bc84cfe153cb9fbab9f20f66de53cbba37/media/social-media-preview.png -------------------------------------------------------------------------------- /media/tetromino-sample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reactgular/tetromino/e53874bc84cfe153cb9fbab9f20f66de53cbba37/media/tetromino-sample.gif -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | output: "export" 4 | }; 5 | 6 | export default nextConfig; 7 | -------------------------------------------------------------------------------- /old/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /old/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_BRAND_NAME=Tetromino 2 | REACT_APP_GITHUB=https://github.com/reactgular/tetromino 3 | REACT_APP_STORAGE_KEY=tetromino 4 | REACT_APP_VERSION=1.0.0 5 | # Base path for loading audio files 6 | REACT_APP_BASE=/tetromino 7 | REACT_APP_ANALYTICS= 8 | -------------------------------------------------------------------------------- /old/.env.production: -------------------------------------------------------------------------------- 1 | # Please change to your Google Analytics ID 2 | REACT_APP_ANALYTICS=UA-141015392-3 3 | -------------------------------------------------------------------------------- /old/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "extends": [ 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:react/recommended", 7 | "plugin:react-hooks/recommended", 8 | "plugin:prettier/recommended", 9 | "prettier", 10 | "prettier/react", 11 | "prettier/@typescript-eslint" 12 | ], 13 | "plugins": [ 14 | "@typescript-eslint", 15 | "react", 16 | "react-hooks", 17 | "prettier" 18 | ], 19 | "rules": { 20 | "no-unused-vars": "off", 21 | "@typescript-eslint/no-unused-vars": "off", 22 | "@typescript-eslint/explicit-module-boundary-types": "off", 23 | "@typescript-eslint/no-inferrable-types": "off", 24 | "@typescript-eslint/no-namespace": "off", 25 | "@typescript-eslint/no-non-null-assertion": "off", 26 | "@typescript-eslint/no-explicit-any": "off", 27 | "react/prop-types": "off", 28 | "react/react-in-jsx-scope": "off", 29 | "react/jsx-first-prop-new-line": [ 30 | 1, 31 | "multiline" 32 | ], 33 | "react/jsx-closing-bracket-location": [ 34 | 2, 35 | "tag-aligned" 36 | ], 37 | "no-restricted-imports": [ 38 | "error", 39 | { 40 | "patterns": [ 41 | "@material-ui/*/*/*", 42 | "!@material-ui/core/test-utils/*", 43 | "react-icons/all" 44 | ] 45 | } 46 | ] 47 | }, 48 | "globals": { 49 | "React": "writable" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /old/.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | npm run git:pre-commit 4 | -------------------------------------------------------------------------------- /old/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # IDEs and editors 26 | /.idea 27 | .project 28 | .classpath 29 | .c9/ 30 | *.launch 31 | .settings/ 32 | *.sublime-workspace 33 | 34 | # IDE - VSCode 35 | .vscode/* 36 | !.vscode/settings.json 37 | !.vscode/tasks.json 38 | !.vscode/launch.json 39 | !.vscode/extensions.json 40 | 41 | *.log 42 | /.sass-cache 43 | 44 | # testing 45 | 46 | # misc 47 | *.pem 48 | Thumbs.db 49 | -------------------------------------------------------------------------------- /old/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "bracketSpacing": false, 5 | "tabWidth": 4, 6 | "useTabs": false, 7 | "trailingComma": "none", 8 | "arrowParens": "always", 9 | "jsxSingleQuote": false, 10 | "jsxBracketSameLine": false 11 | } 12 | -------------------------------------------------------------------------------- /old/.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | 'stories': [ 5 | '../src/**/*.stories.mdx', 6 | '../src/**/*.stories.@(js|jsx|ts|tsx)' 7 | ], 8 | 'addons': [ 9 | '@storybook/addon-links', 10 | '@storybook/addon-essentials', 11 | '@storybook/preset-create-react-app' 12 | ], 13 | webpackFinal: async (config) => { 14 | config.module.rules.push({ 15 | test: /\.css$/, 16 | use: [ 17 | { 18 | loader: 'postcss-loader', 19 | options: { 20 | ident: 'postcss', 21 | plugins: [ 22 | require('tailwindcss'), 23 | require('autoprefixer') 24 | ] 25 | } 26 | } 27 | ], 28 | include: path.resolve(__dirname, '../src') 29 | }); 30 | return config; 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /old/.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import '../src/index.css'; 2 | import {AppTheme} from '../src/components/atoms/app/AppTheme'; 3 | import {useMemo} from 'react'; 4 | import {getAppStore} from '../src/store/app-store'; 5 | import {Provider} from 'react-redux'; 6 | 7 | export const parameters = { 8 | actions: {argTypesRegex: '^on[A-Z].*'}, 9 | controls: { 10 | matchers: { 11 | color: /(background|color)$/i, 12 | date: /Date$/ 13 | } 14 | } 15 | }; 16 | 17 | export const decorators = [ 18 | (Story) => { 19 | const store = useMemo(() => getAppStore(), []); 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | } 28 | ]; 29 | -------------------------------------------------------------------------------- /old/craco.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | style: { 3 | postcss: { 4 | plugins: [require('tailwindcss'), require('autoprefixer')] 5 | } 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /old/src/components/atoms/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-restricted-imports": [ 4 | "error", 5 | { 6 | "patterns": [ 7 | "../**/molecules/*", 8 | "../**/organisms/*", 9 | "../**/templates/*", 10 | "@material-ui/*/*/*", 11 | "!@material-ui/core/test-utils/*", 12 | "react-icons/all" 13 | ] 14 | } 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /old/src/components/atoms/app/AppBar.tsx: -------------------------------------------------------------------------------- 1 | import {PayloadAction} from '@reduxjs/toolkit'; 2 | import classNames from 'classnames'; 3 | import React, {FC, ReactElement} from 'react'; 4 | import {useAppDispatch} from '../../../store/app-store'; 5 | import {ClassNameProps} from '../../particles/particles.types'; 6 | import {UiButton} from '../../particles/ui/UiButton'; 7 | 8 | export interface AppBarTool { 9 | action: PayloadAction; 10 | 11 | active?: boolean; 12 | 13 | icon: ReactElement; 14 | 15 | toolTip: string; 16 | } 17 | 18 | export interface AppBarProps { 19 | tools: Array; 20 | } 21 | 22 | export const AppBar: FC = ({ 23 | tools, 24 | className 25 | }) => { 26 | const dispatch = useAppDispatch(); 27 | return ( 28 |
29 | {tools.map(({icon, toolTip, active, action}, indx) => ( 30 | dispatch(action)} 36 | toolTip={toolTip} 37 | > 38 | {icon} 39 | 40 | ))} 41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /old/src/components/atoms/app/AppCopyright.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import {FC} from 'react'; 3 | import {FaGithub} from 'react-icons/fa'; 4 | import {environment} from '../../../environment/environment'; 5 | import {ClassNameProps} from '../../particles/particles.types'; 6 | 7 | export const AppCopyright: FC = ({className}) => { 8 | return ( 9 |
10 |
Version {environment.version}
11 | 15 | 16 | Source 17 | 18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /old/src/components/atoms/app/AppKeyBinding.tsx: -------------------------------------------------------------------------------- 1 | import {FC, useMemo} from 'react'; 2 | import {toSpaces} from '../../particles/strings.types'; 3 | import {UiButton} from '../../particles/ui/UiButton'; 4 | 5 | const CODE_PREFIXES = ['Digit', 'Key', 'Arrow']; 6 | 7 | export interface AppKeyBindingProps { 8 | keyCode: string; 9 | 10 | label: string; 11 | 12 | onClick: () => void; 13 | } 14 | 15 | export const AppKeyBinding: FC = ({ 16 | label, 17 | keyCode, 18 | onClick 19 | }) => { 20 | const str = useMemo(() => { 21 | const code = CODE_PREFIXES.reduce((str, prefix) => { 22 | return str.replace(prefix, '').trim(); 23 | }, keyCode); 24 | return toSpaces(code); 25 | }, [keyCode]); 26 | 27 | return ( 28 | <> 29 |
{label}
30 | 31 | {str} 32 | 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /old/src/components/atoms/app/AppLogo.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import {FC} from 'react'; 3 | import {useLetters} from '../../particles/hooks/useLetters'; 4 | import {ClassNameProps} from '../../particles/particles.types'; 5 | 6 | export interface AppLogoProps { 7 | name: string; 8 | 9 | speed?: number; 10 | } 11 | 12 | export const AppLogo: FC = ({ 13 | name, 14 | speed = 200, 15 | className 16 | }) => { 17 | const [letters, colors] = useLetters(name, speed); 18 | return ( 19 |
23 |
welcome to
24 |
25 | {letters.map(({key, char}) => ( 26 |
31 | {char} 32 |
33 | ))} 34 |
35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /old/src/components/atoms/app/AppMenu.tsx: -------------------------------------------------------------------------------- 1 | import {PayloadAction} from '@reduxjs/toolkit'; 2 | import classNames from 'classnames'; 3 | import {FC} from 'react'; 4 | import {useAppDispatch} from '../../../store/app-store'; 5 | import {ClassNameProps} from '../../particles/particles.types'; 6 | import {UiButton} from '../../particles/ui/UiButton'; 7 | 8 | export interface AppMenuItem { 9 | action: PayloadAction; 10 | 11 | active?: boolean; 12 | 13 | title: string; 14 | } 15 | 16 | export interface AppMenuProps { 17 | items: AppMenuItem[]; 18 | } 19 | 20 | export const AppMenu: FC = ({ 21 | items, 22 | className 23 | }) => { 24 | const dispatch = useAppDispatch(); 25 | return ( 26 |
27 | {items.map(({action, active, title}, indx) => ( 28 | dispatch(action)} 32 | key={indx} 33 | > 34 | {title} 35 | 36 | ))} 37 |
38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /old/src/components/atoms/app/AppTheme.tsx: -------------------------------------------------------------------------------- 1 | import {StylesProvider} from '@material-ui/core/styles'; 2 | import {FunctionComponent} from 'react'; 3 | import {useDarkMode} from '../../particles/hooks/useDarkMode'; 4 | 5 | export const AppTheme: FunctionComponent = ({children}) => { 6 | useDarkMode(); 7 | return {children}; 8 | }; 9 | -------------------------------------------------------------------------------- /old/src/components/atoms/design/DesignCell.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import {FC} from 'react'; 3 | import {ClassNameProps} from '../../particles/particles.types'; 4 | 5 | export interface DesignCellProps { 6 | center?: boolean; 7 | 8 | onChange: (value: boolean) => void; 9 | 10 | value: boolean; 11 | } 12 | 13 | export const DesignCell: FC = ({ 14 | center, 15 | value, 16 | onChange, 17 | className 18 | }) => { 19 | return ( 20 |
onChange(!value)} 31 | > 32 | {center ? ( 33 |
34 | ) : undefined} 35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /old/src/components/atoms/game/GameAudio.tsx: -------------------------------------------------------------------------------- 1 | import {FC, useEffect, useRef} from 'react'; 2 | 3 | export interface GameAudioProps { 4 | autoPlay?: boolean; 5 | 6 | loop?: boolean; 7 | 8 | onDone?: () => void; 9 | 10 | onLoaded?: () => void; 11 | 12 | src: string; 13 | 14 | volume?: number; 15 | } 16 | 17 | export const GameAudio: FC = ({ 18 | autoPlay = true, 19 | loop, 20 | onDone, 21 | onLoaded, 22 | src, 23 | volume 24 | }) => { 25 | const ref = useRef(null); 26 | 27 | useEffect(() => { 28 | if (volume) { 29 | ref.current!.volume = volume; 30 | } 31 | }, [ref, volume]); 32 | 33 | return ( 34 |