Skip to content

Commit b4b8b12

Browse files
committed
feat: a simple rougelike adv.
1 parent 58ba832 commit b4b8b12

File tree

4 files changed

+346
-0
lines changed

4 files changed

+346
-0
lines changed

public/apps/apps.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,14 @@
100100
"description": "Test your memory by repeating the sequence of lights.",
101101
"icon": "CirclesFourIcon",
102102
"created_at": "2025-11-25T15:43:25+03:00"
103+
},
104+
{
105+
"slug": "roguelike-game",
106+
"to": "/apps/roguelike-game",
107+
"title": "Roguelike Adventure",
108+
"description": "A simple terminal-like roguelike game.",
109+
"icon": "CubeIcon",
110+
"created_at": "2025-11-25T20:00:00+03:00"
103111
}
104112
]
105113
},

src/components/AnimatedRoutes.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import GalacticAgePage from '../pages/apps/GalacticAgePage';
6868
import BpmGuesserPage from '../pages/apps/BpmGuesserPage';
6969
import WhiteboardPage from '../pages/apps/WhiteboardPage';
7070
import FootballEmblemCreatorPage from '../pages/apps/FootballEmblemCreatorPage';
71+
import RoguelikeGamePage from '../pages/apps/RoguelikeGamePage'; // Import RoguelikeGamePage
7172
import SettingsPage from '../pages/SettingsPage';
7273

