Initial commit
This commit is contained in:
commit
f530199b38
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
99
README.md
Normal file
99
README.md
Normal file
@ -0,0 +1,99 @@
|
||||
# Automata Frontend Exercise
|
||||
## Rock, Paper, Scissors, Lizard, Spock
|
||||
|
||||
## Overview
|
||||
This is a modern take on the classic "Rock, Paper, Scissors" game, with two additional choices: **Lizard** and **Spock**.
|
||||
The extended rules create more possible outcomes, adding depth and strategy to the game.
|
||||
|
||||
## Purpose
|
||||
The purpose of this exercise is to provide you the opportunity to demonstrate how you solve problems and express code. We know that
|
||||
in person code exercises are highly pressured and artificial, hence why we asked you to perform this exercise at home. The expectation
|
||||
is that you will use the tools you are comfortable with (stackoverflow, ChatGPT, etc), but you are able to explain and extend the code
|
||||
(as if this was your job). We don't expect you to be able to remember every method of every class, but we would like to have a
|
||||
conversation regarding your code.
|
||||
|
||||
## Basic Rules
|
||||
The game is played between two players. Each player chooses one of the five options:
|
||||
- **Rock**
|
||||
- **Paper**
|
||||
- **Scissors**
|
||||
- **Lizard**
|
||||
- **Spock**
|
||||
|
||||
The winner is determined by the following rules:
|
||||
|
||||
| **Choice** | **Wins Against** | **Reason** |
|
||||
|--------------|------------------|----------------------------------|
|
||||
| **Scissors** | Paper, Lizard | Cuts Paper, Decapitates Lizard |
|
||||
| **Paper** | Rock, Spock | Covers Rock, Disproves Spock |
|
||||
| **Rock** | Scissors, Lizard | Crushes Scissors, Crushes Lizard |
|
||||
| **Lizard** | Paper, Spock | Eats Paper, Poisons Spock |
|
||||
| **Spock** | Scissors, Rock | Smashes Scissors, Vaporizes Rock |
|
||||
If both players choose the same option, the game results in a **tie**.
|
||||
|
||||
## Features
|
||||
- **Interactive Gameplay**: Players can select their choice, and the winner is determined based on the rules.
|
||||
- **Responsive Design**: The game works seamlessly on desktop and mobile devices.
|
||||
- **Clear Visual Feedback**: Winning and losing outcomes are displayed in an engaging and intuitive way.
|
||||
- **Scoreboard**: Tracks the points of the user and the computer across multiple rounds.
|
||||
- **Data Persistence**: Retains the game state and scoreboard within the same browser session.
|
||||
- **Custom Username**: Allows the user to set a username, which is displayed during the game and on the scoreboard.
|
||||
- **Restart**: Allows the user to restart the game, clearing the scoreboard and resetting the game state.
|
||||
|
||||
## Suggestions
|
||||
|
||||
When working on this project, we encourage you to treat the code as if it is intended for a real production environment. Here are some tips to guide you:
|
||||
|
||||
- **Code Quality**: Write clean, readable, and maintainable code.
|
||||
- **Scalability**: Structure your code to allow for easy feature additions or modifications in the future.
|
||||
- **Version Control**: Use meaningful commit messages that explain the purpose of each change.
|
||||
|
||||
---
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
|
||||
- Optionally add `...tseslint.configs.stylisticTypeChecked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import react from 'eslint-plugin-react'
|
||||
|
||||
export default tseslint.config({
|
||||
// Set the react version
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
// Add the react plugin
|
||||
react,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended rules
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
},
|
||||
})
|
||||
```
|
38
eslint.config.js
Normal file
38
eslint.config.js
Normal file
@ -0,0 +1,38 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import react from 'eslint-plugin-react';
|
||||
import { version } from 'react'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
}
|
||||
},
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
react
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Rock, Paper, Scissors, Lizard, Spock</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
35
package.json
Normal file
35
package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "automata-fe-test",
|
||||
"private": true,
|
||||
"author": "Automata",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.14.0",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.18.2",
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
4053
pnpm-lock.yaml
Normal file
4053
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
44
src/App/App.css
Normal file
44
src/App/App.css
Normal file
@ -0,0 +1,44 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 4em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
32
src/App/App.tsx
Normal file
32
src/App/App.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import './App.css'
|
||||
import { Footer } from './Footer'
|
||||
import { Game } from './Game'
|
||||
import { Scoreboard } from './Scoreboard'
|
||||
import { UserSelection } from './UserSelection'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className='flex flex-col min-h-[100vh] p-5'>
|
||||
<h1 className='font-bold'>Rock, Paper, Scissors, Lizard, Spock</h1>
|
||||
<div className='p-5' />
|
||||
<div className='flex-grow'>
|
||||
<UserSelection />
|
||||
<Game />
|
||||
<Scoreboard />
|
||||
<div className='p-5'>
|
||||
<h1 className='text-center p-3 pb-5'>
|
||||
How It works?
|
||||
</h1>
|
||||
<div className='flex justify-center'>
|
||||
<iframe width="420" height="315"
|
||||
src="https://www.youtube.com/embed/pIpmITBocfM">
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
25
src/App/Footer.tsx
Normal file
25
src/App/Footer.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import automataLogo from '../assets/automata.png'
|
||||
import { useSession } from '../lib/store';
|
||||
|
||||
export function Footer() {
|
||||
const session = useSession();
|
||||
|
||||
return <div className='flex items-center justify-center md:justify-between flex-wrap md:flex-nowrap'>
|
||||
<div className='w-full md:w-auto flex justify-center'>
|
||||
<a href="https://automata.tech/" target="_blank" rel="noreferrer">
|
||||
<img src={String(automataLogo)} className="logo automata" alt="Automata logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h2 className='py-5 md:hidden'>Frontend Exercise</h2>
|
||||
<button
|
||||
type="button"
|
||||
className='bg-red-500/20 border-red-500 border border-solid text-red-500 px-2 rounded-lg cursor-pointer w-full md:w-auto'
|
||||
onClick={() => {
|
||||
session.reset();
|
||||
}}
|
||||
>
|
||||
Reset <span className='text-xs'>(this deletes everything)</span>
|
||||
</button>
|
||||
<h2 className='py-2 hidden md:block'>Frontend Exercise</h2>
|
||||
</div>;
|
||||
}
|
25
src/App/Game.css
Normal file
25
src/App/Game.css
Normal file
@ -0,0 +1,25 @@
|
||||
.loser-shrink-to-zero {
|
||||
animation: loser-shrink-to-zero 2s ease-in;
|
||||
}
|
||||
|
||||
@keyframes loser-shrink-to-zero {
|
||||
from {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
to {
|
||||
height: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >=48rem) {
|
||||
@keyframes loser-shrink-to-zero {
|
||||
from {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
to {
|
||||
width: 0px;
|
||||
}
|
||||
}
|
||||
}
|
223
src/App/Game.tsx
Normal file
223
src/App/Game.tsx
Normal file
@ -0,0 +1,223 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { SessionInfoPlayResultOver, useSession } from "../lib/store"
|
||||
import { CPUNAME, CPUS_NAMES } from "../lib/CPUS";
|
||||
import { Play, PLAYER_OPTIONS, PlayerAction, PlayResult } from "../lib/game";
|
||||
import './Game.css';
|
||||
|
||||
// Future improvements create a GameBoard that controls the size of the boxes
|
||||
// rather than having the code here multiple times
|
||||
export function Game() {
|
||||
const session = useSession();
|
||||
|
||||
const [selectedCPU, setSelectedCPU] = useState(CPUS_NAMES[0]);
|
||||
|
||||
const [gameState, setGameState] = useState<'play' | 'result' | 'over'>('play');
|
||||
const [result, setResult] = useState<Play | undefined>();
|
||||
// TODO move this to a type
|
||||
const [lastGame, setLastGame] = useState<SessionInfoPlayResultOver | undefined>();
|
||||
|
||||
// If reset or logout is hit reset the state of this component
|
||||
useEffect(() => {
|
||||
if (session.currentUser === undefined) {
|
||||
setLastGame(undefined);
|
||||
setResult(undefined);
|
||||
setGameState('play');
|
||||
}
|
||||
}, [session.currentUser]);
|
||||
|
||||
if (!session.currentUser) {
|
||||
return <div className="w-full flex flex-col justify-center items-center p-5">
|
||||
<span className="text-4xl">↑</span>
|
||||
<h2 className="text-2xl font-bold">Please choose a name before starting</h2>
|
||||
</div>
|
||||
}
|
||||
|
||||
if (gameState === 'over') {
|
||||
if (!lastGame) {
|
||||
// This should not happen
|
||||
setGameState('play');
|
||||
return <></>
|
||||
}
|
||||
|
||||
function getBoxClass(target: PlayResult) {
|
||||
if (lastGame?.winner === 'tie') return 'h-full w-full';
|
||||
if (lastGame?.winner !== target) return 'loser-shrink-to-zero h-0 md:h-full w-full md:w-0';
|
||||
return 'h-1/2 w-full md:w-1/2 md:h-full flex-grow'
|
||||
}
|
||||
|
||||
// TODO maybe add an icon on top of the winner
|
||||
return <>
|
||||
<div className="h-[500px] flex flex-col md:flex-row rounded-xl overflow-hidden relative">
|
||||
<div className={`bg-blue-500/50 grid place-items-center overflow-hidden ${getBoxClass('user')}`}>
|
||||
<span className="text-4xl whitespace-nowrap">{session.currentUser}</span>
|
||||
</div>
|
||||
<div className={`bg-red-500/50 grid place-items-center overflow-hidden ${getBoxClass('cpu')}`}>
|
||||
<span className="text-4xl whitespace-nowrap">{lastGame.game.cpu}</span>
|
||||
</div>
|
||||
{lastGame.winner === 'tie' && <span className="top-1/2 left-1/2 -translate-1/2 text-yellow-400 drop-shadow-lg drop-shadow-yellow-400 absolute text-6xl">It's a Tie</span>}
|
||||
<button
|
||||
className="defaultBtn absolute bottom-[2em] left-1/2 -translate-x-1/2 shadow-md"
|
||||
onClick={() => {
|
||||
setGameState('play')
|
||||
}}
|
||||
>
|
||||
New Game
|
||||
</button>
|
||||
</div>
|
||||
<GameHistory />
|
||||
</>
|
||||
}
|
||||
|
||||
if (!session.currentGame) {
|
||||
return <div className="h-[500px] flex flex-col md:flex-row rounded-xl overflow-hidden relative">
|
||||
<div className="bg-blue-500/50 h-1/2 md:h-full w-full md:w-1/2 p-4 grid place-items-center">
|
||||
<span className="text-3xl">{session.currentUser}</span>
|
||||
</div>
|
||||
<div className="bg-red-500/50 h-1/2 md:h-full w-full md:w-1/2 p-4 grid place-items-center">
|
||||
<select className="text-3xl" value={selectedCPU} onChange={(e) => setSelectedCPU(e.currentTarget.value as CPUNAME)}>
|
||||
{CPUS_NAMES.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
className="defaultBtn absolute bottom-[2em] left-1/2 -translate-x-1/2 shadow-md"
|
||||
onClick={() => {
|
||||
session.startGame(selectedCPU);
|
||||
}}
|
||||
>
|
||||
Start Game
|
||||
</button>
|
||||
<span className="absolute top-1/2 left-1/2 -translate-x-[56%] -translate-y-1/2 drop-shadow-lg font-bold text-6xl">VS</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
if (gameState === 'result') {
|
||||
if (!result) {
|
||||
// This should not happen
|
||||
setGameState('play');
|
||||
return <></>
|
||||
}
|
||||
return <>
|
||||
<div className="h-[500px] flex flex-col md:flex-row rounded-xl overflow-hidden relative">
|
||||
<div className="bg-blue-500/50 h-1/2 md:h-full w-full md:w-1/2 p-4 grid place-items-center relative">
|
||||
<span className="absolute top-[1em] left-1/2 -translate-1/2 text-2xl">{session.currentUser}</span>
|
||||
<div className="text-2xl">
|
||||
<span className="font-bold">
|
||||
{result.user}
|
||||
</span>
|
||||
<WinnerText me='user' result={result.winner} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-red-500/50 h-1/2 md:h-full w-full md:w-1/2 p-4 grid place-items-center relative">
|
||||
<span className="absolute bottom-2 md:bottom-[unset] md:top-[1em] left-1/2 -translate-x-1/2 text-2xl whitespace-nowrap">{session.currentGame!.cpu}</span>
|
||||
<div className="text-2xl">
|
||||
<span className="font-bold">
|
||||
{result.cpu}
|
||||
</span>
|
||||
<WinnerText me='cpu' result={result.winner} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="absolute top-1/2 left-1/2 -translate-x-[56%] -translate-y-1/2 drop-shadow-lg font-bold text-6xl">VS</span>
|
||||
<button
|
||||
className="defaultBtn absolute text-xs px-2! md:text-base right-1 top-1/2 md:top-[unset] -translate-x-0 md:right-[unset] md:-translate-x-1/2 md:bottom-[2em] md:left-1/2 -translate-1/2 shadow-md"
|
||||
onClick={() => {
|
||||
setGameState('play')
|
||||
}}
|
||||
>
|
||||
Next Round
|
||||
</button>
|
||||
<PointsDisplay />
|
||||
</div>
|
||||
<GameHistory />
|
||||
</>
|
||||
}
|
||||
|
||||
function play(action: PlayerAction) {
|
||||
const r = session.play(action);
|
||||
if (r.type === 'play') {
|
||||
setGameState('result');
|
||||
setResult(r.play);
|
||||
} else {
|
||||
setGameState('over');
|
||||
setLastGame(r);
|
||||
}
|
||||
}
|
||||
|
||||
if (gameState === 'play') {
|
||||
return <>
|
||||
<div className="h-[500px] flex flex-col md:flex-row rounded-xl overflow-hidden relative">
|
||||
<div className="bg-blue-500/50 h-1/2 md:h-full w-full md:w-1/2 p-4 grid place-items-center relative">
|
||||
<span className="absolute top-[1em] left-1/2 -translate-1/2 text-2xl">{session.currentUser}</span>
|
||||
<div className="flex flex-wrap justify-around gap-2">
|
||||
{PLAYER_OPTIONS.map((a) =>
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
className="p-2 drop-shadow-lg text-md md:text-2xl hover:drop-shadow-white cursor-pointer"
|
||||
onClick={() => play(a)}
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-red-500/50 h-1/2 md:h-full w-full md:w-1/2 p-4 grid place-items-center">
|
||||
<select className="text-4xl" value={selectedCPU} onChange={(e) => setSelectedCPU(e.currentTarget.value as CPUNAME)}>
|
||||
{CPUS_NAMES.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<span className="absolute top-1/2 left-1/2 -translate-x-[56%] -translate-y-1/2 drop-shadow-lg font-bold text-6xl">VS</span>
|
||||
<PointsDisplay />
|
||||
</div>
|
||||
<GameHistory />
|
||||
</>
|
||||
}
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
||||
function PointsDisplay() {
|
||||
const session = useSession();
|
||||
const icons = Array(3).fill(null);
|
||||
return <div className="absolute flex gap-2 bg-white/20 rounded-lg shadow-lg p-2 left-2 top-1/2 -translate-y-1/2 md:top-2 md:left-1/2 md:-translate-x-1/2 md:translate-y-0">
|
||||
{icons.map((_, i) => <span key={i} className={
|
||||
`${session.currentGame?.plays[i] === undefined ? 'bg-gray-500' :
|
||||
session.currentGame?.plays[i].winner === 'cpu' ? 'bg-red-500' :
|
||||
session.currentGame?.plays[i].winner === 'user' ? 'bg-blue-500' :
|
||||
'bg-yellow-500'
|
||||
} border-solid border border-black rounded-full min-h-[15px] min-w-[15px]`
|
||||
} />)}
|
||||
</div>
|
||||
}
|
||||
|
||||
function WinnerText({ me, result }: { me: PlayResult, result: PlayResult }) {
|
||||
let r = <span>Loser</span>;
|
||||
if (result === 'tie') r = <span className="text-yellow-500">Tie</span>;
|
||||
else if (result === me) r = <span>Winner</span>;
|
||||
return <div className="text-2xl">
|
||||
{r}
|
||||
</div>
|
||||
}
|
||||
|
||||
function GameHistory() {
|
||||
const session = useSession();
|
||||
|
||||
if (!session.currentGame || session.currentGame.plays.length === 0) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return <>
|
||||
<h2 className="font-bold pt-5">
|
||||
Game History
|
||||
</h2>
|
||||
<div>
|
||||
{session.currentGame.plays.map((play, i) => <div key={i} className="p-2">
|
||||
User played <span className="text-blue-500">{play.user}</span>, and CPU played <span className="text-red-500">{play.cpu}</span>! Winner:{" "}
|
||||
<span className={
|
||||
play.winner === 'tie' ? 'text-yellow-500' :
|
||||
play.winner === 'cpu' ? 'text-red-500' :
|
||||
'text-blue-500'
|
||||
}>{play.winner}</span>
|
||||
</div>)}
|
||||
</div>
|
||||
</>
|
||||
}
|
45
src/App/Scoreboard.tsx
Normal file
45
src/App/Scoreboard.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSession } from "../lib/store";
|
||||
|
||||
export function Scoreboard() {
|
||||
const session = useSession();
|
||||
const sortedKeys = useMemo(() => {
|
||||
const nKeys = Object.keys(session.scoreboard);
|
||||
nKeys.sort((a, b) => session.scoreboard[b] - session.scoreboard[a]);
|
||||
return nKeys;
|
||||
}, [session.scoreboard]);
|
||||
|
||||
if (sortedKeys.length === 0) return <></>;
|
||||
|
||||
return <div className='p-5'>
|
||||
<h1 className='pb-5'> Scoreboard </h1>
|
||||
<div className="flex justify-center p-3">
|
||||
<div className="border-white/20 border border-solid rounded-lg">
|
||||
<table className="">
|
||||
<thead>
|
||||
<tr className="text-xl">
|
||||
<th className="p-2 px-5 bg-white/30 rounded-tl-lg">
|
||||
Player
|
||||
</th>
|
||||
<th className="p-2 px-5 bg-white/30 rounded-tr-lg">
|
||||
No. Wins
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedKeys.map((a, i) =>
|
||||
<tr key={a}>
|
||||
<td className={`p-2 px-5 ${i === 0 ? 'text-xl font-bold' : ''}`}>
|
||||
{a}
|
||||
</td>
|
||||
<td className={`p-2 px-5 ${i === 0 ? 'text-xl font-bold' : ''}`}>
|
||||
{session.scoreboard[a]}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
52
src/App/UserSelection.tsx
Normal file
52
src/App/UserSelection.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { useState } from "react";
|
||||
import { CPUNAME, CPUS_NAMES } from "../lib/CPUS";
|
||||
import { useSession } from "../lib/store";
|
||||
|
||||
export function UserSelection() {
|
||||
const session = useSession();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
|
||||
if (session.currentUser) {
|
||||
return <div className="flex items-center justify-between p-2">
|
||||
<h2 className="text-xl font-bold">Hello, {session.currentUser}. Ready to play?</h2>
|
||||
<button type="button" onClick={() => session.setUser(undefined)}>
|
||||
Change User
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
return <>
|
||||
<form
|
||||
className="p-2 flex gap-5 items-center justify-center flex-wrap"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (CPUS_NAMES.includes(e.currentTarget.value)) {
|
||||
return;
|
||||
}
|
||||
session.setUser(username);
|
||||
setUsername('');
|
||||
}}
|
||||
>
|
||||
<h2 className="font-bold text-xl">Before we can start what is your name?</h2>
|
||||
<input
|
||||
value={username}
|
||||
className="border-b-white border-b-1 border-b-solid text-xl font-bold outline-none flex-grow md:max-w-[50ch]"
|
||||
onChange={(e) => {
|
||||
if (CPUS_NAMES.includes(e.currentTarget.value as CPUNAME)) {
|
||||
e.currentTarget.setCustomValidity('I am sorry, but that name is not valid');
|
||||
} else {
|
||||
e.currentTarget.setCustomValidity('');
|
||||
}
|
||||
|
||||
|
||||
setUsername(e.currentTarget.value);
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="p-1 bg-gray-400/10 rounded-lg px-2 w-full md:w-auto">
|
||||
Select
|
||||
</button>
|
||||
</form>
|
||||
</>;
|
||||
}
|
BIN
src/assets/automata.png
Normal file
BIN
src/assets/automata.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
74
src/index.css
Normal file
74
src/index.css
Normal file
@ -0,0 +1,74 @@
|
||||
@import "tailwindcss";
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button.defaultBtn {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
13
src/lib/CPUS/index.ts
Normal file
13
src/lib/CPUS/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Play, PLAYER_OPTIONS, PlayerAction } from "../game";
|
||||
|
||||
export type CPUNAME = "CPU Random" | "CPU Always Rock";
|
||||
export const CPUS_NAMES: CPUNAME[] = ["CPU Random", "CPU Always Rock"];
|
||||
|
||||
export const CPU_FUNCTION_MAPING: Record<CPUNAME, (plays: Play[]) => PlayerAction> = {
|
||||
"CPU Random": CpuRandom,
|
||||
"CPU Always Rock": () => 'Rock',
|
||||
}
|
||||
|
||||
function CpuRandom(): PlayerAction {
|
||||
return PLAYER_OPTIONS[Math.floor(Math.random() * PLAYER_OPTIONS.length)];
|
||||
}
|
44
src/lib/game.ts
Normal file
44
src/lib/game.ts
Normal file
@ -0,0 +1,44 @@
|
||||
export type PlayerAction = 'Rock' | 'Paper' | 'Scissors' | 'Lizard' | 'Spock';
|
||||
export const PLAYER_OPTIONS: PlayerAction[] = ['Rock', 'Paper', 'Scissors', 'Lizard', 'Spock']
|
||||
|
||||
export type PlayResult = 'user' | 'cpu' | 'tie';
|
||||
|
||||
export interface Play {
|
||||
user: PlayerAction;
|
||||
cpu: PlayerAction;
|
||||
winner: PlayResult,
|
||||
}
|
||||
|
||||
const ResultMappings = {
|
||||
"Scissors": ["Paper", "Lizard"],
|
||||
"Paper": ["Rock", "Spock"],
|
||||
"Rock": ["Scissors", "Lizard"],
|
||||
"Lizard": ["Paper", "Spock"],
|
||||
"Spock": ["Scissors", "Rock"],
|
||||
};
|
||||
|
||||
export function GetResult(cpu: PlayerAction, user: PlayerAction): Play {
|
||||
if (cpu === user) {
|
||||
return {
|
||||
user,
|
||||
cpu,
|
||||
winner: 'tie',
|
||||
};
|
||||
}
|
||||
|
||||
if (ResultMappings[user].includes(cpu)) {
|
||||
return {
|
||||
user,
|
||||
cpu,
|
||||
winner: 'user',
|
||||
};
|
||||
}
|
||||
if (ResultMappings[cpu].includes(user)) {
|
||||
return {
|
||||
user,
|
||||
cpu,
|
||||
winner: 'cpu',
|
||||
};
|
||||
}
|
||||
throw 'unrechable'
|
||||
}
|
122
src/lib/store.tsx
Normal file
122
src/lib/store.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { CPU_FUNCTION_MAPING, CPUNAME } from './CPUS';
|
||||
import { GetResult, Play, PlayerAction, PlayResult } from './game';
|
||||
|
||||
export interface SessionInfoPlayResultOver {
|
||||
type: 'over',
|
||||
winner: PlayResult,
|
||||
game: Game;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
currentUser: string | undefined;
|
||||
// Username -> number of games won
|
||||
scoreboard: Record<string, number>;
|
||||
currentGame: Game | undefined;
|
||||
|
||||
reset: () => void;
|
||||
setUser: (user: string | undefined) => void;
|
||||
startGame: (cpu: CPUNAME) => void;
|
||||
play: (action: PlayerAction) =>
|
||||
| SessionInfoPlayResultOver
|
||||
| { type: 'play', play: Play };
|
||||
}
|
||||
|
||||
export interface Game {
|
||||
cpu: CPUNAME;
|
||||
plays: Play[];
|
||||
}
|
||||
|
||||
export const useSession = create<SessionInfo>()(persist<SessionInfo>((set) => ({
|
||||
currentUser: undefined,
|
||||
scoreboard: {},
|
||||
currentGame: undefined,
|
||||
|
||||
reset: () => set({ currentGame: undefined, currentUser: undefined, scoreboard: {} }),
|
||||
setUser: (user) => set(() => {
|
||||
if (user === undefined) {
|
||||
return { currentUser: undefined, currentGame: undefined };
|
||||
};
|
||||
return { currentUser: user }
|
||||
}),
|
||||
startGame: (cpu) => set((state) => {
|
||||
if (state.currentGame !== undefined) {
|
||||
throw new Error("Game already in progress");
|
||||
}
|
||||
return {
|
||||
currentGame: {
|
||||
cpu,
|
||||
plays: []
|
||||
}
|
||||
};
|
||||
}),
|
||||
play: (action) => {
|
||||
let result:
|
||||
| undefined
|
||||
| { type: 'over', winner: 'user' | 'cpu' | 'tie', game: Game }
|
||||
| { type: 'play', play: Play }
|
||||
= undefined;
|
||||
set((state) => {
|
||||
if (!state.currentGame || !state.currentUser) throw new Error("Game current not in play");
|
||||
|
||||
const cpuName = state.currentGame.cpu;
|
||||
const plays = state.currentGame.plays;
|
||||
const cpuFn = CPU_FUNCTION_MAPING[cpuName];
|
||||
const cpuPlay = cpuFn(plays);
|
||||
|
||||
const res = GetResult(cpuPlay, action);
|
||||
plays.push(res);
|
||||
|
||||
// TODO: maybe make the best of out a configurable
|
||||
if (plays.length === 3) {
|
||||
let winner: 'tie' | 'user' | 'cpu' = 'tie';
|
||||
let cpu = 0;
|
||||
let user = 0;
|
||||
for (const play of plays) {
|
||||
if (play.winner === 'cpu') {
|
||||
cpu++;
|
||||
} else if (play.winner === 'user') {
|
||||
user++;
|
||||
}
|
||||
}
|
||||
if (cpu > user) {
|
||||
winner = 'cpu';
|
||||
} else if (user > cpu) {
|
||||
winner = 'user';
|
||||
}
|
||||
result = {
|
||||
type: 'over',
|
||||
winner,
|
||||
game: { ...state.currentGame },
|
||||
}
|
||||
|
||||
const scoreboard = state.scoreboard;
|
||||
|
||||
if (winner === 'user') {
|
||||
scoreboard[state.currentUser] = scoreboard[state.currentUser] ? scoreboard[state.currentUser] + 1 : 1;
|
||||
} else if (winner === 'cpu') {
|
||||
scoreboard[cpuName] = scoreboard[cpuName] ? scoreboard[cpuName] + 1 : 1;
|
||||
}
|
||||
|
||||
return {
|
||||
currentGame: undefined,
|
||||
scoreboard: { ...scoreboard },
|
||||
}
|
||||
}
|
||||
|
||||
result = {
|
||||
type: 'play',
|
||||
play: res,
|
||||
};
|
||||
|
||||
return {
|
||||
currentGame: { ...state.currentGame, plays: [...plays] }
|
||||
};
|
||||
})
|
||||
if (!result) throw new Error('unrechable');
|
||||
return result;
|
||||
},
|
||||
}), {
|
||||
name: 'session-info',
|
||||
}))
|
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App/App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
26
tsconfig.app.json
Normal file
26
tsconfig.app.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
8
vite.config.ts
Normal file
8
vite.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwind from '@tailwindcss/vite';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwind()],
|
||||
})
|
Loading…
Reference in New Issue
Block a user