created hangman game

This commit is contained in:
bilulib
2025-02-23 23:02:36 +01:00
parent aa9bf6ebbd
commit 6799103f96
19 changed files with 2834 additions and 1 deletions

24
.gitignore vendored Normal file
View 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?

View File

@ -1,2 +1,50 @@
# react_hangman # React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## 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,
},
})
```

28
eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
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'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
index.html Normal file
View 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>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "hangman",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.22.0",
"vite": "^6.1.0"
}
}

1990
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1
public/vite.svg Normal file
View 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

101
src/App.tsx Normal file
View File

@ -0,0 +1,101 @@
import { useCallback, useEffect, useState } from "react";
import { HangmanDrawing } from "./HangmanDrawing";
import { HangmanWord } from "./HangmanWord";
import { Keyboard } from "./Keyboard";
import words from "./wordList.json";
function getWord() {
return words[Math.floor(Math.random() * words.length)];
}
function App() {
const [wordToGuess, setWordToGuess] = useState(getWord);
const [guessedLetters, setGuessedLetters] = useState<string[]>([]);
const incorrectLetters = guessedLetters.filter(
(letter) => !wordToGuess.includes(letter)
);
const isLoser = incorrectLetters.length >= 6;
const isWinner = wordToGuess
.split("")
.every((letter) => guessedLetters.includes(letter));
const addGuessedLetter = useCallback(
(letter: string) => {
if (guessedLetters.includes(letter) || isLoser || isWinner) return;
setGuessedLetters((currentLetters) => [...currentLetters, letter]);
},
[guessedLetters, isWinner, isLoser]
);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const key = e.key;
if (!key.match(/^[a-zäöü]$/)) return;
e.preventDefault();
addGuessedLetter(key);
};
document.addEventListener("keypress", handler);
return () => {
document.removeEventListener("keypress", handler);
};
}, [guessedLetters]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const key = e.key;
if (key !== "Enter") return;
e.preventDefault();
setGuessedLetters([]);
setWordToGuess(getWord());
};
document.addEventListener("keypress", handler);
return () => {
document.removeEventListener("keypress", handler);
};
}, []);
return (
<div
style={{
maxWidth: "800px",
display: "flex",
flexDirection: "column",
gap: "2rem",
margin: "0 auto",
alignItems: "center",
}}
>
<div style={{ fontSize: "2rem", textAlign: "center" }}>
{isWinner && "Winner! - Refresh to try again"}
{isLoser && "Nice Try - Refresh to try again"}
</div>
<HangmanDrawing numberOfGuesses={incorrectLetters.length} />
<HangmanWord
reveal={isLoser}
guessedLetters={guessedLetters}
wordToGuess={wordToGuess}
/>
<div style={{ alignSelf: "stretch" }}>
<Keyboard
disabled={isWinner || isLoser}
activeLetters={guessedLetters.filter((letter) =>
wordToGuess.includes(letter)
)}
inactiveLetters={incorrectLetters}
addGuessedLetter={addGuessedLetter}
/>
</div>
</div>
);
}
export default App;

129
src/HangmanDrawing.tsx Normal file
View File

@ -0,0 +1,129 @@
const HEAD = (
<div
style={{
width: "50px",
height: "50px",
borderRadius: "100%",
border: "10px solid black",
position: "absolute",
top: "50px",
right: "-30px",
}}
/>
);
const BODY = (
<div
style={{
width: "10px",
height: "100px",
background: "black",
position: "absolute",
top: "120px",
right: 0,
}}
/>
);
const RIGHT_ARM = (
<div
style={{
width: "100px",
height: "10px",
background: "black",
position: "absolute",
top: "150px",
right: "-100px",
transform: "rotate(-30deg)",
transformOrigin: "left bottom",
}}
/>
);
const LEFT_ARM = (
<div
style={{
width: "100px",
height: "10px",
background: "black",
position: "absolute",
top: "150px",
right: "10px",
transform: "rotate(30deg)",
transformOrigin: "right bottom",
}}
/>
);
const RIGHT_LEG = (
<div
style={{
width: "100px",
height: "10px",
background: "black",
position: "absolute",
top: "210px",
right: "-90px",
transform: "rotate(60deg)",
transformOrigin: "left bottom",
}}
/>
);
const LEFT_LEG = (
<div
style={{
width: "100px",
height: "10px",
background: "black",
position: "absolute",
top: "210px",
right: 0,
transform: "rotate(-60deg)",
transformOrigin: "right bottom",
}}
/>
);
const BODY_PARTS = [HEAD, BODY, RIGHT_ARM, LEFT_ARM, RIGHT_LEG, LEFT_LEG];
type HanmgmanDrawingProbs = {
numberOfGuesses: number;
};
export function HangmanDrawing({ numberOfGuesses }: HanmgmanDrawingProbs) {
return (
<div style={{ position: "relative" }}>
{BODY_PARTS.slice(0, numberOfGuesses)}
<div
style={{
height: "50px",
width: "10px",
backgroundColor: "black",
top: "0",
right: "0",
position: "absolute",
}}
/>
<div
style={{
height: "10px",
width: "200px",
backgroundColor: "black",
marginLeft: "120px",
}}
/>
<div
style={{
height: "400px",
width: "10px",
backgroundColor: "black",
marginLeft: "120px",
}}
/>
<div
style={{ height: "10px", width: "250px", backgroundColor: "black" }}
/>
</div>
);
}

41
src/HangmanWord.tsx Normal file
View File

@ -0,0 +1,41 @@
type HangmanWordProps = {
guessedLetters: string[];
wordToGuess: string;
reveal?: boolean;
};
export function HangmanWord({
guessedLetters,
wordToGuess,
reveal = false,
}: HangmanWordProps) {
return (
<div
style={{
display: "flex",
gap: ".25rem",
fontSize: "6rem",
fontWeight: "bold",
textTransform: "uppercase",
fontFamily: "monospace",
}}
>
{wordToGuess.split("").map((letter, index) => (
<span style={{ borderBottom: ".5rem solid black" }} key={index}>
<span
style={{
visibility:
guessedLetters.includes(letter) || reveal
? "visible"
: "hidden",
color:
!guessedLetters.includes(letter) && reveal ? "red" : "black",
}}
>
{letter}
</span>
</span>
))}
</div>
);
}

26
src/Keyboard.module.css Normal file
View File

@ -0,0 +1,26 @@
.btn {
width: 100%;
border: 3px solid black;
background: none;
aspect-ratio: 1 / 1;
font-size: 2.5rem;
text-transform: uppercase;
padding: 0.5rem;
font-weight: bold;
cursor: pointer;
color: black;
}
.btn.active {
background-color: hsl(200, 100%, 50%);
color: white;
}
.btn.inactive {
opacity: 0.3;
}
.btn:hover:not(:disabled),
.btn:focus:not(:disabled) {
background-color: hsl(200, 100%, 75%);
}

74
src/Keyboard.tsx Normal file
View File

@ -0,0 +1,74 @@
import styles from "./Keyboard.module.css";
const KEYS = [
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
"Ä",
"Ö",
"Ü",
];
type KeyboardProps = {
disabled?: boolean;
activeLetters: string[];
inactiveLetters: string[];
addGuessedLetter: (letter: string) => void;
};
export function Keyboard({
activeLetters,
inactiveLetters,
addGuessedLetter,
disabled = false,
}: KeyboardProps) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(75px, 1fr))",
gap: ".5rem",
}}
>
{KEYS.map((key) => {
const isActive = activeLetters.includes(key);
const isInactive = inactiveLetters.includes(key);
return (
<button
onClick={() => addGuessedLetter(key)}
className={`${styles.btn} ${isActive ? styles.active : ""} ${
isInactive ? styles.inactive : ""
}`}
disabled={isInactive || isActive || disabled}
key={key}
>
{key}
</button>
);
})}
</div>
);
}

9
src/main.tsx Normal file
View File

@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

255
src/wordList.json Normal file
View File

@ -0,0 +1,255 @@
[
"APFEL",
"BANANE",
"BAUM",
"AUTO",
"HAUS",
"HUND",
"KATZE",
"SONNE",
"MOND",
"STERN",
"BLUME",
"VOGEL",
"WASSER",
"FEUER",
"ERDE",
"LUFT",
"HIMMEL",
"BROT",
"BUTTER",
"MILCH",
"KÄSE",
"EI",
"ZUCKER",
"SALZ",
"TISCH",
"STUHL",
"BETT",
"TÜR",
"FENSTER",
"WAND",
"HAND",
"FUSS",
"KOPF",
"AUGE",
"OHR",
"NASE",
"MUND",
"HERZ",
"BAUCH",
"RÜCKEN",
"PFERD",
"KUH",
"SCHAF",
"HUHN",
"ENTE",
"FISCH",
"BIENE",
"MAUS",
"ELEFANT",
"TIGER",
"ZEBRA",
"AFFE",
"BÄR",
"FROSCH",
"SCHULE",
"LEHRER",
"BUCH",
"HEFT",
"STIFT",
"LINEAL",
"RADIERGUMMI",
"TAFEL",
"KREIDE",
"KLASSENZIMMER",
"SCHÜLER",
"TASCHE",
"RUCKSACK",
"SPORT",
"BALL",
"SPRINGSEIL",
"RAD",
"ROLLER",
"SCHWIMMEN",
"LAUFEN",
"HÜPFEN",
"SPRINGEN",
"KLETTERN",
"RENNEN",
"SCHLITTEN",
"SKI",
"SCHNEE",
"REGEN",
"WOLKE",
"STURM",
"DONNER",
"BLITZ",
"WIESE",
"WALD",
"FLUSS",
"BERG",
"STRAND",
"MEER",
"SEE",
"SAND",
"MUSCHEL",
"FELSEN",
"STEIN",
"HOLZ",
"BLATT",
"AST",
"FRÜHLING",
"SOMMER",
"HERBST",
"WINTER",
"TAG",
"NACHT",
"UHR",
"ZEIT",
"WOCHE",
"MONAT",
"JAHR",
"GEBURTSTAG",
"FEIER",
"FREUND",
"FAMILIE",
"MUTTER",
"VATER",
"OMA",
"OPA",
"BRUDER",
"SCHWESTER",
"TANTE",
"ONKEL",
"COUSIN",
"COUSINE",
"BABY",
"KIND",
"ERWACHSENER",
"ARZT",
"POLIZEI",
"FEUERWEHR",
"BUS",
"ZUG",
"FLUGZEUG",
"SCHIFF",
"FAHRRAD",
"MOTORRAD",
"AMPEL",
"STRASSE",
"BRÜCKE",
"TUNNEL",
"KINO",
"SPIELPLATZ",
"PARK",
"ZOO",
"MUSEUM",
"BIBLIOTHEK",
"SUPERMARKT",
"LADEN",
"MARKT",
"GARTEN",
"SCHLOSS",
"BURG",
"KIRCHE",
"TURM",
"DORF",
"STADT",
"LAND",
"INSEL",
"PLANET",
"WELT",
"UNIVERSUM",
"ROBOTER",
"COMPUTER",
"FERNSEHER",
"RADIO",
"HANDY",
"TABLET",
"INTERNET",
"E-MAIL",
"POST",
"BRIEF",
"KARTE",
"PAKET",
"BASTELN",
"MALEN",
"ZEICHNEN",
"SCHNEIDEN",
"KLEBEN",
"FALTEN",
"KOCHEN",
"BACKEN",
"GRILLEN",
"ESSEN",
"TRINKEN",
"SCHLAFEN",
"TRÄUMEN",
"LÄCHELN",
"WEINEN",
"LACHEN",
"FREUDE",
"TRAURIGKEIT",
"ANGST",
"MUT",
"LIEBE",
"HASS",
"FREUNDSCHAFT",
"STREIT",
"VERSÖHNUNG",
"DANKE",
"BITTE",
"ENTSCHULDIGUNG",
"HALLO",
"TSCHÜSS",
"JA",
"NEIN",
"VIELLEICHT",
"WARUM",
"WEIL",
"WIESO",
"LESEN",
"SCHREIBEN",
"RECHNEN",
"ZÄHLEN",
"MESSEN",
"WIEGEN",
"WISSEN",
"LERNEN",
"DENKEN",
"FRAGEN",
"ANTWORTEN",
"ERZÄHLEN",
"SPRECHEN",
"FLÜSTERN",
"RUFEN",
"SCHREIEN",
"SINGEN",
"HÖREN",
"SEHEN",
"FÜHLEN",
"BERÜHREN",
"RIECHEN",
"SCHMECKEN",
"KLETTERN",
"SPRINGEN",
"TANZEN",
"RENNEN",
"FAULENZEN",
"TRAGEN",
"ZIEHEN",
"DRÜCKEN",
"WERFEN",
"FANGEN",
"SCHLAGEN",
"TRETEN",
"HEBEN",
"SENKEN",
"BEWEGEN",
"STILLSTEHEN",
"SITZEN",
"STEHEN",
"LIEGEN",
"KUSCHELN"
]

26
tsconfig.app.json Normal file
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View 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"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})