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:
BIN
pony-frontend/public/sprites/pony/eye.png
Normal file
BIN
pony-frontend/public/sprites/pony/eye.png
Normal file
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 |
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user