7374
import UsefulLinksPage from '../pages/UsefulLinksPage';
@@ -628,6 +629,10 @@ function AnimatedRoutes() {
628629
path="/apps::fec"
629630
element={<Navigate to="/apps/football-emblem-creator" replace />}
630631
/>
632+
<Route
633+
path="/apps::rl"
634+
element={<Navigate to="/apps/roguelike-game" replace />}
635+
/>
631636
{/* End of hardcoded redirects */}
632637
<Route
633638
path="/apps/ip"
@@ -643,6 +648,20 @@ function AnimatedRoutes() {
643648
</motion.div>
644649
}
645650
/>
651+
<Route
652+
path="/apps/roguelike-game"
653+
element={
654+
<motion.div
655+
initial="initial"
656+
animate="in"
657+
exit="out"
658+
variants={pageVariants}
659+
transition={pageTransition}
660+
>
661+
<RoguelikeGamePage />
662+
</motion.div>
663+
}
664+
/>
646665
<Route
647666
path="/apps/connect-four"
648667
element={
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import React, { useState, useEffect, useCallback } from 'react';
2+
import { Link } from 'react-router-dom';
3+
import { ArrowLeftIcon, CubeIcon } from '@phosphor-icons/react'; // Using CubeIcon as a placeholder icon for now
4+
import useSeo from '../../hooks/useSeo';
5+
6+
const MAP_WIDTH = 20;
7+
const MAP_HEIGHT = 15;
8+
const TILE_FLOOR = '.';
9+
const TILE_WALL = '#';
10+
const ENTITY_PLAYER = '@';
11+
const ENTITY_ENEMY = 'E';
12+
const ENTITY_EXIT = 'X';
13+
14+
function RoguelikeGamePage() {
15+
useSeo({
16+
title: 'Roguelike Game | Fezcodex',
17+
description: 'A simple roguelike game.',
18+
keywords: ['Fezcodex', 'game', 'roguelike'],
19+
ogTitle: 'Roguelike Game | Fezcodex',
20+
ogDescription: 'A simple roguelike game.',
21+
ogImage: 'https://fezcode.github.io/logo512.png',
22+
twitterCard: 'summary_large_image',
23+
twitterTitle: 'Roguelike Game | Fezcodex',
24+
twitterDescription: 'A simple roguelike game.',
25+
twitterImage: 'https://fezcode.github.io/logo512.png',
26+
});
27+
28+
const [gameMap, setGameMap] = useState([]);
29+
const [playerPosition, setPlayerPosition] = useState(null);
30+
const [enemyPositions, setEnemyPositions] = useState([]);
31+
const [exitPosition, setExitPosition] = useState(null);
32+
const [gameStatus, setGameStatus] = useState('playing'); // playing, won, lost
33+
34+
// Utility to get a random empty position
35+
const getRandomEmptyPosition = useCallback((map) => {
36+
let x, y;
37+
do {
38+
x = Math.floor(Math.random() * MAP_WIDTH);
39+
y = Math.floor(Math.random() * MAP_HEIGHT);
40+
} while (map[y][x] !== TILE_FLOOR);
41+
return { x, y };
42+
}, []);
43+
44+
// Map Generation
45+
const generateMap = useCallback(() => {
46+
// Initialize map with walls
47+
let newMap = Array(MAP_HEIGHT)
48+
.fill(null)
49+
.map(() => Array(MAP_WIDTH).fill(TILE_WALL));
50+
51+
// Simple room generation
52+
const roomCount = 5 + Math.floor(Math.random() * 5); // 5-9 rooms
53+
for (let i = 0; i < roomCount; i++) {
54+
const roomW = 5 + Math.floor(Math.random() * 5); // 5-9 width
55+
const roomH = 3 + Math.floor(Math.random() * 5); // 3-7 height
56+
const roomX = 1 + Math.floor(Math.random() * (MAP_WIDTH - roomW - 2));
57+
const roomY = 1 + Math.floor(Math.random() * (MAP_HEIGHT - roomH - 2));
58+
59+
for (let y = roomY; y < roomY + roomH; y++) {
60+
for (let x = roomX; x < roomX + roomW; x++) {
61+
newMap[y][x] = TILE_FLOOR;
62+
}
63+
}
64+
}
65+
66+
// Place Player
67+
const playerPos = getRandomEmptyPosition(newMap);
68+
setPlayerPosition(playerPos);
69+
70+
// Place Exit
71+
let exitPos;
72+
do {
73+
exitPos = getRandomEmptyPosition(newMap);
74+
} while (exitPos.x === playerPos.x && exitPos.y === playerPos.y);
75+
setExitPosition(exitPos);
76+
77+
// Place Enemies
78+
const numEnemies = 3 + Math.floor(Math.random() * 3); // 3-5 enemies
79+
const newEnemyPositions = [];
80+
for (let i = 0; i < numEnemies; i++) {
81+
let enemyPos;
82+
do {
83+
enemyPos = getRandomEmptyPosition(newMap);
84+
} while (
85+
(enemyPos.x === playerPos.x && enemyPos.y === playerPos.y) ||
86+
(enemyPos.x === exitPos.x && enemyPos.y === exitPos.y) ||
87+
newEnemyPositions.some(
88+
(ep) => ep.x === enemyPos.x && ep.y === enemyPos.y,
89+
)
90+
);
91+
newEnemyPositions.push(enemyPos);
92+
}
93+
setEnemyPositions(newEnemyPositions);
94+
95+
setGameMap(newMap);
96+
setGameStatus('playing');
97+
}, [getRandomEmptyPosition]);
98+
99+
useEffect(() => {
100+
generateMap();
101+
}, [generateMap]);
102+
103+
// Enemy AI - Simple random movement (will be handled by player move)
104+
const moveEnemies = useCallback(
105+
(currentPlayerPos) => {
106+
setEnemyPositions((prevEnemyPositions) => {
107+
const newEnemyPositions = prevEnemyPositions.map((enemy) => {
108+
const possibleMoves = [
109+
{ dx: 0, dy: -1 },
110+
{ dx: 0, dy: 1 },
111+
{ dx: -1, dy: 0 },
112+
{ dx: 1, dy: 0 },
113+
];
114+
const randomMove =
115+
possibleMoves[Math.floor(Math.random() * possibleMoves.length)];
116+
117+
const newX = enemy.x + randomMove.dx;
118+
const newY = enemy.y + randomMove.dy;
119+
120+
// Check boundaries and walls
121+
if (
122+
newX >= 0 &&
123+
newX < MAP_WIDTH &&
124+
newY >= 0 &&
125+
newY < MAP_HEIGHT &&
126+
gameMap[newY][newX] !== TILE_WALL
127+
) {
128+
return { x: newX, y: newY };
129+
}
130+
return enemy; // Stay if cannot move
131+
});
132+
133+
// Check for collision with player after all enemies moved
134+
for (const newEnemy of newEnemyPositions) {
135+
if (
136+
newEnemy.x === currentPlayerPos.x &&
137+
newEnemy.y === currentPlayerPos.y
138+
) {
139+
setGameStatus('lost');
140+
break;
141+
}
142+
}
143+
return newEnemyPositions;
144+
});
145+
},
146+
[gameMap, setGameStatus],
147+
);
148+
149+
// Player Movement
150+
const movePlayer = useCallback(
151+
(dx, dy) => {
152+
if (gameStatus !== 'playing') return;
153+
154+
const newX = playerPosition.x + dx;
155+
const newY = playerPosition.y + dy;
156+
157+
// Check boundaries
158+
if (
159+
newX < 0 ||
160+
newX >= MAP_WIDTH ||
161+
newY < 0 ||
162+
newY >= MAP_HEIGHT ||
163+
gameMap[newY][newX] === TILE_WALL
164+
) {
165+
return; // Can't move through walls or out of bounds
166+
}
167+
168+
const nextPlayerPos = { x: newX, y: newY };
169+
setPlayerPosition(nextPlayerPos);
170+
171+
// Check for exit
172+
if (newX === exitPosition.x && newY === exitPosition.y) {
173+
setGameStatus('won');
174+
return;
175+
}
176+
177+
// Check for enemy collision immediately after player moves
178+
for (const enemy of enemyPositions) {
179+
if (newX === enemy.x && newY === enemy.y) {
180+
setGameStatus('lost');
181+
return;
182+
}
183+
}
184+
185+
// If no win/loss condition, then enemies move
186+
moveEnemies(nextPlayerPos);
187+
},
188+
[playerPosition, gameMap, exitPosition, enemyPositions, gameStatus, moveEnemies],
189+
);
190+
191+
// Keyboard input handler
192+
useEffect(() => {
193+
const handleKeyDown = (event) => {
194+
switch (event.key) {
195+
case 'w':
196+
case 'W':
197+
movePlayer(0, -1);
198+
break;
199+
case 's':
200+
case 'S':
201+
movePlayer(0, 1);
202+
break;
203+
case 'a':
204+
case 'A':
205+
movePlayer(-1, 0);
206+
break;
207+
case 'd':
208+
case 'D':
209+
movePlayer(1, 0);
210+
break;
211+
default:
212+
break;
213+
}
214+
};
215+
216+
window.addEventListener('keydown', handleKeyDown);
217+
return () => window.removeEventListener('keydown', handleKeyDown);
218+
}, [movePlayer]);
219+
220+
// Render the game map
221+
const renderMap = () => {
222+
if (!gameMap.length) return null;
223+
224+
return (
225+
<div
226+
className="grid gap-px bg-gray-700 p-px"
227+
style={{
228+
gridTemplateColumns: `repeat(${MAP_WIDTH}, minmax(0, 1fr))`,
229+
width: `${MAP_WIDTH * 24}px`, // Assuming 24px per tile
230+
}}
231+
>
232+
{gameMap.map((row, y) =>
233+
row.map((tile, x) => {
234+
let content = tile;
235+
let className = 'flex items-center justify-center w-6 h-6 text-xs font-mono';
236+
237+
if (playerPosition && playerPosition.x === x && playerPosition.y === y) {
238+
content = ENTITY_PLAYER;
239+
className += ' bg-blue-500 text-white';
240+
} else if (exitPosition && exitPosition.x === x && exitPosition.y === y) {
241+
content = ENTITY_EXIT;
242+
className += ' bg-green-500 text-white';
243+
} else if (enemyPositions.some((ep) => ep.x === x && ep.y === y)) {
244+
content = ENTITY_ENEMY;
245+
className += ' bg-red-500 text-white';
246+
} else if (tile === TILE_WALL) {
247+
className += ' bg-gray-800 text-gray-600';
248+
} else {
249+
className += ' bg-gray-900 text-gray-500';
250+
}
251+
252+
return (
253+
<div key={`${x}-${y}`} className={className}>
254+
{content}
255+
</div>
256+
);
257+
}),
258+
)}
259+
</div>
260+
);
261+
};
262+
263+
return (
264+
<div className="flex flex-col items-center justify-center py-16 sm:py-24 text-gray-300">
265+
<Link
266+
to="/apps"
267+
className="text-primary-400 hover:underline flex items-center justify-center gap-2 text-lg mb-4"
268+
>
269+
<ArrowLeftIcon size={24} /> Back to Apps
270+
</Link>
271+
<h1 className="text-4xl font-bold tracking-tight sm:text-6xl mb-4 flex items-center">
272+
<CubeIcon size={48} className="mr-2" /> Roguelike Adventure
273+
</h1>
274+
<p className="text-lg text-gray-400 mb-6 text-center">
275+
Navigate the maze, avoid enemies, and find the exit!
276+
</p>
277+
278+
{gameStatus === 'won' && (
279+
<div className="text-green-400 text-2xl font-bold mb-4">
280+
You Won! 🎉
281+
<button
282+
onClick={generateMap}
283+
className="ml-4 px-4 py-2 bg-green-600 hover:bg-green-700 rounded text-white text-lg"
284+
>
285+
Play Again
286+
</button>
287+
</div>
288+
)}
289+
{gameStatus === 'lost' && (
290+
<div className="text-red-400 text-2xl font-bold mb-4">
291+
Game Over! 💀
292+
<button
293+
onClick={generateMap}
294+
className="ml-4 px-4 py-2 bg-red-600 hover:bg-red-700 rounded text-white text-lg"
295+
>
296+
Try Again
297+
</button>
298+
</div>
299+
)}
300+
301+
<div className="border-2 border-gray-600 p-2 relative">
302+
{renderMap()}
303+
</div>
304+
305+
<div className="mt-6 text-center">
306+
<p>Use WASD Keys to Move</p>
307+
<p className="text-sm text-gray-500">
308+
<span className="text-blue-400">@</span>: Player,{' '}
309+
<span className="text-red-400">E</span>: Enemy,{' '}
310+
<span className="text-green-400">X</span>: Exit
311+
</p>
312+
</div>
313+
</div>
314+
);
315+
}
316+
317+
export default RoguelikeGamePage;

src/utils/appIcons.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
MetronomeIcon,
4646
PencilSimpleIcon,
4747
TrophyIcon,
48+
CubeIcon,
4849
} from '@phosphor-icons/react';
4950

5051
export const appIcons = {
@@ -94,4 +95,5 @@ export const appIcons = {
9495
MetronomeIcon,
9596
PencilSimpleIcon,
9697
TrophyIcon,
98+
CubeIcon,
9799
};

0 commit comments

Comments
 (0)