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:
2026-08-09 11:34:29 +00:00
co-authored by Claude Sonnet 5
parent 421c9259da
commit 64bc23409d
12 changed files with 449 additions and 295 deletions
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
View File
@@ -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
View File
@@ -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}
/>
+51 -10
View File
@@ -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());
+22 -14
View File
@@ -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>
);
}
-9
View File
@@ -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);
});
-95
View File
@@ -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} 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 },
};
+22 -2
View File
@@ -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];
}