Build Evil Wordle app
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
|
*.err.log
|
||||||
|
.env
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
*.err.log
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
33
README.md
Normal file
33
README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Evil Wordle
|
||||||
|
|
||||||
|
A dark Wordle-style browser game with daily, infinite, daily evil, custom length, and super evil modes.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the local URL printed by Vite.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The compose service publishes the app on `0.0.0.0:6666`.
|
||||||
|
|
||||||
|
The app is built with React, Tailwind CSS, and Vite. Progress, settings, and stats are stored in `localStorage`. Guess validation uses `word-list-json`; curated local word buckets in `src/data/words.ts` control target selection and provide the fallback pool.
|
||||||
6
docker-compose.yml
Normal file
6
docker-compose.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
services:
|
||||||
|
evil-wordle:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:6666:80"
|
||||||
|
restart: unless-stopped
|
||||||
33
eslint.config.js
Normal file
33
eslint.config.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import tsParser from '@typescript-eslint/parser';
|
||||||
|
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'@typescript-eslint': tsPlugin,
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
...tsPlugin.configs.recommended.rules,
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
36
index.html
Normal file
36
index.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#07070a" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Evil Wordle is a darker, harder Wordle-style browser game with daily, infinite, custom, and super evil modes."
|
||||||
|
/>
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="Evil Wordle" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="A polished Wordle-like game with daily puzzles, infinite levels, memory challenges, and super evil long-word mode."
|
||||||
|
/>
|
||||||
|
<meta property="og:site_name" content="Evil Wordle" />
|
||||||
|
<meta property="og:url" content="https://evil-wordle.spaceistyping.com/" />
|
||||||
|
<meta property="og:image" content="https://evil-wordle.spaceistyping.com/icon-512.png" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Evil Wordle" />
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content="Daily Wordle, infinite levels, memory mode, and long-word punishment in one dark browser game."
|
||||||
|
/>
|
||||||
|
<meta name="twitter:image" content="https://evil-wordle.spaceistyping.com/icon-512.png" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<title>Evil Wordle</title>
|
||||||
|
<script defer src="https://not-a-tracker.reversed.dev/script.js" data-website-id="3dc7236e-062d-491e-9c14-635df3dde681"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
nginx.conf
Normal file
16
nginx.conf
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(?:css|js|png|jpg|jpeg|gif|svg|webp|ico)$ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
3969
package-lock.json
generated
Normal file
3969
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "evil-wordle",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"postcss": "^8.5.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.8.0",
|
||||||
|
"vite": "^7.0.0",
|
||||||
|
"word-list-json": "^0.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||||
|
"@typescript-eslint/parser": "^8.0.0",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.0",
|
||||||
|
"globals": "^15.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/icon-192.png
Normal file
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 340 KiB |
361
src/App.tsx
Normal file
361
src/App.tsx
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Moon, RotateCcw } from 'lucide-react';
|
||||||
|
import { AccessWarning } from './components/AccessWarning';
|
||||||
|
import { Board } from './components/Board';
|
||||||
|
import { GameOverPanel } from './components/GameOverPanel';
|
||||||
|
import { Keyboard } from './components/Keyboard';
|
||||||
|
import { ModeSelector } from './components/ModeSelector';
|
||||||
|
import { SettingsPanel } from './components/SettingsPanel';
|
||||||
|
import { StatsPanel } from './components/StatsPanel';
|
||||||
|
import { DEFAULT_SETTINGS, MODE_LABELS, getMaxGuesses } from './lib/modes';
|
||||||
|
import { useLocalStorage } from './lib/storage';
|
||||||
|
import {
|
||||||
|
getDateKey,
|
||||||
|
getInfiniteLength,
|
||||||
|
getSuperEvilLength,
|
||||||
|
isValidGuess,
|
||||||
|
mergeKeyboardState,
|
||||||
|
normalizeGuess,
|
||||||
|
pickDailyWord,
|
||||||
|
pickRandomWord,
|
||||||
|
} from './lib/wordUtils';
|
||||||
|
import type { GameMode, GameStatus, SavedProgress, Settings, Stats } from './types';
|
||||||
|
|
||||||
|
type Session = {
|
||||||
|
mode: GameMode;
|
||||||
|
target: string;
|
||||||
|
guesses: string[];
|
||||||
|
currentGuess: string;
|
||||||
|
status: GameStatus;
|
||||||
|
level: number;
|
||||||
|
isPractice: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialStats: Stats = {
|
||||||
|
gamesPlayed: 0,
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
currentStreak: 0,
|
||||||
|
bestStreak: 0,
|
||||||
|
infiniteLevel: 1,
|
||||||
|
infiniteWins: 0,
|
||||||
|
infiniteLosses: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getModeLength = (mode: GameMode, settings: Settings, level: number, practice: boolean) => {
|
||||||
|
if (mode === 'custom') return settings.wordLength;
|
||||||
|
if (mode === 'infinite') return getInfiniteLength(level);
|
||||||
|
if (mode === 'super-evil') return practice ? getSuperEvilLength() : 10 + (level % 6);
|
||||||
|
return 5;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSession = (
|
||||||
|
mode: GameMode,
|
||||||
|
settings: Settings,
|
||||||
|
level = 1,
|
||||||
|
isPractice = false,
|
||||||
|
): Session => {
|
||||||
|
const wordLength = getModeLength(mode, settings, level, isPractice);
|
||||||
|
const target =
|
||||||
|
mode === 'daily' && !isPractice
|
||||||
|
? pickDailyWord(wordLength, 'daily')
|
||||||
|
: mode === 'evil-daily' && !isPractice
|
||||||
|
? pickDailyWord(wordLength, 'evil')
|
||||||
|
: pickRandomWord(wordLength);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
target,
|
||||||
|
guesses: [],
|
||||||
|
currentGuess: '',
|
||||||
|
status: 'playing',
|
||||||
|
level,
|
||||||
|
isPractice,
|
||||||
|
message: mode === 'super-evil' ? 'Super Evil gives fewer guesses and very long words.' : '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreSession = (saved: SavedProgress): Session => ({
|
||||||
|
mode: saved.mode,
|
||||||
|
target: saved.target,
|
||||||
|
guesses: saved.guesses,
|
||||||
|
currentGuess: '',
|
||||||
|
status: saved.status,
|
||||||
|
level: saved.level,
|
||||||
|
isPractice: saved.isPractice,
|
||||||
|
message: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const canRestore = (saved: SavedProgress | null) => {
|
||||||
|
if (!saved) return false;
|
||||||
|
if ((saved.mode === 'daily' || saved.mode === 'evil-daily') && !saved.isPractice) {
|
||||||
|
return saved.dateKey === getDateKey();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [settings, setSettings] = useLocalStorage<Settings>('evil-wordle-settings', DEFAULT_SETTINGS);
|
||||||
|
const [stats, setStats] = useLocalStorage<Stats>('evil-wordle-stats', initialStats);
|
||||||
|
const [savedProgress, setSavedProgress] = useLocalStorage<SavedProgress | null>('evil-wordle-progress', null);
|
||||||
|
const [shake, setShake] = useState(false);
|
||||||
|
const [session, setSession] = useState<Session>(() =>
|
||||||
|
canRestore(savedProgress) ? restoreSession(savedProgress as SavedProgress) : createSession('daily', settings),
|
||||||
|
);
|
||||||
|
|
||||||
|
const wordLength = session.target.length;
|
||||||
|
const maxGuesses = useMemo(
|
||||||
|
() => getMaxGuesses(session.mode, wordLength, settings.difficulty),
|
||||||
|
[session.mode, settings.difficulty, wordLength],
|
||||||
|
);
|
||||||
|
const evilMemoryPhase = session.mode === 'evil-daily' && session.guesses.length >= 2;
|
||||||
|
const keyboardStates = useMemo(
|
||||||
|
() => (evilMemoryPhase ? new Map() : mergeKeyboardState(session.guesses, session.target)),
|
||||||
|
[evilMemoryPhase, session.guesses, session.target],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSavedProgress({
|
||||||
|
dateKey: getDateKey(),
|
||||||
|
mode: session.mode,
|
||||||
|
target: session.target,
|
||||||
|
guesses: session.guesses,
|
||||||
|
status: session.status,
|
||||||
|
level: session.level,
|
||||||
|
isPractice: session.isPractice,
|
||||||
|
});
|
||||||
|
}, [session, setSavedProgress]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shake) return undefined;
|
||||||
|
const timer = window.setTimeout(() => setShake(false), 300);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [shake]);
|
||||||
|
|
||||||
|
const startNewSession = useCallback(
|
||||||
|
(mode: GameMode, level = mode === 'infinite' ? stats.infiniteLevel : 1, practice = false) => {
|
||||||
|
setSession(createSession(mode, settings, level, practice));
|
||||||
|
},
|
||||||
|
[settings, stats.infiniteLevel],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateStatsForResult = useCallback(
|
||||||
|
(status: GameStatus, mode: GameMode, level: number) => {
|
||||||
|
if (status === 'playing') return;
|
||||||
|
setStats((current) => {
|
||||||
|
const won = status === 'won';
|
||||||
|
const nextStreak = won ? current.currentStreak + 1 : 0;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
gamesPlayed: current.gamesPlayed + 1,
|
||||||
|
wins: current.wins + (won ? 1 : 0),
|
||||||
|
losses: current.losses + (won ? 0 : 1),
|
||||||
|
currentStreak: nextStreak,
|
||||||
|
bestStreak: Math.max(current.bestStreak, nextStreak),
|
||||||
|
infiniteLevel:
|
||||||
|
mode === 'infinite' && won ? Math.max(current.infiniteLevel, level + 1) : current.infiniteLevel,
|
||||||
|
infiniteWins: current.infiniteWins + (mode === 'infinite' && won ? 1 : 0),
|
||||||
|
infiniteLosses: current.infiniteLosses + (mode === 'infinite' && !won ? 1 : 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setStats],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitGuess = useCallback(() => {
|
||||||
|
if (session.status !== 'playing') return;
|
||||||
|
const guess = normalizeGuess(session.currentGuess);
|
||||||
|
|
||||||
|
if (guess.length !== session.target.length) {
|
||||||
|
setShake(true);
|
||||||
|
setSession((current) => ({ ...current, message: `Enter ${session.target.length} letters.` }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidGuess(guess, session.target.length)) {
|
||||||
|
setShake(true);
|
||||||
|
setSession((current) => ({ ...current, message: 'That word is not in the word list.' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guesses = [...session.guesses, guess];
|
||||||
|
const status: GameStatus = guess === session.target ? 'won' : guesses.length >= maxGuesses ? 'lost' : 'playing';
|
||||||
|
updateStatsForResult(status, session.mode, session.level);
|
||||||
|
|
||||||
|
setSession({
|
||||||
|
...session,
|
||||||
|
guesses,
|
||||||
|
currentGuess: '',
|
||||||
|
status,
|
||||||
|
message:
|
||||||
|
status === 'won'
|
||||||
|
? 'Result recorded.'
|
||||||
|
: status === 'lost'
|
||||||
|
? 'No guesses remain.'
|
||||||
|
: session.mode === 'evil-daily' && guesses.length === 2
|
||||||
|
? 'Memory phase begins now.'
|
||||||
|
: '',
|
||||||
|
});
|
||||||
|
}, [maxGuesses, session, updateStatsForResult]);
|
||||||
|
|
||||||
|
const handleKey = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
if (session.status !== 'playing') return;
|
||||||
|
|
||||||
|
if (key === 'Enter') {
|
||||||
|
submitGuess();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'Backspace' || key === 'Delete') {
|
||||||
|
setSession((current) => ({ ...current, currentGuess: current.currentGuess.slice(0, -1), message: '' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clean = normalizeGuess(key);
|
||||||
|
if (clean.length !== 1) return;
|
||||||
|
|
||||||
|
setSession((current) => {
|
||||||
|
if (current.currentGuess.length >= current.target.length) return current;
|
||||||
|
return { ...current, currentGuess: current.currentGuess + clean, message: '' };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[session.status, submitGuess],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||||
|
if (event.key === 'Enter' || event.key === 'Backspace' || /^[a-zA-Z]$/.test(event.key)) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleKey(event.key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [handleKey]);
|
||||||
|
|
||||||
|
const changeMode = (mode: GameMode) => startNewSession(mode);
|
||||||
|
|
||||||
|
const changeSettings = (nextSettings: Settings) => {
|
||||||
|
setSettings(nextSettings);
|
||||||
|
if (session.mode === 'custom') {
|
||||||
|
setSession(createSession('custom', nextSettings, 1, false));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restart = () => {
|
||||||
|
if (session.mode === 'daily' && !session.isPractice) {
|
||||||
|
startNewSession('daily', 1, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLevel = session.mode === 'infinite' && session.status === 'won' ? session.level + 1 : session.level;
|
||||||
|
startNewSession(session.mode, nextLevel, session.isPractice);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPractice = () => startNewSession('daily', 1, true);
|
||||||
|
|
||||||
|
const modeNote =
|
||||||
|
session.mode === 'infinite'
|
||||||
|
? `Level ${session.level}. Current word length: ${wordLength}.`
|
||||||
|
: session.mode === 'custom'
|
||||||
|
? `${wordLength} letter custom puzzle.`
|
||||||
|
: session.mode === 'super-evil'
|
||||||
|
? `${wordLength} letters, ${maxGuesses} guesses.`
|
||||||
|
: session.mode === 'evil-daily'
|
||||||
|
? evilMemoryPhase
|
||||||
|
? 'Keyboard and feedback are hidden.'
|
||||||
|
: 'Two guesses before the memory phase.'
|
||||||
|
: session.isPractice
|
||||||
|
? 'Practice round.'
|
||||||
|
: `Daily seed: ${getDateKey()}.`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-slate-950 text-slate-100">
|
||||||
|
<AccessWarning />
|
||||||
|
<div className="fixed inset-0 -z-10 bg-[radial-gradient(circle_at_18%_0%,rgba(244,63,94,0.18),transparent_34%),radial-gradient(circle_at_90%_18%,rgba(14,165,233,0.10),transparent_28%),linear-gradient(180deg,#09090d_0%,#020617_100%)]" />
|
||||||
|
<div className="mx-auto grid min-h-screen w-full max-w-7xl gap-5 px-4 py-4 sm:px-6 lg:grid-cols-[21rem_minmax(0,1fr)_19rem] lg:py-6">
|
||||||
|
<aside className="space-y-4 lg:sticky lg:top-6 lg:self-start">
|
||||||
|
<header className="rounded-xl border border-slate-800/90 bg-slate-950/85 p-4 shadow-2xl shadow-black/20 backdrop-blur">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="grid h-14 w-14 shrink-0 place-items-center overflow-hidden rounded-xl border border-rose-300/20 bg-slate-900 shadow-ember">
|
||||||
|
<img alt="Evil Wordle" className="h-full w-full object-cover" src="/icon-192.png" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-black text-white">Evil Wordle</h1>
|
||||||
|
<p className="mt-1 text-xs font-bold uppercase tracking-[0.18em] text-rose-200">Wordle, but meaner</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div className="rounded-md bg-slate-900/80 px-2 py-2">
|
||||||
|
<div className="text-base font-black text-white">{maxGuesses}</div>
|
||||||
|
<div className="text-[0.62rem] font-bold uppercase text-slate-500">Guesses</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-slate-900/80 px-2 py-2">
|
||||||
|
<div className="text-base font-black text-white">{wordLength}</div>
|
||||||
|
<div className="text-[0.62rem] font-bold uppercase text-slate-500">Letters</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-slate-900/80 px-2 py-2">
|
||||||
|
<div className="text-base font-black text-white">{session.guesses.length}</div>
|
||||||
|
<div className="text-[0.62rem] font-bold uppercase text-slate-500">Used</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<ModeSelector activeMode={session.mode} onSelect={changeMode} />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="flex min-w-0 flex-col justify-between gap-5 rounded-2xl border border-slate-800/90 bg-slate-900/55 p-3 shadow-2xl shadow-black/30 backdrop-blur sm:p-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-800 pb-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-black uppercase tracking-[0.18em] text-rose-200">
|
||||||
|
<Moon size={16} />
|
||||||
|
{MODE_LABELS[session.mode]}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">{modeNote}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-slate-700 bg-slate-950/70 px-3 py-2 text-sm font-black text-slate-100 hover:border-rose-400"
|
||||||
|
onClick={() => startNewSession(session.mode, session.level, session.isPractice)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col justify-center gap-5">
|
||||||
|
<Board
|
||||||
|
currentGuess={session.currentGuess}
|
||||||
|
guesses={session.guesses}
|
||||||
|
hideFeedback={evilMemoryPhase}
|
||||||
|
maxGuesses={maxGuesses}
|
||||||
|
shake={shake}
|
||||||
|
target={session.target}
|
||||||
|
wordLength={wordLength}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-h-6 text-center text-sm font-semibold text-rose-100">{session.message}</div>
|
||||||
|
|
||||||
|
<GameOverPanel
|
||||||
|
isPracticeAvailable={session.mode === 'daily' && !session.isPractice}
|
||||||
|
onPractice={startPractice}
|
||||||
|
onRestart={restart}
|
||||||
|
status={session.status}
|
||||||
|
target={session.target}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Keyboard hidden={evilMemoryPhase && session.status === 'playing'} letterStates={keyboardStates} onKey={handleKey} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="space-y-4 lg:sticky lg:top-6 lg:self-start">
|
||||||
|
<SettingsPanel onChange={changeSettings} settings={settings} />
|
||||||
|
<StatsPanel level={session.mode === 'infinite' ? session.level : stats.infiniteLevel} stats={stats} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/AccessWarning.tsx
Normal file
25
src/components/AccessWarning.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
const allowedHosts = new Set(['localhost', '127.0.0.1', '::1', 'evil-wordle.spaceistyping.com']);
|
||||||
|
|
||||||
|
const isAllowedHost = () => {
|
||||||
|
if (typeof window === 'undefined') return true;
|
||||||
|
return allowedHosts.has(window.location.hostname);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AccessWarning() {
|
||||||
|
if (isAllowedHost()) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-amber-400/30 bg-amber-400/10 px-4 py-3 text-amber-50">
|
||||||
|
<div className="mx-auto flex max-w-7xl items-start gap-3 text-sm">
|
||||||
|
<AlertTriangle className="mt-0.5 shrink-0 text-amber-300" size={18} />
|
||||||
|
<p>
|
||||||
|
This copy is being viewed from <span className="font-black">{window.location.host}</span>. The intended
|
||||||
|
locations are local development links like <span className="font-black">127.0.0.1</span> or{' '}
|
||||||
|
<span className="font-black">evil-wordle.spaceistyping.com</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/Board.tsx
Normal file
70
src/components/Board.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { evaluateGuess } from '../lib/wordUtils';
|
||||||
|
import type { GuessResult, LetterState } from '../types';
|
||||||
|
|
||||||
|
type BoardProps = {
|
||||||
|
guesses: string[];
|
||||||
|
currentGuess: string;
|
||||||
|
target: string;
|
||||||
|
maxGuesses: number;
|
||||||
|
wordLength: number;
|
||||||
|
hideFeedback: boolean;
|
||||||
|
shake: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateClasses: Record<LetterState, string> = {
|
||||||
|
correct: 'border-emerald-400 bg-emerald-500 text-slate-950',
|
||||||
|
present: 'border-amber-300 bg-amber-400 text-slate-950',
|
||||||
|
absent: 'border-slate-700 bg-slate-800 text-slate-300',
|
||||||
|
empty: 'border-slate-700/80 bg-slate-950/80 text-slate-100',
|
||||||
|
};
|
||||||
|
|
||||||
|
const hiddenResult = (guess: string): GuessResult[] =>
|
||||||
|
guess.split('').map((letter) => ({ letter, state: 'empty' }));
|
||||||
|
|
||||||
|
export function Board({ guesses, currentGuess, target, maxGuesses, wordLength, hideFeedback, shake }: BoardProps) {
|
||||||
|
const rows = Array.from({ length: maxGuesses }, (_, rowIndex) => {
|
||||||
|
const submitted = guesses[rowIndex];
|
||||||
|
const active = rowIndex === guesses.length;
|
||||||
|
const letters = submitted ?? (active ? currentGuess : '');
|
||||||
|
const result = submitted
|
||||||
|
? hideFeedback
|
||||||
|
? hiddenResult(submitted)
|
||||||
|
: evaluateGuess(submitted, target)
|
||||||
|
: Array.from({ length: wordLength }, (_, index) => ({
|
||||||
|
letter: letters[index] ?? '',
|
||||||
|
state: 'empty' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { result, active };
|
||||||
|
});
|
||||||
|
|
||||||
|
const tileSize = wordLength >= 10 ? 'clamp(1.45rem, 5vw, 2.6rem)' : 'clamp(2.35rem, 10vw, 3.7rem)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto grid w-full justify-center gap-2">
|
||||||
|
{rows.map((row, rowIndex) => (
|
||||||
|
<div
|
||||||
|
className={`grid justify-center gap-1.5 ${shake && row.active ? 'animate-shake' : ''}`}
|
||||||
|
style={{ gridTemplateColumns: `repeat(${wordLength}, ${tileSize})` }}
|
||||||
|
key={`row-${rowIndex}`}
|
||||||
|
>
|
||||||
|
{Array.from({ length: wordLength }, (_, columnIndex) => {
|
||||||
|
const tile = row.result[columnIndex] ?? { letter: '', state: 'empty' as const };
|
||||||
|
const wasSubmitted = Boolean(guesses[rowIndex]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`grid aspect-square place-items-center rounded-md border text-center font-black uppercase leading-none shadow-inner transition-colors ${
|
||||||
|
stateClasses[tile.state]
|
||||||
|
} ${wasSubmitted && !hideFeedback ? 'animate-flipTile' : ''}`}
|
||||||
|
style={{ animationDelay: `${columnIndex * 70}ms` }}
|
||||||
|
key={`${rowIndex}-${columnIndex}`}
|
||||||
|
>
|
||||||
|
<span className={wordLength >= 10 ? 'text-sm sm:text-lg' : 'text-xl sm:text-2xl'}>{tile.letter}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/components/GameOverPanel.tsx
Normal file
52
src/components/GameOverPanel.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { RotateCcw, Trophy } from 'lucide-react';
|
||||||
|
import type { GameStatus } from '../types';
|
||||||
|
|
||||||
|
type GameOverPanelProps = {
|
||||||
|
status: GameStatus;
|
||||||
|
target: string;
|
||||||
|
isPracticeAvailable: boolean;
|
||||||
|
onRestart: () => void;
|
||||||
|
onPractice: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GameOverPanel({ status, target, isPracticeAvailable, onRestart, onPractice }: GameOverPanelProps) {
|
||||||
|
if (status === 'playing') return null;
|
||||||
|
|
||||||
|
const won = status === 'won';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-rose-500/30 bg-slate-950/95 p-4 shadow-ember">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-md bg-rose-500/20 text-rose-200">
|
||||||
|
<Trophy size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-black text-slate-100">{won ? 'You survived.' : 'The word won.'}</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Target word: <span className="font-black uppercase text-rose-200">{target}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-rose-500 px-4 py-2 text-sm font-black text-white hover:bg-rose-400"
|
||||||
|
onClick={onRestart}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
Play again
|
||||||
|
</button>
|
||||||
|
{isPracticeAvailable && (
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-slate-700 px-4 py-2 text-sm font-black text-slate-100 hover:border-slate-500"
|
||||||
|
onClick={onPractice}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Practice round
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/Keyboard.tsx
Normal file
67
src/components/Keyboard.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Delete } from 'lucide-react';
|
||||||
|
import type { LetterState } from '../types';
|
||||||
|
|
||||||
|
type KeyboardProps = {
|
||||||
|
letterStates: Map<string, LetterState>;
|
||||||
|
onKey: (key: string) => void;
|
||||||
|
hidden: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
|
||||||
|
|
||||||
|
const keyClasses: Record<LetterState, string> = {
|
||||||
|
correct: 'bg-emerald-500 text-slate-950',
|
||||||
|
present: 'bg-amber-400 text-slate-950',
|
||||||
|
absent: 'bg-slate-800 text-slate-300',
|
||||||
|
empty: 'bg-slate-700/90 text-slate-100 hover:bg-slate-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Keyboard({ letterStates, onKey, hidden }: KeyboardProps) {
|
||||||
|
if (hidden) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-rose-500/30 bg-rose-950/30 px-4 py-4 text-center text-sm font-semibold text-rose-100">
|
||||||
|
Keyboard feedback is hidden.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-2">
|
||||||
|
{rows.map((row, rowIndex) => (
|
||||||
|
<div className="flex justify-center gap-1.5" key={row}>
|
||||||
|
{rowIndex === 2 && (
|
||||||
|
<button
|
||||||
|
className="min-h-11 rounded-md bg-slate-700 px-3 text-xs font-bold uppercase text-slate-100 hover:bg-slate-600"
|
||||||
|
onClick={() => onKey('Enter')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Enter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{row.split('').map((letter) => (
|
||||||
|
<button
|
||||||
|
className={`min-h-11 min-w-0 flex-1 rounded-md px-1 text-sm font-black uppercase transition-colors sm:text-base ${
|
||||||
|
keyClasses[letterStates.get(letter) ?? 'empty']
|
||||||
|
}`}
|
||||||
|
key={letter}
|
||||||
|
onClick={() => onKey(letter)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{rowIndex === 2 && (
|
||||||
|
<button
|
||||||
|
aria-label="Delete"
|
||||||
|
className="grid min-h-11 place-items-center rounded-md bg-slate-700 px-3 text-slate-100 hover:bg-slate-600"
|
||||||
|
onClick={() => onKey('Backspace')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Delete size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/components/ModeSelector.tsx
Normal file
44
src/components/ModeSelector.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Flame, Infinity as InfinityIcon, Ruler, Skull, Sun } from 'lucide-react';
|
||||||
|
import { MODE_DESCRIPTIONS, MODE_LABELS } from '../lib/modes';
|
||||||
|
import type { GameMode } from '../types';
|
||||||
|
|
||||||
|
type ModeSelectorProps = {
|
||||||
|
activeMode: GameMode;
|
||||||
|
onSelect: (mode: GameMode) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const modes: Array<{ mode: GameMode; icon: typeof Sun }> = [
|
||||||
|
{ mode: 'daily', icon: Sun },
|
||||||
|
{ mode: 'infinite', icon: InfinityIcon },
|
||||||
|
{ mode: 'evil-daily', icon: Flame },
|
||||||
|
{ mode: 'custom', icon: Ruler },
|
||||||
|
{ mode: 'super-evil', icon: Skull },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ModeSelector({ activeMode, onSelect }: ModeSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-1">
|
||||||
|
{modes.map(({ mode, icon: Icon }) => {
|
||||||
|
const active = mode === activeMode;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`rounded-lg border p-3 text-left transition ${
|
||||||
|
active
|
||||||
|
? 'border-rose-400 bg-rose-500/15 shadow-ember'
|
||||||
|
: 'border-slate-800 bg-slate-950/70 hover:border-slate-600'
|
||||||
|
}`}
|
||||||
|
key={mode}
|
||||||
|
onClick={() => onSelect(mode)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-sm font-black text-slate-100">
|
||||||
|
<Icon size={16} className={active ? 'text-rose-300' : 'text-slate-400'} />
|
||||||
|
{MODE_LABELS[mode]}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-xs leading-5 text-slate-400">{MODE_DESCRIPTIONS[mode]}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/SettingsPanel.tsx
Normal file
64
src/components/SettingsPanel.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { Difficulty, Settings } from '../types';
|
||||||
|
|
||||||
|
type SettingsPanelProps = {
|
||||||
|
settings: Settings;
|
||||||
|
onChange: (settings: Settings) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const difficulties: Array<{ value: Difficulty; label: string }> = [
|
||||||
|
{ value: 'wicked', label: 'Wicked' },
|
||||||
|
{ value: 'vicious', label: 'Vicious' },
|
||||||
|
{ value: 'nightmare', label: 'Nightmare' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SettingsPanel({ settings, onChange }: SettingsPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-800 bg-slate-950/70 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h2 className="text-sm font-black uppercase tracking-[0.2em] text-slate-300">Settings</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold uppercase text-slate-500" htmlFor="word-length">
|
||||||
|
Word length
|
||||||
|
</label>
|
||||||
|
<div className="mt-2 grid grid-cols-4 gap-2" id="word-length">
|
||||||
|
{[4, 5, 6, 7].map((length) => (
|
||||||
|
<button
|
||||||
|
className={`rounded-md border px-3 py-2 text-sm font-black ${
|
||||||
|
settings.wordLength === length
|
||||||
|
? 'border-rose-400 bg-rose-500/20 text-rose-100'
|
||||||
|
: 'border-slate-800 bg-slate-900 text-slate-300 hover:border-slate-600'
|
||||||
|
}`}
|
||||||
|
key={length}
|
||||||
|
onClick={() => onChange({ ...settings, wordLength: length as Settings['wordLength'] })}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{length}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold uppercase text-slate-500" htmlFor="difficulty">
|
||||||
|
Difficulty
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="mt-2 w-full rounded-md border border-slate-800 bg-slate-900 px-3 py-2 text-sm font-semibold text-slate-100 outline-none focus:border-rose-400"
|
||||||
|
id="difficulty"
|
||||||
|
onChange={(event) => onChange({ ...settings, difficulty: event.target.value as Difficulty })}
|
||||||
|
value={settings.difficulty}
|
||||||
|
>
|
||||||
|
{difficulties.map((difficulty) => (
|
||||||
|
<option key={difficulty.value} value={difficulty.value}>
|
||||||
|
{difficulty.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/StatsPanel.tsx
Normal file
34
src/components/StatsPanel.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Stats } from '../types';
|
||||||
|
|
||||||
|
type StatsPanelProps = {
|
||||||
|
stats: Stats;
|
||||||
|
level: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatsPanel({ stats, level }: StatsPanelProps) {
|
||||||
|
const items = [
|
||||||
|
['Played', stats.gamesPlayed],
|
||||||
|
['Wins', stats.wins],
|
||||||
|
['Losses', stats.losses],
|
||||||
|
['Streak', stats.currentStreak],
|
||||||
|
['Best', stats.bestStreak],
|
||||||
|
['Level', level],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-800 bg-slate-950/70 p-4">
|
||||||
|
<h2 className="text-sm font-black uppercase tracking-[0.2em] text-slate-300">Stats</h2>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||||
|
{items.map(([label, value]) => (
|
||||||
|
<div className="rounded-md border border-slate-800 bg-slate-900/80 p-2 text-center" key={label}>
|
||||||
|
<div className="text-lg font-black text-slate-100">{value}</div>
|
||||||
|
<div className="text-[0.65rem] font-bold uppercase text-slate-500">{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 rounded-md bg-slate-900/70 px-3 py-2 text-xs text-slate-400">
|
||||||
|
Infinite: {stats.infiniteWins} wins, {stats.infiniteLosses} losses
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/data/words.ts
Normal file
71
src/data/words.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import rawWordList from 'word-list-json/words.json';
|
||||||
|
|
||||||
|
export const WORDS_BY_LENGTH: Record<number, string[]> = {
|
||||||
|
4: [
|
||||||
|
'able', 'acid', 'aged', 'ally', 'arch', 'bake', 'bane', 'beam', 'bold', 'brim',
|
||||||
|
'cair', 'cave', 'clay', 'coil', 'cove', 'dare', 'dawn', 'dusk', 'echo', 'emir',
|
||||||
|
'fang', 'fate', 'fern', 'fizz', 'gale', 'gilt', 'glow', 'grim', 'haze', 'helm',
|
||||||
|
'hewn', 'howl', 'iron', 'jolt', 'keen', 'knot', 'lair', 'lurk', 'maze', 'mire',
|
||||||
|
'moon', 'nail', 'omen', 'onyx', 'pale', 'pier', 'pyre', 'rake', 'rift', 'rook',
|
||||||
|
'ruin', 'scar', 'shad', 'silt', 'sink', 'smog', 'snag', 'spit', 'thud', 'void',
|
||||||
|
],
|
||||||
|
5: [
|
||||||
|
'adore', 'aisle', 'altar', 'amber', 'ashen', 'atone', 'basil', 'bleak', 'blitz',
|
||||||
|
'brave', 'cabal', 'caper', 'charm', 'cider', 'coven', 'crane', 'crown', 'crypt',
|
||||||
|
'demon', 'dirge', 'dread', 'dwell', 'eerie', 'ember', 'fable', 'fiend', 'flint',
|
||||||
|
'forge', 'ghost', 'glare', 'gloom', 'grace', 'haunt', 'ivory', 'knife', 'leech',
|
||||||
|
'mirth', 'mourn', 'noble', 'olive', 'piety', 'prism', 'quirk', 'raven', 'reign',
|
||||||
|
'rhyme', 'rivet', 'shade', 'shard', 'shrew', 'siren', 'skulk', 'slate', 'smite',
|
||||||
|
'spare', 'spice', 'spite', 'stare', 'sting', 'stone', 'taunt', 'thorn', 'trace',
|
||||||
|
'twine', 'vapor', 'venom', 'vigil', 'viper', 'waltz', 'wight', 'witch', 'wrath',
|
||||||
|
],
|
||||||
|
6: [
|
||||||
|
'absent', 'arcane', 'balefy', 'beacon', 'bitter', 'candle', 'chisel', 'cinder',
|
||||||
|
'coffin', 'cursed', 'dagger', 'dismal', 'dragon', 'embers', 'enigma', 'falter',
|
||||||
|
'famine', 'fierce', 'goblet', 'gothic', 'guilty', 'harrow', 'hazard', 'hollow',
|
||||||
|
'impish', 'jagged', 'lament', 'legacy', 'malice', 'meteor', 'mirror', 'mortal',
|
||||||
|
'mystic', 'nebula', 'oracle', 'plague', 'poison', 'quartz', 'quiver', 'rancor',
|
||||||
|
'reaper', 'ritual', 'runics', 'savage', 'shadow', 'shriek', 'silver', 'sorrow',
|
||||||
|
'spider', 'spirit', 'talons', 'thorns', 'tombed', 'unholy', 'velvet', 'wicked',
|
||||||
|
],
|
||||||
|
7: [
|
||||||
|
'abyssal', 'ancient', 'banshee', 'betrayl', 'brimful', 'cadence', 'caldera',
|
||||||
|
'chalice', 'chimera', 'cloaked', 'crimson', 'cruelty', 'demonic', 'dragons',
|
||||||
|
'eclipse', 'emerald', 'fangirl', 'fateful', 'gallows', 'glimmer', 'grimace',
|
||||||
|
'harvest', 'haunted', 'hexagon', 'inferno', 'lantern', 'malison', 'midnite',
|
||||||
|
'monster', 'mystery', 'nightly', 'onyxian', 'ominous', 'phantom', 'puzzled',
|
||||||
|
'revenge', 'riddles', 'scarlet', 'serpent', 'shadows', 'shatter', 'specter',
|
||||||
|
'spitefu', 'torment', 'twisted', 'vampire', 'vengean', 'warlock', 'whisper',
|
||||||
|
],
|
||||||
|
10: [
|
||||||
|
'apparition', 'blackthorn', 'candlewick', 'cataclysmn', 'cryptogram', 'darkmatter',
|
||||||
|
'enchanting', 'ghastliest', 'graveyardx', 'shadowcast', 'nightshade', 'phantasmal',
|
||||||
|
],
|
||||||
|
11: [
|
||||||
|
'abomination', 'bloodcurdle', 'conjuration', 'dreadnought', 'maleficence', 'netherworld',
|
||||||
|
'spellbinder', 'thunderbolt', 'witchfinder', 'shadowlands',
|
||||||
|
],
|
||||||
|
12: [
|
||||||
|
'bewilderment', 'clairvoyance', 'consternated', 'disquietedly', 'horrifically',
|
||||||
|
'labyrinthine', 'maledictions', 'spectaculars', 'transylvania',
|
||||||
|
],
|
||||||
|
13: [
|
||||||
|
'bloodlettings', 'catastrophics', 'claustrophobe', 'phantasmagory', 'supernaturals',
|
||||||
|
'unpredictable',
|
||||||
|
],
|
||||||
|
14: [
|
||||||
|
'disenchantings', 'incomprehenses', 'misdirectionzz', 'overcomplicate', 'unforgivinglys',
|
||||||
|
],
|
||||||
|
15: [
|
||||||
|
'counterintuitiv', 'inconsequential', 'misinterpretate', 'overdramtically',
|
||||||
|
'uncharacterized',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ALL_WORDS = Object.values(WORDS_BY_LENGTH).flat();
|
||||||
|
|
||||||
|
const packageWords = rawWordList.words
|
||||||
|
.map((word) => word.toLowerCase())
|
||||||
|
.filter((word) => /^[a-z]+$/.test(word) && word.length >= 4 && word.length <= 15);
|
||||||
|
|
||||||
|
export const VALID_GUESSES = new Set([...ALL_WORDS, ...packageWords]);
|
||||||
36
src/lib/modes.ts
Normal file
36
src/lib/modes.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Difficulty, GameMode, Settings } from '../types';
|
||||||
|
|
||||||
|
export const MODE_LABELS: Record<GameMode, string> = {
|
||||||
|
daily: 'Daily Wordle',
|
||||||
|
infinite: 'Infinite Mode',
|
||||||
|
'evil-daily': 'Daily Evil Challenge',
|
||||||
|
custom: 'Custom Length',
|
||||||
|
'super-evil': 'Super Evil Mode',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MODE_DESCRIPTIONS: Record<GameMode, string> = {
|
||||||
|
daily: 'A seeded word each day, then practice rounds after the result.',
|
||||||
|
infinite: 'Endless levels with longer words as your streak climbs.',
|
||||||
|
'evil-daily': 'After two guesses, the keyboard and feedback go dark.',
|
||||||
|
custom: 'Pick a 4, 5, 6, or 7 letter puzzle.',
|
||||||
|
'super-evil': 'Huge words, fewer hints, and a board built for pain.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
|
wordLength: 5,
|
||||||
|
difficulty: 'wicked',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const difficultyGuesses: Record<Difficulty, number> = {
|
||||||
|
wicked: 6,
|
||||||
|
vicious: 5,
|
||||||
|
nightmare: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMaxGuesses = (mode: GameMode, wordLength: number, difficulty: Difficulty) => {
|
||||||
|
if (mode === 'daily') return 6;
|
||||||
|
if (mode === 'super-evil') return Math.max(4, Math.min(6, 16 - wordLength));
|
||||||
|
if (mode === 'evil-daily') return 6;
|
||||||
|
if (mode === 'infinite') return wordLength >= 7 ? 5 : 6;
|
||||||
|
return difficultyGuesses[difficulty];
|
||||||
|
};
|
||||||
18
src/lib/storage.ts
Normal file
18
src/lib/storage.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useLocalStorage<T>(key: string, initialValue: T) {
|
||||||
|
const [value, setValue] = useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(key);
|
||||||
|
return stored ? (JSON.parse(stored) as T) : initialValue;
|
||||||
|
} catch {
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
}, [key, value]);
|
||||||
|
|
||||||
|
return [value, setValue] as const;
|
||||||
|
}
|
||||||
90
src/lib/wordUtils.ts
Normal file
90
src/lib/wordUtils.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { VALID_GUESSES, WORDS_BY_LENGTH } from '../data/words';
|
||||||
|
import type { GuessResult, LetterState } from '../types';
|
||||||
|
|
||||||
|
export const normalizeGuess = (value: string) => value.toLowerCase().replace(/[^a-z]/g, '');
|
||||||
|
|
||||||
|
export const getDateKey = (date = new Date()) => date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const hashString = (value: string) => {
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
hash ^= value.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seededIndex = (seed: string, length: number) => hashString(seed) % length;
|
||||||
|
|
||||||
|
export const getWordsByLength = (length: number) => WORDS_BY_LENGTH[length] ?? [];
|
||||||
|
|
||||||
|
export const isValidGuess = (guess: string, length: number) => {
|
||||||
|
const clean = normalizeGuess(guess);
|
||||||
|
return clean.length === length && VALID_GUESSES.has(clean);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pickDailyWord = (length: number, salt: string) => {
|
||||||
|
const words = getWordsByLength(length);
|
||||||
|
return words[seededIndex(`${getDateKey()}-${salt}-${length}`, words.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pickSeededWord = (length: number, seed: string) => {
|
||||||
|
const words = getWordsByLength(length);
|
||||||
|
return words[seededIndex(`${seed}-${length}`, words.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pickRandomWord = (length: number) => {
|
||||||
|
const words = getWordsByLength(length);
|
||||||
|
return words[Math.floor(Math.random() * words.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInfiniteLength = (level: number) => {
|
||||||
|
if (level < 3) return 4;
|
||||||
|
if (level < 7) return 5;
|
||||||
|
if (level < 12) return 6;
|
||||||
|
return 7;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSuperEvilLength = () => 10 + Math.floor(Math.random() * 6);
|
||||||
|
|
||||||
|
export const evaluateGuess = (guess: string, target: string): GuessResult[] => {
|
||||||
|
const cleanGuess = normalizeGuess(guess);
|
||||||
|
const targetLetters = target.split('');
|
||||||
|
const result: GuessResult[] = cleanGuess.split('').map((letter) => ({ letter, state: 'absent' }));
|
||||||
|
const remaining = new Map<string, number>();
|
||||||
|
|
||||||
|
targetLetters.forEach((letter, index) => {
|
||||||
|
if (cleanGuess[index] === letter) {
|
||||||
|
result[index].state = 'correct';
|
||||||
|
} else {
|
||||||
|
remaining.set(letter, (remaining.get(letter) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
result.forEach((entry, index) => {
|
||||||
|
if (entry.state === 'correct') return;
|
||||||
|
const count = remaining.get(entry.letter) ?? 0;
|
||||||
|
if (count > 0) {
|
||||||
|
result[index].state = 'present';
|
||||||
|
remaining.set(entry.letter, count - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergeKeyboardState = (guesses: string[], target: string) => {
|
||||||
|
const rank: Record<LetterState, number> = { empty: 0, absent: 1, present: 2, correct: 3 };
|
||||||
|
const state = new Map<string, LetterState>();
|
||||||
|
|
||||||
|
guesses.forEach((guess) => {
|
||||||
|
evaluateGuess(guess, target).forEach(({ letter, state: letterState }) => {
|
||||||
|
const current = state.get(letter) ?? 'empty';
|
||||||
|
if (rank[letterState] > rank[current]) {
|
||||||
|
state.set(letter, letterState);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
31
src/styles.css
Normal file
31
src/styles.css
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #020617;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #020617;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
select:focus-visible {
|
||||||
|
outline: 2px solid #fb7185;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
38
src/types.ts
Normal file
38
src/types.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export type GameMode = 'daily' | 'infinite' | 'evil-daily' | 'custom' | 'super-evil';
|
||||||
|
|
||||||
|
export type LetterState = 'correct' | 'present' | 'absent' | 'empty';
|
||||||
|
|
||||||
|
export type GameStatus = 'playing' | 'won' | 'lost';
|
||||||
|
|
||||||
|
export type Difficulty = 'wicked' | 'vicious' | 'nightmare';
|
||||||
|
|
||||||
|
export type GuessResult = {
|
||||||
|
letter: string;
|
||||||
|
state: LetterState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Settings = {
|
||||||
|
wordLength: 4 | 5 | 6 | 7;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Stats = {
|
||||||
|
gamesPlayed: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
currentStreak: number;
|
||||||
|
bestStreak: number;
|
||||||
|
infiniteLevel: number;
|
||||||
|
infiniteWins: number;
|
||||||
|
infiniteLosses: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SavedProgress = {
|
||||||
|
dateKey: string;
|
||||||
|
mode: GameMode;
|
||||||
|
target: string;
|
||||||
|
guesses: string[];
|
||||||
|
status: GameStatus;
|
||||||
|
level: number;
|
||||||
|
isPractice: boolean;
|
||||||
|
};
|
||||||
32
tailwind.config.js
Normal file
32
tailwind.config.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
display: ['Inter', 'ui-sans-serif', 'system-ui'],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
ember: '0 0 40px rgba(244, 63, 94, 0.18)',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
flipTile: {
|
||||||
|
'0%': { transform: 'rotateX(0deg)' },
|
||||||
|
'48%': { transform: 'rotateX(88deg)' },
|
||||||
|
'52%': { transform: 'rotateX(88deg)' },
|
||||||
|
'100%': { transform: 'rotateX(0deg)' },
|
||||||
|
},
|
||||||
|
shake: {
|
||||||
|
'0%, 100%': { transform: 'translateX(0)' },
|
||||||
|
'20%, 60%': { transform: 'translateX(-6px)' },
|
||||||
|
'40%, 80%': { transform: 'translateX(6px)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
flipTile: 'flipTile 520ms ease-in-out both',
|
||||||
|
shake: 'shake 260ms ease-in-out',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
23
tsconfig.app.json
Normal file
23
tsconfig.app.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
14
tsconfig.node.json
Normal file
14
tsconfig.node.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "eslint.config.js"]
|
||||||
|
}
|
||||||
6
vite.config.ts
Normal file
6
vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user