Typing lessons like duolingo & musicmouse cleanup
This commit is contained in:
@@ -297,13 +297,17 @@ def _build_app(
|
||||
return app
|
||||
|
||||
|
||||
def _service_of_type[T: Service](services: list[Service], kind: type[T]) -> T | None:
|
||||
return next((s for s in services if isinstance(s, kind)), None)
|
||||
|
||||
|
||||
def _analysis_batch_hook(services: list[Service]) -> Callable[[], Awaitable[None]] | None:
|
||||
"""The web front-end's own hub, if it is running - so open tabs refetch the library
|
||||
as background analysis lands, instead of only after a manual reload. `None` when
|
||||
there is no web service, which `MusicLibrary.analyze_pending` treats as "nobody to
|
||||
tell".
|
||||
"""
|
||||
web_service = next((s for s in services if isinstance(s, WebService)), None)
|
||||
web_service = _service_of_type(services, WebService)
|
||||
return web_service.hub.broadcast_library if web_service else None
|
||||
|
||||
|
||||
@@ -339,7 +343,7 @@ def _build_services(
|
||||
|
||||
# Unconditional: a show only starts downloading once someone drops a `feed.txt`
|
||||
# into its folder, so there is nothing to gate here with its own config section.
|
||||
web_service = next((s for s in services if isinstance(s, WebService)), None)
|
||||
web_service = _service_of_type(services, WebService)
|
||||
services.append(
|
||||
PodcastFeedService(
|
||||
app.library,
|
||||
|
||||
@@ -12,6 +12,7 @@ from musicmouse.config import Config, FigureColors
|
||||
from musicmouse.devices.mouse import MusicMouseDevice
|
||||
from musicmouse.devices.player import Player
|
||||
from musicmouse.library import MusicLibrary
|
||||
from musicmouse.library.models import Album
|
||||
from musicmouse.media import Playlist
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
@@ -55,6 +56,10 @@ class App:
|
||||
_log.warning("No playlist for figure %r", figure)
|
||||
return playlist
|
||||
|
||||
def album_for(self, playlist: Playlist | None) -> Album | None:
|
||||
"""The library album a playlist came from, if any."""
|
||||
return self.library.get(playlist.album_id if playlist else None)
|
||||
|
||||
async def rescan_library(
|
||||
self, *, broadcast: Callable[[], Awaitable[None]] | None = None
|
||||
) -> None:
|
||||
|
||||
@@ -267,6 +267,8 @@ class VlcPlayer(PlayerBase):
|
||||
_log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist))
|
||||
|
||||
def play(self) -> None:
|
||||
if self._playing:
|
||||
return
|
||||
self._list_player.play()
|
||||
|
||||
def play_from_start(self) -> None:
|
||||
@@ -279,6 +281,8 @@ class VlcPlayer(PlayerBase):
|
||||
self._list_player.play_item_at_index(max(0, min(index, len(self._playlist) - 1)))
|
||||
|
||||
def pause(self) -> None:
|
||||
if not self._playing:
|
||||
return
|
||||
self._media_player.set_pause(1)
|
||||
|
||||
def stop(self) -> None:
|
||||
|
||||
@@ -41,7 +41,7 @@ __all__ = [
|
||||
"PlaySeriesLatestRequested",
|
||||
"PlaybackChanged",
|
||||
"PlaylistFinished",
|
||||
"PrevTrackRequested",
|
||||
"PreviousTrackRequested",
|
||||
"RfidTokenRead",
|
||||
"RotaryTurned",
|
||||
"SetVolumeRequested",
|
||||
@@ -135,8 +135,6 @@ class RotaryTurned(InputEvent):
|
||||
class PlaylistFinished(InputEvent):
|
||||
"""The player reached the end of the playlist."""
|
||||
|
||||
figure: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class DeviceConnected(InputEvent):
|
||||
@@ -168,7 +166,7 @@ class NextTrackRequested(IntentEvent):
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PrevTrackRequested(IntentEvent):
|
||||
class PreviousTrackRequested(IntentEvent):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ def web_playback(event: PlaybackChanged, app: App) -> None:
|
||||
off_animation(app)
|
||||
return
|
||||
|
||||
album = app.library.get(event.playlist.album_id if event.playlist else None)
|
||||
album = app.album_for(event.playlist)
|
||||
if album is None:
|
||||
return
|
||||
web_animation(app, parse_color(album.colors[0]))
|
||||
|
||||
@@ -20,7 +20,7 @@ from musicmouse.events import (
|
||||
PlaylistFinished,
|
||||
PlayRequested,
|
||||
PlaySeriesLatestRequested,
|
||||
PrevTrackRequested,
|
||||
PreviousTrackRequested,
|
||||
RotaryTurned,
|
||||
SeekRequested,
|
||||
SetVolumeRequested,
|
||||
@@ -118,7 +118,7 @@ def button_pressed(event: ButtonEvent, app: App) -> None:
|
||||
if event.action is not ButtonAction.PRESSED:
|
||||
return
|
||||
if event.button is Button.LEFT and app.player.is_playing:
|
||||
app.bus.emit(PrevTrackRequested(source="device"))
|
||||
app.bus.emit(PreviousTrackRequested(source="device"))
|
||||
elif event.button is Button.RIGHT and app.player.is_playing:
|
||||
app.bus.emit(NextTrackRequested(source="device"))
|
||||
# The rotary press is published to Home Assistant by the MQTT service; what it
|
||||
@@ -143,8 +143,8 @@ def next_track(event: NextTrackRequested, app: App) -> None:
|
||||
app.player.next_track()
|
||||
|
||||
|
||||
@on(PrevTrackRequested)
|
||||
def previous_track(event: PrevTrackRequested, app: App) -> None:
|
||||
@on(PreviousTrackRequested)
|
||||
def previous_track(event: PreviousTrackRequested, app: App) -> None:
|
||||
_log.debug("Previous track (%s)", event.source)
|
||||
app.player.previous_track()
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from musicmouse.events import (
|
||||
PlayAlbumRequested,
|
||||
PlayRequested,
|
||||
PlaySeriesLatestRequested,
|
||||
PrevTrackRequested,
|
||||
PreviousTrackRequested,
|
||||
SetVolumeRequested,
|
||||
VolumeChangeRequested,
|
||||
)
|
||||
@@ -40,8 +40,8 @@ _TRANSPORT: dict[str, Callable[[], IntentEvent]] = {
|
||||
# pauses, like its pause button.
|
||||
"KEY_PAUSE": lambda: PauseRequested(source="lirc"),
|
||||
"KEY_STOP": lambda: PauseRequested(source="lirc"),
|
||||
"KEY_PREVIOUS": lambda: PrevTrackRequested(source="lirc"),
|
||||
"KEY_REWIND": lambda: PrevTrackRequested(source="lirc"),
|
||||
"KEY_PREVIOUS": lambda: PreviousTrackRequested(source="lirc"),
|
||||
"KEY_REWIND": lambda: PreviousTrackRequested(source="lirc"),
|
||||
"KEY_FORWARD": lambda: NextTrackRequested(source="lirc"),
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any, ClassVar
|
||||
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.config import MqttConfig
|
||||
from musicmouse.devices.mouse import MusicMouseDevice
|
||||
from musicmouse.devices.player import Player
|
||||
from musicmouse.events import (
|
||||
ActiveFigureChanged,
|
||||
@@ -22,7 +23,7 @@ from musicmouse.events import (
|
||||
PauseRequested,
|
||||
PlaybackChanged,
|
||||
PlayRequested,
|
||||
PrevTrackRequested,
|
||||
PreviousTrackRequested,
|
||||
SetVolumeRequested,
|
||||
TrackChanged,
|
||||
VolumeChanged,
|
||||
@@ -37,9 +38,11 @@ __all__ = ["PlayerSensor", "TransportButton", "VolumeNumber", "player_entities"]
|
||||
class PlayerSensor(Entity):
|
||||
component = "sensor"
|
||||
|
||||
def __init__(self, bus: EventBus, config: MqttConfig, player: Player) -> None:
|
||||
def __init__(
|
||||
self, bus: EventBus, config: MqttConfig, mouse: MusicMouseDevice, player: Player
|
||||
) -> None:
|
||||
self.mouse = mouse
|
||||
self.player = player
|
||||
self._figure: str | None = None
|
||||
super().__init__(bus, config, object_id="player", name="Music Mouse Player")
|
||||
|
||||
def discovery_payload(self) -> dict[str, Any]:
|
||||
@@ -56,9 +59,7 @@ class PlayerSensor(Entity):
|
||||
for event_type in (PlaybackChanged, TrackChanged, VolumeChanged, ActiveFigureChanged):
|
||||
self.bus.subscribe(event_type, self._on_change)
|
||||
|
||||
async def _on_change(self, event: Event) -> None:
|
||||
if isinstance(event, ActiveFigureChanged):
|
||||
self._figure = event.figure
|
||||
async def _on_change(self, _event: Event) -> None:
|
||||
await self.publish_state()
|
||||
|
||||
async def publish_state(self) -> None:
|
||||
@@ -68,7 +69,7 @@ class PlayerSensor(Entity):
|
||||
await self.publish(
|
||||
f"{self.base_topic}/attributes",
|
||||
{
|
||||
"figure": self._figure,
|
||||
"figure": self.mouse.active_figure,
|
||||
"playlist": playlist.name if playlist else None,
|
||||
"track_index": self.player.track_index,
|
||||
"track_count": len(playlist) if playlist else 0,
|
||||
@@ -128,7 +129,7 @@ class TransportButton(Entity):
|
||||
#: Button object id -> the intent pressing it emits.
|
||||
INTENTS: ClassVar[dict[str, type[IntentEvent]]] = {
|
||||
"next": NextTrackRequested,
|
||||
"previous": PrevTrackRequested,
|
||||
"previous": PreviousTrackRequested,
|
||||
"play": PlayRequested,
|
||||
"pause": PauseRequested,
|
||||
}
|
||||
@@ -154,7 +155,9 @@ class TransportButton(Entity):
|
||||
self.bus.emit(self.INTENTS[self.action](source="mqtt"))
|
||||
|
||||
|
||||
def player_entities(bus: EventBus, config: MqttConfig, player: Player) -> list[Entity]:
|
||||
def player_entities(
|
||||
bus: EventBus, config: MqttConfig, mouse: MusicMouseDevice, player: Player
|
||||
) -> list[Entity]:
|
||||
names = {
|
||||
"next": "Music Mouse Next",
|
||||
"previous": "Music Mouse Previous",
|
||||
@@ -162,7 +165,7 @@ def player_entities(bus: EventBus, config: MqttConfig, player: Player) -> list[E
|
||||
"pause": "Music Mouse Pause",
|
||||
}
|
||||
return [
|
||||
PlayerSensor(bus, config, player),
|
||||
PlayerSensor(bus, config, mouse, player),
|
||||
VolumeNumber(bus, config, player),
|
||||
*(TransportButton(bus, config, action, name) for action, name in names.items()),
|
||||
]
|
||||
|
||||
@@ -41,7 +41,7 @@ def build_entities(
|
||||
"""Everything this backend exposes to Home Assistant."""
|
||||
return [
|
||||
*(LightEntity(bus, config, mouse, zone, ZONE_NAMES[zone]) for zone in LedZone),
|
||||
*player_entities(bus, config, player),
|
||||
*player_entities(bus, config, mouse, player),
|
||||
*trigger_entities(bus, config),
|
||||
]
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ from musicmouse.events import (
|
||||
PauseRequested,
|
||||
PlayAlbumRequested,
|
||||
PlayRequested,
|
||||
PrevTrackRequested,
|
||||
PreviousTrackRequested,
|
||||
SeekRequested,
|
||||
SetVolumeRequested,
|
||||
)
|
||||
@@ -152,7 +152,7 @@ def build_router(
|
||||
|
||||
@router.post("/previous", status_code=204)
|
||||
def previous_track() -> Response:
|
||||
return emit(PrevTrackRequested(source="web"))
|
||||
return emit(PreviousTrackRequested(source="web"))
|
||||
|
||||
@router.post("/seek", status_code=204)
|
||||
def seek(body: SeekIn) -> Response:
|
||||
|
||||
@@ -34,6 +34,8 @@ __all__ = [
|
||||
def to_device_volume(percent: int, general: GeneralConfig) -> int:
|
||||
"""Map 0..100 onto the configured range, so 100 % is exactly ``max_volume``."""
|
||||
span = general.max_volume - general.min_volume
|
||||
if span <= 0:
|
||||
return general.min_volume
|
||||
return general.min_volume + round(span * max(0, min(100, percent)) / 100)
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ __all__ = ["snapshot"]
|
||||
def snapshot(app: App) -> PlayerStateOut:
|
||||
player = app.player
|
||||
playlist = player.playlist
|
||||
album = app.library.get(playlist.album_id if playlist else None)
|
||||
album = app.album_for(playlist)
|
||||
index = player.track_index
|
||||
# The player only knows a file path, and a tagged file's name is rarely its title
|
||||
# ("01 - So ein schoener Tag.mp3"). The library read the real one at scan time.
|
||||
|
||||
@@ -26,7 +26,7 @@ from musicmouse.events import (
|
||||
PauseRequested,
|
||||
PlayAlbumRequested,
|
||||
PlayRequested,
|
||||
PrevTrackRequested,
|
||||
PreviousTrackRequested,
|
||||
RfidTokenRead,
|
||||
RotaryTurned,
|
||||
SeekRequested,
|
||||
@@ -164,7 +164,7 @@ class SimulatorDriver:
|
||||
await self.settle()
|
||||
|
||||
async def emit_prev(self) -> None:
|
||||
self.app.bus.emit(PrevTrackRequested(source="simulator"))
|
||||
self.app.bus.emit(PreviousTrackRequested(source="simulator"))
|
||||
await self.settle()
|
||||
|
||||
async def set_volume(self, volume: int) -> None:
|
||||
@@ -235,9 +235,7 @@ class SimulatorDriver:
|
||||
case "playlist":
|
||||
return player.playlist.name if player.playlist else "none"
|
||||
case "album":
|
||||
album = self.app.library.get(
|
||||
player.playlist.album_id if player.playlist else None
|
||||
)
|
||||
album = self.app.album_for(player.playlist)
|
||||
return album.title if album else "none"
|
||||
case "position":
|
||||
return f"{player.position:.1f}"
|
||||
|
||||
@@ -42,7 +42,6 @@ class FakePlayer(PlayerBase):
|
||||
self._remaining = track_duration
|
||||
self._started_at: float | None = None
|
||||
self._timer: asyncio.Task[None] | None = None
|
||||
self.closed = False
|
||||
|
||||
# -------------------------------------------------------------------- state
|
||||
|
||||
@@ -112,12 +111,8 @@ class FakePlayer(PlayerBase):
|
||||
if self._playing:
|
||||
self._start_timer()
|
||||
|
||||
async def run(self) -> None:
|
||||
return
|
||||
|
||||
def close(self) -> None:
|
||||
self._cancel_timer()
|
||||
self.closed = True
|
||||
|
||||
# ---------------------------------------------------------------- internals
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from musicmouse.events import (
|
||||
PlayAlbumRequested,
|
||||
PlayRequested,
|
||||
PlaySeriesLatestRequested,
|
||||
PrevTrackRequested,
|
||||
PreviousTrackRequested,
|
||||
VolumeChangeRequested,
|
||||
)
|
||||
from musicmouse.services.lirc import LircService
|
||||
@@ -160,7 +160,7 @@ async def test_transport_and_volume_buttons_emit_intents(
|
||||
PlayRequested,
|
||||
PauseRequested,
|
||||
PauseRequested,
|
||||
PrevTrackRequested,
|
||||
PreviousTrackRequested,
|
||||
NextTrackRequested,
|
||||
VolumeChangeRequested,
|
||||
]
|
||||
|
||||
@@ -419,14 +419,18 @@ async def test_transport_buttons_emit_intents(
|
||||
|
||||
|
||||
async def test_the_player_sensor_reports_the_current_track(
|
||||
bus: EventBus, mqtt_config: MqttConfig, player: FakePlayer, publisher: RecordingPublisher
|
||||
bus: EventBus,
|
||||
mqtt_config: MqttConfig,
|
||||
mouse: MusicMouseDevice,
|
||||
player: FakePlayer,
|
||||
publisher: RecordingPublisher,
|
||||
) -> None:
|
||||
from pathlib import Path
|
||||
|
||||
from musicmouse.media import Playlist, Track
|
||||
from musicmouse.services.mqtt.player import PlayerSensor
|
||||
|
||||
entity = PlayerSensor(bus, mqtt_config, player)
|
||||
entity = PlayerSensor(bus, mqtt_config, mouse, player)
|
||||
entity.attach(publisher)
|
||||
player.set_playlist(Playlist(name="fuchs", tracks=(Track(Path("/m/01 - Song.mp3")),)))
|
||||
player.play_from_start()
|
||||
@@ -439,6 +443,29 @@ async def test_the_player_sensor_reports_the_current_track(
|
||||
assert attributes["volume"] == 40
|
||||
|
||||
|
||||
async def test_the_player_sensor_reports_the_mouse_s_active_figure(
|
||||
bus: EventBus,
|
||||
mqtt_config: MqttConfig,
|
||||
transport: FakeTransport,
|
||||
mouse: MusicMouseDevice,
|
||||
player: FakePlayer,
|
||||
publisher: RecordingPublisher,
|
||||
) -> None:
|
||||
"""The sensor reads ``mouse.active_figure`` rather than keeping its own copy of it,
|
||||
so it agrees with the mouse even if it missed the event that changed it."""
|
||||
from musicmouse.services.mqtt.player import PlayerSensor
|
||||
|
||||
entity = PlayerSensor(bus, mqtt_config, mouse, player)
|
||||
entity.attach(publisher)
|
||||
|
||||
transport.inject(RfidTokenRead(tag_id=bytes.fromhex("04a1b2c3d4"), source="device"))
|
||||
await bus.drain()
|
||||
|
||||
attributes = publisher.last_json(f"{entity.base_topic}/attributes")
|
||||
assert attributes["figure"] == "fuchs"
|
||||
assert mouse.active_figure == "fuchs"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- triggers
|
||||
|
||||
|
||||
|
||||
@@ -334,20 +334,76 @@ class _FakeMedia:
|
||||
|
||||
|
||||
class _FakeMediaPlayer:
|
||||
"""Just enough of libVLC's media player to exercise index syncing."""
|
||||
"""Just enough of libVLC's media player to exercise index syncing, pause/seek and
|
||||
the volume/playing poll."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.media: _FakeMedia | None = None
|
||||
self.paused = False
|
||||
self.time_ms = 0
|
||||
self.length_ms = 0
|
||||
self.volume = 50
|
||||
|
||||
def get_media(self) -> _FakeMedia | None:
|
||||
return self.media
|
||||
|
||||
def set_pause(self, flag: int) -> None:
|
||||
self.paused = bool(flag)
|
||||
|
||||
def _vlc_like(bus: EventBus, playlist: Playlist) -> VlcPlayer:
|
||||
def set_time(self, ms: int) -> None:
|
||||
self.time_ms = ms
|
||||
|
||||
def get_time(self) -> int:
|
||||
return self.time_ms
|
||||
|
||||
def get_length(self) -> int:
|
||||
return self.length_ms
|
||||
|
||||
def audio_set_volume(self, volume: int) -> None:
|
||||
self.volume = volume
|
||||
|
||||
def audio_get_volume(self) -> int:
|
||||
return self.volume
|
||||
|
||||
|
||||
class _FakeListPlayer:
|
||||
"""Just enough of libVLC's list player to exercise play/stop and the is-playing
|
||||
half of the poll."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.playing = False
|
||||
self.play_calls = 0
|
||||
self.stop_calls = 0
|
||||
self.played_index: int | None = None
|
||||
|
||||
def play(self) -> None:
|
||||
self.play_calls += 1
|
||||
self.playing = True
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_calls += 1
|
||||
self.playing = False
|
||||
|
||||
def is_playing(self) -> bool:
|
||||
return self.playing
|
||||
|
||||
def play_item_at_index(self, index: int) -> None:
|
||||
self.played_index = index
|
||||
self.playing = True
|
||||
|
||||
|
||||
def _vlc_like(
|
||||
bus: EventBus,
|
||||
playlist: Playlist,
|
||||
*,
|
||||
media_player: _FakeMediaPlayer | None = None,
|
||||
list_player: _FakeListPlayer | None = None,
|
||||
) -> VlcPlayer:
|
||||
"""A VlcPlayer with its libVLC parts stubbed out, without calling __init__."""
|
||||
player = object.__new__(VlcPlayer)
|
||||
PlayerBase.__init__(player, bus)
|
||||
player._media_player = _FakeMediaPlayer() # type: ignore[assignment]
|
||||
player._media_player = media_player or _FakeMediaPlayer()
|
||||
player._list_player = list_player or _FakeListPlayer()
|
||||
player._playlist = playlist
|
||||
player._mrl_to_index = {f"file://{track.path}": i for i, track in enumerate(playlist.tracks)}
|
||||
return player
|
||||
@@ -365,7 +421,7 @@ async def test_the_track_index_is_read_back_off_the_player(bus: EventBus) -> Non
|
||||
events: list[Event] = []
|
||||
bus.subscribe(TrackChanged, events.append)
|
||||
|
||||
player._media_player.media = _FakeMedia("file:///music/2.mp3") # type: ignore[attr-defined]
|
||||
player._media_player.media = _FakeMedia("file:///music/2.mp3")
|
||||
player._on_next_item(object())
|
||||
await bus.drain()
|
||||
|
||||
@@ -379,10 +435,193 @@ async def test_an_unknown_media_leaves_the_index_alone(bus: EventBus) -> None:
|
||||
playlist = Playlist("test", tuple(Track(Path(f"/music/{i}.mp3")) for i in range(3)))
|
||||
player = _vlc_like(bus, playlist)
|
||||
|
||||
player._media_player.media = None # type: ignore[attr-defined]
|
||||
player._media_player.media = None
|
||||
player._on_next_item(object())
|
||||
player._media_player.media = _FakeMedia("file:///elsewhere/x.mp3") # type: ignore[attr-defined]
|
||||
player._media_player.media = _FakeMedia("file:///elsewhere/x.mp3")
|
||||
player._on_next_item(object())
|
||||
await bus.drain()
|
||||
|
||||
assert player.track_index == 0
|
||||
|
||||
|
||||
# ------------------------------------------------------ VlcPlayer actions, stubbed
|
||||
|
||||
|
||||
async def test_vlc_play_starts_playback_when_idle(bus: EventBus) -> None:
|
||||
list_player = _FakeListPlayer()
|
||||
player = _vlc_like(bus, playlist(), list_player=list_player)
|
||||
|
||||
player.play()
|
||||
|
||||
assert list_player.play_calls == 1
|
||||
|
||||
|
||||
async def test_vlc_play_is_a_no_op_once_already_playing(bus: EventBus) -> None:
|
||||
"""Mirrors `FakePlayer.play()`'s own guard, so the two `Player` implementations
|
||||
agree on what a repeated play() does instead of leaving it to libVLC."""
|
||||
list_player = _FakeListPlayer()
|
||||
player = _vlc_like(bus, playlist(), list_player=list_player)
|
||||
player._set_playing(True)
|
||||
|
||||
player.play()
|
||||
|
||||
assert list_player.play_calls == 0
|
||||
|
||||
|
||||
async def test_vlc_pause_pauses_when_playing(bus: EventBus) -> None:
|
||||
media_player = _FakeMediaPlayer()
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
player._set_playing(True)
|
||||
|
||||
player.pause()
|
||||
|
||||
assert media_player.paused is True
|
||||
|
||||
|
||||
async def test_vlc_pause_is_a_no_op_when_not_playing(bus: EventBus) -> None:
|
||||
media_player = _FakeMediaPlayer()
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
|
||||
player.pause()
|
||||
|
||||
assert media_player.paused is False
|
||||
|
||||
|
||||
async def test_vlc_play_track_clamps_to_the_playlist_bounds(bus: EventBus) -> None:
|
||||
list_player = _FakeListPlayer()
|
||||
player = _vlc_like(bus, playlist(count=3), list_player=list_player)
|
||||
|
||||
player.play_track(99)
|
||||
|
||||
assert list_player.played_index == 2
|
||||
|
||||
|
||||
async def test_vlc_play_track_on_an_empty_playlist_does_nothing(
|
||||
bus: EventBus, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
list_player = _FakeListPlayer()
|
||||
player = _vlc_like(bus, Playlist(name="leer", tracks=()), list_player=list_player)
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
player.play_track(0)
|
||||
|
||||
assert list_player.played_index is None
|
||||
assert "playlist is empty" in caplog.text
|
||||
|
||||
|
||||
async def test_vlc_seek_sets_the_time_in_milliseconds(bus: EventBus) -> None:
|
||||
media_player = _FakeMediaPlayer()
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
|
||||
player.seek(12.5)
|
||||
|
||||
assert media_player.time_ms == 12500
|
||||
|
||||
|
||||
async def test_vlc_seek_clamps_negative_positions_to_zero(bus: EventBus) -> None:
|
||||
media_player = _FakeMediaPlayer()
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
|
||||
player.seek(-5.0)
|
||||
|
||||
assert media_player.time_ms == 0
|
||||
|
||||
|
||||
async def test_vlc_position_and_duration_read_the_media_player(bus: EventBus) -> None:
|
||||
media_player = _FakeMediaPlayer()
|
||||
media_player.time_ms = 4_000
|
||||
media_player.length_ms = 180_000
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
|
||||
assert player.position == 4.0
|
||||
assert player.duration == 180.0
|
||||
|
||||
|
||||
async def test_vlc_position_floors_libvlcs_minus_one_at_zero(bus: EventBus) -> None:
|
||||
"""libVLC reports -1 for both until a media is actually opened."""
|
||||
media_player = _FakeMediaPlayer()
|
||||
media_player.time_ms = -1
|
||||
media_player.length_ms = -1
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
|
||||
assert player.position == 0.0
|
||||
assert player.duration == 0.0
|
||||
|
||||
|
||||
async def test_vlc_apply_volume_pushes_to_the_media_player(bus: EventBus) -> None:
|
||||
media_player = _FakeMediaPlayer()
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
|
||||
player.set_volume(77)
|
||||
|
||||
assert media_player.volume == 77
|
||||
|
||||
|
||||
async def test_vlc_poll_announces_a_volume_change_made_outside_the_app(
|
||||
bus: EventBus, seen: list[Event]
|
||||
) -> None:
|
||||
"""The physical volume can move without going through `set_volume` - a hardware
|
||||
knob on the amp, or another process - so the poll is what catches libVLC drifting
|
||||
from what `PlayerBase` last announced."""
|
||||
media_player = _FakeMediaPlayer()
|
||||
media_player.volume = 50
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
|
||||
media_player.volume = 65
|
||||
player._poll()
|
||||
await bus.drain()
|
||||
|
||||
assert player.volume == 65
|
||||
assert only(seen, VolumeChanged) == [VolumeChanged(volume=65, source="player")]
|
||||
|
||||
|
||||
async def test_vlc_poll_ignores_a_negative_volume_reading(bus: EventBus) -> None:
|
||||
media_player = _FakeMediaPlayer()
|
||||
player = _vlc_like(bus, playlist(), media_player=media_player)
|
||||
media_player.volume = -1
|
||||
|
||||
player._poll()
|
||||
|
||||
assert player.volume == 50
|
||||
|
||||
|
||||
async def test_vlc_poll_syncs_is_playing_from_the_list_player(
|
||||
bus: EventBus, seen: list[Event]
|
||||
) -> None:
|
||||
list_player = _FakeListPlayer()
|
||||
player = _vlc_like(bus, playlist(), list_player=list_player)
|
||||
list_player.playing = True
|
||||
|
||||
player._poll()
|
||||
await bus.drain()
|
||||
|
||||
assert player.is_playing
|
||||
assert only(seen, PlaybackChanged)[-1].playing is True
|
||||
|
||||
|
||||
async def test_vlc_on_playing_and_on_stopped_announce_playback_changed(
|
||||
bus: EventBus, seen: list[Event]
|
||||
) -> None:
|
||||
player = _vlc_like(bus, playlist())
|
||||
|
||||
player._on_playing(object())
|
||||
await bus.drain()
|
||||
assert player.is_playing
|
||||
|
||||
player._on_stopped(object())
|
||||
await bus.drain()
|
||||
assert not player.is_playing
|
||||
assert [e.playing for e in only(seen, PlaybackChanged)] == [True, False]
|
||||
|
||||
|
||||
async def test_vlc_on_playlist_end_stops_and_announces_playlist_finished(
|
||||
bus: EventBus, seen: list[Event]
|
||||
) -> None:
|
||||
player = _vlc_like(bus, playlist())
|
||||
player._set_playing(True)
|
||||
|
||||
player._on_playlist_end(object())
|
||||
await bus.drain()
|
||||
|
||||
assert not player.is_playing
|
||||
assert only(seen, PlaylistFinished) == [PlaylistFinished(source="player")]
|
||||
|
||||
18
tippen/package-lock.json
generated
18
tippen/package-lock.json
generated
@@ -9,7 +9,8 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"yaml": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
@@ -2328,6 +2329,21 @@
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"yaml": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
|
||||
@@ -15,12 +15,10 @@ import { Aquarium } from "./components/Aquarium";
|
||||
import { AppHeader } from "./components/AppHeader";
|
||||
import { HelpOverlay } from "./components/HelpOverlay";
|
||||
import { LessonMap } from "./components/LessonMap";
|
||||
import { ModePicker } from "./components/ModePicker";
|
||||
import { ResultSheet } from "./components/ResultSheet";
|
||||
import { Stage } from "./components/Stage";
|
||||
import { BubblesRun } from "./components/modes/BubblesRun";
|
||||
import { FeedRun } from "./components/modes/FeedRun";
|
||||
import { PearlsRun } from "./components/modes/PearlsRun";
|
||||
import { JellyfishRun } from "./components/modes/JellyfishRun";
|
||||
import { RaceRun } from "./components/modes/RaceRun";
|
||||
import { DiveRun } from "./components/modes/DiveRun";
|
||||
@@ -39,7 +37,7 @@ type Screen = "aquarium" | "map" | "run";
|
||||
/** What a mode is handed to draw. Tagged rather than optional-fielded so the render
|
||||
* below narrows on `kind` instead of guessing from which keys are present.
|
||||
*
|
||||
* Only two shapes, for six modes: the arcade modes want a stream of single letters, and
|
||||
* Only two shapes, for five modes: the arcade modes want a stream of single letters, and
|
||||
* everything else wants a line of chunks. That is the whole reason the modes are cheap
|
||||
* to add - they are presentations of one of two targets, all driven by the same engine. */
|
||||
type RunTarget =
|
||||
@@ -49,6 +47,11 @@ type RunTarget =
|
||||
/** Modes that drill one key at a time rather than a line. */
|
||||
const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles", "jellyfish"];
|
||||
|
||||
/** How much of a letters round the spotlighted key(s) should take. An isolated round -
|
||||
* the key's very first lesson - drills it hard; the mixed round right after blends it
|
||||
* back in with everything else, which is the whole point of "isolated then mixed". */
|
||||
const SHARE_FOR_EMPHASIS: Record<"isolated" | "mixed", number> = { isolated: 0.75, mixed: 0.4 };
|
||||
|
||||
interface Outcome {
|
||||
result: RunResult;
|
||||
unlockedTitle: string | null;
|
||||
@@ -89,9 +92,17 @@ export function App() {
|
||||
const spaceActive = true;
|
||||
|
||||
if (LETTER_ONLY_MODES.includes(mode)) {
|
||||
const share = lesson.emphasis ? SHARE_FOR_EMPHASIS[lesson.emphasis] : undefined;
|
||||
return {
|
||||
kind: "letters",
|
||||
letters: letterStream(lesson.activeKeys, rng, bubbleCountFor(lesson.world), focusKey, lesson.newKeys),
|
||||
letters: letterStream(
|
||||
lesson.activeKeys,
|
||||
rng,
|
||||
bubbleCountFor(lesson.world),
|
||||
focusKey,
|
||||
lesson.spotlightKeys,
|
||||
share,
|
||||
),
|
||||
};
|
||||
}
|
||||
const chunks = lineFor(lesson, rng, {
|
||||
@@ -106,16 +117,13 @@ export function App() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lesson, mode, round]);
|
||||
|
||||
const start = useCallback(
|
||||
(lesson: Lesson) => {
|
||||
const start = useCallback((lesson: Lesson) => {
|
||||
setLessonId(lesson.id);
|
||||
setMode(lesson.modes.includes(mode) ? mode : "dive");
|
||||
setMode(lesson.primaryMode);
|
||||
setOutcome(null);
|
||||
setRound((r) => r + 1);
|
||||
setScreen("run");
|
||||
},
|
||||
[mode],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const onFinished = useCallback(
|
||||
(result: RunResult) => {
|
||||
@@ -142,6 +150,16 @@ export function App() {
|
||||
setRound((r) => r + 1);
|
||||
}, []);
|
||||
|
||||
/** A bonus replay in a mode this lesson didn't gate progress on - feed or race,
|
||||
* offered on the result sheet once the lesson is passed. Never touches the unlock:
|
||||
* `onFinished` still runs underneath, so a great bonus run can only improve the best
|
||||
* score, not change what is unlocked. */
|
||||
const playBonus = useCallback((bonusMode: ModeId) => {
|
||||
setMode(bonusMode);
|
||||
setOutcome(null);
|
||||
setRound((r) => r + 1);
|
||||
}, []);
|
||||
|
||||
const continueAfterResult = useCallback(() => {
|
||||
const next = lessonId ? nextLesson(lessonId) : null;
|
||||
setOutcome(null);
|
||||
@@ -201,8 +219,8 @@ export function App() {
|
||||
}
|
||||
|
||||
if (current.screen !== "map") return;
|
||||
const step =
|
||||
event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
|
||||
// The map is a vertical path now, so "next" is down rather than to the right.
|
||||
const step = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
|
||||
if (step !== 0) {
|
||||
event.preventDefault();
|
||||
playPop(340);
|
||||
@@ -290,33 +308,23 @@ export function App() {
|
||||
)
|
||||
) : mode === "feed" ? (
|
||||
<FeedRun {...textProps(run)} />
|
||||
) : mode === "pearls" ? (
|
||||
<PearlsRun {...textProps(run)} />
|
||||
) : mode === "race" ? (
|
||||
<RaceRun {...textProps(run)} ghost={progress.lessons[lesson.id]?.ghost ?? null} />
|
||||
) : (
|
||||
<DiveRun {...textProps(run)} />
|
||||
)}
|
||||
<div style={{ padding: "0 32px 18px", flex: "none" }}>
|
||||
<ModePicker
|
||||
modes={lesson.modes}
|
||||
active={mode}
|
||||
onPick={(chosen) => {
|
||||
setMode(chosen);
|
||||
setRound((r) => r + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{outcome && (
|
||||
{outcome && lesson && (
|
||||
<ResultSheet
|
||||
result={outcome.result}
|
||||
unlockedTitle={outcome.unlockedTitle}
|
||||
newCreature={outcome.newCreature}
|
||||
isNewBest={outcome.isNewBest}
|
||||
bestEver={overallBestAnimal(progress)}
|
||||
bonusModes={outcome.result.passed ? lesson.bonusModes : []}
|
||||
onPlayBonus={playBonus}
|
||||
onRetry={retry}
|
||||
onContinue={continueAfterResult}
|
||||
continueLabel={
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/** The map: four worlds, each a row of lesson cards.
|
||||
/** The map: a single winding path, one world section at a time.
|
||||
*
|
||||
* Locked lessons are dimmed rather than hidden - seeing that there is a world called
|
||||
* "Große Buchstaben" waiting is half the reason to finish the one that is open. Each
|
||||
* card carries its own best animal and star count, so the map doubles as the trophy
|
||||
* cabinet. */
|
||||
* 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
|
||||
* 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. */
|
||||
|
||||
import { LESSONS, WORLDS } from "../lib/curriculum";
|
||||
import type { Lesson } from "../lib/curriculum";
|
||||
import { animalById } from "../lib/grading";
|
||||
import { NODE_SPACING, pathD, pointFor } from "../lib/lessonPath";
|
||||
import { MODE_INFO } from "../lib/modeInfo";
|
||||
import type { Progress } from "../lib/progress";
|
||||
|
||||
interface Props {
|
||||
@@ -17,32 +19,72 @@ interface Props {
|
||||
onPick: (lesson: Lesson) => void;
|
||||
}
|
||||
|
||||
const NODE_SIZE = 88;
|
||||
/** Half the SVG's viewBox width - wide enough for the path's full swing either side. */
|
||||
const PATH_HALF_WIDTH = 160;
|
||||
|
||||
/** What a consolidation node's key-label line says when it has no keys of its own to
|
||||
* show - a child-friendly word rather than the raw `LessonKind`. */
|
||||
const CONSOLIDATION_LABEL: Record<"fragments" | "words" | "sentences", string> = {
|
||||
fragments: "Wörter",
|
||||
words: "Wörter",
|
||||
sentences: "Sätze",
|
||||
};
|
||||
|
||||
export function LessonMap({ progress, selected, onPick }: Props) {
|
||||
return (
|
||||
<div
|
||||
className="view-enter"
|
||||
style={{ flex: 1, overflowY: "auto", padding: "6px 32px 32px", minHeight: 0 }}
|
||||
style={{ flex: 1, overflowY: "auto", padding: "6px 32px 40px", minHeight: 0 }}
|
||||
>
|
||||
<div style={{ maxWidth: 480, margin: "0 auto", display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
{WORLDS.map((world) => {
|
||||
const lessons = LESSONS.filter((lesson) => lesson.world === world.number);
|
||||
const done = lessons.filter((l) => (progress.lessons[l.id]?.bestStars ?? 0) >= 2).length;
|
||||
const height = lessons.length * NODE_SPACING;
|
||||
const points = lessons.map((_, i) => pointFor(i));
|
||||
|
||||
return (
|
||||
<section key={world.number}>
|
||||
<div
|
||||
className="glass-panel"
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
padding: "10px 18px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 1180, margin: "0 auto", display: "flex", flexDirection: "column", gap: 22 }}>
|
||||
{WORLDS.map((world) => (
|
||||
<div key={world.number} className="glass-panel" style={{ padding: "16px 20px 20px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 22 }}>{world.emoji}</span>
|
||||
<span style={{ fontSize: 19, fontWeight: 900, color: "var(--paper)" }}>
|
||||
<span style={{ fontSize: 17, fontWeight: 900, color: "var(--paper)" }}>
|
||||
Welt {world.number} — {world.title}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6 }}>
|
||||
{LESSONS.filter((l) => l.world === world.number && (progress.lessons[l.id]?.bestStars ?? 0) >= 2).length}
|
||||
/{LESSONS.filter((l) => l.world === world.number).length}
|
||||
<span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6, marginLeft: "auto" }}>
|
||||
{done}/{lessons.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
{LESSONS.filter((lesson) => lesson.world === world.number).map((lesson) => {
|
||||
<div style={{ position: "relative", height, margin: "0 auto" }}>
|
||||
<svg
|
||||
style={{ position: "absolute", left: "50%", top: 0, transform: "translateX(-50%)", overflow: "visible" }}
|
||||
width={PATH_HALF_WIDTH * 2}
|
||||
height={height}
|
||||
viewBox={`${-PATH_HALF_WIDTH} 0 ${PATH_HALF_WIDTH * 2} ${height}`}
|
||||
>
|
||||
<path d={pathD(points)} stroke="oklch(97% 0.01 175 / 0.35)" strokeWidth={8} strokeLinecap="round" fill="none" />
|
||||
</svg>
|
||||
|
||||
{lessons.map((lesson, i) => {
|
||||
const entry = progress.lessons[lesson.id];
|
||||
const locked = !entry?.unlocked;
|
||||
const animal = entry?.bestAnimal ? animalById(entry.bestAnimal) : null;
|
||||
const index = LESSONS.indexOf(lesson);
|
||||
const point = points[i]!;
|
||||
const modeInfo = MODE_INFO[lesson.primaryMode];
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -52,53 +94,77 @@ export function LessonMap({ progress, selected, onPick }: Props) {
|
||||
data-locked={locked}
|
||||
disabled={locked}
|
||||
onClick={() => onPick(lesson)}
|
||||
style={{ width: 150, padding: 12, flex: "none" }}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 900, color: "var(--paper)", opacity: 0.7 }}>
|
||||
{lesson.number}
|
||||
</span>
|
||||
<span style={{ fontSize: 26 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 16, fontWeight: 900, color: "var(--paper)", marginTop: 6 }}>
|
||||
{lesson.title}
|
||||
</div>
|
||||
|
||||
{/* The keys themselves, big: for a pre-reader this is the real label
|
||||
and the title is decoration. A drill has no new keys, so it says
|
||||
so with a symbol instead of showing an empty line. */}
|
||||
<div
|
||||
title={lesson.title}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `calc(50% + ${point.x}px)`,
|
||||
top: point.y,
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: NODE_SIZE,
|
||||
height: NODE_SIZE,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
padding: 0,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -4,
|
||||
right: -4,
|
||||
fontSize: 15,
|
||||
filter: locked ? "grayscale(1)" : "none",
|
||||
opacity: locked ? 0.4 : 0.9,
|
||||
}}
|
||||
title={modeInfo.name}
|
||||
>
|
||||
{modeInfo.emoji}
|
||||
</span>
|
||||
|
||||
<span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
|
||||
|
||||
{/* The keys themselves: for a pre-reader this is the real label, the
|
||||
title is decoration. A drill has no new keys, so it says so with
|
||||
a symbol instead of showing an empty line. */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
color: "var(--paper)",
|
||||
opacity: 0.75,
|
||||
letterSpacing: lesson.isDrill ? "normal" : "0.14em",
|
||||
marginTop: 3,
|
||||
minHeight: 20,
|
||||
opacity: 0.8,
|
||||
letterSpacing: lesson.isDrill ? "normal" : "0.08em",
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{lesson.isDrill
|
||||
? "🔁 Übung"
|
||||
: lesson.newKeys
|
||||
.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase()))
|
||||
.join(" ")}
|
||||
</div>
|
||||
? "🔁"
|
||||
: lesson.newKeys.length > 0
|
||||
? lesson.newKeys.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())).join(" ")
|
||||
: lesson.kind === "letters"
|
||||
? "üben"
|
||||
: CONSOLIDATION_LABEL[lesson.kind]}
|
||||
</span>
|
||||
|
||||
<div style={{ marginTop: 8, fontSize: 13, letterSpacing: "0.08em" }}>
|
||||
<span style={{ fontSize: 9, letterSpacing: "0.04em" }}>
|
||||
{[1, 2, 3].map((star) => (
|
||||
<span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}>
|
||||
⭐
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/** Which game a lesson is played as. Dive mode is the measured one; the rest are
|
||||
* the same drill wearing a costume, which is how variety gets discovered without
|
||||
* splitting the curriculum. */
|
||||
|
||||
import type { ModeId } from "../lib/curriculum";
|
||||
|
||||
interface Props {
|
||||
modes: readonly ModeId[];
|
||||
active: ModeId;
|
||||
onPick: (mode: ModeId) => void;
|
||||
}
|
||||
|
||||
export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = {
|
||||
dive: { emoji: "🤿", name: "Tauchgang" },
|
||||
bubbles: { emoji: "🫧", name: "Blasenplatzen" },
|
||||
jellyfish: { emoji: "🦑", name: "Quallenalarm" },
|
||||
feed: { emoji: "🐟", name: "Fütterungszeit" },
|
||||
race: { emoji: "🐬", name: "Delfinrennen" },
|
||||
pearls: { emoji: "🦪", name: "Perlentaucher" },
|
||||
};
|
||||
|
||||
export function ModePicker({ modes, active, onPick }: Props) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
|
||||
{modes.map((mode) => {
|
||||
const info = MODE_INFO[mode];
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
className="pill"
|
||||
data-active={mode === active}
|
||||
onClick={() => onPick(mode)}
|
||||
>
|
||||
{info.emoji} {info.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,11 @@ import { useEffect, useRef } from "react";
|
||||
|
||||
import { creatureById } from "../lib/aquarium";
|
||||
import type { CreatureId } from "../lib/aquarium";
|
||||
import type { ModeId } from "../lib/curriculum";
|
||||
import type { RunResult } from "../lib/grading";
|
||||
import { STAR_THRESHOLDS, visibleAnimals, animalById, animalIndex, animalProgress } from "../lib/grading";
|
||||
import type { AnimalId } from "../lib/grading";
|
||||
import { MODE_INFO } from "../lib/modeInfo";
|
||||
|
||||
interface Props {
|
||||
result: RunResult;
|
||||
@@ -23,6 +25,10 @@ interface Props {
|
||||
/** The fastest animal earned on any lesson so far - decides how much of the ladder
|
||||
* may be revealed. */
|
||||
bestEver: AnimalId | null;
|
||||
/** Extra games this lesson didn't gate progress on - feed or race, offered only once
|
||||
* the lesson is passed. Playing one never changes the unlock, only the best score. */
|
||||
bonusModes: readonly ModeId[];
|
||||
onPlayBonus: (mode: ModeId) => void;
|
||||
onRetry: () => void;
|
||||
onContinue: () => void;
|
||||
/** Null at the end of the curriculum. */
|
||||
@@ -35,6 +41,8 @@ export function ResultSheet({
|
||||
newCreature,
|
||||
isNewBest,
|
||||
bestEver,
|
||||
bonusModes,
|
||||
onPlayBonus,
|
||||
onRetry,
|
||||
onContinue,
|
||||
continueLabel,
|
||||
@@ -159,6 +167,33 @@ export function ResultSheet({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Extra games this lesson didn't need to pass - a treat, not a requirement, so
|
||||
they only appear once the lesson is already behind her. `.pill` is styled for
|
||||
the dark stage background, not this light sheet, so these get their own
|
||||
(smaller, quieter) version of the sheet's own button look instead. */}
|
||||
{bonusModes.length > 0 && (
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center", flexWrap: "wrap" }}>
|
||||
{bonusModes.map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => onPlayBonus(mode)}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
padding: "8px 16px",
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
cursor: "pointer",
|
||||
background: "oklch(90% 0.02 175)",
|
||||
color: "var(--ink)",
|
||||
}}
|
||||
>
|
||||
{MODE_INFO[mode].emoji} {MODE_INFO[mode].name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Dive mode - the core drill, and the run that counts for the unlock.
|
||||
*
|
||||
* A line of chunks with a moving cursor above the keyboard. Everything else in the
|
||||
* game is a variation on this; this is the one that is measured. */
|
||||
/** Dive mode - the plain drill: a line of chunks with a moving cursor above the
|
||||
* keyboard. Most fragments/words/sentences lessons play this by default, though feed
|
||||
* and race periodically take a turn as the required mode instead - any of them can
|
||||
* unlock the next lesson, since `recordRun` doesn't care which mode produced the run. */
|
||||
|
||||
import { currentChar } from "../../lib/engine";
|
||||
import type { RunResult } from "../../lib/grading";
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
/** Pearls mode - the mode with no clock.
|
||||
*
|
||||
* Every other mode measures something. This one deliberately does not show a timer, a
|
||||
* progress bar, a streak or a speed: an oyster opens, a word is inside it, and each
|
||||
* correct letter is a pearl on the string. A mistake costs a pearl, and that is the
|
||||
* only pressure there is.
|
||||
*
|
||||
* It exists because a six-year-old having a bad afternoon needs somewhere to go that is
|
||||
* still the same lesson and still counts, but cannot be failed at quickly. Every lesson
|
||||
* offers it, always. The run is still graded the same way underneath - a calm round and
|
||||
* a frantic one land in the same `grade()` - but nothing on screen is urging her on. */
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { currentChar } from "../../lib/engine";
|
||||
import type { RunEvent } from "../../lib/engine";
|
||||
import { chunkOffsets } from "../../lib/generator";
|
||||
import type { RunResult } from "../../lib/grading";
|
||||
import type { Progress } from "../../lib/progress";
|
||||
import { useRun } from "../../hooks/useRun";
|
||||
import { Keyboard } from "../Keyboard";
|
||||
|
||||
interface Props {
|
||||
chunks: readonly string[];
|
||||
text: string;
|
||||
spaceActive: boolean;
|
||||
activeKeys: readonly string[];
|
||||
progress: Progress;
|
||||
paused: boolean;
|
||||
onFinished: (result: RunResult) => void;
|
||||
}
|
||||
|
||||
export function PearlsRun({
|
||||
chunks,
|
||||
text,
|
||||
spaceActive,
|
||||
activeKeys,
|
||||
progress,
|
||||
paused,
|
||||
onFinished,
|
||||
}: Props) {
|
||||
const [pearls, setPearls] = useState(0);
|
||||
|
||||
const onEvent = (event: RunEvent) => {
|
||||
// A pearl per correct letter, one lost per mistake - but never below zero. Watching
|
||||
// the string shrink past empty is the kind of punishment this mode exists to avoid.
|
||||
if (event.type === "correct") setPearls((n) => n + 1);
|
||||
else if (event.type === "wrong" && event.firstAt) setPearls((n) => Math.max(0, n - 1));
|
||||
};
|
||||
|
||||
const { state, wrong } = useRun({
|
||||
target: text,
|
||||
sound: progress.settings.sound,
|
||||
paused,
|
||||
onFinished,
|
||||
onEvent,
|
||||
});
|
||||
|
||||
const next = currentChar(state);
|
||||
const offsets = chunkOffsets(chunks, spaceActive);
|
||||
|
||||
// Which word the oyster is holding right now.
|
||||
const currentChunkIndex = offsets.findIndex((start, i) => {
|
||||
const end = start + (chunks[i]?.length ?? 0);
|
||||
return state.index <= end;
|
||||
});
|
||||
const word = chunks[currentChunkIndex === -1 ? chunks.length - 1 : currentChunkIndex] ?? "";
|
||||
const wordStart = offsets[currentChunkIndex === -1 ? chunks.length - 1 : currentChunkIndex] ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="view-enter"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 22,
|
||||
padding: "0 32px",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{/* The pearl string. It only ever grows, one bead per letter. */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 5,
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
maxWidth: "70%",
|
||||
minHeight: 26,
|
||||
alignItems: "center",
|
||||
}}
|
||||
aria-label={`${pearls} Perlen`}
|
||||
>
|
||||
{Array.from({ length: pearls }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: "50%",
|
||||
background: "radial-gradient(circle at 32% 30%, oklch(99% 0.01 175), oklch(80% 0.04 300))",
|
||||
boxShadow: "0 2px 7px var(--shadow)",
|
||||
animation: i === pearls - 1 ? "correctPop 240ms ease-out" : undefined,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* The oyster, holding one word at a time. */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "26px 42px",
|
||||
borderRadius: 26,
|
||||
background: "linear-gradient(160deg, oklch(97% 0.01 175 / .2), oklch(97% 0.01 175 / .07))",
|
||||
border: "1px solid oklch(97% 0.01 175 / .22)",
|
||||
animation: wrong ? "wrongShake 260ms ease" : undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 34 }}>🦪</div>
|
||||
<div style={{ display: "flex", fontSize: 46, fontWeight: 800 }}>
|
||||
{[...word].map((char, i) => {
|
||||
const at = wordStart + i;
|
||||
const charState = at < state.index ? "done" : at === state.index ? "current" : "open";
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="target-char"
|
||||
data-state={charState}
|
||||
data-blank={char === " "}
|
||||
data-wrong={charState === "current" && wrong}
|
||||
>
|
||||
{char === " " ? (charState === "current" ? "␣" : "") : char}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Keyboard
|
||||
activeKeys={activeKeys}
|
||||
nextKey={next}
|
||||
progress={progress}
|
||||
mode={progress.settings.keyboardHint}
|
||||
size={38}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
422
tippen/src/data/curriculum.yaml
Normal file
422
tippen/src/data/curriculum.yaml
Normal file
@@ -0,0 +1,422 @@
|
||||
# The lesson plan, as data rather than code, so wording, word lists and pacing can be
|
||||
# tweaked here without touching curriculum.ts.
|
||||
#
|
||||
# Every lesson: title, subtitle (read aloud). Then either:
|
||||
# - keys: the letters-kind key(s) this round drills. The loader tracks which keys were
|
||||
# already active: the first time a key appears its lesson is "isolated" (heavy
|
||||
# weight, alone); the second (identical) appearance is "mixed" (lighter weight,
|
||||
# blended with everything learned so far). 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.
|
||||
# - kind + words - a fragments/words/sentences consolidation round (dive mode by
|
||||
# default; `mode:` overrides it, see curriculum.ts for which modes fit which kind).
|
||||
#
|
||||
# Letter order follows German letter frequency, adapted to a home-row-first, mirrored
|
||||
# pace for a six-year-old - unchanged from the original course.
|
||||
|
||||
worlds:
|
||||
- number: 1
|
||||
title: "Die Grundstellung"
|
||||
emoji: "🏝️"
|
||||
reward: clownfish
|
||||
lessons:
|
||||
- title: "F und J"
|
||||
subtitle: "Die Zeigefinger - die Tasten mit den Punkten"
|
||||
keys: [f, j]
|
||||
- title: "F und J üben"
|
||||
subtitle: "Die neuen Tasten festigen"
|
||||
keys: [f, j]
|
||||
- title: "D und K"
|
||||
subtitle: "Die Mittelfinger"
|
||||
keys: [d, k]
|
||||
- title: "D und K üben"
|
||||
subtitle: "Die neuen Tasten festigen"
|
||||
keys: [d, k]
|
||||
- title: "Übung: F J D K"
|
||||
subtitle: "Die vier Tasten zusammen"
|
||||
drill: true
|
||||
- title: "S und L"
|
||||
subtitle: "Die Ringfinger"
|
||||
keys: [s, l]
|
||||
- title: "S und L üben"
|
||||
subtitle: "Die neuen Tasten festigen"
|
||||
keys: [s, l]
|
||||
- title: "Übung: sechs Tasten"
|
||||
subtitle: "Alles bisher zusammen"
|
||||
drill: true
|
||||
- title: "A und Ö"
|
||||
subtitle: "Die kleinen Finger"
|
||||
keys: [a, ö]
|
||||
- title: "A und Ö üben"
|
||||
subtitle: "Die neuen Tasten festigen"
|
||||
keys: [a, ö]
|
||||
- title: "Übung: die Grundstellung"
|
||||
subtitle: "Alle acht Finger"
|
||||
drill: true
|
||||
- title: "Erste kleine Wörter"
|
||||
subtitle: "Echte Wörter mit acht Tasten"
|
||||
kind: fragments
|
||||
words: [da, ja, das, dass, als, all, fall, falls, lass, saal, kalk, salsa, jass]
|
||||
- title: "Die Leertaste"
|
||||
subtitle: "Der Daumen kommt dazu"
|
||||
keys: [" "]
|
||||
- title: "Übung: Grundstellung mit Leertaste"
|
||||
subtitle: "Jetzt mit dem Daumen"
|
||||
drill: true
|
||||
- title: "Wörter mit Leertaste"
|
||||
subtitle: "Kleine Wörter, kleine Sätze"
|
||||
kind: fragments
|
||||
words: ["lass das", "das da", "da ja", "fall da", "kalk da", "saal da", "ja lass das", "als da"]
|
||||
|
||||
- number: 2
|
||||
title: "Nach oben"
|
||||
emoji: "🌊"
|
||||
reward: octopus
|
||||
lessons:
|
||||
- title: "Das E"
|
||||
subtitle: "Mittelfinger links nach oben"
|
||||
keys: [e]
|
||||
- title: "E üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [e]
|
||||
- title: "Das I"
|
||||
subtitle: "Mittelfinger rechts nach oben"
|
||||
keys: [i]
|
||||
- title: "I üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [i]
|
||||
- title: "Übung: E und I"
|
||||
subtitle: "Beide Mittelfinger nach oben"
|
||||
drill: true
|
||||
- title: "Das R"
|
||||
subtitle: "Zeigefinger links nach oben"
|
||||
keys: [r]
|
||||
- title: "R üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [r]
|
||||
- title: "Das U"
|
||||
subtitle: "Zeigefinger rechts nach oben"
|
||||
keys: [u]
|
||||
- title: "U üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [u]
|
||||
- title: "Übung: R und U"
|
||||
subtitle: "Beide Zeigefinger nach oben"
|
||||
drill: true
|
||||
- title: "Kleine Wörter: E I R U"
|
||||
subtitle: "Erste echte Wörter mit der oberen Reihe"
|
||||
kind: fragments
|
||||
words: [die, sie, elf, eis, esel, see, keks, fiel, lied, rad, reis, eier, rufe, feuer, sauer, lauf]
|
||||
- title: "Das T"
|
||||
subtitle: "Zeigefinger links weit nach oben"
|
||||
keys: [t]
|
||||
- title: "T üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [t]
|
||||
- title: "Das Z"
|
||||
subtitle: "Zeigefinger rechts weit nach oben"
|
||||
keys: [z]
|
||||
- title: "Z üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [z]
|
||||
- title: "Übung: T und Z"
|
||||
subtitle: "Weit nach oben greifen"
|
||||
drill: true
|
||||
- title: "Das O"
|
||||
subtitle: "Ringfinger rechts nach oben"
|
||||
keys: [o]
|
||||
- title: "O üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [o]
|
||||
- title: "Das W"
|
||||
subtitle: "Ringfinger links nach oben"
|
||||
keys: [w]
|
||||
- title: "W üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [w]
|
||||
- title: "Übung: O und W"
|
||||
subtitle: "Beide Ringfinger nach oben"
|
||||
drill: true
|
||||
- title: "Kleine Wörter: T Z O W"
|
||||
subtitle: "Noch mehr echte Wörter"
|
||||
kind: fragments
|
||||
mode: feed
|
||||
words: [tier, tafel, kette, leiter, zeit, salz, zelt, katze, rot, tor, los, foto, wo, wald, zwei, wolke]
|
||||
- title: "Das P"
|
||||
subtitle: "Kleiner Finger rechts nach oben"
|
||||
keys: [p]
|
||||
- title: "P üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [p]
|
||||
- title: "Das Q"
|
||||
subtitle: "Kleiner Finger links nach oben"
|
||||
keys: [q]
|
||||
- title: "Q üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [q]
|
||||
- title: "Das Ü"
|
||||
subtitle: "Kleiner Finger rechts, ganz außen"
|
||||
keys: [ü]
|
||||
- title: "Ü üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [ü]
|
||||
- title: "Übung: P Q Ü"
|
||||
subtitle: "Die kleinen Finger nach oben"
|
||||
drill: true
|
||||
- title: "Übung: die obere Reihe"
|
||||
subtitle: "Die ganze Reihe zusammen"
|
||||
kind: words
|
||||
mode: feed
|
||||
words: [wolke, zeit, pause, prüfe, qualle, torte, reiter, würfel]
|
||||
- title: "Übung: Welt 2 komplett"
|
||||
subtitle: "Alles aus der oberen Reihe"
|
||||
drill: true
|
||||
|
||||
- number: 3
|
||||
title: "Nach unten"
|
||||
emoji: "🪸"
|
||||
reward: seahorse
|
||||
lessons:
|
||||
- title: "Das N"
|
||||
subtitle: "Zeigefinger rechts nach unten"
|
||||
keys: [n]
|
||||
- title: "N üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [n]
|
||||
- title: "Das M"
|
||||
subtitle: "Zeigefinger rechts, neben dem N"
|
||||
keys: [m]
|
||||
- title: "M üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [m]
|
||||
- title: "Übung: N und M"
|
||||
subtitle: "Die neuen Tasten festigen"
|
||||
drill: true
|
||||
- title: "Das G"
|
||||
subtitle: "Zeigefinger links, in der Mitte"
|
||||
keys: [g]
|
||||
- title: "G üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [g]
|
||||
- title: "Das H"
|
||||
subtitle: "Zeigefinger rechts, in der Mitte"
|
||||
keys: [h]
|
||||
- title: "H üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [h]
|
||||
- title: "Übung: G und H"
|
||||
subtitle: "Die Mitte der Grundreihe"
|
||||
drill: true
|
||||
- title: "Kleine Wörter: N M G H"
|
||||
subtitle: "Erste echte Wörter nach unten"
|
||||
kind: fragments
|
||||
words: [nase, nein, kind, wind, sonne, mama, mond, meer, maus, gut, gans, regen, hase, haus, hund, hupe]
|
||||
- title: "Das C"
|
||||
subtitle: "Mittelfinger links nach unten"
|
||||
keys: [c]
|
||||
- title: "C üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [c]
|
||||
- title: "Das V"
|
||||
subtitle: "Zeigefinger links nach unten"
|
||||
keys: [v]
|
||||
- title: "V üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [v]
|
||||
- title: "Übung: C und V"
|
||||
subtitle: "Nach unten greifen"
|
||||
drill: true
|
||||
- title: "Das B"
|
||||
subtitle: "Zeigefinger links, neben dem V"
|
||||
keys: [b]
|
||||
- title: "B üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [b]
|
||||
- title: "Das Y"
|
||||
subtitle: "Kleiner Finger links nach unten"
|
||||
keys: [y]
|
||||
- title: "Y üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [y]
|
||||
- title: "Übung: B und Y"
|
||||
subtitle: "Ganz unten links"
|
||||
drill: true
|
||||
- title: "Kleine Wörter: C V B Y"
|
||||
subtitle: "Noch mehr echte Wörter"
|
||||
kind: fragments
|
||||
words: [koch, milch, schule, chaos, vier, vase, voll, vater, baum, boot, bunt, brot, yoga, baby, typ, pony]
|
||||
- title: "Das X"
|
||||
subtitle: "Ringfinger links nach unten"
|
||||
keys: [x]
|
||||
- title: "X üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [x]
|
||||
- title: "Das Ä"
|
||||
subtitle: "Kleiner Finger rechts, ganz außen"
|
||||
keys: [ä]
|
||||
- title: "Ä üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [ä]
|
||||
- title: "Übung: X und Ä"
|
||||
subtitle: "Die letzten beiden Tasten"
|
||||
drill: true
|
||||
- title: "Übung: alle Buchstaben"
|
||||
subtitle: "Das ganze Alphabet"
|
||||
kind: words
|
||||
mode: race
|
||||
words: [delfin, wasser, xylofon, bäume, vogel, qualle, muschel, tauchen]
|
||||
- title: "Übung: Welt 3 komplett"
|
||||
subtitle: "Alles aus der unteren Reihe"
|
||||
drill: true
|
||||
|
||||
- number: 4
|
||||
title: "Große Buchstaben"
|
||||
emoji: "👑"
|
||||
reward: turtle
|
||||
lessons:
|
||||
- title: "Umschalttaste rechts, Teil 1"
|
||||
subtitle: "Große Buchstaben der linken Hand"
|
||||
keys: ["⇧"]
|
||||
kind: words
|
||||
words: [Delfin, Wal, Fisch, Baum]
|
||||
- title: "Umschalttaste rechts, Teil 2"
|
||||
subtitle: "Noch mehr große Buchstaben"
|
||||
kind: words
|
||||
words: [Garten, Ente, Vogel, Riff]
|
||||
- title: "Umschalttaste links, Teil 1"
|
||||
subtitle: "Große Buchstaben der rechten Hand"
|
||||
kind: words
|
||||
words: [Haus, Kind, Mond, Nase]
|
||||
- title: "Umschalttaste links, Teil 2"
|
||||
subtitle: "Noch mehr große Buchstaben"
|
||||
kind: words
|
||||
words: [Lampe, Onkel, Uhr, Puppe]
|
||||
- title: "Übung: Namen, Teil 1"
|
||||
subtitle: "Namen fangen groß an"
|
||||
kind: words
|
||||
words: [Anna, Lena, Paul, Mia, Emil, Jonas, Tom, Lisa]
|
||||
- title: "Übung: Namen, Teil 2"
|
||||
subtitle: "Noch mehr Namen"
|
||||
kind: words
|
||||
mode: feed
|
||||
words: [Ben, Nora, Finn, Ida, Max, Ella, Oskar, Greta]
|
||||
- title: "Übung: große und kleine"
|
||||
subtitle: "Beides gemischt"
|
||||
kind: words
|
||||
words: ["Das Meer", "Ein Delfin", "Die Sonne", "Mein Boot", "Der Wal", "Eine Muschel", "Ein Fisch", "Das Riff", "Mein Ball", "Die Welle", "Ein Stern", "Der Hai"]
|
||||
- title: "Übung: Welt 4 komplett"
|
||||
subtitle: "Groß und klein zusammen"
|
||||
kind: words
|
||||
drill: true
|
||||
words: [Delfin, Haus, Anna, Ben, "Das Meer", "Der Wal", Mond, Riff]
|
||||
|
||||
- number: 5
|
||||
title: "Ganze Sätze"
|
||||
emoji: "📖"
|
||||
reward: pearlmussel
|
||||
lessons:
|
||||
- title: "Der Punkt"
|
||||
subtitle: "Ringfinger rechts nach unten"
|
||||
keys: ["."]
|
||||
- title: "Punkt üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: ["."]
|
||||
- title: "Erste Sätze mit Punkt"
|
||||
subtitle: "Ein Satz, ein Punkt"
|
||||
kind: sentences
|
||||
words:
|
||||
- "Das Meer ist tief."
|
||||
- "Der Hund bellt."
|
||||
- "Ich mag Kekse."
|
||||
- "Die Sonne scheint."
|
||||
- "Der Wal ist riesig."
|
||||
- "Wir gehen baden."
|
||||
- "Mama liest ein Buch."
|
||||
- "Der Fisch schwimmt."
|
||||
- "Heute ist es warm."
|
||||
- "Ich habe einen Ball."
|
||||
- "Die Welle ist hoch."
|
||||
- "Papa kocht Suppe."
|
||||
- title: "Das Komma"
|
||||
subtitle: "Mittelfinger rechts nach unten"
|
||||
keys: [","]
|
||||
- title: "Komma üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: [","]
|
||||
- title: "Sätze mit Komma"
|
||||
subtitle: "Zwei Gedanken, ein Satz"
|
||||
kind: sentences
|
||||
mode: race
|
||||
words:
|
||||
- "Ich mag Wale, Delfine und Fische."
|
||||
- "Erst lesen, dann tippen."
|
||||
- "Es ist warm, also baden wir."
|
||||
- "Rot, gelb und blau sind Farben."
|
||||
- "Wenn es regnet, bleiben wir drinnen."
|
||||
- "Der Delfin springt, taucht und spielt."
|
||||
- "Morgen, sagt Papa, fahren wir los."
|
||||
- "Eins, zwei, drei, vier."
|
||||
- "Oma, Opa und ich gehen schwimmen."
|
||||
- "Die Sonne scheint, das Meer glitzert."
|
||||
- "Muscheln, Steine und Sand liegen am Strand."
|
||||
- title: "Der Bindestrich"
|
||||
subtitle: "Kleiner Finger rechts, ganz außen"
|
||||
keys: ["-"]
|
||||
- title: "Bindestrich üben"
|
||||
subtitle: "Die neue Taste festigen"
|
||||
keys: ["-"]
|
||||
- title: "Sätze mit Bindestrich"
|
||||
subtitle: "Zwei Wörter, ein Strich"
|
||||
kind: sentences
|
||||
words:
|
||||
- "Wir spielen mit dem Wasser-Ball."
|
||||
- "Das ist ein Delfin-Baby."
|
||||
- "Meine Ur-Oma kommt heute."
|
||||
- "Wir bauen eine Sand-Burg."
|
||||
- "Der Fisch-Schwarm ist riesig."
|
||||
- "Ich trage mein T-Shirt."
|
||||
- "Das Schwimm-Bad ist offen."
|
||||
- "Die Bade-Hose ist nass."
|
||||
- "Wir essen ein Eis-Hörnchen."
|
||||
- "Das Segel-Boot ist blau."
|
||||
- "Mein Lieblings-Tier ist der Delfin."
|
||||
- title: "Fragezeichen und Ausrufezeichen"
|
||||
subtitle: "Mit der Umschalttaste"
|
||||
keys: ["ß", "1"]
|
||||
- title: "Fragezeichen und Ausrufezeichen üben"
|
||||
subtitle: "Die neuen Tasten festigen"
|
||||
keys: ["ß", "1"]
|
||||
- title: "Fragen und Rufe"
|
||||
subtitle: "Wie klingt ein Satz?"
|
||||
kind: sentences
|
||||
words:
|
||||
- "Wo ist der Delfin?"
|
||||
- "Das war toll!"
|
||||
- "Wie geht es dir?"
|
||||
- "Pass auf!"
|
||||
- "Kommst du mit?"
|
||||
- "Der Wal ist so groß!"
|
||||
- "Hast du Hunger?"
|
||||
- "Hurra, Ferien!"
|
||||
- "Was schwimmt da?"
|
||||
- "Schau mal, ein Hai!"
|
||||
- "Wie tief ist das Meer?"
|
||||
- "Wir haben es geschafft!"
|
||||
- title: "Übung: ganze Sätze"
|
||||
subtitle: "Alles zusammen"
|
||||
kind: sentences
|
||||
drill: true
|
||||
mode: race
|
||||
words:
|
||||
- "Der Delfin schwimmt sehr schnell."
|
||||
- "Wo ist mein Boot?"
|
||||
- "Ich tippe jetzt mit zehn Fingern!"
|
||||
- "Das Meer ist blau, tief und kalt."
|
||||
- "Kannst du das auch?"
|
||||
- "Wir bauen eine Sand-Burg am Strand."
|
||||
- "Die Möwe fliegt über das Wasser."
|
||||
- "Oma, Opa und ich gehen schwimmen."
|
||||
- "Das ist ja super!"
|
||||
- "Wie heißt der große Wal?"
|
||||
- "Im Riff wohnen bunte Fische."
|
||||
- "Der Krake hat acht Arme."
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, nextLesson } from "../curriculum";
|
||||
import { ELIGIBLE_MODES, FIRST_LESSON_ID, LESSONS, WORLDS, lessonById, lessonsOfWorld, nextLesson } from "../curriculum";
|
||||
import { fingerOf, keyForChar, needsShift } from "../fingers";
|
||||
|
||||
describe("LESSONS", () => {
|
||||
@@ -125,15 +125,52 @@ describe("LESSONS", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("only offers word modes where words exist", () => {
|
||||
it("plays a mode that actually fits its kind", () => {
|
||||
for (const lesson of LESSONS) {
|
||||
const wordModes = lesson.modes.some((mode) => mode === "feed" || mode === "race");
|
||||
expect(wordModes).toBe(lesson.words.length > 0);
|
||||
expect(ELIGIBLE_MODES[lesson.kind], `${lesson.number}: ${lesson.title}`).toContain(lesson.primaryMode);
|
||||
for (const mode of lesson.bonusModes) {
|
||||
expect(ELIGIBLE_MODES[lesson.kind], `${lesson.number}: ${lesson.title}`).toContain(mode);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("always offers the pressure-free pearl-diving mode", () => {
|
||||
for (const lesson of LESSONS) expect(lesson.modes).toContain("pearls");
|
||||
it("never plays the same arcade game twice in a row", () => {
|
||||
const arcade = LESSONS.filter((lesson) => lesson.kind === "letters");
|
||||
for (let i = 1; i < arcade.length; i++) {
|
||||
expect(arcade[i]!.primaryMode, `${arcade[i]!.number}: ${arcade[i]!.title}`).not.toBe(arcade[i - 1]!.primaryMode);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers every eligible mode this lesson isn't gated on, as a bonus", () => {
|
||||
for (const lesson of LESSONS) {
|
||||
const expected = ELIGIBLE_MODES[lesson.kind].filter(
|
||||
(mode) => (mode === "feed" || mode === "race") && mode !== lesson.primaryMode,
|
||||
);
|
||||
expect([...lesson.bonusModes].sort()).toEqual([...expected].sort());
|
||||
}
|
||||
});
|
||||
|
||||
it("mixes the games instead of always diving - feed and race take a turn as the required mode too", () => {
|
||||
const words = LESSONS.filter((l) => l.kind === "words");
|
||||
const sentences = LESSONS.filter((l) => l.kind === "sentences");
|
||||
expect(words.some((l) => l.primaryMode === "feed")).toBe(true);
|
||||
expect(words.some((l) => l.primaryMode === "race")).toBe(true);
|
||||
expect(sentences.some((l) => l.primaryMode === "race")).toBe(true);
|
||||
});
|
||||
|
||||
it("distinguishes a mixed round from a drill - both have no new keys, only one is a review", () => {
|
||||
const mixed = LESSONS.filter((lesson) => lesson.emphasis === "mixed");
|
||||
expect(mixed.length).toBeGreaterThan(0);
|
||||
for (const lesson of mixed) {
|
||||
expect(lesson.newKeys).toEqual([]);
|
||||
expect(lesson.isDrill).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every isolated round something new to drill", () => {
|
||||
for (const lesson of LESSONS) {
|
||||
if (lesson.emphasis === "isolated") expect(lesson.newKeys.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("teaches capitals only once shift exists", () => {
|
||||
@@ -154,7 +191,7 @@ describe("LESSONS", () => {
|
||||
|
||||
it("follows every pair of new keys with a drill", () => {
|
||||
const drills = LESSONS.filter((lesson) => lesson.isDrill);
|
||||
expect(drills.length).toBeGreaterThanOrEqual(12);
|
||||
expect(drills.length).toBeGreaterThanOrEqual(15);
|
||||
// A drill never introduces anything, and always has something to practise.
|
||||
for (const lesson of drills) {
|
||||
expect(lesson.newKeys).toEqual([]);
|
||||
@@ -162,8 +199,8 @@ describe("LESSONS", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("is long enough to be a real course", () => {
|
||||
expect(LESSONS.length).toBeGreaterThanOrEqual(40);
|
||||
it("is long enough to be a real, Duolingo-length course", () => {
|
||||
expect(LESSONS.length).toBeGreaterThanOrEqual(80);
|
||||
expect(WORLDS.length).toBe(5);
|
||||
});
|
||||
|
||||
|
||||
44
tippen/src/lib/__tests__/lessonPath.test.ts
Normal file
44
tippen/src/lib/__tests__/lessonPath.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PATH_AMPLITUDE, pathD, pointFor, xOffsetFor } from "../lessonPath";
|
||||
|
||||
describe("xOffsetFor", () => {
|
||||
it("starts centred", () => {
|
||||
expect(xOffsetFor(0)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("never strays further than the amplitude", () => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
expect(Math.abs(xOffsetFor(i))).toBeLessThanOrEqual(PATH_AMPLITUDE + 1e-9);
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic", () => {
|
||||
expect(xOffsetFor(5)).toBe(xOffsetFor(5));
|
||||
});
|
||||
});
|
||||
|
||||
describe("pointFor", () => {
|
||||
it("places nodes strictly further down as the index grows", () => {
|
||||
let previousY = -Infinity;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const point = pointFor(i);
|
||||
expect(point.y).toBeGreaterThan(previousY);
|
||||
previousY = point.y;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("pathD", () => {
|
||||
it("draws nothing for fewer than two points", () => {
|
||||
expect(pathD([])).toBe("");
|
||||
expect(pathD([{ x: 0, y: 0 }])).toBe("");
|
||||
});
|
||||
|
||||
it("starts at the first point and mentions every point", () => {
|
||||
const points = [pointFor(0), pointFor(1), pointFor(2)];
|
||||
const d = pathD(points);
|
||||
expect(d.startsWith(`M ${points[0]!.x} ${points[0]!.y}`)).toBe(true);
|
||||
for (const point of points) expect(d).toContain(String(point.x));
|
||||
});
|
||||
});
|
||||
@@ -150,7 +150,7 @@ describe("migrate", () => {
|
||||
it("returns a fresh profile for anything unusable", () => {
|
||||
for (const raw of [null, undefined, 42, "nope", {}, { version: 99 }, []]) {
|
||||
const progress = migrate(raw);
|
||||
expect(progress.version).toBe(1);
|
||||
expect(progress.version).toBe(2);
|
||||
expect(progress.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
|
||||
}
|
||||
});
|
||||
@@ -177,6 +177,33 @@ describe("migrate", () => {
|
||||
broken.lessons[FIRST_LESSON_ID] = { ...broken.lessons[FIRST_LESSON_ID]!, unlocked: false };
|
||||
expect(migrate(broken).lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("restarts the lesson path from an old version, but keeps the aquarium, pearls and streak", () => {
|
||||
// Version 1's lesson ids don't correspond to today's much finer-grained curriculum,
|
||||
// so there is nothing sensible to remap them onto - she starts the path over, but
|
||||
// does not lose what she already earned.
|
||||
const old = {
|
||||
version: 1,
|
||||
lessons: { l09: { unlocked: true, runs: 3, bestStars: 3, bestAnimal: "orca", bestPoints: 999, ghost: null } },
|
||||
keyStats: { a: { ema: 400, attempts: 10, errors: 1 } },
|
||||
pearls: 42,
|
||||
aquarium: ["clownfish", "octopus"],
|
||||
streak: { days: 5, lastPlayed: "2024-01-01" },
|
||||
settings: { sound: false, keyboardHint: "off" },
|
||||
};
|
||||
const restored = migrate(JSON.parse(JSON.stringify(old)));
|
||||
expect(restored.version).toBe(2);
|
||||
// "l09" exists in the new curriculum too, just as a different lesson - its old
|
||||
// three-star, level-99 progress must not carry over onto whatever l09 means now.
|
||||
expect(restored.lessons["l09"]).toEqual({ unlocked: false, runs: 0, bestStars: 0, bestAnimal: null, bestPoints: 0, ghost: null });
|
||||
expect(restored.lessons[FIRST_LESSON_ID]!.unlocked).toBe(true);
|
||||
expect(Object.values(restored.lessons).filter((l) => l.unlocked)).toHaveLength(1);
|
||||
expect(restored.pearls).toBe(42);
|
||||
expect(restored.aquarium).toEqual(["clownfish", "octopus"]);
|
||||
expect(restored.keyStats["a"]?.attempts).toBe(10);
|
||||
expect(restored.streak).toEqual({ days: 5, lastPlayed: "2024-01-01" });
|
||||
expect(restored.settings).toEqual({ sound: false, keyboardHint: "off" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("focusKeyFor", () => {
|
||||
|
||||
@@ -1,41 +1,36 @@
|
||||
/** The lesson plan: 48 lessons in 5 worlds.
|
||||
/** The lesson plan: content lives in data/curriculum.yaml, this file only derives and
|
||||
* validates it.
|
||||
*
|
||||
* The shape follows what every serious ten-finger course does - start on the
|
||||
* home row `asdf jklö`, add keys ordered by German letter frequency (E, N, I, S,
|
||||
* R, A, T, D, H, U, L …), and drill everything learned so far each time. TIPP10's
|
||||
* German course is the same idea in 18 lessons.
|
||||
* The shape follows what every serious ten-finger course does - start on the home row
|
||||
* `asdf jklö`, add keys ordered by German letter frequency (E, N, I, S, R, A, T, D, H,
|
||||
* U, L …), and drill everything learned so far each time. TIPP10's German course is the
|
||||
* same idea in 18 lessons.
|
||||
*
|
||||
* Where this deviates, it deviates for the age, and the deviation is *pace*. A
|
||||
* six-year-old gets:
|
||||
* Where this deviates, it deviates for the age and for the Duolingo-style path this app
|
||||
* wants: every new key gets an **isolated** round (heavy weight, alone) before a
|
||||
* **mixed** round (lighter weight, blended with everything learned so far) - the same
|
||||
* new-key / mixed-key / real-content progression typing.com and typingstudy.com use,
|
||||
* just written as short rounds a six-year-old can climb one at a time. A drill lesson
|
||||
* follows every couple of keys, and periodically a **fragments** round turns the
|
||||
* newly-active keys into real short German words - the bridge between drilling letters
|
||||
* and typing whole words or sentences.
|
||||
*
|
||||
* - **one new key per lesson** from world 2 onward (world 1 pairs the same finger on
|
||||
* both hands, which is one motion, not two);
|
||||
* - **a drill lesson after every pair of new keys** - no new keys at all, just the
|
||||
* ones she has. Consolidation is where typing actually becomes automatic, and a
|
||||
* course that only ever moves forward never gives it room;
|
||||
* - **short rounds**, growing from twelve characters in world 1 to whole sentences in
|
||||
* world 5.
|
||||
*
|
||||
* That is 48 short lessons rather than 17 big ones. The curriculum is the same; the
|
||||
* steps between are small enough to climb.
|
||||
*
|
||||
* `activeKeys` is cumulative on purpose: lesson 30 still drills `f`, or the first
|
||||
* `activeKeys` is cumulative on purpose: lesson 80 still drills `f`, or the first
|
||||
* lessons rot while the last ones are learned. */
|
||||
|
||||
import { parse } from "yaml";
|
||||
|
||||
import curriculumYaml from "../data/curriculum.yaml?raw";
|
||||
import type { CreatureId } from "./aquarium";
|
||||
import { HOME_ROW, SPACE_KEY } from "./fingers";
|
||||
|
||||
export type ModeId =
|
||||
| "dive"
|
||||
| "bubbles"
|
||||
| "jellyfish"
|
||||
| "feed"
|
||||
| "race"
|
||||
| "pearls";
|
||||
/** What a lesson's targets are made of. `fragments` is short real syllables/mini-words -
|
||||
* the bridge between drilling isolated letters and typing whole words or sentences. */
|
||||
export type LessonKind = "letters" | "fragments" | "words" | "sentences";
|
||||
|
||||
/** What a lesson's targets are made of. Decides which modes make sense: jellyfish mode
|
||||
* cannot show a sentence, and feed mode cannot put one on a fish. */
|
||||
export type LessonKind = "letters" | "words" | "sentences";
|
||||
/** Which game a lesson can be played as. `pearls` is gone - every other mode measures
|
||||
* something, and that one deliberately didn't. */
|
||||
export type ModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
|
||||
|
||||
export interface Lesson {
|
||||
id: string;
|
||||
@@ -44,16 +39,31 @@ export interface Lesson {
|
||||
title: string;
|
||||
/** What this lesson is about, in words a six-year-old hears read aloud. */
|
||||
subtitle: string;
|
||||
/** The keys introduced here - what the generator weights toward. Empty for a drill. */
|
||||
kind: LessonKind;
|
||||
/** The keys that became active *this* lesson - empty for a mixed round or a drill.
|
||||
* Used to size how gently a round starts (see World 1's mirrored pairs) and to check
|
||||
* the course never introduces more than it should. */
|
||||
newKeys: readonly string[];
|
||||
/** The keys this round is about, whether or not they are new - what the generator
|
||||
* over-represents. Equal to `newKeys` for an isolated round, the same keys again
|
||||
* (already active) for the mixed round that follows it, empty for a drill. */
|
||||
spotlightKeys: readonly string[];
|
||||
/** Isolated: newly-active keys, drilled alone. Mixed: the same keys, blended with
|
||||
* everything else. `null` for a drill or for anything that isn't a letters round. */
|
||||
emphasis: "isolated" | "mixed" | null;
|
||||
/** Everything typable in this lesson, cumulative. */
|
||||
activeKeys: readonly string[];
|
||||
/** Which game modes this lesson offers, in carousel order. */
|
||||
modes: readonly ModeId[];
|
||||
/** Real German words (or sentences) for the word modes. */
|
||||
/** The one mode that gates progress this round - passing a run in this mode is what
|
||||
* can unlock the next lesson. Mostly `dive` for non-letters kinds, but `feed`/`race`
|
||||
* take a periodic turn so the games stay mixed, not just `dive` end to end. */
|
||||
primaryMode: ModeId;
|
||||
/** Extra replays, offered on the result sheet once this lesson is passed. Never gate
|
||||
* anything - they exist so a favourite game can be played again. */
|
||||
bonusModes: readonly ModeId[];
|
||||
/** Real German words, fragments or sentences for the non-letters kinds. */
|
||||
words: readonly string[];
|
||||
kind: LessonKind;
|
||||
/** True for a consolidation lesson - no new keys, just practice. */
|
||||
/** True for a pure review round - no fresh content, just practice. Independent of
|
||||
* `newKeys`: a mixed round also has no new keys but is not a drill. */
|
||||
isDrill: boolean;
|
||||
/** How long one run is. Grows with the curriculum - see `lengthFor`. */
|
||||
chunks: number;
|
||||
@@ -70,233 +80,168 @@ export interface World {
|
||||
|
||||
/** A world's creature is a pet that stays - a drawing that swims behind every screen
|
||||
* from then on. An animal in grading.ts is a speed trophy that changes, and is an
|
||||
* emoji. lib/aquarium.ts has why the two are kept in different visual languages. The
|
||||
* pets grow with the worlds: a small fish first, the pearl mussel - the pearls' own
|
||||
* home - last. */
|
||||
export const WORLDS: readonly World[] = [
|
||||
{ number: 1, title: "Die Grundstellung", emoji: "🏝️", reward: "clownfish" },
|
||||
{ number: 2, title: "Nach oben", emoji: "🌊", reward: "octopus" },
|
||||
{ number: 3, title: "Nach unten", emoji: "🪸", reward: "seahorse" },
|
||||
{ number: 4, title: "Große Buchstaben", emoji: "👑", reward: "turtle" },
|
||||
{ number: 5, title: "Ganze Sätze", emoji: "📖", reward: "pearlmussel" },
|
||||
];
|
||||
* emoji. lib/aquarium.ts has why the two are kept in different visual languages. */
|
||||
const CREATURE_IDS: readonly CreatureId[] = ["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"];
|
||||
|
||||
/** Letters only - no words can be spelled yet. */
|
||||
const LETTER_MODES: readonly ModeId[] = ["dive", "bubbles", "jellyfish", "pearls"];
|
||||
/** Real words exist: the word modes and the race join in. */
|
||||
const WORD_MODES: readonly ModeId[] = ["dive", "feed", "bubbles", "race", "jellyfish", "pearls"];
|
||||
/** Sentences do not fit on a bubble or a fish. */
|
||||
const SENTENCE_MODES: readonly ModeId[] = ["dive", "race", "pearls"];
|
||||
/** Which modes make sense for a kind. Letters rounds are single keys, so only the
|
||||
* arcade modes fit; fragments/words can be fed to a fish; only words and sentences are
|
||||
* long enough for a race. */
|
||||
export const ELIGIBLE_MODES: Record<LessonKind, readonly ModeId[]> = {
|
||||
letters: ["bubbles", "jellyfish"],
|
||||
fragments: ["dive", "feed"],
|
||||
words: ["dive", "feed", "race"],
|
||||
sentences: ["dive", "race"],
|
||||
};
|
||||
|
||||
/** Line length by world - a full block of text per round, at least five lines of it.
|
||||
*
|
||||
* Deliberately five times what the first version used. Twelve characters was long
|
||||
* enough to *finish*, which is what the first day needs, but far too short to build
|
||||
* anything: a round that ends before the hands settle measures reaction time rather
|
||||
* than typing, and the score bounces around so much that getting better is invisible.
|
||||
* Sixty-plus characters is long enough for a rhythm to appear and for the characters
|
||||
* per minute to mean something.
|
||||
*
|
||||
* World 1 is still the gentlest by a wide margin, and the rounds still grow from there. */
|
||||
/** Line length by world and kind - a full block of text per round. Letters rounds are
|
||||
* never rendered through `dive` any more (they are bubbles/jellyfish), but the length is
|
||||
* still computed for type uniformity and so the "still starts gentler than it ends"
|
||||
* shape is preserved if a letters round is ever asked for one. */
|
||||
function lengthFor(world: number, kind: LessonKind): { chunks: number; chunkSize: number } {
|
||||
if (world === 1) return kind === "words" ? { chunks: 25, chunkSize: 3 } : { chunks: 24, chunkSize: 3 }; // 72 characters
|
||||
if (kind === "fragments") return { chunks: 16, chunkSize: 4 }; // 64
|
||||
if (kind === "sentences") return { chunks: 10, chunkSize: 4 }; // ten whole sentences
|
||||
if (kind === "words") return { chunks: 25, chunkSize: 4 };
|
||||
// kind === "letters"
|
||||
if (world === 1) return { chunks: 24, chunkSize: 3 }; // 72 characters
|
||||
if (world === 2) return { chunks: 25, chunkSize: 4 }; // 100
|
||||
if (world === 3) return { chunks: 30, chunkSize: 4 }; // 120
|
||||
if (world === 4) return { chunks: 25, chunkSize: 4 }; // 25 words
|
||||
return { chunks: 10, chunkSize: 4 }; // world 5: ten whole sentences
|
||||
return { chunks: 30, chunkSize: 4 }; // 120, world 3 onward
|
||||
}
|
||||
|
||||
interface PlanEntry {
|
||||
world: number;
|
||||
interface YamlLesson {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
/** Empty marks a drill - consolidation, no new keys. */
|
||||
newKeys: readonly string[];
|
||||
words?: readonly string[];
|
||||
kind?: LessonKind;
|
||||
keys?: readonly string[];
|
||||
drill?: boolean;
|
||||
mode?: ModeId;
|
||||
words?: readonly string[];
|
||||
}
|
||||
|
||||
/** The plan. Everything else is derived from this, so a curriculum change is one edit. */
|
||||
const PLAN: readonly PlanEntry[] = [
|
||||
// ---------------------------------------------------------------- World 1 --
|
||||
// Two keys per lesson, always the same finger on each hand - one motion, mirrored.
|
||||
// F and J first because they carry the tactile bumps: they are the two keys a child
|
||||
// can find without looking, and every other key is taught as an offset from them.
|
||||
{ world: 1, title: "F und J", subtitle: "Die Zeigefinger - die Tasten mit den Punkten", newKeys: ["f", "j"] },
|
||||
{ world: 1, title: "D und K", subtitle: "Die Mittelfinger", newKeys: ["d", "k"] },
|
||||
{ world: 1, title: "Übung: F J D K", subtitle: "Die vier Tasten zusammen", newKeys: [] },
|
||||
{ world: 1, title: "S und L", subtitle: "Die Ringfinger", newKeys: ["s", "l"] },
|
||||
{ world: 1, title: "Übung: sechs Tasten", subtitle: "Alles bisher zusammen", newKeys: [] },
|
||||
{ world: 1, title: "A und Ö", subtitle: "Die kleinen Finger", newKeys: ["a", "ö"] },
|
||||
{ world: 1, title: "Übung: die Grundstellung", subtitle: "Alle acht Finger", newKeys: [],
|
||||
words: [
|
||||
"da", "das", "dass", "ja", "als", "all", "fall", "falls", "lass",
|
||||
"aal", "aas", "as", "ass", "fass", "saal", "kalk", "salsa", "jass",
|
||||
"asa", "sas", "sad", "sal", "dal", "fad", "fal", "jak", "jas", "kal",
|
||||
"kas", "lak", "las", "lad", "dasa", "sala", "kala", "jala", "fasa",
|
||||
"daka", "kasa", "salla", "dalla", "jassa", "fassa", "kalla", "falla",
|
||||
"salak", "dalas", "jasal", "kalas",
|
||||
"lö", "döl", "söl", "jöl", "köl", "löl",
|
||||
] },
|
||||
{ world: 1, title: "Die Leertaste", subtitle: "Der Daumen kommt dazu", newKeys: [SPACE_KEY] },
|
||||
interface YamlWorld {
|
||||
number: number;
|
||||
title: string;
|
||||
emoji: string;
|
||||
reward: CreatureId;
|
||||
lessons: readonly YamlLesson[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- World 2 --
|
||||
// One key per lesson from here on, an Übung after every two.
|
||||
{ world: 2, title: "Das E", subtitle: "Mittelfinger links nach oben", newKeys: ["e"],
|
||||
words: ["elf", "alle", "esel", "see", "keks", "fell"] },
|
||||
{ world: 2, title: "Das I", subtitle: "Mittelfinger rechts nach oben", newKeys: ["i"],
|
||||
words: ["die", "sie", "eis", "fiel", "lied", "leise", "diese", "seide"] },
|
||||
{ world: 2, title: "Übung: E und I", subtitle: "Die neuen Tasten festigen", newKeys: [],
|
||||
words: ["die", "eis", "elf", "leise", "diese", "esel", "keks", "fiel"] },
|
||||
{ world: 2, title: "Das R", subtitle: "Zeigefinger links nach oben", newKeys: ["r"],
|
||||
words: ["rad", "reis", "eier", "riese", "leider", "feier", "keller", "kerle"] },
|
||||
{ world: 2, title: "Das U", subtitle: "Zeigefinger rechts nach oben", newKeys: ["u"],
|
||||
words: ["rufe", "kurs", "lauf", "feuer", "sauer", "ruder", "saurier", "raus"] },
|
||||
{ world: 2, title: "Übung: R und U", subtitle: "Die Zeigefinger nach oben", newKeys: [],
|
||||
words: ["rufe", "reis", "feuer", "sauer", "eier", "lauf", "ruder", "leider"] },
|
||||
{ world: 2, title: "Das T", subtitle: "Zeigefinger links weit nach oben", newKeys: ["t"],
|
||||
words: ["tier", "tafel", "titel", "kette", "leiter", "reiter", "dritte", "alter"] },
|
||||
{ world: 2, title: "Das Z", subtitle: "Zeigefinger rechts weit nach oben", newKeys: ["z"],
|
||||
words: ["zeit", "salz", "zelt", "sitz", "katze", "kreuz", "zirkus", "zettel"] },
|
||||
{ world: 2, title: "Übung: T und Z", subtitle: "Weit nach oben greifen", newKeys: [],
|
||||
words: ["zeit", "tier", "salz", "katze", "leiter", "zelt", "reiter", "zirkus"] },
|
||||
{ world: 2, title: "Das O", subtitle: "Ringfinger rechts nach oben", newKeys: ["o"],
|
||||
words: ["rot", "tor", "los", "sofa", "foto", "oder", "torte", "koffer"] },
|
||||
{ world: 2, title: "Das W", subtitle: "Ringfinger links nach oben", newKeys: ["w"],
|
||||
words: ["wo", "wald", "weit", "zwei", "wolke", "wurst", "wasser", "wetter"] },
|
||||
{ world: 2, title: "Übung: W und O", subtitle: "Die Ringfinger nach oben", newKeys: [],
|
||||
words: ["wo", "wald", "torte", "wolke", "foto", "wasser", "zwei", "oder"] },
|
||||
{ world: 2, title: "Das P", subtitle: "Kleiner Finger rechts nach oben", newKeys: ["p"],
|
||||
words: ["pause", "post", "kopf", "apfel", "platz", "puppe", "papier", "palette"] },
|
||||
{ world: 2, title: "Das Q", subtitle: "Kleiner Finger links nach oben", newKeys: ["q"],
|
||||
words: ["quiz", "quark", "quelle", "qualle", "quader", "quitte"] },
|
||||
{ world: 2, title: "Das Ü", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["ü"],
|
||||
words: ["für", "tür", "tüte", "wüste", "küste", "prüfe", "würfel", "flüsse"] },
|
||||
{ world: 2, title: "Übung: die obere Reihe", subtitle: "Die ganze Reihe zusammen", newKeys: [],
|
||||
words: ["wolke", "zeit", "pause", "prüfe", "qualle", "torte", "reiter", "würfel"] },
|
||||
interface YamlRoot {
|
||||
worlds: readonly YamlWorld[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- World 3 --
|
||||
{ world: 3, title: "Das N", subtitle: "Zeigefinger rechts nach unten", newKeys: ["n"],
|
||||
words: ["nase", "nein", "nudel", "kind", "wind", "sonne", "kanne", "unten"] },
|
||||
{ world: 3, title: "Das M", subtitle: "Zeigefinger rechts, neben dem N", newKeys: ["m"],
|
||||
words: ["mama", "mond", "meer", "maus", "matte", "sommer", "moment", "tomate"] },
|
||||
{ world: 3, title: "Übung: N und M", subtitle: "Die neuen Tasten festigen", newKeys: [],
|
||||
words: ["mond", "nase", "meer", "sonne", "name", "maus", "moment", "kind"] },
|
||||
{ world: 3, title: "Das G", subtitle: "Zeigefinger links, in der Mitte", newKeys: ["g"],
|
||||
words: ["gut", "gans", "regen", "wagen", "tiger", "garten", "morgen", "gestern"] },
|
||||
{ world: 3, title: "Das H", subtitle: "Zeigefinger rechts, in der Mitte", newKeys: ["h"],
|
||||
words: ["hase", "haus", "hund", "hemd", "hupe", "sehen", "hunger", "höhle"] },
|
||||
{ world: 3, title: "Übung: G und H", subtitle: "Die Mitte der Grundreihe", newKeys: [],
|
||||
words: ["haus", "tiger", "hund", "garten", "hunger", "regen", "höhle", "morgen"] },
|
||||
{ world: 3, title: "Das C", subtitle: "Mittelfinger links nach unten", newKeys: ["c"],
|
||||
words: ["koch", "milch", "schaf", "schule", "sicher", "chaos", "clown", "cousin"] },
|
||||
{ world: 3, title: "Das V", subtitle: "Zeigefinger links nach unten", newKeys: ["v"],
|
||||
words: ["vier", "vase", "voll", "vogel", "vater", "video", "verein", "vulkan"] },
|
||||
{ world: 3, title: "Übung: C und V", subtitle: "Nach unten greifen", newKeys: [],
|
||||
words: ["vogel", "milch", "vater", "schule", "vier", "koch", "clown", "vulkan"] },
|
||||
{ world: 3, title: "Das B", subtitle: "Zeigefinger links, neben dem V", newKeys: ["b"],
|
||||
words: ["baum", "boot", "bunt", "bild", "brot", "bauch", "bagger", "arbeit"] },
|
||||
{ world: 3, title: "Das Y", subtitle: "Kleiner Finger links nach unten", newKeys: ["y"],
|
||||
words: ["yoga", "baby", "typ", "pony", "hobby", "yacht", "system"] },
|
||||
{ world: 3, title: "Übung: B und Y", subtitle: "Ganz unten links", newKeys: [],
|
||||
words: ["baby", "boot", "baum", "hobby", "brot", "pony", "bagger", "yoga"] },
|
||||
{ world: 3, title: "Das X", subtitle: "Ringfinger links nach unten", newKeys: ["x"],
|
||||
words: ["hexe", "taxi", "box", "text", "extra", "xylofon", "maximal"] },
|
||||
{ world: 3, title: "Das Ä", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["ä"],
|
||||
words: ["bär", "käse", "bäume", "gläser", "ärmel", "träume", "hände", "mädchen"] },
|
||||
{ world: 3, title: "Übung: alle Buchstaben", subtitle: "Das ganze Alphabet", newKeys: [],
|
||||
words: ["delfin", "wasser", "xylofon", "bäume", "vogel", "qualle", "muschel", "tauchen"] },
|
||||
/** Turns a YAML typo into a clear startup error instead of a silently wrong lesson. */
|
||||
function validatePlan(worlds: readonly YamlWorld[]): void {
|
||||
if (worlds.length !== CREATURE_IDS.length) {
|
||||
throw new Error(`curriculum.yaml: expected ${CREATURE_IDS.length} worlds, found ${worlds.length}`);
|
||||
}
|
||||
const rewards = new Set<CreatureId>();
|
||||
for (const world of worlds) {
|
||||
if (rewards.has(world.reward)) throw new Error(`curriculum.yaml: world ${world.number} reuses reward "${world.reward}"`);
|
||||
if (!CREATURE_IDS.includes(world.reward)) throw new Error(`curriculum.yaml: world ${world.number} has an unknown reward "${world.reward}"`);
|
||||
rewards.add(world.reward);
|
||||
|
||||
// ---------------------------------------------------------------- World 4 --
|
||||
{ world: 4, title: "Umschalttaste rechts", subtitle: "Große Buchstaben der linken Hand", newKeys: ["⇧"],
|
||||
words: ["Delfin", "Wal", "Fisch", "Baum", "Garten", "Ente", "Vogel", "Riff",
|
||||
"Sonne", "Auto", "Tiger", "Robbe", "Qualle", "Stern", "Wolke", "Ball"] },
|
||||
{ world: 4, title: "Umschalttaste links", subtitle: "Große Buchstaben der rechten Hand", newKeys: [],
|
||||
words: ["Haus", "Kind", "Mond", "Nase", "Lampe", "Onkel", "Uhr", "Puppe",
|
||||
"Hai", "Muschel", "Insel", "Opa", "Oma", "Pinguin", "Kuchen", "Zelt"] },
|
||||
{ world: 4, title: "Übung: Namen", subtitle: "Namen fangen groß an", newKeys: [],
|
||||
words: ["Anna", "Lena", "Paul", "Mia", "Emil", "Jonas", "Tom", "Lisa",
|
||||
"Ben", "Nora", "Finn", "Ida", "Max", "Ella", "Oskar", "Greta"] },
|
||||
{ world: 4, title: "Übung: große und kleine", subtitle: "Beides gemischt", newKeys: [],
|
||||
words: ["Das Meer", "Ein Delfin", "Die Sonne", "Mein Boot", "Der Wal", "Eine Muschel",
|
||||
"Ein Fisch", "Das Riff", "Mein Ball", "Die Welle", "Ein Stern", "Der Hai"] },
|
||||
world.lessons.forEach((entry, i) => {
|
||||
const where = `world ${world.number}, lesson ${i + 1} (${entry.title})`;
|
||||
const words = entry.words ?? [];
|
||||
const kind: LessonKind = entry.kind ?? "letters";
|
||||
if (words.length > 0 && entry.kind === undefined) {
|
||||
throw new Error(`${where}: has words but no explicit kind`);
|
||||
}
|
||||
if (kind === "letters" && words.length > 0) throw new Error(`${where}: kind "letters" cannot have words`);
|
||||
if (kind !== "letters" && words.length === 0) throw new Error(`${where}: kind "${kind}" needs a non-empty words list`);
|
||||
if (entry.drill && entry.keys?.length) throw new Error(`${where}: a drill cannot also introduce keys`);
|
||||
if ((entry.keys?.length ?? 0) > 2) throw new Error(`${where}: at most two keys per lesson`);
|
||||
if (entry.mode && !ELIGIBLE_MODES[kind].includes(entry.mode)) {
|
||||
throw new Error(`${where}: mode "${entry.mode}" does not fit kind "${kind}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- World 5 --
|
||||
{ world: 5, title: "Der Punkt", subtitle: "Ringfinger rechts nach unten", newKeys: ["."], kind: "sentences",
|
||||
words: [
|
||||
"Das Meer ist tief.", "Der Hund bellt.", "Ich mag Kekse.", "Die Sonne scheint.",
|
||||
"Der Wal ist riesig.", "Wir gehen baden.", "Mama liest ein Buch.", "Der Fisch schwimmt.",
|
||||
"Heute ist es warm.", "Ich habe einen Ball.", "Die Welle ist hoch.", "Papa kocht Suppe.",
|
||||
] },
|
||||
{ world: 5, title: "Das Komma", subtitle: "Mittelfinger rechts nach unten", newKeys: [","], kind: "sentences",
|
||||
words: [
|
||||
"Ich mag Wale, Delfine und Fische.", "Erst lesen, dann tippen.", "Es ist warm, also baden wir.",
|
||||
"Rot, gelb und blau sind Farben.", "Wenn es regnet, bleiben wir drinnen.",
|
||||
"Der Delfin springt, taucht und spielt.", "Morgen, sagt Papa, fahren wir los.",
|
||||
"Eins, zwei, drei, vier.", "Oma, Opa und ich gehen schwimmen.",
|
||||
"Die Sonne scheint, das Meer glitzert.", "Muscheln, Steine und Sand liegen am Strand.",
|
||||
] },
|
||||
{ world: 5, title: "Der Bindestrich", subtitle: "Kleiner Finger rechts, ganz außen", newKeys: ["-"], kind: "sentences",
|
||||
words: [
|
||||
"Wir spielen mit dem Wasser-Ball.", "Das ist ein Delfin-Baby.", "Meine Ur-Oma kommt heute.",
|
||||
"Wir bauen eine Sand-Burg.", "Der Fisch-Schwarm ist riesig.", "Ich trage mein T-Shirt.",
|
||||
"Das Schwimm-Bad ist offen.", "Die Bade-Hose ist nass.", "Wir essen ein Eis-Hörnchen.",
|
||||
"Das Segel-Boot ist blau.", "Mein Lieblings-Tier ist der Delfin.",
|
||||
] },
|
||||
{ world: 5, title: "Fragezeichen und Ausrufezeichen", subtitle: "Mit der Umschalttaste", newKeys: ["ß", "1"], kind: "sentences",
|
||||
words: [
|
||||
"Wo ist der Delfin?", "Das war toll!", "Wie geht es dir?", "Pass auf!",
|
||||
"Kommst du mit?", "Der Wal ist so groß!", "Hast du Hunger?", "Hurra, Ferien!",
|
||||
"Was schwimmt da?", "Schau mal, ein Hai!", "Wie tief ist das Meer?", "Wir haben es geschafft!",
|
||||
] },
|
||||
{ world: 5, title: "Übung: ganze Sätze", subtitle: "Alles zusammen", newKeys: [], kind: "sentences",
|
||||
words: [
|
||||
"Der Delfin schwimmt sehr schnell.", "Wo ist mein Boot?", "Ich tippe jetzt mit zehn Fingern!",
|
||||
"Das Meer ist blau, tief und kalt.", "Kannst du das auch?", "Wir bauen eine Sand-Burg am Strand.",
|
||||
"Die Möwe fliegt über das Wasser.", "Oma, Opa und ich gehen schwimmen.", "Das ist ja super!",
|
||||
"Wie heißt der große Wal?", "Im Riff wohnen bunte Fische.", "Der Krake hat acht Arme.",
|
||||
] },
|
||||
];
|
||||
function loadPlan(): readonly YamlWorld[] {
|
||||
const root = parse(curriculumYaml) as YamlRoot;
|
||||
validatePlan(root.worlds);
|
||||
return root.worlds;
|
||||
}
|
||||
|
||||
function buildLessons(): Lesson[] {
|
||||
function buildLessons(worlds: readonly YamlWorld[]): Lesson[] {
|
||||
const lessons: Lesson[] = [];
|
||||
const active = new Set<string>();
|
||||
const seenBefore = new Set<string>();
|
||||
// Letters rounds alternate bubbles/jellyfish across the *whole* course, not per world
|
||||
// or per pair - a fragments/words lesson in between does not reset the count, so the
|
||||
// arcade game never repeats twice in a row even across a consolidation gap.
|
||||
let lastArcade: "bubbles" | "jellyfish" = "jellyfish"; // so lesson 1 opens on bubbles
|
||||
|
||||
PLAN.forEach((entry, i) => {
|
||||
for (const key of entry.newKeys) active.add(key);
|
||||
for (const world of worlds) {
|
||||
for (const entry of world.lessons) {
|
||||
const keys = entry.keys ?? [];
|
||||
const newKeys = keys.filter((key) => !seenBefore.has(key));
|
||||
for (const key of keys) {
|
||||
seenBefore.add(key);
|
||||
active.add(key);
|
||||
}
|
||||
// The space-bar lesson is where the whole home row comes together, so it
|
||||
// activates every home key - a belt-and-braces guarantee that the four finger-pair
|
||||
// lessons before it really did cover all eight.
|
||||
if (entry.newKeys.includes(SPACE_KEY)) for (const key of HOME_ROW) active.add(key);
|
||||
// activates every home key - belt-and-braces confirmation that the four
|
||||
// finger-pair lessons before it really did cover all eight.
|
||||
if (keys.includes(SPACE_KEY)) for (const key of HOME_ROW) active.add(key);
|
||||
|
||||
// Shift is not a character the generator can emit - the capitals in the word list
|
||||
// are what teaches it - so it never enters activeKeys. The question-mark lesson
|
||||
// reaches its marks with Shift too, so ß and 1 are active as *keys* even though the
|
||||
// characters that appear are ? and !.
|
||||
// Shift is not a character the generator can emit - the capitals in the word
|
||||
// list are what teaches it - so it never enters activeKeys.
|
||||
const activeKeys = [...active].filter((key) => key !== "⇧").sort();
|
||||
const words = entry.words ?? [];
|
||||
const kind: LessonKind = entry.kind ?? (words.length > 0 ? "words" : "letters");
|
||||
const kind: LessonKind = entry.kind ?? "letters";
|
||||
const isDrill = entry.drill === true;
|
||||
|
||||
const emphasis: Lesson["emphasis"] =
|
||||
kind !== "letters" || isDrill ? null : newKeys.length > 0 ? "isolated" : "mixed";
|
||||
|
||||
let primaryMode: ModeId;
|
||||
if (kind === "letters") {
|
||||
primaryMode = entry.mode ?? (lastArcade === "bubbles" ? "jellyfish" : "bubbles");
|
||||
lastArcade = primaryMode as "bubbles" | "jellyfish";
|
||||
} else {
|
||||
primaryMode = entry.mode ?? "dive";
|
||||
}
|
||||
// Bonus replays are only ever feed/race - dive is the plain default, not a treat
|
||||
// worth offering separately, and bubbles/jellyfish already alternate on their own.
|
||||
const bonusModes = ELIGIBLE_MODES[kind].filter(
|
||||
(mode) => (mode === "feed" || mode === "race") && mode !== primaryMode,
|
||||
);
|
||||
|
||||
lessons.push({
|
||||
id: `l${String(i + 1).padStart(2, "0")}`,
|
||||
world: entry.world,
|
||||
number: i + 1,
|
||||
id: `l${String(lessons.length + 1).padStart(2, "0")}`,
|
||||
world: world.number,
|
||||
number: lessons.length + 1,
|
||||
title: entry.title,
|
||||
subtitle: entry.subtitle,
|
||||
newKeys: entry.newKeys,
|
||||
activeKeys,
|
||||
modes: kind === "sentences" ? SENTENCE_MODES : kind === "words" ? WORD_MODES : LETTER_MODES,
|
||||
words,
|
||||
kind,
|
||||
isDrill: entry.newKeys.length === 0,
|
||||
...lengthFor(entry.world, kind),
|
||||
});
|
||||
newKeys,
|
||||
spotlightKeys: kind === "letters" && !isDrill ? keys : [],
|
||||
emphasis,
|
||||
activeKeys,
|
||||
primaryMode,
|
||||
bonusModes,
|
||||
words,
|
||||
isDrill,
|
||||
...lengthFor(world.number, kind),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return lessons;
|
||||
}
|
||||
|
||||
export const LESSONS: readonly Lesson[] = buildLessons();
|
||||
const PLAN = loadPlan();
|
||||
|
||||
export const WORLDS: readonly World[] = PLAN.map((world) => ({
|
||||
number: world.number,
|
||||
title: world.title,
|
||||
emoji: world.emoji,
|
||||
reward: world.reward,
|
||||
}));
|
||||
|
||||
export const LESSONS: readonly Lesson[] = buildLessons(PLAN);
|
||||
|
||||
const BY_ID = new Map(LESSONS.map((lesson) => [lesson.id, lesson]));
|
||||
|
||||
|
||||
@@ -38,29 +38,33 @@ export interface LineOptions {
|
||||
chunkSize?: number;
|
||||
/** The key to over-represent, if any. */
|
||||
focusKey?: string | null;
|
||||
/** The keys this lesson introduces. They take the majority of the line; everything
|
||||
/** The keys this lesson spotlights. They take the majority of the line; everything
|
||||
* learned earlier keeps appearing as review. */
|
||||
newKeys?: readonly string[];
|
||||
/** How much of the line the spotlighted keys should take, in place of the default
|
||||
* (see `NEW_KEY_SHARE`). An isolated round wants this high; a mixed round - the same
|
||||
* keys again, but blended with everything else - wants it lower. */
|
||||
newKeyShare?: number;
|
||||
}
|
||||
|
||||
/** The most of a line one key may ever occupy. Above this it stops being practice and
|
||||
* starts being a stutter - and on a small key set it starves the other fingers. */
|
||||
const MAX_FOCUS_SHARE = 0.3;
|
||||
|
||||
/** How much of a line the lesson's *new* keys should take. The rest is review of
|
||||
* everything learned so far, which is what stops the early lessons rotting while the
|
||||
* late ones are learned.
|
||||
/** How much of a line the lesson's spotlighted keys should take by default. The rest is
|
||||
* review of everything learned so far, which is what stops the early lessons rotting
|
||||
* while the late ones are learned.
|
||||
*
|
||||
* Without this, lesson 2 ("the right hand") drew evenly from all eight home keys and
|
||||
* spent half the line on the left hand it had already taught - which is not what a
|
||||
* lesson called "the right hand" should drill. */
|
||||
const NEW_KEY_SHARE = 0.6;
|
||||
|
||||
/** Copies of each new key needed to reach `NEW_KEY_SHARE` of the pool, clamped so a
|
||||
* lesson with one new key and many old ones does not bury the review entirely. */
|
||||
function newKeyCopies(newCount: number, oldCount: number): number {
|
||||
/** Copies of each spotlighted key needed to reach `share` of the pool, clamped so a
|
||||
* lesson with one spotlighted key and many old ones does not bury the review entirely. */
|
||||
function newKeyCopies(newCount: number, oldCount: number, share: number): number {
|
||||
if (newCount === 0 || oldCount === 0) return 1;
|
||||
const exact = (NEW_KEY_SHARE * oldCount) / (newCount * (1 - NEW_KEY_SHARE));
|
||||
const exact = (share * oldCount) / (newCount * (1 - share));
|
||||
return Math.max(1, Math.min(6, Math.round(exact)));
|
||||
}
|
||||
|
||||
@@ -81,6 +85,7 @@ function weighted(
|
||||
activeKeys: readonly string[],
|
||||
focusKey: string | null | undefined,
|
||||
newKeys: readonly string[] = [],
|
||||
newKeyShare: number = NEW_KEY_SHARE,
|
||||
): string[] {
|
||||
const letters = activeKeys.filter((key) => key !== " ");
|
||||
if (letters.length === 0) return [];
|
||||
@@ -91,7 +96,7 @@ function weighted(
|
||||
|
||||
const pool = [...letters];
|
||||
if (newActive.length > 0 && oldActive.length > 0) {
|
||||
const copies = newKeyCopies(newActive.length, oldActive.length);
|
||||
const copies = newKeyCopies(newActive.length, oldActive.length, newKeyShare);
|
||||
for (const key of newActive) for (let i = 1; i < copies; i++) pool.push(key);
|
||||
}
|
||||
|
||||
@@ -137,8 +142,8 @@ export function drillChunks(
|
||||
rng: Rng,
|
||||
options: LineOptions = {},
|
||||
): string[] {
|
||||
const { chunks = 6, chunkSize = 4, focusKey = null, newKeys = [] } = options;
|
||||
const pool = weighted(activeKeys, focusKey, newKeys);
|
||||
const { chunks = 6, chunkSize = 4, focusKey = null, newKeys = [], newKeyShare } = options;
|
||||
const pool = weighted(activeKeys, focusKey, newKeys, newKeyShare);
|
||||
if (pool.length === 0) return [];
|
||||
|
||||
const letters = bagDraw(pool, chunks * chunkSize, rng);
|
||||
@@ -225,8 +230,9 @@ export function letterStream(
|
||||
count: number,
|
||||
focusKey?: string | null,
|
||||
newKeys: readonly string[] = [],
|
||||
newKeyShare?: number,
|
||||
): string[] {
|
||||
const pool = weighted(activeKeys, focusKey, newKeys);
|
||||
const pool = weighted(activeKeys, focusKey, newKeys, newKeyShare);
|
||||
if (pool.length === 0) return [];
|
||||
return bagDraw(pool, count, rng);
|
||||
}
|
||||
|
||||
45
tippen/src/lib/lessonPath.ts
Normal file
45
tippen/src/lib/lessonPath.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** The geometry of the zigzag lesson path - kept pure and out of the component so it can
|
||||
* be tested the way `aquarium.ts`'s swim physics are. */
|
||||
|
||||
/** How many nodes make one full left-to-right-to-left swing. */
|
||||
export const PATH_PERIOD = 6;
|
||||
|
||||
/** The furthest a node strays from the centre line, in px. */
|
||||
export const PATH_AMPLITUDE = 110;
|
||||
|
||||
/** Vertical spacing between two nodes, in px. */
|
||||
export const NODE_SPACING = 108;
|
||||
|
||||
/** Horizontal offset for the nth node of a world's path, centred on 0. A sine wave
|
||||
* rather than a zigzag of straight segments, so the path reads as one smooth ribbon
|
||||
* instead of a jagged staircase. */
|
||||
export function xOffsetFor(indexInWorld: number): number {
|
||||
return Math.sin((indexInWorld / PATH_PERIOD) * 2 * Math.PI) * PATH_AMPLITUDE;
|
||||
}
|
||||
|
||||
export interface PathPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** The centre of the nth node, for both its own placement and the connector line. */
|
||||
export function pointFor(indexInWorld: number): PathPoint {
|
||||
return { x: xOffsetFor(indexInWorld), y: indexInWorld * NODE_SPACING + NODE_SPACING / 2 };
|
||||
}
|
||||
|
||||
/** An SVG path string threading every node centre with a smooth curve - a straight
|
||||
* polyline through a sine wave looks faceted; a vertical Bezier through each segment
|
||||
* does not. Empty/one-point paths draw nothing, which is fine: a one-lesson world needs
|
||||
* no connector. */
|
||||
export function pathD(points: readonly PathPoint[]): string {
|
||||
if (points.length < 2) return "";
|
||||
const [first, ...rest] = points;
|
||||
let d = `M ${first!.x} ${first!.y}`;
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
const from = points[i]!;
|
||||
const to = rest[i]!;
|
||||
const midY = (from.y + to.y) / 2;
|
||||
d += ` C ${from.x} ${midY}, ${to.x} ${midY}, ${to.x} ${to.y}`;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
12
tippen/src/lib/modeInfo.ts
Normal file
12
tippen/src/lib/modeInfo.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/** Labels and emoji for each game, shared by the result sheet's bonus buttons and the
|
||||
* lesson map's mode badge. */
|
||||
|
||||
import type { ModeId } from "./curriculum";
|
||||
|
||||
export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = {
|
||||
dive: { emoji: "🤿", name: "Tauchgang" },
|
||||
bubbles: { emoji: "🫧", name: "Blasenplatzen" },
|
||||
jellyfish: { emoji: "🦑", name: "Quallenalarm" },
|
||||
feed: { emoji: "🐟", name: "Fütterungszeit" },
|
||||
race: { emoji: "🐬", name: "Delfinrennen" },
|
||||
};
|
||||
@@ -46,7 +46,7 @@ export interface Settings {
|
||||
}
|
||||
|
||||
export interface Progress {
|
||||
version: 1;
|
||||
version: 2;
|
||||
lessons: Record<string, LessonProgress>;
|
||||
keyStats: Record<string, KeyStat>;
|
||||
pearls: number;
|
||||
@@ -66,7 +66,7 @@ export function freshProgress(): Progress {
|
||||
lessons[lesson.id] = emptyLesson(lesson.id === FIRST_LESSON_ID);
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
lessons,
|
||||
keyStats: {},
|
||||
pearls: 0,
|
||||
@@ -76,18 +76,43 @@ export function freshProgress(): Progress {
|
||||
};
|
||||
}
|
||||
|
||||
/** Everything that survives a curriculum rebuild - the pets, the currency, the per-key
|
||||
* stats and the streak - pulled defensively off whatever shape was stored, old or new. */
|
||||
function carryForward(stored: Record<string, unknown>, fresh: Progress): Omit<Progress, "version" | "lessons"> {
|
||||
return {
|
||||
keyStats: typeof stored.keyStats === "object" && stored.keyStats !== null ? (stored.keyStats as Progress["keyStats"]) : {},
|
||||
pearls: typeof stored.pearls === "number" ? stored.pearls : 0,
|
||||
aquarium: Array.isArray(stored.aquarium) ? migrateAquarium(stored.aquarium) : [],
|
||||
streak:
|
||||
typeof stored.streak === "object" && stored.streak !== null
|
||||
? {
|
||||
days: (stored.streak as Progress["streak"]).days ?? 0,
|
||||
lastPlayed: (stored.streak as Progress["streak"]).lastPlayed ?? null,
|
||||
}
|
||||
: fresh.streak,
|
||||
settings: { ...fresh.settings, ...((stored.settings as Partial<Settings>) ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Bring any stored value up to the current shape. Anything unrecognisable is thrown
|
||||
* away rather than trusted - a half-valid Progress would crash the lesson map, and a
|
||||
* fresh one merely means starting over. */
|
||||
export function migrate(raw: unknown): Progress {
|
||||
const fresh = freshProgress();
|
||||
if (typeof raw !== "object" || raw === null) return fresh;
|
||||
const stored = raw as Partial<Progress>;
|
||||
if (stored.version !== 1) return fresh;
|
||||
const stored = raw as Record<string, unknown>;
|
||||
|
||||
// Version 1's lesson ids don't correspond to today's much finer-grained curriculum -
|
||||
// "l09" used to be "Das E" and might be anything now - so there is nothing sensible to
|
||||
// remap. She keeps her aquarium, pearls, key stats and streak, and starts the (longer,
|
||||
// gentler) path over from the first lesson.
|
||||
if (stored.version !== 2) {
|
||||
return { version: 2, lessons: fresh.lessons, ...carryForward(stored, fresh) };
|
||||
}
|
||||
|
||||
const lessons = { ...fresh.lessons };
|
||||
if (typeof stored.lessons === "object" && stored.lessons !== null) {
|
||||
for (const [id, value] of Object.entries(stored.lessons)) {
|
||||
for (const [id, value] of Object.entries(stored.lessons as Record<string, unknown>)) {
|
||||
// Lessons that no longer exist in the curriculum are dropped silently.
|
||||
if (!(id in lessons) || typeof value !== "object" || value === null) continue;
|
||||
lessons[id] = { ...emptyLesson(false), ...value };
|
||||
@@ -96,18 +121,7 @@ export function migrate(raw: unknown): Progress {
|
||||
// The first lesson is unlocked by definition; a save that says otherwise is wrong.
|
||||
lessons[FIRST_LESSON_ID] = { ...lessons[FIRST_LESSON_ID]!, unlocked: true };
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
lessons,
|
||||
keyStats: typeof stored.keyStats === "object" && stored.keyStats !== null ? stored.keyStats : {},
|
||||
pearls: typeof stored.pearls === "number" ? stored.pearls : 0,
|
||||
aquarium: Array.isArray(stored.aquarium) ? migrateAquarium(stored.aquarium) : [],
|
||||
streak:
|
||||
typeof stored.streak === "object" && stored.streak !== null
|
||||
? { days: stored.streak.days ?? 0, lastPlayed: stored.streak.lastPlayed ?? null }
|
||||
: fresh.streak,
|
||||
settings: { ...fresh.settings, ...(stored.settings ?? {}) },
|
||||
};
|
||||
return { version: 2, lessons, ...carryForward(stored, fresh) };
|
||||
}
|
||||
|
||||
/** Saves from before the pets were drawings hold emoji here. Those map onto the creature
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/AppHeader.tsx","./src/components/Aquarium.tsx","./src/components/AquariumTiere.tsx","./src/components/Bubbles.tsx","./src/components/HandHint.tsx","./src/components/HelpOverlay.tsx","./src/components/Keyboard.tsx","./src/components/LessonMap.tsx","./src/components/ModePicker.tsx","./src/components/ResultSheet.tsx","./src/components/Stage.tsx","./src/components/Target.tsx","./src/components/modes/BlasenRun.tsx","./src/components/modes/FuetternRun.tsx","./src/components/modes/PerlenRun.tsx","./src/components/modes/QuallenRun.tsx","./src/components/modes/RennenRun.tsx","./src/components/modes/TauchgangRun.tsx","./src/hooks/useRun.ts","./src/lib/aquarium.ts","./src/lib/curriculum.ts","./src/lib/engine.ts","./src/lib/fingers.ts","./src/lib/generator.ts","./src/lib/grading.ts","./src/lib/pop.ts","./src/lib/progress.ts","./src/lib/speech.ts","./src/lib/theme.ts","./src/lib/__tests__/aquarium.test.ts","./src/lib/__tests__/curriculum.test.ts","./src/lib/__tests__/engine.test.ts","./src/lib/__tests__/fingers.test.ts","./src/lib/__tests__/generator.test.ts","./src/lib/__tests__/grading.test.ts","./src/lib/__tests__/progress.test.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/AppHeader.tsx","./src/components/Aquarium.tsx","./src/components/AquariumCreatures.tsx","./src/components/Bubbles.tsx","./src/components/HelpOverlay.tsx","./src/components/Keyboard.tsx","./src/components/LessonMap.tsx","./src/components/ResultSheet.tsx","./src/components/Stage.tsx","./src/components/Target.tsx","./src/components/modes/BubblesRun.tsx","./src/components/modes/DiveRun.tsx","./src/components/modes/FeedRun.tsx","./src/components/modes/JellyfishRun.tsx","./src/components/modes/RaceRun.tsx","./src/hooks/useRun.ts","./src/lib/aquarium.ts","./src/lib/curriculum.ts","./src/lib/engine.ts","./src/lib/fingers.ts","./src/lib/generator.ts","./src/lib/grading.ts","./src/lib/lessonPath.ts","./src/lib/modeInfo.ts","./src/lib/pop.ts","./src/lib/progress.ts","./src/lib/theme.ts","./src/lib/__tests__/aquarium.test.ts","./src/lib/__tests__/curriculum.test.ts","./src/lib/__tests__/engine.test.ts","./src/lib/__tests__/fingers.test.ts","./src/lib/__tests__/generator.test.ts","./src/lib/__tests__/grading.test.ts","./src/lib/__tests__/lessonPath.test.ts","./src/lib/__tests__/progress.test.ts"],"version":"5.9.3"}
|
||||
24
web/eslint.config.js
Normal file
24
web/eslint.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import js from "@eslint/js";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config([
|
||||
{
|
||||
ignores: ["dist"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
reactHooks.configs["recommended-latest"],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
]);
|
||||
1501
web/package-lock.json
generated
1501
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "tsc -b --noEmit false --emitDeclarationOnly false && eslint . || tsc -b",
|
||||
"check": "tsc -b && eslint .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -15,10 +15,16 @@
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^15.15.0",
|
||||
"typescript": "^5.9.0",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^7.1.0",
|
||||
"vitest": "^3.2.0"
|
||||
},
|
||||
|
||||
@@ -154,6 +154,33 @@ export function App() {
|
||||
void (state.playing ? api.pause() : api.resume());
|
||||
}, [connection, library.albums, play, results.albums, state]);
|
||||
|
||||
const onMute = useCallback(
|
||||
() => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT),
|
||||
[setVolume, state],
|
||||
);
|
||||
|
||||
/** The one seek primitive: an optimistic position patch plus the backend call.
|
||||
* Shared by dragging the progress bar (an absolute target) and Shift+Arrow /
|
||||
* podcast skip (a target computed from the current position). */
|
||||
const seekTo = useCallback(
|
||||
(target: number) => {
|
||||
connection.optimistic({ position: target });
|
||||
void api.seek(target);
|
||||
},
|
||||
[connection],
|
||||
);
|
||||
|
||||
const skipPodcast = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
const target =
|
||||
direction > 0
|
||||
? Math.min(state?.duration ?? 0, (state?.position ?? 0) + PODCAST_SKIP_SECONDS)
|
||||
: Math.max(0, (state?.position ?? 0) - PODCAST_SKIP_SECONDS);
|
||||
seekTo(target);
|
||||
},
|
||||
[seekTo, state],
|
||||
);
|
||||
|
||||
/** The one place a keyboard action, a click or a tap all end up. */
|
||||
// Read-modify-write the whole mapping, like ParentPanel does for settings: the
|
||||
// backend takes a full replacement, not a per-slot patch.
|
||||
@@ -218,12 +245,7 @@ export function App() {
|
||||
case "next":
|
||||
playPop(260);
|
||||
if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
|
||||
const target = Math.min(
|
||||
state?.duration ?? 0,
|
||||
(state?.position ?? 0) + PODCAST_SKIP_SECONDS,
|
||||
);
|
||||
connection.optimistic({ position: target });
|
||||
void api.seek(target);
|
||||
skipPodcast(1);
|
||||
} else {
|
||||
connection.optimistic({ position: 0 });
|
||||
void api.next();
|
||||
@@ -232,9 +254,7 @@ export function App() {
|
||||
case "previous":
|
||||
playPop(260);
|
||||
if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
|
||||
const target = Math.max(0, (state?.position ?? 0) - PODCAST_SKIP_SECONDS);
|
||||
connection.optimistic({ position: target });
|
||||
void api.seek(target);
|
||||
skipPodcast(-1);
|
||||
} else {
|
||||
connection.optimistic({ position: 0 });
|
||||
void api.previous();
|
||||
@@ -249,8 +269,7 @@ export function App() {
|
||||
0,
|
||||
Math.min(state.duration, (state?.position ?? 0) + action.delta),
|
||||
);
|
||||
connection.optimistic({ position: target });
|
||||
void api.seek(target);
|
||||
seekTo(target);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -258,12 +277,23 @@ export function App() {
|
||||
playPop(action.freq);
|
||||
break;
|
||||
case "mute":
|
||||
setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
|
||||
onMute();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[assignCurrentTo, connection, currentAlbum, play, setVolume, state, toggle],
|
||||
[
|
||||
assignCurrentTo,
|
||||
connection,
|
||||
currentAlbum,
|
||||
onMute,
|
||||
play,
|
||||
seekTo,
|
||||
setVolume,
|
||||
skipPodcast,
|
||||
state,
|
||||
toggle,
|
||||
],
|
||||
);
|
||||
|
||||
// Held in a ref so the listener is installed once rather than on every state change.
|
||||
@@ -356,11 +386,8 @@ export function App() {
|
||||
};
|
||||
|
||||
const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index);
|
||||
const onSeek = (target: number) => {
|
||||
connection.optimistic({ position: target });
|
||||
void api.seek(target);
|
||||
};
|
||||
const onMute = () => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
|
||||
const onNext = useCallback(() => run([{ type: "next" }]), [run]);
|
||||
const onPrevious = useCallback(() => run([{ type: "previous" }]), [run]);
|
||||
|
||||
// The single top-left "back" button, shared by every level of the music page -
|
||||
// search, categories, play view - so it always means "one step out" no matter what
|
||||
@@ -425,9 +452,9 @@ export function App() {
|
||||
state={state}
|
||||
album={currentAlbum}
|
||||
onToggle={toggle}
|
||||
onNext={() => run([{ type: "next" }])}
|
||||
onPrevious={() => run([{ type: "previous" }])}
|
||||
onSeek={onSeek}
|
||||
onNext={onNext}
|
||||
onPrevious={onPrevious}
|
||||
onSeek={seekTo}
|
||||
onVolume={setVolume}
|
||||
onMute={onMute}
|
||||
onOpenAlbum={onOpenCurrentAlbum}
|
||||
@@ -514,9 +541,9 @@ export function App() {
|
||||
state={state}
|
||||
album={currentAlbum}
|
||||
onToggle={toggle}
|
||||
onNext={() => run([{ type: "next" }])}
|
||||
onPrevious={() => run([{ type: "previous" }])}
|
||||
onSeek={onSeek}
|
||||
onNext={onNext}
|
||||
onPrevious={onPrevious}
|
||||
onSeek={seekTo}
|
||||
onVolume={setVolume}
|
||||
onMute={onMute}
|
||||
onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))}
|
||||
|
||||
@@ -30,32 +30,34 @@ function post(path: string, body?: unknown): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** `null` means the room-control page isn't configured, not an error - unlike
|
||||
* `request()`, a 404 here is expected and shouldn't throw. */
|
||||
async function fetchHaConfig(): Promise<HaConfig | null> {
|
||||
const response = await fetch("/api/ha");
|
||||
/** GET, treating a 404 as an expected "not configured" rather than an error - unlike
|
||||
* `request()`, which throws on it. Anything else that isn't ok still throws. */
|
||||
async function fetchOrNullOn404<T>(path: string): Promise<T | null> {
|
||||
const response = await fetch(`/api${path}`);
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) throw new Error(`GET /ha failed: ${response.status}`);
|
||||
return (await response.json()) as HaConfig;
|
||||
if (!response.ok) throw new Error(`GET ${path} failed: ${response.status}`);
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
/** `null` means the IR remote isn't configured, not an error - same convention as
|
||||
* `fetchHaConfig`. */
|
||||
async function fetchLircConfig(): Promise<LircConfig | null> {
|
||||
const response = await fetch("/api/lirc");
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) throw new Error(`GET /lirc failed: ${response.status}`);
|
||||
return (await response.json()) as LircConfig;
|
||||
/** GET, treating *any* non-ok response as "nothing to show" rather than an error - for
|
||||
* callers that poll and would rather fall back quietly than crash the poll loop. */
|
||||
async function fetchOrNullOnError<T>(path: string): Promise<T | null> {
|
||||
const response = await fetch(`/api${path}`);
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
/** `null` means the room-control page isn't configured, not an error. */
|
||||
const fetchHaConfig = (): Promise<HaConfig | null> => fetchOrNullOn404<HaConfig>("/ha");
|
||||
|
||||
/** `null` means the IR remote isn't configured, not an error. */
|
||||
const fetchLircConfig = (): Promise<LircConfig | null> => fetchOrNullOn404<LircConfig>("/lirc");
|
||||
|
||||
/** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable
|
||||
* right now" (the backend answers the latter with a 502) - the room page treats a
|
||||
* device with no state the same way either way, rather than crashing on a poll. */
|
||||
async function fetchHaState(entityId: string): Promise<HaEntityState | null> {
|
||||
const response = await fetch(`/api/ha/states/${entityId}`);
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as HaEntityState;
|
||||
}
|
||||
const fetchHaState = (entityId: string): Promise<HaEntityState | null> =>
|
||||
fetchOrNullOnError<HaEntityState>(`/ha/states/${entityId}`);
|
||||
|
||||
async function fetchHaStates(entityIds: string[]): Promise<Record<string, HaEntityState>> {
|
||||
const results = await Promise.all(entityIds.map(fetchHaState));
|
||||
@@ -68,11 +70,8 @@ async function fetchHaStates(entityIds: string[]): Promise<Record<string, HaEnti
|
||||
|
||||
/** `null` means "not analyzed" (the backend's expected 404 for this), not an error -
|
||||
* the ambient background just falls back to its un-analyzed baseline for that track. */
|
||||
async function fetchTrackDetail(albumId: string, trackIndex: number): Promise<TrackDetail | null> {
|
||||
const response = await fetch(`/api/tracks/${albumId}/${trackIndex}/analysis`);
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as TrackDetail;
|
||||
}
|
||||
const fetchTrackDetail = (albumId: string, trackIndex: number): Promise<TrackDetail | null> =>
|
||||
fetchOrNullOnError<TrackDetail>(`/tracks/${albumId}/${trackIndex}/analysis`);
|
||||
|
||||
export const api = {
|
||||
library: () => request<{ albums: Album[] }>("/library").then((body) => body.albums),
|
||||
|
||||
@@ -234,7 +234,6 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
||||
const baseRef = useRef(ambienceBaseFor(album, trackAnalysis));
|
||||
useEffect(() => {
|
||||
baseRef.current = ambienceBaseFor(album, trackAnalysis);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [album, trackAnalysis]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* the whole page. */
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
||||
|
||||
import type { Album } from "../api/types";
|
||||
import {
|
||||
@@ -546,7 +547,7 @@ function ShelfRow({
|
||||
children,
|
||||
focusedIndex,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
/** Which tile in this row the keyboard is on, or `null` while some other row has
|
||||
* focus - keeps a Ctrl+h/l selection that's scrolled off the edge on screen, the
|
||||
* horizontal equivalent of the outer scroller's own keep-in-view effect above. */
|
||||
@@ -580,7 +581,7 @@ function ShelfRow({
|
||||
return () => element.removeEventListener("wheel", onWheel);
|
||||
}, []);
|
||||
|
||||
const onMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const onMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
const element = ref.current;
|
||||
if (event.button !== 0 || !element) return;
|
||||
event.preventDefault(); // no text-selection/ghost-drag while panning
|
||||
@@ -632,7 +633,7 @@ function CategoryTile({
|
||||
entry: Category;
|
||||
navIndex?: number;
|
||||
selected?: boolean;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const books = entry.albums.filter(isBook).length;
|
||||
const allBooks = books === entry.albums.length;
|
||||
@@ -728,7 +729,7 @@ function SectionTitle({
|
||||
children,
|
||||
centered,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
centered?: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -750,7 +751,7 @@ function SectionTitle({
|
||||
);
|
||||
}
|
||||
|
||||
function Muted({ children }: { children: React.ReactNode }) {
|
||||
function Muted({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: "oklch(88% 0.02 210 / .7)" }}>
|
||||
{children}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* taller than wide for an audiobook. Callers pick which axis is fixed, never the ratio.
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { coverUrl } from "../api/client";
|
||||
import type { Album } from "../api/types";
|
||||
import { aspectOf, coverBackground, isBook } from "../lib/covers";
|
||||
@@ -21,7 +23,7 @@ interface Props {
|
||||
fit?: "width" | "height";
|
||||
radius?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
style?: CSSProperties;
|
||||
/** Print the title over generated art. Off for thumbnails, where it would not fit. */
|
||||
label?: boolean;
|
||||
}
|
||||
@@ -29,7 +31,7 @@ interface Props {
|
||||
export function Cover({ album, size, fit = "width", radius, className, style, label }: Props) {
|
||||
const book = isBook(album);
|
||||
const defaultRadius = book ? "6px 18px 18px 6px" : "16px";
|
||||
const box: React.CSSProperties =
|
||||
const box: CSSProperties =
|
||||
fit === "height"
|
||||
? { height: "100%", width: "auto", aspectRatio: aspectOf(album) }
|
||||
: { width: "100%", aspectRatio: aspectOf(album) };
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Album, PlayerState } from "../api/types";
|
||||
import { usePlaybackClock } from "../hooks/usePlaybackClock";
|
||||
import { albumLine, isBook } from "../lib/covers";
|
||||
import { isBook, nowPlayingText } from "../lib/covers";
|
||||
import { clock, remainingInAlbum } from "../lib/format";
|
||||
import { groupOf } from "../lib/search";
|
||||
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
|
||||
@@ -41,6 +41,7 @@ export function PlayView({
|
||||
assignPending,
|
||||
}: Props) {
|
||||
const position = usePlaybackClock(state.position, state.playing);
|
||||
const now = nowPlayingText(state, album);
|
||||
const book = album ? isBook(album) : false;
|
||||
const podcast = album ? groupOf(album) === "podcasts" : false;
|
||||
const remaining = album
|
||||
@@ -138,7 +139,7 @@ export function PlayView({
|
||||
textWrap: "pretty",
|
||||
}}
|
||||
>
|
||||
{state.track_title ?? "Wähl ein Album!"}
|
||||
{now.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -148,7 +149,7 @@ export function PlayView({
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{album ? albumLine(album) : "Tippen oder klicken"}
|
||||
{now.subtitle}
|
||||
</div>
|
||||
{album && (
|
||||
<div
|
||||
@@ -159,7 +160,7 @@ export function PlayView({
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{book ? "Kapitel" : "Song"} {state.track_index + 1} von {state.track_count}
|
||||
{now.progress}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -169,7 +170,7 @@ export function PlayView({
|
||||
position={position}
|
||||
duration={state.duration}
|
||||
onSeek={onSeek}
|
||||
resetKey={`${state.album_id ?? ""}:${state.track_index}`}
|
||||
resetKey={now.resetKey}
|
||||
height={14}
|
||||
interactive
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Album, PlayerState } from "../api/types";
|
||||
import { usePlaybackClock } from "../hooks/usePlaybackClock";
|
||||
import { albumLine, isBook } from "../lib/covers";
|
||||
import { nowPlayingText } from "../lib/covers";
|
||||
import { SHOW_DECORATIVE_ANIMATIONS, SHOW_GLASS_BLUR } from "../lib/theme";
|
||||
import { Cover } from "./Cover";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
@@ -33,6 +33,7 @@ export function PlayerBar({
|
||||
onOpenPlayView,
|
||||
}: Props) {
|
||||
const position = usePlaybackClock(state.position, state.playing);
|
||||
const now = nowPlayingText(state, album);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -100,7 +101,7 @@ export function PlayerBar({
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{state.track_title ?? "Wähl ein Album!"}
|
||||
{now.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -112,7 +113,7 @@ export function PlayerBar({
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{album ? albumLine(album) : "Tippen oder klicken"}
|
||||
{now.subtitle}
|
||||
</div>
|
||||
{album && (
|
||||
<div
|
||||
@@ -123,8 +124,7 @@ export function PlayerBar({
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
{isBook(album) ? "Kapitel" : "Song"} {state.track_index + 1} von{" "}
|
||||
{state.track_count}
|
||||
{now.progress}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -134,7 +134,7 @@ export function PlayerBar({
|
||||
position={position}
|
||||
duration={state.duration}
|
||||
onSeek={onSeek}
|
||||
resetKey={`${state.album_id ?? ""}:${state.track_index}`}
|
||||
resetKey={now.resetKey}
|
||||
height={10}
|
||||
interactive
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** The progress bar, and the one thing the mockup could not do: scrubbing. */
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { PointerEvent } from "react";
|
||||
|
||||
interface Props {
|
||||
position: number;
|
||||
@@ -45,7 +46,7 @@ export function ProgressBar({
|
||||
const shown = dragging ?? position;
|
||||
const percent = duration > 0 ? Math.min(100, (shown / duration) * 100) : 0;
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!interactive || !duration) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDragging(positionAt(event.clientX));
|
||||
|
||||
@@ -29,6 +29,11 @@ export function useHomeAssistant(config: HaConfig | null): HomeAssistant {
|
||||
// when the patch landed resolves with pre-click data - without this, that stale
|
||||
// response clobbers the optimistic "on" back to "off" until the *next* poll catches
|
||||
// up, which is what made toggling feel laggy despite the optimistic update existing.
|
||||
//
|
||||
// `usePlayerState`'s `optimistic()` solves the same "local guess vs. eventual truth"
|
||||
// problem far more simply, by just patching the whole object - it can get away with
|
||||
// that because the backend pushes over a websocket rather than being polled, so
|
||||
// there's no in-flight request that can resolve late and stomp on a newer patch.
|
||||
const optimisticAt = useRef<Record<string, number>>({});
|
||||
|
||||
const entityIds = useMemo(
|
||||
|
||||
@@ -15,7 +15,12 @@ const RECONNECT_DELAY_MS = 1500;
|
||||
export interface Connection {
|
||||
state: PlayerState | null;
|
||||
online: boolean;
|
||||
/** Apply a change locally so a keypress feels instant; the next frame reconciles. */
|
||||
/** Apply a change locally so a keypress feels instant; the next frame reconciles.
|
||||
*
|
||||
* A plain whole-object patch is enough here because state only ever arrives pushed
|
||||
* over the websocket - there's no in-flight poll that could resolve late and stomp
|
||||
* on it. `useHomeAssistant`'s `optimistic()` solves the same problem for a *polled*
|
||||
* source, where that race is real, with a per-entity timestamp guard instead. */
|
||||
optimistic: (patch: Partial<PlayerState>) => void;
|
||||
}
|
||||
|
||||
@@ -34,13 +39,21 @@ export function usePlayerState(onLibraryChanged: () => void): Connection {
|
||||
|
||||
const connect = () => {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
socket = new WebSocket(`${protocol}//${location.host}/api/ws`);
|
||||
const ws = new WebSocket(`${protocol}//${location.host}/api/ws`);
|
||||
socket = ws;
|
||||
|
||||
socket.onopen = () => {
|
||||
setOnline(true);
|
||||
// The server sends a snapshot on connect, but a reconnect may have missed
|
||||
// changes in between, so ask for the truth as well.
|
||||
void api.state().then(setState).catch(() => undefined);
|
||||
// changes in between, so ask for the truth as well. Guarded against a slow
|
||||
// response landing after a *later* reconnect already replaced this socket -
|
||||
// every other fetch-on-mount hook in this codebase guards the same way.
|
||||
void api
|
||||
.state()
|
||||
.then((fetched) => {
|
||||
if (!closed && socket === ws) setState(fetched);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* artwork agree - and so do the LED strips, which run the same primary colour.
|
||||
*/
|
||||
|
||||
import type { Album, AlbumKind } from "../api/types";
|
||||
import type { Album, AlbumKind, PlayerState } from "../api/types";
|
||||
import { GROUP_HUE } from "./theme";
|
||||
|
||||
export const isBook = (album: Album): boolean => album.kind === "book";
|
||||
@@ -14,8 +14,8 @@ export const isBook = (album: Album): boolean => album.kind === "book";
|
||||
/** Shape is how you tell the two apart without reading anything: albums are square,
|
||||
* audiobooks are taller than wide, everywhere they appear - grid, list, group preview,
|
||||
* player bar, now-playing. Nothing else may set an aspect ratio on a cover. */
|
||||
export const ALBUM_ASPECT = 1;
|
||||
export const BOOK_ASPECT = 0.82;
|
||||
const ALBUM_ASPECT = 1;
|
||||
const BOOK_ASPECT = 0.82;
|
||||
|
||||
export const aspectOfKind = (kind: AlbumKind): number =>
|
||||
kind === "book" ? BOOK_ASPECT : ALBUM_ASPECT;
|
||||
@@ -29,7 +29,7 @@ const colours = (album: Album): [string, string, string] => [
|
||||
];
|
||||
|
||||
/** Diagonal two-tone stripes, the mockup's stand-in for a music cover. */
|
||||
export function stripes(album: Album, width: number): string {
|
||||
function stripes(album: Album, width: number): string {
|
||||
const [primary, secondary] = colours(album);
|
||||
return (
|
||||
`repeating-linear-gradient(135deg, ${primary} 0px, ${primary} ${width}px, ` +
|
||||
@@ -82,3 +82,24 @@ export const albumLine = (album: Album): string => {
|
||||
? `${prefix}${album.title} · ${album.artist}`
|
||||
: `${prefix}${album.title}`;
|
||||
};
|
||||
|
||||
export interface NowPlayingText {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
/** "Kapitel 3 von 8" or "Song 3 von 8" - only meaningful while an album is loaded. */
|
||||
progress: string;
|
||||
/** Changes whenever the track itself changes, so a progress bar can reset its drag
|
||||
* state instead of animating across two unrelated tracks. */
|
||||
resetKey: string;
|
||||
}
|
||||
|
||||
/** The title/subtitle/progress text shared by the player bar and the full-screen
|
||||
* now-playing view, so the two cannot drift apart on a copy change. */
|
||||
export function nowPlayingText(state: PlayerState, album: Album | null): NowPlayingText {
|
||||
return {
|
||||
title: state.track_title ?? "Wähl ein Album!",
|
||||
subtitle: album ? albumLine(album) : "Tippen oder klicken",
|
||||
progress: `${album && isBook(album) ? "Kapitel" : "Song"} ${state.track_index + 1} von ${state.track_count}`,
|
||||
resetKey: `${state.album_id ?? ""}:${state.track_index}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/** The browse screen's shelf/row look, in one place: per-group colors and identity,
|
||||
* and the feature toggles that have gone back and forth while this design was under
|
||||
* review. Flip a toggle here rather than hunting through BrowseView.tsx/covers.ts.
|
||||
/** Per-group colors and identity, and the feature toggles that have gone back and
|
||||
* forth while this design was under review - flip one here rather than hunting
|
||||
* through the components that read it. Most toggles are about the browse screen's
|
||||
* shelf/row look, but some (`SHOW_GLASS_BLUR`, `SHOW_AMBIENCE`,
|
||||
* `SHOW_DECORATIVE_ANIMATIONS`) reach further: the play view, the player bar, the
|
||||
* room page and `App.tsx` all read this module too, wherever they share the same
|
||||
* "cut this on weak hardware" knob.
|
||||
*
|
||||
* `SHOW_ROW_TITLES` and `SHOW_ROW_ICONS` both `true` reproduces the original shelf
|
||||
* header - an icon badge plus label button above each row's tiles. */
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { Group } from "./search";
|
||||
|
||||
// ---------------------------------------------------------------- toggles --
|
||||
@@ -70,7 +76,7 @@ export const GROUP_LABEL: Record<Group, string> = {
|
||||
|
||||
/** A shelf/row's frosted background and border, tinted with its group's hue - or,
|
||||
* with `SHOW_ROW_TINT` off, `{}` so `.glass-panel`'s own neutral CSS shows through. */
|
||||
export function glassTint(hue: number): React.CSSProperties {
|
||||
export function glassTint(hue: number): CSSProperties {
|
||||
if (!SHOW_ROW_TINT) return {};
|
||||
return {
|
||||
background: `linear-gradient(160deg, oklch(55% 0.1 ${hue} / .32), oklch(30% 0.06 ${hue} / .14))`,
|
||||
@@ -81,7 +87,7 @@ export function glassTint(hue: number): React.CSSProperties {
|
||||
/** One list row's background, tinted with its group's hue and brighter/more opaque
|
||||
* while it's the currently-playing row - or, with `SHOW_ROW_TINT` off, the same
|
||||
* neutral highlight the row used before tinting existed. */
|
||||
export function rowTint(hue: number, highlighted: boolean): React.CSSProperties {
|
||||
export function rowTint(hue: number, highlighted: boolean): CSSProperties {
|
||||
if (!SHOW_ROW_TINT) {
|
||||
return { background: `oklch(97% 0.01 210 / ${highlighted ? ".22" : ".10"})` };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user