Skip to content

Commit 1b1d11d

Browse files
committed
feat: lights out game
1 parent f2ea847 commit 1b1d11d

File tree

4 files changed

+211
-0
lines changed

4 files changed

+211
-0
lines changed

public/apps/apps.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@
109109
"title": "Word Ladder",
110110
"description": "Transform one word into another by changing one letter at a time.",
111111
"icon": "LadderSimpleIcon"
112+
},
113+
{
114+
"slug": "lights-out",
115+
"to": "/apps/lights-out",
116+
"title": "Lights Out",
117+
"description": "A classic logic puzzle where you turn off all the lights.",
118+
"icon": "LightbulbFilamentIcon"
112119
}
113120
]
114121
},

src/components/AnimatedRoutes.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import PomodoroTimerPage from '../pages/apps/PomodoroTimerPage';
5656
import MorseCodeTranslatorPage from '../pages/apps/MorseCodeTranslatorPage';
5757
import MastermindPage from '../pages/apps/MastermindPage';
5858
import WordLadderPage from '../pages/apps/WordLadderPage'; // Import WordLadderPage
59+
import LightsOutPage from '../pages/apps/LightsOutPage'; // Import LightsOutPage
5960
import SettingsPage from '../pages/SettingsPage';
6061

6162
import UsefulLinksPage from '../pages/UsefulLinksPage';
@@ -549,6 +550,10 @@ function AnimatedRoutes() {
549550
path="/apps::wl"
550551
element={<Navigate to="/apps/word-ladder" replace />}
551552
/>
553+
<Route
554+
path="/apps::lo"
555+
element={<Navigate to="/apps/lights-out" replace />}
556+
/>
552557
{/* End of hardcoded redirects */}
553558
<Route
554559
path="/apps/ip"
@@ -1083,6 +1088,20 @@ function AnimatedRoutes() {
10831088
</motion.div>
10841089
}
10851090
/>
1091+
<Route
1092+
path="/apps/lights-out"
1093+
element={
1094+
<motion.div
1095+
initial="initial"
1096+
animate="in"
1097+
exit="out"
1098+
variants={pageVariants}
1099+
transition={pageTransition}
1100+
>
1101+
<LightsOutPage />
1102+
</motion.div>
1103+
}
1104+
/>
10861105
{/* D&D specific 404 page */}
10871106
<Route
10881107
path="/stories/*"

src/pages/apps/LightsOutPage.js

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import React, { useState, useEffect, useRef, useContext } from 'react';
2+
import { Link } from 'react-router-dom';
3+
import { ArrowLeftIcon, LightbulbIcon, ArrowCounterClockwiseIcon, LightbulbFilamentIcon } from '@phosphor-icons/react';
4+
import useSeo from '../../hooks/useSeo';
5+
import { ToastContext } from '../../context/ToastContext';
6+
7+
const LightsOutPage = () => {
8+
useSeo({
9+
title: 'Lights Out | Fezcodex',
10+
description: 'A classic logic puzzle game where you turn off all the lights.',
11+
keywords: ['Fezcodex', 'lights out', 'puzzle game', 'logic game', 'strategy game'],
12+
ogTitle: 'Lights Out | Fezcodex',
13+
ogDescription: 'A classic logic puzzle game where you turn off all the lights.',
14+
ogImage: 'https://fezcode.github.io/logo512.png',
15+
twitterCard: 'summary_large_image',
16+
twitterTitle: 'Lights Out | Fezcodex',
17+
twitterDescription: 'A classic logic puzzle game where you turn off all the lights.',
18+
twitterImage: 'https://fezcode.github.io/logo512.png',
19+
});
20+
21+
const { addToast } = useContext(ToastContext);
22+
23+
const GRID_SIZE = 5;
24+
const [grid, setGrid] = useState([]);
25+
const [moves, setMoves] = useState(0);
26+
const [gameOver, setGameOver] = useState(false);
27+
const [message, setMessage] = useState('');
28+
29+
// Function to initialize a solvable Lights Out grid
30+
const initGame = () => {
31+
let newGrid = Array(GRID_SIZE).fill(null).map(() =>
32+
Array(GRID_SIZE).fill(false) // false means off
33+
);
34+
35+
// To ensure solvability and a non-trivial starting state, apply random "clicks"
36+
// to a solved board. Each click guarantees a solvable state.
37+
for (let i = 0; i < GRID_SIZE; i++) {
38+
for (let j = 0; j < GRID_SIZE; j++) {
39+
if (Math.random() < 0.3) { // Adjust probability for initial lights
40+
newGrid = toggleLight(i, j, newGrid, true); // Assign the returned new grid
41+
}
42+
}
43+
}
44+
setGrid(newGrid);
45+
setMoves(0);
46+
setGameOver(false);
47+
setMessage('');
48+
};
49+
50+
useEffect(() => {
51+
initGame();
52+
}, []);
53+
54+
// Toggles a light and its neighbors
55+
const toggleLight = (row, col, currentGrid, isInitialSetup = false) => {
56+
const newGrid = currentGrid.map(arr => [...arr]); // Deep copy
57+
58+
const performToggle = (r, c) => {
59+
if (r >= 0 && r < GRID_SIZE && c >= 0 && c < GRID_SIZE) {
60+
newGrid[r][c] = !newGrid[r][c];
61+
}
62+
};
63+
64+
performToggle(row, col); // Toggle self
65+
performToggle(row - 1, col); // Toggle above
66+
performToggle(row + 1, col); // Toggle below
67+
performToggle(row, col - 1); // Toggle left
68+
performToggle(row, col + 1); // Toggle right
69+
70+
if (!isInitialSetup) {
71+
setGrid(newGrid);
72+
setMoves(prevMoves => prevMoves + 1);
73+
if (checkWin(newGrid)) {
74+
setGameOver(true);
75+
setMessage(`You won in ${moves + 1} moves!`);
76+
addToast({ message: `You won in ${moves + 1} moves!`, type: 'success' });
77+
}
78+
} else {
79+
return newGrid; // Return the modified grid for initial setup
80+
}
81+
};
82+
83+
// Checks if all lights are off
84+
const checkWin = (currentGrid) => {
85+
return currentGrid.every(row => row.every(light => !light));
86+
};
87+
88+
const handleLightClick = (row, col) => {
89+
if (gameOver) return;
90+
toggleLight(row, col, grid);
91+
};
92+
93+
const handleResetGame = () => {
94+
initGame();
95+
};
96+
97+
return (
98+
<div className="py-16 sm:py-24">
99+
<div className="mx-auto max-w-7xl px-6 lg:px-8 text-gray-300">
100+
<Link to="/apps" className="text-article hover:underline flex items-center justify-center gap-2 text-lg mb-4">
101+
<ArrowLeftIcon size={24} /> Back to Apps
102+
</Link>
103+
<h1 className="text-4xl font-bold tracking-tight sm:text-6xl mb-4 flex items-center justify-center">
104+
<span className="codex-color">fc</span>
105+
<span className="separator-color">::</span>
106+
<span className="apps-color">apps</span>
107+
<span className="separator-color">::</span>
108+
<span className="single-app-color">lo</span>
109+
</h1>
110+
<hr className="border-gray-700" />
111+
<div className="flex justify-center items-center mt-16">
112+
<div className="bg-app-alpha-10 border-app-alpha-50 text-app hover:bg-app/15 group border rounded-lg shadow-2xl p-6 flex flex-col justify-between relative transform transition-all duration-300 ease-in-out scale-105 overflow-hidden h-full w-full max-w-2xl">
113+
<div className="absolute top-0 left-0 w-full h-full opacity-10" style={{ backgroundImage: 'radial-gradient(circle, white 1px, transparent 1px)', backgroundSize: '10px 10px' }}></div>
114+
<div className="relative z-10 p-4">
115+
<h2 className="text-3xl font-arvo font-normal mb-2 text-app text-center">Lights Out</h2>
116+
<p className="text-center text-app-light mb-4">
117+
Turn off all the lights on the grid. Toggling a light also flips its neighbors!
118+
</p>
119+
<hr className="border-gray-700 mb-6" />
120+
121+
<div className="flex justify-between items-center mb-4">
122+
<span className="text-lg font-semibold text-app-light">Moves: {moves}</span>
123+
<button
124+
onClick={handleResetGame}
125+
className="px-4 py-2 rounded-md font-semibold bg-tb text-app border-app-alpha-50 hover:bg-app/15 border flex items-center gap-2"
126+
>
127+
<ArrowCounterClockwiseIcon size={24} /> New Game
128+
</button>
129+
</div>
130+
131+
{message && (
132+
<div
133+
className={`p-4 rounded-md text-center font-semibold text-lg my-4
134+
${gameOver ? 'bg-green-700 text-green-100' : ''}
135+
`}
136+
>
137+
{message}
138+
</div>
139+
)}
140+
141+
<div className="grid gap-1 mx-auto" style={{
142+
gridTemplateColumns: `repeat(${GRID_SIZE}, minmax(0, 1fr))`,
143+
width: `${GRID_SIZE * 50}px` // Adjust grid container width based on cell size
144+
}}>
145+
{grid.map((row, rowIndex) => (
146+
row.map((lightOn, colIndex) => (
147+
<button
148+
key={`${rowIndex}-${colIndex}`}
149+
className={`w-12 h-12 rounded-md transition-colors duration-200 ease-in-out
150+
${lightOn ? 'bg-yellow-400 shadow-lg' : 'bg-gray-700'}
151+
${gameOver ? 'cursor-not-allowed' : ''}
152+
`}
153+
onClick={() => handleLightClick(rowIndex, colIndex)}
154+
disabled={gameOver}
155+
aria-label={`Light ${rowIndex}-${colIndex}`}
156+
>
157+
{lightOn && <LightbulbFilamentIcon size={24} className="mx-auto my-auto text-gray-900" />}
158+
</button>
159+
))
160+
))}
161+
</div>
162+
163+
<div className="bg-app-alpha-10 border border-app-alpha-50 text-app p-4 mt-8 rounded-md">
164+
<div className="flex items-center mb-2">
165+
<LightbulbIcon size={24} className="mr-3" />
166+
<p className="font-bold">How to Play:</p>
167+
</div>
168+
<ul className="list-disc list-inside ml-5 text-sm">
169+
<li>The goal is to turn off all the lights on the {GRID_SIZE}x{GRID_SIZE} grid.</li>
170+
<li>Clicking a light toggles its state (on/off).</li>
171+
<li>Crucially, clicking a light also toggles the state of its immediate neighbors (up, down, left, right).</li>
172+
<li>Plan your moves carefully to solve the puzzle!</li>
173+
</ul>
174+
</div>
175+
</div>
176+
</div>
177+
</div>
178+
</div>
179+
</div>
180+
);
181+
};
182+
183+
export default LightsOutPage;

src/utils/appIcons.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
TranslateIcon,
3535
PuzzlePieceIcon,
3636
LadderSimpleIcon,
37+
LightbulbFilamentIcon,
3738
} from '@phosphor-icons/react';
3839

3940
export const appIcons = {
@@ -72,4 +73,5 @@ export const appIcons = {
7273
TranslateIcon,
7374
PuzzlePieceIcon,
7475
LadderSimpleIcon,
76+
LightbulbFilamentIcon,
7577
};

0 commit comments

Comments
 (0)