diff --git a/pony-frontend/src/App.css b/pony-frontend/src/App.css index 508e19b..eb9f5dc 100644 --- a/pony-frontend/src/App.css +++ b/pony-frontend/src/App.css @@ -173,6 +173,15 @@ body { box-shadow: 0 4px 14px rgba(255, 90, 163, 0.4); } +/* The third, clearly-separate option: the walkable Ponyby town. */ +.btn-ponyby { + background: linear-gradient(135deg, #b6a3d8, #8a6fc0); + color: #ffffff; + font-size: 1.05rem; + margin-top: var(--space-md); + box-shadow: 0 4px 14px rgba(138, 111, 192, 0.45); +} + .btn-back { background: rgba(255, 255, 255, 0.2); color: var(--color-white); diff --git a/pony-frontend/src/App.js b/pony-frontend/src/App.js index 637f531..1e5cbf8 100644 --- a/pony-frontend/src/App.js +++ b/pony-frontend/src/App.js @@ -9,6 +9,7 @@ import GameScenePage from './pages/GameScenePage'; import GameEndPage from './pages/GameEndPage'; import StatsPage from './pages/StatsPage'; import PonyFarmPage from './pages/PonyFarmPage'; +import PonyvillePage from './pages/PonyvillePage'; import Narrator from './components/Narrator'; import FullscreenButton from './components/FullscreenButton'; import { prepareDanishSpeech, speakDanish } from './services/tts'; @@ -398,6 +399,14 @@ function App() { setVolume={setVolume} /> )} + + {page === 'ponyville' && ( + + )} )} diff --git a/pony-frontend/src/pages/HomePage.js b/pony-frontend/src/pages/HomePage.js index df9857b..5073b98 100644 --- a/pony-frontend/src/pages/HomePage.js +++ b/pony-frontend/src/pages/HomePage.js @@ -116,6 +116,15 @@ export default function HomePage({ volume, setVolume, stats, showAchievements, s > 🐴 Ponystalden + onNavigate('ponyville')} + aria-label="Gå til Ponyby" + > + 🏰 Ponyby + ); diff --git a/pony-frontend/src/pages/PonyFarmPage.js b/pony-frontend/src/pages/PonyFarmPage.js index 2923460..b86a67e 100644 --- a/pony-frontend/src/pages/PonyFarmPage.js +++ b/pony-frontend/src/pages/PonyFarmPage.js @@ -83,8 +83,8 @@ function PonyFarmPage({ onNavigate, volume, setVolume }) { 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; }; + const kd = (e) => { if (map[e.key.toLowerCase()]) { e.preventDefault(); keysRef.current[map[e.key.toLowerCase()]] = true; } }; + const ku = (e) => { if (map[e.key.toLowerCase()]) keysRef.current[map[e.key.toLowerCase()]] = false; }; window.addEventListener('keydown', kd); window.addEventListener('keyup', ku); return () => { window.removeEventListener('keydown', kd); window.removeEventListener('keyup', ku); }; diff --git a/pony-frontend/src/pages/PonyvillePage.css b/pony-frontend/src/pages/PonyvillePage.css new file mode 100644 index 0000000..3916c3f --- /dev/null +++ b/pony-frontend/src/pages/PonyvillePage.css @@ -0,0 +1,231 @@ +/* PonyvillePage — "Ponyby" walkable town + stories. */ + +.pv-page { + display: flex; + flex-direction: column; + height: 100dvh; + min-height: 0; + background: #2a2e35; +} + +.pv-topbar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.75rem; + background: rgba(0, 0, 0, 0.35); +} +.pv-topbar .btn-back { flex: 0 0 auto; } + +.pv-title { + flex: 0 1 auto; + display: flex; + align-items: center; + gap: 0.4rem; + color: #fff; + font-weight: 800; + font-size: 1.15rem; +} +.pv-title > span:first-child { font-size: 1.4rem; } + +.pv-journal-btn { + flex: 1 1 auto; + margin: 0 auto; + border: none; + border-radius: 12px; + background: #b6a3d8; + color: #2a1e3a; + font-weight: 800; + font-size: 0.95rem; + padding: 0.5rem 0.9rem; + box-shadow: 0 3px 0 rgba(0, 0, 0, 0.3); + cursor: pointer; +} +.pv-journal-btn:active { transform: translateY(2px); box-shadow: 0 1px 0 rgba(0, 0, 0, 0.3); } + +.pv-stars { + flex: 0 0 auto; + color: #ffd75a; + font-size: 0.95rem; + letter-spacing: 1px; + white-space: nowrap; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); +} + +.pv-stage { + position: relative; + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem; + min-height: 0; +} + +.pv-canvas { + display: block; + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + image-rendering: pixelated; + image-rendering: crisp-edges; + border-radius: 14px; + box-shadow: 0 12px 34px rgba(0, 0, 0, 0.5); + background: #79c466; + touch-action: none; +} + +/* story banner (top of stage) */ +.pv-banner { + position: absolute; + top: 0.85rem; + left: 50%; + transform: translateX(-50%); + width: min(92%, 760px); + padding: 0.6rem 0.9rem; + border-radius: 14px; + background: linear-gradient(135deg, rgba(70, 50, 120, 0.92), rgba(40, 28, 60, 0.92)); + border: 2px solid #d7bfff; + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.4); + color: #fff; + text-align: center; + z-index: 4; +} +.pv-banner p { margin: 0; font-size: 1rem; line-height: 1.3; } +.pv-banner-sub { font-size: 0.85rem !important; color: #e6d7ff !important; margin-top: 0.2rem !important; } +.pv-banner-done { background: linear-gradient(135deg, rgba(90, 60, 20, 0.94), rgba(50, 34, 12, 0.94)); border-color: #ffd75a; } + +/* "Hjælp!" action button when in range of the target */ +.pv-do { + position: absolute; + bottom: 0.9rem; + left: 50%; + transform: translateX(-50%); + border: none; + border-radius: 16px; + background: linear-gradient(180deg, #ffd75a, #ffab3a); + color: #3a2400; + font-weight: 900; + font-size: 1.15rem; + padding: 0.7rem 1.6rem; + box-shadow: 0 5px 0 rgba(0, 0, 0, 0.35), 0 0 0 4px rgba(255, 215, 90, 0.35); + cursor: pointer; + animation: pv-do-pulse 1s ease-in-out infinite; + z-index: 5; +} +@keyframes pv-do-pulse { + 0%, 100% { transform: translateX(-50%) scale(1); } + 50% { transform: translateX(-50%) scale(1.06); } +} + +/* journal panel */ +.pv-journal { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(90%, 460px); + max-height: 82%; + overflow-y: auto; + background: rgba(38, 28, 52, 0.97); + border: 2px solid #d7bfff; + border-radius: 16px; + padding: 0.7rem; + box-shadow: 0 14px 40px rgba(0, 0, 0, 0.55); + color: #fff; + z-index: 6; +} +.pv-journal-head { + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 800; + font-size: 1.05rem; + padding: 0.2rem 0.3rem 0.6rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.2); + margin-bottom: 0.5rem; +} +.pv-close { border: none; background: rgba(255,255,255,0.15); color:#fff; border-radius: 8px; width: 28px; height: 28px; cursor: pointer; } +.pv-jrow { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.5rem; + border-radius: 10px; + background: rgba(255, 255, 255, 0.06); + margin-bottom: 0.4rem; +} +.pv-jrow.is-active { background: rgba(255, 215, 90, 0.16); outline: 2px solid #ffd75a; } +.pv-jrow.is-done { opacity: 0.72; } +.pv-jemoji { font-size: 1.3rem; } +.pv-jtitle { flex: 1 1 auto; font-weight: 600; } +.pv-jstars { color: #ffd75a; font-weight: 800; } +.pv-jcount { text-align: center; font-size: 0.9rem; color: #d9cbff; margin: 0.4rem 0 0; } + +/* celebrate overlay */ +.pv-celebrate { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(10, 6, 16, 0.55); + z-index: 7; +} +.pv-celebrate-card { + width: min(88%, 420px); + background: linear-gradient(135deg, #ffe9b0, #ffd0f0); + border-radius: 18px; + padding: 1.1rem 1.2rem 1.3rem; + text-align: center; + color: #4a2f40; + box-shadow: 0 16px 44px rgba(0, 0, 0, 0.5); + border: 3px solid #fff; +} +.pv-celebrate-card h3 { margin: 0.4rem 0; font-size: 1.25rem; } +.pv-celebrate-stars { font-size: 2rem; letter-spacing: 4px; } +.pv-celebrate-card p { margin: 0.4rem 0; } +.pv-next { font-weight: 700; color: #6a3f5a; } + +/* controls */ +.pv-controls { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.6rem 0.9rem 0.9rem; +} +.pv-dpad { + display: grid; + grid-template-columns: repeat(3, 46px); + grid-template-rows: repeat(3, 46px); + gap: 4px; +} +.pv-dpad > span { display: block; } +.pv-dpad-mid { align-self: center; justify-self: center; font-size: 1.6rem; } +.pv-dir { + width: 46px; + height: 46px; + border: none; + border-radius: 11px; + 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; +} +.pv-dir:active { transform: translateY(2px); box-shadow: 0 1px 0 rgba(0, 0, 0, 0.3); background: #ffd968; } +.pv-hint { flex: 1 1 auto; color: #cfd4dc; font-size: 0.9rem; } +.pv-hint p { margin: 0 0 0.2rem; } + +@media (max-width: 560px) { + .pv-hint { display: none; } + .pv-stars { font-size: 0.8rem; } + .pv-journal-btn { font-size: 0.85rem; padding: 0.4rem 0.6rem; } +} diff --git a/pony-frontend/src/pages/PonyvillePage.js b/pony-frontend/src/pages/PonyvillePage.js new file mode 100644 index 0000000..1ceba73 --- /dev/null +++ b/pony-frontend/src/pages/PonyvillePage.js @@ -0,0 +1,590 @@ +/** + * PonyvillePage — "Ponyby": a walkable 2D town where you move your own pony + * and do stories/quests. + * + * - A larger world than one screen, so the camera follows the pony. + * - ~6 landmarks (bibliotek, frugthaven, skole, stald, park, slot). + * - Your pony is the EXACT one the child configured (same pixel pony, colour + * and walk gait) — reuses pixelPony/spriteData + services/ponyAppearance. + * - An NPC gives a story (Danish); you complete it by walking to the target + * landmark and pressing "Hjælp!" → earn a star in the story journal. + * - Progress persists to localStorage; success lines are read aloud (Danish TTS). + * + * Movement + pony compositing follow the proven PonyFarmPage pattern, scaled + * up with a follow-camera. + */ + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { loadAppearance } from '../services/ponyAppearance'; +import { + getManeStyle, getTailStyle, getHornStyle, getWingStyle, getPonyType, +} from '../pixelPony/spriteData'; +import { playClick, playSuccess } from '../SceneMusic'; +import { speakDanish } from '../services/tts'; +import { + STORIES, getStory, loadProgress, computeActive, completeStory, + starsEarned, countDone, +} from '../ponyville/stories'; +import './PonyvillePage.css'; + +const TILE = 32; +// The base (body) and mane sheets carry a walk gait on row 4. Column 3 is a +// broken blob and column 7 is empty, so these columns are the clean frames. +const WALK = [0, 1, 2, 4, 5, 6]; +const WALK_FPS_MS = 110; + +const VIEW_W = 960; +const VIEW_H = 640; +const WORLD_W = 1280; +const WORLD_H = 896; + +const PONY_SCALE = 1.6; +const PONY_PX = TILE * PONY_SCALE; +const CENTER = { x: 640, y: 470 }; + +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)', +}; + +const LANDMARKS = [ + { id: 'library', x: 210, y: 250, name: 'Biblioteket', color: '#c98a4e', roof: '#a44a2a', emoji: '📚' }, + { id: 'orchard', x: 1070, y: 250, name: 'Frugthaven', color: '#b07c41', roof: '#7f9a3a', emoji: '🍎' }, + { id: 'school', x: 210, y: 640, name: 'Skolen', color: '#8fa9d8', roof: '#5b6fb0', emoji: '✏️' }, + { id: 'stable', x: 1070, y: 640, name: 'Stalden', color: '#9c7a4a', roof: '#6e4f2a', emoji: '🐴' }, + { id: 'park', x: 640, y: 200, name: 'Parken', color: '#5cbf5f', roof: '#3f9b46', emoji: '🌳' }, + { id: 'castle', x: 640, y: 700, name: 'Slottet', color: '#b6a3d8', roof: '#7d6bb0', emoji: '👑' }, +]; + +// Deterministic scatter so decoration is stable across frames. +function scatter(seed, count) { + let s = seed >>> 0; + const out = []; + const rnd = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 0xffffffff; }; + for (let i = 0; i < count; i++) out.push({ x: rnd() * WORLD_W, y: rnd() * WORLD_H, r: rnd() }); + return out; +} +const FLOWERS = scatter(1337, 46); +const TREES = scatter(777, 16); + +function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } + +function buildLayers(appearance, ponyType) { + const mane = getManeStyle(appearance.mane); + const tail = getTailStyle(appearance.tail); + const layers = [ + { id: 'base', file: '/sprites/pony/base.png', colorKey: 'bodyColor', animated: true }, + { id: 'tail', file: `/sprites/pony/${tail.file.split('/').pop()}`, colorKey: 'tailColor', animated: false }, + { id: 'mane', file: `/sprites/pony/${mane.file.split('/').pop()}`, colorKey: 'maneColor', animated: true }, + ]; + if (ponyType.hasWings && appearance.hasWings) { + const wing = getWingStyle(appearance.wing); + layers.push({ id: 'wing', file: `/sprites/pony/${wing.file.split('/').pop()}`, colorKey: 'wingColor', animated: false }); + } + if (ponyType.hasHorn && appearance.hasHorn) { + const horn = getHornStyle(appearance.horn); + layers.push({ id: 'horn', file: `/sprites/pony/${horn.file.split('/').pop()}`, colorKey: 'hornColor', animated: false }); + } + layers.push({ id: 'eye', file: '/sprites/pony/eye.png', colorKey: 'eyeColor', animated: false }); + return layers; +} + +export default function PonyvillePage({ onNavigate, volume, setVolume }) { + const ponyType = getPonyType(loadAppearance().ponyType); + const [appearance] = useState(() => loadAppearance()); + const [layers] = useState(() => buildLayers(appearance, ponyType)); + + const [progress, setProgress] = useState(() => loadProgress()); + const [activeId, setActiveId] = useState(() => computeActive(loadProgress())); + const [banner, setBanner] = useState(null); + const [inRange, setInRange] = useState(false); + const [celebrate, setCelebrate] = useState(null); + const [journalOpen, setJournalOpen] = useState(false); + const [stars, setStars] = useState(() => starsEarned(loadProgress())); + + const canvasRef = useRef(null); + const stageRef = useRef(null); + const layerCanvasRef = useRef(null); + const sheetsRef = useRef({}); + const worldRef = useRef({ x: CENTER.x, y: CENTER.y, facing: 1 }); + const camRef = useRef({ x: clamp(CENTER.x - VIEW_W / 2, 0, WORLD_W - VIEW_W), y: clamp(CENTER.y - VIEW_H / 2, 0, WORLD_H - VIEW_H) }); + const keysRef = useRef({}); + const walkFrameRef = useRef(0); + const lastWalkTickRef = useRef(0); + const goalRef = useRef(null); + const inRangeRef = useRef(false); + const activeRef = useRef(activeId); + activeRef.current = activeId; + + // Fit the canvas into the stage at a 3:2 ratio, uniformly, on any viewport. + // (Pointer mapping uses getBoundingClientRect, so a uniform CSS scale stays 1:1.) + useEffect(() => { + const fit = () => { + const canvas = canvasRef.current; + const stage = stageRef.current; + if (!canvas || !stage) return; + const availW = stage.clientWidth - 12; + const availH = stage.clientHeight - 12; + const ratio = VIEW_W / VIEW_H; + let w = Math.min(availW, availH * ratio); + let h = w / ratio; + if (w > 0 && h > 0) { + canvas.style.width = `${Math.floor(w)}px`; + canvas.style.height = `${Math.floor(h)}px`; + } + }; + fit(); + window.addEventListener('resize', fit); + const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(fit) : null; + if (ro && stageRef.current) ro.observe(stageRef.current); + return () => { window.removeEventListener('resize', fit); if (ro) ro.disconnect(); }; + }, []); + + // Preload every pony 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]); + + // Keyboard: arrows + WASD, and E to complete. + useEffect(() => { + const map = { + arrowup: 'up', w: 'up', arrowdown: 'down', s: 'down', + arrowleft: 'left', a: 'left', arrowright: 'right', d: 'right', + }; + const kd = (e) => { + const key = e.key.toLowerCase(); + if (map[key]) { e.preventDefault(); keysRef.current[map[key]] = true; } + if (key === 'e' && activeRef.current) tryComplete(); + }; + const ku = (e) => { const key = e.key.toLowerCase(); if (map[key]) keysRef.current[map[key]] = false; }; + window.addEventListener('keydown', kd); + window.addEventListener('keyup', ku); + return () => { window.removeEventListener('keydown', kd); window.removeEventListener('keyup', ku); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Entering a new active story → show + read its intro. + useEffect(() => { + if (!activeId) return; + const story = getStory(activeId); + const lm = LANDMARKS.find((l) => l.id === story.target); + setBanner({ text: `${story.giver}: ${story.intro}`, sub: `Gå til ${lm.name} ${story.emoji} — ${story.objective}`, kind: 'intro' }); + if (volume > 0) speakDanish(story.intro, volume); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeId]); + + const setGoal = useCallback((wx, wy) => { + goalRef.current = { x: clamp(wx, 20, WORLD_W - 20), y: clamp(wy, 20, WORLD_H - 20), hit: false }; + }, []); + + const onPointer = (e) => { + const canvas = canvasRef.current; + if (!canvas) return; + const r = canvas.getBoundingClientRect(); + const cx = ((e.clientX - r.left) / r.width) * VIEW_W; + const cy = ((e.clientY - r.top) / r.height) * VIEW_H; + setGoal(camRef.current.x + cx, camRef.current.y + cy); + }; + + const tryComplete = useCallback(() => { + const id = activeRef.current; + if (!id) return; + const story = getStory(id); + const lm = LANDMARKS.find((l) => l.id === story.target); + const w = worldRef.current; + if (Math.hypot(w.x - lm.x, w.y - lm.y) > 92) return; // must be at the place + playSuccess(); + const np = completeStory(id); + setProgress(np); + setStars(starsEarned(np)); + inRangeRef.current = false; + setInRange(false); + const nextId = computeActive(np); + setCelebrate({ story, next: nextId ? getStory(nextId) : null }); + if (volume > 0) speakDanish(story.success, volume); + setActiveId(nextId); + goalRef.current = null; + }, [volume]); + + const getLayerCanvas = () => { + if (!layerCanvasRef.current) { + const c = document.createElement('canvas'); + c.width = TILE; c.height = TILE; + layerCanvasRef.current = c; + } + return layerCanvasRef.current.getContext('2d'); + }; + + // ---- main animation loop (one rAF drives everything) ---- + useEffect(() => { + let raf; + let last = performance.now(); + + const drawLandmarks = (ctx, t) => { + const targetId = activeRef.current ? getStory(activeRef.current).target : null; + const nearId = activeRef.current ? getStory(activeRef.current).target : null; + const isNear = (lm) => Math.hypot(worldRef.current.x - lm.x, worldRef.current.y - lm.y) <= 92; + LANDMARKS.slice().sort((a, b) => a.y - b.y).forEach((lm) => { + const isTarget = lm.id === targetId; + drawLandmark(ctx, lm, t, isTarget, isTarget && isNear(lm), nearId === lm.id); + }); + }; + + const drawPony = (ctx, t, moving) => { + const octx = getLayerCanvas(); + const w = worldRef.current; + const row = moving ? 4 : 0; + const col = moving ? WALK[walkFrameRef.current] : (Math.floor(t / 480) % 2 === 0 ? 0 : 1); + const cx = Math.round(w.x), cy = Math.round(w.y + 16); + ctx.fillStyle = 'rgba(0,0,0,0.18)'; + ctx.beginPath(); ctx.ellipse(cx, cy + 14, 20, 7, 0, 0, Math.PI * 2); ctx.fill(); + octx.clearRect(0, 0, TILE, TILE); + 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 : 0; + octx.filter = COLOR_FILTERS[appearance[l.colorKey]] || 'none'; + octx.drawImage(sheet, useCol * TILE, useRow * TILE, TILE, TILE, 0, 0, TILE, TILE); + } + octx.filter = 'none'; + const sx = Math.round(w.x - PONY_PX / 2); + const sy = Math.round(w.y - PONY_PX); + ctx.save(); + ctx.imageSmoothingEnabled = false; + if (w.facing === -1) { + ctx.translate(sx + PONY_PX, sy); + ctx.scale(-1, 1); + ctx.drawImage(octx.canvas, 0, 0, TILE, TILE, 0, 0, PONY_PX, PONY_PX); + } else { + ctx.drawImage(octx.canvas, 0, 0, TILE, TILE, sx, sy, PONY_PX, PONY_PX); + } + ctx.restore(); + }; + + const drawCompass = (ctx) => { + const id = activeRef.current; + if (!id) return; + const story = getStory(id); + const lm = LANDMARKS.find((l) => l.id === story.target); + if (!lm) return; + const near = Math.hypot(worldRef.current.x - lm.x, worldRef.current.y - lm.y) <= 92; + if (near) return; + const cam = camRef.current; + const wx = lm.x - cam.x; + const wy = lm.y - cam.y; + const inside = wx > 0 && wy > 0 && wx < VIEW_W && wy < VIEW_H; + const cx = clamp(wx, 30, VIEW_W - 30); + const cy = clamp(wy, 30, VIEW_H - 30); + const ang = Math.atan2(wy - cy, wx - cx); + ctx.save(); + ctx.fillStyle = 'rgba(40,28,12,0.75)'; + ctx.beginPath(); ctx.arc(cx, cy, 22, 0, Math.PI * 2); ctx.fill(); + ctx.translate(cx + Math.cos(ang) * 34, cy + Math.sin(ang) * 34); + ctx.rotate(ang); + ctx.fillStyle = '#ffcf3a'; + ctx.beginPath(); ctx.moveTo(12, 0); ctx.lineTo(-6, -8); ctx.lineTo(-6, 8); ctx.closePath(); ctx.fill(); + ctx.restore(); + if (!inside) { + ctx.font = 'bold 11px system-ui'; ctx.textAlign = 'center'; + ctx.fillStyle = 'rgba(255,255,255,0.9)'; + ctx.fillText('gå mod pilen →', clamp(wx, 44, VIEW_W - 44), clamp(wy, 34, VIEW_H - 22)); + } + }; + + const step = (t) => { + const dt = clamp((t - last) / 16.667, 0, 2.5); + last = t; + const canvas = canvasRef.current; + const ctx = canvas && canvas.getContext('2d'); + if (!ctx) { raf = requestAnimationFrame(step); return; } + + const world = worldRef.current; + const k = keysRef.current; + let dx = (k.left ? -1 : 0) + (k.right ? 1 : 0); + let dy = (k.up ? -1 : 0) + (k.down ? 1 : 0); + const g = goalRef.current; + if (g) { + if (dx === 0) dx = g.x - world.x > 3 ? 1 : g.x - world.x < -3 ? -1 : 0; + if (dy === 0) dy = g.y - world.y > 3 ? 1 : g.y - world.y < -3 ? -1 : 0; + if (Math.abs(g.x - world.x) <= 4 && Math.abs(g.y - world.y) <= 4) { goalRef.current = null; } + } + const moving = dx !== 0 || dy !== 0; + if (moving) { + if (dx < 0) world.facing = -1; else if (dx > 0) world.facing = 1; + const len = Math.hypot(dx, dy) || 1; + world.x = clamp(world.x + (dx / len) * 2.7 * dt, 16, WORLD_W - 16); + world.y = clamp(world.y + (dy / len) * 2.7 * dt, 16, WORLD_H - 16); + } + // camera follow + const tx = clamp(world.x - VIEW_W / 2, 0, WORLD_W - VIEW_W); + const ty = clamp(world.y - VIEW_H / 2, 0, WORLD_H - VIEW_H); + camRef.current.x += (tx - camRef.current.x) * 0.2 * dt; + camRef.current.y += (ty - camRef.current.y) * 0.2 * dt; + // debug hook (harmless) — exposed so tests/tools can observe the pony + try { + window.__ponyby_dbg = { + x: Math.round(world.x), y: Math.round(world.y), + active: activeRef.current, moving, + }; + } catch (e) { /* ignore */ } + // gait cadence + if (moving && t - lastWalkTickRef.current > WALK_FPS_MS) { + lastWalkTickRef.current = t; + walkFrameRef.current = (walkFrameRef.current + 1) % WALK.length; + } + // proximity to active target + let near = false; + if (activeRef.current) { + const lm = LANDMARKS.find((l) => l.id === getStory(activeRef.current).target); + if (lm) near = Math.hypot(world.x - lm.x, world.y - lm.y) <= 92; + } + if (near !== inRangeRef.current) { inRangeRef.current = near; setInRange(near); } + + // ---- draw ---- + ctx.imageSmoothingEnabled = false; + ctx.filter = 'none'; + ctx.fillStyle = '#79c466'; + ctx.fillRect(0, 0, VIEW_W, VIEW_H); + ctx.save(); + ctx.translate(-Math.round(camRef.current.x), -Math.round(camRef.current.y)); + drawGround(ctx); + drawPaths(ctx); + drawPond(ctx, 470, 470); + drawBushes(ctx); + drawLandmarks(ctx, t); + drawPony(ctx, t, moving); + ctx.restore(); + drawCompass(ctx); + // soft frame + ctx.strokeStyle = 'rgba(0,0,0,0.25)'; + ctx.lineWidth = 3; + ctx.strokeRect(2, 2, VIEW_W - 4, VIEW_H - 4); + + raf = requestAnimationFrame(step); + }; + + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const dirBtn = (dir, label) => ( + + ); + + const closeCelebrate = () => { + playClick(); + setCelebrate(null); + if (!activeId) { + setBanner({ text: '🎉 Du klarede alle historier i Ponyby! 🎉', sub: 'Gå rundt så længe du har lyst.', kind: 'done' }); + } + }; + + return ( +
+
+ +
🏰Ponyby
+ +
+ {'⭐'.repeat(stars)}{'·'.repeat(Math.max(0, 18 - stars))} +
+
+ +
+ {banner && ( +
+

{banner.text}

+ {banner.sub &&

{banner.sub}

} +
+ )} + + + + {inRange && activeId && ( + + )} + + {journalOpen && ( +
+
+ Historier + +
+ {STORIES.map((s) => { + const done = !!progress.done[s.id]; + const active = s.id === activeId; + return ( +
+ {s.emoji} + {s.title} + {done ? '⭐'.repeat(s.stars) : active ? '▸' : '🔒'} +
+ ); + })} +

Klaret: {countDone(progress)} af {STORIES.length}

+
+ )} + + {celebrate && ( +
+
+
{'⭐'.repeat(celebrate.story.stars)}
+

{celebrate.story.emoji} {celebrate.story.title}

+

{celebrate.story.success}

+ {celebrate.next ? ( +

Næste: {celebrate.next.emoji} {celebrate.next.title}

+ ) : ( +

Du klarede dem alle! 🏆

+ )} + +
+
+ )} +
+ +
+
+ + {dirBtn('up', '▲')} + + {dirBtn('left', '◀')} + 🐴 + {dirBtn('right', '▶')} + + {dirBtn('down', '▼')} + +
+
+

💡 Tryk på græsset for at gå, eller brug piletasterne / W A S D.

+

🙌 Når du er ved det rigtige sted, tryk “Hjælp!” (eller E).

+
+
+
+ ); +} + +// ---- pure world-drawing helpers (take coords as args) ---- + +function drawGround(ctx) { + ctx.fillStyle = '#79c466'; + for (let x = 0; x < WORLD_W; x += 96) ctx.fillRect(x, 0, 48, WORLD_H); + ctx.fillStyle = 'rgba(255,255,255,0.04)'; + for (let y = 0; y < WORLD_H; y += 80) ctx.fillRect(0, y, WORLD_W, 40); + FLOWERS.forEach((f) => drawFlower(ctx, f.x, f.y, f.r)); +} + +function drawFlower(ctx, x, y, r) { + const c = r > 0.66 ? '#ffd23f' : r > 0.33 ? '#ff7eb6' : '#c39bff'; + ctx.save(); ctx.filter = 'none'; + ctx.fillStyle = '#4c8a3f'; ctx.fillRect(x - 1, y, 2, 8); + ctx.fillStyle = c; ctx.fillRect(x - 3, y - 3, 8, 4); ctx.fillRect(x, y - 6, 2, 8); + ctx.fillStyle = '#fff27a'; ctx.fillRect(x, y - 2, 2, 2); + ctx.restore(); +} + +function drawPaths(ctx) { + ctx.strokeStyle = '#c9a972'; + ctx.lineCap = 'round'; + ctx.lineWidth = 26; + ctx.beginPath(); + ctx.moveTo(CENTER.x, CENTER.y); + LANDMARKS.forEach((lm) => ctx.lineTo(lm.x, lm.y)); + ctx.stroke(); + ctx.fillStyle = '#d8bd88'; + ctx.beginPath(); ctx.arc(CENTER.x, CENTER.y, 60, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#c9a972'; + ctx.beginPath(); ctx.arc(CENTER.x, CENTER.y, 46, 0, Math.PI * 2); ctx.fill(); +} + +function drawBushes(ctx) { + ctx.fillStyle = '#3f9b46'; + TREES.forEach((tr) => { + ctx.beginPath(); + ctx.arc(tr.x, tr.y, 10 + tr.r * 6, 0, Math.PI * 2); + ctx.arc(tr.x - 8, tr.y + 4, 7, 0, Math.PI * 2); + ctx.arc(tr.x + 8, tr.y + 4, 7, 0, Math.PI * 2); + ctx.fill(); + }); +} + +function drawPond(ctx, x, y) { + ctx.fillStyle = '#5aa6e0'; + ctx.beginPath(); ctx.ellipse(x, y, 46, 28, 0, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#7cc2ee'; + ctx.beginPath(); ctx.ellipse(x - 8, y - 4, 26, 13, 0, 0, Math.PI * 2); ctx.fill(); +} + +function drawLandmark(ctx, lm, t, isTarget, targetNear) { + const { x, y, name, color, roof, emoji } = lm; + ctx.save(); ctx.filter = 'none'; + ctx.fillStyle = color; ctx.fillRect(x - 34, y, 68, 46); + ctx.fillStyle = roof; + ctx.beginPath(); ctx.moveTo(x - 42, y); ctx.lineTo(x, y - 26); ctx.lineTo(x + 42, y); ctx.closePath(); ctx.fill(); + ctx.fillStyle = '#5a3a1e'; ctx.fillRect(x - 8, y + 20, 16, 26); + ctx.fillStyle = '#ffe08a'; ctx.fillRect(x + 16, y + 12, 12, 12); + ctx.strokeStyle = '#3a2410'; ctx.lineWidth = 2; ctx.strokeRect(x + 16, y + 12, 12, 12); + ctx.font = '20px system-ui'; ctx.textAlign = 'center'; + ctx.fillText(emoji, x, y + 12); + ctx.font = 'bold 13px system-ui'; ctx.textAlign = 'center'; + const w = ctx.measureText(name).width + 12; + ctx.fillStyle = 'rgba(40,28,12,0.85)'; + ctx.fillRect(x - w / 2, y + 50, w, 18); + ctx.fillStyle = '#fffbe6'; + ctx.fillText(name, x, y + 63); + ctx.restore(); + + if (isTarget) { + const pulse = 0.5 + 0.5 * Math.sin(t / 200); + ctx.save(); + ctx.globalAlpha = 0.55 + 0.4 * pulse; + ctx.strokeStyle = targetNear ? '#a8ff9e' : '#fff27a'; + ctx.lineWidth = 4; + ctx.beginPath(); ctx.ellipse(x, y + 14, 54 + 6 * pulse, 34 + 4 * pulse, 0, 0, Math.PI * 2); ctx.stroke(); + const bounce = Math.sin(t / 160) * 5; + ctx.globalAlpha = 1; + ctx.font = 'bold 34px system-ui'; ctx.textAlign = 'center'; + ctx.fillStyle = targetNear ? '#5be07a' : '#ffcf3a'; + ctx.fillText('!', x, y - 34 + bounce); + ctx.restore(); + } +} diff --git a/pony-frontend/src/ponyville/stories.js b/pony-frontend/src/ponyville/stories.js new file mode 100644 index 0000000..120ee23 --- /dev/null +++ b/pony-frontend/src/ponyville/stories.js @@ -0,0 +1,132 @@ +/** + * Ponyby — story/quest data + progress state. + * + * A "story" is a small Danish quest: an NPC (giver) gives an intro line, + * an objective, a target location in the Ponyby world, and a success line. + * The child completes it by walking to the landmark and doing the task. + * + * Progress is persisted to localStorage so a 4-year-old's progress survives + * a browser refresh, exactly like the rest of the game (achievements, etc.). + */ + +import { loadAppearance } from '../services/ponyAppearance'; + +export const STORAGE_KEY = 'pony_ponyby_progress'; + +// Landmark ids double as world coordinates (see PonyvillePage.js WORLD). +// Each story targets one landmark so "walk there + do the thing" is the beat. +export const STORIES = [ + { + id: 'bog', + emoji: '📚', + giver: 'Bibliotekar Twi', + title: 'Twilights forsvundne bog', + intro: 'Hej! Jeg hedder Twi. Jeg kan ikke finde min røde bog i biblioteket. Kan du hjælpe?', + objective: 'Gå til biblioteket 📚 og find den røde bog.', + target: 'library', + success: 'Fantastisk! Her er min røde bog. Du er en rigtig hjælper!', + stars: 1, + }, + { + id: 'apple', + emoji: '🍎', + giver: 'Bønderen Applejack', + title: 'Æblerne ruller væk', + intro: 'Vau! Min kurv er fuld af æbler, men de ruller ned ad bakken. Hjælp mig med at fange dem!', + objective: 'Gå til frugthaven 🍎 og fang de rullende æbler.', + target: 'orchard', + success: 'Tak! Nu har jeg alle mine æbler. Du kan ALT!', + stars: 2, + }, + { + id: 'skole', + emoji: '✏️', + giver: 'Læreren Rarity', + title: 'Raritys glimmer-sten', + intro: 'Hej lille pony! Jeg har gemt en skøn glimmer-sten på skolen. Hent den til mig!', + objective: 'Gå til skolen ✏️ og hent den glimrende sten.', + target: 'school', + success: 'Åh, den glimrer så smukt! Tusind tak for din hjælp!', + stars: 2, + }, + { + id: 'hest', + emoji: '🐴', + giver: 'Staldbønderen Zecora', + title: 'Zecoras lille hest', + intro: 'Min lille hest har fået ondt i benet. Kan du gå til stalden og give hende en kramme?', + objective: 'Gå til stalden 🐴 og giv hesten et kram.', + target: 'stable', + success: 'Aaaah, tak for krampet! Hesten er glad nu. Godt hjerte, du!', + stars: 1, + }, + { + id: 'park', + emoji: '🌳', + giver: 'Parkvagten Pinkie', + title: 'Pinkies flyvende ballonfest', + intro: 'Vupti! Mine balloner er flyvet op i parken! Få fat i den røde ballon for mig!', + objective: 'Gå til parken 🌳 og fang den røde ballon.', + target: 'park', + success: 'WOOHOO! En fest! Tak for ballonen! 🎉', + stars: 1, + }, + { + id: 'slottet', + emoji: '👑', + giver: 'Prinsesse Cadance', + title: 'Cadances stjerne', + intro: 'Kærlig hilsen fra slottet! Jeg har mistet min lille stjerne. Hent den for mig, kære pony?', + objective: 'Gå til slottet 👑 og hent prinsessens stjerne.', + target: 'castle', + success: 'Den er tryg igen. Du er min helt i dag. 🌟', + stars: 3, + }, +]; + +export function getStory(id) { + return STORIES.find((s) => s.id === id); +} + +/** + * Load saved progress. Shape: + * { done: { [id]: true }, active: 'bog' | null } + * `active` = the story currently offered/in-progress (the one to chase). + */ +export function loadProgress() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return { done: {}, active: null, ...JSON.parse(raw) }; + } catch { /* ignore */ } + return { done: {}, active: null }; +} + +/** First not-yet-done story is the active one; once all done, null. */ +export function computeActive(progress) { + if (progress.active && !progress.done[progress.active]) return progress.active; + const next = STORIES.find((s) => !progress.done[s.id]); + return next ? next.id : null; +} + +export function saveProgress(progress) { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(progress)); } catch { /* ignore */ } +} + +export function completeStory(id) { + const progress = loadProgress(); + progress.done[id] = true; + progress.active = computeActive({ ...progress, active: null }); + saveProgress(progress); + return progress; +} + +/** Total stars earned so far (sum of stars for completed stories). */ +export function starsEarned(progress) { + return STORIES.reduce((sum, s) => sum + (progress.done[s.id] ? s.stars : 0), 0); +} + +export function countDone(progress) { + return STORIES.reduce((n, s) => n + (progress.done[s.id] ? 1 : 0), 0); +} + +export { loadAppearance };