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