feat(pony): Ponyby — walkable 2D town with stories/quests

Adds a new mode reachable from the home page ("Ponyby" button):
a larger 2D world with a follow-camera where the child moves their own
configured pony (same pixel pony, colour and walk gait as the rest of the
game) around ~6 landmarks. NPCs give Danish stories; the child completes
each by walking to the target landmark and pressing Hjælp! (or E), earning
a star in the story journal (progress persists to localStorage).

Also fixes a latent keyboard bug in PonyFarmPage: keydown/keyup stored the
raw key ("arrowup") but the move loop read the mapped name ("up"), so
arrows/WASD never registered — only tap-to-move worked. Now both the D-pad
and the keyboard drive the same direction keys.
This commit is contained in:
alex
2026-09-30 23:51:15 +00:00
parent cd6385a4cc
commit 101dda7bfe
7 changed files with 982 additions and 2 deletions
+9
View File
@@ -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);
+9
View File
@@ -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' && (
<PonyvillePage
onNavigate={navigateTo}
volume={volume}
setVolume={setVolume}
/>
)}
</>
)}
</AnimatePresence>
+9
View File
@@ -116,6 +116,15 @@ export default function HomePage({ volume, setVolume, stats, showAchievements, s
>
🐴 Ponystalden
</motion.button>
<motion.button
whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.95 }}
className="btn-start btn-ponyby"
onClick={() => onNavigate('ponyville')}
aria-label="Gå til Ponyby"
>
🏰 Ponyby
</motion.button>
</motion.div>
</motion.div>
);
+2 -2
View File
@@ -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); };
+231
View File
@@ -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; }
}
+590
View File
@@ -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) => (
<button
className="pv-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>
);
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 (
<div className="pv-page">
<div className="pv-topbar">
<button className="btn-back" onClick={() => { playClick(); onNavigate('home'); }} aria-label="Tilbage">←</button>
<div className="pv-title"><span>🏰</span><span>Ponyby</span></div>
<button
className="pv-journal-btn"
onClick={() => { playClick(); setJournalOpen((o) => !o); }}
aria-label="Åbn journal"
>
📖 Journal
</button>
<div className="pv-stars" aria-label="Stjerner" title="Stjerner samlet">
{'⭐'.repeat(stars)}{'·'.repeat(Math.max(0, 18 - stars))}
</div>
</div>
<div className="pv-stage" ref={stageRef}>
{banner && (
<div className={`pv-banner pv-banner-${banner.kind}`} role="status">
<p>{banner.text}</p>
{banner.sub && <p className="pv-banner-sub">{banner.sub}</p>}
</div>
)}
<canvas ref={canvasRef} width={VIEW_W} height={VIEW_H} className="pv-canvas" onPointerDown={onPointer} />
{inRange && activeId && (
<button className="pv-do" onPointerDown={(e) => { e.preventDefault(); tryComplete(); }} aria-label="Hjælp!">
🙌 Hjælp!
</button>
)}
{journalOpen && (
<div className="pv-journal" role="dialog" aria-label="Historie-journal">
<div className="pv-journal-head">
<span>Historier</span>
<button className="pv-close" onClick={() => { playClick(); setJournalOpen(false); }} aria-label="Luk">✕</button>
</div>
{STORIES.map((s) => {
const done = !!progress.done[s.id];
const active = s.id === activeId;
return (
<div key={s.id} className={`pv-jrow ${done ? 'is-done' : ''} ${active ? 'is-active' : ''}`}>
<span className="pv-jemoji">{s.emoji}</span>
<span className="pv-jtitle">{s.title}</span>
<span className="pv-jstars">{done ? '⭐'.repeat(s.stars) : active ? '▸' : '🔒'}</span>
</div>
);
})}
<p className="pv-jcount">Klaret: {countDone(progress)} af {STORIES.length}</p>
</div>
)}
{celebrate && (
<div className="pv-celebrate" role="dialog" aria-label="Historie klaret">
<div className="pv-celebrate-card">
<div className="pv-celebrate-stars">{'⭐'.repeat(celebrate.story.stars)}</div>
<h3>{celebrate.story.emoji} {celebrate.story.title}</h3>
<p>{celebrate.story.success}</p>
{celebrate.next ? (
<p className="pv-next">Næste: {celebrate.next.emoji} {celebrate.next.title}</p>
) : (
<p className="pv-next">Du klarede dem alle! 🏆</p>
)}
<button className="btn-start" onClick={closeCelebrate}>
{celebrate.next ? 'Fortsæt →' : '🏆 Færdig!'}
</button>
</div>
</div>
)}
</div>
<div className="pv-controls">
<div className="pv-dpad">
<span />
{dirBtn('up', '▲')}
<span />
{dirBtn('left', '◀')}
<span className="pv-dpad-mid">🐴</span>
{dirBtn('right', '▶')}
<span />
{dirBtn('down', '▼')}
<span />
</div>
<div className="pv-hint">
<p>💡 Tryk på græsset for at gå, eller brug piletasterne / W A S D.</p>
<p>🙌 Når du er ved det rigtige sted, tryk “Hjælp!” (eller <b>E</b>).</p>
</div>
</div>
</div>
);
}
// ---- 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();
}
}
+132
View File
@@ -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 };