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 => ( +
+ ); +} + 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', 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', 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, };