feat: add fox adventure game

This commit is contained in:
End 2025-02-17 19:47:06 -07:00
parent 80a86c36e1
commit 40e518b818
No known key found for this signature in database
16 changed files with 479 additions and 95 deletions

36
package-lock.json generated
View file

@ -15,7 +15,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"tailwind-merge": "^2.2.1"
"tailwind-merge": "^2.2.1",
"zustand": "5.0.3"
},
"devDependencies": {
"@cloudflare/workers-types": "4.20250129.0",
@ -25,8 +26,8 @@
"@testing-library/react": "^14.2.1",
"@testing-library/user-event": "^14.5.2",
"@types/node": "20.17.16",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react-swc": "^3.5.0",
@ -10701,6 +10702,35 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz",
"integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View file

@ -29,7 +29,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"tailwind-merge": "^2.2.1"
"tailwind-merge": "^2.2.1",
"zustand": "5.0.3"
},
"devDependencies": {
"@cloudflare/workers-types": "4.20250129.0",
@ -39,8 +40,8 @@
"@testing-library/react": "^14.2.1",
"@testing-library/user-event": "^14.5.2",
"@types/node": "20.17.16",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react-swc": "^3.5.0",

View file

@ -4,6 +4,7 @@ import AboutPage from "@/pages/AboutPage";
import ProjectsPage from "@/pages/ProjectsPage";
import APCSPPage from "@/pages/APCSPPage";
import ParallaxPage from "@/pages/ParallaxPage";
import VNCViewer from '@/components/VNCViewer';
const App = () => {
return (
@ -28,7 +29,8 @@ const App = () => {
<Route path="/" element={<AboutPage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/apcsp" element={<APCSPPage />} />
<Route path="/parallax" element={<ParallaxPage />} />
{/* <Route path="/parallax" element={<ParallaxPage />} /> */}
<Route path="/novnc" element={<VNCViewer />} />
<Route path="*" element={
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
<h1 className="text-4xl font-bold text-glow">404: Page Not Found</h1>

View file

@ -32,6 +32,14 @@ const Navbar = () => {
<span>APCSP</span>
</Link>
<Link
to="/novnc"
className={`nav-link ${location.pathname === '/novnc' ? 'active' : ''}`}
>
<Code size={20} />
<span>noVNC</span>
</Link>
<a
href="https://twitch.tv/EndofTimee"
target="_blank"

View file

@ -1,80 +1,75 @@
import React, { useEffect, useState } from 'react';
import { Monitor } from 'lucide-react';
import React, { useState } from 'react';
import { Monitor, Power, Lock } from 'lucide-react';
const VNCViewer = () => {
const [isFullscreen, setIsFullscreen] = useState(false);
const [isConnected, setIsConnected] = useState(false);
// Matrix rain effect characters
const chars = '01';
const [drops, setDrops] = useState([]);
useEffect(() => {
// Initialize matrix rain
const canvas = document.getElementById('matrix-bg');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const fontSize = 10;
const columns = canvas.width / fontSize;
const initialDrops = Array(Math.floor(columns)).fill(1);
setDrops(initialDrops);
const draw = () => {
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#0F0';
ctx.font = fontSize + 'px monospace';
for (let i = 0; i < drops.length; i++) {
const text = chars.charAt(Math.floor(Math.random() * chars.length));
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
if (drops[i] * fontSize > canvas.height && Math.random() > 0.975) {
drops[i] = 0;
}
drops[i]++;
const toggleFullscreen = () => {
const iframe = document.getElementById('vnc-iframe');
if (iframe) {
if (!document.fullscreenElement) {
iframe.requestFullscreen();
setIsFullscreen(true);
} else {
document.exitFullscreen();
setIsFullscreen(false);
}
};
const interval = setInterval(draw, 33);
return () => clearInterval(interval);
}, []);
}
};
return (
<div className="min-h-screen w-full flex items-center justify-center p-4 relative overflow-hidden">
<canvas id="matrix-bg" className="absolute inset-0 z-0" />
<div className="w-full max-w-2xl bg-background-primary/80 backdrop-blur-sm rounded-xl shadow-xl border border-accent-primary/20 p-6 space-y-4 transition-all duration-300 hover:border-accent-neon/40 hover:shadow-accent-primary/20 z-10">
<div className="flex items-center gap-3 mb-4">
<Monitor className="text-accent-primary" size={24} />
<h2 className="text-xl font-semibold text-text-primary">Raspberry Pi VNC Viewer</h2>
<div className="min-h-screen w-full flex items-center justify-center p-4">
<div className="w-full max-w-6xl bg-background-primary/80 backdrop-blur-sm rounded-xl shadow-xl border border-accent-primary/20 overflow-hidden transition-all duration-300 hover:border-accent-neon/40 hover:shadow-accent-primary/20">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-accent-primary/20">
<div className="flex items-center gap-3">
<Monitor className="text-accent-primary" size={24} />
<h2 className="text-xl font-semibold text-text-primary">Raspberry Pi Remote Access</h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={toggleFullscreen}
className="p-2 rounded-lg hover:bg-accent-primary/20 transition-colors"
title="Toggle Fullscreen"
>
<Monitor size={20} className="text-accent-primary" />
</button>
<button
onClick={() => setIsConnected(!isConnected)}
className="p-2 rounded-lg hover:bg-accent-primary/20 transition-colors"
title="Toggle Connection"
>
<Power
size={20}
className={isConnected ? "text-green-500" : "text-accent-primary"}
/>
</button>
</div>
</div>
<div className="aspect-video w-full bg-black/50 rounded-lg overflow-hidden">
{/* VNC Viewer */}
<div className="aspect-video w-full bg-black/50 relative">
{isConnected ? (
<iframe
src="http://your-raspberry-pi-ip:6080/vnc.html?host=your-raspberry-pi-ip&port=5900"
<iframe
id="vnc-iframe"
src="http://68.104.222.58:6080/vnc.html?host=68.104.222.58&port=5901&autoconnect=true&resize=scale"
className="w-full h-full border-0"
title="VNC Viewer"
allow="fullscreen"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-text-primary/60">
Not connected
<div className="absolute inset-0 flex flex-col items-center justify-center text-text-primary/60">
<Lock size={48} className="mb-4 text-accent-primary/40" />
<p>Click the power button to connect</p>
</div>
)}
</div>
<div className="flex justify-end">
<button
onClick={() => setIsConnected(!isConnected)}
className="px-4 py-2 bg-accent-primary/20 rounded-lg text-text-primary hover:bg-accent-primary/30 transition-colors"
>
{isConnected ? 'Disconnect' : 'Connect'}
</button>
{/* Status Bar */}
<div className="p-2 border-t border-accent-primary/20 flex justify-between items-center text-sm text-text-primary/60">
<span>Status: {isConnected ? 'Connected' : 'Disconnected'}</span>
<span>Press ESC to exit fullscreen</span>
</div>
</div>
</div>

View file

@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react';
import useGameStore from '../state/gameStore';
import { useGameLoop } from '../hooks/useGameLoop';
import { useGameControls } from '../hooks/useGameControls';
import { Player } from './Player';
import { Enemy } from './Enemy';
import { Collectible } from './Collectible';
import { PowerUp } from './PowerUp';
import { GameHUD } from './GameHUD';
import { GameOverlay } from './GameOverlay';
const FoxGame: React.FC = () => {
const [isActive, setIsActive] = useState(false);
// Initialize game systems
useGameLoop();
useGameControls();
// Konami code for game activation
useEffect(() => {
const konamiCode = [
'ArrowUp', 'ArrowUp',
'ArrowDown', 'ArrowDown',
'ArrowLeft', 'ArrowRight',
'ArrowLeft', 'ArrowRight',
'b', 'a'
];
let index = 0;
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === konamiCode[index]) {
index++;
if (index === konamiCode.length) {
setIsActive(true);
useGameStore.getState().startNewGame();
}
} else {
index = 0;
}
};
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
}, []);
if (!isActive) return null;
return (
<div className="fixed inset-0 bg-gradient-game z-50">
<div className="relative w-full h-full overflow-hidden game-viewport">
{/* Game world */}
<div className="absolute inset-0">
<Player />
{/* Other game elements render here */}
</div>
<GameHUD />
<GameOverlay />
</div>
</div>
);
};
export default FoxGame;

View file

@ -0,0 +1,26 @@
import React from 'react';
import useGameStore from '../state/gameStore';
export const Player: React.FC = () => {
const player = useGameStore(state => state.player);
return (
<div
className={`absolute transition-all duration-100 ${
player.isInvincible ? 'animate-pulse-fast' : ''
}`}
style={{
left: `${player.position.x}%`,
top: `${player.position.y}%`,
transform: 'translate(-50%, -50%)'
}}
>
<div className="relative w-16 h-16">
{/* Fox body */}
<div className="absolute inset-0 bg-fox-orange rounded-full">
{/* Face details here */}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,57 @@
import { useEffect } from 'react';
import useGameStore from '../state/gameStore';
export const useGameControls = () => {
const gameStore = useGameStore();
useEffect(() => {
const keys = new Set<string>();
const handleKeyDown = (e: KeyboardEvent) => {
keys.add(e.key);
if (e.key === 'Escape') {
if (gameStore.gameStatus === 'PLAYING') {
gameStore.pauseGame();
} else if (gameStore.gameStatus === 'PAUSED') {
gameStore.resumeGame();
}
}
};
const handleKeyUp = (e: KeyboardEvent) => {
keys.delete(e.key);
};
const updatePlayerMovement = () => {
if (gameStore.gameStatus !== 'PLAYING') return;
const direction = { x: 0, y: 0 };
if (keys.has('ArrowUp') || keys.has('w')) direction.y -= 1;
if (keys.has('ArrowDown') || keys.has('s')) direction.y += 1;
if (keys.has('ArrowLeft') || keys.has('a')) direction.x -= 1;
if (keys.has('ArrowRight') || keys.has('d')) direction.x += 1;
if (direction.x !== 0 || direction.y !== 0) {
gameStore.movePlayer(direction);
}
};
let animationFrameId: number;
const gameLoop = () => {
updatePlayerMovement();
animationFrameId = requestAnimationFrame(gameLoop);
};
animationFrameId = requestAnimationFrame(gameLoop);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
cancelAnimationFrame(animationFrameId);
};
}, []);
};

View file

@ -0,0 +1,33 @@
import { useEffect, useRef } from 'react';
import useGameStore from '../state/gameStore';
export const useGameLoop = () => {
const frameRef = useRef<number>();
const lastUpdateRef = useRef<number>(0);
const gameStore = useGameStore();
useEffect(() => {
const gameLoop = (timestamp: number) => {
if (!lastUpdateRef.current) lastUpdateRef.current = timestamp;
const deltaTime = timestamp - lastUpdateRef.current;
if (gameStore.gameStatus === 'PLAYING') {
// Update game state
gameStore.updateEnemies();
checkCollisions();
updatePowerUps(deltaTime);
}
lastUpdateRef.current = timestamp;
frameRef.current = requestAnimationFrame(gameLoop);
};
frameRef.current = requestAnimationFrame(gameLoop);
return () => {
if (frameRef.current) {
cancelAnimationFrame(frameRef.current);
}
};
}, []);
};

View file

@ -1,13 +1,53 @@
/* Default cursor for all elements */
* {
cursor: url('/cursors/default.svg') 16 16, auto;
}
a, button, [role="button"],
input[type="submit"], input[type="button"],
select {
/* Interactive elements cursor */
a,
button,
[role="button"],
input[type="submit"],
input[type="button"],
select,
.interactive,
.nav-link {
cursor: url('/cursors/paw.svg') 16 16, pointer;
}
.loading {
/* Loading state cursor */
.loading,
:disabled,
[aria-busy="true"] {
cursor: url('/cursors/tail-loading.svg') 16 16, progress;
}
/* Hover effects for interactive elements */
a:hover,
button:hover,
[role="button"]:hover,
.nav-link:hover {
/* Add a subtle glow effect on hover */
filter: drop-shadow(0 0 4px var(--fox-pink-glow));
transition: filter 0.3s ease;
}
/* Custom cursor regions */
.text-select {
cursor: text;
}
.resize {
cursor: nw-resize;
}
/* Ensure cursors work with transform effects */
* {
cursor-position: fixed;
}
/* Prevent cursor inheritance in certain cases */
iframe,
canvas {
cursor: inherit;
}

View file

@ -1,3 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path fill="#ffffff" stroke="#000000" stroke-width="2" d="M6,2 L26,22 L16,22 L12,30 L8,22 L6,22 Z"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<!-- Fox head shape -->
<path d="M16 2 L24 10 L24 24 L8 24 L8 10 Z"
fill="#ff9466"
stroke="#240046"
stroke-width="1.5"/>
<!-- Ears -->
<path d="M8 10 L2 2 L8 8"
fill="#ff9466"
stroke="#240046"
stroke-width="1.5"/>
<path d="M24 10 L30 2 L24 8"
fill="#ff9466"
stroke="#240046"
stroke-width="1.5"/>
<!-- Eyes -->
<circle cx="12" cy="14" r="2" fill="#240046"/>
<circle cx="20" cy="14" r="2" fill="#240046"/>
<!-- Nose -->
<circle cx="16" cy="18" r="1.5" fill="#240046"/>
</svg>

Before

Width:  |  Height:  |  Size: 196 B

After

Width:  |  Height:  |  Size: 658 B

View file

@ -1,12 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path fill="none" stroke="#ffc6e5" stroke-width="2">
<animateTransform
attributeName="transform"
type="rotate"
from="0 16 16"
to="360 16 16"
dur="1s"
repeatCount="indefinite"/>
<path d="M16,4 C22,4 26,8 26,14 C26,20 22,24 16,24 C10,24 6,20 6,14 C6,8 10,4 16,4 Z"/>
</path>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<!-- Tail base -->
<path d="M4 16 Q8 8 16 8 Q24 8 28 16 Q24 24 16 24 Q8 24 4 16"
fill="#ff9466"
stroke="#240046"
stroke-width="1.5">
<animateTransform
attributeName="transform"
type="rotate"
from="0 16 16"
to="360 16 16"
dur="1s"
repeatCount="indefinite"/>
</path>
<!-- Tail tip -->
<circle cx="16" cy="16" r="4"
fill="#ffc6e5"
stroke="#240046"
stroke-width="1.5">
<animate
attributeName="r"
values="4;5;4"
dur="1s"
repeatCount="indefinite"/>
</circle>
</svg>

Before

Width:  |  Height:  |  Size: 460 B

After

Width:  |  Height:  |  Size: 683 B

31
src/styles/game.css Normal file
View file

@ -0,0 +1,31 @@
/* Game-specific styles */
.game-active * {
cursor: none;
}
.game-viewport {
touch-action: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Animation utilities */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
/* Game-specific shadows */
.fox-shadow {
filter: drop-shadow(0 0 8px var(--fox-pink-glow));
}
.enemy-shadow {
filter: drop-shadow(0 0 8px rgba(255, 0, 0, 0.3));
}

View file

@ -2,3 +2,30 @@
@import 'animations.css';
@import 'utilities.css';
@import 'cursor.css';
@import './game.css';
/* Game Styles */
.game-viewport {
touch-action: none;
user-select: none;
}
.game-active * {
cursor: none;
}
/* Game Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
/* Game-specific effects */
.fox-shadow {
filter: drop-shadow(0 0 8px var(--fox-pink-glow));
}

42
src/types/game.ts Normal file
View file

@ -0,0 +1,42 @@
export interface PowerUp {
id: string;
type: 'SPEED' | 'SHIELD' | 'MAGNET';
duration: number;
position: { x: number; y: number };
}
export interface Collectible {
id: string;
type: 'STAR' | 'GEM' | 'KEY';
value: number;
position: { x: number; y: number };
}
export interface Enemy {
id: string;
type: 'WOLF' | 'OWL' | 'HUNTER';
position: { x: number; y: number };
direction: { x: number; y: number };
speed: number;
}
export interface PlayerState {
position: { x: number; y: number };
health: number;
speed: number;
powerUps: PowerUp[];
isInvincible: boolean;
hasKey: boolean;
}
export interface GameState {
player: PlayerState;
enemies: Enemy[];
collectibles: Collectible[];
powerUps: PowerUp[];
score: number;
level: number;
gameStatus: 'MENU' | 'PLAYING' | 'PAUSED' | 'GAME_OVER';
highScores: number[];
timePlayed: number;
}

View file

@ -23,6 +23,8 @@ export default {
'bounce-slow': 'bounce 3s linear infinite',
'glow': 'glow 2s ease-in-out infinite',
'float': 'float 3s ease-in-out infinite',
'spin-slow': 'spin 3s linear infinite',
'wag': 'wag 1s ease-in-out infinite',
},
keyframes: {
glow: {
@ -33,16 +35,11 @@ export default {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-10px)' },
},
wag: {
'0%, 100%': { transform: 'rotate(-10deg)' },
'50%': { transform: 'rotate(10deg)' },
},
},
backgroundImage: {
'gradient-primary': 'linear-gradient(135deg, var(--background-primary) 0%, var(--background-secondary) 100%)',
'gradient-card': 'linear-gradient(135deg, rgba(47, 28, 84, 0.3) 0%, rgba(157, 78, 221, 0.1) 100%)',
'gradient-hover': 'linear-gradient(135deg, rgba(157, 78, 221, 0.2) 0%, rgba(178, 73, 248, 0.1) 100%)',
},
zIndex: {
'behind': '-1',
'deep': '-10',
}
},
},
plugins: [],