feat(pony): add walkable 2D Pony Farm (Ponystalden) using the R4 walk gait

This commit is contained in:
agent
2026-09-28 14:56:13 +00:00
parent f8de503004
commit 7efb23ff26
4 changed files with 458 additions and 0 deletions
+9
View File
@@ -8,6 +8,7 @@ import PixelPonyConfiguratorPage from './pages/PixelPonyConfiguratorPage';
import GameScenePage from './pages/GameScenePage';
import GameEndPage from './pages/GameEndPage';
import StatsPage from './pages/StatsPage';
import PonyFarmPage from './pages/PonyFarmPage';
import Narrator from './components/Narrator';
import FullscreenButton from './components/FullscreenButton';
import { prepareDanishSpeech, speakDanish } from './services/tts';
@@ -388,6 +389,14 @@ function App() {
{page === 'stats' && (
<StatsPage onNavigate={navigateTo} />
)}
{page === 'farm' && (
<PonyFarmPage
onNavigate={navigateTo}
volume={volume}
setVolume={setVolume}
/>
)}
</>
)}
</AnimatePresence>
+9
View File
@@ -107,6 +107,15 @@ export default function HomePage({ volume, setVolume, stats, showAchievements, s
>
🎮 Start Nyt Spil!
</motion.button>
<motion.button
whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.95 }}
className="btn-start btn-farm"
onClick={() => onNavigate('farm')}
aria-label="Gå til ponystalden"
>
🐴 Ponystalden
</motion.button>
</motion.div>
</motion.div>
);
+132
View File
@@ -0,0 +1,132 @@
.farm-page {
display: flex;
flex-direction: column;
height: 100%;
background: #2a2e35;
}
.farm-topbar {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
background: rgba(0, 0, 0, 0.35);
}
.farm-topbar .btn-back {
flex: 0 0 auto;
}
.farm-title {
flex: 1 1 auto;
display: flex;
align-items: center;
gap: 0.4rem;
color: #fff;
font-weight: 700;
font-size: 1.05rem;
}
.farm-title-emoji {
font-size: 1.3rem;
}
.farm-hud {
flex: 0 0 auto;
}
.farm-vol {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: #fff;
}
.farm-vol input[type='range'] {
width: 80px;
}
.farm-stage {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
min-height: 0;
}
.farm-canvas {
width: 100%;
max-width: 640px;
aspect-ratio: 320 / 220;
image-rendering: pixelated;
image-rendering: crisp-edges;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
background: #6fb95a;
touch-action: none;
}
.farm-controls {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 0.9rem 0.9rem;
}
.farm-dpad {
display: grid;
grid-template-columns: repeat(3, 44px);
grid-template-rows: repeat(3, 44px);
gap: 4px;
}
.farm-dpad .farm-dpad-mid {
align-self: center;
justify-self: center;
font-size: 1.6rem;
}
.farm-dpad > span {
display: block;
}
.farm-dir {
width: 44px;
height: 44px;
border: none;
border-radius: 10px;
background: #f7c948;
color: #3a2c10;
font-size: 1.1rem;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 3px 0 rgba(0, 0, 0, 0.3);
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.farm-dir:active {
transform: translateY(2px);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.3);
background: #ffd968;
}
.farm-hint {
flex: 1 1 auto;
color: #cfd4dc;
font-size: 0.9rem;
}
.farm-hint p {
margin: 0;
}
@media (max-width: 420px) {
.farm-hint {
display: none;
}
}
+308
View File
@@ -0,0 +1,308 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { loadAppearance } from '../services/ponyAppearance';
import { playClick, playSuccess } from '../SceneMusic';
import './PonyFarmPage.css';
const SPRITE_SIZE = 32;
// The body (base) and mane sheets contain a walk gait on row 4. Only these
// columns are clean frames — column 3 is a broken blob and column 7 is empty.
const WALK = [0, 1, 2, 4, 5, 6];
const IDLE_COL = 0; // shared standing pose at (row 0, col 0) across every sheet
const WALK_FPS_MS = 110; // ~9 fps gait while moving
const COLOR_FILTERS = {
original: 'none',
pink: 'hue-rotate(330deg) saturate(1.3)',
purple: 'hue-rotate(275deg) saturate(1.4)',
blue: 'hue-rotate(215deg) saturate(1.5)',
teal: 'hue-rotate(180deg) saturate(1.4)',
green: 'hue-rotate(125deg) saturate(1.3)',
yellow: 'hue-rotate(50deg) saturate(1.5) brightness(1.15)',
white: 'saturate(0.15) brightness(1.7)',
black: 'brightness(0.35)',
};
// Build the pony's layer stack (back → front) from the child's config.
// Only base + mane have a walk gait (row 4); the rest are single-frame
// overlays that always sit on the idle pose.
function buildLayers(appearance) {
const layers = [
{ id: 'base', file: '/sprites/pony/base.png', colorKey: 'bodyColor', animated: true },
{ id: 'tail', file: `/sprites/pony/tail-${appearance.tail}.png`, colorKey: 'tailColor', animated: false },
{ id: 'mane', file: `/sprites/pony/mane-${appearance.mane}.png`, colorKey: 'maneColor', animated: true },
];
if (appearance.hasWings) layers.push({ id: 'wing', file: `/sprites/pony/wing-${appearance.wing}.png`, colorKey: 'wingColor', animated: false });
if (appearance.hasHorn) layers.push({ id: 'horn', file: `/sprites/pony/horn-${appearance.horn}.png`, colorKey: 'hornColor', animated: false });
layers.push({ id: 'eye', file: '/sprites/pony/eye.png', colorKey: 'eyeColor', animated: false });
return layers;
}
function PonyFarmPage({ onNavigate, volume, setVolume }) {
const [appearance] = useState(loadAppearance);
const [layers] = useState(() => buildLayers(appearance));
const canvasRef = useRef(null);
const layerCanvasRef = useRef(null);
const sheetsRef = useRef({});
const worldRef = useRef({ x: 96, y: 70, facing: 1 });
const keysRef = useRef({});
const walkFrameRef = useRef(0);
const lastWalkTickRef = useRef(0);
const goalRef = useRef(null);
const [goal, setGoal] = useState(null);
// Preload every layer sheet once.
useEffect(() => {
let alive = true;
Promise.all(layers.map((l) => new Promise((res) => {
const img = new Image();
const ok = () => res(img);
img.onload = ok; img.onerror = ok;
img.src = l.file;
}))).then((imgs) => {
if (!alive) return;
const map = {};
layers.forEach((l, i) => { map[l.id] = imgs[i]; });
sheetsRef.current = map;
});
return () => { alive = false; };
}, [layers]);
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const start = useCallback((x, y) => {
const world = worldRef.current;
world.facing = x < world.x ? -1 : 1;
const g = { x: clamp(x, 24, 296), y: clamp(y, 40, 188), hit: false };
goalRef.current = g; setGoal(g);
}, []);
// Keyboard: arrow keys + WASD.
useEffect(() => {
const map = {
arrowup: 'up', w: 'up', arrowdown: 'down', s: 'down',
arrowleft: 'left', a: 'left', arrowright: 'right', d: 'right',
};
const kd = (e) => { if (map[e.key.toLowerCase()]) { e.preventDefault(); keysRef.current[e.key.toLowerCase()] = true; } };
const ku = (e) => { if (map[e.key.toLowerCase()]) keysRef.current[e.key.toLowerCase()] = false; };
window.addEventListener('keydown', kd);
window.addEventListener('keyup', ku);
return () => { window.removeEventListener('keydown', kd); window.removeEventListener('keyup', ku); };
}, []);
const getLayerCanvas = () => {
if (!layerCanvasRef.current) {
const c = document.createElement('canvas');
c.width = SPRITE_SIZE; c.height = SPRITE_SIZE;
layerCanvasRef.current = c;
}
return layerCanvasRef.current.getContext('2d');
};
// Animation loop — one requestAnimationFrame drives everything.
useEffect(() => {
let raf;
const step = (t) => {
const canvas = canvasRef.current;
const ctx = canvas && canvas.getContext('2d');
if (ctx) draw(ctx, t);
raf = requestAnimationFrame(step);
};
const draw = (ctx, t) => {
const world = worldRef.current;
const W = 320, H = 220;
ctx.imageSmoothingEnabled = false;
ctx.filter = 'none';
// ---- background: grass with vertical stripes, sky strip, fence ----
ctx.fillStyle = '#6fb95a'; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#79c466';
for (let i = 0; i < W; i += 16) ctx.fillRect(i, 0, 8, H);
ctx.fillStyle = '#bfe3ff'; ctx.fillRect(0, 0, W, 18);
ctx.fillStyle = '#9c6b34';
for (let i = 6; i < W; i += 22) ctx.fillRect(i, 14, 3, 12);
ctx.fillStyle = '#b07c41'; ctx.fillRect(0, 15, W, 3);
drawFlower(ctx, 34, 150, '#ffd23f');
drawFlower(ctx, 285, 60, '#ff7eb6');
drawFlower(ctx, 60, 190, '#c39bff');
drawTree(ctx, 272, 40);
drawHouse(ctx, 44, 40);
// ---- move the pony ----
const k = keysRef.current;
let dx = (k.left ? -1 : 0) + (k.right ? 1 : 0);
let dy = (k.up ? -1 : 0) + (k.down ? 1 : 0);
if (goalRef.current) {
const g = goalRef.current;
if (dx === 0) dx = g.x - world.x > 2 ? 1 : g.x - world.x < -2 ? -1 : 0;
if (dy === 0) dy = g.y - world.y > 2 ? 1 : g.y - world.y < -2 ? -1 : 0;
if (Math.abs(g.x - world.x) <= 2 && Math.abs(g.y - world.y) <= 2) g.hit = true;
}
const moving = dx !== 0 || dy !== 0;
if (moving) {
if (dx < 0) world.facing = -1; else if (dx > 0) world.facing = 1;
world.x = clamp(world.x + dx * 2.2, 24, 296);
world.y = clamp(world.y + dy * 2.2, 40, 188);
}
if (goalRef.current && goalRef.current.hit) {
goalRef.current = null; setGoal(null); playSuccess();
}
// gait cadence while moving
if (moving && t - lastWalkTickRef.current > WALK_FPS_MS) {
lastWalkTickRef.current = t;
walkFrameRef.current = (walkFrameRef.current + 1) % WALK.length;
}
// ---- shadow under the pony ----
const cx = Math.round(world.x + 16), cy = Math.round(world.y + 16);
ctx.fillStyle = 'rgba(0,0,0,0.18)';
ctx.beginPath(); ctx.ellipse(cx, cy + 15, 12, 4, 0, 0, Math.PI * 2); ctx.fill();
// ---- composite the pony into a 32x32 offscreen canvas (per-layer color) ----
const row = moving ? 4 : 0;
const col = moving ? WALK[walkFrameRef.current] : IDLE_COL;
const octx = getLayerCanvas();
octx.clearRect(0, 0, SPRITE_SIZE, SPRITE_SIZE);
for (const l of layers) {
const sheet = sheetsRef.current[l.id];
if (!sheet || !sheet.width) continue;
const useRow = l.animated ? row : 0;
const useCol = l.animated ? col : IDLE_COL;
octx.filter = COLOR_FILTERS[appearance[l.colorKey]] || 'none';
octx.drawImage(sheet,
useCol * SPRITE_SIZE, useRow * SPRITE_SIZE, SPRITE_SIZE, SPRITE_SIZE,
0, 0, SPRITE_SIZE, SPRITE_SIZE);
}
octx.filter = 'none';
// ---- blit the pony, flipping for left-facing ----
ctx.save();
ctx.imageSmoothingEnabled = false;
if (world.facing === -1) {
ctx.translate(Math.round(world.x) + SPRITE_SIZE, Math.round(world.y));
ctx.scale(-1, 1);
ctx.drawImage(octx.canvas, 0, 0, SPRITE_SIZE, SPRITE_SIZE, 0, 0, SPRITE_SIZE, SPRITE_SIZE);
} else {
ctx.drawImage(octx.canvas, 0, 0, SPRITE_SIZE, SPRITE_SIZE, Math.round(world.x), Math.round(world.y), SPRITE_SIZE, SPRITE_SIZE);
}
ctx.restore();
// ---- goal marker ----
if (goalRef.current) {
const g = goalRef.current;
const pulse = 0.5 + 0.5 * Math.sin(t / 200);
ctx.save();
ctx.globalAlpha = 0.6 + 0.4 * pulse;
ctx.strokeStyle = '#fff27a'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.ellipse(g.x + 16, g.y + 16, 16 + 2 * pulse, 9 + 2 * pulse, 0, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
ctx.font = '10px system-ui'; ctx.fillStyle = '#fffbe6'; ctx.textAlign = 'center';
ctx.fillText('Hjælpen! →', g.x + 16, g.y - 4);
}
// ---- HUD hint ----
ctx.filter = 'none';
ctx.textAlign = 'left';
ctx.font = 'bold 12px system-ui';
ctx.fillStyle = 'rgba(255,255,255,0.92)';
ctx.fillText('Styr ponyen: piletaster / W A S D — eller tryk på græsset', 8, 30);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onPointer = (e) => {
const canvas = canvasRef.current;
if (!canvas) return;
const r = canvas.getBoundingClientRect();
const x = ((e.clientX - r.left) / r.width) * 320;
const y = ((e.clientY - r.top) / r.height) * 220;
start(x - 16, y - 16);
};
const dirBtn = (dir, label) => (
<button
className="pf-dir"
aria-label={label}
onPointerDown={(e) => { e.preventDefault(); keysRef.current[dir] = true; }}
onPointerUp={() => { keysRef.current[dir] = false; }}
onPointerLeave={() => { keysRef.current[dir] = false; }}
onPointerCancel={() => { keysRef.current[dir] = false; }}
>
{label}
</button>
);
return (
<div className="farm-page">
<div className="farm-topbar">
<button className="btn-back" onClick={() => { playClick(); onNavigate('home'); }} aria-label="Tilbage">←</button>
<div className="farm-title">
<span className="farm-title-emoji">🌾</span>
<span>Ponystalden</span>
</div>
<div className="farm-hud">
<label className="farm-vol">
🔊
<input type="range" min="0" max="1" step="0.05" value={volume} onChange={(e) => setVolume(parseFloat(e.target.value))} />
</label>
</div>
</div>
<div className="farm-stage">
<canvas ref={canvasRef} width={320} height={220} className="farm-canvas" onPointerDown={onPointer} />
</div>
<div className="farm-controls">
<div className="farm-dpad">
<span />
{dirBtn('up', '▲')}
<span />
{dirBtn('left', '◀')}
<span className="farm-dpad-mid">🐴</span>
{dirBtn('right', '▶')}
<span />
{dirBtn('down', '▼')}
<span />
</div>
<div className="farm-hint">
<p>💡 Gå ud i græsset eller brug piletasterne, så går ponyen.</p>
</div>
</div>
</div>
);
}
function drawFlower(ctx, x, y, color) {
ctx.save(); ctx.filter = 'none';
ctx.fillStyle = '#4c8a3f'; ctx.fillRect(x - 1, y, 2, 10);
ctx.fillStyle = color; ctx.fillRect(x - 3, y - 4, 8, 4); ctx.fillRect(x, y - 7, 2, 8);
ctx.fillStyle = '#fff27a'; ctx.fillRect(x, y - 2, 2, 2);
ctx.restore();
}
function drawTree(ctx, x, y) {
ctx.save(); ctx.filter = 'none';
ctx.fillStyle = '#7a5220'; ctx.fillRect(x - 2, y + 12, 5, 14);
ctx.fillStyle = '#3f9b46';
ctx.beginPath(); ctx.arc(x, y + 8, 12, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(x - 8, y + 14, 8, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(x + 8, y + 14, 8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#5cbf5f'; ctx.beginPath(); ctx.arc(x - 3, y + 5, 4, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawHouse(ctx, x, y) {
ctx.save(); ctx.filter = 'none';
ctx.fillStyle = '#e7cfa2'; ctx.fillRect(x - 16, y, 40, 30);
ctx.fillStyle = '#b9774a';
ctx.beginPath(); ctx.moveTo(x - 20, y); ctx.lineTo(x + 4, y - 16); ctx.lineTo(x + 28, y); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#7a4a2a'; ctx.fillRect(x - 2, y + 12, 12, 18);
ctx.fillStyle = '#ffe08a'; ctx.fillRect(x + 14, y + 8, 8, 8);
ctx.restore();
}
export default PonyFarmPage;