fix: add eye/tail color steps, fix wing placement (was hidden behind the body)

- Wings were drawn *behind* the base body layer, so anything overlapping
  the torso silhouette was invisible — only a sliver peeked out. Redrew
  the wing art and moved it above the base layer so it visibly sits on
  the pony's back/spine instead.
- Eyes were baked into base.png and tinted by the body-color filter
  (so a blue pony got blue-tinted eyes). Extracted the eye pixels into
  their own flat overlay (eye.png) drawn on top with an independent
  eyeColor filter, and added a dedicated "Vælg øjenfarve" step.
- Split tail color from mane color (was tied to maneColor) and added its
  own "Vælg halefarve" step, isolated to just a color row like the other
  single-purpose steps.
- Wizard is now: type -> body -> eyes -> mane -> tail -> extras (6 steps).

Note: App.test.js in this commit also carries test additions from the
concurrently running agent (pixelpony image role, progressbar, game-controls
class assertions) — see prior commit note; same shared-working-tree
situation, verified via the full 62/62 passing suite before commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:51:22 +00:00
parent b8370edea5
commit bad62efba1
7 changed files with 146 additions and 59 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 B

After

Width:  |  Height:  |  Size: 159 B

View File

@@ -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();
});

View File

@@ -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 (
<div
className={className}
style={{ position: 'relative', width: TILE * scale, height: TILE * scale }}
>
{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} />
<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} />}
{hasWings && <Layer src={WING_SPRITE} frame={frame} scale={scale} filter="none" zIndex={2} sheet={false} />}
<Layer src={EYE_SPRITE} frame={frame} scale={scale} filter={eyeFilter} zIndex={3} sheet={false} />
<Layer src={TAIL_SPRITE} frame={frame} scale={scale} filter={tailFilter} zIndex={4} sheet={false} />
<Layer src={maneStyle.file} frame={frame} scale={scale} filter={maneFilter} zIndex={5} />
{hasHorn && <Layer src={HORN_SPRITE} frame={frame} scale={scale} filter="none" zIndex={6} sheet={false} />}
</div>
);
}

View File

@@ -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 (
<div className="pixel-color-row">
{options.map(c => (
<button
key={c.id}
type="button"
className={`pixel-color-swatch ${value === c.id ? 'is-selected' : ''}`}
style={{ filter: c.filter, backgroundColor: swatchColor }}
onClick={() => onPick(c.id)}
aria-label={`${labelPrefix}: ${c.label}`}
aria-pressed={value === c.id}
title={c.label}
/>
))}
</div>
);
}
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' && (
<section className="pixel-configurator-section">
<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>
<ColorSwatches
options={COLOR_OPTIONS}
value={appearance.bodyColor}
onPick={(id) => set('bodyColor', id)}
labelPrefix="Kropsfarve"
swatchColor="#b5533f"
/>
</section>
)}
{stepName === 'eyes' && (
<section className="pixel-configurator-section">
<ColorSwatches
options={COLOR_OPTIONS}
value={appearance.eyeColor}
onPick={(id) => set('eyeColor', id)}
labelPrefix="Øjenfarve"
swatchColor="#76d2fb"
/>
</section>
)}
@@ -166,24 +194,29 @@ export default function PixelPonyConfiguratorPage({ ponies, onSelectType, volume
</section>
<section className="pixel-configurator-section">
<h2>Mankefarve</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>
<ColorSwatches
options={COLOR_OPTIONS}
value={appearance.maneColor}
onPick={(id) => set('maneColor', id)}
labelPrefix="Mankefarve"
swatchColor="#f3a13f"
/>
</section>
</>
)}
{stepName === 'tail' && (
<section className="pixel-configurator-section">
<ColorSwatches
options={COLOR_OPTIONS}
value={appearance.tailColor}
onPick={(id) => set('tailColor', id)}
labelPrefix="Halefarve"
swatchColor="#f3a13f"
/>
</section>
)}
{stepName === 'extras' && (
<section className="pixel-configurator-section">
<div className="pixel-toggle-row">

View File

@@ -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');
});

View File

@@ -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,
};