feat: turn pony configurator into a 4-step wizard, fix invisible horn/wings
- Replace the old single-screen pony-type select with a 4-step wizard: type -> mane -> colors -> tail/horn/wings, ending in "Start eventyr!" which starts the game with the chosen type. PonySelectPage is removed; the wizard is now the 'start' page. - Fix a real bug: the source sprite pack's horn.png/wing.png sheets turned out to be 1-3px alignment markers, not visible art, so toggling horn/wings previously had no visible effect. Replaced with hand-drawn flat overlay sprites (horn, wing, tail) positioned against the base sprite's actual silhouette. - Add a dedicated tail toggle/layer (the pack has no separate tail art either), using the same hand-drawn-overlay approach. - Update PixelPonySprite to support flat single-frame overlays alongside the existing 8x8 sheet layers. - Rewrite App.test.js flow helpers and PixelPonyConfiguratorPage tests for the new step-based flow (58 frontend tests passing). Note: App.js/App.test.js in this commit also carry in-flight interaction-choice plumbing (handleInteraction/CHOICE_SCENE) from a concurrently running agent in this same working tree — left as-is since splitting it out isn't possible without touching files that agent still has in progress, and the combined state passes the full test suite. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 631 B After Width: | Height: | Size: 122 B |
Binary file not shown.
|
After Width: | Height: | Size: 142 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 148 B |
+66
-18
@@ -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);
|
||||
|
||||
+26
-11
@@ -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' && (
|
||||
<PixelPonyConfiguratorPage
|
||||
volume={volume}
|
||||
setVolume={setVolume}
|
||||
onNavigate={navigateTo}
|
||||
/>
|
||||
)}
|
||||
|
||||
{page === 'start' && (
|
||||
<PonySelectPage
|
||||
<PixelPonyConfiguratorPage
|
||||
ponies={content.ponies}
|
||||
onSelectType={handleSelectPonyType}
|
||||
volume={volume}
|
||||
@@ -337,7 +350,9 @@ function App() {
|
||||
data={data}
|
||||
onRollDice={handleRollDice}
|
||||
onVoiceAnswer={handleVoiceAnswer}
|
||||
onInteract={handleInteraction}
|
||||
rolling={diceRolling}
|
||||
interactionBusy={interactionBusy}
|
||||
volume={volume}
|
||||
setVolume={setVolume}
|
||||
/>
|
||||
|
||||
@@ -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(<App />);
|
||||
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());
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: TILE * scale,
|
||||
height: TILE * scale,
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundImage: `url(${src})`,
|
||||
backgroundSize: `${sheetW}px ${sheetH}px`,
|
||||
backgroundPosition: `-${frame.col * TILE * scale}px -${frame.row * TILE * scale}px`,
|
||||
imageRendering: 'pixelated',
|
||||
filter,
|
||||
zIndex,
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 && <Layer src={WING_SPRITE} frame={frame} scale={scale} filter={bodyFilter} zIndex={1} />}
|
||||
<Layer src={BASE_SPRITE} frame={frame} scale={scale} filter={bodyFilter} zIndex={2} />
|
||||
{hasHorn && <Layer src={HORN_SPRITE} frame={frame} scale={scale} filter={bodyFilter} zIndex={3} />}
|
||||
<Layer src={maneStyle.file} frame={frame} scale={scale} filter={maneFilter} zIndex={4} />
|
||||
{hasWings && <Layer src={WING_SPRITE} frame={frame} scale={scale} filter="none" zIndex={0} sheet={false} />}
|
||||
<Layer src={BASE_SPRITE} frame={frame} scale={scale} filter={bodyFilter} zIndex={1} />
|
||||
{hasTail && <Layer src={TAIL_SPRITE} frame={frame} scale={scale} filter={maneFilter} zIndex={2} sheet={false} />}
|
||||
<Layer src={maneStyle.file} frame={frame} scale={scale} filter={maneFilter} zIndex={3} />
|
||||
{hasHorn && <Layer src={HORN_SPRITE} frame={frame} scale={scale} filter="none" zIndex={4} sheet={false} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,15 +99,6 @@ export default function HomePage({ volume, setVolume, stats, showAchievements, s
|
||||
>
|
||||
🎮 Start Nyt Spil!
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="btn-secondary"
|
||||
onClick={() => onNavigate('pixelConfigurator')}
|
||||
aria-label="Design din pixel pony"
|
||||
>
|
||||
🎨 Design din Pony
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<motion.div
|
||||
@@ -46,122 +77,167 @@ export default function PixelPonyConfiguratorPage({ volume, setVolume, onNavigat
|
||||
className="pixel-configurator"
|
||||
>
|
||||
<SceneMusic sceneType={volume > 0 ? 'home' : 'none'} />
|
||||
<Narrator text={narration} volume={volume} narrationKey="pixel-configurator" />
|
||||
<Narrator text={narration} volume={volume} narrationKey={`pixel-configurator-${stepName}`} />
|
||||
<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">
|
||||
Pixel Pony 🎨
|
||||
{STEP_TITLES[stepName]}
|
||||
</motion.h1>
|
||||
<p className="pixel-configurator-progress">Trin {step + 1} af {STEPS.length}</p>
|
||||
<SpeakButton text={narration} volume={volume} label="Læs siden højt" />
|
||||
|
||||
<div className="pixel-configurator-preview">
|
||||
<PixelPonySprite
|
||||
{...appearance}
|
||||
frame={bobFrame ? IDLE_FRAME_2 : IDLE_FRAME}
|
||||
scale={8}
|
||||
/>
|
||||
</div>
|
||||
{stepName !== 'type' && (
|
||||
<div className="pixel-configurator-preview">
|
||||
<PixelPonySprite
|
||||
{...appearance}
|
||||
frame={bobFrame ? IDLE_FRAME_2 : IDLE_FRAME}
|
||||
scale={8}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="pixel-configurator-section">
|
||||
<h2>Manke</h2>
|
||||
<div className="pixel-mane-grid">
|
||||
{MANE_STYLES.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
className={`pixel-mane-swatch ${appearance.mane === m.id ? 'is-selected' : ''}`}
|
||||
onClick={() => set('mane', m.id)}
|
||||
aria-label={`Vælg manke: ${m.label}`}
|
||||
aria-pressed={appearance.mane === m.id}
|
||||
title={m.label}
|
||||
{stepName === 'type' && (
|
||||
<div className="pony-choices">
|
||||
{ponies.map((p, i) => (
|
||||
<motion.div
|
||||
key={p.navn || p.name}
|
||||
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: p.color }}
|
||||
onClick={() => handlePickType(i)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Vælg ${p.navn || p.name} - ${p.bonus}`}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handlePickType(i); }}
|
||||
>
|
||||
<PixelPonySprite
|
||||
{...appearance}
|
||||
mane={m.id}
|
||||
hasHorn={false}
|
||||
hasWings={false}
|
||||
frame={IDLE_FRAME}
|
||||
scale={2}
|
||||
/>
|
||||
<span>{m.label}</span>
|
||||
<div className="pony-emoji">{p.emoji}</div>
|
||||
<h3>{p.navn || p.name}</h3>
|
||||
<p className="pony-bonus">{p.bonus}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stepName === 'mane' && (
|
||||
<section className="pixel-configurator-section">
|
||||
<div className="pixel-mane-grid">
|
||||
{MANE_STYLES.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
className={`pixel-mane-swatch ${appearance.mane === m.id ? 'is-selected' : ''}`}
|
||||
onClick={() => set('mane', m.id)}
|
||||
aria-label={`Vælg manke: ${m.label}`}
|
||||
aria-pressed={appearance.mane === m.id}
|
||||
title={m.label}
|
||||
>
|
||||
<PixelPonySprite
|
||||
{...appearance}
|
||||
mane={m.id}
|
||||
hasHorn={false}
|
||||
hasWings={false}
|
||||
hasTail={false}
|
||||
frame={IDLE_FRAME}
|
||||
scale={2}
|
||||
/>
|
||||
<span>{m.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{stepName === 'colors' && (
|
||||
<>
|
||||
<section className="pixel-configurator-section">
|
||||
<h2>Krop-farve</h2>
|
||||
<div className="pixel-color-row">
|
||||
{COLOR_OPTIONS.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className={`pixel-color-swatch ${appearance.bodyColor === c.id ? 'is-selected' : ''}`}
|
||||
style={{ filter: c.filter, backgroundColor: '#b5533f' }}
|
||||
onClick={() => set('bodyColor', c.id)}
|
||||
aria-label={`Kropsfarve: ${c.label}`}
|
||||
aria-pressed={appearance.bodyColor === c.id}
|
||||
title={c.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="pixel-configurator-section">
|
||||
<h2>Manke- og hale-farve</h2>
|
||||
<div className="pixel-color-row">
|
||||
{COLOR_OPTIONS.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className={`pixel-color-swatch ${appearance.maneColor === c.id ? 'is-selected' : ''}`}
|
||||
style={{ filter: c.filter, backgroundColor: '#f3a13f' }}
|
||||
onClick={() => set('maneColor', c.id)}
|
||||
aria-label={`Mankefarve: ${c.label}`}
|
||||
aria-pressed={appearance.maneColor === c.id}
|
||||
title={c.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stepName === 'extras' && (
|
||||
<section className="pixel-configurator-section">
|
||||
<div className="pixel-toggle-row">
|
||||
<button
|
||||
type="button"
|
||||
className={`pixel-toggle ${appearance.hasTail ? 'is-selected' : ''}`}
|
||||
onClick={() => set('hasTail', !appearance.hasTail)}
|
||||
aria-pressed={appearance.hasTail}
|
||||
>
|
||||
🐎 Hale
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pixel-configurator-section">
|
||||
<h2>Krop-farve</h2>
|
||||
<div className="pixel-color-row">
|
||||
{COLOR_OPTIONS.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className={`pixel-color-swatch ${appearance.bodyColor === c.id ? 'is-selected' : ''}`}
|
||||
style={{ filter: c.filter, backgroundColor: '#b5533f' }}
|
||||
onClick={() => set('bodyColor', c.id)}
|
||||
aria-label={`Kropsfarve: ${c.label}`}
|
||||
aria-pressed={appearance.bodyColor === c.id}
|
||||
title={c.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pixel-configurator-section">
|
||||
<h2>Manke-farve</h2>
|
||||
<div className="pixel-color-row">
|
||||
{COLOR_OPTIONS.map(c => (
|
||||
className={`pixel-toggle ${appearance.hasHorn ? 'is-selected' : ''}`}
|
||||
onClick={() => set('hasHorn', !appearance.hasHorn)}
|
||||
aria-pressed={appearance.hasHorn}
|
||||
>
|
||||
🦄 Horn
|
||||
</button>
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className={`pixel-color-swatch ${appearance.maneColor === c.id ? 'is-selected' : ''}`}
|
||||
style={{ filter: c.filter, backgroundColor: '#f3a13f' }}
|
||||
onClick={() => set('maneColor', c.id)}
|
||||
aria-label={`Mankefarve: ${c.label}`}
|
||||
aria-pressed={appearance.maneColor === c.id}
|
||||
title={c.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
className={`pixel-toggle ${appearance.hasWings ? 'is-selected' : ''}`}
|
||||
onClick={() => set('hasWings', !appearance.hasWings)}
|
||||
aria-pressed={appearance.hasWings}
|
||||
>
|
||||
🪽 Vinger
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="pixel-configurator-section">
|
||||
<h2>Ekstra</h2>
|
||||
<div className="pixel-toggle-row">
|
||||
<button
|
||||
type="button"
|
||||
className={`pixel-toggle ${appearance.hasHorn ? 'is-selected' : ''}`}
|
||||
onClick={() => set('hasHorn', !appearance.hasHorn)}
|
||||
aria-pressed={appearance.hasHorn}
|
||||
>
|
||||
🦄 Horn
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`pixel-toggle ${appearance.hasWings ? 'is-selected' : ''}`}
|
||||
onClick={() => set('hasWings', !appearance.hasWings)}
|
||||
aria-pressed={appearance.hasWings}
|
||||
>
|
||||
🪽 Vinger
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="btn-start"
|
||||
onClick={handleSave}
|
||||
aria-label="Gem min pony"
|
||||
>
|
||||
{saved ? '✅ Gemt!' : '💾 Gem min pony'}
|
||||
</motion.button>
|
||||
|
||||
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }} className="btn-back" onClick={() => onNavigate('home')} aria-label="Tilbage til forsiden">
|
||||
Tilbage
|
||||
</motion.button>
|
||||
<div className="pixel-configurator-nav">
|
||||
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }} className="btn-back" onClick={goBack} aria-label="Tilbage">
|
||||
Tilbage
|
||||
</motion.button>
|
||||
{stepName !== 'type' && stepName !== 'extras' && (
|
||||
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }} className="btn-start" onClick={goNext} aria-label="Næste trin">
|
||||
Næste ➡️
|
||||
</motion.button>
|
||||
)}
|
||||
{stepName === 'extras' && (
|
||||
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }} className="btn-start" onClick={handleStart} aria-label="Start eventyr">
|
||||
Start eventyr! 🎮
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(<PixelPonyConfiguratorPage volume={0.5} setVolume={() => {}} onNavigate={() => {}} />);
|
||||
function renderWizard(onSelectType = jest.fn()) {
|
||||
const onNavigate = jest.fn();
|
||||
render(
|
||||
<PixelPonyConfiguratorPage
|
||||
ponies={PONIES}
|
||||
onSelectType={onSelectType}
|
||||
volume={0.5}
|
||||
setVolume={() => {}}
|
||||
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(<PixelPonyConfiguratorPage volume={0.5} setVolume={() => {}} 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(<PixelPonyConfiguratorPage volume={0.5} setVolume={() => {}} 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(<PixelPonyConfiguratorPage volume={0.5} setVolume={() => {}} 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(<PixelPonyConfiguratorPage volume={0.5} setVolume={() => {}} 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);
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<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'} />
|
||||
<Narrator
|
||||
text={`Vælg din pony. ${choices} Tryk på den pony, du vil være.`}
|
||||
volume={volume}
|
||||
narrationKey="pony-selection"
|
||||
/>
|
||||
<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>
|
||||
<SpeakButton text={`Vælg din pony. ${choices}`} volume={volume} label="Læs alle ponyer højt" />
|
||||
<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={() => onSelectType(i)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Vælg ${p.navn} - ${p.bonus}`}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelectType(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>
|
||||
)}
|
||||
<SpeakButton
|
||||
text={`${p.navn || p.name}. ${p.bonus || ''}`}
|
||||
volume={volume}
|
||||
label={`Læs om ${p.navn || p.name}`}
|
||||
className="card-speak-button"
|
||||
/>
|
||||
</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 },
|
||||
};
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user