diff --git a/pony-frontend/public/sprites/pony/horn.png b/pony-frontend/public/sprites/pony/horn.png index df6c3da..10babd3 100644 Binary files a/pony-frontend/public/sprites/pony/horn.png and b/pony-frontend/public/sprites/pony/horn.png differ diff --git a/pony-frontend/public/sprites/pony/tail.png b/pony-frontend/public/sprites/pony/tail.png new file mode 100644 index 0000000..b771b7c Binary files /dev/null and b/pony-frontend/public/sprites/pony/tail.png differ diff --git a/pony-frontend/public/sprites/pony/wing.png b/pony-frontend/public/sprites/pony/wing.png index 74eeaf2..9ca81f3 100644 Binary files a/pony-frontend/public/sprites/pony/wing.png and b/pony-frontend/public/sprites/pony/wing.png differ diff --git a/pony-frontend/src/App.css b/pony-frontend/src/App.css index 759b4d7..c1a9492 100644 --- a/pony-frontend/src/App.css +++ b/pony-frontend/src/App.css @@ -164,24 +164,6 @@ body { transform: scale(1.05); } -.btn-secondary { - background: rgba(255, 255, 255, 0.85); - color: var(--color-accent); - border: none; - padding: var(--space-sm) var(--space-lg); - border-radius: var(--radius-pill); - font-size: 1rem; - cursor: pointer; - box-shadow: var(--shadow-sm); - margin-top: var(--space-md); - transition: transform 0.2s; -} - -.btn-secondary:hover { - transform: scale(1.05); - background: var(--color-white); -} - .btn-back { background: rgba(255, 255, 255, 0.2); color: var(--color-white); @@ -396,6 +378,21 @@ body { } /* --- Pixel pony configurator --- */ +.pixel-configurator-progress { + color: var(--color-white); + opacity: 0.85; + font-size: var(--font-small); + margin-bottom: var(--space-sm); +} + +.pixel-configurator-nav { + display: flex; + gap: var(--space-md); + justify-content: center; + align-items: center; + margin-top: var(--space-lg); +} + .pixel-configurator-preview { display: flex; justify-content: center; @@ -670,6 +667,57 @@ body { min-height: 132px; } +.interaction-prompt { + margin: var(--space-sm) 0; + padding: 0.65rem; + border-radius: var(--radius-sm); + background: #fff4c7; + color: #4a3975; + font-weight: 800; +} + +.interaction-feedback { + margin-top: var(--space-sm); + color: #a24600; + font-weight: 800; +} + +.interaction-options { + display: grid; + grid-template-columns: repeat(2, minmax(120px, 1fr)); + gap: 0.55rem; + width: min(430px, 62vw); +} + +.interaction-option { + min-height: 58px; + padding: 0.45rem 0.65rem; + border: 3px solid rgba(255, 255, 255, 0.9); + border-radius: var(--radius-md); + background: linear-gradient(135deg, var(--option-color, #f093fb), color-mix(in srgb, var(--option-color, #667eea) 65%, #333)); + color: #fff; + font: inherit; + font-weight: 900; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45); + box-shadow: var(--shadow-md); + cursor: pointer; +} + +.interaction-option:disabled { opacity: 0.55; cursor: wait; } +.interaction-option-emoji { display: block; font-size: 1.35rem; } + +.interaction-number .interaction-option, +.interaction-memory .interaction-option { + font-size: 1.2rem; + background: linear-gradient(135deg, #55c7ff, #667eea); +} + +@media (max-width: 600px) { + .game-controls { gap: 0.8rem; } + .interaction-options { width: min(310px, 66vw); } + .interaction-option { min-height: 52px; padding: 0.3rem; font-size: 0.8rem; } +} + .scene-number { background: var(--color-primary); color: var(--color-white); diff --git a/pony-frontend/src/App.js b/pony-frontend/src/App.js index e817b88..4bfd925 100644 --- a/pony-frontend/src/App.js +++ b/pony-frontend/src/App.js @@ -4,12 +4,11 @@ import SceneMusic, { playClick, playRoll, playSuccess, playFail, playSelect, pla import TutorialOverlay from './components/TutorialOverlay'; import HomePage from './pages/HomePage'; import ThemeSelectPage from './pages/ThemeSelectPage'; -import PonySelectPage from './pages/PonySelectPage'; import PixelPonyConfiguratorPage from './pages/PixelPonyConfiguratorPage'; import GameScenePage from './pages/GameScenePage'; import GameEndPage from './pages/GameEndPage'; import Narrator from './components/Narrator'; -import { prepareDanishSpeech } from './services/tts'; +import { prepareDanishSpeech, speakDanish } from './services/tts'; import { buildResultNarration } from './services/narration'; import * as api from './services/api'; import * as achievements from './services/achievements'; @@ -68,6 +67,7 @@ function App() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [diceRolling, setDiceRolling] = useState(false); + const [interactionBusy, setInteractionBusy] = useState(false); const [volume, setVolume] = useState(0.5); const [showAchievements, setShowAchievements] = useState(false); const [soundEnabled, setSoundEnabled] = useState(false); @@ -150,6 +150,27 @@ function App() { return result; }; + const handleInteraction = async (selection) => { + setInteractionBusy(true); + setError(null); + try { + const json = await api.chooseInteraction(selection); + setData(json); + if (json.interactionProgressed) { + const lastResult = json.history?.[json.history.length - 1]; + prepareDanishSpeech(buildResultNarration(lastResult)); + playSuccess(); + } else { + playFail(); + await speakDanish(json.interactionFeedback, volume); + } + } catch (e) { + setError('Kunne ikke vælge: ' + e.message); + } finally { + setInteractionBusy(false); + } + }; + const handleRetryRoll = async () => { setError(null); setDiceRolling(true); @@ -314,16 +335,8 @@ function App() { /> )} - {page === 'pixelConfigurator' && ( - - )} - {page === 'start' && ( - diff --git a/pony-frontend/src/App.test.js b/pony-frontend/src/App.test.js index bfd74de..121c055 100644 --- a/pony-frontend/src/App.test.js +++ b/pony-frontend/src/App.test.js @@ -45,6 +45,7 @@ jest.mock('./services/tts', () => ({ speakDanish: jest.fn(), cancelSpeech: jest.fn(), prepareDanishSpeech: jest.fn(), + NARRATION_STATUS_EVENT: 'pony-narration-status', })); // Mock Narrator @@ -125,6 +126,16 @@ function mockMultiFetch(responses) { const findByText = (t) => screen.getByText(t); +// Clicking a pony type on the configurator's first step only selects the +// type and advances to the mane step; two more "Næste" clicks reach the +// final step, whose "Start eventyr" button actually starts the game. +async function pickPonyAndStartGame(name = 'Jordpony') { + await userEvent.click(screen.getByText(name)); + await userEvent.click(screen.getByRole('button', { name: 'Næste trin' })); + await userEvent.click(screen.getByRole('button', { name: 'Næste trin' })); + await userEvent.click(screen.getByRole('button', { name: 'Start eventyr' })); +} + const CONTENT = { ponies: [ { navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' }, @@ -141,6 +152,21 @@ const GAME_SCENE = { ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [], }; +const CHOICE_SCENE = { + ...GAME_SCENE, + sceneNum: 2, + interaction: { + type: 'choice', + prompt: 'Hvordan vil du komme videre?', + options: [ + { id: 'modig', label: 'Vær modig', emoji: '🦁' }, + { id: 'klog', label: 'Tænk dig om', emoji: '💡' }, + { id: 'ven', label: 'Bed en ven om hjælp', emoji: '🤝' }, + { id: 'magi', label: 'Brug pony-magi', emoji: '✨' }, + ], + }, +}; + const VICTORY = { victory: true, finished: true, endText: 'Du vandt!', score: '100', history: [{ action: 'Kæmp', dice: [6, 6], result: 'Sejr!', success: true }], @@ -230,7 +256,7 @@ test('calls /api/start when selecting a pony', async () => { await dismissSoundPrompt(); await userEvent.click(findByText('🎮 Start Nyt Spil!')); await userEvent.click(findByText('Skyggen')); - await userEvent.click(screen.getByText('Jordpony')); + await pickPonyAndStartGame(); expect(SFX.playSelect).toHaveBeenCalled(); await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(2)); }); @@ -244,7 +270,7 @@ test('shows error when /api/start fails', async () => { await dismissSoundPrompt(); await userEvent.click(findByText('🎮 Start Nyt Spil!')); await userEvent.click(findByText('Skyggen')); - await userEvent.click(screen.getByText('Jordpony')); + await pickPonyAndStartGame(); await waitFor(() => expect(screen.getByText(/Kunne ikke starte spil/)).toBeInTheDocument()); }); @@ -269,7 +295,7 @@ test('renders scene information', async () => { await dismissSoundPrompt(); await userEvent.click(findByText('🎮 Start Nyt Spil!')); await userEvent.click(findByText('Skyggen')); - await userEvent.click(screen.getByText('Jordpony')); + await pickPonyAndStartGame(); await waitFor(() => expect(screen.getByText('Eventyr')).toBeInTheDocument()); expect(screen.getByText('Du møder en drage.')).toBeInTheDocument(); }); @@ -283,10 +309,25 @@ test('shows roll button in game', async () => { await dismissSoundPrompt(); await userEvent.click(findByText('🎮 Start Nyt Spil!')); await userEvent.click(findByText('Skyggen')); - await userEvent.click(screen.getByText('Jordpony')); + await pickPonyAndStartGame(); await waitFor(() => expect(screen.getByRole('button', { name: 'Kast terningerne' })).toBeInTheDocument()); }); +test('shows four story choices instead of dice in a choice scene', async () => { + mockMultiFetch([ + { body: CONTENT }, + { body: CHOICE_SCENE }, + ]); + render(); + await dismissSoundPrompt(); + await userEvent.click(findByText('🎮 Start Nyt Spil!')); + await userEvent.click(findByText('Skyggen')); + await pickPonyAndStartGame(); + await waitFor(() => expect(screen.getByText('Hvordan vil du komme videre?')).toBeInTheDocument()); + expect(screen.getAllByRole('button', { name: /^Vælg / })).toHaveLength(4); + expect(screen.queryByRole('button', { name: 'Kast terningerne' })).not.toBeInTheDocument(); +}); + test('calls /api/kast when rolling dice', async () => { mockMultiFetch([ { body: CONTENT }, @@ -297,7 +338,7 @@ test('calls /api/kast when rolling dice', async () => { await dismissSoundPrompt(); await userEvent.click(findByText('🎮 Start Nyt Spil!')); await userEvent.click(findByText('Skyggen')); - await userEvent.click(screen.getByText('Jordpony')); + await pickPonyAndStartGame(); await waitFor(() => expect(screen.getByRole('button', { name: 'Kast terningerne' })).toBeInTheDocument()); await userEvent.click(screen.getByRole('button', { name: 'Kast terningerne' })); expect(SFX.playRoll).toHaveBeenCalled(); @@ -315,7 +356,7 @@ test('shows victory message', async () => { await dismissSoundPrompt(); await userEvent.click(findByText('🎮 Start Nyt Spil!')); await userEvent.click(findByText('Skyggen')); - await userEvent.click(screen.getByText('Jordpony')); + await pickPonyAndStartGame(); await waitFor(() => expect(screen.getByRole('button', { name: 'Kast terningerne' })).toBeInTheDocument()); await userEvent.click(screen.getByRole('button', { name: 'Kast terningerne' })); await waitFor(() => expect(screen.getByText('🌟 SEJR! 🌟')).toBeInTheDocument()); @@ -332,7 +373,7 @@ test('shows defeat message for non-victory', async () => { await dismissSoundPrompt(); await userEvent.click(findByText('🎮 Start Nyt Spil!')); await userEvent.click(findByText('Skyggen')); - await userEvent.click(screen.getByText('Jordpony')); + await pickPonyAndStartGame(); await waitFor(() => expect(screen.getByRole('button', { name: 'Kast terningerne' })).toBeInTheDocument()); await userEvent.click(screen.getByRole('button', { name: 'Kast terningerne' })); await waitFor(() => expect(screen.getByText('💪 Prøv igen! 💪')).toBeInTheDocument()); @@ -348,7 +389,7 @@ test('Spil Igen navigates to pony selection', async () => { await dismissSoundPrompt(); await userEvent.click(findByText('🎮 Start Nyt Spil!')); await userEvent.click(findByText('Skyggen')); - await userEvent.click(screen.getByText('Jordpony')); + await pickPonyAndStartGame(); await waitFor(() => expect(screen.getByRole('button', { name: 'Kast terningerne' })).toBeInTheDocument()); await userEvent.click(screen.getByRole('button', { name: 'Kast terningerne' })); await waitFor(() => expect(screen.getByText('🌟 SEJR! 🌟')).toBeInTheDocument()); @@ -366,7 +407,7 @@ test('Forside button navigates to home', async () => { await dismissSoundPrompt(); await userEvent.click(findByText('🎮 Start Nyt Spil!')); await userEvent.click(findByText('Skyggen')); - await userEvent.click(screen.getByText('Jordpony')); + await pickPonyAndStartGame(); await waitFor(() => expect(screen.getByRole('button', { name: 'Kast terningerne' })).toBeInTheDocument()); await userEvent.click(screen.getByRole('button', { name: 'Kast terningerne' })); await waitFor(() => expect(screen.getByText('🌟 SEJR! 🌟')).toBeInTheDocument()); @@ -387,7 +428,7 @@ test('home -> theme -> pony -> game -> end -> home', async () => { expect(screen.getByText('Vælg et eventyr! 📖')).toBeInTheDocument(); await userEvent.click(findByText('Skyggen')); expect(screen.getByText('Vælg din Pony! 🐴')).toBeInTheDocument(); - await userEvent.click(screen.getByText('Jordpony')); + await pickPonyAndStartGame(); await waitFor(() => expect(screen.getByRole('button', { name: 'Kast terningerne' })).toBeInTheDocument()); await userEvent.click(screen.getByRole('button', { name: 'Kast terningerne' })); await waitFor(() => expect(screen.getByText('🌟 SEJR! 🌟')).toBeInTheDocument()); diff --git a/pony-frontend/src/components/PixelPonySprite.js b/pony-frontend/src/components/PixelPonySprite.js index d6b6b74..c001646 100644 --- a/pony-frontend/src/components/PixelPonySprite.js +++ b/pony-frontend/src/components/PixelPonySprite.js @@ -1,39 +1,46 @@ /** - * Renders a layered pixel-art pony from sprite sheets: body + mane + + * Renders a layered pixel-art pony from sprite sheets: body + mane + tail + * optional horn/wings, recolored with CSS filters. */ import React from 'react'; import { TILE, SHEET_COLS, SHEET_ROWS, IDLE_FRAME, - BASE_SPRITE, HORN_SPRITE, WING_SPRITE, + BASE_SPRITE, TAIL_SPRITE, HORN_SPRITE, WING_SPRITE, getManeStyle, getColorOption, } from '../pixelPony/spriteData'; -function Layer({ src, frame, scale, filter, zIndex }) { - const sheetW = SHEET_COLS * TILE * scale; - const sheetH = SHEET_ROWS * TILE * scale; +function Layer({ src, frame, scale, filter, zIndex, sheet = true }) { + const size = TILE * scale; + const style = sheet + ? { + backgroundSize: `${SHEET_COLS * size}px ${SHEET_ROWS * size}px`, + backgroundPosition: `-${frame.col * size}px -${frame.row * size}px`, + } + : { + backgroundSize: `${size}px ${size}px`, + backgroundPosition: '0 0', + }; return (
); } export default function PixelPonySprite({ - mane, bodyColor, maneColor, hasHorn, hasWings, + mane, bodyColor, maneColor, hasHorn, hasWings, hasTail, frame = IDLE_FRAME, scale = 4, className = '', }) { const maneStyle = getManeStyle(mane); @@ -45,10 +52,11 @@ export default function PixelPonySprite({ className={className} style={{ position: 'relative', width: TILE * scale, height: TILE * scale }} > - {hasWings && } - - {hasHorn && } - + {hasWings && } + + {hasTail && } + + {hasHorn && }
); } diff --git a/pony-frontend/src/pages/HomePage.js b/pony-frontend/src/pages/HomePage.js index 7f851dd..c343920 100644 --- a/pony-frontend/src/pages/HomePage.js +++ b/pony-frontend/src/pages/HomePage.js @@ -99,15 +99,6 @@ export default function HomePage({ volume, setVolume, stats, showAchievements, s > 🎮 Start Nyt Spil! - onNavigate('pixelConfigurator')} - aria-label="Design din pixel pony" - > - 🎨 Design din Pony - ); diff --git a/pony-frontend/src/pages/PixelPonyConfiguratorPage.js b/pony-frontend/src/pages/PixelPonyConfiguratorPage.js index 207e6a9..d2cd41f 100644 --- a/pony-frontend/src/pages/PixelPonyConfiguratorPage.js +++ b/pony-frontend/src/pages/PixelPonyConfiguratorPage.js @@ -1,5 +1,7 @@ /** - * Pixel Pony Configurator — design your own pixel-art pony. + * Pixel Pony Configurator — build your pony in steps: type, mane, colors, + * then tail/horn/wings. Replaces the old plain pony-type select screen; + * finishing the wizard starts the game with the chosen type. */ import React, { useEffect, useState } from 'react'; @@ -10,30 +12,59 @@ import FloatingBg from '../components/FloatingBg'; import Narrator from '../components/Narrator'; import SpeakButton from '../components/SpeakButton'; import PixelPonySprite from '../components/PixelPonySprite'; -import { MANE_STYLES, COLOR_OPTIONS, IDLE_FRAME, IDLE_FRAME_2 } from '../pixelPony/spriteData'; +import { MANE_STYLES, COLOR_OPTIONS, PONY_TYPES, IDLE_FRAME, IDLE_FRAME_2 } from '../pixelPony/spriteData'; import { loadAppearance, saveAppearance } from '../services/ponyAppearance'; -export default function PixelPonyConfiguratorPage({ volume, setVolume, onNavigate }) { +const STEPS = ['type', 'mane', 'colors', 'extras']; +const STEP_TITLES = { + type: 'Vælg din Pony! 🐴', + mane: 'Vælg manke 💇', + colors: 'Vælg farver 🎨', + extras: 'Hale, horn & vinger ✨', +}; + +export default function PixelPonyConfiguratorPage({ ponies, onSelectType, volume, setVolume, onNavigate }) { + const [step, setStep] = useState(0); + const [typeIdx, setTypeIdx] = useState(null); const [appearance, setAppearance] = useState(loadAppearance); const [bobFrame, setBobFrame] = useState(false); - const [saved, setSaved] = useState(false); useEffect(() => { const id = window.setInterval(() => setBobFrame(b => !b), 500); return () => window.clearInterval(id); }, []); - const set = (key, value) => { - setSaved(false); - setAppearance(prev => ({ ...prev, [key]: value })); + const set = (key, value) => setAppearance(prev => ({ ...prev, [key]: value })); + + const handlePickType = (idx) => { + const type = PONY_TYPES[idx] || PONY_TYPES[0]; + setTypeIdx(idx); + setAppearance(prev => ({ ...prev, ponyType: type.id, hasHorn: type.hasHorn, hasWings: type.hasWings })); + setStep(1); }; - const handleSave = () => { + const goBack = () => { + if (step === 0) { + onNavigate('home'); + } else { + setStep(s => s - 1); + } + }; + + const goNext = () => setStep(s => Math.min(s + 1, STEPS.length - 1)); + + const handleStart = () => { saveAppearance(appearance); - setSaved(true); + onSelectType(typeIdx ?? 0); }; - const narration = 'Design din egen pixel pony. Vælg manke, farver, horn og vinger. Tryk på gem-knappen når du er glad for din pony.'; + const stepName = STEPS[step]; + const narration = { + type: 'Vælg din pony type. Tryk på den pony du vil være.', + mane: 'Vælg en manke til din pony.', + colors: 'Vælg farver til krop, manke og hale.', + extras: 'Vælg om din pony skal have hale, horn og vinger. Tryk på start eventyr når du er klar.', + }[stepName]; return ( 0 ? 'home' : 'none'} /> - +
- Pixel Pony 🎨 + {STEP_TITLES[stepName]} +

Trin {step + 1} af {STEPS.length}

-
- -
+ {stepName !== 'type' && ( +
+ +
+ )} -
-

Manke

-
- {MANE_STYLES.map(m => ( - + ))} +
+
+ )} + + {stepName === 'colors' && ( + <> +
+

Krop-farve

+
+ {COLOR_OPTIONS.map(c => ( +
+
+
+

Manke- og hale-farve

+
+ {COLOR_OPTIONS.map(c => ( +
+
+ + )} + + {stepName === 'extras' && ( +
+
+ - ))} -
-
- -
-

Krop-farve

-
- {COLOR_OPTIONS.map(c => (
-
- -
-

Manke-farve

-
- {COLOR_OPTIONS.map(c => ( + className={`pixel-toggle ${appearance.hasHorn ? 'is-selected' : ''}`} + onClick={() => set('hasHorn', !appearance.hasHorn)} + aria-pressed={appearance.hasHorn} + > + 🦄 Horn +
-
+ className={`pixel-toggle ${appearance.hasWings ? 'is-selected' : ''}`} + onClick={() => set('hasWings', !appearance.hasWings)} + aria-pressed={appearance.hasWings} + > + 🪽 Vinger + + + + )} -
-

Ekstra

-
- - -
-
- - - {saved ? '✅ Gemt!' : '💾 Gem min pony'} - - - onNavigate('home')} aria-label="Tilbage til forsiden"> - Tilbage - +
+ + Tilbage + + {stepName !== 'type' && stepName !== 'extras' && ( + + Næste ➡️ + + )} + {stepName === 'extras' && ( + + Start eventyr! 🎮 + + )} +
); } diff --git a/pony-frontend/src/pages/PixelPonyConfiguratorPage.test.js b/pony-frontend/src/pages/PixelPonyConfiguratorPage.test.js index 66d5370..6d8a3ed 100644 --- a/pony-frontend/src/pages/PixelPonyConfiguratorPage.test.js +++ b/pony-frontend/src/pages/PixelPonyConfiguratorPage.test.js @@ -6,7 +6,7 @@ import PixelPonyConfiguratorPage from './PixelPonyConfiguratorPage'; jest.mock('framer-motion', () => { const React = require('react'); const motion = {}; - ['div', 'button', 'h1', 'h2', 'span'].forEach(tag => { + ['div', 'button', 'h1', 'h2', 'h3', 'p', 'span'].forEach(tag => { motion[tag] = ({ children, ...props }) => React.createElement(tag, { 'data-motion': 'true', ...props }, children); }); @@ -16,46 +16,96 @@ jest.mock('framer-motion', () => { jest.mock('../SceneMusic', () => function SceneMusic() { return null; }); jest.mock('../services/tts', () => ({ speakDanish: jest.fn(), cancelSpeech: jest.fn() })); +const PONIES = [ + { navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪' }, + { navn: 'Pegasus', emoji: '🦅', bonus: 'Flyver 🪽' }, + { navn: 'Enhjørning', emoji: '🦄', bonus: 'Magisk horn ✨' }, + { navn: 'Alicorn', emoji: '👑', bonus: 'Magi + vinger 🌟' }, +]; + beforeEach(() => { window.localStorage.clear(); }); -test('renders mane options, color swatches, and toggles', () => { - render( {}} onNavigate={() => {}} />); +function renderWizard(onSelectType = jest.fn()) { + const onNavigate = jest.fn(); + render( + {}} + onNavigate={onNavigate} + /> + ); + return { onNavigate, onSelectType }; +} + +async function pickTypeAndAdvanceToExtras() { + await userEvent.click(screen.getByRole('button', { name: 'Vælg Jordpony - Stærk 💪' })); + await userEvent.click(screen.getByRole('button', { name: 'Næste trin' })); // mane -> colors + await userEvent.click(screen.getByRole('button', { name: 'Næste trin' })); // colors -> extras +} + +test('step 1 shows all four pony types', () => { + renderWizard(); + expect(screen.getByText('Jordpony')).toBeInTheDocument(); + expect(screen.getByText('Pegasus')).toBeInTheDocument(); + expect(screen.getByText('Enhjørning')).toBeInTheDocument(); + expect(screen.getByText('Alicorn')).toBeInTheDocument(); + expect(screen.getByText('Trin 1 af 4')).toBeInTheDocument(); +}); + +test('picking a pony type advances to the mane step', async () => { + renderWizard(); + await userEvent.click(screen.getByRole('button', { name: 'Vælg Pegasus - Flyver 🪽' })); + expect(screen.getByText('Trin 2 af 4')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Vælg manke: Boglig' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: '🦄 Horn' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: '🪽 Vinger' })).toBeInTheDocument(); }); -test('selecting a mane style marks it as pressed', async () => { - render( {}} onNavigate={() => {}} />); - const bubbly = screen.getByRole('button', { name: 'Vælg manke: Boblende' }); - expect(bubbly).toHaveAttribute('aria-pressed', 'false'); - await userEvent.click(bubbly); - expect(bubbly).toHaveAttribute('aria-pressed', 'true'); +test('back on the first step exits to home', async () => { + const { onNavigate } = renderWizard(); + await userEvent.click(screen.getByRole('button', { name: 'Tilbage' })); + expect(onNavigate).toHaveBeenCalledWith('home'); }); -test('toggling wings flips its pressed state', async () => { - render( {}} onNavigate={() => {}} />); +test('back on a later step returns to the previous step, not home', async () => { + const { onNavigate } = renderWizard(); + await userEvent.click(screen.getByRole('button', { name: 'Vælg Jordpony - Stærk 💪' })); + await userEvent.click(screen.getByRole('button', { name: 'Tilbage' })); + expect(onNavigate).not.toHaveBeenCalled(); + expect(screen.getByText('Trin 1 af 4')).toBeInTheDocument(); +}); + +test('walks through mane, colors, and extras before starting the game', async () => { + const { onSelectType } = renderWizard(); + await pickTypeAndAdvanceToExtras(); + expect(screen.getByText('Trin 4 af 4')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Start eventyr' })); + expect(onSelectType).toHaveBeenCalledWith(0); +}); + +test('toggling horn and wings flips their pressed state', async () => { + renderWizard(); + await pickTypeAndAdvanceToExtras(); + const horn = screen.getByRole('button', { name: '🦄 Horn' }); const wings = screen.getByRole('button', { name: '🪽 Vinger' }); - expect(wings).toHaveAttribute('aria-pressed', 'false'); + expect(horn).toHaveAttribute('aria-pressed', 'false'); + await userEvent.click(horn); + expect(horn).toHaveAttribute('aria-pressed', 'true'); await userEvent.click(wings); expect(wings).toHaveAttribute('aria-pressed', 'true'); }); -test('saving persists the appearance to localStorage', async () => { - render( {}} onNavigate={() => {}} />); +test('starting the game saves the appearance to localStorage', async () => { + renderWizard(); + await userEvent.click(screen.getByRole('button', { name: 'Vælg Enhjørning - Magisk horn ✨' })); await userEvent.click(screen.getByRole('button', { name: 'Vælg manke: Boblende' })); - const saveButton = screen.getByRole('button', { name: 'Gem min pony' }); - await userEvent.click(saveButton); + await userEvent.click(screen.getByRole('button', { name: 'Næste trin' })); + await userEvent.click(screen.getByRole('button', { name: 'Næste trin' })); + await userEvent.click(screen.getByRole('button', { name: 'Start eventyr' })); const saved = JSON.parse(window.localStorage.getItem('pony_appearance')); expect(saved.mane).toBe('bubbly'); - expect(saveButton).toHaveTextContent('✅ Gemt!'); -}); - -test('back button navigates home', async () => { - const onNavigate = jest.fn(); - render( {}} onNavigate={onNavigate} />); - await userEvent.click(screen.getByRole('button', { name: 'Tilbage til forsiden' })); - expect(onNavigate).toHaveBeenCalledWith('home'); + expect(saved.ponyType).toBe('enhjorning'); + expect(saved.hasHorn).toBe(true); }); diff --git a/pony-frontend/src/pages/PonySelectPage.js b/pony-frontend/src/pages/PonySelectPage.js deleted file mode 100644 index 6fc434d..0000000 --- a/pony-frontend/src/pages/PonySelectPage.js +++ /dev/null @@ -1,95 +0,0 @@ -/** - * 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'; -import Narrator from '../components/Narrator'; -import SpeakButton from '../components/SpeakButton'; - -const API = window.location.origin.replace('3001', '8082'); - -export default function PonySelectPage({ ponies, onSelectType, volume, setVolume, onNavigate }) { - const choices = ponies.map((pony, index) => - `Mulighed ${index + 1}: ${pony.navn || pony.name}. ${pony.bonus || ''}.` - ).join(' '); - return ( - - 0 ? 'home' : 'none'} /> - -
- -
- - - Vælg din Pony! 🐴 - - - Hver pony har sine egne superkræfter! - - -
- {ponies.map((p, i) => ( - onSelectType(i)} - role="button" - tabIndex={0} - aria-label={`Vælg ${p.navn} - ${p.bonus}`} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelectType(i); }} - > - - {p.navn} - -
{p.emoji}
-

{p.navn}

-

{p.bonus}

- {p.diceBonus > 0 && ( -

+{p.diceBonus} på første terning 🎲

- )} - -
- ))} -
- onNavigate('home')} aria-label="Tilbage til forsiden"> - Tilbage - -
- ); -} - -const pageVariants = { - initial: { opacity: 0, y: 30 }, - animate: { opacity: 1, y: 0 }, - exit: { opacity: 0, y: -30 }, -}; diff --git a/pony-frontend/src/pixelPony/spriteData.js b/pony-frontend/src/pixelPony/spriteData.js index 31032c7..e977ab8 100644 --- a/pony-frontend/src/pixelPony/spriteData.js +++ b/pony-frontend/src/pixelPony/spriteData.js @@ -1,6 +1,9 @@ /** * Data for the pixel pony configurator: sprite sheet layout and available - * customization options. Sheets are 8x8 grids of 32x32 pixel frames. + * customization options. Body/mane/tail sheets are 8x8 grids of 32x32 + * frames. Horn/wing are small single-frame accent overlays (the source + * asset pack's horn/wing sheets are just 1-3px alignment markers, not + * visible art, so those two are hand-drawn flat 32x32 overlays instead). */ export const TILE = 32; @@ -13,9 +16,20 @@ export const IDLE_FRAME = { row: 0, col: 0 }; export const IDLE_FRAME_2 = { row: 0, col: 1 }; export const BASE_SPRITE = '/sprites/pony/base.png'; +// Flat single-frame overlays (not sheets) — always drawn at the same spot. +// The source pack's horn/wing/tail sheets are just 1-3px alignment markers, +// not visible art, so these three are hand-drawn accents instead. +export const TAIL_SPRITE = '/sprites/pony/tail.png'; export const HORN_SPRITE = '/sprites/pony/horn.png'; export const WING_SPRITE = '/sprites/pony/wing.png'; +export const PONY_TYPES = [ + { id: 'jordpony', label: 'Jordpony', hasHorn: false, hasWings: false }, + { id: 'pegasus', label: 'Pegasus', hasHorn: false, hasWings: true }, + { id: 'enhjorning', label: 'Enhjørning', hasHorn: true, hasWings: false }, + { id: 'alicorn', label: 'Alicorn', hasHorn: true, hasWings: true }, +]; + export const MANE_STYLES = [ { id: 'bookish', label: 'Boglig', file: '/sprites/pony/mane-bookish.png' }, { id: 'bubbly', label: 'Boblende', file: '/sprites/pony/mane-bubbly.png' }, @@ -49,10 +63,12 @@ export const COLOR_OPTIONS = [ ]; export const DEFAULT_APPEARANCE = { + ponyType: PONY_TYPES[0].id, mane: MANE_STYLES[0].id, bodyColor: COLOR_OPTIONS[1].id, maneColor: COLOR_OPTIONS[6].id, - hasHorn: true, + hasTail: true, + hasHorn: false, hasWings: false, }; @@ -63,3 +79,7 @@ export function getManeStyle(id) { export function getColorOption(id) { return COLOR_OPTIONS.find(c => c.id === id) || COLOR_OPTIONS[0]; } + +export function getPonyType(id) { + return PONY_TYPES.find(t => t.id === id) || PONY_TYPES[0]; +}