refactor: split App.js (884→319 lines) into pages/, components/, services/
This commit is contained in:
+84
-648
@@ -1,11 +1,15 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import ReactDice from './dice/ReactDice';
|
||||
import SceneMusic, { playClick, playRoll, playSuccess, playFail, playSelect, playVictory, setMasterVolume, resumeAudioContext } from './SceneMusic';
|
||||
import SceneMusic, { playClick, playRoll, playSuccess, playFail, playSelect, playVictory, resumeAudioContext } from './SceneMusic';
|
||||
import TutorialOverlay from './components/TutorialOverlay';
|
||||
import HomePage from './pages/HomePage';
|
||||
import ThemeSelectPage from './pages/ThemeSelectPage';
|
||||
import PonySelectPage from './pages/PonySelectPage';
|
||||
import GameScenePage from './pages/GameScenePage';
|
||||
import GameEndPage from './pages/GameEndPage';
|
||||
import * as api from './services/api';
|
||||
import './App.css';
|
||||
|
||||
const API = window.location.origin.replace('3001', '8082');
|
||||
|
||||
// Fallback pony data if API fails to load
|
||||
const DEFAULT_PONIES = [
|
||||
{ navn: 'Jordpony', emoji: '🐴', img: 'jordpony.png', bonus: 'Stærk 💪', color: '#8B4513', diceBonus: 1 },
|
||||
@@ -14,19 +18,7 @@ const DEFAULT_PONIES = [
|
||||
{ name: 'Alicorn', emoji: '👑', img: 'alicorn.png', bonus: 'Magi + vinger 🌟', color: '#FFD700', diceBonus: 2 },
|
||||
];
|
||||
|
||||
// Fetch content (ponies + themes) from backend
|
||||
async function loadContent() {
|
||||
try {
|
||||
const r = await fetch(`${API}/api/content`);
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
return { ponies: data.ponies, themes: data.themes };
|
||||
}
|
||||
} catch (e) { /* fall through */ }
|
||||
return { ponies: DEFAULT_PONIES, themes: [] };
|
||||
}
|
||||
|
||||
// Achievement system
|
||||
// Achievement hook
|
||||
function useAchievements() {
|
||||
const [stats, setStats] = useState(() => {
|
||||
try {
|
||||
@@ -57,7 +49,7 @@ function useAchievements() {
|
||||
return { stats, recordGame, resetStats };
|
||||
}
|
||||
|
||||
// Tutorial system
|
||||
// Tutorial hook
|
||||
function useTutorial() {
|
||||
const [shown, setShown] = useState(() => {
|
||||
return localStorage.getItem('pony_tutorial_seen') === 'true';
|
||||
@@ -71,223 +63,6 @@ function useTutorial() {
|
||||
return { shown, markSeen };
|
||||
}
|
||||
|
||||
// Tutorial overlay component
|
||||
function TutorialOverlay({ onClose }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="tutorial-overlay"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', bounce: 0.4 }}
|
||||
className="tutorial-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2>🦄 Sådan spiller du!</h2>
|
||||
<ol className="tutorial-steps">
|
||||
<li><strong>Vælg en pony</strong> — hver har sine egne fordele ✨</li>
|
||||
<li><strong>Læs scenen</strong> — find ud af hvad du skal gøre 📖</li>
|
||||
<li><strong>Kast terningerne</strong> — held og lykke! 🎲</li>
|
||||
<li><strong>Overlev 5 scener</strong> — for at vinde! 🏆</li>
|
||||
</ol>
|
||||
<button className="btn-start" onClick={onClose} aria-label="Start spil">
|
||||
Lad os gå! 🚀
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sparkle component
|
||||
function Sparkles() {
|
||||
const sparkles = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
size: 8 + Math.random() * 16,
|
||||
delay: Math.random() * 3,
|
||||
duration: 2 + Math.random() * 2,
|
||||
emoji: ['✨', '⭐', '💫', '🌟', '🦋'][Math.floor(Math.random() * 5)]
|
||||
}));
|
||||
return (
|
||||
<div className="sparkles-container">
|
||||
{sparkles.map((s) => (
|
||||
<motion.div
|
||||
key={s.id}
|
||||
className="sparkle"
|
||||
style={{ left: `${s.x}%`, top: `${s.y}%`, fontSize: s.size }}
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: [0, 1, 0], scale: [0, 1.5, 0.5] }}
|
||||
transition={{ delay: s.delay, duration: s.duration, repeat: Infinity, ease: 'easeInOut' }}
|
||||
>
|
||||
{s.emoji}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Confetti component for victory
|
||||
function Confetti() {
|
||||
const pieces = Array.from({ length: 40 }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * 100,
|
||||
delay: Math.random() * 2,
|
||||
duration: 2 + Math.random() * 2,
|
||||
color: ['#FF69B4', '#FFD700', '#00CED1', '#FF6347', '#7B68EE', '#32CD32'][Math.floor(Math.random() * 6)],
|
||||
size: 6 + Math.random() * 10,
|
||||
shape: Math.random() > 0.5 ? 'circle' : 'square'
|
||||
}));
|
||||
return (
|
||||
<div className="confetti-container">
|
||||
{pieces.map((p) => (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
className={`confetti confetti-${p.shape}`}
|
||||
style={{ left: `${p.x}%`, backgroundColor: p.color, width: p.size, height: p.size }}
|
||||
initial={{ y: -20, opacity: 1, rotate: 0 }}
|
||||
animate={{ y: window.innerHeight + 20, opacity: [1, 1, 0], rotate: 720 }}
|
||||
transition={{ delay: p.delay, duration: p.duration, repeat: Infinity, ease: 'easeIn' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Floating background elements
|
||||
function FloatingBg() {
|
||||
const items = [
|
||||
{ emoji: '🌈', x: 10, y: 20, d: 4 },
|
||||
{ emoji: '🦋', x: 80, y: 15, d: 3 },
|
||||
{ emoji: '🌸', x: 20, y: 70, d: 5 },
|
||||
{ emoji: '🎈', x: 70, y: 60, d: 3.5 },
|
||||
{ emoji: '🍭', x: 50, y: 80, d: 4.5 },
|
||||
{ emoji: '🌙', x: 90, y: 40, d: 5.5 },
|
||||
{ emoji: '🎀', x: 5, y: 50, d: 3 },
|
||||
{ emoji: '🌻', x: 60, y: 30, d: 4 },
|
||||
];
|
||||
return (
|
||||
<div className="floating-bg">
|
||||
{items.map((item, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="floating-emoji"
|
||||
style={{ left: `${item.x}%`, top: `${item.y}%` }}
|
||||
animate={{ y: [0, -20, 0], rotate: [0, 10, -10, 0] }}
|
||||
transition={{ duration: item.d, repeat: Infinity, ease: 'easeInOut' }}
|
||||
>
|
||||
{item.emoji}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Dice component using react-dice-complete
|
||||
function DiceRoll({ dice }) {
|
||||
const reactDice = useRef(null);
|
||||
const hasRolled = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (dice && dice.length > 0 && !hasRolled.current) {
|
||||
hasRolled.current = true;
|
||||
playRoll();
|
||||
setTimeout(() => {
|
||||
reactDice.current?.rollAll(dice);
|
||||
}, 100);
|
||||
}
|
||||
}, [dice]);
|
||||
|
||||
return (
|
||||
<div className="dice-row">
|
||||
<ReactDice
|
||||
ref={reactDice}
|
||||
numDice={dice?.length || 2}
|
||||
sides={6}
|
||||
dieSize={48}
|
||||
faceColor="#ffffff"
|
||||
dotColor="#e91e8c"
|
||||
dieCornerRadius={8}
|
||||
margin={10}
|
||||
outline={true}
|
||||
outlineColor="#f093fb"
|
||||
rollTime={1.5}
|
||||
disableIndividual={true}
|
||||
disableRandom={true}
|
||||
defaultRoll={1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Volume slider component
|
||||
function VolumeControl({ volume, onChange }) {
|
||||
return (
|
||||
<div className="volume-control">
|
||||
<span className="volume-icon" aria-label={`Lydstyrke: ${Math.round(volume * 100)}%`}>
|
||||
{volume === 0 ? '🔇' : volume < 0.5 ? '🔉' : '🔊'}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
onChange(v);
|
||||
setMasterVolume(v);
|
||||
}}
|
||||
className="volume-slider"
|
||||
aria-label="Justér lydstyrke"
|
||||
title={`Lydstyrke: ${Math.round(volume * 100)}%`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Achievements display
|
||||
function Achievements({ stats }) {
|
||||
return (
|
||||
<div className="achievements">
|
||||
<h3>🏆 Statistikk</h3>
|
||||
<div className="achievement-grid">
|
||||
<div className="achievement-card">
|
||||
<span className="achievement-emoji">🎮</span>
|
||||
<span className="achievement-value">{stats.games}</span>
|
||||
<span className="achievement-label">Spil</span>
|
||||
</div>
|
||||
<div className="achievement-card">
|
||||
<span className="achievement-emoji">🌟</span>
|
||||
<span className="achievement-value">{stats.wins}</span>
|
||||
<span className="achievement-label">Sejre</span>
|
||||
</div>
|
||||
<div className="achievement-card">
|
||||
<span className="achievement-emoji">💪</span>
|
||||
<span className="achievement-value">{stats.losses}</span>
|
||||
<span className="achievement-label">Nederlag</span>
|
||||
</div>
|
||||
<div className="achievement-card">
|
||||
<span className="achievement-emoji">🏆</span>
|
||||
<span className="achievement-value">{stats.bestScore}</span>
|
||||
<span className="achievement-label">Bedste score</span>
|
||||
</div>
|
||||
</div>
|
||||
{stats.games >= 1 && (
|
||||
<div className="achievement-badge">
|
||||
{stats.wins / stats.games >= 0.5 ? '⭐ Halv vejen til Pony Mester!' : '🐣 Keep trying, little pony!'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Page transition wrapper
|
||||
const pageVariants = {
|
||||
initial: { opacity: 0, y: 30 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
@@ -305,22 +80,16 @@ function App() {
|
||||
const [soundEnabled, setSoundEnabled] = useState(false);
|
||||
const [selectedTheme, setSelectedTheme] = useState(0);
|
||||
const [content, setContent] = useState({ ponies: DEFAULT_PONIES, themes: [] });
|
||||
const scrollRef = useRef(null);
|
||||
const { stats, recordGame } = useAchievements();
|
||||
const { shown: tutorialShown, markSeen } = useTutorial();
|
||||
|
||||
// Load content from backend on mount
|
||||
useEffect(() => {
|
||||
loadContent().then(setContent);
|
||||
api.loadContent().then(result => {
|
||||
setContent(result || { ponies: DEFAULT_PONIES, themes: [] });
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-scroll history feed
|
||||
useEffect(() => {
|
||||
if (scrollRef.current && data && data.history && data.history.length > 0) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Record game end achievements
|
||||
const isGameEnd = page === 'game' && data && data.finished;
|
||||
useEffect(() => {
|
||||
@@ -331,19 +100,12 @@ function App() {
|
||||
}
|
||||
}, [isGameEnd, data]);
|
||||
|
||||
const startGame = async (typeIdx) => {
|
||||
const handleStartGame = async (typeIdx) => {
|
||||
playSelect();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await fetch(`${API}/api/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ type: typeIdx, tema: selectedTheme })
|
||||
});
|
||||
if (!r.ok) throw new Error('Server fejl');
|
||||
const json = await r.json();
|
||||
const json = await api.startGame(typeIdx, selectedTheme);
|
||||
setData(json);
|
||||
setPage('game');
|
||||
} catch (e) {
|
||||
@@ -353,19 +115,13 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const rollDice = async () => {
|
||||
const handleRollDice = async () => {
|
||||
playRoll();
|
||||
setDiceRolling(true);
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await fetch(`${API}/api/kast`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!r.ok) throw new Error('Server fejl');
|
||||
const json = await r.json();
|
||||
const json = await api.rollDice();
|
||||
setData(json);
|
||||
// Play SFX based on result
|
||||
const lastResult = json.history && json.history[json.history.length - 1];
|
||||
if (lastResult) {
|
||||
if (lastResult.success) playSuccess();
|
||||
@@ -379,18 +135,12 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const retryRoll = async () => {
|
||||
// Retry the last roll without losing game state
|
||||
const handleRetryRoll = async () => {
|
||||
setError(null);
|
||||
setDiceRolling(true);
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await fetch(`${API}/api/kast`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!r.ok) throw new Error('Server fejl');
|
||||
const json = await r.json();
|
||||
const json = await api.rollDice();
|
||||
setData(json);
|
||||
const lastResult = json.history && json.history[json.history.length - 1];
|
||||
if (lastResult) {
|
||||
@@ -412,13 +162,18 @@ function App() {
|
||||
setError(null);
|
||||
};
|
||||
|
||||
// Enable sound on first user interaction
|
||||
const enableSound = () => {
|
||||
resumeAudioContext();
|
||||
setSoundEnabled(true);
|
||||
};
|
||||
|
||||
// === LOADING (always shows first) ===
|
||||
const handleTutorialClose = () => {
|
||||
markSeen();
|
||||
setSelectedTheme(0);
|
||||
navigateTo('theme');
|
||||
};
|
||||
|
||||
// === LOADING ===
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
@@ -437,7 +192,7 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
// === ERROR (always shows first) ===
|
||||
// === ERROR ===
|
||||
if (error) {
|
||||
const isMidGame = page === 'game' && data;
|
||||
return (
|
||||
@@ -450,7 +205,7 @@ function App() {
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="btn-start"
|
||||
onClick={retryRoll}
|
||||
onClick={handleRetryRoll}
|
||||
style={{ marginBottom: '0.5rem' }}
|
||||
aria-label="Prøv igen"
|
||||
>
|
||||
@@ -464,10 +219,10 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
// === RENDER PAGES WITH TRANSITIONS ===
|
||||
// === RENDER PAGES ===
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{/* Sound enable prompt */}
|
||||
{/* Sound prompt */}
|
||||
{!soundEnabled && (
|
||||
<motion.div
|
||||
key="sound-prompt"
|
||||
@@ -495,390 +250,71 @@ function App() {
|
||||
{/* Tutorial overlay */}
|
||||
{!tutorialShown && soundEnabled && !showAchievements && (
|
||||
<AnimatePresence>
|
||||
<TutorialOverlay onClose={() => { markSeen(); setSelectedTheme(0); navigateTo('theme'); }} />
|
||||
<TutorialOverlay onClose={handleTutorialClose} />
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{/* HOME */}
|
||||
{page === 'home' && soundEnabled && (
|
||||
<motion.div
|
||||
key="home"
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.4 }}
|
||||
className="home"
|
||||
>
|
||||
<SceneMusic sceneType={volume > 0 ? 'home' : 'none'} />
|
||||
<div className="top-bar">
|
||||
<VolumeControl volume={volume} onChange={setVolume} />
|
||||
<button
|
||||
className="achievements-btn"
|
||||
onClick={() => setShowAchievements(!showAchievements)}
|
||||
title="Statistikk"
|
||||
aria-label="Åbn statistikk"
|
||||
>
|
||||
🏆
|
||||
</button>
|
||||
</div>
|
||||
{showAchievements && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="achievements-backdrop"
|
||||
onClick={() => setShowAchievements(false)}
|
||||
{/* Pages */}
|
||||
{soundEnabled && (
|
||||
<>
|
||||
{page === 'home' && (
|
||||
<HomePage
|
||||
volume={volume}
|
||||
setVolume={setVolume}
|
||||
stats={stats}
|
||||
showAchievements={showAchievements}
|
||||
setShowAchievements={setShowAchievements}
|
||||
onNavigate={navigateTo}
|
||||
/>
|
||||
)}
|
||||
{showAchievements && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -20, scale: 0.9 }}
|
||||
className="achievements-overlay"
|
||||
role="dialog"
|
||||
aria-label="Statistikk"
|
||||
>
|
||||
<Achievements stats={stats} />
|
||||
<button className="btn-close" onClick={() => setShowAchievements(false)} aria-label="Luk statistikk">✕</button>
|
||||
</motion.div>
|
||||
)}
|
||||
<FloatingBg />
|
||||
<Sparkles />
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="home-content">
|
||||
<motion.div
|
||||
className="banner-emoji"
|
||||
animate={{ y: [0, -10, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
>
|
||||
🦄🌈✨
|
||||
</motion.div>
|
||||
<motion.h1
|
||||
animate={{ scale: [1, 1.05, 1], rotate: [0, 2, -2, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="title"
|
||||
>
|
||||
My Little Pony
|
||||
</motion.h1>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="subtitle"
|
||||
>
|
||||
Tails of Equestria
|
||||
</motion.h2>
|
||||
<motion.p className="home-desc" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.8 }}>
|
||||
Vælg din pony og gå på eventyr! 🎮
|
||||
</motion.p>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="btn-start"
|
||||
onClick={() => { setSelectedTheme(0); navigateTo('theme'); }}
|
||||
aria-label="Start nyt spil"
|
||||
>
|
||||
🎮 Start Nyt Spil!
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* CHOOSE THEME */}
|
||||
{page === 'theme' && soundEnabled && (
|
||||
<motion.div
|
||||
key="theme"
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.4 }}
|
||||
className="start-page"
|
||||
>
|
||||
<SceneMusic sceneType={volume > 0 ? 'home' : 'none'} />
|
||||
<div className="top-bar">
|
||||
<VolumeControl volume={volume} onChange={setVolume} />
|
||||
</div>
|
||||
<FloatingBg />
|
||||
<motion.h1 initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="title">
|
||||
Vælg et eventyr! 📖
|
||||
</motion.h1>
|
||||
<motion.p className="page-desc" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }}>
|
||||
Hvilken historie vil du opleve?
|
||||
</motion.p>
|
||||
<div className="pony-choices">
|
||||
{content.themes.map((t, i) => (
|
||||
<motion.div
|
||||
key={t.id || i}
|
||||
whileHover={{ scale: 1.08, y: -8 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.15 }}
|
||||
className="pony-card"
|
||||
style={{ borderColor: selectedTheme === i ? '#f093fb' : 'transparent' }}
|
||||
onClick={() => { setSelectedTheme(i); navigateTo('start'); }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Vælg ${t.titel}`}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { setSelectedTheme(i); navigateTo('start'); } }}
|
||||
>
|
||||
<div className="pony-emoji">{t.emoji}</div>
|
||||
<h3>{t.titel}</h3>
|
||||
<p className="pony-bonus">{t.sceneCount || 5} scener</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }} className="btn-back" onClick={() => navigateTo('home')} aria-label="Tilbage til forsiden">
|
||||
Tilbage
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* CHOOSE PONY */}
|
||||
{page === 'start' && soundEnabled && (
|
||||
<motion.div
|
||||
key="start"
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.4 }}
|
||||
className="start-page"
|
||||
>
|
||||
<SceneMusic sceneType={volume > 0 ? 'home' : 'none'} />
|
||||
<div className="top-bar">
|
||||
<VolumeControl volume={volume} onChange={setVolume} />
|
||||
</div>
|
||||
<FloatingBg />
|
||||
<motion.h1 initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="title">
|
||||
Vælg din Pony! 🐴
|
||||
</motion.h1>
|
||||
<motion.p className="page-desc" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }}>
|
||||
Hver pony har sine egne superkræfter!
|
||||
</motion.p>
|
||||
<div className="pony-choices">
|
||||
{content.ponies.map((p, i) => (
|
||||
<motion.div
|
||||
key={p.navn}
|
||||
whileHover={{ scale: 1.08, y: -8, rotate: [0, -2, 2, 0] }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.15 }}
|
||||
className="pony-card"
|
||||
style={{ borderColor: p.color }}
|
||||
onClick={() => startGame(i)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Vælg ${p.navn} - ${p.bonus}`}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') startGame(i); }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: [0, 5, -5, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity, delay: i * 0.5 }}
|
||||
>
|
||||
<img src={`${API}/static/images/${p.img}`} alt={p.navn} />
|
||||
</motion.div>
|
||||
<div className="pony-emoji">{p.emoji}</div>
|
||||
<h3>{p.navn}</h3>
|
||||
<p className="pony-bonus">{p.bonus}</p>
|
||||
{p.diceBonus > 0 && (
|
||||
<p className="pony-dice-bonus">+{p.diceBonus} på første terning 🎲</p>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }} className="btn-back" onClick={() => navigateTo('home')} aria-label="Tilbage til forsiden">
|
||||
Tilbage
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* GAME END */}
|
||||
{isGameEnd && soundEnabled && (
|
||||
<motion.div
|
||||
key="game-end"
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.4 }}
|
||||
className="game-end"
|
||||
>
|
||||
<SceneMusic sceneType={volume > 0 ? (data.victory ? 'victory' : data.mixed ? 'mixed' : 'defeat') : 'none'} />
|
||||
<div className="top-bar">
|
||||
<VolumeControl volume={volume} onChange={setVolume} />
|
||||
</div>
|
||||
{data.victory && <Confetti />}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="end-title"
|
||||
>
|
||||
{data.victory ? '🌟 SEJR! 🌟' : data.mixed ? '⚖️ Blant resultat! ⚖️' : '💪 Prøv igen! 💪'}
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="end-text"
|
||||
>
|
||||
{data.endText}
|
||||
</motion.p>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="score"
|
||||
>
|
||||
{data.score}
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
className="history"
|
||||
>
|
||||
<h2>📖 Hvad skete der? 📖</h2>
|
||||
{data.history.map((item, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.8 + i * 0.1 }}
|
||||
className={`history-item ${item.success ? 'success' : 'fail'}`}
|
||||
>
|
||||
<div className="history-action">{item.action}</div>
|
||||
<DiceRoll dice={item.dice} />
|
||||
<div className="history-result">{item.result}</div>
|
||||
{item.story && <p className="history-story">{item.story}</p>}
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
<div className="actions">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="btn-start"
|
||||
onClick={() => { setData(null); setPage('start'); }}
|
||||
aria-label="Spil igen"
|
||||
>
|
||||
🎲 Spil Igen!
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="btn-home"
|
||||
onClick={() => { setData(null); setPage('home'); }}
|
||||
aria-label="Tilbage til forsiden"
|
||||
>
|
||||
🏠 Forside
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* GAME SCENE */}
|
||||
{page === 'game' && data && !data.finished && soundEnabled && (
|
||||
<motion.div
|
||||
key="game"
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.4 }}
|
||||
className="game"
|
||||
>
|
||||
<SceneMusic sceneType={volume > 0 ? 'game' : 'none'} />
|
||||
<div className="top-bar">
|
||||
<VolumeControl volume={volume} onChange={setVolume} />
|
||||
</div>
|
||||
<FloatingBg />
|
||||
<motion.div key={data.sceneNum} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="scene-header">
|
||||
<motion.div
|
||||
className="progress-bar-visual"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="progress-dot"
|
||||
style={{ backgroundColor: i < (data.history?.length || 0) ? (data.history[i]?.success ? '#4caf50' : '#ff9800') : '#ccc' }}
|
||||
animate={{ scale: i < (data.history?.length || 0) ? 1.3 : 1 }}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
<h1>{data.tema}</h1>
|
||||
</motion.div>
|
||||
|
||||
<motion.div key="pony" initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} className="pony-card-mini">
|
||||
<motion.img
|
||||
src={`${API}${data.ponyImg}`}
|
||||
alt={data.ponyName}
|
||||
className="pony-mini-img"
|
||||
animate={{ rotate: [-3, 3, -3] }}
|
||||
transition={{ duration: 3, repeat: Infinity }}
|
||||
{page === 'theme' && (
|
||||
<ThemeSelectPage
|
||||
themes={content.themes}
|
||||
selectedTheme={selectedTheme}
|
||||
setSelectedTheme={setSelectedTheme}
|
||||
volume={volume}
|
||||
setVolume={setVolume}
|
||||
onNavigate={navigateTo}
|
||||
/>
|
||||
<div className="pony-info">
|
||||
<strong>{data.ponyName}</strong>
|
||||
<span className="pony-type-badge">{data.ponyType}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* History feed scrolls up */}
|
||||
<div className="history-feed" ref={scrollRef}>
|
||||
<AnimatePresence>
|
||||
{(data.history || []).map((item, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 30, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ type: 'spring', bounce: 0.4 }}
|
||||
className={`history-feed-item ${item.success ? 'success' : 'fail'}`}
|
||||
>
|
||||
<div className="feed-action">
|
||||
<span className="feed-emoji">{item.success ? '✅' : '❌'}</span>
|
||||
{item.action}
|
||||
</div>
|
||||
<DiceRoll dice={item.dice} />
|
||||
<div className="feed-result">{item.result}</div>
|
||||
{item.story && <p className="feed-story">{item.story}</p>}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{page === 'start' && (
|
||||
<PonySelectPage
|
||||
ponies={content.ponies}
|
||||
onStartGame={handleStartGame}
|
||||
volume={volume}
|
||||
setVolume={setVolume}
|
||||
onNavigate={navigateTo}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Current scene */}
|
||||
<motion.div key={data.sceneNum} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="scene-card">
|
||||
<div className="scene-number">{data.sceneNum}</div>
|
||||
<p className="scene-text">{data.sceneText}</p>
|
||||
<div className="scene-action">
|
||||
<span className="action-label">Du skal:</span>
|
||||
<span className="action-text">{data.actionText}</span>
|
||||
</div>
|
||||
<div className="scene-difficulty">{data.difficulty}</div>
|
||||
</motion.div>
|
||||
{page === 'game' && data && !data.finished && (
|
||||
<GameScenePage
|
||||
data={data}
|
||||
onRollDice={handleRollDice}
|
||||
volume={volume}
|
||||
setVolume={setVolume}
|
||||
/>
|
||||
)}
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="btn-roll"
|
||||
onClick={rollDice}
|
||||
disabled={loading}
|
||||
animate={diceRolling ? { rotate: [0, -10, 10, -10, 10, 0] } : {}}
|
||||
transition={{ duration: 0.5 }}
|
||||
aria-label="Kast terningerne"
|
||||
>
|
||||
🎲 KAST TERNINGERNE! 🎲
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
{isGameEnd && (
|
||||
<GameEndPage
|
||||
data={data}
|
||||
onNavigate={(p) => {
|
||||
if (p === 'start') {
|
||||
setData(null);
|
||||
setPage('start');
|
||||
} else {
|
||||
navigateTo(p);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Achievements({ stats }) {
|
||||
return (
|
||||
<div className="achievements">
|
||||
<h3>🏆 Statistikk</h3>
|
||||
<div className="achievement-grid">
|
||||
<div className="achievement-card">
|
||||
<span className="achievement-emoji">🎮</span>
|
||||
<span className="achievement-value">{stats.games}</span>
|
||||
<span className="achievement-label">Spil</span>
|
||||
</div>
|
||||
<div className="achievement-card">
|
||||
<span className="achievement-emoji">🌟</span>
|
||||
<span className="achievement-value">{stats.wins}</span>
|
||||
<span className="achievement-label">Sejre</span>
|
||||
</div>
|
||||
<div className="achievement-card">
|
||||
<span className="achievement-emoji">💪</span>
|
||||
<span className="achievement-value">{stats.losses}</span>
|
||||
<span className="achievement-label">Nederlag</span>
|
||||
</div>
|
||||
<div className="achievement-card">
|
||||
<span className="achievement-emoji">🏆</span>
|
||||
<span className="achievement-value">{stats.bestScore}</span>
|
||||
<span className="achievement-label">Bedste score</span>
|
||||
</div>
|
||||
</div>
|
||||
{stats.games >= 1 && (
|
||||
<div className="achievement-badge">
|
||||
{stats.wins / stats.games >= 0.5 ? '⭐ Halv vejen til Pony Mester!' : '🐣 Keep trying, little pony!'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Confetti() {
|
||||
const pieces = Array.from({ length: 40 }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * 100,
|
||||
delay: Math.random() * 2,
|
||||
duration: 2 + Math.random() * 2,
|
||||
color: ['#FF69B4', '#FFD700', '#00CED1', '#FF6347', '#7B68EE', '#32CD32'][Math.floor(Math.random() * 6)],
|
||||
size: 6 + Math.random() * 10,
|
||||
shape: Math.random() > 0.5 ? 'circle' : 'square',
|
||||
}));
|
||||
return (
|
||||
<div className="confetti-container">
|
||||
{pieces.map((p) => (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
className={`confetti confetti-${p.shape}`}
|
||||
style={{ left: `${p.x}%`, backgroundColor: p.color, width: p.size, height: p.size }}
|
||||
initial={{ y: -20, opacity: 1, rotate: 0 }}
|
||||
animate={{ y: window.innerHeight + 20, opacity: [1, 1, 0], rotate: 720 }}
|
||||
transition={{ delay: p.delay, duration: p.duration, repeat: Infinity, ease: 'easeIn' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import ReactDice from '../dice/ReactDice';
|
||||
import { playRoll } from '../SceneMusic';
|
||||
|
||||
export default function DiceRoll({ dice }) {
|
||||
const reactDice = useRef(null);
|
||||
const hasRolled = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (dice && dice.length > 0 && !hasRolled.current) {
|
||||
hasRolled.current = true;
|
||||
playRoll();
|
||||
setTimeout(() => {
|
||||
reactDice.current?.rollAll(dice);
|
||||
}, 100);
|
||||
}
|
||||
}, [dice]);
|
||||
|
||||
return (
|
||||
<div className="dice-row">
|
||||
<ReactDice
|
||||
ref={reactDice}
|
||||
numDice={dice?.length || 2}
|
||||
sides={6}
|
||||
dieSize={48}
|
||||
faceColor="#ffffff"
|
||||
dotColor="#e91e8c"
|
||||
dieCornerRadius={8}
|
||||
margin={10}
|
||||
outline={true}
|
||||
outlineColor="#f093fb"
|
||||
rollTime={1.5}
|
||||
disableIndividual={true}
|
||||
disableRandom={true}
|
||||
defaultRoll={1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const items = [
|
||||
{ emoji: '🌈', x: 10, y: 20, d: 4 },
|
||||
{ emoji: '🦋', x: 80, y: 15, d: 3 },
|
||||
{ emoji: '🌸', x: 20, y: 70, d: 5 },
|
||||
{ emoji: '🎈', x: 70, y: 60, d: 3.5 },
|
||||
{ emoji: '🍭', x: 50, y: 80, d: 4.5 },
|
||||
{ emoji: '🌙', x: 90, y: 40, d: 5.5 },
|
||||
{ emoji: '🎀', x: 5, y: 50, d: 3 },
|
||||
{ emoji: '🌻', x: 60, y: 30, d: 4 },
|
||||
];
|
||||
|
||||
export default function FloatingBg() {
|
||||
return (
|
||||
<div className="floating-bg">
|
||||
{items.map((item, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="floating-emoji"
|
||||
style={{ left: `${item.x}%`, top: `${item.y}%` }}
|
||||
animate={{ y: [0, -20, 0], rotate: [0, 10, -10, 0] }}
|
||||
transition={{ duration: item.d, repeat: Infinity, ease: 'easeInOut' }}
|
||||
>
|
||||
{item.emoji}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Sparkles() {
|
||||
const sparkles = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
size: 8 + Math.random() * 16,
|
||||
delay: Math.random() * 3,
|
||||
duration: 2 + Math.random() * 2,
|
||||
emoji: ['✨', '⭐', '💫', '🌟', '🦋'][Math.floor(Math.random() * 5)],
|
||||
}));
|
||||
return (
|
||||
<div className="sparkles-container">
|
||||
{sparkles.map((s) => (
|
||||
<motion.div
|
||||
key={s.id}
|
||||
className="sparkle"
|
||||
style={{ left: `${s.x}%`, top: `${s.y}%`, fontSize: s.size }}
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: [0, 1, 0], scale: [0, 1.5, 0.5] }}
|
||||
transition={{ delay: s.delay, duration: s.duration, repeat: Infinity, ease: 'easeInOut' }}
|
||||
>
|
||||
{s.emoji}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function TutorialOverlay({ onClose }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="tutorial-overlay"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', bounce: 0.4 }}
|
||||
className="tutorial-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2>🦄 Sådan spiller du!</h2>
|
||||
<ol className="tutorial-steps">
|
||||
<li><strong>Vælg en pony</strong> — hver har sine egne fordele ✨</li>
|
||||
<li><strong>Læs scenen</strong> — find ud af hvad du skal gøre 📖</li>
|
||||
<li><strong>Kast terningerne</strong> — held og lykke! 🎲</li>
|
||||
<li><strong>Overlev 5 scener</strong> — for at vinde! 🏆</li>
|
||||
</ol>
|
||||
<button className="btn-start" onClick={onClose} aria-label="Start spil">
|
||||
Lad os gå! 🚀
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { setMasterVolume } from '../SceneMusic';
|
||||
|
||||
export default function VolumeControl({ volume, onChange }) {
|
||||
return (
|
||||
<div className="volume-control">
|
||||
<span className="volume-icon" aria-label={`Lydstyrke: ${Math.round(volume * 100)}%`}>
|
||||
{volume === 0 ? '🔇' : volume < 0.5 ? '🔉' : '🔊'}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
onChange(v);
|
||||
setMasterVolume(v);
|
||||
}}
|
||||
className="volume-slider"
|
||||
aria-label="Justér lydstyrke"
|
||||
title={`Lydstyrke: ${Math.round(volume * 100)}%`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Game end page — victory/defeat summary with history and replay buttons.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import SceneMusic from '../SceneMusic';
|
||||
import VolumeControl from '../components/VolumeControl';
|
||||
import Confetti from '../components/Confetti';
|
||||
import DiceRoll from '../components/DiceRoll';
|
||||
|
||||
export default function GameEndPage({ data, onNavigate }) {
|
||||
const sceneType = data.victory ? 'victory' : data.mixed ? 'mixed' : 'defeat';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="game-end"
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.4 }}
|
||||
className="game-end"
|
||||
>
|
||||
<SceneMusic sceneType={sceneType} />
|
||||
<div className="top-bar">
|
||||
<VolumeControl volume={0.5} onChange={() => {}} />
|
||||
</div>
|
||||
{data.victory && <Confetti />}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="end-title"
|
||||
>
|
||||
{data.victory ? '🌟 SEJR! 🌟' : data.mixed ? '⚖️ Blant resultat! ⚖️' : '💪 Prøv igen! 💪'}
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="end-text"
|
||||
>
|
||||
{data.endText}
|
||||
</motion.p>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="score"
|
||||
>
|
||||
{data.score}
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
className="history"
|
||||
>
|
||||
<h2>📖 Hvad skete der? 📖</h2>
|
||||
{data.history.map((item, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.8 + i * 0.1 }}
|
||||
className={`history-item ${item.success ? 'success' : 'fail'}`}
|
||||
>
|
||||
<div className="history-action">{item.action}</div>
|
||||
<DiceRoll dice={item.dice} />
|
||||
<div className="history-result">{item.result}</div>
|
||||
{item.story && <p className="history-story">{item.story}</p>}
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
<div className="actions">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="btn-start"
|
||||
onClick={() => onNavigate('start')}
|
||||
aria-label="Spil igen"
|
||||
>
|
||||
🎲 Spil Igen!
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="btn-home"
|
||||
onClick={() => onNavigate('home')}
|
||||
aria-label="Tilbage til forsiden"
|
||||
>
|
||||
🏠 Forside
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const pageVariants = {
|
||||
initial: { opacity: 0, y: 30 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -30 },
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Game scene — active gameplay with scene text, dice roll, and history feed.
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import SceneMusic from '../SceneMusic';
|
||||
import VolumeControl from '../components/VolumeControl';
|
||||
import FloatingBg from '../components/FloatingBg';
|
||||
import DiceRoll from '../components/DiceRoll';
|
||||
|
||||
const API = window.location.origin.replace('3001', '8082');
|
||||
|
||||
export default function GameScenePage({ data, onRollDice, volume, setVolume }) {
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current && data && data.history && data.history.length > 0) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="game"
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.4 }}
|
||||
className="game"
|
||||
>
|
||||
<SceneMusic sceneType={volume > 0 ? 'game' : 'none'} />
|
||||
<div className="top-bar">
|
||||
<VolumeControl volume={volume} onChange={setVolume} />
|
||||
</div>
|
||||
<FloatingBg />
|
||||
<motion.div key={data.sceneNum} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="scene-header">
|
||||
<motion.div
|
||||
className="progress-bar-visual"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="progress-dot"
|
||||
style={{ backgroundColor: i < (data.history?.length || 0) ? (data.history[i]?.success ? '#4caf50' : '#ff9800') : '#ccc' }}
|
||||
animate={{ scale: i < (data.history?.length || 0) ? 1.3 : 1 }}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
<h1>{data.tema}</h1>
|
||||
</motion.div>
|
||||
|
||||
<motion.div key="pony" initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} className="pony-card-mini">
|
||||
<motion.img
|
||||
src={`${API}${data.ponyImg}`}
|
||||
alt={data.ponyName}
|
||||
className="pony-mini-img"
|
||||
animate={{ rotate: [-3, 3, -3] }}
|
||||
transition={{ duration: 3, repeat: Infinity }}
|
||||
/>
|
||||
<div className="pony-info">
|
||||
<strong>{data.ponyName}</strong>
|
||||
<span className="pony-type-badge">{data.ponyType}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="history-feed" ref={scrollRef}>
|
||||
<AnimatePresence>
|
||||
{(data.history || []).map((item, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 30, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ type: 'spring', bounce: 0.4 }}
|
||||
className={`history-feed-item ${item.success ? 'success' : 'fail'}`}
|
||||
>
|
||||
<div className="feed-action">
|
||||
<span className="feed-emoji">{item.success ? '✅' : '❌'}</span>
|
||||
{item.action}
|
||||
</div>
|
||||
<DiceRoll dice={item.dice} />
|
||||
<div className="feed-result">{item.result}</div>
|
||||
{item.story && <p className="feed-story">{item.story}</p>}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<motion.div key={data.sceneNum} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="scene-card">
|
||||
<div className="scene-number">{data.sceneNum}</div>
|
||||
<p className="scene-text">{data.sceneText}</p>
|
||||
<div className="scene-action">
|
||||
<span className="action-label">Du skal:</span>
|
||||
<span className="action-text">{data.actionText}</span>
|
||||
</div>
|
||||
<div className="scene-difficulty">{data.difficulty}</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="btn-roll"
|
||||
onClick={onRollDice}
|
||||
disabled={false}
|
||||
animate={{ rotate: [0, -10, 10, -10, 10, 0] }}
|
||||
transition={{ duration: 0.5 }}
|
||||
aria-label="Kast terningerne"
|
||||
>
|
||||
🎲 KAST TERNINGERNE! 🎲
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const pageVariants = {
|
||||
initial: { opacity: 0, y: 30 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -30 },
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Home page — title, subtitle, start button.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import SceneMusic from '../SceneMusic';
|
||||
import VolumeControl from '../components/VolumeControl';
|
||||
import FloatingBg from '../components/FloatingBg';
|
||||
import Sparkles from '../components/Sparkles';
|
||||
import Achievements from '../components/Achievements';
|
||||
|
||||
export default function HomePage({ volume, setVolume, stats, showAchievements, setShowAchievements, onNavigate }) {
|
||||
return (
|
||||
<motion.div
|
||||
key="home"
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.4 }}
|
||||
className="home"
|
||||
>
|
||||
<SceneMusic sceneType={volume > 0 ? 'home' : 'none'} />
|
||||
<div className="top-bar">
|
||||
<VolumeControl volume={volume} onChange={setVolume} />
|
||||
<button
|
||||
className="achievements-btn"
|
||||
onClick={() => setShowAchievements(!showAchievements)}
|
||||
title="Statistikk"
|
||||
aria-label="Åbn statistikk"
|
||||
>
|
||||
🏆
|
||||
</button>
|
||||
</div>
|
||||
{showAchievements && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="achievements-backdrop"
|
||||
onClick={() => setShowAchievements(false)}
|
||||
/>
|
||||
)}
|
||||
{showAchievements && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -20, scale: 0.9 }}
|
||||
className="achievements-overlay"
|
||||
role="dialog"
|
||||
aria-label="Statistikk"
|
||||
>
|
||||
<Achievements stats={stats} />
|
||||
<button className="btn-close" onClick={() => setShowAchievements(false)} aria-label="Luk statistikk">✕</button>
|
||||
</motion.div>
|
||||
)}
|
||||
<FloatingBg />
|
||||
<Sparkles />
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="home-content">
|
||||
<motion.div
|
||||
className="banner-emoji"
|
||||
animate={{ y: [0, -10, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
>
|
||||
🦄🌈✨
|
||||
</motion.div>
|
||||
<motion.h1
|
||||
animate={{ scale: [1, 1.05, 1], rotate: [0, 2, -2, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="title"
|
||||
>
|
||||
My Little Pony
|
||||
</motion.h1>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="subtitle"
|
||||
>
|
||||
Tails of Equestria
|
||||
</motion.h2>
|
||||
<motion.p className="home-desc" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.8 }}>
|
||||
Vælg din pony og gå på eventyr! 🎮
|
||||
</motion.p>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="btn-start"
|
||||
onClick={() => onNavigate('theme')}
|
||||
aria-label="Start nyt spil"
|
||||
>
|
||||
🎮 Start Nyt Spil!
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const pageVariants = {
|
||||
initial: { opacity: 0, y: 30 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -30 },
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Pony selection page — choose your character.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import SceneMusic from '../SceneMusic';
|
||||
import VolumeControl from '../components/VolumeControl';
|
||||
import FloatingBg from '../components/FloatingBg';
|
||||
|
||||
const API = window.location.origin.replace('3001', '8082');
|
||||
|
||||
export default function PonySelectPage({ ponies, onStartGame, volume, setVolume, onNavigate }) {
|
||||
return (
|
||||
<motion.div
|
||||
key="start"
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.4 }}
|
||||
className="start-page"
|
||||
>
|
||||
<SceneMusic sceneType={volume > 0 ? 'home' : 'none'} />
|
||||
<div className="top-bar">
|
||||
<VolumeControl volume={volume} onChange={setVolume} />
|
||||
</div>
|
||||
<FloatingBg />
|
||||
<motion.h1 initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="title">
|
||||
Vælg din Pony! 🐴
|
||||
</motion.h1>
|
||||
<motion.p className="page-desc" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }}>
|
||||
Hver pony har sine egne superkræfter!
|
||||
</motion.p>
|
||||
<div className="pony-choices">
|
||||
{ponies.map((p, i) => (
|
||||
<motion.div
|
||||
key={p.navn}
|
||||
whileHover={{ scale: 1.08, y: -8, rotate: [0, -2, 2, 0] }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.15 }}
|
||||
className="pony-card"
|
||||
style={{ borderColor: p.color }}
|
||||
onClick={() => onStartGame(i)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Vælg ${p.navn} - ${p.bonus}`}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onStartGame(i); }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: [0, 5, -5, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity, delay: i * 0.5 }}
|
||||
>
|
||||
<img src={`${API}/static/images/${p.img}`} alt={p.navn} />
|
||||
</motion.div>
|
||||
<div className="pony-emoji">{p.emoji}</div>
|
||||
<h3>{p.navn}</h3>
|
||||
<p className="pony-bonus">{p.bonus}</p>
|
||||
{p.diceBonus > 0 && (
|
||||
<p className="pony-dice-bonus">+{p.diceBonus} på første terning 🎲</p>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }} className="btn-back" onClick={() => onNavigate('home')} aria-label="Tilbage til forsiden">
|
||||
Tilbage
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const pageVariants = {
|
||||
initial: { opacity: 0, y: 30 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -30 },
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Theme selection page — choose which adventure to play.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import SceneMusic from '../SceneMusic';
|
||||
import VolumeControl from '../components/VolumeControl';
|
||||
import FloatingBg from '../components/FloatingBg';
|
||||
|
||||
export default function ThemeSelectPage({ themes, selectedTheme, setSelectedTheme, volume, setVolume, onNavigate }) {
|
||||
return (
|
||||
<motion.div
|
||||
key="theme"
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.4 }}
|
||||
className="start-page"
|
||||
>
|
||||
<SceneMusic sceneType={volume > 0 ? 'home' : 'none'} />
|
||||
<div className="top-bar">
|
||||
<VolumeControl volume={volume} onChange={setVolume} />
|
||||
</div>
|
||||
<FloatingBg />
|
||||
<motion.h1 initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} className="title">
|
||||
Vælg et eventyr! 📖
|
||||
</motion.h1>
|
||||
<motion.p className="page-desc" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }}>
|
||||
Hvilken historie vil du opleve?
|
||||
</motion.p>
|
||||
<div className="pony-choices">
|
||||
{themes.map((t, i) => (
|
||||
<motion.div
|
||||
key={t.id || i}
|
||||
whileHover={{ scale: 1.08, y: -8 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.15 }}
|
||||
className="pony-card"
|
||||
style={{ borderColor: selectedTheme === i ? '#f093fb' : 'transparent' }}
|
||||
onClick={() => { setSelectedTheme(i); onNavigate('start'); }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Vælg ${t.titel}`}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { setSelectedTheme(i); onNavigate('start'); } }}
|
||||
>
|
||||
<div className="pony-emoji">{t.emoji}</div>
|
||||
<h3>{t.titel}</h3>
|
||||
<p className="pony-bonus">{t.sceneCount || 5} scener</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }} className="btn-back" onClick={() => onNavigate('home')} aria-label="Tilbage til forsiden">
|
||||
Tilbage
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const pageVariants = {
|
||||
initial: { opacity: 0, y: 30 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -30 },
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* API service layer — all backend communication.
|
||||
*
|
||||
* Centralizes fetch calls so pages don't know about URLs or credentials.
|
||||
*/
|
||||
|
||||
const API = window.location.origin.replace('3001', '8082');
|
||||
|
||||
/**
|
||||
* Fetch and parse JSON, throwing on non-OK.
|
||||
*/
|
||||
async function apiFetch(path, options = {}) {
|
||||
const resp = await fetch(`${API}${path}`, {
|
||||
credentials: 'include',
|
||||
...options,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Server fejl (${resp.status})`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new game.
|
||||
* @param {number} typeIdx - pony type index
|
||||
* @param {number} temaIdx - theme index
|
||||
* @returns {Promise<Object>} game state JSON
|
||||
*/
|
||||
export async function startGame(typeIdx, temaIdx) {
|
||||
return apiFetch('/api/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: typeIdx, tema: temaIdx }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll dice for the current scene.
|
||||
* @returns {Promise<Object>} updated game state JSON
|
||||
*/
|
||||
export async function rollDice() {
|
||||
return apiFetch('/api/kast', { method: 'POST' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current scene data.
|
||||
* @returns {Promise<Object>} scene JSON
|
||||
*/
|
||||
export async function getScene() {
|
||||
return apiFetch('/api/scene');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the current game.
|
||||
*/
|
||||
export async function resetGame() {
|
||||
return apiFetch('/api/reset', { method: 'POST' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Load ponies + themes metadata.
|
||||
* @returns {Promise<{ponies: any[], themes: any[]}>}
|
||||
*/
|
||||
export async function loadContent() {
|
||||
try {
|
||||
const data = await apiFetch('/api/content');
|
||||
return { ponies: data.ponies, themes: data.themes };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check.
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function healthCheck() {
|
||||
try {
|
||||
const data = await apiFetch('/api/health');
|
||||
return !!data.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user