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 => (
-
+
+
+ )}
-
- Ekstra
-
- set('hasHorn', !appearance.hasHorn)}
- aria-pressed={appearance.hasHorn}
- >
- 🦄 Horn
-
- set('hasWings', !appearance.hasWings)}
- aria-pressed={appearance.hasWings}
- >
- 🪽 Vinger
-
-
-
-
-
- {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.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];
+}