Compare commits
10 Commits
e243862769
...
dd14f5d901
| Author | SHA1 | Date | |
|---|---|---|---|
| dd14f5d901 | |||
| 274085b92e | |||
| f3337975c2 | |||
| e6f6b15cc7 | |||
| f3f7082fb8 | |||
| 36e93be79b | |||
| b6342cc117 | |||
| fd6283718c | |||
| 75b0eed080 | |||
| a69db98241 |
@@ -123,3 +123,7 @@ reported at once.
|
|||||||
and size, so **a change to how the scanner derives a title, artist or series is
|
and size, so **a change to how the scanner derives a title, artist or series is
|
||||||
invisible until the cache is invalidated** — bump `_INDEX_VERSION` in
|
invisible until the cache is invalidated** — bump `_INDEX_VERSION` in
|
||||||
`musicmouse/library/cache.py` when you touch that logic.
|
`musicmouse/library/cache.py` when you touch that logic.
|
||||||
|
- Track analysis (librosa) runs in a pool of worker *processes* - see
|
||||||
|
`musicmouse/library/workers.py`. Anything an `Analyzer` returns therefore has to be
|
||||||
|
picklable, and an analyzer that records state in its own instance (a test double
|
||||||
|
counting calls) only behaves as written with `analysis_workers=1`.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 650 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 567 KiB |
@@ -23,6 +23,12 @@ general:
|
|||||||
# is rebuilt on the next start. Deleting it does throw away track analysis, which
|
# is rebuilt on the next start. Deleting it does throw away track analysis, which
|
||||||
# is expensive to recompute.
|
# is expensive to recompute.
|
||||||
cache: .musicmouse-cache
|
cache: .musicmouse-cache
|
||||||
|
# How many tracks the background analyzer may work on at once, each in its own
|
||||||
|
# worker process. Omitted means one per core bar one (capped at 8), which is what
|
||||||
|
# turns a first-time pass over a whole library from an overnight job into a coffee
|
||||||
|
# break on a desktop. Set it to 1 on a machine that has better things to do, or to
|
||||||
|
# a specific number to cap how much of it analysis may take.
|
||||||
|
# analysis_workers: 4
|
||||||
|
|
||||||
# Serial port the ESP32 firmware is on. A dropped link is retried, not fatal.
|
# Serial port the ESP32 firmware is on. A dropped link is retried, not fatal.
|
||||||
# Required - use "simulate" to run without the mouse attached, which is a complete
|
# Required - use "simulate" to run without the mouse attached, which is a complete
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from musicmouse.devices.mouse import MusicMouseDevice
|
|||||||
from musicmouse.devices.null_transport import NullTransport
|
from musicmouse.devices.null_transport import NullTransport
|
||||||
from musicmouse.devices.player import Player, VlcPlayer
|
from musicmouse.devices.player import Player, VlcPlayer
|
||||||
from musicmouse.devices.serial_link import SerialLink
|
from musicmouse.devices.serial_link import SerialLink
|
||||||
from musicmouse.library import MusicLibrary
|
from musicmouse.library import MusicLibrary, default_worker_count
|
||||||
from musicmouse.library.analysis import build_analyzer
|
from musicmouse.library.analysis import build_analyzer
|
||||||
from musicmouse.reactions import register_all
|
from musicmouse.reactions import register_all
|
||||||
from musicmouse.services.base import Service
|
from musicmouse.services.base import Service
|
||||||
@@ -283,11 +283,16 @@ def _build_player(bus: EventBus, general: GeneralConfig, *, clock: RealClock) ->
|
|||||||
|
|
||||||
async def build_library(config: Config) -> MusicLibrary:
|
async def build_library(config: Config) -> MusicLibrary:
|
||||||
library_config = config.general.library
|
library_config = config.general.library
|
||||||
|
workers = library_config.analysis_workers
|
||||||
return await MusicLibrary.build(
|
return await MusicLibrary.build(
|
||||||
library_config.root,
|
library_config.root,
|
||||||
library_config.cache,
|
library_config.cache,
|
||||||
frozenset(config.general.audio_extensions),
|
frozenset(config.general.audio_extensions),
|
||||||
analyzer=build_analyzer(),
|
analyzer=build_analyzer(),
|
||||||
|
# Unset in the config means "use the machine": a first pass over an unanalyzed
|
||||||
|
# library is hours of DSP, and there is no reason for a desktop to do it one
|
||||||
|
# core at a time. `MusicLibrary` itself defaults to 1 - see its docstring.
|
||||||
|
analysis_workers=default_worker_count() if workers is None else workers,
|
||||||
figure_kinds=config.figure_kinds,
|
figure_kinds=config.figure_kinds,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,11 @@ class LibraryConfig(_Strict):
|
|||||||
root: Path
|
root: Path
|
||||||
#: Scan results, extracted cover art and track analysis. Relative to this file.
|
#: Scan results, extracted cover art and track analysis. Relative to this file.
|
||||||
cache: Path = Path(".musicmouse-cache")
|
cache: Path = Path(".musicmouse-cache")
|
||||||
|
#: How many tracks background analysis may work on at once, each in its own worker
|
||||||
|
#: process. Omit for one per core bar one (see
|
||||||
|
#: :func:`musicmouse.library.workers.default_worker_count`); set it to 1 to keep
|
||||||
|
#: analysis to a single process on a machine that has other work to do.
|
||||||
|
analysis_workers: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
@field_validator("root")
|
@field_validator("root")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ plain immutable data.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
|
from collections import deque
|
||||||
from collections.abc import Awaitable, Callable, Collection, Mapping
|
from collections.abc import Awaitable, Callable, Collection, Mapping
|
||||||
|
from concurrent.futures import BrokenExecutor, Executor
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Any, Final
|
||||||
|
|
||||||
from musicmouse.library.analysis import (
|
from musicmouse.library.analysis import (
|
||||||
ANALYZER_VERSION,
|
ANALYZER_VERSION,
|
||||||
@@ -29,6 +29,7 @@ from musicmouse.library.cache import Fingerprint, LibraryCache
|
|||||||
from musicmouse.library.models import Album, LibraryTrack, album_id, track_key
|
from musicmouse.library.models import Album, LibraryTrack, album_id, track_key
|
||||||
from musicmouse.library.scanner import scan_library
|
from musicmouse.library.scanner import scan_library
|
||||||
from musicmouse.library.sections import SECTIONS, AlbumKind
|
from musicmouse.library.sections import SECTIONS, AlbumKind
|
||||||
|
from musicmouse.library.workers import analysis_pool, analyze_one, default_worker_count
|
||||||
from musicmouse.media import Playlist
|
from musicmouse.media import Playlist
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
@@ -45,6 +46,7 @@ __all__ = [
|
|||||||
"NullAnalyzer",
|
"NullAnalyzer",
|
||||||
"TrackCurves",
|
"TrackCurves",
|
||||||
"album_id",
|
"album_id",
|
||||||
|
"default_worker_count",
|
||||||
"track_key",
|
"track_key",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -69,19 +71,12 @@ _PUBLISH_BATCH_SIZE: Final = 25
|
|||||||
_PROGRESS_INTERVAL_SECONDS: Final = 5.0
|
_PROGRESS_INTERVAL_SECONDS: Final = 5.0
|
||||||
|
|
||||||
|
|
||||||
def _analyze_one(
|
def _discard(future: asyncio.Future[Any]) -> None:
|
||||||
analyzer: Analyzer, path: Path
|
"""Drop a result nobody is going to read, without leaving a warning behind."""
|
||||||
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
|
if not future.done():
|
||||||
"""Runs in a worker thread. Lowers this thread's own scheduling priority first.
|
future.cancel()
|
||||||
|
elif not future.cancelled():
|
||||||
On Linux, ``os.nice`` affects only the calling thread, not the whole process - so
|
future.exception()
|
||||||
this makes idle-time analysis yield CPU to anything else without touching threads
|
|
||||||
used for other work. Niceness only ever increases and clamps at the OS maximum
|
|
||||||
(19), so calling this repeatedly on a reused pool thread is harmless.
|
|
||||||
"""
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
os.nice(1)
|
|
||||||
return analyzer.analyze(path)
|
|
||||||
|
|
||||||
|
|
||||||
class MusicLibrary:
|
class MusicLibrary:
|
||||||
@@ -94,12 +89,19 @@ class MusicLibrary:
|
|||||||
extensions: frozenset[str],
|
extensions: frozenset[str],
|
||||||
*,
|
*,
|
||||||
analyzer: Analyzer | None = None,
|
analyzer: Analyzer | None = None,
|
||||||
|
analysis_workers: int = 1,
|
||||||
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.root = root
|
self.root = root
|
||||||
self.cache = cache
|
self.cache = cache
|
||||||
self.extensions = extensions
|
self.extensions = extensions
|
||||||
self.analyzer: Analyzer = analyzer or NullAnalyzer()
|
self.analyzer: Analyzer = analyzer or NullAnalyzer()
|
||||||
|
#: How many tracks background analysis may work on at once. The default of 1
|
||||||
|
#: keeps the analyzer in this process, where an analyzer that holds state
|
||||||
|
#: still behaves as written; the app passes `default_worker_count()`, which is
|
||||||
|
#: what makes a first-time pass finish in hours rather than days on a desktop.
|
||||||
|
#: See `musicmouse.library.workers`.
|
||||||
|
self.analysis_workers = analysis_workers
|
||||||
#: What each figure holds. The only thing a folder name cannot say.
|
#: What each figure holds. The only thing a folder name cannot say.
|
||||||
self.figure_kinds: Mapping[str, AlbumKind] = figure_kinds or {}
|
self.figure_kinds: Mapping[str, AlbumKind] = figure_kinds or {}
|
||||||
self._entries: dict[str, tuple[Album, Fingerprint]] = {}
|
self._entries: dict[str, tuple[Album, Fingerprint]] = {}
|
||||||
@@ -146,9 +148,7 @@ class MusicLibrary:
|
|||||||
]
|
]
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return None
|
return None
|
||||||
return max(
|
return max(candidates, key=lambda album: album.tracks[0].path.name if album.tracks else "")
|
||||||
candidates, key=lambda album: album.tracks[0].path.name if album.tracks else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
def beats(self, identifier: str, index: int) -> BeatGrid | None:
|
def beats(self, identifier: str, index: int) -> BeatGrid | None:
|
||||||
album = self.get(identifier)
|
album = self.get(identifier)
|
||||||
@@ -219,10 +219,18 @@ class MusicLibrary:
|
|||||||
extensions: frozenset[str],
|
extensions: frozenset[str],
|
||||||
*,
|
*,
|
||||||
analyzer: Analyzer | None = None,
|
analyzer: Analyzer | None = None,
|
||||||
|
analysis_workers: int = 1,
|
||||||
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
figure_kinds: Mapping[str, AlbumKind] | None = None,
|
||||||
) -> MusicLibrary:
|
) -> MusicLibrary:
|
||||||
cache = LibraryCache(cache_dir)
|
cache = LibraryCache(cache_dir)
|
||||||
library = cls(root, cache, extensions, analyzer=analyzer, figure_kinds=figure_kinds)
|
library = cls(
|
||||||
|
root,
|
||||||
|
cache,
|
||||||
|
extensions,
|
||||||
|
analyzer=analyzer,
|
||||||
|
analysis_workers=analysis_workers,
|
||||||
|
figure_kinds=figure_kinds,
|
||||||
|
)
|
||||||
library._entries = await asyncio.to_thread(cache.load_index)
|
library._entries = await asyncio.to_thread(cache.load_index)
|
||||||
await library.refresh()
|
await library.refresh()
|
||||||
return library
|
return library
|
||||||
@@ -261,64 +269,145 @@ class MusicLibrary:
|
|||||||
is_busy: Callable[[], bool] = lambda: False,
|
is_busy: Callable[[], bool] = lambda: False,
|
||||||
on_batch: Callable[[], Awaitable[None]] | None = None,
|
on_batch: Callable[[], Awaitable[None]] | None = None,
|
||||||
batch_size: int = _PUBLISH_BATCH_SIZE,
|
batch_size: int = _PUBLISH_BATCH_SIZE,
|
||||||
|
workers: int | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Run the analyzer over tracks of `kinds` that have no current result.
|
"""Run the analyzer over tracks of `kinds` that have no current result.
|
||||||
|
|
||||||
Restricted to music by default - see `_ANALYZED_KINDS`. Checked before every
|
Restricted to music by default - see `_ANALYZED_KINDS`. Up to `workers` tracks
|
||||||
track, `is_busy()` pauses the whole pass rather than one file: analysis must
|
are analyzed at once (`self.analysis_workers` when not given), each in its own
|
||||||
never compete with audio decoding for CPU, and a children's player is idle most
|
worker process - see `musicmouse.library.workers` for why processes and how the
|
||||||
of the day, so the pass simply resumes next time it is. A track the analyzer
|
machine is kept usable while they run. Checked before every track is handed out,
|
||||||
fails on (corrupt file, DRM, zero length) is still recorded as attempted - with
|
`is_busy()` pauses the whole pass rather than one file: analysis must never
|
||||||
every scalar left `None` - so it is never retried forever and the frontend falls
|
compete with audio decoding for CPU, and a children's player is idle most of the
|
||||||
back to the un-analyzed baseline for it. Results are folded into the live index
|
day, so the pass simply resumes next time it is. Tracks already in flight when
|
||||||
and persisted every `batch_size` tracks, so a long first run is visible in open
|
it goes busy are allowed to finish - their results are already paid for - and
|
||||||
browser tabs as it goes rather than only once it finishes.
|
the workers are then shut down for the duration rather than sitting idle with
|
||||||
|
a librosa apiece resident on a machine that is now playing music.
|
||||||
|
|
||||||
|
A track the analyzer fails on (corrupt file, DRM, zero length) is still recorded
|
||||||
|
as attempted - with every scalar left `None` - so it is never retried forever and
|
||||||
|
the frontend falls back to the un-analyzed baseline for it. A worker *process*
|
||||||
|
dying, though, says nothing about the track it was on, so that ends the pass
|
||||||
|
without recording anything: the next one picks the same tracks up again.
|
||||||
|
|
||||||
|
Results are folded into the live index and persisted every `batch_size` tracks,
|
||||||
|
so a long first run is visible in open browser tabs as it goes rather than only
|
||||||
|
once it finishes.
|
||||||
"""
|
"""
|
||||||
analyzer = self.analyzer
|
analyzer = self.analyzer
|
||||||
if analyzer.version < ANALYZER_VERSION:
|
if analyzer.version < ANALYZER_VERSION:
|
||||||
return 0
|
return 0
|
||||||
|
pending = deque(self._pending_tracks(kinds, analyzer.version))
|
||||||
|
if not pending:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
workers = self.analysis_workers if workers is None else max(1, workers)
|
||||||
|
total = len(pending)
|
||||||
|
_log.info("Analyzing %d tracks with %d worker(s)", total, workers)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
in_flight: dict[asyncio.Future[Any], tuple[str, Path]] = {}
|
||||||
done = 0
|
done = 0
|
||||||
last_report = time.monotonic()
|
last_report = time.monotonic()
|
||||||
for album in self.albums:
|
|
||||||
if album.kind not in kinds:
|
|
||||||
continue
|
|
||||||
for track in album.tracks:
|
|
||||||
while is_busy():
|
|
||||||
await asyncio.sleep(_BUSY_POLL_SECONDS)
|
|
||||||
key = track_key(track.path)
|
|
||||||
cached = self.cache.load_analysis(key)
|
|
||||||
if cached is not None and cached.version >= analyzer.version:
|
|
||||||
continue
|
|
||||||
now = time.monotonic()
|
|
||||||
if now - last_report >= _PROGRESS_INTERVAL_SECONDS:
|
|
||||||
_log.info("Analyzing library: %d tracks done so far, now on %s", done, track.path)
|
|
||||||
last_report = now
|
|
||||||
try:
|
try:
|
||||||
analysis, grid, curve = await asyncio.to_thread(
|
while pending:
|
||||||
_analyze_one, analyzer, track.path
|
while is_busy():
|
||||||
|
# Waited out *between* pools, so a half-hour album is not played
|
||||||
|
# with a houseful of idle worker processes holding onto librosa.
|
||||||
|
await asyncio.sleep(_BUSY_POLL_SECONDS)
|
||||||
|
with analysis_pool(workers) as pool:
|
||||||
|
try:
|
||||||
|
while in_flight or (pending and not is_busy()):
|
||||||
|
while pending and len(in_flight) < workers and not is_busy():
|
||||||
|
key, path = pending.popleft()
|
||||||
|
in_flight[self._submit(pool, loop, path)] = (key, path)
|
||||||
|
if not in_flight:
|
||||||
|
break # gone busy: drop the pool and wait above
|
||||||
|
finished, _ = await asyncio.wait(
|
||||||
|
in_flight, return_when=asyncio.FIRST_COMPLETED
|
||||||
)
|
)
|
||||||
except Exception:
|
for future in finished:
|
||||||
_log.warning(
|
key, path = in_flight.pop(future)
|
||||||
"Analyzer raised on %s; marking it attempted so it is not retried forever",
|
self._store_result(key, path, future)
|
||||||
track.path,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
analysis, grid, curve = TrackAnalysis(version=analyzer.version), None, None
|
|
||||||
if grid is not None:
|
|
||||||
self.cache.store_beats(key, grid)
|
|
||||||
if curve is not None:
|
|
||||||
self.cache.store_curve(key, curve)
|
|
||||||
self.cache.store_analysis(key, analysis)
|
|
||||||
done += 1
|
done += 1
|
||||||
if done % batch_size == 0:
|
if done % batch_size == 0:
|
||||||
await self._publish_analysis(kinds, on_batch)
|
await self._publish_analysis(kinds, on_batch)
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - last_report >= _PROGRESS_INTERVAL_SECONDS:
|
||||||
|
_log.info("Analyzing library: %d/%d tracks done", done, total)
|
||||||
|
last_report = now
|
||||||
|
finally:
|
||||||
|
# Nothing is left to read these - without this, a pass that ends
|
||||||
|
# early (cancelled at shutdown, or a dead pool) leaves "exception
|
||||||
|
# was never retrieved" behind for every track still in flight.
|
||||||
|
for future in in_flight:
|
||||||
|
_discard(future)
|
||||||
|
in_flight.clear()
|
||||||
|
except BrokenExecutor:
|
||||||
|
_log.error(
|
||||||
|
"An analysis worker process died (out of memory?) after %d of %d tracks; "
|
||||||
|
"stopping this pass. The rest are retried on the next one.",
|
||||||
|
done,
|
||||||
|
total,
|
||||||
|
)
|
||||||
if done % batch_size:
|
if done % batch_size:
|
||||||
await self._publish_analysis(kinds, on_batch)
|
await self._publish_analysis(kinds, on_batch)
|
||||||
if done:
|
if done:
|
||||||
_log.info("Analyzed %d tracks", done)
|
_log.info("Analyzed %d tracks", done)
|
||||||
return done
|
return done
|
||||||
|
|
||||||
|
def _pending_tracks(self, kinds: Collection[AlbumKind], version: int) -> list[tuple[str, Path]]:
|
||||||
|
"""The whole pass's worklist, as (cache key, path), worked out up front.
|
||||||
|
|
||||||
|
Up front rather than per track, because a pool has to have the next file ready
|
||||||
|
the moment a worker frees up, and because it is what lets the progress line say
|
||||||
|
"37/412". Deduplicated by cache key: the same file can sit in two albums, and
|
||||||
|
two workers analyzing it at once would be pure waste.
|
||||||
|
"""
|
||||||
|
worklist: list[tuple[str, Path]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for album in self.albums:
|
||||||
|
if album.kind not in kinds:
|
||||||
|
continue
|
||||||
|
for track in album.tracks:
|
||||||
|
key = track_key(track.path)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
cached = self.cache.load_analysis(key)
|
||||||
|
if cached is not None and cached.version >= version:
|
||||||
|
continue
|
||||||
|
worklist.append((key, track.path))
|
||||||
|
return worklist
|
||||||
|
|
||||||
|
def _submit(
|
||||||
|
self, pool: Executor | None, loop: asyncio.AbstractEventLoop, path: Path
|
||||||
|
) -> asyncio.Future[Any]:
|
||||||
|
"""Start one track, in a worker process or - with no pool - on a thread here."""
|
||||||
|
if pool is None:
|
||||||
|
return asyncio.ensure_future(asyncio.to_thread(analyze_one, self.analyzer, path))
|
||||||
|
return loop.run_in_executor(pool, analyze_one, self.analyzer, path)
|
||||||
|
|
||||||
|
def _store_result(self, key: str, path: Path, future: asyncio.Future[Any]) -> None:
|
||||||
|
"""Persist one finished track. Re-raises only what ends the whole pass."""
|
||||||
|
error = future.exception()
|
||||||
|
if isinstance(error, BrokenExecutor):
|
||||||
|
raise error
|
||||||
|
if error is not None:
|
||||||
|
_log.warning(
|
||||||
|
"Analyzer raised on %s; marking it attempted so it is not retried forever",
|
||||||
|
path,
|
||||||
|
exc_info=error,
|
||||||
|
)
|
||||||
|
analysis, grid, curve = TrackAnalysis(version=self.analyzer.version), None, None
|
||||||
|
else:
|
||||||
|
analysis, grid, curve = future.result()
|
||||||
|
if grid is not None:
|
||||||
|
self.cache.store_beats(key, grid)
|
||||||
|
if curve is not None:
|
||||||
|
self.cache.store_curve(key, curve)
|
||||||
|
self.cache.store_analysis(key, analysis)
|
||||||
|
|
||||||
async def _publish_analysis(
|
async def _publish_analysis(
|
||||||
self, kinds: Collection[AlbumKind], on_batch: Callable[[], Awaitable[None]] | None
|
self, kinds: Collection[AlbumKind], on_batch: Callable[[], Awaitable[None]] | None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
143
python-backend/musicmouse/library/workers.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""Where analysis actually burns CPU, and how it is kept from taking the machine over.
|
||||||
|
|
||||||
|
Analyzing one track is a few seconds of single-threaded DSP that the GIL will not let
|
||||||
|
another Python thread overlap with - librosa's work is numba-jitted and numpy glue, not
|
||||||
|
long C calls that release it. So a library-sized pass parallelizes across *processes*:
|
||||||
|
:func:`analysis_pool` hands :meth:`~musicmouse.library.MusicLibrary.analyze_pending` an
|
||||||
|
executor whose workers are separate interpreters, and a 12-core desktop chews through a
|
||||||
|
first-time scan roughly an order of magnitude faster than the Raspberry Pi this also
|
||||||
|
has to stay polite on.
|
||||||
|
|
||||||
|
Polite means three things, all of them set here rather than at the call site:
|
||||||
|
|
||||||
|
* **One core stays free** (:func:`default_worker_count`), so the audio thread and the
|
||||||
|
web server never have to fight a full house of analyzers for a timeslice.
|
||||||
|
* **Workers run niced**, so even the cores they do own yield to playback instantly.
|
||||||
|
* **Each worker stays single-threaded** - numpy's BLAS and numba would each happily
|
||||||
|
start one thread per core *inside* every worker, and N x N threads on a 4-core Pi is
|
||||||
|
slower than N, not faster.
|
||||||
|
|
||||||
|
Whatever an analyzer returns has to survive the trip back from a worker process, which
|
||||||
|
is what keeps :class:`~musicmouse.library.analysis.TrackAnalysis` and friends plain
|
||||||
|
frozen dataclasses of floats. An analyzer that records state in its own instance -
|
||||||
|
a test double counting calls, say - only sees that state in the worker, so such an
|
||||||
|
analyzer must be run with ``workers=1``, where everything stays in this process on a
|
||||||
|
thread.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from concurrent.futures import Executor, ProcessPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from musicmouse.library.analysis import Analyzer, BeatGrid, TrackAnalysis, TrackCurves
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = ["analysis_pool", "analyze_one", "default_worker_count"]
|
||||||
|
|
||||||
|
#: Ceiling on the automatic worker count. Every worker is a fresh interpreter with its
|
||||||
|
#: own librosa, numpy and a decoded track in memory - a few hundred MB each - so on a
|
||||||
|
#: big machine the limit that bites first is RAM, not cores. An explicit
|
||||||
|
#: ``analysis_workers`` in the config overrides this; the default stays conservative.
|
||||||
|
_MAX_AUTO_WORKERS: Final = 8
|
||||||
|
|
||||||
|
#: How much worse than everything else analysis schedules. Niceness clamps at the OS
|
||||||
|
#: maximum (19) and only ever increases, so re-applying it to a reused worker is
|
||||||
|
#: harmless.
|
||||||
|
_NICENESS: Final = 5
|
||||||
|
|
||||||
|
#: Forced into every worker *before* it imports numpy, which reads these once at import
|
||||||
|
#: time. Without them each worker opens its own BLAS thread pool sized for the whole
|
||||||
|
#: machine and the pool oversubscribes every core several times over.
|
||||||
|
_SINGLE_THREADED: Final = {
|
||||||
|
"OMP_NUM_THREADS": "1",
|
||||||
|
"OPENBLAS_NUM_THREADS": "1",
|
||||||
|
"MKL_NUM_THREADS": "1",
|
||||||
|
"NUMEXPR_NUM_THREADS": "1",
|
||||||
|
"NUMBA_NUM_THREADS": "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_worker_count() -> int:
|
||||||
|
"""One worker per core bar one, capped at :data:`_MAX_AUTO_WORKERS`.
|
||||||
|
|
||||||
|
The core left over is for the rest of the app: audio decoding, the web server and
|
||||||
|
the serial link all have to stay responsive while a first-time pass runs for hours.
|
||||||
|
Single-core machines get 1, which :func:`analysis_pool` turns into the in-process
|
||||||
|
path rather than a pool of one.
|
||||||
|
"""
|
||||||
|
return max(1, min(_MAX_AUTO_WORKERS, (os.cpu_count() or 1) - 1))
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_one(
|
||||||
|
analyzer: Analyzer, path: Path
|
||||||
|
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
|
||||||
|
"""Analyze one file off the event loop - in a worker process, or on a thread here.
|
||||||
|
|
||||||
|
Lowers the caller's own scheduling priority first. On Linux ``os.nice`` affects only
|
||||||
|
the calling *thread*, so on the in-process path this makes idle-time analysis yield
|
||||||
|
CPU without touching threads doing other work; in a worker process there is nothing
|
||||||
|
else in the process to slow down anyway.
|
||||||
|
"""
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.nice(_NICENESS)
|
||||||
|
return analyzer.analyze(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _init_worker() -> None:
|
||||||
|
"""Runs once per worker process, before it imports librosa or numpy.
|
||||||
|
|
||||||
|
A spawned worker starts from a bare interpreter and pulls the analyzer in when it
|
||||||
|
unpickles its first task, so setting the thread-count variables here still lands
|
||||||
|
ahead of numpy reading them.
|
||||||
|
"""
|
||||||
|
os.environ.update(_SINGLE_THREADED)
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.nice(_NICENESS)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def analysis_pool(workers: int) -> Iterator[Executor | None]:
|
||||||
|
"""A pool of `workers` analyzer processes, or ``None`` for "stay in this process".
|
||||||
|
|
||||||
|
``None`` - for ``workers <= 1``, and as the fallback when a pool cannot be started
|
||||||
|
at all - means the caller should run each track on a thread instead. That path is
|
||||||
|
what the tests and single-core devices use, and it is the only one where an
|
||||||
|
analyzer holding state in its own instance behaves as written.
|
||||||
|
|
||||||
|
Workers are *spawned*, never forked: this process has an asyncio loop, a serial
|
||||||
|
reader and libVLC's own threads running, and forking that is a well-known way to
|
||||||
|
inherit a held lock and deadlock in a child. The price is one librosa import per
|
||||||
|
worker, a few seconds paid once per pass - nothing next to the hours of DSP a
|
||||||
|
first-time pass over a real library costs.
|
||||||
|
"""
|
||||||
|
if workers <= 1:
|
||||||
|
yield None
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
executor = ProcessPoolExecutor(
|
||||||
|
max_workers=workers,
|
||||||
|
mp_context=multiprocessing.get_context("spawn"),
|
||||||
|
initializer=_init_worker,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
_log.warning(
|
||||||
|
"Could not start %d analysis workers; analyzing in this process instead",
|
||||||
|
workers,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
yield None
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
yield executor
|
||||||
|
finally:
|
||||||
|
# `wait=False`: shutdown happens on cancellation too (the app is stopping), and
|
||||||
|
# waiting there would hold it up for however long the tracks in flight take.
|
||||||
|
executor.shutdown(wait=False, cancel_futures=True)
|
||||||
@@ -372,7 +372,6 @@ class TippenSettingsIn(BaseModel):
|
|||||||
class TippenProgressOut(BaseModel):
|
class TippenProgressOut(BaseModel):
|
||||||
lessons: dict[str, TippenLessonProgressOut]
|
lessons: dict[str, TippenLessonProgressOut]
|
||||||
key_stats: dict[str, TippenKeyStatOut]
|
key_stats: dict[str, TippenKeyStatOut]
|
||||||
pearls: int
|
|
||||||
aquarium: list[str]
|
aquarium: list[str]
|
||||||
streak: TippenStreakOut
|
streak: TippenStreakOut
|
||||||
settings: TippenSettingsOut
|
settings: TippenSettingsOut
|
||||||
@@ -395,7 +394,6 @@ class TippenRunIn(BaseModel):
|
|||||||
animal: AnimalId
|
animal: AnimalId
|
||||||
points: float
|
points: float
|
||||||
passed: bool
|
passed: bool
|
||||||
pearls: int = Field(ge=0)
|
|
||||||
strokes: list[TippenStrokeIn] = Field(default_factory=list)
|
strokes: list[TippenStrokeIn] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ def progress_out(progress: TypingProgress) -> TippenProgressOut:
|
|||||||
key: TippenKeyStatOut(ema=stat.ema, attempts=stat.attempts, errors=stat.errors)
|
key: TippenKeyStatOut(ema=stat.ema, attempts=stat.attempts, errors=stat.errors)
|
||||||
for key, stat in progress.key_stats.items()
|
for key, stat in progress.key_stats.items()
|
||||||
},
|
},
|
||||||
pearls=progress.pearls,
|
|
||||||
aquarium=list(progress.aquarium),
|
aquarium=list(progress.aquarium),
|
||||||
streak=TippenStreakOut(days=progress.streak.days, last_played=progress.streak.last_played),
|
streak=TippenStreakOut(days=progress.streak.days, last_played=progress.streak.last_played),
|
||||||
settings=TippenSettingsOut(
|
settings=TippenSettingsOut(
|
||||||
@@ -159,7 +158,6 @@ def record_tippen_run(app: App, body: TippenRunIn) -> TippenRunOut:
|
|||||||
animal=body.animal,
|
animal=body.animal,
|
||||||
points=body.points,
|
points=body.points,
|
||||||
passed=body.passed,
|
passed=body.passed,
|
||||||
pearls=body.pearls,
|
|
||||||
strokes=tuple(
|
strokes=tuple(
|
||||||
Stroke(key=s.key, expected=s.expected, correct=s.correct, at=s.at) for s in body.strokes
|
Stroke(key=s.key, expected=s.expected, correct=s.correct, at=s.at) for s in body.strokes
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ __all__ = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
type LessonKind = Literal["letters", "fragments", "words", "sentences"]
|
type LessonKind = Literal["letters", "fragments", "words", "sentences"]
|
||||||
type ModeId = Literal["dive", "bubbles", "jellyfish", "feed", "race"]
|
type ModeId = Literal["dive", "bubbles", "feed", "race"]
|
||||||
type CreatureId = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"]
|
type CreatureId = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"]
|
||||||
|
|
||||||
#: In world order - see ``tippen/src/lib/aquarium.ts``, the one place this list is
|
#: In world order - see ``tippen/src/lib/aquarium.ts``, the one place this list is
|
||||||
@@ -52,9 +52,9 @@ CREATURE_IDS: Final[tuple[CreatureId, ...]] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
#: Which modes make sense for a kind - letters rounds are single keys, so only the
|
#: Which modes make sense for a kind - letters rounds are single keys, so only the
|
||||||
#: arcade modes fit; only words and sentences are long enough for a race.
|
#: arcade mode fits; only words and sentences are long enough for a race.
|
||||||
ELIGIBLE_MODES: Final[dict[LessonKind, tuple[ModeId, ...]]] = {
|
ELIGIBLE_MODES: Final[dict[LessonKind, tuple[ModeId, ...]]] = {
|
||||||
"letters": ("bubbles", "jellyfish"),
|
"letters": ("bubbles",),
|
||||||
"fragments": ("dive", "feed"),
|
"fragments": ("dive", "feed"),
|
||||||
"words": ("dive", "feed", "race"),
|
"words": ("dive", "feed", "race"),
|
||||||
"sentences": ("dive", "race"),
|
"sentences": ("dive", "race"),
|
||||||
@@ -104,6 +104,10 @@ class _YamlRoot(_Strict):
|
|||||||
problems.append(f"expected {len(CREATURE_IDS)} worlds, found {len(self.worlds)}")
|
problems.append(f"expected {len(CREATURE_IDS)} worlds, found {len(self.worlds)}")
|
||||||
|
|
||||||
seen_rewards: set[CreatureId] = set()
|
seen_rewards: set[CreatureId] = set()
|
||||||
|
# A lesson's `keys` is its spotlight, not only what is new: a round may list
|
||||||
|
# every key learned so far to drill them evenly. What must stay small is how
|
||||||
|
# much is *new* in one round - two keys, the mirrored finger pair.
|
||||||
|
seen_keys: set[str] = set()
|
||||||
for world in self.worlds:
|
for world in self.worlds:
|
||||||
if world.reward in seen_rewards:
|
if world.reward in seen_rewards:
|
||||||
problems.append(f"world {world.number} reuses reward {world.reward!r}")
|
problems.append(f"world {world.number} reuses reward {world.reward!r}")
|
||||||
@@ -121,8 +125,10 @@ class _YamlRoot(_Strict):
|
|||||||
problems.append(f'{where}: kind "{kind}" needs a non-empty words list')
|
problems.append(f'{where}: kind "{kind}" needs a non-empty words list')
|
||||||
if lesson.drill and lesson.keys:
|
if lesson.drill and lesson.keys:
|
||||||
problems.append(f"{where}: a drill cannot also introduce keys")
|
problems.append(f"{where}: a drill cannot also introduce keys")
|
||||||
if lesson.keys and len(lesson.keys) > 2:
|
new_keys = [key for key in (lesson.keys or ()) if key not in seen_keys]
|
||||||
problems.append(f"{where}: at most two keys per lesson")
|
if len(new_keys) > 2:
|
||||||
|
problems.append(f"{where}: at most two new keys per lesson")
|
||||||
|
seen_keys.update(lesson.keys or ())
|
||||||
if lesson.mode and lesson.mode not in ELIGIBLE_MODES[kind]:
|
if lesson.mode and lesson.mode not in ELIGIBLE_MODES[kind]:
|
||||||
problems.append(f'{where}: mode "{lesson.mode}" does not fit kind "{kind}"')
|
problems.append(f'{where}: mode "{lesson.mode}" does not fit kind "{kind}"')
|
||||||
|
|
||||||
@@ -230,9 +236,6 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]:
|
|||||||
lessons: list[Lesson] = []
|
lessons: list[Lesson] = []
|
||||||
active: set[str] = set()
|
active: set[str] = set()
|
||||||
seen_before: set[str] = set()
|
seen_before: set[str] = set()
|
||||||
# Letters rounds alternate bubbles/jellyfish across the whole course, so the arcade
|
|
||||||
# game never repeats twice in a row even across a fragments/words lesson in between.
|
|
||||||
last_arcade: ModeId = "jellyfish" # so lesson 1 opens on bubbles
|
|
||||||
|
|
||||||
for world in worlds:
|
for world in worlds:
|
||||||
for entry in world.lessons:
|
for entry in world.lessons:
|
||||||
@@ -260,16 +263,10 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]:
|
|||||||
else:
|
else:
|
||||||
emphasis = "mixed"
|
emphasis = "mixed"
|
||||||
|
|
||||||
primary_mode: ModeId
|
primary_mode: ModeId = entry.mode or ("bubbles" if kind == "letters" else "dive")
|
||||||
if kind == "letters":
|
|
||||||
default_arcade: ModeId = "jellyfish" if last_arcade == "bubbles" else "bubbles"
|
|
||||||
primary_mode = entry.mode or default_arcade
|
|
||||||
last_arcade = primary_mode
|
|
||||||
else:
|
|
||||||
primary_mode = entry.mode or "dive"
|
|
||||||
|
|
||||||
# Bonus replays are only ever feed/race - dive is the plain default, and
|
# Bonus replays are only ever feed/race - dive is the plain default, and
|
||||||
# bubbles/jellyfish already alternate on their own.
|
# bubbles is the only letters-round arcade mode.
|
||||||
bonus_modes = tuple(
|
bonus_modes = tuple(
|
||||||
mode
|
mode
|
||||||
for mode in ELIGIBLE_MODES[kind]
|
for mode in ELIGIBLE_MODES[kind]
|
||||||
@@ -287,7 +284,13 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]:
|
|||||||
subtitle=entry.subtitle,
|
subtitle=entry.subtitle,
|
||||||
kind=kind,
|
kind=kind,
|
||||||
new_keys=new_keys,
|
new_keys=new_keys,
|
||||||
spotlight_keys=keys if kind == "letters" and not is_drill else (),
|
# A round that lists every key learned so far still spotlights only
|
||||||
|
# what it introduces - a lesson titled "K" must drill K, not spread
|
||||||
|
# itself evenly over all five keys it happens to name. With nothing
|
||||||
|
# new, the whole list is the spotlight: that is the "mixed" replay.
|
||||||
|
spotlight_keys=(
|
||||||
|
(new_keys or keys) if kind == "letters" and not is_drill else ()
|
||||||
|
),
|
||||||
emphasis=emphasis,
|
emphasis=emphasis,
|
||||||
active_keys=active_keys,
|
active_keys=active_keys,
|
||||||
primary_mode=primary_mode,
|
primary_mode=primary_mode,
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ class RunResult:
|
|||||||
animal: AnimalId
|
animal: AnimalId
|
||||||
points: float
|
points: float
|
||||||
passed: bool
|
passed: bool
|
||||||
pearls: int
|
|
||||||
strokes: tuple[Stroke, ...]
|
strokes: tuple[Stroke, ...]
|
||||||
|
|
||||||
|
|
||||||
@@ -143,7 +142,6 @@ class TypingProgress(BaseModel):
|
|||||||
version: Literal[1] = 1
|
version: Literal[1] = 1
|
||||||
lessons: dict[str, LessonProgress] = Field(default_factory=dict)
|
lessons: dict[str, LessonProgress] = Field(default_factory=dict)
|
||||||
key_stats: dict[str, KeyStat] = Field(default_factory=dict)
|
key_stats: dict[str, KeyStat] = Field(default_factory=dict)
|
||||||
pearls: int = 0
|
|
||||||
#: Pets that have moved into the aquarium, in the order they arrived.
|
#: Pets that have moved into the aquarium, in the order they arrived.
|
||||||
aquarium: list[CreatureId] = Field(default_factory=list)
|
aquarium: list[CreatureId] = Field(default_factory=list)
|
||||||
streak: Streak = Field(default_factory=Streak)
|
streak: Streak = Field(default_factory=Streak)
|
||||||
@@ -265,7 +263,7 @@ def record_run(
|
|||||||
curriculum: Curriculum,
|
curriculum: Curriculum,
|
||||||
day: str | None = None,
|
day: str | None = None,
|
||||||
) -> RecordOutcome:
|
) -> RecordOutcome:
|
||||||
"""Record a finished run: stars, animal, pearls, key stats, streak, and the unlock.
|
"""Record a finished run: stars, animal, key stats, streak, and the unlock.
|
||||||
|
|
||||||
The unlock rule, in one place: two stars unlocks the next lesson, and so does the
|
The unlock rule, in one place: two stars unlocks the next lesson, and so does the
|
||||||
fifth attempt whatever the score. Speed is nowhere in it.
|
fifth attempt whatever the score. Speed is nowhere in it.
|
||||||
@@ -317,7 +315,6 @@ def record_run(
|
|||||||
"lessons": lessons,
|
"lessons": lessons,
|
||||||
"aquarium": aquarium,
|
"aquarium": aquarium,
|
||||||
"key_stats": _fold_key_stats(progress.key_stats, result.strokes),
|
"key_stats": _fold_key_stats(progress.key_stats, result.strokes),
|
||||||
"pearls": progress.pearls + result.pearls,
|
|
||||||
"streak": _bump_streak(progress.streak, day),
|
"streak": _bump_streak(progress.streak, day),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
|
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
|
||||||
from musicmouse.library import Album, Analyzer, MusicLibrary
|
from musicmouse.library import Album, Analyzer, MusicLibrary, default_worker_count
|
||||||
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
|
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
|
||||||
from musicmouse.library.cache import LibraryCache
|
from musicmouse.library.cache import LibraryCache
|
||||||
from musicmouse.library.colors import colors_from_id
|
from musicmouse.library.colors import colors_from_id
|
||||||
@@ -52,6 +54,19 @@ class FakeAnalyzer:
|
|||||||
return analysis, BeatGrid((0.1,), (1.0,)), curves
|
return analysis, BeatGrid((0.1,), (1.0,)), curves
|
||||||
|
|
||||||
|
|
||||||
|
class PidAnalyzer(FakeAnalyzer):
|
||||||
|
"""Reports *which process* analyzed each track, as `tempo`.
|
||||||
|
|
||||||
|
The point of a pool is that this is never the process running the test - and a
|
||||||
|
class at module scope is also the only kind of analyzer a worker can be handed,
|
||||||
|
since it has to survive being pickled over to one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
|
||||||
|
analysis, grid, curves = super().analyze(path)
|
||||||
|
return replace(analysis, tempo=float(os.getpid())), grid, curves
|
||||||
|
|
||||||
|
|
||||||
def album_named(library: MusicLibrary, title: str) -> Album:
|
def album_named(library: MusicLibrary, title: str) -> Album:
|
||||||
return next(album for album in library.albums if album.title == title)
|
return next(album for album in library.albums if album.title == title)
|
||||||
|
|
||||||
@@ -641,3 +656,73 @@ async def test_requests_raised_during_a_pass_coalesce_into_one_more_pass(
|
|||||||
|
|
||||||
assert pass_count == 2
|
assert pass_count == 2
|
||||||
assert len(analyzer.calls) == 5
|
assert len(analyzer.calls) == 5
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ worker processes
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_parallel_pass_analyzes_every_track_in_worker_processes(
|
||||||
|
config_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""The whole point of `analysis_workers`: a machine with cores gets to use them.
|
||||||
|
|
||||||
|
Two workers rather than the real default, because what is being checked is that
|
||||||
|
the work left this process at all - not how fast a five-track fixture goes.
|
||||||
|
"""
|
||||||
|
library = await build(config_dir, analyzer=PidAnalyzer(fails={"00 - lied.mp3"}))
|
||||||
|
|
||||||
|
done = await library.analyze_pending(workers=2)
|
||||||
|
|
||||||
|
assert done == 5
|
||||||
|
analyzed = [
|
||||||
|
track.analysis
|
||||||
|
for album in library.albums
|
||||||
|
if album.kind == "music"
|
||||||
|
for track in album.tracks
|
||||||
|
]
|
||||||
|
assert all(a is not None and a.version == ANALYZER_VERSION for a in analyzed)
|
||||||
|
# The one file the analyzer chokes on is recorded as attempted, exactly as on the
|
||||||
|
# in-process path - a raise in a worker must not abort the other four.
|
||||||
|
failed = [a for a in analyzed if a is not None and a.tempo is None]
|
||||||
|
assert len(failed) == 1
|
||||||
|
workers = {a.tempo for a in analyzed if a is not None and a.tempo is not None}
|
||||||
|
assert workers
|
||||||
|
assert os.getpid() not in workers
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_parallel_pass_starts_no_workers_while_playback_is_busy(
|
||||||
|
config_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Being busy holds the pass *before* the pool exists, so a device playing an album
|
||||||
|
is not also hosting a set of idle analyzer processes."""
|
||||||
|
import musicmouse.library as library_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(library_module, "_BUSY_POLL_SECONDS", 0.01)
|
||||||
|
library = await build(config_dir, analyzer=PidAnalyzer())
|
||||||
|
|
||||||
|
busy = True
|
||||||
|
task = asyncio.create_task(library.analyze_pending(workers=2, is_busy=lambda: busy))
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
assert list(library.cache.analysis.glob("*.json")) == []
|
||||||
|
|
||||||
|
busy = False
|
||||||
|
assert await asyncio.wait_for(task, timeout=30) == 5
|
||||||
|
finally:
|
||||||
|
if not task.done():
|
||||||
|
await _cancel(task)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_parallel_pass_is_cancellable_mid_flight(config_dir: Path) -> None:
|
||||||
|
"""Shutdown cancels this task like any other, and it must not hang waiting for
|
||||||
|
worker processes to finish the tracks they are on."""
|
||||||
|
library = await build(config_dir, analyzer=PidAnalyzer())
|
||||||
|
|
||||||
|
task = asyncio.create_task(library.analyze_pending(workers=2))
|
||||||
|
await asyncio.sleep(0.1) # long enough to have workers starting up
|
||||||
|
await asyncio.wait_for(_cancel(task), timeout=10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_default_worker_count_leaves_a_core_for_everything_else() -> None:
|
||||||
|
count = default_worker_count()
|
||||||
|
assert 1 <= count <= max(1, (os.cpu_count() or 1) - 1)
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ def _run_body(lesson_id: str, *, stars: int = 3, passed: bool = True) -> dict:
|
|||||||
"animal": "fish",
|
"animal": "fish",
|
||||||
"points": 42.0,
|
"points": 42.0,
|
||||||
"passed": passed,
|
"passed": passed,
|
||||||
"pearls": 5,
|
|
||||||
"strokes": [{"key": "a", "expected": "a", "correct": True, "at": 0.0}],
|
"strokes": [{"key": "a", "expected": "a", "correct": True, "at": 0.0}],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +170,6 @@ async def test_get_progress_is_fresh_with_only_the_first_lesson_unlocked(
|
|||||||
body = (await client.get("/api/tippen/progress")).json()
|
body = (await client.get("/api/tippen/progress")).json()
|
||||||
assert body["lessons"]["l01"]["unlocked"] is True
|
assert body["lessons"]["l01"]["unlocked"] is True
|
||||||
assert body["lessons"]["l02"]["unlocked"] is False
|
assert body["lessons"]["l02"]["unlocked"] is False
|
||||||
assert body["pearls"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_put_settings_round_trips(client: httpx2.AsyncClient) -> None:
|
async def test_put_settings_round_trips(client: httpx2.AsyncClient) -> None:
|
||||||
|
|||||||
@@ -117,19 +117,21 @@ def test_wrong_world_count_is_one_problem(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
def test_multiple_problems_are_all_reported_at_once(tmp_path: Path) -> None:
|
def test_multiple_problems_are_all_reported_at_once(tmp_path: Path) -> None:
|
||||||
worlds = [dict(w) for w in VALID["worlds"]]
|
worlds = [dict(w) for w in VALID["worlds"]]
|
||||||
# Two independent problems in one file: a reused reward, and a lesson with too many
|
# Two independent problems in one file: a reused reward, and a lesson introducing
|
||||||
# keys - both should show up in a single error, not just the first one found.
|
# too many keys at once - both should show up in a single error, not just the first
|
||||||
|
# one found. ("a" and "s" are already taught in world 1, so only the three unseen
|
||||||
|
# keys count against the cap.)
|
||||||
worlds[1] = {**worlds[1], "reward": "clownfish"}
|
worlds[1] = {**worlds[1], "reward": "clownfish"}
|
||||||
worlds[2] = {
|
worlds[2] = {
|
||||||
**worlds[2],
|
**worlds[2],
|
||||||
"lessons": [{"title": "Zu viel", "subtitle": "x", "keys": ["a", "s", "d"]}],
|
"lessons": [{"title": "Zu viel", "subtitle": "x", "keys": ["a", "s", "q", "w", "e"]}],
|
||||||
}
|
}
|
||||||
with pytest.raises(CurriculumError) as excinfo:
|
with pytest.raises(CurriculumError) as excinfo:
|
||||||
load_curriculum(_write(tmp_path, {"worlds": worlds}))
|
load_curriculum(_write(tmp_path, {"worlds": worlds}))
|
||||||
message = str(excinfo.value)
|
message = str(excinfo.value)
|
||||||
assert "2 problems" in message
|
assert "2 problems" in message
|
||||||
assert "reuses reward" in message
|
assert "reuses reward" in message
|
||||||
assert "at most two keys" in message
|
assert "at most two new keys" in message
|
||||||
|
|
||||||
|
|
||||||
def test_letters_kind_cannot_have_words(tmp_path: Path) -> None:
|
def test_letters_kind_cannot_have_words(tmp_path: Path) -> None:
|
||||||
@@ -244,11 +246,10 @@ def test_plays_a_mode_that_fits_its_kind(curriculum) -> None:
|
|||||||
assert mode in ELIGIBLE_MODES[lesson.kind], where
|
assert mode in ELIGIBLE_MODES[lesson.kind], where
|
||||||
|
|
||||||
|
|
||||||
def test_never_plays_the_same_arcade_game_twice_in_a_row(curriculum) -> None:
|
def test_letters_rounds_always_play_bubbles(curriculum) -> None:
|
||||||
arcade = [lesson for lesson in curriculum.lessons if lesson.kind == "letters"]
|
for lesson in curriculum.lessons:
|
||||||
for i in range(1, len(arcade)):
|
if lesson.kind == "letters":
|
||||||
where = f"{arcade[i].number}: {arcade[i].title}"
|
assert lesson.primary_mode == "bubbles", f"{lesson.number}: {lesson.title}"
|
||||||
assert arcade[i].primary_mode != arcade[i - 1].primary_mode, where
|
|
||||||
|
|
||||||
|
|
||||||
def test_offers_every_eligible_mode_not_gated_on_as_a_bonus(curriculum) -> None:
|
def test_offers_every_eligible_mode_not_gated_on_as_a_bonus(curriculum) -> None:
|
||||||
|
|||||||
@@ -53,13 +53,12 @@ def _curriculum() -> Curriculum:
|
|||||||
return Curriculum(worlds=worlds, lessons=lessons)
|
return Curriculum(worlds=worlds, lessons=lessons)
|
||||||
|
|
||||||
|
|
||||||
def _result(*, stars: int, passed: bool, points: float = 10.0, pearls: int = 3) -> RunResult:
|
def _result(*, stars: int, passed: bool, points: float = 10.0) -> RunResult:
|
||||||
return RunResult(
|
return RunResult(
|
||||||
stars=stars, # type: ignore[arg-type]
|
stars=stars, # type: ignore[arg-type]
|
||||||
animal="fish",
|
animal="fish",
|
||||||
points=points,
|
points=points,
|
||||||
passed=passed,
|
passed=passed,
|
||||||
pearls=pearls,
|
|
||||||
strokes=(Stroke(key="a", expected="a", correct=True, at=0.0),),
|
strokes=(Stroke(key="a", expected="a", correct=True, at=0.0),),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -99,7 +98,6 @@ def test_save_then_load_round_trips(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
assert reloaded.lessons["l01"].best_stars == 3
|
assert reloaded.lessons["l01"].best_stars == 3
|
||||||
assert reloaded.lessons["l02"].unlocked is True
|
assert reloaded.lessons["l02"].unlocked is True
|
||||||
assert reloaded.pearls == 3
|
|
||||||
# Atomic write leaves no temp file behind.
|
# Atomic write leaves no temp file behind.
|
||||||
assert list(tmp_path.glob("*.tmp*")) == []
|
assert list(tmp_path.glob("*.tmp*")) == []
|
||||||
|
|
||||||
@@ -209,16 +207,15 @@ def test_finishing_a_world_awards_its_creature_exactly_once() -> None:
|
|||||||
assert step3.progress.aquarium == ["clownfish"]
|
assert step3.progress.aquarium == ["clownfish"]
|
||||||
|
|
||||||
|
|
||||||
def test_pearls_and_streak_accumulate() -> None:
|
def test_streak_accumulates() -> None:
|
||||||
curriculum = _curriculum()
|
curriculum = _curriculum()
|
||||||
progress = fresh_progress(curriculum)
|
progress = fresh_progress(curriculum)
|
||||||
day1 = record_run(
|
day1 = record_run(
|
||||||
progress, "l01", _result(stars=1, passed=False, pearls=3), curriculum, day="2026-01-01"
|
progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-01"
|
||||||
)
|
)
|
||||||
day2 = record_run(
|
day2 = record_run(
|
||||||
day1.progress, "l01", _result(stars=1, passed=False, pearls=4), curriculum, day="2026-01-02"
|
day1.progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-02"
|
||||||
)
|
)
|
||||||
assert day2.progress.pearls == 7
|
|
||||||
assert day2.progress.streak.days == 2
|
assert day2.progress.streak.days == 2
|
||||||
assert day2.progress.streak.last_played == "2026-01-02"
|
assert day2.progress.streak.last_played == "2026-01-02"
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
# The typing game's lesson plan, referenced from config.yml's general.tippen.curriculum_file.
|
# The typing game's lesson plan, referenced from config.yml's general.tippen.curriculum_file.
|
||||||
#
|
#
|
||||||
# Every lesson: title, subtitle (read aloud). Then either:
|
# Every lesson: title, subtitle (read aloud). Then either:
|
||||||
# - keys: the letters-kind key(s) this round drills. The loader tracks which keys were
|
# - keys: the letters-kind keys this round has on the table. At most two of them may
|
||||||
# already active: the first time a key appears its lesson is "isolated" (heavy
|
# be new; the rest are keys already taught, written out to say "these are in play
|
||||||
# weight, alone); the second (identical) appearance is "mixed" (lighter weight,
|
# too". The loader tracks which keys were already active and spotlights only what is
|
||||||
# blended with everything learned so far). Two lessons with the same `keys` back to
|
# new, so `keys: [f, j, a, d, k]` right after `[f, j, a, d]` is a K lesson with the
|
||||||
# back is exactly how you write "isolated, then mixed" - no separate flag needed.
|
# other four as review ("isolated" - heavy weight on K). A round that introduces
|
||||||
|
# nothing new spotlights its whole list instead ("mixed" - lighter weight, blended
|
||||||
|
# with everything learned so far), so two lessons with the same `keys` back to back
|
||||||
|
# is exactly how you write "isolated, then mixed" - no separate flag needed.
|
||||||
# - drill: true - a pure review round, no new content, whatever is active so far.
|
# - drill: true - a pure review round, no new content, whatever is active so far.
|
||||||
# - kind + words - a fragments/words/sentences consolidation round (dive mode by
|
# - kind + words - a fragments/words/sentences consolidation round (dive mode by
|
||||||
# default; `mode:` overrides it - see the backend's `ELIGIBLE_MODES` for which
|
# default; `mode:` overrides it - see the backend's `ELIGIBLE_MODES` for which
|
||||||
@@ -18,8 +21,11 @@
|
|||||||
#
|
#
|
||||||
# unlocks: <path>
|
# unlocks: <path>
|
||||||
#
|
#
|
||||||
# A path into the music library (see config.yml's general.library.root - absolute, or
|
# A path into the music library, written relative to config.yml's
|
||||||
# relative to it; "~" is expanded). Passing this lesson (two stars, or five attempts
|
# general.library.root - "Musik/Klaviersammlung/Lied.mp3", not a path from "/" or "~".
|
||||||
|
# (An absolute path, or one starting with "~", still resolves, so an old file keeps
|
||||||
|
# working - but relative is the form to write, since it survives moving the library.)
|
||||||
|
# Passing this lesson (two stars, or five attempts
|
||||||
# regardless of score) unlocks that track and everything before it in the same album,
|
# regardless of score) unlocks that track and everything before it in the same album,
|
||||||
# inclusive - so pointing at an album's last track unlocks the whole album, and
|
# inclusive - so pointing at an album's last track unlocks the whole album, and
|
||||||
# pointing at track 5 of an audiobook unlocks chapters 1 through 5. The web front-end
|
# pointing at track 5 of an audiobook unlocks chapters 1 through 5. The web front-end
|
||||||
@@ -59,7 +65,7 @@ worlds:
|
|||||||
subtitle: "Die Ringfinger"
|
subtitle: "Die Ringfinger"
|
||||||
keys: [s, l]
|
keys: [s, l]
|
||||||
# Example - unlocks tracks 1 through 5 (inclusive) of this album:
|
# Example - unlocks tracks 1 through 5 (inclusive) of this album:
|
||||||
# unlocks: ~/Music/Musik/Kinderparty - Kinderparty Lieder/05 - Lied.mp3
|
# unlocks: "Musik/Kinderparty - Kinderparty Lieder/05 - Lied.mp3"
|
||||||
- title: "S und L üben"
|
- title: "S und L üben"
|
||||||
subtitle: "Die neuen Tasten festigen"
|
subtitle: "Die neuen Tasten festigen"
|
||||||
keys: [s, l]
|
keys: [s, l]
|
||||||
@@ -195,7 +201,7 @@ worlds:
|
|||||||
drill: true
|
drill: true
|
||||||
# Example - unlocks a whole audiobook (its last chapter, inclusive of every
|
# Example - unlocks a whole audiobook (its last chapter, inclusive of every
|
||||||
# chapter before it):
|
# chapter before it):
|
||||||
# unlocks: ~/Music/Hörbücher/Conni - Conni in den Bergen/06 - Teil.mp3
|
# unlocks: "Hörbücher/Conni - Conni in den Bergen/06 - Teil.mp3"
|
||||||
|
|
||||||
- number: 3
|
- number: 3
|
||||||
title: "Nach unten"
|
title: "Nach unten"
|
||||||
@@ -295,7 +301,7 @@ worlds:
|
|||||||
drill: true
|
drill: true
|
||||||
# Example - unlocks a whole podcast show (its most recent episode, and every
|
# Example - unlocks a whole podcast show (its most recent episode, and every
|
||||||
# earlier one of the same show, chronologically):
|
# earlier one of the same show, chronologically):
|
||||||
# unlocks: ~/Music/Kinderpodcasts/Wissen macht Ah/20260101 - Neu.mp3
|
# unlocks: "Kinderpodcasts/Wissen macht Ah/20260101 - Neu.mp3"
|
||||||
|
|
||||||
- number: 4
|
- number: 4
|
||||||
title: "Große Buchstaben"
|
title: "Große Buchstaben"
|
||||||
|
|||||||
241
scripts/sync-library.sh
Executable file
@@ -0,0 +1,241 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Copy a local music library, and the expensive parts of its scan cache, onto a
|
||||||
|
# MusicMouse device.
|
||||||
|
#
|
||||||
|
# scripts/sync-library.sh --host musicdolphin
|
||||||
|
#
|
||||||
|
# The cache is the reason this is a script rather than one rsync line. Its three kinds
|
||||||
|
# of content behave completely differently when they move between machines (see
|
||||||
|
# python-backend/musicmouse/library/cache.py):
|
||||||
|
#
|
||||||
|
# covers/<album_id>.jpg album_id = sha1(folder path relative to the library
|
||||||
|
# root). Portable, as long as the shelf layout under the
|
||||||
|
# target root matches the source - which it does, because
|
||||||
|
# this script syncs the shelves verbatim.
|
||||||
|
#
|
||||||
|
# analysis/<track_key>.json track_key = sha1("<name>:<size>:<int mtime>"). Portable
|
||||||
|
# *only if mtimes survive the copy*. That is why every
|
||||||
|
# rsync here is -a and why you must never reach for scp
|
||||||
|
# without -p: losing mtimes silently invalidates every
|
||||||
|
# analysis file, each of which costs minutes of DSP to
|
||||||
|
# rebuild.
|
||||||
|
#
|
||||||
|
# index.json NOT copied. It holds absolute source-machine paths, and
|
||||||
|
# scan_library() reuses a cached album whenever its
|
||||||
|
# (name, size, mtime) fingerprint matches, without
|
||||||
|
# re-checking the path. Copying it makes every album come
|
||||||
|
# back pointing at /home/<you>/Music/..., and playback
|
||||||
|
# fails on files that are sitting right there. It is the
|
||||||
|
# cheap part of the cache - the device rebuilds it on its
|
||||||
|
# first scan, reusing every cover and analysis file we did
|
||||||
|
# copy.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST="musicdolphin"
|
||||||
|
SSH_USER="root"
|
||||||
|
SRC="${HOME}/Music"
|
||||||
|
CACHE=""
|
||||||
|
DEST="/media/musicmouse"
|
||||||
|
DRY_RUN=0
|
||||||
|
COPY_CACHE=1
|
||||||
|
|
||||||
|
# Shelves the scanner knows about (musicmouse/library/sections.py). Syncing these by
|
||||||
|
# name rather than the whole directory is what makes --delete safe: config.yml,
|
||||||
|
# tippen-curriculum.yml, tippen-progress.json and .musicmouse-cache all live in $DEST
|
||||||
|
# on the device and must survive.
|
||||||
|
SECTIONS=("Figuren" "Musik" "Hörbücher" "Kinderpodcasts")
|
||||||
|
|
||||||
|
# Leave this much room on the target after the copy. These devices are SD cards with
|
||||||
|
# little to spare, and a full root filesystem breaks far more than the music player.
|
||||||
|
HEADROOM_BYTES=$((1024 * 1024 * 1024))
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
Usage: scripts/sync-library.sh [options]
|
||||||
|
|
||||||
|
--host HOST Target device (default: musicdolphin)
|
||||||
|
--user USER SSH user (default: root)
|
||||||
|
--src DIR Local library (default: ~/Music)
|
||||||
|
--dest DIR Library root on target (default: /media/musicmouse)
|
||||||
|
--cache DIR Local cache directory (default: <src>/.musicmouse-cache, falling
|
||||||
|
back to python-backend/.musicmouse-cache next to this script)
|
||||||
|
--no-cache Skip the cache; the device recomputes everything from scratch
|
||||||
|
--dry-run Show what would be transferred, change nothing
|
||||||
|
-h, --help This message
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--host) HOST="$2"; shift 2 ;;
|
||||||
|
--user) SSH_USER="$2"; shift 2 ;;
|
||||||
|
--src) SRC="$2"; shift 2 ;;
|
||||||
|
--dest) DEST="$2"; shift 2 ;;
|
||||||
|
--cache) CACHE="$2"; shift 2 ;;
|
||||||
|
--no-cache) COPY_CACHE=0; shift ;;
|
||||||
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
SSH_TARGET="${SSH_USER}@${HOST}"
|
||||||
|
SRC="${SRC%/}"
|
||||||
|
DEST="${DEST%/}"
|
||||||
|
|
||||||
|
die() { echo "error: $*" >&2; exit 1; }
|
||||||
|
step() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
||||||
|
|
||||||
|
human() {
|
||||||
|
# Bytes -> something a person can judge at a glance.
|
||||||
|
numfmt --to=iec --suffix=B "$1" 2>/dev/null || echo "$1 bytes"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- cache location
|
||||||
|
|
||||||
|
if [[ -z "$CACHE" ]]; then
|
||||||
|
if [[ -d "${SRC}/.musicmouse-cache" ]]; then
|
||||||
|
CACHE="${SRC}/.musicmouse-cache"
|
||||||
|
else
|
||||||
|
# The dev setup keeps config.yml (and therefore the cache, which resolves
|
||||||
|
# relative to it) in python-backend/ rather than beside the music.
|
||||||
|
CACHE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/python-backend/.musicmouse-cache"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------- preflight
|
||||||
|
|
||||||
|
step "Preflight"
|
||||||
|
|
||||||
|
[[ -d "$SRC" ]] || die "source library not found: $SRC"
|
||||||
|
|
||||||
|
present_sections=()
|
||||||
|
for section in "${SECTIONS[@]}"; do
|
||||||
|
[[ -d "${SRC}/${section}" ]] && present_sections+=("$section")
|
||||||
|
done
|
||||||
|
[[ ${#present_sections[@]} -gt 0 ]] \
|
||||||
|
|| die "no known shelves under $SRC (looked for: ${SECTIONS[*]})"
|
||||||
|
|
||||||
|
echo "source: $SRC"
|
||||||
|
echo "shelves: ${present_sections[*]}"
|
||||||
|
|
||||||
|
if [[ $COPY_CACHE -eq 1 ]]; then
|
||||||
|
if [[ -d "$CACHE" ]]; then
|
||||||
|
echo "cache: $CACHE"
|
||||||
|
else
|
||||||
|
echo "cache: none found at $CACHE - continuing without it"
|
||||||
|
COPY_CACHE=0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "cache: skipped (--no-cache)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_TARGET" true 2>/dev/null \
|
||||||
|
|| die "cannot ssh to $SSH_TARGET (needs key auth, no password prompt)"
|
||||||
|
echo "target: ${SSH_TARGET}:${DEST}"
|
||||||
|
|
||||||
|
# Size the transfer. This is an upper bound: rsync skips files already present and
|
||||||
|
# identical, so a repeat run moves far less than this.
|
||||||
|
src_bytes=0
|
||||||
|
for section in "${present_sections[@]}"; do
|
||||||
|
section_bytes=$(du -sb "${SRC}/${section}" | cut -f1)
|
||||||
|
src_bytes=$((src_bytes + section_bytes))
|
||||||
|
printf ' %-18s %s\n' "$section" "$(human "$section_bytes")"
|
||||||
|
done
|
||||||
|
if [[ $COPY_CACHE -eq 1 ]]; then
|
||||||
|
for part in covers analysis; do
|
||||||
|
if [[ -d "${CACHE}/${part}" ]]; then
|
||||||
|
part_bytes=$(du -sb "${CACHE}/${part}" | cut -f1)
|
||||||
|
src_bytes=$((src_bytes + part_bytes))
|
||||||
|
printf ' %-18s %s\n' "cache/${part}" "$(human "$part_bytes")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# df on the parent: $DEST may not exist yet on a first run.
|
||||||
|
avail_kb=$(ssh "$SSH_TARGET" "df -Pk '$(dirname "$DEST")' | awk 'NR==2 {print \$4}'")
|
||||||
|
[[ -n "$avail_kb" ]] || die "could not read free space on $HOST"
|
||||||
|
avail_bytes=$((avail_kb * 1024))
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "to transfer (upper bound): $(human "$src_bytes")"
|
||||||
|
echo "free on target: $(human "$avail_bytes")"
|
||||||
|
|
||||||
|
if (( src_bytes + HEADROOM_BYTES > avail_bytes )); then
|
||||||
|
# Already-synced content counts against src_bytes but costs nothing, so this is a
|
||||||
|
# warning on a repeat run and a hard stop only when nothing is there yet.
|
||||||
|
remote_used=$(ssh "$SSH_TARGET" "du -sb '$DEST' 2>/dev/null | cut -f1" || echo 0)
|
||||||
|
remote_used=${remote_used:-0}
|
||||||
|
if (( remote_used > 0 )); then
|
||||||
|
echo
|
||||||
|
echo "warning: the full library would not fit in the free space, but $(human "$remote_used")"
|
||||||
|
echo " is already at ${DEST}. rsync only moves the difference; watch the"
|
||||||
|
echo " free space as it runs."
|
||||||
|
else
|
||||||
|
die "not enough space: need $(human $((src_bytes + HEADROOM_BYTES))) including $(human "$HEADROOM_BYTES") headroom, have $(human "$avail_bytes")"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- transfer
|
||||||
|
|
||||||
|
RSYNC_OPTS=(-aH --info=progress2 --human-readable)
|
||||||
|
[[ $DRY_RUN -eq 1 ]] && RSYNC_OPTS+=(-n)
|
||||||
|
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
echo
|
||||||
|
echo "(dry run - nothing will be written)"
|
||||||
|
else
|
||||||
|
step "Creating ${DEST} on ${HOST}"
|
||||||
|
ssh "$SSH_TARGET" "mkdir -p '$DEST'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Library"
|
||||||
|
for section in "${present_sections[@]}"; do
|
||||||
|
echo "--- ${section}"
|
||||||
|
# Per-shelf, with --delete scoped to that shelf. A --delete on $DEST as a whole
|
||||||
|
# would take out config.yml and the cache, which live in the same directory on the
|
||||||
|
# device but have no counterpart here.
|
||||||
|
rsync "${RSYNC_OPTS[@]}" --delete \
|
||||||
|
"${SRC}/${section}/" "${SSH_TARGET}:${DEST}/${section}/"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $COPY_CACHE -eq 1 ]]; then
|
||||||
|
step "Cache (covers and analysis; index.json deliberately not copied)"
|
||||||
|
for part in covers analysis; do
|
||||||
|
[[ -d "${CACHE}/${part}" ]] || continue
|
||||||
|
echo "--- ${part}"
|
||||||
|
# No --delete: the device may have analysed tracks this machine never saw.
|
||||||
|
rsync "${RSYNC_OPTS[@]}" \
|
||||||
|
"${CACHE}/${part}/" "${SSH_TARGET}:${DEST}/.musicmouse-cache/${part}/"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------- report
|
||||||
|
|
||||||
|
step "Done"
|
||||||
|
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
echo "Dry run only - nothing was written to ${HOST}."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh "$SSH_TARGET" "
|
||||||
|
echo 'library: '\$(du -sh '$DEST' 2>/dev/null | cut -f1)
|
||||||
|
echo 'free: '\$(df -Ph '$DEST' | awk 'NR==2 {print \$4}')
|
||||||
|
if [ -d '${DEST}/.musicmouse-cache' ]; then
|
||||||
|
echo 'covers: '\$(find '${DEST}/.musicmouse-cache/covers' -type f 2>/dev/null | wc -l)' files'
|
||||||
|
echo 'analysis: '\$(find '${DEST}/.musicmouse-cache/analysis' -type f 2>/dev/null | wc -l)' files'
|
||||||
|
fi
|
||||||
|
"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
The backend rescans on its next start - a few minutes for a large library - and writes
|
||||||
|
a fresh index.json with this device's own paths. Every cover and analysis file copied
|
||||||
|
above is reused, so the expensive work does not happen again.
|
||||||
|
|
||||||
|
ssh ${SSH_TARGET} systemctl restart musicmouse
|
||||||
|
ssh ${SSH_TARGET} journalctl -u musicmouse -f
|
||||||
|
EOF
|
||||||
BIN
web/public/hidden_audiobook_or_podcast.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
web/public/hiddenalbum.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
@@ -158,7 +158,7 @@ export interface LircConfig {
|
|||||||
// Mirrors the `Tippen*` schemas in `musicmouse/services/web/schemas.py`.
|
// Mirrors the `Tippen*` schemas in `musicmouse/services/web/schemas.py`.
|
||||||
|
|
||||||
export type TippenLessonKind = "letters" | "fragments" | "words" | "sentences";
|
export type TippenLessonKind = "letters" | "fragments" | "words" | "sentences";
|
||||||
export type TippenModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
|
export type TippenModeId = "dive" | "bubbles" | "feed" | "race";
|
||||||
|
|
||||||
/** What a lesson's `unlocks:` resolves to right now. `resolved: false` means the
|
/** What a lesson's `unlocks:` resolves to right now. `resolved: false` means the
|
||||||
* configured path matches nothing in the current library. */
|
* configured path matches nothing in the current library. */
|
||||||
@@ -236,7 +236,6 @@ export interface TippenSettings {
|
|||||||
export interface TippenProgress {
|
export interface TippenProgress {
|
||||||
lessons: Record<string, TippenLessonProgress>;
|
lessons: Record<string, TippenLessonProgress>;
|
||||||
key_stats: Record<string, TippenKeyStat>;
|
key_stats: Record<string, TippenKeyStat>;
|
||||||
pearls: number;
|
|
||||||
aquarium: string[];
|
aquarium: string[];
|
||||||
streak: TippenStreak;
|
streak: TippenStreak;
|
||||||
settings: TippenSettings;
|
settings: TippenSettings;
|
||||||
@@ -257,7 +256,6 @@ export interface TippenRunInput {
|
|||||||
animal: string;
|
animal: string;
|
||||||
points: number;
|
points: number;
|
||||||
passed: boolean;
|
passed: boolean;
|
||||||
pearls: number;
|
|
||||||
strokes: TippenStroke[];
|
strokes: TippenStroke[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ interface Props {
|
|||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
/** Print the title over generated art. Off for thumbnails, where it would not fit. */
|
/** Print the title over generated art. Off for thumbnails, where it would not fit. */
|
||||||
label?: boolean;
|
label?: boolean;
|
||||||
/** A reward-gated album/track not yet earned - shows a question mark instead of the
|
/** A reward-gated album/track not yet earned - shows a placeholder cover instead of
|
||||||
* real art or title, regardless of `has_cover`. */
|
* the real art or title, regardless of `has_cover`. */
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,20 +53,18 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{locked ? (
|
{locked ? (
|
||||||
<div
|
<img
|
||||||
|
src={book ? "/hidden_audiobook_or_podcast.png" : "/hiddenalbum.png"}
|
||||||
|
alt=""
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
display: "flex",
|
width: "100%",
|
||||||
alignItems: "center",
|
height: "100%",
|
||||||
justifyContent: "center",
|
objectFit: "cover",
|
||||||
fontSize: Math.max(22, Math.round(size / 2.2)),
|
clipPath: book ? "inset(0 5% 0 0)" : undefined,
|
||||||
color: "oklch(99% 0 0 / .85)",
|
|
||||||
textShadow: "0 2px 8px oklch(15% 0.05 210 / .6)",
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
❓
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!album.has_cover && label && (
|
{!album.has_cover && label && (
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
/** The typing game, as a tab of the music player rather than its own app.
|
/** The typing game, as a tab of the music player rather than its own app.
|
||||||
*
|
*
|
||||||
* Ported from the standalone tippen app's App.tsx - same shape (one state object, one
|
* Ported from the standalone tippen app's App.tsx - same shape (one state object, one
|
||||||
* keydown listener for navigation, four screens) - with two changes: curriculum and
|
* keydown listener for navigation, screens) - with two changes: curriculum and
|
||||||
* progress are now fetched from the backend instead of a build-time YAML import and
|
* progress are now fetched from the backend instead of a build-time YAML import and
|
||||||
* localStorage (see hooks/useTippenCurriculum.ts and hooks/useTippenProgress.ts), and
|
* localStorage (see hooks/useTippenCurriculum.ts and hooks/useTippenProgress.ts), and
|
||||||
* `onExit` is the new base case for "back" - Escape/the map's own navigation peel one
|
* `onExit` is the new base case for "back". The lesson map is the floor screen - the
|
||||||
* layer at a time, same as before, but the aquarium screen is no longer the floor: one
|
* swimming aquarium creatures already show behind it, so there is no separate landing
|
||||||
* more Escape leaves the tab entirely, back to the music player.
|
* screen in front of it - and one Escape from the map leaves the tab entirely, back to
|
||||||
|
* the music player.
|
||||||
*
|
*
|
||||||
* The rule that still matters most: **while a run is going, every key belongs to the
|
* The rule that still matters most: **while a run is going, every key belongs to the
|
||||||
* run**. This component's own keydown listener only ever intercepts Escape and F1 - the
|
* run**. This component's own keydown listener only ever intercepts Escape and F1 - the
|
||||||
@@ -18,36 +19,37 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { useTippenCurriculum } from "../hooks/useTippenCurriculum";
|
import { useTippenCurriculum } from "../hooks/useTippenCurriculum";
|
||||||
import { useTippenProgress } from "../hooks/useTippenProgress";
|
import { useTippenProgress } from "../hooks/useTippenProgress";
|
||||||
import { Aquarium } from "./tippen/Aquarium";
|
|
||||||
import { AppHeader } from "./tippen/AppHeader";
|
import { AppHeader } from "./tippen/AppHeader";
|
||||||
import { HelpOverlay } from "./tippen/HelpOverlay";
|
import { HelpOverlay } from "./tippen/HelpOverlay";
|
||||||
import { LessonMap } from "./tippen/LessonMap";
|
import { LessonMap } from "./tippen/LessonMap";
|
||||||
import { ResultSheet } from "./tippen/ResultSheet";
|
import { ResultSheet } from "./tippen/ResultSheet";
|
||||||
|
import { RewardUnlockOverlay } from "./tippen/RewardUnlockOverlay";
|
||||||
import { Stage } from "./tippen/Stage";
|
import { Stage } from "./tippen/Stage";
|
||||||
import { BubblesRun } from "./tippen/modes/BubblesRun";
|
import { BubblesRun } from "./tippen/modes/BubblesRun";
|
||||||
import { DiveRun } from "./tippen/modes/DiveRun";
|
import { DiveRun } from "./tippen/modes/DiveRun";
|
||||||
import { FeedRun } from "./tippen/modes/FeedRun";
|
import { FeedRun } from "./tippen/modes/FeedRun";
|
||||||
import { JellyfishRun } from "./tippen/modes/JellyfishRun";
|
|
||||||
import { RaceRun } from "./tippen/modes/RaceRun";
|
import { RaceRun } from "./tippen/modes/RaceRun";
|
||||||
import type { CreatureId } from "../lib/tippen/aquarium";
|
import type { CreatureId } from "../lib/tippen/aquarium";
|
||||||
|
import { creatureById } from "../lib/tippen/aquarium";
|
||||||
import type { Lesson, ModeId } from "../lib/tippen/curriculum";
|
import type { Lesson, ModeId } from "../lib/tippen/curriculum";
|
||||||
import { lessonById, nextLesson } from "../lib/tippen/curriculum";
|
import { lessonById, nextLesson } from "../lib/tippen/curriculum";
|
||||||
import { letterStream, lineFor, lineText, mulberry32 } from "../lib/tippen/generator";
|
import { letterStream, lineFor, lineText, mulberry32 } from "../lib/tippen/generator";
|
||||||
|
import { animalById } from "../lib/tippen/grading";
|
||||||
import type { RunResult } from "../lib/tippen/grading";
|
import type { RunResult } from "../lib/tippen/grading";
|
||||||
import { playFanfare, playPop } from "../lib/tippen/pop";
|
import { playFanfare, playPop } from "../lib/tippen/pop";
|
||||||
import { focusKeyFor, overallBestAnimal } from "../lib/tippen/progress";
|
import { focusKeyFor, overallBestAnimal } from "../lib/tippen/progress";
|
||||||
import type { UnlockedReward } from "../lib/tippen/progress";
|
import type { UnlockedReward } from "../lib/tippen/progress";
|
||||||
import { bubbleCountFor } from "../lib/tippen/theme";
|
import { bubbleCountFor } from "../lib/tippen/theme";
|
||||||
|
|
||||||
type Screen = "aquarium" | "map" | "run";
|
type Screen = "map" | "run";
|
||||||
|
|
||||||
/** What a mode is handed to draw - see the original App.tsx for why there are only two
|
/** What a mode is handed to draw - see the original App.tsx for why there are only two
|
||||||
* shapes for five modes. */
|
* shapes for four modes. */
|
||||||
type RunTarget =
|
type RunTarget =
|
||||||
| { kind: "letters"; letters: readonly string[] }
|
| { kind: "letters"; letters: readonly string[] }
|
||||||
| { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
|
| { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
|
||||||
|
|
||||||
const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles", "jellyfish"];
|
const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles"];
|
||||||
|
|
||||||
const SHARE_FOR_EMPHASIS: Record<"isolated" | "mixed", number> = { isolated: 0.75, mixed: 0.4 };
|
const SHARE_FOR_EMPHASIS: Record<"isolated" | "mixed", number> = { isolated: 0.75, mixed: 0.4 };
|
||||||
|
|
||||||
@@ -68,10 +70,14 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
const configured = curriculum !== null;
|
const configured = curriculum !== null;
|
||||||
const { progress, loading: progressLoading, recordRun, saveSettings } = useTippenProgress(configured);
|
const { progress, loading: progressLoading, recordRun, saveSettings } = useTippenProgress(configured);
|
||||||
|
|
||||||
const [screen, setScreen] = useState<Screen>("aquarium");
|
const [screen, setScreen] = useState<Screen>("map");
|
||||||
const [lessonId, setLessonId] = useState<string | null>(null);
|
const [lessonId, setLessonId] = useState<string | null>(null);
|
||||||
const [mode, setMode] = useState<ModeId>("dive");
|
const [mode, setMode] = useState<ModeId>("dive");
|
||||||
const [outcome, setOutcome] = useState<Outcome | null>(null);
|
const [outcome, setOutcome] = useState<Outcome | null>(null);
|
||||||
|
/** The unlock celebration, in front of the result sheet - see RewardUnlockOverlay.
|
||||||
|
* Separate from `outcome.unlockedReward` because it is dismissed on its own, leaving
|
||||||
|
* the result sheet (and its recap of the same reward) behind it. */
|
||||||
|
const [celebration, setCelebration] = useState<UnlockedReward | null>(null);
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
const [selected, setSelected] = useState(0);
|
const [selected, setSelected] = useState(0);
|
||||||
/** Bumped to generate a fresh line - a new seed for the same lesson. */
|
/** Bumped to generate a fresh line - a new seed for the same lesson. */
|
||||||
@@ -118,6 +124,7 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
}, [lesson, mode, round]);
|
}, [lesson, mode, round]);
|
||||||
|
|
||||||
const start = useCallback((lesson: Lesson) => {
|
const start = useCallback((lesson: Lesson) => {
|
||||||
|
setCelebration(null);
|
||||||
setLessonId(lesson.id);
|
setLessonId(lesson.id);
|
||||||
setMode(lesson.primaryMode);
|
setMode(lesson.primaryMode);
|
||||||
setOutcome(null);
|
setOutcome(null);
|
||||||
@@ -136,10 +143,11 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
isNewBest: recorded.isNewBest,
|
isNewBest: recorded.isNewBest,
|
||||||
unlockedReward: recorded.unlockedReward,
|
unlockedReward: recorded.unlockedReward,
|
||||||
});
|
});
|
||||||
if (
|
// The unlock celebration brings its own sounds (and is the bigger moment), so
|
||||||
progress.settings.sound &&
|
// the generic fanfare would only step on its opening. One or the other.
|
||||||
(recorded.unlockedLessonId || recorded.newCreature || recorded.unlockedReward)
|
if (recorded.unlockedReward) {
|
||||||
) {
|
setCelebration(recorded.unlockedReward);
|
||||||
|
} else if (progress.settings.sound && (recorded.unlockedLessonId || recorded.newCreature)) {
|
||||||
playFanfare();
|
playFanfare();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -148,6 +156,7 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const retry = useCallback(() => {
|
const retry = useCallback(() => {
|
||||||
|
setCelebration(null);
|
||||||
setOutcome(null);
|
setOutcome(null);
|
||||||
setRound((r) => r + 1);
|
setRound((r) => r + 1);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -156,6 +165,7 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
* unlock: `onFinished` still runs underneath, so a great bonus run can only improve
|
* unlock: `onFinished` still runs underneath, so a great bonus run can only improve
|
||||||
* the best score, not change what is unlocked. */
|
* the best score, not change what is unlocked. */
|
||||||
const playBonus = useCallback((bonusMode: ModeId) => {
|
const playBonus = useCallback((bonusMode: ModeId) => {
|
||||||
|
setCelebration(null);
|
||||||
setMode(bonusMode);
|
setMode(bonusMode);
|
||||||
setOutcome(null);
|
setOutcome(null);
|
||||||
setRound((r) => r + 1);
|
setRound((r) => r + 1);
|
||||||
@@ -163,22 +173,23 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
|
|
||||||
const continueAfterResult = useCallback(() => {
|
const continueAfterResult = useCallback(() => {
|
||||||
const next = lessonId && curriculum ? nextLesson(curriculum, lessonId) : null;
|
const next = lessonId && curriculum ? nextLesson(curriculum, lessonId) : null;
|
||||||
|
setCelebration(null);
|
||||||
setOutcome(null);
|
setOutcome(null);
|
||||||
if (next && progress?.lessons[next.id]?.unlocked) start(next);
|
if (next && progress?.lessons[next.id]?.unlocked) start(next);
|
||||||
else setScreen("map");
|
else setScreen("map");
|
||||||
}, [lessonId, curriculum, progress, start]);
|
}, [lessonId, curriculum, progress, start]);
|
||||||
|
|
||||||
const goBack = useCallback(() => {
|
const goBack = useCallback(() => {
|
||||||
|
if (celebration) return setCelebration(null);
|
||||||
if (outcome) return setOutcome(null);
|
if (outcome) return setOutcome(null);
|
||||||
if (screen === "run") return setScreen("map");
|
if (screen === "run") return setScreen("map");
|
||||||
if (screen === "map") return setScreen("aquarium");
|
if (screen === "map") return onExit();
|
||||||
if (screen === "aquarium") return onExit();
|
}, [celebration, outcome, screen, onExit]);
|
||||||
}, [outcome, screen, onExit]);
|
|
||||||
|
|
||||||
// --- navigation keys -----------------------------------------------------
|
// --- navigation keys -----------------------------------------------------
|
||||||
|
|
||||||
const latest = useRef({ screen, outcome, selected, goBack, retry, nextUp, start, curriculum });
|
const latest = useRef({ screen, outcome, celebration, selected, goBack, retry, nextUp, start, curriculum });
|
||||||
latest.current = { screen, outcome, selected, goBack, retry, nextUp, start, curriculum };
|
latest.current = { screen, outcome, celebration, selected, goBack, retry, nextUp, start, curriculum };
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -196,6 +207,17 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enter dismisses the unlock celebration. It must be handled before the result
|
||||||
|
// sheet's own Enter below, or finishing a reward run would restart it instantly
|
||||||
|
// from behind the overlay.
|
||||||
|
if (current.celebration) {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
setCelebration(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Enter repeats a finished run; the result sheet's own button has focus, so this
|
// Enter repeats a finished run; the result sheet's own button has focus, so this
|
||||||
// is only a fallback for when focus has been lost.
|
// is only a fallback for when focus has been lost.
|
||||||
if (current.outcome) {
|
if (current.outcome) {
|
||||||
@@ -212,12 +234,8 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
|
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (current.screen === "aquarium") {
|
|
||||||
if (current.nextUp) current.start(current.nextUp);
|
|
||||||
} else {
|
|
||||||
const lesson = current.curriculum.lessons[current.selected];
|
const lesson = current.curriculum.lessons[current.selected];
|
||||||
if (lesson) current.start(lesson);
|
if (lesson) current.start(lesson);
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +257,7 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (screen === "run" && !outcome) return;
|
if (screen === "run" && !outcome) return;
|
||||||
|
if (celebration) return;
|
||||||
if (!progress) return;
|
if (!progress) return;
|
||||||
const key = event.key.toLowerCase();
|
const key = event.key.toLowerCase();
|
||||||
if (key === "m") void saveSettings({ ...progress.settings, sound: !progress.settings.sound });
|
if (key === "m") void saveSettings({ ...progress.settings, sound: !progress.settings.sound });
|
||||||
@@ -251,7 +270,7 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
}, [screen, outcome, progress, saveSettings]);
|
}, [screen, outcome, celebration, progress, saveSettings]);
|
||||||
|
|
||||||
// --- render --------------------------------------------------------------
|
// --- render --------------------------------------------------------------
|
||||||
|
|
||||||
@@ -274,6 +293,7 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const next = lessonId ? nextLesson(curriculum, lessonId) : null;
|
const next = lessonId ? nextLesson(curriculum, lessonId) : null;
|
||||||
|
const bestAnimal = overallBestAnimal(progress);
|
||||||
|
|
||||||
// Every mode takes the same bundle; only the drawing differs.
|
// Every mode takes the same bundle; only the drawing differs.
|
||||||
const shared = { activeKeys: lesson?.activeKeys ?? [], progress, paused: outcome !== null, onFinished };
|
const shared = { activeKeys: lesson?.activeKeys ?? [], progress, paused: outcome !== null, onFinished };
|
||||||
@@ -292,34 +312,58 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
compact={screen === "run"}
|
compact={screen === "run"}
|
||||||
status={
|
status={
|
||||||
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}>
|
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}>
|
||||||
<span>🦪 {progress.pearls}</span>
|
{screen === "map" && (
|
||||||
|
<>
|
||||||
|
<div className="tp-map-creatures">
|
||||||
|
{curriculum.worlds.map((world) => {
|
||||||
|
const creature = creatureById(world.reward);
|
||||||
|
const owned = progress.aquarium.includes(creature.id);
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
key={creature.id}
|
||||||
|
src={creature.image}
|
||||||
|
alt=""
|
||||||
|
title={owned ? creature.name : `Welt ${world.number}`}
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
objectFit: "contain",
|
||||||
|
filter: owned ? "none" : "brightness(0) invert(1)",
|
||||||
|
opacity: owned ? 1 : 0.3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span title="Tage am Stück">🔥 {progress.streak.days}</span>
|
||||||
|
{bestAnimal && (
|
||||||
|
<span title="Schnellstes Tier">
|
||||||
|
{animalById(bestAnimal).emoji} {animalById(bestAnimal).name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{!progress.settings.sound && <span title="Ton aus">🔇</span>}
|
{!progress.settings.sound && <span title="Ton aus">🔇</span>}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{screen === "aquarium" && (
|
{screen === "map" && (
|
||||||
<Aquarium
|
<LessonMap
|
||||||
worlds={curriculum.worlds}
|
worlds={curriculum.worlds}
|
||||||
|
lessons={curriculum.lessons}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
|
selected={selected}
|
||||||
|
onPick={start}
|
||||||
nextLesson={nextUp}
|
nextLesson={nextUp}
|
||||||
onContinue={() => nextUp && start(nextUp)}
|
onContinue={() => nextUp && start(nextUp)}
|
||||||
onOpenMap={() => setScreen("map")}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{screen === "map" && (
|
|
||||||
<LessonMap worlds={curriculum.worlds} lessons={curriculum.lessons} progress={progress} selected={selected} onPick={start} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{screen === "run" && lesson && run && (
|
{screen === "run" && lesson && run && (
|
||||||
<>
|
<>
|
||||||
{run.kind === "letters" ? (
|
{run.kind === "letters" ? (
|
||||||
mode === "jellyfish" ? (
|
|
||||||
<JellyfishRun {...letterProps(run.letters)} />
|
|
||||||
) : (
|
|
||||||
<BubblesRun {...letterProps(run.letters)} />
|
<BubblesRun {...letterProps(run.letters)} />
|
||||||
)
|
|
||||||
) : mode === "feed" ? (
|
) : mode === "feed" ? (
|
||||||
<FeedRun {...textProps(run)} />
|
<FeedRun {...textProps(run)} />
|
||||||
) : mode === "race" ? (
|
) : mode === "race" ? (
|
||||||
@@ -337,7 +381,7 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
newCreature={outcome.newCreature}
|
newCreature={outcome.newCreature}
|
||||||
unlockedReward={outcome.unlockedReward}
|
unlockedReward={outcome.unlockedReward}
|
||||||
isNewBest={outcome.isNewBest}
|
isNewBest={outcome.isNewBest}
|
||||||
bestEver={overallBestAnimal(progress)}
|
bestEver={bestAnimal}
|
||||||
bonusModes={outcome.result.passed ? lesson.bonusModes : []}
|
bonusModes={outcome.result.passed ? lesson.bonusModes : []}
|
||||||
onPlayBonus={playBonus}
|
onPlayBonus={playBonus}
|
||||||
onRetry={retry}
|
onRetry={retry}
|
||||||
@@ -346,6 +390,14 @@ export function TippenApp({ onExit }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{celebration && (
|
||||||
|
<RewardUnlockOverlay
|
||||||
|
reward={celebration}
|
||||||
|
sound={progress.settings.sound}
|
||||||
|
onClose={() => setCelebration(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
|
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
|
||||||
</Stage>
|
</Stage>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
/** Shown on the right - pearls, streak, a back hint. */
|
/** Shown on the right - streak, best animal, a back hint. */
|
||||||
status?: ReactNode;
|
status?: ReactNode;
|
||||||
/** Smaller header while a lesson is running, so the target line gets the room. */
|
/** Smaller header while a lesson is running, so the target line gets the room. */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
/** The home screen: what she has collected, before she is asked to do anything.
|
|
||||||
*
|
|
||||||
* Deliberately the first thing on opening the app. The reward for finishing a world is a
|
|
||||||
* pet that moves in for good - it swims behind every screen from then on (see
|
|
||||||
* AquariumCreatures.tsx) - and a reward you can see before you start is worth more than
|
|
||||||
* one you are told about afterwards.
|
|
||||||
*
|
|
||||||
* The panel shows every pet there is to earn: the ones at home in colour, the rest as
|
|
||||||
* pale outlines. The same idea as the animal ladder - the next thing has to be visible to
|
|
||||||
* be worth aiming at - while the outline alone keeps a little surprise for the arrival. */
|
|
||||||
|
|
||||||
import { creatureById } from "../../lib/tippen/aquarium";
|
|
||||||
import type { Lesson, World } from "../../lib/tippen/curriculum";
|
|
||||||
import { animalById } from "../../lib/tippen/grading";
|
|
||||||
import { overallBestAnimal } from "../../lib/tippen/progress";
|
|
||||||
import type { Progress } from "../../lib/tippen/progress";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
worlds: readonly World[];
|
|
||||||
progress: Progress;
|
|
||||||
/** The lesson the "Weiter üben" button jumps to - the first unfinished one. */
|
|
||||||
nextLesson: Lesson | null;
|
|
||||||
onContinue: () => void;
|
|
||||||
onOpenMap: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Aquarium({ worlds, progress, nextLesson, onContinue, onOpenMap }: Props) {
|
|
||||||
const bestAnimal = overallBestAnimal(progress);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="view-enter"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 22,
|
|
||||||
padding: "0 32px 32px",
|
|
||||||
minHeight: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="tp-glass-panel"
|
|
||||||
style={{ padding: "26px 34px", width: "min(680px, 100%)", textAlign: "center" }}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 15, fontWeight: 900, color: "var(--paper)", opacity: 0.8 }}>
|
|
||||||
Dein Aquarium
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
aria-label="Deine Tiere"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: 18,
|
|
||||||
margin: "16px 0 6px",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{worlds.map((world) => {
|
|
||||||
const creature = creatureById(world.reward);
|
|
||||||
const owned = progress.aquarium.includes(creature.id);
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
key={creature.id}
|
|
||||||
src={creature.image}
|
|
||||||
alt={owned ? creature.name : `Noch nicht da - Welt ${world.number}`}
|
|
||||||
title={owned ? creature.name : `Welt ${world.number}`}
|
|
||||||
style={{
|
|
||||||
width: 62,
|
|
||||||
height: 62,
|
|
||||||
objectFit: "contain",
|
|
||||||
// Not yet earned: a pale outline of the shape, no colours given away.
|
|
||||||
filter: owned ? "none" : "brightness(0) invert(1)",
|
|
||||||
opacity: owned ? 1 : 0.2,
|
|
||||||
animation: owned ? `dolphinBob ${3 + world.number * 0.4}s ease-in-out infinite` : undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{progress.aquarium.length === 0 && (
|
|
||||||
<div style={{ fontSize: 15, fontWeight: 800, color: "var(--paper)", opacity: 0.65 }}>
|
|
||||||
Schaffe eine ganze Welt und dein erstes Tier zieht ein!
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: "flex", justifyContent: "center", gap: 30, marginTop: 14 }}>
|
|
||||||
<Stat label="Perlen" value={`🦪 ${progress.pearls}`} />
|
|
||||||
<Stat label="Tage am Stück" value={`🔥 ${progress.streak.days}`} />
|
|
||||||
<Stat
|
|
||||||
label="Schnellstes Tier"
|
|
||||||
value={bestAnimal ? `${animalById(bestAnimal).emoji} ${animalById(bestAnimal).name}` : "—"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 12 }}>
|
|
||||||
{nextLesson && (
|
|
||||||
<button
|
|
||||||
onClick={onContinue}
|
|
||||||
style={{
|
|
||||||
border: "none",
|
|
||||||
borderRadius: 999,
|
|
||||||
padding: "15px 32px",
|
|
||||||
fontSize: 19,
|
|
||||||
fontWeight: 900,
|
|
||||||
cursor: "pointer",
|
|
||||||
background: "var(--accent)",
|
|
||||||
color: "var(--paper)",
|
|
||||||
boxShadow: "0 8px 24px var(--shadow)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
▶ {nextLesson.title}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className="tp-pill" onClick={onOpenMap} style={{ fontSize: 16, padding: "15px 26px" }}>
|
|
||||||
🗺️ Alle Lektionen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stat({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 19, fontWeight: 900, color: "var(--paper)" }}>{value}</div>
|
|
||||||
<div style={{ fontSize: 11, fontWeight: 800, color: "var(--paper)", opacity: 0.6 }}>{label}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,29 @@
|
|||||||
/** The map: a single winding path, one world section at a time.
|
/** The map: a single winding path, one world section at a time.
|
||||||
|
*
|
||||||
|
* The floor screen of the tippen tab - the swimming aquarium creatures already show
|
||||||
|
* behind it, so there is no separate landing screen in front of this one anymore.
|
||||||
*
|
*
|
||||||
* Locked lessons are dimmed rather than hidden - seeing that "Große Buchstaben" is
|
* Locked lessons are dimmed rather than hidden - seeing that "Große Buchstaben" is
|
||||||
* waiting is half the reason to finish the world that is open. Each node carries its own
|
* waiting is half the reason to finish the world that is open. Each node carries its own
|
||||||
* best animal, star count and a badge for which game it plays, so the map doubles as
|
* best animal, star count and a badge for which game it plays, so the map doubles as
|
||||||
* both a path forward and a trophy cabinet. */
|
* both a path forward and a trophy cabinet.
|
||||||
|
*
|
||||||
|
* A lesson that unlocks real music or an audiobook chapter carries a treasure chest,
|
||||||
|
* drawn *outside* its node so the dimming a locked node gets never touches it: the whole
|
||||||
|
* value of the chest is that it is visible from far off, on lessons she cannot play yet.
|
||||||
|
* Shut while the reward is still to be won, open with the cover art inside it once it has
|
||||||
|
* been - same reasoning as the dimmed-not-hidden lessons, except stronger here, because
|
||||||
|
* this is the one reward that reaches outside the game. */
|
||||||
|
|
||||||
|
import { Fragment } from "react";
|
||||||
|
|
||||||
|
import { coverUrl } from "../../api/client";
|
||||||
import type { Lesson, World } from "../../lib/tippen/curriculum";
|
import type { Lesson, World } from "../../lib/tippen/curriculum";
|
||||||
import { animalById } from "../../lib/tippen/grading";
|
import { animalById } from "../../lib/tippen/grading";
|
||||||
import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath";
|
import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath";
|
||||||
import { MODE_INFO } from "../../lib/tippen/modeInfo";
|
import { MODE_INFO } from "../../lib/tippen/modeInfo";
|
||||||
import type { Progress } from "../../lib/tippen/progress";
|
import type { Progress } from "../../lib/tippen/progress";
|
||||||
|
import { TreasureChest } from "./TreasureChest";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
worlds: readonly World[];
|
worlds: readonly World[];
|
||||||
@@ -18,9 +32,15 @@ interface Props {
|
|||||||
/** Which card the keyboard selection is on. */
|
/** Which card the keyboard selection is on. */
|
||||||
selected: number;
|
selected: number;
|
||||||
onPick: (lesson: Lesson) => void;
|
onPick: (lesson: Lesson) => void;
|
||||||
|
/** The lesson the floating "Weiter üben" button jumps to - the first unfinished one. */
|
||||||
|
nextLesson: Lesson | null;
|
||||||
|
onContinue: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NODE_SIZE = 88;
|
const NODE_SIZE = 88;
|
||||||
|
/** Big enough to read as a treasure chest at a glance, scrolling past - the 15px 🎁 this
|
||||||
|
* replaced was invisible in practice. */
|
||||||
|
const CHEST_SIZE = 46;
|
||||||
/** Half the SVG's viewBox width - wide enough for the path's full swing either side. */
|
/** Half the SVG's viewBox width - wide enough for the path's full swing either side. */
|
||||||
const PATH_HALF_WIDTH = 160;
|
const PATH_HALF_WIDTH = 160;
|
||||||
|
|
||||||
@@ -32,7 +52,7 @@ const CONSOLIDATION_LABEL: Record<"fragments" | "words" | "sentences", string> =
|
|||||||
sentences: "Sätze",
|
sentences: "Sätze",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props) {
|
export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLesson, onContinue }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="view-enter"
|
className="view-enter"
|
||||||
@@ -88,8 +108,11 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props
|
|||||||
const modeInfo = MODE_INFO[lesson.primaryMode];
|
const modeInfo = MODE_INFO[lesson.primaryMode];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// The node and its chest are two siblings, both positioned against
|
||||||
|
// this world's own box - see RewardChest for why the chest must not
|
||||||
|
// be a child of the node.
|
||||||
|
<Fragment key={lesson.id}>
|
||||||
<button
|
<button
|
||||||
key={lesson.id}
|
|
||||||
className="card tp-glass-panel"
|
className="card tp-glass-panel"
|
||||||
data-selected={index === selected}
|
data-selected={index === selected}
|
||||||
data-locked={locked}
|
data-locked={locked}
|
||||||
@@ -160,6 +183,15 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props
|
|||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{lesson.reward.resolved && (
|
||||||
|
<RewardChest
|
||||||
|
point={point}
|
||||||
|
earned={entry?.earned ?? false}
|
||||||
|
albumId={lesson.reward.hasCover ? lesson.reward.albumId : null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -167,6 +199,100 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{nextLesson && (
|
||||||
|
<button
|
||||||
|
onClick={onContinue}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: 24,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "15px 32px",
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: 900,
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "var(--paper)",
|
||||||
|
boxShadow: "0 8px 24px var(--shadow)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▶ {nextLesson.title}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A lesson's treasure chest, sitting on the rim of its node.
|
||||||
|
*
|
||||||
|
* A sibling of the node button rather than a child, for one reason that is easy to lose
|
||||||
|
* again later: `.card[data-locked="true"]` dims the whole button to 45% opacity, and the
|
||||||
|
* chest that most needs to be bright is exactly the one on a lesson three worlds away.
|
||||||
|
* `pointerEvents: none` hands clicks straight through to the node underneath, so the
|
||||||
|
* chest never becomes a second, dead target beside the real one.
|
||||||
|
*
|
||||||
|
* Open, with the cover art of what it gave inside it, once the lesson is earned: it stops
|
||||||
|
* being a promise and becomes the trophy, which makes scrolling the map also a way of
|
||||||
|
* seeing everything she has won. */
|
||||||
|
function RewardChest({
|
||||||
|
point,
|
||||||
|
earned,
|
||||||
|
albumId,
|
||||||
|
}: {
|
||||||
|
point: { x: number; y: number };
|
||||||
|
earned: boolean;
|
||||||
|
albumId: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
// On the node's lower-right rim, overlapping it a little so the two read as one
|
||||||
|
// object rather than as a badge parked nearby.
|
||||||
|
left: `calc(50% + ${point.x + NODE_SIZE / 2 + 2}px)`,
|
||||||
|
top: point.y + NODE_SIZE / 2 - 6,
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
zIndex: 1,
|
||||||
|
filter: "drop-shadow(0 3px 6px oklch(15% 0.05 175 / 0.55))",
|
||||||
|
}}
|
||||||
|
title={earned ? "Freigespielt! Im Musik-Player zu hören" : "Hier gibt es neue Musik zu gewinnen!"}
|
||||||
|
>
|
||||||
|
{/* Shut, the chest is drawn in one piece, because a shut lid has to cover the body's
|
||||||
|
top edge. Won, it is split around the cover art so the cover sits *in* the chest -
|
||||||
|
behind the front wall, under the thrown-back lid - rather than on top of it. See
|
||||||
|
TreasureChest's `layer`. */}
|
||||||
|
{earned && albumId ? (
|
||||||
|
<div style={{ position: "relative", width: CHEST_SIZE, height: CHEST_SIZE }}>
|
||||||
|
<TreasureChest size={CHEST_SIZE} open layer="back" style={{ position: "absolute", inset: 0 }} />
|
||||||
|
<img
|
||||||
|
src={coverUrl(albumId)}
|
||||||
|
alt=""
|
||||||
|
// A cover that fails to load must not leave a broken-image glyph in the chest:
|
||||||
|
// the open chest on its own still says "won", which is the part that matters.
|
||||||
|
onError={(event) => {
|
||||||
|
event.currentTarget.style.display = "none";
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
top: "34%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
width: CHEST_SIZE * 0.46,
|
||||||
|
height: CHEST_SIZE * 0.46,
|
||||||
|
objectFit: "cover",
|
||||||
|
borderRadius: 4,
|
||||||
|
border: "1.5px solid oklch(92% 0.13 92 / 0.9)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TreasureChest size={CHEST_SIZE} open layer="front" style={{ position: "absolute", inset: 0 }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TreasureChest size={CHEST_SIZE} open={earned} muted={!earned} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ export function ResultSheet({
|
|||||||
<div style={{ display: "flex", justifyContent: "center", gap: 26, marginTop: 12 }}>
|
<div style={{ display: "flex", justifyContent: "center", gap: 26, marginTop: 12 }}>
|
||||||
<Stat label="Richtig" value={`${Math.round(result.accuracy * 100)}%`} />
|
<Stat label="Richtig" value={`${Math.round(result.accuracy * 100)}%`} />
|
||||||
<Stat label="Zeichen/Min" value={String(Math.round(result.speed))} />
|
<Stat label="Zeichen/Min" value={String(Math.round(result.speed))} />
|
||||||
<Stat label="Perlen" value={`+${result.pearls}`} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* How close the next animal is. A near miss is the strongest reason to press
|
{/* How close the next animal is. A near miss is the strongest reason to press
|
||||||
@@ -149,34 +148,42 @@ export function ResultSheet({
|
|||||||
{creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
|
{creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* The recap, not the reveal. RewardUnlockOverlay has already given this its own
|
||||||
|
full-screen celebration by the time this sheet is visible, so here it only has
|
||||||
|
to stay on the page as a reminder of what she just won - which is also why it
|
||||||
|
is the one badge on this sheet that gets a tinted row of its own rather than a
|
||||||
|
line of text. */}
|
||||||
{unlockedReward && (
|
{unlockedReward && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
gap: 12,
|
||||||
gap: 10,
|
marginTop: 12,
|
||||||
color: "var(--ink)",
|
padding: "10px 14px",
|
||||||
fontWeight: 900,
|
borderRadius: 16,
|
||||||
marginTop: 8,
|
textAlign: "left",
|
||||||
|
background: "oklch(93% 0.06 92)",
|
||||||
|
border: "1px solid oklch(80% 0.12 90)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{unlockedReward.hasCover ? (
|
{unlockedReward.hasCover ? (
|
||||||
<img
|
<img
|
||||||
src={coverUrl(unlockedReward.albumId)}
|
src={coverUrl(unlockedReward.albumId)}
|
||||||
alt=""
|
alt=""
|
||||||
style={{
|
style={{ width: 52, height: 52, objectFit: "cover", borderRadius: 8, flexShrink: 0 }}
|
||||||
width: 52,
|
|
||||||
height: 52,
|
|
||||||
objectFit: "cover",
|
|
||||||
borderRadius: 8,
|
|
||||||
animation: "tierEnter 520ms ease-out",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: 40, animation: "tierEnter 520ms ease-out" }}>🎁</span>
|
<span style={{ fontSize: 40, flexShrink: 0 }}>🎵</span>
|
||||||
)}
|
)}
|
||||||
🎁 Neu zum Anhören: {unlockedReward.title}
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 800, color: "var(--ink)", opacity: 0.7 }}>
|
||||||
|
Freigespielt — ab jetzt im Musik-Player
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 900, color: "var(--ink)" }}>
|
||||||
|
{unlockedReward.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!result.passed && !unlockedTitle && (
|
{!result.passed && !unlockedTitle && (
|
||||||
|
|||||||
409
web/src/components/tippen/RewardUnlockOverlay.tsx
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
/** The unlock celebration: a treasure chest that opens and hands back real music.
|
||||||
|
*
|
||||||
|
* This is the one moment the typing game exists for. Everything else it gives out -
|
||||||
|
* stars, animals, aquarium creatures - lives inside the game; this is the only reward
|
||||||
|
* that reaches outside it, into the music player. So it gets the whole screen, its own
|
||||||
|
* tune (`playRewardJingle`), and a sequence that has to be waited out, rather than being
|
||||||
|
* one more badge on the result sheet. The result sheet stays mounted underneath and is
|
||||||
|
* revealed when this closes, so nothing is lost by putting this in front of it.
|
||||||
|
*
|
||||||
|
* The staging, and why each beat is there:
|
||||||
|
*
|
||||||
|
* 0ms "drop" the chest falls in, shut, and rattles. The rattle is the beat that
|
||||||
|
* earns the opening - something inside wants out.
|
||||||
|
* 1150ms "open" the lid swings, light bursts, confetti starts, the tune starts.
|
||||||
|
* 1500ms "reveal" the cover art rises out of the chest; the title and the button
|
||||||
|
* arrive under it.
|
||||||
|
*
|
||||||
|
* Dismissal is deliberately not automatic. A six-year-old should get to look at the
|
||||||
|
* cover of the song she just won for as long as she likes, and pressing the button is
|
||||||
|
* itself part of the reward. It is, however, dismissible from the first frame: the
|
||||||
|
* sequence can always be cut short with Enter, Escape or a click. */
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { coverUrl } from "../../api/client";
|
||||||
|
import {
|
||||||
|
playChestOpen,
|
||||||
|
playChestThud,
|
||||||
|
playRewardJingle,
|
||||||
|
} from "../../lib/tippen/pop";
|
||||||
|
import type { UnlockedReward } from "../../lib/tippen/progress";
|
||||||
|
import { TreasureChest } from "./TreasureChest";
|
||||||
|
|
||||||
|
type Phase = "drop" | "open" | "reveal";
|
||||||
|
|
||||||
|
const OPEN_AT = 1150;
|
||||||
|
const REVEAL_AT = 1500;
|
||||||
|
|
||||||
|
/** The chest is drawn wider than the cover on purpose: the lid swings up across the
|
||||||
|
* middle, so a cover as wide as the chest hides it completely and the open chest reads as
|
||||||
|
* a shut one. Keeping the cover to roughly three-quarters of the chest's width leaves the
|
||||||
|
* lid standing clear beside it. */
|
||||||
|
const CHEST_SIZE = 196;
|
||||||
|
const COVER_SIZE = 148;
|
||||||
|
|
||||||
|
/** Headline and fallback glyph per reward kind. In German, and phrased as a gift rather
|
||||||
|
* than as a transaction: "freigeschaltet" is what the map's badge says, "für dich" is
|
||||||
|
* what this screen says. */
|
||||||
|
const COPY: Record<
|
||||||
|
UnlockedReward["kind"],
|
||||||
|
{ heading: string; lead: string; glyph: string }
|
||||||
|
> = {
|
||||||
|
album: {
|
||||||
|
heading: "Ein neues Lied für dich!",
|
||||||
|
lead: "Du hast Musik freigespielt",
|
||||||
|
glyph: "🎵",
|
||||||
|
},
|
||||||
|
book: {
|
||||||
|
heading: "Ein neues Hörbuch für dich!",
|
||||||
|
lead: "Du hast eine Geschichte freigespielt",
|
||||||
|
glyph: "📖",
|
||||||
|
},
|
||||||
|
podcast_episode: {
|
||||||
|
heading: "Eine neue Folge für dich!",
|
||||||
|
lead: "Du hast eine Folge freigespielt",
|
||||||
|
glyph: "🎙️",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONFETTI_COLOURS = [
|
||||||
|
"oklch(85% 0.17 88)",
|
||||||
|
"oklch(78% 0.16 340)",
|
||||||
|
"oklch(82% 0.15 150)",
|
||||||
|
"oklch(80% 0.14 220)",
|
||||||
|
"oklch(88% 0.13 60)",
|
||||||
|
"oklch(75% 0.17 300)",
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONFETTI_COUNT = 34;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
reward: UnlockedReward;
|
||||||
|
/** Honours her own sound setting - the same flag that silences every other sound in
|
||||||
|
* the app. A silent celebration still celebrates. */
|
||||||
|
sound: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RewardUnlockOverlay({ reward, sound, onClose }: Props) {
|
||||||
|
const reducedMotion = usePrefersReducedMotion();
|
||||||
|
const [phase, setPhase] = useState<Phase>(reducedMotion ? "reveal" : "drop");
|
||||||
|
const button = useRef<HTMLButtonElement>(null);
|
||||||
|
const copy = COPY[reward.kind];
|
||||||
|
|
||||||
|
// Fixed per mount, so a re-render (the button focusing, say) never reshuffles the
|
||||||
|
// confetti mid-fall.
|
||||||
|
const confetti = useMemo(
|
||||||
|
() => (reducedMotion ? [] : makeConfetti()),
|
||||||
|
[reducedMotion],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reducedMotion) {
|
||||||
|
if (sound) playRewardJingle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sound) playChestThud();
|
||||||
|
const timers = [
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setPhase("open");
|
||||||
|
if (sound) {
|
||||||
|
playChestOpen();
|
||||||
|
playRewardJingle();
|
||||||
|
}
|
||||||
|
}, OPEN_AT),
|
||||||
|
window.setTimeout(() => setPhase("reveal"), REVEAL_AT),
|
||||||
|
];
|
||||||
|
return () => timers.forEach(window.clearTimeout);
|
||||||
|
}, [reducedMotion, sound]);
|
||||||
|
|
||||||
|
// The button only exists from "reveal" on, so this focuses it as it appears. Taking
|
||||||
|
// focus matters: the result sheet underneath grabbed it for its own "Nochmal" button
|
||||||
|
// when it mounted, and Enter must not restart the run from behind this screen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === "reveal") button.current?.focus();
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
const open = phase !== "drop";
|
||||||
|
const revealed = phase === "reveal";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="tp-reward-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-label={`${copy.heading} ${reward.title}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{confetti.map((piece, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="tp-confetti-piece"
|
||||||
|
style={{
|
||||||
|
left: piece.left,
|
||||||
|
width: piece.width,
|
||||||
|
height: piece.height,
|
||||||
|
background: piece.colour,
|
||||||
|
borderRadius: piece.round ? "50%" : 2,
|
||||||
|
animationDelay: `${OPEN_AT + piece.delay}ms`,
|
||||||
|
animationDuration: `${piece.duration}ms`,
|
||||||
|
["--tp-drift" as string]: piece.drift,
|
||||||
|
["--tp-spin" as string]: piece.spin,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "var(--paper)",
|
||||||
|
opacity: revealed ? 0.75 : 0,
|
||||||
|
transition: "opacity 300ms",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copy.lead}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The chest and the cover share one stacking box, and the cover is sandwiched
|
||||||
|
between the chest's two halves - see TreasureChest's `layer`. That is what makes
|
||||||
|
the cover come *out of* the chest rather than float in front of a picture of
|
||||||
|
one: it rises from behind the chest's front wall, under the thrown-back lid. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: CHEST_SIZE,
|
||||||
|
height: CHEST_SIZE,
|
||||||
|
// Room above for the cover, which overhangs this box by most of its height.
|
||||||
|
marginTop: COVER_SIZE * 0.78,
|
||||||
|
animation: open
|
||||||
|
? undefined
|
||||||
|
: "chestDrop 520ms cubic-bezier(0.34, 1.4, 0.64, 1), chestRattle 420ms ease-in-out 540ms 2",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TreasureChest size={CHEST_SIZE} open={open} layer="back" style={{ position: "absolute", inset: 0 }} />
|
||||||
|
|
||||||
|
{open && !reducedMotion && <Rays />}
|
||||||
|
|
||||||
|
{revealed && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
// Deep enough that the cover's foot is hidden behind the front wall.
|
||||||
|
bottom: CHEST_SIZE * 0.5,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RewardArt reward={reward} glyph={copy.glyph} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TreasureChest size={CHEST_SIZE} open={open} layer="front" style={{ position: "absolute", inset: 0 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 27,
|
||||||
|
fontWeight: 900,
|
||||||
|
color: "var(--paper)",
|
||||||
|
marginTop: 16,
|
||||||
|
opacity: revealed ? 1 : 0,
|
||||||
|
animation: revealed
|
||||||
|
? "rewardTextEnter 360ms ease-out 80ms both"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copy.heading}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "oklch(90% 0.13 92)",
|
||||||
|
maxWidth: 460,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
marginTop: 4,
|
||||||
|
opacity: revealed ? 1 : 0,
|
||||||
|
animation: revealed
|
||||||
|
? "rewardTextEnter 360ms ease-out 200ms both"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reward.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--paper)",
|
||||||
|
opacity: revealed ? 0.7 : 0,
|
||||||
|
marginTop: 8,
|
||||||
|
transition: "opacity 300ms 400ms",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ab jetzt im Musik-Player 🎧
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{revealed && (
|
||||||
|
<button
|
||||||
|
ref={button}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "14px 30px",
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 900,
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "var(--paper)",
|
||||||
|
boxShadow: "0 8px 24px var(--shadow)",
|
||||||
|
marginTop: 22,
|
||||||
|
animation: "rewardTextEnter 360ms ease-out 320ms both",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Toll! ⏎
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cover art, or a glyph when the album has none. */
|
||||||
|
function RewardArt({
|
||||||
|
reward,
|
||||||
|
glyph,
|
||||||
|
}: {
|
||||||
|
reward: UnlockedReward;
|
||||||
|
glyph: string;
|
||||||
|
}) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const shared: React.CSSProperties = {
|
||||||
|
width: COVER_SIZE,
|
||||||
|
height: COVER_SIZE,
|
||||||
|
borderRadius: 18,
|
||||||
|
border: "3px solid oklch(90% 0.13 92 / 0.9)",
|
||||||
|
animation:
|
||||||
|
"coverRise 760ms cubic-bezier(0.22, 1.2, 0.36, 1) both, coverHalo 2.6s ease-in-out 760ms infinite",
|
||||||
|
};
|
||||||
|
|
||||||
|
// A cover the backend said exists but that fails to load would otherwise leave a
|
||||||
|
// broken-image box as the centrepiece of the celebration.
|
||||||
|
if (!reward.hasCover || failed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...shared,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 92,
|
||||||
|
background:
|
||||||
|
"linear-gradient(160deg, oklch(70% 0.09 175), oklch(45% 0.08 175))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={coverUrl(reward.albumId)}
|
||||||
|
alt=""
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
style={{
|
||||||
|
...shared,
|
||||||
|
objectFit: "cover",
|
||||||
|
background: "oklch(45% 0.08 175)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The burst of light at the moment the lid lets go. Two counter-rotating stars of rays,
|
||||||
|
* so the burst reads as light rather than as a spinning shape. */
|
||||||
|
function Rays() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
top: "18%",
|
||||||
|
width: 260,
|
||||||
|
height: 260,
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[0, 1].map((layer) => (
|
||||||
|
<div
|
||||||
|
key={layer}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
animation: `rayBurst ${layer === 0 ? 900 : 1150}ms ease-out ${layer * 90}ms both`,
|
||||||
|
background: `repeating-conic-gradient(from ${layer * 11}deg, oklch(95% 0.14 92 / 0.55) 0deg 5deg, transparent 5deg 22deg)`,
|
||||||
|
maskImage:
|
||||||
|
"radial-gradient(circle, transparent 12%, black 34%, transparent 72%)",
|
||||||
|
WebkitMaskImage:
|
||||||
|
"radial-gradient(circle, transparent 12%, black 34%, transparent 72%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfettiPiece {
|
||||||
|
left: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
colour: string;
|
||||||
|
round: boolean;
|
||||||
|
delay: number;
|
||||||
|
duration: number;
|
||||||
|
drift: string;
|
||||||
|
spin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeConfetti(): ConfettiPiece[] {
|
||||||
|
return Array.from({ length: CONFETTI_COUNT }, (_, i) => {
|
||||||
|
const round = i % 3 === 0;
|
||||||
|
const size = 7 + Math.random() * 8;
|
||||||
|
return {
|
||||||
|
left: `${Math.random() * 100}%`,
|
||||||
|
width: round ? size : size * 0.55,
|
||||||
|
height: size,
|
||||||
|
colour: CONFETTI_COLOURS[i % CONFETTI_COLOURS.length]!,
|
||||||
|
round,
|
||||||
|
// Spread over a second and a half, so it falls as a shower rather than as a line.
|
||||||
|
delay: Math.random() * 1500,
|
||||||
|
duration: 2400 + Math.random() * 2200,
|
||||||
|
drift: `${(Math.random() - 0.5) * 220}px`,
|
||||||
|
spin: `${(Math.random() > 0.5 ? 1 : -1) * (360 + Math.random() * 720)}deg`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePrefersReducedMotion(): boolean {
|
||||||
|
const [reduced, setReduced] = useState(
|
||||||
|
() =>
|
||||||
|
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
||||||
|
if (!query) return;
|
||||||
|
const onChange = () => setReduced(query.matches);
|
||||||
|
query.addEventListener("change", onChange);
|
||||||
|
return () => query.removeEventListener("change", onChange);
|
||||||
|
}, []);
|
||||||
|
return reduced;
|
||||||
|
}
|
||||||
149
web/src/components/tippen/TreasureChest.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/** A treasure chest whose lid actually opens.
|
||||||
|
*
|
||||||
|
* Drawn rather than set as an emoji for one reason: the lid has to be a separate group
|
||||||
|
* so it can swing on a hinge. A 🎁 can only ever pop and scale, which is exactly the
|
||||||
|
* animation the old reward badge had and exactly why nobody noticed it. It is also
|
||||||
|
* sharp at every size, which matters because the same component draws both the 46px
|
||||||
|
* badge on the lesson map and the 168px chest in the unlock overlay.
|
||||||
|
*
|
||||||
|
* `open` is a plain prop, not an animation trigger: the lid transitions to its open
|
||||||
|
* angle through CSS (`.tp-chest-lid`, tippen.css), so the map can render a permanently
|
||||||
|
* open chest for a claimed reward with no animation bookkeeping at all, and the overlay
|
||||||
|
* gets its swing for free just by flipping the prop mid-sequence.
|
||||||
|
*
|
||||||
|
* `layer` splits the drawing in two so that something can be put *inside* the chest. The
|
||||||
|
* lid swings up across the space above the chest, which is exactly where a rising cover
|
||||||
|
* wants to be; drawn as one piece, the lid crosses in front of the cover and the whole
|
||||||
|
* thing reads as a z-order bug. Drawn as "back" (lid, mouth, glow), then the cover, then
|
||||||
|
* "front" (the body), the cover emerges from behind the chest's own front wall - which is
|
||||||
|
* what coming out of a chest actually looks like. The map, which puts nothing inside, and
|
||||||
|
* whose shut chests need the lid painted over the body's top edge, takes the default. */
|
||||||
|
|
||||||
|
/** Instance-unique gradient ids. Two chests on one page (the map has many) must not share
|
||||||
|
* `<defs>` ids, or the second one silently paints with the first one's stops. */
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size: number;
|
||||||
|
open: boolean;
|
||||||
|
/** Which half to draw - see the header. "all" is the whole chest, in one element. */
|
||||||
|
layer?: "all" | "back" | "front";
|
||||||
|
/** Softened - a reward whose lesson is still ahead of her. Muted rather than greyed
|
||||||
|
* out: a shut chest still has to look like treasure, or the badge promises nothing. */
|
||||||
|
muted?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TreasureChest({ size, open, layer = "all", muted = false, className, style }: Props) {
|
||||||
|
const id = `tp-chest-${(nextId += 1)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 64 62"
|
||||||
|
className={className}
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
overflow: "visible",
|
||||||
|
filter: muted ? "grayscale(0.35) brightness(0.9)" : undefined,
|
||||||
|
opacity: muted ? 0.9 : 1,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`${id}-wood`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="oklch(58% 0.11 55)" />
|
||||||
|
<stop offset="100%" stopColor="oklch(38% 0.09 45)" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={`${id}-lid`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="oklch(66% 0.11 58)" />
|
||||||
|
<stop offset="100%" stopColor="oklch(46% 0.1 48)" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={`${id}-gold`} x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stopColor="oklch(92% 0.14 95)" />
|
||||||
|
<stop offset="45%" stopColor="oklch(82% 0.17 88)" />
|
||||||
|
<stop offset="100%" stopColor="oklch(66% 0.15 80)" />
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id={`${id}-glow`}>
|
||||||
|
<stop offset="0%" stopColor="oklch(97% 0.14 95 / 0.95)" />
|
||||||
|
<stop offset="55%" stopColor="oklch(90% 0.16 92 / 0.35)" />
|
||||||
|
<stop offset="100%" stopColor="oklch(90% 0.16 92 / 0)" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* What makes it read as *open* rather than as a chest with its lid drawn beside
|
||||||
|
it: a dark mouth, with light coming out of it. Both are painted before the body,
|
||||||
|
so the body's front wall covers their lower half and the opening looks like a
|
||||||
|
hole in the chest rather than a shape on it. */}
|
||||||
|
{layer !== "front" && open && (
|
||||||
|
<>
|
||||||
|
<ellipse cx="32" cy="27" rx="24" ry="7" fill="oklch(22% 0.03 45)" />
|
||||||
|
<circle cx="32" cy="25" r="24" fill={`url(#${id}-glow)`} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{layer === "back" && <Lid id={id} open={open} />}
|
||||||
|
|
||||||
|
{layer !== "back" && (
|
||||||
|
<>
|
||||||
|
<rect
|
||||||
|
x="6"
|
||||||
|
y="26"
|
||||||
|
width="52"
|
||||||
|
height="30"
|
||||||
|
rx="5"
|
||||||
|
fill={`url(#${id}-wood)`}
|
||||||
|
stroke="oklch(28% 0.06 45)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
{/* Two gold straps down the body, and the band along its foot. */}
|
||||||
|
<rect x="13" y="26" width="4.5" height="30" fill={`url(#${id}-gold)`} opacity="0.85" />
|
||||||
|
<rect x="46.5" y="26" width="4.5" height="30" fill={`url(#${id}-gold)`} opacity="0.85" />
|
||||||
|
<rect x="6" y="47" width="52" height="4" fill={`url(#${id}-gold)`} opacity="0.7" />
|
||||||
|
|
||||||
|
{/* Lock plate. It stays on the body - a real chest's hasp swings with the lid,
|
||||||
|
but a lock that leaves with the lid loses the "this was shut" read. */}
|
||||||
|
<rect
|
||||||
|
x="26"
|
||||||
|
y="30"
|
||||||
|
width="12"
|
||||||
|
height="14"
|
||||||
|
rx="2.5"
|
||||||
|
fill={`url(#${id}-gold)`}
|
||||||
|
stroke="oklch(45% 0.1 75)"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
/>
|
||||||
|
<circle cx="32" cy="36" r="2.2" fill="oklch(32% 0.05 60)" />
|
||||||
|
<rect x="31" y="36" width="2" height="5" rx="1" fill="oklch(32% 0.05 60)" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* In one piece, the lid goes last: shut, it has to cover the body's top edge. */}
|
||||||
|
{layer === "all" && <Lid id={id} open={open} />}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A half-round lid, hinged at its left end - the cartoon flip-open, and the only hinge
|
||||||
|
* that works in a flat, front-on view. */
|
||||||
|
function Lid({ id, open }: { id: string; open: boolean }) {
|
||||||
|
return (
|
||||||
|
<g className="tp-chest-lid" data-open={open || undefined} style={{ transformOrigin: "8px 27px" }}>
|
||||||
|
<path d="M8 27 A 24 21 0 0 1 56 27 Z" fill={`url(#${id}-lid)`} stroke="oklch(28% 0.06 45)" strokeWidth="2" />
|
||||||
|
<rect
|
||||||
|
x="7"
|
||||||
|
y="22.5"
|
||||||
|
width="50"
|
||||||
|
height="5"
|
||||||
|
rx="2"
|
||||||
|
fill={`url(#${id}-gold)`}
|
||||||
|
stroke="oklch(45% 0.1 75)"
|
||||||
|
strokeWidth="0.8"
|
||||||
|
/>
|
||||||
|
<path d="M13 27 A 24 21 0 0 1 15.5 15" stroke={`url(#${id}-gold)`} strokeWidth="3.5" fill="none" opacity="0.85" />
|
||||||
|
<path d="M51 27 A 24 21 0 0 0 48.5 15" stroke={`url(#${id}-gold)`} strokeWidth="3.5" fill="none" opacity="0.85" />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
/** Jellyfish mode - pure key location, nothing else.
|
|
||||||
*
|
|
||||||
* Six jellyfish drift in the water, each showing a letter. One of them glows: that is
|
|
||||||
* the one to zap. There is no line to read and no word to spell, so the only thing
|
|
||||||
* being exercised is "where does this letter live" - which is exactly the skill dive
|
|
||||||
* mode hides behind reading.
|
|
||||||
*
|
|
||||||
* The decoys matter. Showing only the target turns this into the bubble mode; showing
|
|
||||||
* five wrong letters next to it means she has to find *her* letter before she can type
|
|
||||||
* it, which is the searching step that eventually goes away. */
|
|
||||||
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { currentChar } from "../../../lib/tippen/engine";
|
|
||||||
import { fingerOf } from "../../../lib/tippen/fingers";
|
|
||||||
import { mulberry32 } from "../../../lib/tippen/generator";
|
|
||||||
import type { RunResult } from "../../../lib/tippen/grading";
|
|
||||||
import type { Progress } from "../../../lib/tippen/progress";
|
|
||||||
import { useRun } from "../../../hooks/useTippenRun";
|
|
||||||
import { Keyboard } from "../Keyboard";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
letters: readonly string[];
|
|
||||||
activeKeys: readonly string[];
|
|
||||||
progress: Progress;
|
|
||||||
paused: boolean;
|
|
||||||
onFinished: (result: RunResult) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How many jellyfish are in the water at once, target included. */
|
|
||||||
const JELLYFISH_COUNT = 6;
|
|
||||||
|
|
||||||
interface Jellyfish {
|
|
||||||
left: number;
|
|
||||||
top: number;
|
|
||||||
drift: number;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function JellyfishRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
|
|
||||||
const text = letters.join("");
|
|
||||||
const { state, wrong } = useRun({
|
|
||||||
target: text,
|
|
||||||
sound: progress.settings.sound,
|
|
||||||
paused,
|
|
||||||
onFinished,
|
|
||||||
});
|
|
||||||
|
|
||||||
const next = currentChar(state);
|
|
||||||
|
|
||||||
// Fixed positions, seeded once: jellyfish that jump to a new spot on every keystroke
|
|
||||||
// would make the searching step impossible rather than merely hard.
|
|
||||||
const spots = useMemo<Jellyfish[]>(() => {
|
|
||||||
const rng = mulberry32(text.length * 31 + 7);
|
|
||||||
return Array.from({ length: JELLYFISH_COUNT }, () => ({
|
|
||||||
left: 10 + rng() * 76,
|
|
||||||
top: 6 + rng() * 66,
|
|
||||||
drift: 3 + rng() * 3,
|
|
||||||
size: 74 + rng() * 26,
|
|
||||||
}));
|
|
||||||
}, [text]);
|
|
||||||
|
|
||||||
/** The decoys shown alongside the target: other active keys, never the target itself,
|
|
||||||
* and stable for as long as the target is. */
|
|
||||||
const decoys = useMemo(() => {
|
|
||||||
if (!next) return [];
|
|
||||||
const rng = mulberry32(state.index * 101 + 13);
|
|
||||||
const others = activeKeys.filter((key) => key !== next && key !== " ");
|
|
||||||
const shuffled = [...others].sort(() => rng() - 0.5);
|
|
||||||
return shuffled.slice(0, JELLYFISH_COUNT - 1);
|
|
||||||
}, [next, state.index, activeKeys]);
|
|
||||||
|
|
||||||
// Which jellyfish carries the target. Moves around so it is not always the same one.
|
|
||||||
const targetSlot = next ? state.index % JELLYFISH_COUNT : -1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="view-enter"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 16,
|
|
||||||
padding: "0 32px 8px",
|
|
||||||
minHeight: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0 }}>
|
|
||||||
{spots.map((spot, i) => {
|
|
||||||
const isTarget = i === targetSlot;
|
|
||||||
const letter = isTarget ? next : decoys[i > targetSlot ? i - 1 : i];
|
|
||||||
if (!letter) return null;
|
|
||||||
const finger = fingerOf(letter);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="tp-jellyfish"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: `${spot.left}%`,
|
|
||||||
top: `${spot.top}%`,
|
|
||||||
width: spot.size,
|
|
||||||
height: spot.size,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
fontSize: spot.size * (isTarget ? 0.4 : 0.3),
|
|
||||||
fontWeight: 900,
|
|
||||||
borderRadius: "50% 50% 42% 42%",
|
|
||||||
color: isTarget ? "oklch(25% 0.05 175)" : "var(--paper)",
|
|
||||||
background: isTarget
|
|
||||||
? "var(--paper)"
|
|
||||||
: `linear-gradient(160deg, oklch(70% 0.13 ${finger?.hue ?? 175} / .38), oklch(45% 0.09 ${finger?.hue ?? 175} / .16))`,
|
|
||||||
border: `2px solid oklch(88% 0.07 ${finger?.hue ?? 175} / ${isTarget ? 0.9 : 0.3})`,
|
|
||||||
boxShadow: isTarget
|
|
||||||
? "0 0 34px oklch(97% 0.01 175 / .55), 0 10px 26px var(--shadow)"
|
|
||||||
: "0 4px 14px var(--shadow)",
|
|
||||||
opacity: isTarget ? 1 : 0.55,
|
|
||||||
transform: isTarget ? "scale(1.12)" : "scale(1)",
|
|
||||||
animation: `dolphinBob ${spot.drift}s ease-in-out infinite`,
|
|
||||||
transition: "opacity 200ms ease, transform 200ms ease, background 200ms ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{letter === " " ? "␣" : letter}
|
|
||||||
{isTarget && wrong && (
|
|
||||||
<div style={{ position: "absolute", inset: -6, borderRadius: "50%", animation: "wrongShake 260ms ease" }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Keyboard
|
|
||||||
activeKeys={activeKeys}
|
|
||||||
nextKey={next}
|
|
||||||
progress={progress}
|
|
||||||
mode={progress.settings.keyboardHint}
|
|
||||||
size={36}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -85,7 +85,10 @@ export const TAB_RAIL_CLEARANCE = 88;
|
|||||||
* smarthome tab only renders when Home Assistant is configured (see `App.tsx`); the
|
* smarthome tab only renders when Home Assistant is configured (see `App.tsx`); the
|
||||||
* other two always show. */
|
* other two always show. */
|
||||||
export const PAGE_ICON: Record<UiState["page"], string> = {
|
export const PAGE_ICON: Record<UiState["page"], string> = {
|
||||||
music: "🎵",
|
// A plain Unicode symbol, not the 🎵 emoji: that glyph's own built-in colour is a
|
||||||
|
// muted grey-blue on at least one real platform, which is illegible against the tab
|
||||||
|
// rail's own glass background and does not respond to `color` the way text does.
|
||||||
|
music: "♪",
|
||||||
room: "💡",
|
room: "💡",
|
||||||
typing: "⌨️",
|
typing: "⌨️",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,9 +67,6 @@ describe("grade", () => {
|
|||||||
expect(grade(state).errors).toBe(1);
|
expect(grade(state).errors).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("awards pearls even for a bad run", () => {
|
|
||||||
expect(grade(run("a".repeat(20), [0, 1, 2, 3, 4, 5, 6, 7])).pearls).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("stars", () => {
|
describe("stars", () => {
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { focusKeyFor, mastery } from "../progress";
|
import { focusKeyFor, mastery, progressFromApi } from "../progress";
|
||||||
import type { Progress } from "../progress";
|
import type { Progress } from "../progress";
|
||||||
|
import type { TippenProgress as ApiProgress } from "../../../api/types";
|
||||||
|
|
||||||
function basicProgress(over: Partial<Progress> = {}): Progress {
|
function basicProgress(over: Partial<Progress> = {}): Progress {
|
||||||
return {
|
return {
|
||||||
lessons: {},
|
lessons: {},
|
||||||
keyStats: {},
|
keyStats: {},
|
||||||
pearls: 0,
|
|
||||||
aquarium: [],
|
aquarium: [],
|
||||||
streak: { days: 0, lastPlayed: null },
|
streak: { days: 0, lastPlayed: null },
|
||||||
settings: { sound: true, keyboardHint: "auto" },
|
settings: { sound: true, keyboardHint: "auto" },
|
||||||
@@ -65,3 +65,26 @@ describe("mastery", () => {
|
|||||||
expect(mastery(stat(300, 50), "a")).toBeCloseTo(0.5, 5);
|
expect(mastery(stat(300, 50), "a")).toBeCloseTo(0.5, 5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("progressFromApi", () => {
|
||||||
|
/** `earned` was silently dropped here once, which is not a visible bug anywhere except
|
||||||
|
* on the lesson map, where it decides whether a reward's treasure chest is drawn open.
|
||||||
|
* A dropped field fails no type check - the mapping is written out by hand - so it
|
||||||
|
* needs a test that names it. */
|
||||||
|
it("keeps the backend's derived `earned` flag", () => {
|
||||||
|
const api = {
|
||||||
|
lessons: {
|
||||||
|
won: { unlocked: true, runs: 3, best_stars: 3, best_animal: "delfin", best_points: 9, earned: true, ghost: null },
|
||||||
|
open: { unlocked: true, runs: 1, best_stars: 1, best_animal: "krabbe", best_points: 2, earned: false, ghost: null },
|
||||||
|
},
|
||||||
|
key_stats: {},
|
||||||
|
aquarium: [],
|
||||||
|
streak: { days: 2, last_played: null },
|
||||||
|
settings: { sound: true, keyboard_hint: "auto" },
|
||||||
|
} as unknown as ApiProgress;
|
||||||
|
|
||||||
|
const progress = progressFromApi(api);
|
||||||
|
expect(progress.lessons.won?.earned).toBe(true);
|
||||||
|
expect(progress.lessons.open?.earned).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
import type { CreatureId } from "./aquarium";
|
import type { CreatureId } from "./aquarium";
|
||||||
|
|
||||||
export type LessonKind = "letters" | "fragments" | "words" | "sentences";
|
export type LessonKind = "letters" | "fragments" | "words" | "sentences";
|
||||||
export type ModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
|
export type ModeId = "dive" | "bubbles" | "feed" | "race";
|
||||||
|
|
||||||
/** What this lesson unlocks in the music library, if anything - see `rewards.py`.
|
/** What this lesson unlocks in the music library, if anything - see `rewards.py`.
|
||||||
* `resolved: false` means the curriculum names a path that matches nothing right now. */
|
* `resolved: false` means the curriculum names a path that matches nothing right now. */
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function press(state: RunState, key: string, now: number): [RunState, Run
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Give up on the rest of the line - what Escape does. The run is still graded on what
|
/** Give up on the rest of the line - what Escape does. The run is still graded on what
|
||||||
* was typed, so a half-finished bubbles round still earns its pearls. */
|
* was typed, so a half-finished bubbles round still earns its stars. */
|
||||||
export function abandonRun(state: RunState, now: number): RunState {
|
export function abandonRun(state: RunState, now: number): RunState {
|
||||||
if (isFinished(state) || state.startedAt === null) return state;
|
if (isFinished(state) || state.startedAt === null) return state;
|
||||||
return { ...state, finishedAt: now };
|
return { ...state, finishedAt: now };
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export function wordChunks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The line a lesson should show, given what it can spell. Word lessons alternate:
|
/** The line a lesson should show, given what it can spell. Word lessons alternate:
|
||||||
* `preferWords` lets a mode ask for letters even in a late lesson (jellyfish mode is
|
* `preferWords` lets a mode ask for letters even in a late lesson (bubbles mode is
|
||||||
* always single letters) or for words wherever they exist (feed mode). */
|
* always single letters) or for words wherever they exist (feed mode). */
|
||||||
export function lineFor(
|
export function lineFor(
|
||||||
lesson: { activeKeys: readonly string[]; words: readonly string[]; newKeys?: readonly string[] },
|
lesson: { activeKeys: readonly string[]; words: readonly string[]; newKeys?: readonly string[] },
|
||||||
@@ -222,8 +222,8 @@ export function chunkOffsets(chunks: readonly string[], spaceActive: boolean): n
|
|||||||
return offsets;
|
return offsets;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single letters for the bubbles and jellyfish modes: one key per bubble, drawn from
|
/** Single letters for bubbles mode: one key per bubble, drawn from the same bag, so the
|
||||||
* the same bag, so the arcade modes drill the same spread as the dive mode. */
|
* arcade mode drills the same spread as the dive mode. */
|
||||||
export function letterStream(
|
export function letterStream(
|
||||||
activeKeys: readonly string[],
|
activeKeys: readonly string[],
|
||||||
rng: Rng,
|
rng: Rng,
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ export interface RunResult {
|
|||||||
animal: AnimalId;
|
animal: AnimalId;
|
||||||
/** Whether this run unlocks the next lesson on its own. */
|
/** Whether this run unlocks the next lesson on its own. */
|
||||||
passed: boolean;
|
passed: boolean;
|
||||||
/** Pearls earned - the aquarium currency. */
|
|
||||||
pearls: number;
|
|
||||||
/** Kept for the race-mode ghost and the per-key stats. */
|
/** Kept for the race-mode ghost and the per-key stats. */
|
||||||
strokes: readonly Stroke[];
|
strokes: readonly Stroke[];
|
||||||
}
|
}
|
||||||
@@ -162,12 +160,6 @@ export function isPassed(accuracy: number): boolean {
|
|||||||
return accuracy >= STAR_THRESHOLDS.two;
|
return accuracy >= STAR_THRESHOLDS.two;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One pearl per five correct keys, plus a bonus per star. Small numbers that go up
|
|
||||||
* every single run, including a bad one - the aquarium should never stall. */
|
|
||||||
function pearlsFor(characters: number, stars: number): number {
|
|
||||||
return Math.floor(characters / 5) + stars * 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function grade(state: RunState): RunResult {
|
export function grade(state: RunState): RunResult {
|
||||||
const characters = state.strokes.filter((stroke) => stroke.correct).length;
|
const characters = state.strokes.filter((stroke) => stroke.correct).length;
|
||||||
const errors = state.missed.size;
|
const errors = state.missed.size;
|
||||||
@@ -196,7 +188,6 @@ export function grade(state: RunState): RunResult {
|
|||||||
stars,
|
stars,
|
||||||
animal: animalFor(points).id,
|
animal: animalFor(points).id,
|
||||||
passed: isPassed(accuracy),
|
passed: isPassed(accuracy),
|
||||||
pearls: pearlsFor(characters, stars),
|
|
||||||
strokes: state.strokes,
|
strokes: state.strokes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { ModeId } from "./curriculum";
|
|||||||
export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = {
|
export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = {
|
||||||
dive: { emoji: "🤿", name: "Tauchgang" },
|
dive: { emoji: "🤿", name: "Tauchgang" },
|
||||||
bubbles: { emoji: "🫧", name: "Blasenplatzen" },
|
bubbles: { emoji: "🫧", name: "Blasenplatzen" },
|
||||||
jellyfish: { emoji: "🦑", name: "Quallenalarm" },
|
|
||||||
feed: { emoji: "🐟", name: "Fütterungszeit" },
|
feed: { emoji: "🐟", name: "Fütterungszeit" },
|
||||||
race: { emoji: "🐬", name: "Delfinrennen" },
|
race: { emoji: "🐬", name: "Delfinrennen" },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,3 +54,114 @@ export function playFanfare(): void {
|
|||||||
window.setTimeout(() => blip(frequency, frequency * 1.5, 0.13, 0.28), i * 110);
|
window.setTimeout(() => blip(frequency, frequency * 1.5, 0.13, 0.28), i * 110);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One scheduled note of a tune. `at`/`duration` are seconds relative to the tune's
|
||||||
|
* own start, so a melody reads as a score rather than as nested `setTimeout`s - which
|
||||||
|
* also keeps the notes sample-accurate against each other instead of drifting by
|
||||||
|
* however late the event loop happened to be. */
|
||||||
|
interface Note {
|
||||||
|
/** Hz. */
|
||||||
|
hz: number;
|
||||||
|
at: number;
|
||||||
|
duration: number;
|
||||||
|
gain?: number;
|
||||||
|
type?: OscillatorType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playTune(notes: readonly Note[]): void {
|
||||||
|
try {
|
||||||
|
context ??= new AudioContext();
|
||||||
|
// Resuming matters here specifically: this tune plays at the end of a run, and on
|
||||||
|
// some browsers the context created during the run's first keystroke is suspended
|
||||||
|
// again by the time the result screen opens.
|
||||||
|
void context.resume?.();
|
||||||
|
const start = context.currentTime + 0.02;
|
||||||
|
for (const note of notes) {
|
||||||
|
const oscillator = context.createOscillator();
|
||||||
|
const gain = context.createGain();
|
||||||
|
const peak = note.gain ?? 0.12;
|
||||||
|
const from = start + note.at;
|
||||||
|
oscillator.type = note.type ?? "triangle";
|
||||||
|
oscillator.frequency.setValueAtTime(note.hz, from);
|
||||||
|
// A short attack rather than an instant one: a hard start on a triangle wave
|
||||||
|
// clicks, and a click in a reward jingle sounds like a fault.
|
||||||
|
gain.gain.setValueAtTime(0.0001, from);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(peak, from + 0.02);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, from + note.duration);
|
||||||
|
oscillator.connect(gain).connect(context.destination);
|
||||||
|
oscillator.start(from);
|
||||||
|
oscillator.stop(from + note.duration + 0.02);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Same as `blip`: no audio context, no sound, never an exception.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const C5 = 523.25;
|
||||||
|
const D5 = 587.33;
|
||||||
|
const E5 = 659.25;
|
||||||
|
const F5 = 698.46;
|
||||||
|
const G5 = 783.99;
|
||||||
|
const A5 = 880;
|
||||||
|
const C6 = 1046.5;
|
||||||
|
const E6 = 1318.5;
|
||||||
|
const G6 = 1568;
|
||||||
|
const C4 = 261.63;
|
||||||
|
const E4 = 329.63;
|
||||||
|
const G4 = 392;
|
||||||
|
const G3 = 196;
|
||||||
|
|
||||||
|
/** The unlock tune: about two seconds of unambiguous "you got something".
|
||||||
|
*
|
||||||
|
* Deliberately a whole melody rather than one more blip. `playFanfare` already marks
|
||||||
|
* every smaller win in this app - a new lesson, a new animal, a new aquarium creature -
|
||||||
|
* so if opening real music sounded like that too, the biggest reward in the game would
|
||||||
|
* be the one thing she could not hear coming. This one is longer, has a bass line under
|
||||||
|
* it and lands on a held major chord, which is what makes it read as an arrival.
|
||||||
|
*
|
||||||
|
* C major throughout: a rising C-E-G-C run, a little D-E-F-G turn over it, then the
|
||||||
|
* tonic triad plus its octave held together over a low C. */
|
||||||
|
export function playRewardJingle(): void {
|
||||||
|
playTune([
|
||||||
|
// The run up.
|
||||||
|
{ hz: C5, at: 0, duration: 0.16 },
|
||||||
|
{ hz: E5, at: 0.12, duration: 0.16 },
|
||||||
|
{ hz: G5, at: 0.24, duration: 0.16 },
|
||||||
|
{ hz: C6, at: 0.36, duration: 0.26 },
|
||||||
|
// The turn - the bit that makes it a tune instead of an arpeggio.
|
||||||
|
{ hz: A5, at: 0.62, duration: 0.13 },
|
||||||
|
{ hz: G5, at: 0.74, duration: 0.13 },
|
||||||
|
{ hz: A5, at: 0.86, duration: 0.13 },
|
||||||
|
{ hz: C6, at: 0.98, duration: 0.22 },
|
||||||
|
// The arrival: a held triad, with the bass under it.
|
||||||
|
{ hz: C5, at: 1.24, duration: 0.95, gain: 0.1 },
|
||||||
|
{ hz: E5, at: 1.24, duration: 0.95, gain: 0.09 },
|
||||||
|
{ hz: G5, at: 1.24, duration: 0.95, gain: 0.09 },
|
||||||
|
{ hz: C6, at: 1.24, duration: 0.95, gain: 0.08 },
|
||||||
|
{ hz: C4, at: 1.24, duration: 1, gain: 0.09, type: "sine" },
|
||||||
|
{ hz: G3, at: 1.24, duration: 1, gain: 0.07, type: "sine" },
|
||||||
|
// Sparkles over the held chord, as the cover comes out of the chest.
|
||||||
|
{ hz: E6, at: 1.4, duration: 0.2, gain: 0.05, type: "sine" },
|
||||||
|
{ hz: G6, at: 1.56, duration: 0.2, gain: 0.045, type: "sine" },
|
||||||
|
{ hz: C6 * 2, at: 1.72, duration: 0.3, gain: 0.04, type: "sine" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The chest landing on the screen, just before its lid opens: two low thuds. Pitched
|
||||||
|
* well below the jingle so it reads as a thing arriving, not as a note. */
|
||||||
|
export function playChestThud(): void {
|
||||||
|
playTune([
|
||||||
|
{ hz: 150, at: 0, duration: 0.12, gain: 0.1, type: "sine" },
|
||||||
|
{ hz: 110, at: 0.14, duration: 0.16, gain: 0.09, type: "sine" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lid coming open: a bright upward creak-and-pop. */
|
||||||
|
export function playChestOpen(): void {
|
||||||
|
playTune([
|
||||||
|
{ hz: G4, at: 0, duration: 0.1, gain: 0.07 },
|
||||||
|
{ hz: E4 * 2, at: 0.06, duration: 0.12, gain: 0.07 },
|
||||||
|
{ hz: D5 * 2, at: 0.13, duration: 0.14, gain: 0.06 },
|
||||||
|
{ hz: F5 * 2, at: 0.2, duration: 0.2, gain: 0.05, type: "sine" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ export interface LessonProgress {
|
|||||||
bestStars: 0 | 1 | 2 | 3;
|
bestStars: 0 | 1 | 2 | 3;
|
||||||
bestAnimal: AnimalId | null;
|
bestAnimal: AnimalId | null;
|
||||||
bestPoints: number;
|
bestPoints: number;
|
||||||
|
/** Passed on its own merits, or given up on gracefully after enough tries - derived
|
||||||
|
* server-side from `bestStars`/`runs`, see `progress.py`. This, not `bestStars`, is
|
||||||
|
* what decides whether a lesson's `unlocks:` reward has actually been claimed, so the
|
||||||
|
* map draws its treasure chest from this. */
|
||||||
|
earned: boolean;
|
||||||
/** Best-run keystrokes, replayed as the opponent in race mode. */
|
/** Best-run keystrokes, replayed as the opponent in race mode. */
|
||||||
ghost: { key: string; at: number }[] | null;
|
ghost: { key: string; at: number }[] | null;
|
||||||
}
|
}
|
||||||
@@ -37,7 +42,6 @@ export interface Settings {
|
|||||||
export interface Progress {
|
export interface Progress {
|
||||||
lessons: Record<string, LessonProgress>;
|
lessons: Record<string, LessonProgress>;
|
||||||
keyStats: Record<string, KeyStat>;
|
keyStats: Record<string, KeyStat>;
|
||||||
pearls: number;
|
|
||||||
/** Pets that have moved into the aquarium, in the order they arrived. */
|
/** Pets that have moved into the aquarium, in the order they arrived. */
|
||||||
aquarium: CreatureId[];
|
aquarium: CreatureId[];
|
||||||
streak: { days: number; lastPlayed: string | null };
|
streak: { days: number; lastPlayed: string | null };
|
||||||
@@ -53,6 +57,7 @@ export function progressFromApi(progress: ApiProgress): Progress {
|
|||||||
bestStars: entry.best_stars,
|
bestStars: entry.best_stars,
|
||||||
bestAnimal: entry.best_animal as AnimalId | null,
|
bestAnimal: entry.best_animal as AnimalId | null,
|
||||||
bestPoints: entry.best_points,
|
bestPoints: entry.best_points,
|
||||||
|
earned: entry.earned,
|
||||||
ghost: entry.ghost,
|
ghost: entry.ghost,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -63,7 +68,6 @@ export function progressFromApi(progress: ApiProgress): Progress {
|
|||||||
return {
|
return {
|
||||||
lessons,
|
lessons,
|
||||||
keyStats,
|
keyStats,
|
||||||
pearls: progress.pearls,
|
|
||||||
aquarium: progress.aquarium as CreatureId[],
|
aquarium: progress.aquarium as CreatureId[],
|
||||||
streak: { days: progress.streak.days, lastPlayed: progress.streak.last_played },
|
streak: { days: progress.streak.days, lastPlayed: progress.streak.last_played },
|
||||||
settings: { sound: progress.settings.sound, keyboardHint: progress.settings.keyboard_hint },
|
settings: { sound: progress.settings.sound, keyboardHint: progress.settings.keyboard_hint },
|
||||||
@@ -83,7 +87,6 @@ export function toRunInput(lessonId: string, result: RunResult): TippenRunInput
|
|||||||
animal: result.animal,
|
animal: result.animal,
|
||||||
points: result.points,
|
points: result.points,
|
||||||
passed: result.passed,
|
passed: result.passed,
|
||||||
pearls: result.pearls,
|
|
||||||
strokes,
|
strokes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ export const SPEECH_DEFAULT = true;
|
|||||||
|
|
||||||
export const SOUND_DEFAULT = true;
|
export const SOUND_DEFAULT = true;
|
||||||
|
|
||||||
/** How many letters one bubbles or jellyfish round sends up - matched to the dive
|
/** How many letters one bubbles round sends up - matched to the dive mode's length so a
|
||||||
* mode's length so a mode swap is not also a difficulty swap. Dive-mode line length
|
* mode swap is not also a difficulty swap. Dive-mode line length lives on the lesson
|
||||||
* lives on the lesson itself (`lengthFor` in lib/curriculum.ts). */
|
* itself (`lengthFor` in lib/curriculum.ts). */
|
||||||
export function bubbleCountFor(world: number): number {
|
export function bubbleCountFor(world: number): number {
|
||||||
return world === 1 ? 50 : 100;
|
return world === 1 ? 50 : 100;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,19 +295,156 @@
|
|||||||
min-width: 0.9em;
|
min-width: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A dome plus three trailing tentacles. Without them the jellyfish read as plain
|
/* The world-reward pets, shown small in the map's header: one per world, in colour once
|
||||||
circles, and jellyfish mode stops being a picture of anything. Drawn in CSS rather
|
it has moved in and as a white silhouette until then. The same "the next thing has to
|
||||||
than as an emoji so the letter stays centred and legible inside the dome. */
|
be visible to be worth aiming at" rule as the animal ladder on the result sheet. */
|
||||||
.tp-jellyfish::after {
|
.tp-map-creatures {
|
||||||
content: "";
|
display: flex;
|
||||||
position: absolute;
|
align-items: center;
|
||||||
bottom: -11px;
|
gap: 4px;
|
||||||
left: 26%;
|
}
|
||||||
right: 26%;
|
|
||||||
height: 16px;
|
@media (max-width: 520px) {
|
||||||
opacity: 0.55;
|
/* The streak and the best animal say more per pixel on a narrow screen. */
|
||||||
background:
|
.tp-map-creatures {
|
||||||
linear-gradient(currentColor, transparent) left / 3px 100% no-repeat,
|
display: none;
|
||||||
linear-gradient(currentColor, transparent) center / 3px 100% no-repeat,
|
}
|
||||||
linear-gradient(currentColor, transparent) right / 3px 100% no-repeat;
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------- the unlock celebration
|
||||||
|
|
||||||
|
What happens when a lesson opens real music (RewardUnlockOverlay.tsx). Its own
|
||||||
|
section because it is the only thing in this app that deliberately interrupts: it
|
||||||
|
sits above the result sheet and has to be waited out, since the whole point of the
|
||||||
|
typing game - for a six-year-old - is that it hands back songs. Everything here is
|
||||||
|
staged rather than simultaneous; the sequence is what makes it read as a chest being
|
||||||
|
opened rather than as a dialog appearing. */
|
||||||
|
|
||||||
|
/* Above .tp-overlay's own layer: the result sheet is already on screen underneath, and
|
||||||
|
is meant to be revealed when this closes rather than replaced by it. */
|
||||||
|
.tp-reward-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: radial-gradient(
|
||||||
|
circle at 50% 42%,
|
||||||
|
oklch(45% 0.07 175 / 0.82) 0%,
|
||||||
|
oklch(16% 0.04 175 / 0.94) 70%
|
||||||
|
);
|
||||||
|
animation: backdropEnter 260ms ease-out;
|
||||||
|
/* Thrown right back, out of the column the cover rises through - see the note on
|
||||||
|
.tp-chest-lid. Alone this would look like a lid coming off; with the cover standing
|
||||||
|
in the middle it reads as the lid flung open to make room for it. */
|
||||||
|
--tp-chest-open: -108deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The lid's two states. Transitioned rather than keyframed so the map can draw a
|
||||||
|
permanently open chest with no animation at all - see TreasureChest.tsx. */
|
||||||
|
.tp-chest-lid {
|
||||||
|
transform-box: view-box;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
transition: transform 620ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* How far the lid swings. The default is for a chest standing on its own (the lesson
|
||||||
|
map): far enough back to be unmistakably open, but not so far that it stops looking
|
||||||
|
attached to its hinge - a lid much past -90deg, alone, reads as a flap flying off.
|
||||||
|
The overlay overrides it above, because there the lid has a cover rising through the
|
||||||
|
middle of the chest and has to clear out of its way. */
|
||||||
|
.tp-chest-lid[data-open] {
|
||||||
|
transform: rotate(var(--tp-chest-open, -62deg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The chest arriving: dropped in, with a squash on landing. */
|
||||||
|
@keyframes chestDrop {
|
||||||
|
0% { opacity: 0; transform: translateY(-120px) scale(0.7); }
|
||||||
|
55% { opacity: 1; transform: translateY(0) scale(1.06, 0.9); }
|
||||||
|
75% { transform: translateY(-6px) scale(0.98, 1.04); }
|
||||||
|
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The wait before the lid goes: the chest rattling, because something inside wants out.
|
||||||
|
This is the beat that earns the opening - a lid that opens the instant the chest lands
|
||||||
|
is just a transition, not a reveal. */
|
||||||
|
@keyframes chestRattle {
|
||||||
|
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||||
|
15% { transform: translateX(-3px) rotate(-2.5deg); }
|
||||||
|
30% { transform: translateX(3px) rotate(2.5deg); }
|
||||||
|
45% { transform: translateX(-2px) rotate(-1.5deg); }
|
||||||
|
60% { transform: translateX(2px) rotate(1.5deg); }
|
||||||
|
80% { transform: translateX(-1px) rotate(-0.5deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The burst of light at the moment the lid lets go. */
|
||||||
|
@keyframes rayBurst {
|
||||||
|
0% { opacity: 0; transform: scale(0.2) rotate(0deg); }
|
||||||
|
25% { opacity: 0.9; }
|
||||||
|
100% { opacity: 0; transform: scale(2.4) rotate(140deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The cover art coming up out of the chest and settling where it can be looked at.
|
||||||
|
Overshoots on the way up - a reward that rises and stops dead looks placed, not
|
||||||
|
thrown. */
|
||||||
|
@keyframes coverRise {
|
||||||
|
0% { opacity: 0; transform: translateY(46px) scale(0.28) rotate(-14deg); }
|
||||||
|
45% { opacity: 1; transform: translateY(-26px) scale(1.12) rotate(5deg); }
|
||||||
|
70% { transform: translateY(2px) scale(0.97) rotate(-2deg); }
|
||||||
|
100% { opacity: 1; transform: translateY(0) scale(1) rotate(0deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The cover's own halo, once it has settled - a slow pulse, so the thing she just won
|
||||||
|
keeps drawing the eye while the text and the button arrive under it. */
|
||||||
|
@keyframes coverHalo {
|
||||||
|
0%, 100% { box-shadow: 0 10px 34px var(--shadow), 0 0 0 0 oklch(92% 0.15 92 / 0.55); }
|
||||||
|
50% { box-shadow: 0 10px 34px var(--shadow), 0 0 42px 10px oklch(92% 0.15 92 / 0.28); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rewardTextEnter {
|
||||||
|
0% { opacity: 0; transform: translateY(12px); }
|
||||||
|
100% { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confetti. Each piece gets its own left/delay/duration/colour/spin from inline style -
|
||||||
|
the keyframe only owns the fall. `--tp-drift` is how far sideways it wanders, so no
|
||||||
|
two pieces trace the same line down the screen. */
|
||||||
|
@keyframes confettiFall {
|
||||||
|
0% { opacity: 0; transform: translate(0, -8vh) rotate(0deg); }
|
||||||
|
8% { opacity: 1; }
|
||||||
|
85% { opacity: 1; }
|
||||||
|
100% { opacity: 0; transform: translate(var(--tp-drift, 0px), 104vh) rotate(var(--tp-spin, 540deg)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-confetti-piece {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
will-change: transform;
|
||||||
|
animation-name: confettiFall;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-iteration-count: 1;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
/* The reveal still happens - it is information, not decoration - but nothing travels:
|
||||||
|
the chest is simply open, the cover simply there, and the confetti does not fall.
|
||||||
|
RewardUnlockOverlay also skips straight to its final step when this is set, so the
|
||||||
|
staging never leaves her waiting on animations that were turned off. */
|
||||||
|
.tp-reward-overlay,
|
||||||
|
.tp-reward-overlay * {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
.tp-chest-lid {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.tp-confetti-piece {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||