diff --git a/pony-frontend/src/App.js b/pony-frontend/src/App.js index d70d532..c7b30fe 100644 --- a/pony-frontend/src/App.js +++ b/pony-frontend/src/App.js @@ -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' && ( )} + + {page === 'farm' && ( + + )} )} diff --git a/pony-frontend/src/pages/HomePage.js b/pony-frontend/src/pages/HomePage.js index 9b84e5c..df9857b 100644 --- a/pony-frontend/src/pages/HomePage.js +++ b/pony-frontend/src/pages/HomePage.js @@ -107,6 +107,15 @@ export default function HomePage({ volume, setVolume, stats, showAchievements, s > 🎮 Start Nyt Spil! + onNavigate('farm')} + aria-label="Gå til ponystalden" + > + 🐴 Ponystalden + ); diff --git a/pony-frontend/src/pages/PonyFarmPage.css b/pony-frontend/src/pages/PonyFarmPage.css new file mode 100644 index 0000000..69176fc --- /dev/null +++ b/pony-frontend/src/pages/PonyFarmPage.css @@ -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; + } +} diff --git a/pony-frontend/src/pages/PonyFarmPage.js b/pony-frontend/src/pages/PonyFarmPage.js new file mode 100644 index 0000000..2923460 --- /dev/null +++ b/pony-frontend/src/pages/PonyFarmPage.js @@ -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) => ( + + ); + + return ( +
+
+ +
+ 🌾 + Ponystalden +
+
+ +
+
+ +
+ +
+ +
+
+ + {dirBtn('up', '▲')} + + {dirBtn('left', '◀')} + 🐴 + {dirBtn('right', '▶')} + + {dirBtn('down', '▼')} + +
+
+

💡 Gå ud i græsset eller brug piletasterne, så går ponyen.

+
+
+
+ ); +} + +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;