diff --git a/pony-frontend/public/sprites/pony/eye.png b/pony-frontend/public/sprites/pony/eye.png
new file mode 100644
index 0000000..acdcc7e
Binary files /dev/null and b/pony-frontend/public/sprites/pony/eye.png differ
diff --git a/pony-frontend/public/sprites/pony/wing.png b/pony-frontend/public/sprites/pony/wing.png
index 9ca81f3..bb91e57 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.test.js b/pony-frontend/src/App.test.js
index 121c055..b152a9c 100644
--- a/pony-frontend/src/App.test.js
+++ b/pony-frontend/src/App.test.js
@@ -127,12 +127,14 @@ 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.
+// type and advances to the body-color step; four more "Næste" clicks walk
+// through eyes/mane/tail/extras to 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' }));
+ for (let i = 0; i < 4; i++) {
+ await userEvent.click(screen.getByRole('button', { name: 'Næste trin' }));
+ }
await userEvent.click(screen.getByRole('button', { name: 'Start eventyr' }));
}
@@ -298,6 +300,9 @@ test('renders scene information', async () => {
await pickPonyAndStartGame();
await waitFor(() => expect(screen.getByText('Eventyr')).toBeInTheDocument());
expect(screen.getByText('Du møder en drage.')).toBeInTheDocument();
+ expect(screen.getByRole('img', { name: 'Jordpony, din pixelpony' })).toBeInTheDocument();
+ expect(screen.queryByRole('img', { name: 'Jordpony' })).not.toBeInTheDocument();
+ expect(screen.getByRole('progressbar', { name: /lytte|oplæsning|fortæller/i })).toBeInTheDocument();
});
test('shows roll button in game', async () => {
@@ -324,7 +329,9 @@ test('shows four story choices instead of dice in a choice scene', async () => {
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);
+ const choices = screen.getAllByRole('button', { name: /^Vælg / });
+ expect(choices).toHaveLength(4);
+ expect(choices[0].closest('.game-controls')).toHaveClass('game-controls-options');
expect(screen.queryByRole('button', { name: 'Kast terningerne' })).not.toBeInTheDocument();
});
diff --git a/pony-frontend/src/components/PixelPonySprite.js b/pony-frontend/src/components/PixelPonySprite.js
index 035f65a..03bc97a 100644
--- a/pony-frontend/src/components/PixelPonySprite.js
+++ b/pony-frontend/src/components/PixelPonySprite.js
@@ -1,12 +1,12 @@
/**
- * Renders a layered pixel-art pony from sprite sheets: body + tail + mane +
- * optional horn/wings, recolored with CSS filters.
+ * Renders a layered pixel-art pony from sprite sheets: body + eyes + tail +
+ * mane + optional horn/wings, recolored with CSS filters.
*/
import React from 'react';
import {
TILE, SHEET_COLS, SHEET_ROWS, IDLE_FRAME,
- BASE_SPRITE, TAIL_SPRITE, HORN_SPRITE, WING_SPRITE,
+ BASE_SPRITE, EYE_SPRITE, TAIL_SPRITE, HORN_SPRITE, WING_SPRITE,
getManeStyle, getColorOption,
} from '../pixelPony/spriteData';
@@ -40,23 +40,26 @@ function Layer({ src, frame, scale, filter, zIndex, sheet = true }) {
}
export default function PixelPonySprite({
- mane, bodyColor, maneColor, hasHorn, hasWings,
+ mane, bodyColor, maneColor, eyeColor, tailColor, hasHorn, hasWings,
frame = IDLE_FRAME, scale = 4, className = '',
}) {
const maneStyle = getManeStyle(mane);
const bodyFilter = getColorOption(bodyColor).filter;
const maneFilter = getColorOption(maneColor).filter;
+ const eyeFilter = getColorOption(eyeColor).filter;
+ const tailFilter = getColorOption(tailColor).filter;
return (
- {hasWings && }
-
-
- {hasHorn && }
+ {hasWings && }
+
+
+
+ {hasHorn && }
);
}
diff --git a/pony-frontend/src/pages/PixelPonyConfiguratorPage.js b/pony-frontend/src/pages/PixelPonyConfiguratorPage.js
index 510d397..ac8ee2b 100644
--- a/pony-frontend/src/pages/PixelPonyConfiguratorPage.js
+++ b/pony-frontend/src/pages/PixelPonyConfiguratorPage.js
@@ -1,7 +1,7 @@
/**
* Pixel Pony Configurator — build your pony in steps: type, body color,
- * mane, then horn/wings. Replaces the old plain pony-type select screen;
- * finishing the wizard starts the game with the chosen type.
+ * eyes, mane, tail, then 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';
@@ -15,14 +15,35 @@ import PixelPonySprite, { ManeIcon } from '../components/PixelPonySprite';
import { MANE_STYLES, COLOR_OPTIONS, PONY_TYPES, IDLE_FRAME, IDLE_FRAME_2 } from '../pixelPony/spriteData';
import { loadAppearance, saveAppearance } from '../services/ponyAppearance';
-const STEPS = ['type', 'body', 'mane', 'extras'];
+const STEPS = ['type', 'body', 'eyes', 'mane', 'tail', 'extras'];
const STEP_TITLES = {
type: 'Vælg din Pony! 🐴',
body: 'Vælg krop-farve 🎨',
+ eyes: 'Vælg øjenfarve 👀',
mane: 'Vælg manke 💇',
+ tail: 'Vælg halefarve 🐎',
extras: 'Horn & vinger ✨',
};
+function ColorSwatches({ options, value, onPick, labelPrefix, swatchColor }) {
+ return (
+
+ {options.map(c => (
+ onPick(c.id)}
+ aria-label={`${labelPrefix}: ${c.label}`}
+ aria-pressed={value === c.id}
+ title={c.label}
+ />
+ ))}
+
+ );
+}
+
export default function PixelPonyConfiguratorPage({ ponies, onSelectType, volume, setVolume, onNavigate }) {
const [step, setStep] = useState(0);
const [typeIdx, setTypeIdx] = useState(null);
@@ -62,7 +83,9 @@ export default function PixelPonyConfiguratorPage({ ponies, onSelectType, volume
const narration = {
type: 'Vælg din pony type. Tryk på den pony du vil være.',
body: 'Vælg en farve til din ponys krop.',
+ eyes: 'Vælg en farve til din ponys øjne.',
mane: 'Vælg en manke og en mankefarve til din pony.',
+ tail: 'Vælg en farve til din ponys hale.',
extras: 'Vælg om din pony skal have horn og vinger. Tryk på start eventyr når du er klar.',
}[stepName];
@@ -126,20 +149,25 @@ export default function PixelPonyConfiguratorPage({ ponies, onSelectType, volume
{stepName === 'body' && (
-
- {COLOR_OPTIONS.map(c => (
- set('bodyColor', c.id)}
- aria-label={`Kropsfarve: ${c.label}`}
- aria-pressed={appearance.bodyColor === c.id}
- title={c.label}
- />
- ))}
-
+ set('bodyColor', id)}
+ labelPrefix="Kropsfarve"
+ swatchColor="#b5533f"
+ />
+
+ )}
+
+ {stepName === 'eyes' && (
+
+ set('eyeColor', id)}
+ labelPrefix="Øjenfarve"
+ swatchColor="#76d2fb"
+ />
)}
@@ -166,24 +194,29 @@ export default function PixelPonyConfiguratorPage({ ponies, onSelectType, volume
Mankefarve
-
- {COLOR_OPTIONS.map(c => (
- set('maneColor', c.id)}
- aria-label={`Mankefarve: ${c.label}`}
- aria-pressed={appearance.maneColor === c.id}
- title={c.label}
- />
- ))}
-
+ set('maneColor', id)}
+ labelPrefix="Mankefarve"
+ swatchColor="#f3a13f"
+ />
>
)}
+ {stepName === 'tail' && (
+
+ set('tailColor', id)}
+ labelPrefix="Halefarve"
+ swatchColor="#f3a13f"
+ />
+
+ )}
+
{stepName === 'extras' && (
diff --git a/pony-frontend/src/pages/PixelPonyConfiguratorPage.test.js b/pony-frontend/src/pages/PixelPonyConfiguratorPage.test.js
index 62acf48..38cbb66 100644
--- a/pony-frontend/src/pages/PixelPonyConfiguratorPage.test.js
+++ b/pony-frontend/src/pages/PixelPonyConfiguratorPage.test.js
@@ -23,6 +23,9 @@ const PONIES = [
{ navn: 'Alicorn', emoji: '👑', bonus: 'Magi + vinger 🌟' },
];
+// type -> body -> eyes -> mane -> tail -> extras
+const STEP_COUNT = 6;
+
beforeEach(() => {
window.localStorage.clear();
});
@@ -45,8 +48,10 @@ const next = () => userEvent.click(screen.getByRole('button', { name: 'Næste tr
async function pickTypeAndAdvanceToExtras() {
await userEvent.click(screen.getByRole('button', { name: 'Vælg Jordpony - Stærk 💪' }));
- await next(); // body -> mane
- await next(); // mane -> extras
+ await next(); // body -> eyes
+ await next(); // eyes -> mane
+ await next(); // mane -> tail
+ await next(); // tail -> extras
}
test('step 1 shows all four pony types', () => {
@@ -55,25 +60,47 @@ test('step 1 shows all four pony types', () => {
expect(screen.getByText('Pegasus')).toBeInTheDocument();
expect(screen.getByText('Enhjørning')).toBeInTheDocument();
expect(screen.getByText('Alicorn')).toBeInTheDocument();
- expect(screen.getByText('Trin 1 af 4')).toBeInTheDocument();
+ expect(screen.getByText(`Trin 1 af ${STEP_COUNT}`)).toBeInTheDocument();
});
test('picking a pony type advances to the body-color step, not mane', async () => {
renderWizard();
await userEvent.click(screen.getByRole('button', { name: 'Vælg Pegasus - Flyver 🪽' }));
- expect(screen.getByText('Trin 2 af 4')).toBeInTheDocument();
+ expect(screen.getByText(`Trin 2 af ${STEP_COUNT}`)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Kropsfarve: Lilla' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Vælg manke: Boglig' })).not.toBeInTheDocument();
});
-test('mane step shows only mane swatches, not the color pickers', async () => {
+test('mane step shows only mane swatches, not the body/eye/tail color pickers', async () => {
renderWizard();
await userEvent.click(screen.getByRole('button', { name: 'Vælg Jordpony - Stærk 💪' }));
- await next(); // body -> mane
- expect(screen.getByText('Trin 3 af 4')).toBeInTheDocument();
+ await next(); // body -> eyes
+ await next(); // eyes -> mane
+ expect(screen.getByText(`Trin 4 af ${STEP_COUNT}`)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Vælg manke: Boglig' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Mankefarve: Gul' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Kropsfarve: Lilla' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Øjenfarve: Lilla' })).not.toBeInTheDocument();
+});
+
+test('eyes step shows only eye-color swatches', async () => {
+ renderWizard();
+ await userEvent.click(screen.getByRole('button', { name: 'Vælg Jordpony - Stærk 💪' }));
+ await next(); // body -> eyes
+ expect(screen.getByText(`Trin 3 af ${STEP_COUNT}`)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Øjenfarve: Blå' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Kropsfarve: Blå' })).not.toBeInTheDocument();
+});
+
+test('tail step shows only tail-color swatches', async () => {
+ renderWizard();
+ await userEvent.click(screen.getByRole('button', { name: 'Vælg Jordpony - Stærk 💪' }));
+ await next(); // body -> eyes
+ await next(); // eyes -> mane
+ await next(); // mane -> tail
+ expect(screen.getByText(`Trin 5 af ${STEP_COUNT}`)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Halefarve: Grøn' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Vælg manke: Boglig' })).not.toBeInTheDocument();
});
test('back on the first step exits to home', async () => {
@@ -87,17 +114,25 @@ test('back on a later step returns to the previous step, not home', async () =>
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();
+ expect(screen.getByText('Trin 1 af 6')).toBeInTheDocument();
});
-test('walks through body color and mane before starting the game', async () => {
+test('walks through every step before starting the game', async () => {
const { onSelectType } = renderWizard();
await pickTypeAndAdvanceToExtras();
- expect(screen.getByText('Trin 4 af 4')).toBeInTheDocument();
+ expect(screen.getByText(`Trin ${STEP_COUNT} af ${STEP_COUNT}`)).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Start eventyr' }));
expect(onSelectType).toHaveBeenCalledWith(0);
});
+test('extras step has no tail toggle — every pony always has a tail', async () => {
+ renderWizard();
+ await pickTypeAndAdvanceToExtras();
+ expect(screen.queryByRole('button', { name: /Hale/ })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '🦄 Horn' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '🪽 Vinger' })).toBeInTheDocument();
+});
+
test('toggling horn and wings flips their pressed state', async () => {
renderWizard();
await pickTypeAndAdvanceToExtras();
@@ -110,15 +145,21 @@ test('toggling horn and wings flips their pressed state', async () => {
expect(wings).toHaveAttribute('aria-pressed', 'true');
});
-test('starting the game saves the appearance to localStorage', async () => {
+test('starting the game saves the full appearance to localStorage', async () => {
renderWizard();
await userEvent.click(screen.getByRole('button', { name: 'Vælg Enhjørning - Magisk horn ✨' }));
- await next(); // body -> mane
+ await next(); // body -> eyes
+ await userEvent.click(screen.getByRole('button', { name: 'Øjenfarve: Blå' }));
+ await next(); // eyes -> mane
await userEvent.click(screen.getByRole('button', { name: 'Vælg manke: Boblende' }));
- await next(); // mane -> extras
+ await next(); // mane -> tail
+ await userEvent.click(screen.getByRole('button', { name: 'Halefarve: Grøn' }));
+ await next(); // tail -> extras
await userEvent.click(screen.getByRole('button', { name: 'Start eventyr' }));
const saved = JSON.parse(window.localStorage.getItem('pony_appearance'));
expect(saved.mane).toBe('bubbly');
expect(saved.ponyType).toBe('enhjorning');
expect(saved.hasHorn).toBe(true);
+ expect(saved.eyeColor).toBe('blue');
+ expect(saved.tailColor).toBe('green');
});
diff --git a/pony-frontend/src/pixelPony/spriteData.js b/pony-frontend/src/pixelPony/spriteData.js
index 9494c0d..155f9cd 100644
--- a/pony-frontend/src/pixelPony/spriteData.js
+++ b/pony-frontend/src/pixelPony/spriteData.js
@@ -18,7 +18,8 @@ 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.
+// not visible art, so these four are hand-drawn accents instead.
+export const EYE_SPRITE = '/sprites/pony/eye.png';
export const TAIL_SPRITE = '/sprites/pony/tail.png';
export const HORN_SPRITE = '/sprites/pony/horn.png';
export const WING_SPRITE = '/sprites/pony/wing.png';
@@ -51,7 +52,7 @@ export const MANE_STYLES = [
];
export const COLOR_OPTIONS = [
- { id: 'original', label: 'Rødbrun', filter: 'none' },
+ { id: 'original', label: 'Original', filter: 'none' },
{ id: 'pink', label: 'Lyserød', filter: 'hue-rotate(300deg) saturate(1.3)' },
{ id: 'purple', label: 'Lilla', filter: 'hue-rotate(220deg) saturate(1.4)' },
{ id: 'blue', label: 'Blå', filter: 'hue-rotate(150deg) saturate(1.5)' },
@@ -67,6 +68,8 @@ export const DEFAULT_APPEARANCE = {
mane: MANE_STYLES[0].id,
bodyColor: COLOR_OPTIONS[1].id,
maneColor: COLOR_OPTIONS[6].id,
+ tailColor: COLOR_OPTIONS[6].id,
+ eyeColor: COLOR_OPTIONS[0].id,
hasHorn: false,
hasWings: false,
};