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:
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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); };
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user