refactor: split App.js (884→319 lines) into pages/, components/, services/

This commit is contained in:
2026-08-09 08:01:00 +00:00
parent cfb0a95df3
commit 819a27ef67
14 changed files with 867 additions and 648 deletions
+84 -648
View File
@@ -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>
);
}
+28
View File
@@ -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>
);
}
+39
View File
@@ -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>
);
}
+30
View File
@@ -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>
);
}
+103
View File
@@ -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 },
};
+123
View File
@@ -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 },
};
+104
View File
@@ -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 },
};
+78
View File
@@ -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 },
};
+84
View File
@@ -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;
}
}