diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 2867b8b..711be57 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -2,18 +2,52 @@ This file provides guidance to Claude Code when working in this repository. -# BoxHockey +# BoxHockey (PuckTastic) -Physics-driven tabletop box hockey against the CPU or a friend across the table (Expo/React Native, Skia-rendered). Store name BoxHockeyTastic. Part of the `@rific`/`@tastic`/InfiniteToken app ecosystem - depends on `@rific/auto-paper`, `@rific/feedback-press`, `@rific/focus-chain`, `@rific/splash-gate`, `@rific/toaster`, `@rific/updater`, `@tastic/sprites` (the `./gravity` subpath only, for the gravity-well particle-swirl visual - see `src/components/GravityWellMarker.tsx`), `@tastic/core`, `@tastic/hud`, `@tastic/input`, `@tastic/physics`, `@tastic/profile`, `@tastic/split-screen`. The gravity well's *force* math (`applyGravityWells`, bounded quadratic falloff) stays in `@tastic/physics` - only the cosmetic particle-swirl rendering moved to `@tastic/sprites`; the two packages compose at this app's own call sites, neither depends on the other. +**This repo is PuckTastic, a pack of puck games, since 2026-10-24: read "Pack structure" below first.** Most sections after it were written when the repo was Box Hockey alone and name its old paths (`src/app/game.tsx`, `src/app/loadout.tsx`, `src/components/...`); that section's "Where the old paths went" maps them. + +Physics-driven tabletop box hockey against the CPU or a friend across the table (Expo/React Native, Skia-rendered). Store name PuckTastic (BoxHockeyTastic until 2026-10-24). Part of the `@rific`/`@tastic`/InfiniteToken app ecosystem - depends on `@rific/auto-paper`, `@rific/feedback-press`, `@rific/focus-chain`, `@rific/splash-gate`, `@rific/toaster`, `@rific/updater`, `@tastic/sprites` (the `./gravity` subpath only, for the gravity-well particle-swirl visual - see `src/components/GravityWellMarker.tsx`), `@tastic/core`, `@tastic/hud`, `@tastic/input`, `@tastic/physics`, `@tastic/profile`, `@tastic/split-screen`. The gravity well's *force* math (`applyGravityWells`, bounded quadratic falloff) stays in `@tastic/physics` - only the cosmetic particle-swirl rendering moved to `@tastic/sprites`; the two packages compose at this app's own call sites, neither depends on the other. **Expo has changed.** Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code - don't rely on general Expo knowledge, this app is on SDK 57 specifically. +## Pack structure (PuckTastic, 2026-10-24) + +App Review flagged LightCycles, Snake and Solitaire as spam (too many similar apps), so Jay is folding the fleet into packs. PuckTastic is BoxHockey's own App Store record (`com.infinitetoken.BoxHockey`, BoxHockey 1.0 already released), grown into four games in Home's order: **Box Hockey** (the star), **Air Hockey** (from the AirHockey repo), **Paddles** (Pong with a puck) and **Breaker** (Pong's bricks, plus a round alone). Soccer may come later. All four ship in 2.0. The plan doc (decisions, the 3-day cut, what's left) is https://claude.ai/artifact/NDpksPYUmoJrJ7dRjs3Aet. + +**Layout.** The registry is meant to fit the later packs too (DuelTastic, CardTastic). +- `src/app/`: thin routes only. `index` (Home), `profiles`, `stats`, `[game]/loadout`, `[game]/play` (both with `generateStaticParams` over `games`, so the static web export has a page per game). +- `src/pack.ts`: the pack's identity (`name`, `wordmark`, `colorCaption`), `games` in Home's order, and the one store (`createPackStore`, keyPrefix `'pucktastic:'`). +- `src/shell/`: app-wide, imports no game. `RootLayout` (providers, gates, `GuideHost`, the stack), screens (`HomeScreen`, `LoadoutScreen`, `StatsScreen`, `ProfilesRoute`, `GameRoute`), `SettingsDialog` (app-wide settings only), `SeatTwoPanel`, `types.ts` (the `GameDefinition` contract), and the shell's store slices (`shellState.ts`). Moves into `@tastic` after 2.0. +- `src/shared/`: code two or more games use, imports no game. `gameStats.tsx` (`createGameStats`: one game's achievements slice, its profile-removal cleanup, and its context), `PlayerRankings`, `SeatResultCards`, `SeatToasters`, `useUnlockToasts`, `useGameSound`, `lazySkia` (every board's web host), `controlScheme`, and `paddleGame/` (Paddles' and Breaker's one engine). +- `src/games//`: each game, exported as one `GameDefinition` from its `index.ts`. +- **Every piece of code lives in one place. The shell and shared code never import a game, and a game never imports another game.** `eslint.config.cjs` enforces it with `no-restricted-imports` (a game may import itself). A game hands the shell what it needs through its `GameDefinition`: store reducers, a Provider (its stats context), `useLoadout(mode)` (its match options, seat controls and CPU difficulty for the shared loadout), `PlayScreen`, `StatsSections`/`AchievementList`, `resetStats`, its How to Play (`guide`), its `seatTwo` choices and `twoPlayerSeats`. + +**Saved data (decided now, for good).** The store is saved under `pucktastic:root` (keyPrefix `'pucktastic:'`). Each game keeps its own store keys, named for it: `Settings`/`Controls`/`Achievements`/`Guide` (`boxhockey`, `airhockey`, `paddles`, `breaker`); settings and controls are per player (see "Loadouts follow the player" below). Pack-wide: `appSettings` (Lock Orientation, its angle, Edge Guard), `opponent` (seat 2), `profileSelection` and `seatColors` (action namespace `'pack'`), theme, haptics, sound, Keep Awake and profiles; `liveplay` is never persisted. Stats and achievements stay separate per game. **A BoxHockey 1.0 save is imported once** (Jay, 2026-10-24: clean names, without dropping his earliest players). `src/importBoxHockey1.ts`, the persistConfig's `migrate` in `pack.ts`: on the first launch of a pack build (no `pucktastic:root` yet), a `boxhockey:root` save is copied over under the pack's names. `theme`, `haptic`, `sound`, `keepAwake`, `profiles`, `profileSelection` and `seatColors` keep theirs; `achievements` and `guide` become `boxhockeyAchievements` and `boxhockeyGuide`; 1.0's one `settings` becomes seat 1's player's own `boxhockeySettings` (the profile 1.0 had in seat 1, or seat 1's guest), minus the fields that are the pack's now (Lock Orientation, its angle and Edge Guard go to `appSettings`, the last mode picked on 1.0's Home to `opponent`, 2 Player as a person and anything else the CPU, and each seat's shot style to the player in that seat); and 1.0's `profileExtensions` (each profile's own shot style, which wins over the one it inherited from its seat) go to `boxhockeyControls`. Every later launch finds the pack save and does nothing. The 1.0 save stays in storage, unread from then on; an unreadable one starts fresh. **It's temporary:** delete the file and the `migrate` once nearly everyone is past 1.0, no earlier than six months after 2.0 ships and not before May 2027 (`TODO.md` has the steps); a 1.0 player who skips every update until then starts fresh. A `persist:root` save is never read, and pre-1.0 players are no longer grandfathered past Box Hockey's cards, so the older notes on `LEGACY_STORAGE_KEYS` and `createReturningPlayerMigrate` below no longer apply. Never change the prefix or a key now: every player would start over. Pinned by `src/__tests__/store.test.ts`. `store/seed/write-store.py` writes the same keys for the App Store screenshots. + +**Seat 1 and seat 2.** Seat 1 is picked on Home (Solitaire's player button) and starts every loadout. Seat 2 is one button in each loadout: the CPU at a difficulty, Player 2 (a guest), a profile, or Nobody (Breaker only, the same choice as Snake's Solo). It's one value for the whole pack (`opponentSlice`), kept forever; a fresh install starts on the CPU so one person (App Review included) can tap a game and play. A game without Nobody reads a saved Nobody as the CPU. The CPU's difficulty is per game, and seat 1's player's. + +**Loadouts follow the player** (Jay, 2026-10-24: "ALL the loadout settings should follow the player... the one exception is the player 2's settings, optional override color, and their chosen control scheme"). `src/shared/playerSettings.ts`'s `createPlayerSettings({ id, defaults, distinctControls? })` is every game's settings: `Settings` holds each player's own changes to the match settings (CPU difficulty and tilt steering included), loaded whenever that player is seat 1, over the game's `DEFAULT_SETTINGS` (so a field added later starts at its default); `Controls` holds each player's controls (Box Hockey's shot style, the desktop keys elsewhere), loaded in whichever seat they sit, seat 2's only for a person. A player is a profile id, or `guest:1`/`guest:2` (the two seats' guests are different people). A seat's color pick already follows its profile (`seatColors`). Where two seats can't share a control (the mouse and key clusters: Air Hockey, Paddles, Breaker), a saved clash moves seat 2 to the next free one for the match, unsaved. Deleting a profile forgets both. A game's How to Play reads the same through `select(state)`, returned flat (`control1`/`control2`) for `GuideHost`'s shallow compare. Pinned by `src/__tests__/playerSettings.test.tsx`. + +**Home** (Solitaire's list, Jay 2026-10-24): the wordmark, seat 1's button, then one row per game on a well (`@tastic/hud`'s `WELL_SHADOW` surface): its icon at app-icon size (56; the art was drawn as an app icon, not a big button), title, blurb, and a rules button that replays its How to Play. Each blurb first says what the game is, the classic it remakes, in Solitaire's style ("The classic wooden table game. Take turns shooting."), never mechanics only a Box Hockey player would know (Jay, 2026-10-24). Never name Pong or Breakout (Atari's marks): Paddles is "the arcade original", Breaker "the classic brick breaker". A tap shows the game's How to Play the first time, then opens its loadout. The icons are the source games' PNGs for now; SVG tiles in the seat colors come later. Paddles and Breaker both use Pong's icon until Jay draws Breaker's. + +**How to Play** is per game, every game's on `shell/redux/gameGuideSlice.ts`'s `createGameGuideSlice`, which reads a missing key as not seen (`@tastic/hud`'s `createGuideSlice`, which stamps an existing store as seen, is no longer used). `GuideHost`'s `useGameGuide()` gives `openFirstTime(gameId, next)` and `openFor(gameId)`. + +**Stats** (`StatsScreen`): Stats and Achievements tabs, a player filter (Everyone or one profile) and a game filter (All Games or one), like Solitaire's; Reset clears the games shown. Its press-away catcher sits inside `BaseStatsScreen`'s content, under the open filters (an opaque container hid one placed before it). + +**Paddles and Breaker** (`shared/paddleGame/`, from Pong): `createPaddleGame({ id, title, blurb, icon, hasBricks, guideVersion })` builds the whole `GameDefinition`: settings (`createPaddleSettings`), stats (`createPaddleGameStats`, one stats shape for both; Paddles' solo record and brick count stay 0 and are never shown), the loadout (`usePaddleLoadout`: paddle size, puck speed, score to win, tilt steering for one player on a phone), the play screen (`PaddlePlayScreen`) and the cards (`getGuideSteps`: Paddles 3, Breaker 4 with "Play Alone"). The ball is drawn as Air Hockey's puck (black disc, white ring, the whole puck pulsing while waiting); the physics still say ball. Paddles never has bricks; Breaker has Pong's shared middle field every versus round and Pong's Brickbreaker field with Nobody in seat 2 (3 lives). Copy says puck, never ball or Pong (tested). **Open for Jay:** Breaker's versus design, Pong's shared middle bricks (as built) or a wall per player. + +**Where the old paths went.** `src/app/game.tsx` is `src/games/boxhockey/PlayScreen.tsx`; `src/app/loadout.tsx` is the shared `shell/screens/LoadoutScreen.tsx` plus `games/boxhockey/useLoadout.tsx`; `src/app/achievements.tsx` is `shell/screens/StatsScreen.tsx` plus `games/boxhockey/StatsSections.tsx`; Box Hockey's own `src/components/*`, `src/constants/*`, `src/hooks/*`, `src/redux/*` (its settings, guide and profile-extension slices), `src/utils/*` and its own sounds are under `src/games/boxhockey/`; the app-wide ones (`Providers`, `Theme`, `Fonts`, `SettingsDialog`, `GuideHost`, `HeroTitle`, `useProfiles`, the liveplay, profile-selection and seat-color slices, navigation) are under `src/shell/`; the two-seat pieces (`SeatResultCards`, `SeatToasters`, `useUnlockToasts`, `useGameSound`) are under `src/shared/`. `src/constants/release.ts` stays put for the OTA tooling. + +**Identity.** `app.json`'s `expo.name` (the home-screen name) and `eas.json`'s `appName` are PuckTastic; the slug, scheme, bundle ids, EAS project and the web path `/boxhockey` stay BoxHockey's. The bundle ids can't change: Apple ties them to the App Store record, and a new one is a new app with a fresh review, which this whole plan avoids. `store.config.json` is PuckTastic's listing (138 achievements: Box Hockey 33, Air Hockey 33, Paddles 33, Breaker 39; recheck when a catalog changes); `eas metadata:lint` passes. The version is still 1.1.3: bump it to 2.0.0 at release. The name change (and `@tastic/profile` 0.17) need a new native build. + +**Checked on the web (2026-10-24):** every game's Home row, How to Play, loadout and a match start (Paddles vs CPU, Breaker alone and 2 Player), the seat-2 menu, the stats filters and their press-away, no console errors. **Not checked on a device yet**, and no full match was played to Game Over in the pack build. + ## Commands ```bash npm run lint # expo lint . npm run fix # expo lint . --fix -npm test # Jest (24 suites, 352 tests) +npm test # Jest (49 suites, 715 tests) npm run test:watch # Jest --watchAll npm run typecheck # tsc npm run verify # lint + test + typecheck @@ -142,7 +176,7 @@ A first-run, replayable "how to play" flow, built on `@tastic/hud/guide` (a ligh - **Leaving the app mid-match pauses it until the player resumes.** `game.tsx` calls `@tastic/core`'s `useBackgroundPause(() => phase !== 'gameOver' && !settingsOpen && !confirmVisible)` and folds the latch into `paused = settingsOpen || confirmVisible || backgroundPaused`, so everything a dialog halts (physics loop, scored/coin-flip/face-off/triple-tap timers, CPU chains, Edge Guard) halts the same way. Any AppState other than `'active'` counts, iOS `'inactive'` (Control Center, call banner) included; web gets it from react-native-web's visibility mapping. The latch survives the return: `MatchOverlays` (props `backgroundPaused`/`onResume`) shows a Paused `ConfirmDialog` with the Quit Match? dialog's own score (`progressMessage`) and rotation, Resume (contained) and Quit (outlined, `safeBack` directly). - `useFaceoff.ts`'s own AppState restart effect is gone: the pause-resume effect restarts an interrupted draw on Resume. GameScreen also calls `aimCancel()` when a background pause lands, because a CPU windup or intercept can be caught mid-chain (a dialog can't, see `cornerButtonsVisible`) and `useAIOpponent` can't resume one; the CPU replans from `'idle'` after Resume. -- **A seat can no longer point at a profile a sibling app deleted.** `useProfiles.tsx` dispatches `profileSelectionActions.pruneToRoster(ids)` right after both `profilesActions.setAll` calls (initial reconcile and `onRemoteChange`). `profileExtensions` (controlScheme) and stats are deliberately not pruned: a failed native read reports an empty roster, which would erase real players' data for good. +- **A seat can no longer point at a profile a sibling app deleted.** `useProfiles.tsx` dispatches `profileSelectionActions.pruneToRoster(ids)` right after both `profilesActions.setAll` calls (initial reconcile and `onRemoteChange`). Per-player settings and controls (`profileExtensions` back then) and stats are deliberately not pruned: a failed native read reports an empty roster, which would erase real players' data for good. - Needs `@tastic/core` 0.12.0 (`useBackgroundPause`) and a `@tastic/profile` release with `pruneToRoster`; until Jay releases them, both are installed from local tarballs with `npm install --no-save` and `package.json` is unchanged. - Tested in `backgroundPause.test.tsx` (the real `useBackgroundPause` driving `useGameState`/`useFaceoff`/`useAIOpponent` through a faked AppState) and `profiles.test.tsx` (the real `ProfilesProvider` and `@tastic/profile` sync over a faked `TasticProfile` native module). Not verified live on a device or in a browser. @@ -206,7 +240,7 @@ The web build is meant for https://games.infinitetoken.com/boxhockey/, beside th ## App Store (2026-10-18) -Jay put BoxHockey in the first iOS pass with Solitaire, Hangman, LightCycles and Snake. Store names for remakes of classics with generic names (this one, AirHockey, Pong, Snake, Solitaire, Hangman, LightCycles) are `Tastic`, dropping a trailing s from the name first: LightCycles is LightCycleTastic. Original games (RampantRats, OttosOrchard, HexFleet) keep their own name, with real spaces ("Rampant Rats") so each word is indexed for search (Jay, 2026-10-19). `eas.json`'s `submit.production.ios.appName` is `BoxHockeyTastic`; the home-screen name stays `expo.name` BoxHockey. +Jay put BoxHockey in the first iOS pass with Solitaire, Hangman, LightCycles and Snake. Store names for remakes of classics with generic names (this one, AirHockey, Pong, Snake, Solitaire, Hangman, LightCycles) are `Tastic`, dropping a trailing s from the name first: LightCycles is LightCycleTastic. Original games (RampantRats, OttosOrchard, HexFleet) keep their own name, with real spaces ("Rampant Rats") so each word is indexed for search (Jay, 2026-10-19). `eas.json`'s `submit.production.ios.appName` was `BoxHockeyTastic` and the home-screen name `expo.name` BoxHockey (superseded 2026-10-24: both are PuckTastic, see "Pack structure"). - **Listing:** `store.config.json` (EAS Metadata: title, subtitle "Tabletop Box Hockey. No Ads.", promo text, description, keywords, Games > Sports and Casual, all-None 4+ age rating, URLs on games.infinitetoken.com: `/boxhockey`, `/support`, `/privacy`), modeled on the other four games' files. `eas metadata:lint` passes. Its description counts 33 achievements and names the three tip-offs, the three boards and Rink: recheck it when any of those change. `app.json`'s and `package.json`'s `description` are Games-Web's sentence for the game. - **Everything else** (App Privacy, App Review notes, content rights, screenshots, how to redo them, the ship steps) is in `store/README.md`. diff --git a/README.md b/README.md index 6a00818..cab464b 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,44 @@ -# BoxHockey +# PuckTastic -Turn-based tabletop box hockey on one screen: flick the puck through the gaps and into the other player's goal, against the CPU or a friend across the table. +Four tabletop puck games in one app: Box Hockey, Air Hockey, Paddles and Breaker. Play a friend face to face on one device, or the CPU. -BoxHockey is a physics-driven box hockey game for iOS, Android, and web. Built with Expo, React Native, and React Native Skia, it's part of a family of games built on my open-source `@tastic` game-engine and `@rific` Expo utility packages. App Store and Google Play release in progress. +PuckTastic is a pack of physics-driven puck games for iOS, Android, and web, and the 2.0 of BoxHockey on the App Store. Built with Expo, React Native, and React Native Skia, it's part of a family of games built on my open-source `@tastic` game-engine and `@rific` Expo utility packages. -

BoxHockey gameplay: a face-off, then flick shots against the CPU

+

Box Hockey gameplay: a face-off, then flick shots against the CPU

+ +## The games + +- **Box Hockey:** the classic wooden table game. Players take turns shooting the puck through the gaps and into the other player's goal. + - Four ways to shoot, per player: Flick (power from swipe speed, the default), Pull (a slingshot: drag back, release to fire), Push (drag toward the shot), or Stick (swing your finger into the puck like a stick blade). + - Three tip-offs: Face-Off (a reaction race: both players ready up, a 3-2-1 countdown runs, then one of each player's two circles lights after a random hold and the first correct tap wins the knock), Coin Flip, and Triple Tap (iOS and Android only: both players tap their own half three times in step, then race to swipe; a second swipe within a quarter second knocks the puck off its line). + - Three boards: Classic (a center divider with two gaps), Gauntlet (three gapped walls) and Gravity (the divider plus a center attractor and four corner repellers). A Rink toggle adds rounded corners and a net pocket behind each goal. + - Match options: puck speed, puck size, score to win, a shot preview that can draw predicted bank shots, and a Slap Shot setting for how early a player can hit a puck that's still sliding. +- **Air Hockey:** the arcade table classic. Both players play at once: your paddle goes straight to your finger, stays in your own half, and a faster swing hits harder. The score shows as arcs lit in front of each goal. Options: a Rink toggle (rounded corners and a pocket behind each goal), puck speed, puck size, paddle size, and score to win. +- **Paddles:** the arcade original, played with a puck. Your paddle lines up with your finger, and the puck speeds up a little with every hit. Options: paddle size, puck speed, score to win, and tilt steering when one person plays on a phone. +- **Breaker:** the classic brick breaker. Against the CPU or a friend, a block of bricks sits between the paddles and breaks when hit. With Nobody in seat 2, it's you against a whole field of bricks: clear every one before your 3 lives run out. ## Features -- **Two modes:** One Player against a CPU opponent (Easy, Medium, Hard), or Two Player pass-and-play with both people on the same device. Players take turns shooting. After a goal, Coin Flip mode hands the puck to the player who conceded, in their own zone; Face-Off and Triple Tap restart from center with a new draw. -- **Three ways to shoot, per player:** Flick (power comes from swipe speed, the default), Pull (slingshot: drag back, release to fire), or Push (drag toward the shot). Each seat picks its own, and a saved profile remembers its choice. -- **Three tip-off modes:** Face-Off, a reaction race (both players ready up, a 3-2-1 countdown runs, then one of each player's two circles lights after a random hold and the first correct tap wins the knock); Coin Flip, a tap-to-flip animation for the opening possession; and Triple Tap (iOS and Android only), where each player taps their zone twice and then swipes, and the two swipes add together into the launch. -- **Board variants and modifiers:** Classic (a center divider with two gaps), Gauntlet (three gapped walls splitting the rink into four zones), and Gravity (the classic divider plus a center attractor and four corner repellers). A Rink toggle adds rounded corners and a net pocket behind each goal on any of the three. -- **Match options:** puck speed (friction), puck size (the goal and wall gaps scale with it), score to win (3, 5, 7, or 10), a shot preview (Off by default, or Short, Medium, or Long, where the longer lengths draw predicted bank shots), and a Slap Shot setting (Patient, Alert, or Aggressive) that sets how early the receiving player can hit a puck that's still sliding. The loadout also has randomize and reset buttons. -- **Player profiles:** saved names, colors, tags, and shot style per seat. On iOS the roster is shared with my other games through an App Group. -- **Stats and achievements:** per-profile and device-wide records (vs CPU by difficulty, two-player results by seat, wins per board variant, goals, shutouts, best win margin, win and day streaks, player rankings) and 33 bronze/silver/gold achievements. -- **How-to-play guide:** a short first-run guide, replayable from Settings, built from the live settings: the shot card describes each seat's actual control scheme and the tip-off card matches the selected mode. -- **Settings:** sound, haptics, appearance, an app-wide Lock Orientation setting, Edge Guard (an iOS option that makes system edge gestures need a second swipe during play), and Check for Updates. A match pauses automatically when the app goes to the background and stays Paused until a player taps Resume. +- **One app, one Home:** pick who's playing (seat 1), then a game from the list. Each game shows its How to Play cards the first time it's opened, and its rules button replays them. +- **Seat 2, kept across games:** each loadout's seat 2 button picks the CPU (Easy, Medium, or Hard, set per game), Player 2, a saved profile, or Nobody (Breaker only). It's saved for the whole pack, so the same two people stay set up from one game to the next. In 2 Player on a phone, the loadout and the match seat the two players face to face. +- **Player profiles:** saved names, colors, and tags. On iOS the roster is shared with my other games through an App Group. A profile's color pick for a seat is kept for this pack only. +- **Stats and achievements:** each game keeps its own stats and its own achievements (138 in all: Box Hockey 33, Air Hockey 33, Paddles 33, Breaker 39). One stats screen filters by player and by game, with per-profile and device-wide records, player rankings, and day streaks. Unlocks show as toasts; in 2 Player each player's toasts and Game Over card face them in their own half. +- **Desktop web:** in Air Hockey, Paddles and Breaker each seat plays with the mouse or a key cluster (WASD, the arrow keys, IJKL, or the numpad), picked in the loadout. Box Hockey shoots with the mouse and takes face-offs on the keys. +- **Settings:** sound, haptics, appearance, blur, Lock Orientation, Keep Awake, Edge Guard (an iOS option that makes system edge gestures need a second swipe during play), and Check for Updates. A match pauses when the app goes to the background and stays Paused until a player taps Resume. ## Under the hood -- **Physics:** a custom puck integrator (`src/utils/physics.ts`) built on `@tastic/physics` collision primitives (circle vs. rect, circle vs. circle, rounded corners, reflection, friction, gravity wells). Fast pucks are sub-stepped to avoid tunneling, a goal only counts once the puck's whole body has crossed the line, and goalposts are round so they deflect along a curve. With Rink on, the goal mouth is a one-way door: a puck can go in and around the pocket but can't come back out the front. A shot taken at a still-moving puck adds the puck's existing velocity to the shot. -- **AI opponent by simulation:** the CPU doesn't use an angle formula. It replays candidate shots through the same `stepPhysics` the game loop runs, over a grid of angles and power levels, and scores each result: a goal (fewer bounces and a straighter line rank higher), an own goal (discarded unless every candidate is one), or where the puck stops. Difficulty sets how wide it searches, whether it tries bank shots (Hard sweeps a full circle), how much it weighs defense, its aim and power noise, and its thinking time. It plays its turn through the same aim/shoot input path a human drag uses, so the stick and preview render the same way for both. -- **Face-off resolution (`src/utils/faceoff.ts`):** the winner's knock launches from center toward the loser's goal. Two inputs drive it separately: the margin between the two presses sets the angle jitter (a close race gives a scrappier, wider knock), and the winner's own reaction time sets the power. Jitter is capped well under 90° so the knock can't head back toward the winner's own net, and the loser always takes the next real turn. Each player also gets a blind 50/50 guess at which circle will light, worth a small reaction-time bonus. The CPU's reaction times follow real human reaction-time ranges, and on Easy and Medium it sometimes taps the wrong circle. -- **Turn-based reducer:** a single `useGameState` reducer (`src/hooks/useGameState.ts`) holds every phase (aiming, striking, simulating, scored, face-off, coin flip, triple tap, game over), driven by `@tastic/core`'s game loop. Random choices (which circle lights, the coin flip winner), timestamps, and the frame delta are all passed in by the caller, so the reducer itself stays deterministic and testable. -- **Board geometry (`src/utils/board.ts`):** the goal and wall gaps are sized from the current puck radius, with more margin on larger pucks, so every tier stays makeable. On native and mobile web the board fills the screen edge to edge, so bank shots bounce off the real screen edge; desktop web fixes the board to a real box hockey table's 3.3:8 ratio and draws perimeter walls. When the device is turned upside down the board flips 180° and touch input is mapped back into board coordinates, while the menus and loadout reflow for landscape. -- **Split-screen loadout:** in two-player the loadout uses `@tastic/split-screen` to lay out both players' panels facing each other or side by side, following the device's tilt. -- **Skia rendering across platforms:** the rink, puck, posts, score wedges around each crease, goal celebrations, gravity-well swirls, and the coin flip are drawn with React Native Skia and `@tastic/sprites`. `BoardHost.web.tsx` lazy-loads the board only after CanvasKit (WASM) is ready, while the native `BoardHost.tsx` is a passthrough. -- **State and updates:** Redux Toolkit with redux-persist covers settings, profiles, seat colors, per-profile shot style, stats, achievements, and guide state, with per-round state left out of persistence. A persist `migrate` recognizes players from the pre-Redux build so they skip the guide. Over-the-air updates ship through `expo-updates` and `@rific/updater`. -- **Tests:** 16 Jest suites with 298 tests covering physics and rink geometry, the AI planner, face-off knocks, the slap-shot intercept path, Triple Tap sequencing, a turn-alternation fuzz test, stats and achievements, settings and guide persistence, background pause, and profile syncing. +- **One pack, separate games:** `src/pack.ts` lists the games in Home's order and builds the one store. Each game in `src/games//` hands the app a single `GameDefinition` (its store slices, loadout options, play screen, stats sections, achievements, and How to Play), and the shared screens in `src/shell/` (Home, the loadout, stats, profiles) draw every game from those. A lint rule keeps the shell and shared code from importing any game, and one game from importing another, so a game can join or leave the pack on its own. +- **Saved data:** Redux Toolkit with redux-persist, one store with each game's settings, stats, achievements, and How to Play state under its own keys beside the pack-wide settings, seat 2, profiles, and seat colors. A game's settings are saved per player: the match settings load with whoever is seat 1, and each seat's controls with the player in it. Per-round state is left out of persistence. A BoxHockey 1.0 save is imported once, on the first launch of a PuckTastic build, under the pack's key names. Over-the-air updates ship through `expo-updates`. +- **Box Hockey physics (`src/games/boxhockey/utils/physics.ts`):** a custom puck integrator built on `@tastic/physics` collision primitives (circle vs. rect, circle vs. circle, rounded corners, reflection, friction, gravity wells). Fast pucks are sub-stepped to avoid tunneling, a goal only counts once the puck's whole body has crossed the line, and goalposts are round so they deflect along a curve. With Rink on, the goal mouth is a one-way door: a puck can go in and around the pocket but can't come back out the front. A shot taken at a still-moving puck adds the puck's existing velocity to the shot. +- **Box Hockey's CPU plans by simulation:** it doesn't use an angle formula. It replays candidate shots through the same `stepPhysics` the game loop runs, over a grid of angles and power levels, and scores each result: a goal (fewer bounces and a straighter line rank higher), an own goal (discarded unless every candidate is one), or where the puck stops. Difficulty sets how wide it searches, whether it tries bank shots, how much it weighs defense, its aim and power noise, and its thinking time. It plays its turn through the same aim-and-shoot input path a human drag uses. +- **Face-off resolution (`src/games/boxhockey/utils/faceoff.ts`):** the winner's knock launches from center toward the loser's goal. The margin between the two presses sets the angle jitter (a close race gives a scrappier, wider knock), and the winner's own reaction time sets the power. Jitter is capped well under 90° so the knock can't head back toward the winner's own net, and the loser always takes the next real turn. The CPU's reaction times follow real human reaction-time ranges. +- **Turn-based reducer:** Box Hockey's `useGameState` reducer holds every phase (aiming, striking, simulating, scored, face-off, coin flip, triple tap, game over), driven by `@tastic/core`'s game loop. Random choices, timestamps, and the frame delta are all passed in by the caller, so the reducer stays deterministic and testable. +- **Air Hockey (`src/games/airhockey/`):** both paddles and the puck are live at once. A paddle's velocity comes from how far it moved since the last tick and is added to the puck on contact, capped at a top paddle speed. The CPU watches and adjusts instead of planning: it predicts the puck's straight-line position a short time ahead, blurs its aim by difficulty, caps how fast its paddle can get there (which alone gives it believable reaction lag), and eases back toward its own goal when the puck isn't a threat. With Rink on, a puck that comes to rest in the pocket behind a goal is served again. +- **Paddles and Breaker (`src/shared/paddleGame/`):** one engine for both. The puck has no friction: its speed tier sets the serve speed and how much each paddle hit adds, and its speed is tracked as one number so many hits in a row can't drift. Bricks bounce the puck with a little random jitter, so a partly cleared field can't trap it in a loop, and they don't speed it up. The CPU tracks the puck the same watch-and-adjust way as Air Hockey's. +- **Board geometry:** each game's board is sized from the screen and its puck and paddle settings, so every size stays playable. On native and mobile web a board fills the screen edge to edge; desktop web gives each board a fixed table shape. The OS stays locked to portrait and the app draws its own rotation, following how the device is held. +- **Skia rendering across platforms:** the boards, pucks, paddles, bricks, score wedges, goal celebrations, gravity-well swirls, and the coin flip are drawn with React Native Skia and `@tastic/sprites`. On the web each board loads only once CanvasKit (WASM) is ready; on native it draws on the first frame. +- **Tests:** 48 Jest suites with 710 tests covering each game's physics and board geometry, the AI opponents, face-off knocks, the slap-shot and Stick paths, Triple Tap sequencing, a turn-alternation fuzz test, bricks and lives, stats and achievements for every game, the How to Play copy, the store and its migrations, background pause, and profile syncing. ## Built on @@ -36,20 +46,21 @@ BoxHockey is a physics-driven box hockey game for iOS, Android, and web. Built w | --- | --- | | [`@tastic/core`](https://github.com/jayrdeaton/React-Native-Game-Core) | Game loop, orientation and rotation handling, settled window sizing for the web canvas, background pause, config plugin for the native portrait lock | | [`@tastic/physics`](https://www.npmjs.com/package/@tastic/physics) | Circle/rect/rounded-corner collision, reflection, friction, gravity wells, goal-wedge geometry | -| [`@tastic/input`](https://www.npmjs.com/package/@tastic/input) | Drag-to-aim vector math | +| [`@tastic/input`](https://www.npmjs.com/package/@tastic/input) | Drag-to-aim vector math, desktop key schemes | | [`@tastic/split-screen`](https://www.npmjs.com/package/@tastic/split-screen) | Two-player loadout zone layout (face-to-face vs side-by-side) | | [`@tastic/hud`](https://www.npmjs.com/package/@tastic/hud) | Player setup panels, dropdowns, settings, confirm and update dialogs, stats screen sections, how-to-play guide, Skia web loader | | [`@tastic/sprites`](https://www.npmjs.com/package/@tastic/sprites) | Skia board shapes, hockey rink visuals (score wedges, goal-door flash, zone lines), gravity-well swirl | -| [`@tastic/profile`](https://www.npmjs.com/package/@tastic/profile) | Player profiles, picker, roster screen, per-profile extensions, shared App Group sync | -| [`@tastic/achievements`](https://www.npmjs.com/package/@tastic/achievements) | Tiered achievement catalog, streak tracking, Redux-backed persistence | +| [`@tastic/profile`](https://www.npmjs.com/package/@tastic/profile) | Player profiles, pickers, roster screen, per-profile extensions, shared App Group sync | +| [`@tastic/achievements`](https://www.npmjs.com/package/@tastic/achievements) | Tiered achievement catalogs, streak tracking, Redux-backed persistence | | [`@tastic/edge-guard`](https://www.npmjs.com/package/@tastic/edge-guard) | Optional guard against iOS edge system gestures during play | -| [`@rific/core`](https://www.npmjs.com/package/@rific/core) | Redux settings slice factory, safe back navigation | +| [`@rific/core`](https://www.npmjs.com/package/@rific/core) | Redux settings slice factory, safe back navigation, Keep Awake | | [`@rific/auto-paper`](https://www.npmjs.com/package/@rific/auto-paper) | React Native Paper theming from seed colors, light/dark appearance | | [`@rific/feedback-press`](https://www.npmjs.com/package/@rific/feedback-press) | Buttons with haptic and sound feedback, vibration, game sounds | | [`@rific/toaster`](https://www.npmjs.com/package/@rific/toaster) | Toast notifications (achievement unlocks, update errors) | | [`@rific/splash-gate`](https://www.npmjs.com/package/@rific/splash-gate) | Keeps the splash screen up until the theme, fonts, and profiles are ready | -| [`@rific/updater`](https://www.npmjs.com/package/@rific/updater) | OTA update checks and the `build`/`update` scripts | +| [`@rific/updater`](https://www.npmjs.com/package/@rific/updater) | OTA update checks | | [`@rific/focus-chain`](https://www.npmjs.com/package/@rific/focus-chain) | Peer dependency of `@tastic/profile` for form focus | +| [`@rific/gestures`](https://www.npmjs.com/package/@rific/gestures) | Peer dependency of `@tastic/profile` and `@rific/toaster` | ## Getting started @@ -66,21 +77,20 @@ The app uses native code that Expo Go doesn't include (the `@tastic/core` and `@ npm run verify # lint (expo lint) + Jest + typecheck (tsc) ``` -CI runs the same command on pushes and pull requests to `main`, and `npm run build` / `npm run update` (via `@rific/updater`) run it before building or publishing. +CI runs the same command on pushes and pull requests to `main`. `npm run build` and `npm run update` are plain `eas build` and `eas update` and don't run it, so run it first. `npm run build:web` runs it, then exports the web build. ## Project layout ``` src/ - app/ expo-router screens: home, loadout, game, profiles, achievements - components/ board rendering, gravity-well marker, loadout controls, dialogs, theme, guide host and art - constants/ game tuning, achievement catalog, guide steps, fonts, release info - hooks/ game state reducer and loop, AI opponent, face-off and Triple Tap timers, profiles, stats, settings, sound - redux/ redux-persist store and slices (settings, profiles, per-profile shot style, seat colors, achievements, guide, live play) + app/ expo-router routes: home, stats, profiles, and each game's loadout and play screens + pack.ts the pack's name, its games in Home's order, and the store + shell/ app-wide screens (home, loadout, stats, profiles), providers, settings, and store slices + shared/ code two or more games use: stats, result cards, unlock toasts, and the paddle-game engine + games/ one folder per game: boxhockey, airhockey, paddles, breaker + constants/ release info for over-the-air updates testUtils/ shared test helpers - types/ shared TypeScript types - utils/ physics, board geometry, AI, face-off knock, game mode helpers, stats engine and validation - __tests__/ Jest suites + __tests__/ Jest suites for the pack and Box Hockey (each other game keeps its own beside its code) __mocks__/ manual mocks for native and Expo modules ``` diff --git a/TODO.md b/TODO.md index a7df864..f116171 100644 --- a/TODO.md +++ b/TODO.md @@ -19,3 +19,14 @@ Deferred to ship SDK 58 the same day: - **Check it on a device.** Verify, typecheck and an iOS prebuild pass, but nothing ran on hardware: launch (SDK 58's scene life cycle), swiping a toast away (`@rific/toaster` 0.8.1 only had its gesture-handler 3 peer range raised, and its tests mock gesture-handler), the board's aim arrows and chevrons (moved to Skia 2.14's `PathBuilder`), and the edge-gesture guard. - **React Native 0.88 strict types.** `@infinitetoken/tsconfig` 0.6 resolves React Native 0.88's legacy types, and React Native drops them after 0.88. This app's own code is already clean under the strict types (checked 2026-10-16 with `customConditions: ["react-native"]` only); the errors it sees are all in package source it type-checks (`@tastic/hud` 15, `@tastic/split-screen` 6, `@rific/auto-paper` 3, `@tastic/profile` 3, `@tastic/core` 1, `@rific/updater` 1). Fix those packages first (their TODO.md files), then drop the deferral. - **Web tab title in the static HTML.** Since SDK 58, `expo-router/head`'s `` (in `src/components/Fonts.tsx`) only sets the title after hydration; the static export's HTML has none, so the tab shows the URL until the JS loads and crawlers see no title. Export `generateMetadata()` (returns `{ title }`) from each route, or from the root layout if Expo Router supports it there, then drop ``. + +## Later: drop the BoxHockey 1.0 import (not before May 2027) + +PuckTastic 2.0 copies a BoxHockey 1.0 save into its own store on its first launch (`src/importBoxHockey1.ts`; CLAUDE.md, "Pack structure" > "Saved data"). It only matters for a device that hasn't opened a 2.x build yet, so it can go once almost nobody is still on 1.0: **no earlier than six months after 2.0 is on the App Store, and not before May 2027.** Before deleting, check App Store Connect (App Analytics or Sales and Trends, by app version) that 1.x is down to nothing. Anyone still on 1.0 afterwards just starts fresh. + +To remove it: +1. Delete `src/importBoxHockey1.ts`. +2. In `src/pack.ts`, drop the `migrate:` line and the `AsyncStorage` and `createBoxHockey1Import` imports. +3. In `src/__tests__/store.test.ts`, delete the "a BoxHockey 1.0 save" tests. +4. In CLAUDE.md's "Saved data", replace the import paragraph with a line saying 1.0 saves are no longer read (and when it was removed). +5. Run `npm run verify`. diff --git a/app.json b/app.json index 94fdf61..bc1c585 100644 --- a/app.json +++ b/app.json @@ -1,10 +1,10 @@ { "expo": { - "name": "BoxHockey", + "name": "PuckTastic", "slug": "BoxHockey", "version": "1.1.3", "scheme": "boxhockey", - "description": "Physics-driven tabletop box hockey. Flick the puck through the gaps and into the other player's goal, against the CPU or a friend across the table.", + "description": "Four tabletop puck games in one app: Box Hockey, Air Hockey, Paddles and Breaker, against the CPU or a friend across the table.", "orientation": "portrait", "platforms": [ "ios", diff --git a/eas.json b/eas.json index ae6ca07..4a9ca91 100644 --- a/eas.json +++ b/eas.json @@ -33,7 +33,7 @@ "submit": { "production": { "ios": { - "appName": "BoxHockeyTastic" + "appName": "PuckTastic" } }, "preview": {} diff --git a/eslint.config.cjs b/eslint.config.cjs index ca3ca54..effa0d5 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -1 +1,27 @@ -module.exports = require('@infinitetoken/eslint-config/expo') +const fs = require('node:fs') +const path = require('node:path') + +const expo = require('@infinitetoken/eslint-config/expo') + +// The pack's layering (CLAUDE.md, "Pack structure"): the shell and shared code never import a game, +// and a game never imports another game, so any game can leave or join the pack on its own. +const games = fs + .readdirSync(path.join(path.dirname(require.resolve('./package.json')), 'src/games'), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + +const noGameImports = (allowed) => ({ + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@/games/*', ...(allowed ? [`!@/games/${allowed}`, `!@/games/${allowed}/**`] : [])], + message: allowed ? 'A game never imports another game. Move code two games share into src/shared.' : 'The shell and shared code never import a game. A game hands the shell what it needs through its GameDefinition (src/pack.ts).' + } + ] + } + ] +}) + +module.exports = [...expo, { files: ['src/shell/**', 'src/shared/**'], rules: noGameImports(null) }, ...games.map((id) => ({ files: [`src/games/${id}/**`], rules: noGameImports(id) }))] diff --git a/package-lock.json b/package-lock.json index 84704b5..588013f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,25 +25,25 @@ "@tastic/achievements": "^0.6.0", "@tastic/core": "^0.14.0", "@tastic/edge-guard": "^0.1.5", - "@tastic/hud": "^0.24.1", + "@tastic/hud": "^0.25.1", "@tastic/input": "^0.4.1", "@tastic/physics": "^0.3.1", - "@tastic/profile": "^0.16.2", - "@tastic/split-screen": "^0.5.4", + "@tastic/profile": "^0.17.0", + "@tastic/split-screen": "^0.5.5", "@tastic/sprites": "^0.2.1", "expo": "~58.0.16", "expo-asset": "~58.0.16", "expo-audio": "~58.0.9", "expo-blur": "~58.0.5", "expo-constants": "~58.0.12", - "expo-dev-client": "~58.0.13", + "expo-dev-client": "~58.0.14", "expo-font": "~58.0.8", - "expo-haptics": "~58.0.6", + "expo-haptics": "~58.0.7", "expo-keep-awake": "~58.0.3", "expo-linking": "~58.0.13", "expo-router": "~58.0.24", "expo-sensors": "~58.0.4", - "expo-splash-screen": "~58.0.9", + "expo-splash-screen": "~58.0.10", "expo-system-ui": "~58.0.6", "expo-updates": "~58.0.17", "react": "19.3.0", @@ -61,7 +61,7 @@ }, "devDependencies": { "@babel/core": "^7.29.7", - "@infinitetoken/eslint-config": "^0.2.0", + "@infinitetoken/eslint-config": "^0.2.1", "@infinitetoken/jest-config": "^0.5.2", "@infinitetoken/tsconfig": "^0.6.0", "@react-native/jest-preset": "^0.88.0", @@ -6320,9 +6320,9 @@ } }, "node_modules/@tastic/hud": { - "version": "0.24.1", - "resolved": "https://registry.npmjs.org/@tastic/hud/-/hud-0.24.1.tgz", - "integrity": "sha512-0KJDENzRyQVlcd/JKrdMG9OcvEnsX9SNxeKS3cJS+6MQ3AKe4NtE+fIY5MQiLhpBnHMgdNFC94/tVZTaTR96yA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@tastic/hud/-/hud-0.25.1.tgz", + "integrity": "sha512-VN4zm02y68/su+NqDYDXv1qmz9p07YlG+t50AWS9iS+zCEYFjFdbZyCEsGbYomkwp6/gfPK8ntfHmHZlESC5Jg==", "license": "MIT", "peerDependencies": { "@rific/auto-paper": "^0.13.0", @@ -6380,9 +6380,9 @@ } }, "node_modules/@tastic/profile": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@tastic/profile/-/profile-0.16.2.tgz", - "integrity": "sha512-RxTsz9jpoBgIypSs2EG+IuCEUy4B5V/xbIgFPRqu9fykDgydrrp1a2y3Z/Ls/DWSkJ5fdKqgphevmuIaYJZjgA==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@tastic/profile/-/profile-0.17.0.tgz", + "integrity": "sha512-3LxJX0bXglVFAp1Enp5OEJOnoku9amWh/wQ08hWdfKOS8caFBW/UhCvv1cqO1mQ/dym7VCVRX4JfUaTrtpq4Vw==", "license": "MIT", "peerDependencies": { "@rific/auto-paper": ">=0.13.0", @@ -6390,7 +6390,7 @@ "@rific/focus-chain": ">=0.4.0", "@rific/toaster": ">=0.4.0", "@tastic/core": ">=0.1.0", - "@tastic/hud": ">=0.23.0", + "@tastic/hud": ">=0.25.1", "expo-modules-core": ">=1.0.0", "react": ">=19.0.0", "react-native": ">=0.76.0", diff --git a/package.json b/package.json index 711bf4f..dc77683 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "box-hockey", "version": "1.1.3", "private": true, - "description": "Physics-driven tabletop box hockey. Flick the puck through the gaps and into the other player's goal, against the CPU or a friend across the table.", + "description": "Four tabletop puck games in one app: Box Hockey, Air Hockey, Paddles and Breaker, against the CPU or a friend across the table.", "type": "commonjs", "main": "expo-router/entry", "scripts": { @@ -45,25 +45,25 @@ "@tastic/achievements": "^0.6.0", "@tastic/core": "^0.14.0", "@tastic/edge-guard": "^0.1.5", - "@tastic/hud": "^0.24.1", + "@tastic/hud": "^0.25.1", "@tastic/input": "^0.4.1", "@tastic/physics": "^0.3.1", - "@tastic/profile": "^0.16.2", - "@tastic/split-screen": "^0.5.4", + "@tastic/profile": "^0.17.0", + "@tastic/split-screen": "^0.5.5", "@tastic/sprites": "^0.2.1", "expo": "~58.0.16", "expo-asset": "~58.0.16", "expo-audio": "~58.0.9", "expo-blur": "~58.0.5", "expo-constants": "~58.0.12", - "expo-dev-client": "~58.0.13", + "expo-dev-client": "~58.0.14", "expo-font": "~58.0.8", - "expo-haptics": "~58.0.6", + "expo-haptics": "~58.0.7", "expo-keep-awake": "~58.0.3", "expo-linking": "~58.0.13", "expo-router": "~58.0.24", "expo-sensors": "~58.0.4", - "expo-splash-screen": "~58.0.9", + "expo-splash-screen": "~58.0.10", "expo-system-ui": "~58.0.6", "expo-updates": "~58.0.17", "react": "19.3.0", @@ -81,7 +81,7 @@ }, "devDependencies": { "@babel/core": "^7.29.7", - "@infinitetoken/eslint-config": "^0.2.0", + "@infinitetoken/eslint-config": "^0.2.1", "@infinitetoken/jest-config": "^0.5.2", "@infinitetoken/tsconfig": "^0.6.0", "@react-native/jest-preset": "^0.88.0", diff --git a/src/__mocks__/@shopify/react-native-skia.ts b/src/__mocks__/@shopify/react-native-skia.ts new file mode 100644 index 0000000..cab2b0e --- /dev/null +++ b/src/__mocks__/@shopify/react-native-skia.ts @@ -0,0 +1,15 @@ +// Skia's native module can't initialize under Jest, and its own jestSetup needs CanvasKit's wasm in +// a custom test environment. Nothing in this app's suites draws a board: they only load the modules +// a board lives in (the pack's game registry imports every game's play screen). So every export is +// an inert stand-in: any property, call or `new` gives back another stand-in. +type Inert = ((...args: unknown[]) => Inert) & Record + +const inert: Inert = new Proxy(function () {} as unknown as Inert, { + get: (_target, prop) => (prop === 'then' ? undefined : prop === Symbol.toPrimitive ? () => 0 : inert), + apply: () => inert, + construct: () => inert +}) + +module.exports = new Proxy({ __esModule: true } as Record, { + get: (target, prop) => (prop in target ? target[prop] : inert) +}) diff --git a/src/__tests__/SeatResultCards.test.tsx b/src/__tests__/SeatResultCards.test.tsx index 1a6297e..923e484 100644 --- a/src/__tests__/SeatResultCards.test.tsx +++ b/src/__tests__/SeatResultCards.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen, within } from '@testing-library/react-native' -import { SeatResultCards } from '@/components/SeatResultCards' +import { SeatResultCards } from '@/shared/SeatResultCards' const COLORS = { 1: '#E53935', 2: '#1E88E5' } const ON_COLORS = { 1: '#FFFFFF', 2: '#FFFFFF' } diff --git a/src/__tests__/SeatToasters.test.tsx b/src/__tests__/SeatToasters.test.tsx index a1593b2..7dc64ca 100644 --- a/src/__tests__/SeatToasters.test.tsx +++ b/src/__tests__/SeatToasters.test.tsx @@ -2,7 +2,7 @@ import { render, screen, within } from '@testing-library/react-native' import { Dimensions, Text } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { SeatToasters } from '@/components/SeatToasters' +import { SeatToasters } from '@/shared/SeatToasters' // Each Toaster is drawn as its stack's name in the frame it hands renderFrame, followed by the safe-area insets the frame provides: which // toasts a stack shows, and how it lays them out against those insets, are @rific/toaster's to test. diff --git a/src/__tests__/achievements.test.ts b/src/__tests__/achievements.test.ts index fa02b2a..e669cff 100644 --- a/src/__tests__/achievements.test.ts +++ b/src/__tests__/achievements.test.ts @@ -1,7 +1,7 @@ -import { ACHIEVEMENT_CATALOG } from '@/constants/achievements' -import { StatsState } from '@/types' -import { evaluateUnlockedIds, evaluateUnlockedIdsForProfile, unlockedKey } from '@/utils/achievementEngine' -import { DEFAULT_PROFILE_STATS, DEFAULT_STATS } from '@/utils/statsValidation' +import { ACHIEVEMENT_CATALOG } from '@/games/boxhockey/constants/achievements' +import { StatsState } from '@/games/boxhockey/types' +import { evaluateUnlockedIds, evaluateUnlockedIdsForProfile, unlockedKey } from '@/games/boxhockey/utils/achievementEngine' +import { DEFAULT_PROFILE_STATS, DEFAULT_STATS } from '@/games/boxhockey/utils/statsValidation' function stats(overrides: Partial): StatsState { return { ...DEFAULT_STATS, ...overrides } diff --git a/src/__tests__/aiOpponent.test.ts b/src/__tests__/aiOpponent.test.ts index 38a0deb..861c945 100644 --- a/src/__tests__/aiOpponent.test.ts +++ b/src/__tests__/aiOpponent.test.ts @@ -1,6 +1,6 @@ -import { BoardLayout } from '@/types' -import { AIDifficulty, planAIShot, randomAIThinkDelayMs, randomInterceptReactionMs } from '@/utils/aiOpponent' -import { stepPhysics } from '@/utils/physics' +import { BoardLayout } from '@/games/boxhockey/types' +import { AIDifficulty, planAIShot, randomAIThinkDelayMs, randomInterceptReactionMs } from '@/games/boxhockey/utils/aiOpponent' +import { stepPhysics } from '@/games/boxhockey/utils/physics' // A standard open board with no obstacles/wells - mirrors the fixture in physics.test.ts. const openBoard: BoardLayout = { diff --git a/src/__tests__/backgroundPause.test.tsx b/src/__tests__/backgroundPause.test.tsx index 6104f07..3960e6c 100644 --- a/src/__tests__/backgroundPause.test.tsx +++ b/src/__tests__/backgroundPause.test.tsx @@ -3,12 +3,12 @@ import { act, renderHook } from '@testing-library/react-native' import { useEffect } from 'react' import { AppState } from 'react-native' -import { FACEOFF_COUNTDOWN_STEP_MS, FRICTION_LEVELS, INTERCEPT_SPEED_THRESHOLDS } from '@/constants/game' -import { useAIOpponent } from '@/hooks/useAIOpponent' -import { useFaceoff } from '@/hooks/useFaceoff' -import { useGameState } from '@/hooks/useGameState' -import { DEFAULT_SETTINGS } from '@/redux/settingsSlice' -import { BoardLayout, Player, TipoffMode } from '@/types' +import { FACEOFF_COUNTDOWN_STEP_MS, FRICTION_LEVELS, INTERCEPT_SPEED_THRESHOLDS } from '@/games/boxhockey/constants/game' +import { useAIOpponent } from '@/games/boxhockey/hooks/useAIOpponent' +import { useFaceoff } from '@/games/boxhockey/hooks/useFaceoff' +import { useGameState } from '@/games/boxhockey/hooks/useGameState' +import { DEFAULT_SETTINGS } from '@/games/boxhockey/redux/settingsSlice' +import { BoardLayout, Player, TipoffMode } from '@/games/boxhockey/types' import { emitAppState } from '../testUtils/appState' diff --git a/src/__tests__/boardGeometry.test.ts b/src/__tests__/boardGeometry.test.ts index c0a3637..4ce9e29 100644 --- a/src/__tests__/boardGeometry.test.ts +++ b/src/__tests__/boardGeometry.test.ts @@ -1,7 +1,7 @@ -import { FRICTION_LEVELS, GOAL_LINE_STROKE_WIDTH, MAX_SPEED, WALL_POST_RADIUS_RATIO } from '@/constants/game' -import { BoardVariant, PuckSizeLevel } from '@/types' -import { computeBoardLayout, faceoffZoneCenters, faceoffZoneRadius } from '@/utils/board' -import { circleVsRect, computeTrajectory, stepPhysics } from '@/utils/physics' +import { FRICTION_LEVELS, GOAL_LINE_STROKE_WIDTH, MAX_SPEED, WALL_POST_RADIUS_RATIO } from '@/games/boxhockey/constants/game' +import { BoardVariant, PuckSizeLevel } from '@/games/boxhockey/types' +import { computeBoardLayout, faceoffZoneCenters, faceoffZoneRadius } from '@/games/boxhockey/utils/board' +import { circleVsRect, computeTrajectory, stepPhysics } from '@/games/boxhockey/utils/physics' // Integration coverage for computeBoardLayout + stepPhysics together, using the REAL layout // geometry - unlike physics.test.ts/aiOpponent.test.ts, which construct a synthetic BoardLayout diff --git a/src/__tests__/faceoff.test.ts b/src/__tests__/faceoff.test.ts index 7cfbc99..00ad836 100644 --- a/src/__tests__/faceoff.test.ts +++ b/src/__tests__/faceoff.test.ts @@ -1,7 +1,7 @@ -import { FACEOFF_KNOCK_MAX_POWER, FACEOFF_KNOCK_MIN_POWER, FACEOFF_MARGIN_MAX_MS, FACEOFF_REACTION_MAX_MS, FRICTION_LEVELS, MAX_SPEED } from '@/constants/game' -import { makeReducer } from '@/hooks/useGameState' -import { BoardLayout, ControlScheme, FaceoffState, GameState, Player } from '@/types' -import { computeFaceoffKnock } from '@/utils/faceoff' +import { FACEOFF_KNOCK_MAX_POWER, FACEOFF_KNOCK_MIN_POWER, FACEOFF_MARGIN_MAX_MS, FACEOFF_REACTION_MAX_MS, FRICTION_LEVELS, MAX_SPEED } from '@/games/boxhockey/constants/game' +import { makeReducer } from '@/games/boxhockey/hooks/useGameState' +import { BoardLayout, ControlScheme, FaceoffState, GameState, Player } from '@/games/boxhockey/types' +import { computeFaceoffKnock } from '@/games/boxhockey/utils/faceoff' // Same synthetic board physics.test.ts uses. const board: BoardLayout = { diff --git a/src/__tests__/goalWedges.test.ts b/src/__tests__/goalWedges.test.ts index 7bfad3a..e2512a1 100644 --- a/src/__tests__/goalWedges.test.ts +++ b/src/__tests__/goalWedges.test.ts @@ -1,4 +1,4 @@ -import { computeGoalWedges } from '@/utils/goalWedges' +import { computeGoalWedges } from '@tastic/physics' describe('computeGoalWedges', () => { it('returns exactly `count` wedges', () => { diff --git a/src/__tests__/gravityRink.test.ts b/src/__tests__/gravityRink.test.ts index bd4e440..f3a90b6 100644 --- a/src/__tests__/gravityRink.test.ts +++ b/src/__tests__/gravityRink.test.ts @@ -1,7 +1,7 @@ -import { FRICTION_LEVELS, MAX_SPEED } from '@/constants/game' -import { BoardLayout, BoardVariant, PuckState } from '@/types' -import { computeBoardLayout } from '@/utils/board' -import { stepPhysics } from '@/utils/physics' +import { FRICTION_LEVELS, MAX_SPEED } from '@/games/boxhockey/constants/game' +import { BoardLayout, BoardVariant, PuckState } from '@/games/boxhockey/types' +import { computeBoardLayout } from '@/games/boxhockey/utils/board' +import { stepPhysics } from '@/games/boxhockey/utils/physics' // The bug seen while capturing App Store screenshots (2026-10-18): on Gravity with Rink, against // the CPU on Hard, the puck came to rest behind the bottom goal line beside the goal mouth, outside diff --git a/src/__tests__/guideSlice.test.ts b/src/__tests__/guideSlice.test.ts index dbaee9f..b085459 100644 --- a/src/__tests__/guideSlice.test.ts +++ b/src/__tests__/guideSlice.test.ts @@ -1,108 +1,30 @@ -import { createReturningPlayerMigrate, type GuidePersistedState, type GuideStorageReader } from '@tastic/hud/guide' - -import { GUIDE_VERSION } from '@/constants/guide' -import reducer, { guideActions, LEGACY_STORAGE_KEYS, selectGuideVersionSeen } from '@/redux/guideSlice' +import { GUIDE_VERSION } from '@/games/boxhockey/constants/guide' +import reducer, { guideActions, selectGuideVersionSeen } from '@/games/boxhockey/redux/guideSlice' const REHYDRATE = 'persist/REHYDRATE' -// redux-persist's own default persistConfig version (store.ts doesn't set one). -const PERSIST_VERSION = -1 - -// The factory's own behavior is covered in @tastic/hud's tests; this only pins this app's binding of -// it: GUIDE_VERSION is what an existing player is stamped with, and the slice is mounted (and -// therefore rehydrated) under `guide` in the 'root' persisted store (store.ts). -describe('guideSlice', () => { - it('starts a fresh install as never seen, so the guide auto-shows on Home', () => { - const state = reducer(undefined, { type: REHYDRATE, key: 'root', payload: undefined }) - expect(state.versionSeen).toBe(0) - }) - - it('stamps a player who already had the app with the current version, so an update never nags them', () => { - const state = reducer(undefined, { type: REHYDRATE, key: 'root', payload: { theme: {}, profiles: {}, seatColors: {} } }) - expect(state.versionSeen).toBe(GUIDE_VERSION) - }) - - it('keeps a version this player already finished or skipped', () => { - const state = reducer(undefined, { type: REHYDRATE, key: 'root', payload: { theme: {}, guide: { versionSeen: GUIDE_VERSION } } }) - expect(state.versionSeen).toBe(GUIDE_VERSION) - }) - - it('records the version on finish or skip, and reads back from the root state under `guide`', () => { - const state = reducer(undefined, guideActions.markSeen(GUIDE_VERSION)) - - expect(state.versionSeen).toBe(GUIDE_VERSION) - expect(selectGuideVersionSeen({ guide: state })).toBe(GUIDE_VERSION) - }) -}) -function storageWith(items: Record): GuideStorageReader { - return { getItem: async (key: string) => items[key] ?? null } -} - -// store.ts's migrate over what storage held, then the guide slice rehydrated with its result, in the -// same order redux-persist uses. -async function launch(persistedRoot: GuidePersistedState, storage: GuideStorageReader) { - const payload = await createReturningPlayerMigrate(storage, LEGACY_STORAGE_KEYS)(persistedRoot, PERSIST_VERSION) - return reducer(undefined, { type: REHYDRATE, key: 'root', payload }).versionSeen -} - -// Players of the shipped pre-redux build (ad363aa) have no root store, only these keys. See -// LEGACY_STORAGE_KEYS's own doc for where each one came from. -describe('LEGACY_STORAGE_KEYS', () => { - it('lists at least one key, every one namespaced to this app, none twice', () => { - expect(LEGACY_STORAGE_KEYS.length).toBeGreaterThan(0) - expect(new Set(LEGACY_STORAGE_KEYS).size).toBe(LEGACY_STORAGE_KEYS.length) - for (const key of LEGACY_STORAGE_KEYS) expect(key).toMatch(/^boxhockey\.[a-zA-Z]+$/) +// Box Hockey's How to Play state, under its own key like every game's (shell/redux/gameGuideSlice.ts). +describe("Box Hockey's guide slice", () => { + it('starts a fresh install as never seen, so the cards show on the first tap of the game', () => { + expect(reducer(undefined, { type: REHYDRATE, payload: undefined }).versionSeen).toBe(0) }) - it.each(LEGACY_STORAGE_KEYS)('grandfathers a shipped-build player who has only %s', async (key) => { - expect(await launch(undefined, storageWith({ [key]: 'true' }))).toBe(GUIDE_VERSION) + it('reads a saved store without its key as never seen', () => { + expect(reducer(undefined, { type: REHYDRATE, payload: { theme: {}, profiles: {} } }).versionSeen).toBe(0) }) - it('still shows the guide on a fresh install: no root store and none of the keys', async () => { - expect(await launch(undefined, storageWith({}))).toBe(0) + it('keeps a version this player already finished or skipped, saved under boxhockeyGuide', () => { + expect(reducer(undefined, { type: REHYDRATE, payload: { boxhockeyGuide: { versionSeen: GUIDE_VERSION } } }).versionSeen).toBe(GUIDE_VERSION) }) - it('leaves an existing root store alone, so a player who has not finished the guide still sees it', async () => { - const persistedRoot = { _persist: { version: PERSIST_VERSION, rehydrated: true } } - const payload = await createReturningPlayerMigrate(storageWith({ 'boxhockey.settings': '{}' }), LEGACY_STORAGE_KEYS)(persistedRoot, PERSIST_VERSION) - - expect(payload).toBe(persistedRoot) + it('ignores 1.0 guide state saved under the old `guide` key', () => { + expect(reducer(undefined, { type: REHYDRATE, payload: { guide: { versionSeen: GUIDE_VERSION } } }).versionSeen).toBe(0) }) -}) - -type StoreModule = typeof import('@/redux/store') -type AsyncStorageModule = typeof import('@react-native-async-storage/async-storage') - -// The real store.ts, loaded fresh against the in-memory AsyncStorage mock (@infinitetoken/jest-config -// maps the package to its official Jest mock), so this checks the actual persistConfig carries the -// migrate, not a copy of it. -describe('store.ts persistConfig', () => { - async function launchRealStore(items: Record): Promise { - let loaded: StoreModule | undefined - // A fresh module registry per launch: a fresh (empty) storage mock and a fresh store.ts, whose - // persistStore call reads storage at import time. - jest.isolateModules(() => { - // The official mock is a plain CommonJS object (module.exports = the mock), with no `default`. - const AsyncStorage = require('@react-native-async-storage/async-storage') as AsyncStorageModule['default'] - // Its multiSet writes before its first await, so storage is seeded before store.ts reads it. - void AsyncStorage.multiSet(Object.entries(items)) - loaded = require('@/redux/store') as StoreModule - }) - const { persistor, store } = loaded as StoreModule - await new Promise((resolve) => { - if (persistor.getState().bootstrapped) resolve() - else persistor.subscribe(() => persistor.getState().bootstrapped && resolve()) - }) - // Drains redux-persist's write queue, so no timer outlives the test. - await persistor.flush() - return selectGuideVersionSeen(store.getState()) - } - it('grandfathers a player from the shipped pre-redux build', async () => { - expect(await launchRealStore({ 'boxhockey.settings': JSON.stringify({ gameMode: 'vsCpu' }) })).toBe(GUIDE_VERSION) - }) + it('records the version on finish or skip, and reads it back from the root state', () => { + const state = reducer(undefined, guideActions.markSeen(GUIDE_VERSION)) - it('shows the guide on a fresh install', async () => { - expect(await launchRealStore({})).toBe(0) + expect(state.versionSeen).toBe(GUIDE_VERSION) + expect(selectGuideVersionSeen({ boxhockeyGuide: state })).toBe(GUIDE_VERSION) }) }) diff --git a/src/__tests__/guideSteps.test.tsx b/src/__tests__/guideSteps.test.tsx index cebfa05..bb4582e 100644 --- a/src/__tests__/guideSteps.test.tsx +++ b/src/__tests__/guideSteps.test.tsx @@ -1,9 +1,9 @@ import { render } from '@testing-library/react-native' import { View } from 'react-native' -import { getGuideSteps } from '@/constants/guideSteps' -import { DEFAULT_SETTINGS } from '@/redux/settingsSlice' -import type { ControlScheme, GameMode, Player, TipoffMode } from '@/types' +import { getGuideSteps } from '@/games/boxhockey/constants/guideSteps' +import { DEFAULT_SETTINGS } from '@/games/boxhockey/redux/settingsSlice' +import type { ControlScheme, GameMode, Player, TipoffMode } from '@/games/boxhockey/types' // SwipeHint's own rendering is @tastic/hud's to test (it needs Reanimated's useReducedMotion, which // this app's Reanimated mock doesn't stub). A plain View stands in for it, so the app-local art @@ -14,8 +14,9 @@ const SCHEMES: ControlScheme[] = ['flick', 'stick', 'push', 'pull'] const TIPOFFS: TipoffMode[] = ['faceoff', 'coinFlip', 'tripleTap'] const MODES: GameMode[] = ['twoPlayer', 'vsCpu'] -// A fresh install's live settings: what the auto-shown guide is actually built from. -const DEFAULTS = { mode: DEFAULT_SETTINGS.gameMode, controls: DEFAULT_SETTINGS.controlScheme, tipoff: DEFAULT_SETTINGS.tipoffMode, scoreToWin: DEFAULT_SETTINGS.scoreToWin } +// A fresh install's live settings: what the first-tap guide is actually built from. A fresh install +// starts with the CPU in seat 2 (the shell's opponentSlice.ts). +const DEFAULTS = { mode: 'vsCpu' as GameMode, controls: DEFAULT_SETTINGS.controlScheme, tipoff: DEFAULT_SETTINGS.tipoffMode, scoreToWin: DEFAULT_SETTINGS.scoreToWin } function stepsFor(touch: boolean, overrides: Partial = {}) { return getGuideSteps({ touch, ...DEFAULTS, ...overrides }) @@ -102,12 +103,12 @@ describe('getGuideSteps', () => { it("describes each seat's live shooting style: a shared one in full, different ones per player", () => { expect(stepsFor(true, { controls: { 1: 'pull', 2: 'pull' } })[0].body).toBe('Drag from anywhere and let go. The puck flies the opposite way. Longer drags hit harder.') expect(stepsFor(true, { controls: { 1: 'push', 2: 'push' } })[0].body).toBe('Drag from anywhere and let go. The puck flies the way you drag. Longer drags hit harder.') - expect(stepsFor(true, { controls: { 1: 'flick', 2: 'pull' } })[0].body).toBe('Start anywhere. The puck flies along player 1’s fast swipe and against player 2’s drag.') - expect(stepsFor(false, { controls: { 1: 'push', 2: 'flick' } })[0].body).toBe('Start anywhere. The puck flies along player 1’s drag and along player 2’s fast drag.') + expect(stepsFor(true, { mode: 'twoPlayer', controls: { 1: 'flick', 2: 'pull' } })[0].body).toBe('Start anywhere. The puck flies along player 1’s fast swipe and against player 2’s drag.') + expect(stepsFor(false, { mode: 'twoPlayer', controls: { 1: 'push', 2: 'flick' } })[0].body).toBe('Start anywhere. The puck flies along player 1’s drag and along player 2’s fast drag.') }) it("gives a solo player only their own shooting style, never the CPU's leftover one", () => { - // vs-CPU has no player 2 picker (loadout.tsx), so controls[2] is whatever it last was. + // Against the CPU seat 2 has no shot-style picker (useLoadout.tsx), so controls[2] is whatever it last was. expect(stepsFor(true, { mode: 'vsCpu', controls: { 1: 'pull', 2: 'flick' } })[0].body).toBe('Drag from anywhere and let go. The puck flies the opposite way. Longer drags hit harder.') expect(stepsFor(false, { mode: 'vsCpu', controls: { 1: 'flick', 2: 'push' } })[0].body).toBe('Drag the mouse fast from anywhere. The puck flies that way. Faster drags hit harder.') for (const { mode, steps } of everyGuide()) { diff --git a/src/__tests__/intercept.test.ts b/src/__tests__/intercept.test.ts index d8efcf7..f97c87c 100644 --- a/src/__tests__/intercept.test.ts +++ b/src/__tests__/intercept.test.ts @@ -5,9 +5,9 @@ // calls - no React, no timers, no physics simulation loop (each test seeds a puck velocity/phase // directly rather than simulating a shot down to that state). -import { FRICTION_LEVELS, MAX_SPEED, MAX_STICK_PULLBACK_PIXELS } from '@/constants/game' -import { makeReducer } from '@/hooks/useGameState' -import { BoardLayout, ControlScheme, GameState, Player } from '@/types' +import { FRICTION_LEVELS, MAX_SPEED, MAX_STICK_PULLBACK_PIXELS } from '@/games/boxhockey/constants/game' +import { makeReducer } from '@/games/boxhockey/hooks/useGameState' +import { BoardLayout, ControlScheme, GameState, Player } from '@/games/boxhockey/types' // Same synthetic board turnAlternation.test.ts/physics.test.ts use - flat rectangle, no // obstacles/wells/posts, so geometry never interferes with these reducer-level assertions. diff --git a/src/__tests__/physics.test.ts b/src/__tests__/physics.test.ts index 6c6f57c..3f2c558 100644 --- a/src/__tests__/physics.test.ts +++ b/src/__tests__/physics.test.ts @@ -1,6 +1,6 @@ -import { FRICTION_LEVELS } from '@/constants/game' -import { BoardLayout } from '@/types' -import { applyGravityWellForces, computeAimVector, computeTrajectory, stepPhysics } from '@/utils/physics' +import { FRICTION_LEVELS } from '@/games/boxhockey/constants/game' +import { BoardLayout } from '@/games/boxhockey/types' +import { applyGravityWellForces, computeAimVector, computeTrajectory, stepPhysics } from '@/games/boxhockey/utils/physics' // A standard test board with no obstacles const board: BoardLayout = { diff --git a/src/__tests__/playerSettings.test.tsx b/src/__tests__/playerSettings.test.tsx new file mode 100644 index 0000000..8b524ff --- /dev/null +++ b/src/__tests__/playerSettings.test.tsx @@ -0,0 +1,142 @@ +import { combineReducers, configureStore } from '@reduxjs/toolkit' +import { profilesActions } from '@tastic/profile' +import { act, renderHook } from '@testing-library/react-native' +import type { ReactNode } from 'react' +import { Provider } from 'react-redux' +import type { UnknownAction } from 'redux' + +import { createPlayerSettings, seatPlayerKey } from '@/shared/playerSettings' +import opponent, { opponentActions } from '@/shell/redux/opponentSlice' +import profileSelection, { profileSelectionActions } from '@/shell/redux/profileSelectionSlice' +import type { Seat, SeatTwo } from '@/shell/types' + +// A game's loadout follows its players (Jay, 2026-10-24): the match settings are seat 1's player's +// own, and each seat's controls are its player's. + +type Key = 'mouse' | 'wasd' | 'arrows' +interface Settings { + speed: 'slow' | 'medium' | 'fast' + scoreToWin: number + controlScheme: Record +} + +const DEFAULTS: Settings = { speed: 'medium', scoreToWin: 5, controlScheme: { 1: 'mouse', 2: 'wasd' } } +const game = createPlayerSettings({ id: 'test', defaults: DEFAULTS, distinctControls: ['mouse', 'wasd', 'arrows'] }) +const shared = createPlayerSettings({ id: 'shared', defaults: DEFAULTS }) + +const NOBODY = { 1: null, 2: null } + +function makeStore(selection: Record = NOBODY, seatTwo: SeatTwo = 'cpu') { + const store = configureStore({ reducer: combineReducers({ profileSelection, opponent, ...game.reducers }) }) + store.dispatch(profileSelectionActions.select({ seat: 1, profileId: selection[1] })) + store.dispatch(profileSelectionActions.select({ seat: 2, profileId: selection[2] })) + store.dispatch(opponentActions.initialize({ seatTwo })) + return store +} + +async function renderSettings(store: ReturnType) { + const wrapper = ({ children }: { children: ReactNode }) => {children} + return renderHook(() => game.useSettings(), { wrapper }) +} + +const saved = (store: ReturnType) => store.getState() as unknown as { testSettings: Record; testControls: Record } + +describe('seatPlayerKey', () => { + it("is the seat's profile, or that seat's own guest", () => { + expect(seatPlayerKey({ 1: 'sam', 2: null }, 1)).toBe('sam') + expect(seatPlayerKey({ 1: 'sam', 2: null }, 2)).toBe('guest:2') + expect(seatPlayerKey(NOBODY, 1)).toBe('guest:1') + }) +}) + +describe('compose', () => { + it('is the defaults for a player who has changed nothing', () => { + expect(game.compose(undefined, undefined, NOBODY, false)).toEqual(DEFAULTS) + }) + + it("loads seat 1's player's own setup, over the defaults", () => { + const settings = { sam: { speed: 'fast' as const }, riley: { scoreToWin: 10 } } + expect(game.compose(settings, {}, { 1: 'sam', 2: 'riley' }, true)).toEqual({ ...DEFAULTS, speed: 'fast' }) + expect(game.compose(settings, {}, { 1: 'riley', 2: 'sam' }, true)).toEqual({ ...DEFAULTS, scoreToWin: 10 }) + }) + + it("gives each seat its own player's controls, seat 2's only for a person", () => { + const controls = { sam: 'arrows' as const, riley: 'mouse' as const } + expect(game.compose({}, controls, { 1: 'riley', 2: 'sam' }, true).controlScheme).toEqual({ 1: 'mouse', 2: 'arrows' }) + expect(game.compose({}, controls, { 1: 'sam', 2: 'riley' }, false).controlScheme).toEqual({ 1: 'arrows', 2: 'wasd' }) + }) + + it('moves seat 2 off a control seat 1 is using, where two seats cannot share one', () => { + const controls = { sam: 'wasd' as const, riley: 'wasd' as const } + expect(game.compose({}, controls, { 1: 'sam', 2: 'riley' }, true).controlScheme).toEqual({ 1: 'wasd', 2: 'mouse' }) + // Against the CPU too, so a later 2 Player round never starts with both seats on the same keys. + expect(game.compose({}, { sam: 'wasd' }, { 1: 'sam', 2: null }, false).controlScheme).toEqual({ 1: 'wasd', 2: 'mouse' }) + }) + + it('lets both seats keep the same control where they can share one', () => { + expect(shared.compose({}, { sam: 'wasd', riley: 'wasd' }, { 1: 'sam', 2: 'riley' }, true).controlScheme).toEqual({ 1: 'wasd', 2: 'wasd' }) + }) +}) + +describe('the reducers', () => { + const { settingsReducer, controlsReducer } = game + // @tastic/profile's action creators are typed without an index signature. + const removeSam = profilesActions.remove('sam') as unknown as UnknownAction + + it("keep each player's changes apart", () => { + let state = settingsReducer(undefined, game.actions.patch('sam', { speed: 'fast' })) + state = settingsReducer(state, game.actions.patch('riley', { scoreToWin: 3 })) + state = settingsReducer(state, game.actions.patch('sam', { scoreToWin: 7 })) + expect(state).toEqual({ sam: { speed: 'fast', scoreToWin: 7 }, riley: { scoreToWin: 3 } }) + }) + + it("forget a deleted profile's setup and controls, and keep everyone else's", () => { + const settings = settingsReducer({ sam: { speed: 'fast' }, riley: { scoreToWin: 3 } }, removeSam) + const controls = controlsReducer({ sam: 'arrows', 'guest:1': 'wasd' }, removeSam) + expect(settings).toEqual({ riley: { scoreToWin: 3 } }) + expect(controls).toEqual({ 'guest:1': 'wasd' }) + }) +}) + +describe('useSettings', () => { + it("saves a match setting with seat 1's player, and loads each player's own when seat 1 changes", async () => { + const store = makeStore({ 1: 'sam', 2: null }) + const { result } = await renderSettings(store) + + await act(async () => result.current.setSettings({ speed: 'fast' })) + expect(result.current.settings.speed).toBe('fast') + expect(saved(store).testSettings).toEqual({ sam: { speed: 'fast' } }) + + await act(async () => store.dispatch(profileSelectionActions.select({ seat: 1, profileId: 'riley' }))) + expect(result.current.settings.speed).toBe('medium') + + await act(async () => store.dispatch(profileSelectionActions.select({ seat: 1, profileId: 'sam' }))) + expect(result.current.settings.speed).toBe('fast') + }) + + it("saves each seat's control with the player in it", async () => { + const store = makeStore({ 1: 'sam', 2: 'riley' }, 'person') + const { result } = await renderSettings(store) + + await act(async () => result.current.setSettings({ controlScheme: { 1: 'arrows', 2: 'mouse' } })) + expect(saved(store).testControls).toEqual({ sam: 'arrows', riley: 'mouse' }) + expect(saved(store).testSettings).toEqual({}) + }) + + it("never saves seat 2's control for the CPU", async () => { + const store = makeStore({ 1: 'sam', 2: 'riley' }, 'cpu') + const { result } = await renderSettings(store) + + await act(async () => result.current.setSettings({ controlScheme: { 1: 'wasd', 2: 'mouse' } })) + expect(saved(store).testControls).toEqual({ sam: 'wasd' }) + }) + + it("keeps a guest's setup with the seat they sat in", async () => { + const store = makeStore(NOBODY, 'person') + const { result } = await renderSettings(store) + + await act(async () => result.current.setSettings({ scoreToWin: 10, controlScheme: { 1: 'arrows', 2: 'wasd' } })) + expect(saved(store).testSettings).toEqual({ 'guest:1': { scoreToWin: 10 } }) + expect(saved(store).testControls).toEqual({ 'guest:1': 'arrows' }) + }) +}) diff --git a/src/__tests__/profiles.test.tsx b/src/__tests__/profiles.test.tsx index 4b12c78..935b521 100644 --- a/src/__tests__/profiles.test.tsx +++ b/src/__tests__/profiles.test.tsx @@ -4,10 +4,10 @@ import { act, render } from '@testing-library/react-native' import { AppState } from 'react-native' import { Provider } from 'react-redux' -import { ProfilesProvider } from '@/hooks/useProfiles' -import profileExtensions from '@/redux/profileExtensionsSlice' -import profileSelection from '@/redux/profileSelectionSlice' -import { Player } from '@/types' +import { playerSettings } from '@/games/boxhockey/redux/settingsSlice' +import { Player } from '@/games/boxhockey/types' +import { ProfilesProvider } from '@/shell/hooks/useProfiles' +import profileSelection from '@/shell/redux/profileSelectionSlice' import { emitAppState } from '../testUtils/appState' @@ -58,8 +58,8 @@ beforeEach(() => { // and seat selection as the persisted state, and lets the initial reconciliation land. async function mountProfiles(local: BaseProfile[], selection: Record) { const store = configureStore({ - reducer: combineReducers({ profiles: profilesReducer, profileExtensions, profileSelection }), - preloadedState: { profiles: local, profileExtensions: { [bo.id]: { controlScheme: 'push' } }, profileSelection: selection } + reducer: combineReducers({ profiles: profilesReducer, boxhockeyControls: playerSettings.controlsReducer, profileSelection }), + preloadedState: { profiles: local, boxhockeyControls: { [bo.id]: 'push' }, profileSelection: selection } }) await render( @@ -112,6 +112,6 @@ describe('ProfilesProvider seat selection', () => { await returnToForeground() expect(store.getState().profiles).toEqual([ada, bo]) expect(store.getState().profileSelection).toEqual({ 1: ada.id, 2: bo.id }) - expect(store.getState().profileExtensions).toEqual({ [bo.id]: { controlScheme: 'push' } }) + expect(store.getState().boxhockeyControls).toEqual({ [bo.id]: 'push' }) }) }) diff --git a/src/__tests__/seatToastSpots.test.ts b/src/__tests__/seatToastSpots.test.ts index bb45c84..94c3424 100644 --- a/src/__tests__/seatToastSpots.test.ts +++ b/src/__tests__/seatToastSpots.test.ts @@ -1,4 +1,4 @@ -import { seatToastSpots } from '@/utils/seatToastSpots' +import { seatToastSpots } from '@/games/boxhockey/utils/seatToastSpots' // Where each 2 Player seat's unlock toasts go: the half of the screen that's theirs, turned the way they read it. describe('seatToastSpots', () => { diff --git a/src/__tests__/settingsSlice.test.ts b/src/__tests__/settingsSlice.test.ts index 5e5ef1b..1437cfe 100644 --- a/src/__tests__/settingsSlice.test.ts +++ b/src/__tests__/settingsSlice.test.ts @@ -1,33 +1,41 @@ -import reducer, { DEFAULT_SETTINGS, settingsActions } from '@/redux/settingsSlice' +import { DEFAULT_SETTINGS, playerSettings } from '@/games/boxhockey/redux/settingsSlice' +import appSettingsReducer, { appSettingsActions, DEFAULT_APP_SETTINGS } from '@/shell/redux/appSettingsSlice' const REHYDRATE = 'persist/REHYDRATE' -describe('settingsSlice', () => { - it('starts from the defaults', () => { - expect(reducer(undefined, { type: '@@INIT' })).toEqual(DEFAULT_SETTINGS) +// The per-player behavior itself is shared/playerSettings.ts's, tested in playerSettings.test.tsx. +describe("Box Hockey's per-player settings", () => { + it('are saved under boxhockeySettings and boxhockeyControls', () => { + expect(Object.keys(playerSettings.reducers)).toEqual(['boxhockeySettings', 'boxhockeyControls']) }) - it('merges a partial patch over the current settings', () => { - const next = reducer(DEFAULT_SETTINGS, settingsActions.initialize({ lockOrientation: true, lockedRotation: -90 })) - expect(next).toEqual({ ...DEFAULT_SETTINGS, lockOrientation: true, lockedRotation: -90 }) + // A player's saved setup only holds what they changed, so a field added since starts at its default. + it('fill what a player never changed from the defaults', () => { + expect(playerSettings.compose({ sam: { boardVariant: 'gauntlet' } }, {}, { 1: 'sam', 2: null }, false)).toEqual({ ...DEFAULT_SETTINGS, boardVariant: 'gauntlet' }) }) - it('clears lockedRotation when unlocking', () => { - const locked = reducer(DEFAULT_SETTINGS, settingsActions.initialize({ lockOrientation: true, lockedRotation: 180 })) - expect(reducer(locked, settingsActions.initialize({ lockOrientation: false, lockedRotation: undefined })).lockedRotation).toBeUndefined() + it('let both seats play the same shot style', () => { + expect(playerSettings.compose({}, { sam: 'stick', riley: 'stick' }, { 1: 'sam', 2: 'riley' }, true).controlScheme).toEqual({ 1: 'stick', 2: 'stick' }) }) +}) - // Settings saved by an older build have no value for a field added since; rehydrating must fill it - // from the defaults rather than leave it undefined. - it('fills fields missing from saved settings with the defaults on rehydrate', () => { - const key = (Object.keys(DEFAULT_SETTINGS) as (keyof typeof DEFAULT_SETTINGS)[]).find((k) => k !== 'lockOrientation')! - const saved: Partial = { ...DEFAULT_SETTINGS, lockOrientation: true } - delete saved[key] - const next = reducer(undefined, { type: REHYDRATE, payload: { settings: saved } } as { type: string }) - expect(next).toEqual({ ...DEFAULT_SETTINGS, lockOrientation: true }) +describe("the shell's appSettingsSlice", () => { + it('starts from the defaults', () => { + expect(appSettingsReducer(undefined, { type: '@@INIT' })).toEqual(DEFAULT_APP_SETTINGS) + }) + + it('records the locked angle on lock', () => { + const next = appSettingsReducer(DEFAULT_APP_SETTINGS, appSettingsActions.initialize({ lockOrientation: true, lockedRotation: -90 })) + expect(next).toEqual({ ...DEFAULT_APP_SETTINGS, lockOrientation: true, lockedRotation: -90 }) + }) + + it('clears lockedRotation when unlocking', () => { + const locked = appSettingsReducer(DEFAULT_APP_SETTINGS, appSettingsActions.initialize({ lockOrientation: true, lockedRotation: 180 })) + expect(appSettingsReducer(locked, appSettingsActions.initialize({ lockOrientation: false, lockedRotation: undefined })).lockedRotation).toBeUndefined() }) - it('keeps the defaults when nothing was saved', () => { - expect(reducer(undefined, { type: REHYDRATE, payload: {} } as { type: string })).toEqual(DEFAULT_SETTINGS) + it('fills fields missing from saved app settings with the defaults on rehydrate', () => { + const next = appSettingsReducer(undefined, { type: REHYDRATE, payload: { appSettings: { lockOrientation: true } } } as { type: string }) + expect(next).toEqual({ ...DEFAULT_APP_SETTINGS, lockOrientation: true }) }) }) diff --git a/src/__tests__/statsEngine.test.ts b/src/__tests__/statsEngine.test.ts index 9c702e6..acffbce 100644 --- a/src/__tests__/statsEngine.test.ts +++ b/src/__tests__/statsEngine.test.ts @@ -1,6 +1,6 @@ -import { Profile, StatsState } from '@/types' -import { applyMatchOutcome, getOverallTotals, getProfileRankings, getProfileStatsView, getVariantsWon, MatchContext, MatchOutcome } from '@/utils/statsEngine' -import { DEFAULT_PROFILE_STATS, DEFAULT_STATS } from '@/utils/statsValidation' +import { Profile, StatsState } from '@/games/boxhockey/types' +import { applyMatchOutcome, getOverallTotals, getProfileRankings, getProfileStatsView, getVariantsWon, MatchContext, MatchOutcome } from '@/games/boxhockey/utils/statsEngine' +import { DEFAULT_PROFILE_STATS, DEFAULT_STATS } from '@/games/boxhockey/utils/statsValidation' const VS_CPU: MatchContext = { gameMode: 'vsCpu', aiDifficulty: 'medium', boardVariant: 'classic' } const TWO_PLAYER: MatchContext = { gameMode: 'twoPlayer', aiDifficulty: 'medium', boardVariant: 'classic' } diff --git a/src/__tests__/statsValidation.test.ts b/src/__tests__/statsValidation.test.ts index b10813c..a36587d 100644 --- a/src/__tests__/statsValidation.test.ts +++ b/src/__tests__/statsValidation.test.ts @@ -1,4 +1,4 @@ -import { DEFAULT_PROFILE_STATS, DEFAULT_STATS, isValidStats } from '@/utils/statsValidation' +import { DEFAULT_PROFILE_STATS, DEFAULT_STATS, isValidStats } from '@/games/boxhockey/utils/statsValidation' describe('DEFAULT_STATS', () => { it('is a zeroed blob that validates', () => { diff --git a/src/__tests__/statusBar.test.tsx b/src/__tests__/statusBar.test.tsx index 6ffde23..7ede12d 100644 --- a/src/__tests__/statusBar.test.tsx +++ b/src/__tests__/statusBar.test.tsx @@ -2,7 +2,7 @@ import { render } from '@testing-library/react-native' import { readdirSync, readFileSync, statSync } from 'fs' import { join, relative } from 'path' -import { Theme } from '@/components/Theme' +import { Theme } from '@/shell/components/Theme' // The status bar is set per screen: the root Stack's screen options (_layout.tsx, from @tastic/core's // useRotationAwareStatusBar) hide it while the content is turned, and /game hides it for the whole round. @@ -18,7 +18,7 @@ jest.mock('@rific/auto-paper', () => ({ useThemeBridgeProps: (props: object) => props })) jest.mock('react-redux', () => ({ shallowEqual: () => true, useDispatch: () => () => {}, useSelector: () => ({}) })) -jest.mock('@/utils/splashGate', () => ({ markSplashReady: () => {}, useSplashReady: () => {} })) +jest.mock('@/shell/splashGate', () => ({ markSplashReady: () => {}, useSplashReady: () => {} })) jest.mock('expo-blur', () => ({ BlurView: () => null })) const SRC = join(__dirname, '..') diff --git a/src/__tests__/stick.test.ts b/src/__tests__/stick.test.ts index 7e8de71..aea3c3e 100644 --- a/src/__tests__/stick.test.ts +++ b/src/__tests__/stick.test.ts @@ -2,10 +2,10 @@ // stick blade and the shot is the contact (utils/stick.ts), checked in the reducer on every aim // update, and on every physics step during an intercept. -import { FRICTION_LEVELS, MAX_SPEED, STICK_RESTITUTION, STICK_VELOCITY_SCALE } from '@/constants/game' -import { makeReducer } from '@/hooks/useGameState' -import { BoardLayout, ControlScheme, GameState, Player } from '@/types' -import { clampBladeToBoard, planStickSwing, resolveStickContact, stickBladeRadius } from '@/utils/stick' +import { FRICTION_LEVELS, MAX_SPEED, STICK_RESTITUTION, STICK_VELOCITY_SCALE } from '@/games/boxhockey/constants/game' +import { makeReducer } from '@/games/boxhockey/hooks/useGameState' +import { BoardLayout, ControlScheme, GameState, Player } from '@/games/boxhockey/types' +import { clampBladeToBoard, planStickSwing, resolveStickContact, stickBladeRadius } from '@/games/boxhockey/utils/stick' // Same synthetic board the other reducer suites use. const board: BoardLayout = { diff --git a/src/__tests__/store.test.ts b/src/__tests__/store.test.ts index 56d1909..1e034f8 100644 --- a/src/__tests__/store.test.ts +++ b/src/__tests__/store.test.ts @@ -1,14 +1,15 @@ -import { GUIDE_VERSION } from '@/constants/guide' -import { selectGuideVersionSeen } from '@/redux/guideSlice' +import { GUIDE_VERSION } from '@/games/boxhockey/constants/guide' +import { selectGuideVersionSeen } from '@/games/boxhockey/redux/guideSlice' +import { DEFAULT_SETTINGS, playerSettings } from '@/games/boxhockey/redux/settingsSlice' -type StoreModule = typeof import('@/redux/store') +type StoreModule = typeof import('@/pack') type AsyncStorageModule = typeof import('@react-native-async-storage/async-storage') type AsyncStorage = AsyncStorageModule['default'] // redux-persist keeps each slice as its own JSON string inside the root blob. const rootBlob = (slices: Record) => JSON.stringify(Object.fromEntries([...Object.entries(slices), ['_persist', { version: -1, rehydrated: true }]].map(([key, value]) => [key, JSON.stringify(value)]))) -// The real store.ts, loaded fresh against the in-memory AsyncStorage mock (as in guideSlice.test.ts), +// The real pack store (pack.ts), loaded fresh against the in-memory AsyncStorage mock (as in guideSlice.test.ts), // so this checks the key the actual persistConfig reads and writes, not a copy of the config. async function launch(items: Record) { let loaded: StoreModule | undefined @@ -18,7 +19,7 @@ async function launch(items: Record) { storage = require('@react-native-async-storage/async-storage') as AsyncStorage // Its multiSet writes before its first await, so storage is seeded before store.ts reads it. void storage.multiSet(Object.entries(items)) - loaded = require('@/redux/store') as StoreModule + loaded = require('@/pack') as StoreModule }) const { persistor, store } = loaded as StoreModule await new Promise((resolve) => { @@ -32,33 +33,123 @@ async function launch(items: Record) { // On the web every Tastic game shares games.infinitetoken.com's localStorage, where redux-persist's // default prefix would make this game's state the same persist:root every game writes. -describe('store.ts storage key', () => { - it("writes its state under BoxHockey's own prefix, boxhockey:root, and nothing else", async () => { +describe("the pack store's storage key", () => { + it('writes its state under its own prefix, pucktastic:root, and nothing else', async () => { const { storage } = await launch({}) - expect(await storage.getAllKeys()).toEqual(['boxhockey:root']) + expect(await storage.getAllKeys()).toEqual(['pucktastic:root']) }) - it('reads its state back from boxhockey:root on the next launch', async () => { - const { store } = await launch({ 'boxhockey:root': rootBlob({ guide: { versionSeen: GUIDE_VERSION } }) }) + it('reads its state back from pucktastic:root on the next launch', async () => { + const { store } = await launch({ 'pucktastic:root': rootBlob({ boxhockeyGuide: { versionSeen: GUIDE_VERSION }, opponent: { seatTwo: 'person' } }) }) expect(selectGuideVersionSeen(store.getState())).toBe(GUIDE_VERSION) + expect(store.getState().opponent).toEqual({ seatTwo: 'person' }) }) - // No migrations: a save under the old key (unshipped builds, or another game's on the web) starts - // fresh, so the guide shows once, and the old key is left where it was. - it('leaves a save under the old persist:root unread and in place', async () => { - const oldSave = rootBlob({ guide: { versionSeen: GUIDE_VERSION } }) + // Only BoxHockey 1.0's own key is imported (see below); an older build's save, or another game's + // on the web, starts fresh and is left where it was. + it('leaves a save under persist:root unread and in place', async () => { + const oldSave = rootBlob({ guide: { versionSeen: GUIDE_VERSION }, settings: { gameMode: 'twoPlayer', lockOrientation: true } }) const { storage, store } = await launch({ 'persist:root': oldSave }) + const state = store.getState() - expect(selectGuideVersionSeen(store.getState())).toBe(0) + expect(selectGuideVersionSeen(state)).toBe(0) + expect(state.appSettings.lockOrientation).toBe(false) + expect(state.opponent).toEqual({ seatTwo: 'cpu' }) expect(await storage.getItem('persist:root')).toBe(oldSave) }) - it('still grandfathers a shipped pre-redux player past the guide, under the new key', async () => { - const { storage, store } = await launch({ 'boxhockey.settings': JSON.stringify({ gameMode: 'vsCpu' }) }) + it('starts a fresh install with the CPU in seat 2', async () => { + const { store } = await launch({}) + + expect(store.getState().opponent).toEqual({ seatTwo: 'cpu' }) + }) +}) +// BoxHockey 1.0 saved under boxhockey:root, Box Hockey's keys unprefixed and Lock Orientation, Edge +// Guard and the last mode picked on Home inside Box Hockey's own settings (importBoxHockey1.ts). +describe('a BoxHockey 1.0 save', () => { + const settings = { gameMode: 'vsCpu', boardVariant: 'gravity', scoreToWin: 7, lockOrientation: true, lockedRotation: 90, deferBottomEdgeGestures: true } + const oneZero = (extra: Record = {}) => + rootBlob({ + settings, + achievements: { stats: { firstGameResult: 'win' }, unlocked: { first_blood: 1760000000000 } }, + guide: { versionSeen: GUIDE_VERSION }, + profileExtensions: { sam: { controlScheme: 'stick' } }, + sound: { enabled: false }, + ...extra + }) + const root = (state: unknown) => state as Record> + + it("is imported on the first launch, Box Hockey's keys under their new names", async () => { + const { store } = await launch({ 'boxhockey:root': oneZero() }) + const state = root(store.getState()) + + expect(state.boxhockeySettings).toEqual({ 'guest:1': { boardVariant: 'gravity', scoreToWin: 7 } }) + expect(state.boxhockeyAchievements).toMatchObject({ unlocked: { first_blood: 1760000000000 } }) expect(selectGuideVersionSeen(store.getState())).toBe(GUIDE_VERSION) - expect(JSON.parse(JSON.parse((await storage.getItem('boxhockey:root')) ?? '{}').guide)).toEqual({ versionSeen: GUIDE_VERSION }) + expect(state.boxhockeyControls).toEqual({ sam: 'stick' }) + expect(state.sound).toMatchObject({ enabled: false }) + }) + + // 1.0 had one setup for everyone; it's seat 1's player's now. Each seat's shot style goes to the + // player in it, and a profile's own saved pick wins over the one it only inherited from its seat. + it("gives 1.0's setup to seat 1's guest, and each seat's shot style to its guest", async () => { + const { store } = await launch({ 'boxhockey:root': oneZero({ settings: { ...settings, controlScheme: { 1: 'pull', 2: 'push' } } }) }) + const state = root(store.getState()) + + expect(state.boxhockeySettings).toEqual({ 'guest:1': { boardVariant: 'gravity', scoreToWin: 7 } }) + expect(state.boxhockeyControls).toEqual({ 'guest:1': 'pull', 'guest:2': 'push', sam: 'stick' }) + expect(playerSettings.select(store.getState())).toEqual({ ...DEFAULT_SETTINGS, boardVariant: 'gravity', scoreToWin: 7, controlScheme: { 1: 'pull', 2: 'flick' } }) + }) + + it("gives 1.0's setup to the profile in seat 1, who loads it with their own shot style", async () => { + const twoPlayer = { ...settings, gameMode: 'twoPlayer', controlScheme: { 1: 'pull', 2: 'push' } } + const { store } = await launch({ 'boxhockey:root': oneZero({ settings: twoPlayer, profileSelection: { 1: 'sam', 2: 'riley' } }) }) + const state = root(store.getState()) + + expect(state.boxhockeySettings).toEqual({ sam: { boardVariant: 'gravity', scoreToWin: 7 } }) + expect(state.boxhockeyControls).toEqual({ sam: 'stick', riley: 'push' }) + expect(playerSettings.select(store.getState())).toEqual({ ...DEFAULT_SETTINGS, boardVariant: 'gravity', scoreToWin: 7, controlScheme: { 1: 'stick', 2: 'push' } }) + }) + + it("moves 1.0's app-wide settings out of Box Hockey's, and its last mode into seat 2", async () => { + const { store } = await launch({ 'boxhockey:root': oneZero() }) + const state = store.getState() + + expect(state.appSettings).toEqual({ lockOrientation: true, lockedRotation: 90, deferBottomEdgeGestures: true }) + expect(state.opponent).toEqual({ seatTwo: 'cpu' }) + for (const field of ['gameMode', 'lockOrientation', 'lockedRotation', 'deferBottomEdgeGestures']) expect(root(state).boxhockeySettings['guest:1']).not.toHaveProperty(field) + }) + + it('reads a 2 Player last mode as a person in seat 2', async () => { + const { store } = await launch({ 'boxhockey:root': oneZero({ settings: { ...settings, gameMode: 'twoPlayer' } }) }) + + expect(store.getState().opponent).toEqual({ seatTwo: 'person' }) + }) + + it('saves the import under pucktastic:root and leaves the 1.0 save in place', async () => { + const save = oneZero() + const { storage } = await launch({ 'boxhockey:root': save }) + + expect(JSON.parse(JSON.parse((await storage.getItem('pucktastic:root')) ?? '{}').boxhockeyGuide)).toEqual({ versionSeen: GUIDE_VERSION }) + expect(await storage.getItem('boxhockey:root')).toBe(save) + }) + + it('is ignored once there is a pack save', async () => { + const { store } = await launch({ 'boxhockey:root': oneZero(), 'pucktastic:root': rootBlob({ opponent: { seatTwo: 'person' } }) }) + const state = store.getState() + + expect(state.opponent).toEqual({ seatTwo: 'person' }) + expect(selectGuideVersionSeen(state)).toBe(0) + expect(state.appSettings.lockOrientation).toBe(false) + }) + + it('starts fresh when the 1.0 save is unreadable', async () => { + const { store } = await launch({ 'boxhockey:root': '{not json' }) + + expect(selectGuideVersionSeen(store.getState())).toBe(0) + expect(store.getState().opponent).toEqual({ seatTwo: 'cpu' }) }) }) diff --git a/src/__tests__/tripleTap.test.ts b/src/__tests__/tripleTap.test.ts index a9e8157..9f6027f 100644 --- a/src/__tests__/tripleTap.test.ts +++ b/src/__tests__/tripleTap.test.ts @@ -4,10 +4,10 @@ // the other player's swipe within TRIPLE_TAP_CLASH_WINDOW_MS is added onto the moving puck // (combineAndClampVelocity, the same clamp-to-MAX_SPEED helper the ordinary shot mechanic uses). -import { FRICTION_LEVELS, MAX_SPEED, MIN_FLICK_VELOCITY, MIN_SHOOT_DRAG, TRIPLE_TAP_CLASH_WINDOW_MS, TRIPLE_TAP_TAPS } from '@/constants/game' -import { canTripleTap, combineAndClampVelocity, makeReducer } from '@/hooks/useGameState' -import { BoardLayout, ControlScheme, GameState, Player, TripleTapState } from '@/types' -import { planTripleTapSwipe } from '@/utils/aiOpponent' +import { FRICTION_LEVELS, MAX_SPEED, MIN_FLICK_VELOCITY, MIN_SHOOT_DRAG, TRIPLE_TAP_CLASH_WINDOW_MS, TRIPLE_TAP_TAPS } from '@/games/boxhockey/constants/game' +import { canTripleTap, combineAndClampVelocity, makeReducer } from '@/games/boxhockey/hooks/useGameState' +import { BoardLayout, ControlScheme, GameState, Player, TripleTapState } from '@/games/boxhockey/types' +import { planTripleTapSwipe } from '@/games/boxhockey/utils/aiOpponent' // Same synthetic board faceoff.test.ts/turnAlternation.test.ts use. const board: BoardLayout = { diff --git a/src/__tests__/turnAlternation.test.ts b/src/__tests__/turnAlternation.test.ts index 7267258..361a18f 100644 --- a/src/__tests__/turnAlternation.test.ts +++ b/src/__tests__/turnAlternation.test.ts @@ -19,10 +19,10 @@ // match "conceding player" in the ordinary case but handed the puck to the wrong player - the // beneficiary, not the conceder - on an own goal. -import { CONTROL_SCHEME_SIGN, FRICTION_LEVELS, MAX_FLICK_VELOCITY, MIN_FLICK_VELOCITY, MIN_SHOOT_DRAG } from '@/constants/game' -import { makeReducer } from '@/hooks/useGameState' -import { BoardLayout, ControlScheme, FaceoffState, GameState, Player, TipoffMode } from '@/types' -import { computeFaceoffKnock } from '@/utils/faceoff' +import { CONTROL_SCHEME_SIGN, FRICTION_LEVELS, MAX_FLICK_VELOCITY, MIN_FLICK_VELOCITY, MIN_SHOOT_DRAG } from '@/games/boxhockey/constants/game' +import { makeReducer } from '@/games/boxhockey/hooks/useGameState' +import { BoardLayout, ControlScheme, FaceoffState, GameState, Player, TipoffMode } from '@/games/boxhockey/types' +import { computeFaceoffKnock } from '@/games/boxhockey/utils/faceoff' // ─── Board fixture (same synthetic board physics.test.ts uses - no obstacles, no gravity wells, // no goalposts, cornerRadius 0 / behindGoalDepth 0, so geometry is the simplest possible flat diff --git a/src/__tests__/useUnlockToasts.test.tsx b/src/__tests__/useUnlockToasts.test.tsx index 0d2762d..bf1aead 100644 --- a/src/__tests__/useUnlockToasts.test.tsx +++ b/src/__tests__/useUnlockToasts.test.tsx @@ -5,8 +5,8 @@ import { readdirSync, readFileSync, statSync } from 'fs' import { join } from 'path' import type { ReactNode } from 'react' -import { SEAT_TOAST_STACKS, unlockCaption, unlockToasts, useUnlockToasts } from '@/hooks/useUnlockToasts' -import type { Player } from '@/types' +import type { Player } from '@/games/boxhockey/types' +import { SEAT_TOAST_STACKS, unlockCaption, unlockToasts, useUnlockToasts } from '@/shared/useUnlockToasts' // Every unlock is a toast named for the profile: Vs CPU's on the app's main Toaster, 2 Player's on a Toaster per seat in that seat's half // of the screen, turned to face it (SeatToasters), so none is listed on the Game Over card. diff --git a/src/app/[game]/loadout.tsx b/src/app/[game]/loadout.tsx new file mode 100644 index 0000000..2140f82 --- /dev/null +++ b/src/app/[game]/loadout.tsx @@ -0,0 +1,9 @@ +import { games } from '@/pack' +import { LoadoutRoute } from '@/shell/screens/GameRoute' + +// The web build is a static export, so every game's loadout page is listed here. +export function generateStaticParams(): { game: string }[] { + return games.map((g) => ({ game: g.id })) +} + +export default LoadoutRoute diff --git a/src/app/[game]/play.tsx b/src/app/[game]/play.tsx new file mode 100644 index 0000000..c3098ec --- /dev/null +++ b/src/app/[game]/play.tsx @@ -0,0 +1,9 @@ +import { games } from '@/pack' +import { PlayRoute } from '@/shell/screens/GameRoute' + +// The web build is a static export, so every game's play page is listed here. +export function generateStaticParams(): { game: string }[] { + return games.map((g) => ({ game: g.id })) +} + +export default PlayRoute diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 106f97f..cb99814 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,151 +1,6 @@ -import { useAutoPaperTheme } from '@rific/auto-paper' -import { useHapticSettings } from '@rific/feedback-press' -import { Toaster, ToastProvider } from '@rific/toaster' -import { OrientationProvider, useRotationAwareStatusBar, useThemedRootBackground } from '@tastic/core' -import * as Haptics from 'expo-haptics' -import { Stack } from 'expo-router' -import { DeviceMotion } from 'expo-sensors' -import { ReactNode } from 'react' -import { StyleSheet } from 'react-native' -import { GestureHandlerRootView } from 'react-native-gesture-handler' -import * as RNPaper from 'react-native-paper' -import { SafeAreaProvider } from 'react-native-safe-area-context' +import { pack, persistor, store } from '@/pack' +import { RootLayout } from '@/shell/RootLayout' -import { Fonts } from '@/components/Fonts' -import { GuideHost } from '@/components/GuideHost' -import { Providers } from '@/components/Providers' -import { Theme } from '@/components/Theme' -import { UpdateDialog } from '@/components/UpdateDialog' -import { GameStatsProvider } from '@/hooks/useGameStats' -import { ProfilesProvider, useProfiles } from '@/hooks/useProfiles' -import { useSettings } from '@/hooks/useSettings' -import { SplashGate } from '@/utils/splashGate' - -// This file is identical across the fleet's games (AirHockey, BoxHockey, LightCycles, Pong). Change -// it in all of them or none, so any game can be the template for the next one. - -// Sits below Theme so it can read the live theme. useThemedRootBackground (@tastic/core) keeps both -// the screens' contentStyle and the OS root window's own background on the live theme, which is what -// stops white flashing through the display's rounded corners during a push/pop transition. Passing -// false disables iOS's swipe-back gesture: otherwise it competes with the game's own pan gestures on -// /game and wins (confirmed on-device in LightCycles, where it kicked the player back to the title -// screen mid-round instead of turning their cycle). Web has no such OS-level gesture. -// -// useRotationAwareStatusBar (@tastic/core) hides the OS status bar whenever the content is rotated -// off portrait. It does it through screen options because that is the only way left: an app linked -// against the iOS 27 SDK can't set the status bar app-wide, which is all React Native's StatusBar -// (and expo-status-bar) does on iOS, and with @tastic/core's config plugin on, every mounted -// StatusBar logs an error. So nothing in the app mounts one (Theme passes auto-paper's Provider -// statusBar={false}). No `locked` argument: OrientationBridge already freezes the reading it -// follows. /game hides the bar for the whole round, whatever the angle (a screen's own options win -// over screenOptions); index is listed first so it stays the stack's first route. -const GAME_OPTIONS = { statusBarHidden: true } - -function AppStack() { - const { dark } = useAutoPaperTheme() - const rootBackground = useThemedRootBackground(dark, false) - const statusBar = useRotationAwareStatusBar() - return ( - - - - - ) -} - -// Wires the persisted Lock Orientation setting into @tastic/core's OrientationProvider. Its own -// component because RootLayout renders the Redux store (inside Providers) and so can't call -// useSettings() itself. Providers' PersistGate holds rendering until the store has rehydrated, so -// lockInitialValue carries the saved lock from the very first render. -// -// freezeWhileLocked makes the lock app-wide: while settings.lockOrientation is on, the ONE shared -// orientation reading freezes for every consumer (every screen, the in-game board, hud dialogs, the -// status bar), including anything that mounts after the lock, so no call site ever takes a `locked` -// argument of its own (one would freeze on the pre-sensor default at mount). The Settings dialog -// toggle only writes settings.lockOrientation, which reaches the provider as a changed -// lockInitialValue; the provider reports the angle it froze on through onLockChange (`rotation`, -// cleared on unlock), persisted as settings.lockedRotation and handed back here so a relaunch reopens -// in the locked orientation instead of portrait. -// -// Mounted once, here, rather than per screen: one app-lifetime sensor subscription is what lets the -// committed orientation survive navigation. deviceMotion is the app's own expo-sensors import, handed -// in because @tastic/core never imports expo-sensors itself (apps without tilt tracking would -// otherwise need it installed just to bundle). -function OrientationBridge({ children }: { children: ReactNode }) { - const { settings, setSettings } = useSettings() - return ( - setSettings({ lockOrientation: next.locked, lockedRotation: next.rotation })}> - {children} - - ) -} - -// ProfilesProvider reconciles the local roster with the shared App Group one before `loaded` flips, -// so a screen mounting earlier would read (and could persist) the wrong profiles. This holds -// everything nested inside it, and the splash screen, until that's done, while ProfilesProvider sits -// above it and starts reconciling immediately. Settings and stats need no gate: they live in the -// Redux store, which Providers' PersistGate already holds until rehydrated. -function GatedApp({ children }: { children: ReactNode }) { - const { loaded } = useProfiles() - return ( - - {children} - - ) -} - -// The toaster only gets expo-haptics while the player's Vibrate setting is on, so its haptic ticks -// follow the same setting as every other haptic in the app. Today they can't fire anyway (the only -// controls that tick, the history and clear buttons, are off below), but this keeps it that way if -// either is ever shown. Must render inside Providers, whose FeedbackBridge provides the setting. -function HapticGatedToastProvider({ children }: { children: ReactNode }) { - const { settings } = useHapticSettings() - return ( - - {children} - - ) +export default function Layout() { + return } - -export default function RootLayout() { - return ( - - - - - - - - - - - {/* Wraps the stack (rather than sitting beside it like UpdateDialog) because - Home's useAutoShowGuide() and the Settings dialog's "How to Play" row reach - it through context (see GuideHost). Inside the toast provider and - OrientationBridge because the card renders through a Portal and rotates with - the shared reading, and inside the gate so it mounts together with Home rather - than before it. */} - - - - {/* historyButton/clearButton off: no game has a toast history to open, and a - control row for it above the toast stack would go nowhere. limit pins the - package's current default (3) so a future toaster release can't change it. */} - - - - - - - - - - - - - ) -} - -const styles = StyleSheet.create({ - root: { flex: 1 } -}) diff --git a/src/app/achievements.tsx b/src/app/achievements.tsx deleted file mode 100644 index 4382830..0000000 --- a/src/app/achievements.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useAutoPaperTheme } from '@rific/auto-paper' -import { getAchievementCatalogRows } from '@tastic/achievements' -import { FakeLandscapeView, rotateInsets, useRotation } from '@tastic/core' -import { AchievementCatalogSection, ActivityStatSection, BaseStatsScreen, StatRow, StatSection, usePopoverHost, useWellColors } from '@tastic/hud' -import { ProfileChip, ProfilePicker } from '@tastic/profile' -import { useMemo, useState } from 'react' -import { StyleSheet, View } from 'react-native' -import { Text } from 'react-native-paper' -import { useSafeAreaInsets } from 'react-native-safe-area-context' - -import { ACHIEVEMENT_CATALOG } from '@/constants/achievements' -import { MONO_FONT } from '@/constants/fonts' -import { useGameStats } from '@/hooks/useGameStats' -import { useProfiles } from '@/hooks/useProfiles' -import { safeBack } from '@/utils/navigation' -import { getOverallTotals, getProfileRankings, getProfileStatsView, ProfileRanking } from '@/utils/statsEngine' -import { DEFAULT_PROFILE_STATS } from '@/utils/statsValidation' - -interface ProfileRankingRowProps { - ranking: ProfileRanking - fg: string - fgMuted: string -} - -// One row per saved profile - a ProfileChip (that profile's own saved color and tag) plus its name -// and record. Stays BoxHockey-local: @tastic/hud's StatRow has no notion of a leading identity chip. -function ProfileRankingRow({ ranking, fg, fgMuted }: ProfileRankingRowProps) { - const { profile, wins, losses, played } = ranking - return ( - - - - - {profile.name} - - - - {wins}-{losses} - ({played} played) - - - ) -} - -export default function AchievementsScreen() { - const { dark, colors: themeColors } = useAutoPaperTheme() - // Unlike index.tsx/loadout.tsx, this screen previously had no orientation handling at all - it - // always rendered right-side-up regardless of how the phone was actually being held, the one - // screen in the app that didn't rotate along with everywhere else. rotateInsets remaps the - // device's own raw (never-rotated) safe-area reading onto whichever edge it actually corresponds - // to once FakeLandscapeView below visually rotates the content - see index.tsx's identical - // comment for the full reasoning. - const rotation = useRotation() - const insets = rotateInsets(useSafeAreaInsets(), rotation) - const { stats, unlockedAchievements, resetAll } = useGameStats() - const { profiles } = useProfiles() - const profilePickerHost = usePopoverHost() - // The rankings' text sits in a StatSection well, so it takes the well's own colors (@tastic/hud's - // useWellColors: onSurface, onSurfaceVariant), the same ones hud's StatRow and AchievementRow default to. - const { fg, fgMuted } = useWellColors() - - // null = "All Profiles" (device-wide). Self-heals to null if the selected profile is deleted from - // another still-mounted screen - effectiveProfileId simply stops matching any entry in `profiles`. - const [selectedProfileId, setSelectedProfileId] = useState(null) - const effectiveProfileId = selectedProfileId && profiles.some((p) => p.id === selectedProfileId) ? selectedProfileId : null - const profileBucket = effectiveProfileId ? (stats.profiles[effectiveProfileId] ?? DEFAULT_PROFILE_STATS) : null - // The same StatsState-shaped object every predicate and helper already reads - statsView === stats - // for "All Profiles", so every section collapses to the device-wide view in that case. - const statsView = profileBucket ? getProfileStatsView(profileBucket) : stats - - const overall = useMemo(() => getOverallTotals(statsView), [statsView]) - const rankings = useMemo(() => getProfileRankings(profiles, stats.profiles), [profiles, stats.profiles]) - const goalDiff = statsView.goals.scored - statsView.goals.conceded - - return ( - - - {profiles.length > 0 && setSelectedProfileId(profile?.id ?? null)} />} - - - - - - - - - - - 0 ? `+${goalDiff}` : String(goalDiff)} /> - - - - - - - - - - - {/* All Profiles keeps the seat framing (whoever sat where); a selected profile switches to - that profile's own record, where p1Wins+p2Wins is always THIS profile's own win count. */} - - {profileBucket ? ( - <> - - - - - ) : ( - <> - - - - - )} - - - - {(['classic', 'gauntlet', 'gravity'] as const).map((variant) => ( - - ))} - - - {/* A leaderboard across profiles is inherently a cross-profile question, so it only appears - on "All Profiles" - once you've drilled into one profile it answers a different question. */} - {effectiveProfileId === null && rankings.length > 0 && ( - - {rankings.map((ranking) => ( - - ))} - - )} - - - - - - - ) -} - -const styles = StyleSheet.create({ - boldText: { - fontWeight: 'bold' - }, - profileValue: { - alignItems: 'center', - flexDirection: 'row', - gap: 8 - }, - // Everything visible sits inside FakeLandscapeView now, which needs a real (not shrink-wrapped) - // full-bleed box to size its own ±90° dimension-swap math against correctly - see index.tsx's - // identical style/comment. - rotatable: { - flex: 1 - }, - statRow: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between' - } -}) diff --git a/src/app/index.tsx b/src/app/index.tsx index 89b5644..8cd4e9a 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -1,114 +1,3 @@ -import { useAutoPaperTheme } from '@rific/auto-paper' -import { Button, IconButton } from '@rific/feedback-press' -import { FakeLandscapeView, rotateInsets, useRotation } from '@tastic/core' -import { useAutoShowGuide } from '@tastic/hud/guide' -import { router } from 'expo-router' -import { useCallback, useState } from 'react' -import { StyleSheet, View } from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { HomeScreen } from '@/shell/screens/HomeScreen' -import { HeroTitle } from '@/components/HeroTitle' -import { SettingsDialog } from '@/components/SettingsDialog' -import { useSettings } from '@/hooks/useSettings' -import { GameMode } from '@/types' - -export default function HomeScreen() { - const { settings, setSettings } = useSettings() - // Follows the device's current physical tilt (see @tastic/core's useOrientationState, which - // useRotation reads internally), so the title screen rotates along with everywhere else as soon - // as the phone is turned. Lock Orientation (see SettingsDialog) pins it app-wide by freezing the - // shared reading itself (OrientationProvider's freezeWhileLocked in _layout.tsx), so neither this - // nor FakeLandscapeView below takes a `locked` argument. - // app.json is portrait-locked at the OS level now (no more real OS rotation to rely on) - see - // FakeLandscapeView below, which fakes the rest entirely in JS from this hook's live - // accelerometer reading. - const rotation = useRotation() - - const [settingsOpen, setSettingsOpen] = useState(false) - // First launch only (and once per session): opens the how-to-play cards over this screen. Called - // here and nowhere else so it can never fire over a live match or a two-seat loadout. Home is the - // one screen with a single reader, before any seating. See GuideHost / @tastic/hud/guide. - useAutoShowGuide() - const { colors, dark } = useAutoPaperTheme() - // react-native-safe-area-context always reports insets relative to the device's own fixed - // physical frame (the OS thinks the interface is still portrait-locked and never rotates, so it - // has no idea FakeLandscapeView below is rotating the content) - rotateInsets remaps them onto - // whichever edge they actually correspond to once visually rotated, using the same rotation - // FakeLandscapeView itself renders with, so `top`/`right` below always mean the screen's real, - // visual edges regardless of how the phone is being held. - const insets = rotateInsets(useSafeAreaInsets(), rotation) - - // High-contrast retro look: literal black/white, flipped by appearance, rather than auto-paper's - // own (slightly tinted) background role. - const bg = dark ? '#000000' : '#FFFFFF' - const fg = dark ? '#FFFFFF' : '#000000' - - const chooseMode = useCallback( - (gameMode: GameMode) => { - setSettings({ gameMode }) - router.push({ pathname: '/loadout', params: { mode: gameMode } }) - }, - [setSettings] - ) - - return ( - - {/* Everything below reads correctly no matter which way the phone is actually being held: - see @tastic/core's FakeLandscapeView. SettingsDialog stays outside it: it renders through a - Portal over the whole screen, and BaseSettingsDialog lays itself out in its own frame turned - by the `rotation` passed below (see @tastic/hud's RotatedFrame). */} - - {/* Same top-left slot every @tastic title screen uses for this - see LightCycles' own - identical trophy IconButton. */} - router.push('/achievements')} accessibilityLabel='Stats & Achievements' /> - setSettingsOpen(true)} accessibilityLabel='Settings' /> - - - - - - - - - - setSettingsOpen(false)} rotation={rotation} settings={settings} setSettings={setSettings} showHowToPlay /> - - ) -} - -const styles = StyleSheet.create({ - actionButton: { - minWidth: 180 - }, - actions: { - alignItems: 'center', - gap: 16 - }, - container: { - flex: 1 - }, - // Owns the flex-centering layout `container` used to apply directly - now one level deeper, since - // everything visible sits inside FakeLandscapeView, which needs a real (not shrink-wrapped) - // full-bleed box to size its own absolutely-positioned children (the settings cog) against - // correctly. - rotatable: { - alignItems: 'center', - flex: 1, - gap: 48, - justifyContent: 'center' - }, - topLeft: { - left: 8, - position: 'absolute', - top: 8 - }, - topRight: { - position: 'absolute', - right: 8, - top: 8 - } -}) +export default HomeScreen diff --git a/src/app/loadout.tsx b/src/app/loadout.tsx deleted file mode 100644 index c85ab83..0000000 --- a/src/app/loadout.tsx +++ /dev/null @@ -1,636 +0,0 @@ -import { defaultColors, getThirdColor, useAutoPaperTheme, useThemeSettings } from '@rific/auto-paper' -import { FakeLandscapeView, getViewRotation, rotateInsets, useOrientationState } from '@tastic/core' -import { CornerActionButtons, MenuOption, PlayerSetupPanel, PressAwayOverlay, ReadyButton, SectionedDropdown, SharedActionBand, usePopoverHost } from '@tastic/hud' -import { avoidSeatColorClash, type Profile as BaseProfile, profileSeatColor } from '@tastic/profile' -import { DualZoneLayout, needsSharedNeutralZone, useDualZoneLayout } from '@tastic/split-screen' -import { router, useFocusEffect, useLocalSearchParams } from 'expo-router' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Platform, StyleSheet, View } from 'react-native' -import Animated from 'react-native-reanimated' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useDispatch, useSelector } from 'react-redux' - -import { LOADOUT_SHARED_CONTROLS_IDS, LoadoutSharedControls } from '@/components/LoadoutSharedControls' -import { SettingsDialog } from '@/components/SettingsDialog' -import { DEFAULT_P1_COLOR, DEFAULT_P2_COLOR, LOADOUT_PANEL_SWAP_FADE_MS } from '@/constants/game' -import { useProfiles } from '@/hooks/useProfiles' -import { useSettings } from '@/hooks/useSettings' -import { liveplayActions } from '@/redux/liveplaySlice' -import { seatColorsActions } from '@/redux/seatColorsSlice' -import { DEFAULT_SETTINGS } from '@/redux/settingsSlice' -import type { AppDispatch, RootState } from '@/redux/store' -import { BoardVariant, ControlScheme, FrictionLevel, InterceptSpeed, Player, PuckSizeLevel, ShotPreviewLength, TipoffMode } from '@/types' -import { AI_DIFFICULTY_LABELS, AIDifficulty } from '@/utils/aiOpponent' -import { BOARD_VARIANT_LABELS } from '@/utils/board' -import { humanPlayersFor, parseGameMode } from '@/utils/gameMode' -import { safeBack } from '@/utils/navigation' - -// What @tastic/profile's avoidSeatColorClash moves seat 2 to when both seats land on one color: a -// profile picked for one seat can be saved as the other seat's color, with no swap step behind it. -const SEAT_DEFAULT_COLORS = [DEFAULT_P1_COLOR, DEFAULT_P2_COLOR] as const - -const BOARD_VARIANT_DESCRIPTIONS: Record = { - classic: 'Off-center holes', - gauntlet: 'Three alternating walls', - gravity: 'Classic walls + corner pushes' -} -const BOARD_VARIANT_OPTIONS: MenuOption[] = (['classic', 'gauntlet', 'gravity'] as BoardVariant[]).map((value) => ({ value, label: BOARD_VARIANT_LABELS[value], description: BOARD_VARIANT_DESCRIPTIONS[value] })) - -// Independent of boardVariant, but rides along in the same popover as one of its rows - see -// LoadoutSharedControls' own comment. A single option rather than an on/off pair: presence in a -// one-item multi-select's value array already means "on," so there's nothing an "off" row would -// add beyond what tapping the "on" row a second time (to un-toggle it) already does. -const RINK_OPTION: MenuOption<'on'> = { value: 'on', label: 'Rink', description: 'Rounded corners + net pocket', icon: 'rounded-corner' } - -const FRICTION_OPTIONS: MenuOption[] = [ - { value: 'high', label: 'Slow', description: 'Puck stops quickly', icon: 'speedometer-slow' }, - { value: 'medium', label: 'Medium', description: 'Standard glide', icon: 'speedometer-medium' }, - { value: 'low', label: 'Fast', description: 'Puck glides farther', icon: 'speedometer' } -] - -const INTERCEPT_SPEED_OPTIONS: MenuOption[] = [ - { value: 'low', label: 'Patient', description: 'Only once it’s nearly stopped', icon: 'timer-sand-full' }, - { value: 'medium', label: 'Alert', description: 'Jump on a slow roll', icon: 'hand-back-right-outline' }, - { value: 'high', label: 'Aggressive', description: 'Take it while it’s still gliding', icon: 'lightning-bolt' } -] - -const PUCK_SIZE_OPTIONS: MenuOption[] = [ - { value: 'small', label: 'Small', description: 'Tighter goal & gaps', icon: 'circle-small' }, - { value: 'medium', label: 'Medium', description: 'Standard size', icon: 'circle-medium' }, - { value: 'large', label: 'Large', description: 'Wider goal & gaps', icon: 'circle-outline' } -] - -const SCORE_TO_WIN_OPTIONS: MenuOption[] = [3, 5, 7, 10].map((value) => ({ value, label: String(value) })) - -const SHOT_PREVIEW_LENGTH_OPTIONS: MenuOption[] = [ - { value: 'off', label: 'Off', description: 'No trajectory preview', icon: 'eye-off-outline' }, - { value: 'short', label: 'Short', description: 'Just the aim direction', icon: 'ray-start' }, - { value: 'medium', label: 'Medium', description: 'Occasionally reveals a bank', icon: 'ray-start-end' }, - { value: 'long', label: 'Long', description: 'Shows most bank shots', icon: 'ray-start-arrow' } -] - -const CONTROL_SCHEME_OPTIONS: MenuOption[] = [ - { value: 'flick', label: 'Flick', description: 'Swipe fast: power comes from speed', icon: 'gesture-swipe' }, - { value: 'stick', label: 'Stick', description: 'Swing into the puck to hit it', icon: 'hockey-sticks' }, - { value: 'pull', label: 'Pull', description: 'Drag back, release to fire', icon: 'arrow-collapse' }, - { value: 'push', label: 'Push', description: 'Drag toward your shot, release to fire', icon: 'arrow-expand' } -] - -const ALL_TIPOFF_MODE_OPTIONS: MenuOption[] = [ - { value: 'coinFlip', label: 'Coin Flip', description: 'Random pick starts, loser gets the puck', icon: 'hand-coin-outline' }, - { value: 'faceoff', label: 'Face-Off', description: 'Race the tap on every score', icon: 'hockey-puck' }, - { value: 'tripleTap', label: 'Triple Tap', description: 'Tap 3 times together, then race to swipe', icon: 'gesture-double-tap' } -] -// 'tripleTap' is touch/mobile only - no keyboard equivalent (see game.tsx), so it's hidden from the -// picker entirely on web rather than offered and left unplayable there. Computed once at module -// scope since Platform.OS never changes at runtime; also keeps handleRandomizeMatchSettings below -// from ever landing on it on web, with no separate edit needed there. -const TIPOFF_MODE_OPTIONS: MenuOption[] = Platform.OS === 'web' ? ALL_TIPOFF_MODE_OPTIONS.filter((o) => o.value !== 'tripleTap') : ALL_TIPOFF_MODE_OPTIONS - -const AI_DIFFICULTY_OPTIONS: MenuOption[] = [ - { value: 'easy', label: AI_DIFFICULTY_LABELS.easy, icon: 'emoticon-happy-outline' }, - { value: 'medium', label: AI_DIFFICULTY_LABELS.medium, icon: 'emoticon-neutral-outline' }, - { value: 'hard', label: AI_DIFFICULTY_LABELS.hard, icon: 'emoticon-devil-outline' } -] - -function pickRandom(options: readonly { value: T }[]): T { - return options[Math.floor(Math.random() * options.length)].value -} - -export default function LoadoutScreen() { - // Read only from this screen's own route param, never from useSettings().settings.gameMode: - // the route param is the mode this screen was opened for (settings.gameMode is only the last mode - // saved). - const params = useLocalSearchParams<{ mode: string }>() - const gameMode = useMemo(() => parseGameMode(params.mode), [params.mode]) - const humanPlayers = useMemo(() => humanPlayersFor(gameMode), [gameMode]) - // CPU (when present at all) is always player 2 - see humanPlayersFor's own comment. Read this - // early: the guest/CPU color plumbing below needs it before the panels themselves do. - const p2IsHuman = humanPlayers.includes(2) - - const { settings, setSettings } = useSettings() - const { profiles, lastSelected, updateProfile, selectProfile } = useProfiles() - // orientationMode just follows the device's current physical tilt (see @tastic/core's - // useOrientationState), CPU games included - how you're holding the phone right now decides the - // panel arrangement, not a stored per-round choice. This is what lets two players sit - // shoulder-to-shoulder and pick a color at the same time once the phone is turned sideways, - // instead of a portrait-narrow column - and for vs-CPU, it's simply whichever way the solo player - // is holding it. Lock Orientation (see SettingsDialog) pins it app-wide by freezing the shared - // reading itself (OrientationProvider's freezeWhileLocked in _layout.tsx), so this read takes no - // `locked` argument. app.json is portrait-locked at the OS level now (no more real OS rotation to - // rely on: see @tastic/split-screen's FakeLandscapeView below, which fakes the rest entirely in - // JS from this hook's live accelerometer reading). - const { orientationMode, p1OnRight, upsideDown, resolved: p1OnRightResolved } = useOrientationState() - - // The panel area's own layout (which branch renders, which side each panel is on) lags one fade - // behind the live orientationMode/p1OnRight/upsideDown above - see useDualZoneLayout's own - // comment. Also drives the solo (vs-CPU) branch's own row below - that one doesn't rotate - // anything, but still reorders/fades across a portrait<->landscape change the same way. - // - // 2 Player stays face-to-face however the phone is held: P1 in the bottom half, P2 in the top - // half, flipped only for an upside-down hold, the same split /game plays on (its board never turns - // a quarter turn, only flips upside down), so each player gets the same half of the phone here - // and in the match. Following a landscape tilt used to put P1 at the phone's top end here and - // then move them to the bottom on Ready. - const fixedSeats = gameMode === 'twoPlayer' - const { panelLayout, panelFadeStyle } = useDualZoneLayout(fixedSeats ? 'faceToFace' : orientationMode, fixedSeats ? true : p1OnRight, fixedSeats || p1OnRightResolved, fixedSeats ? orientationMode === 'faceToFace' && upsideDown : upsideDown, LOADOUT_PANEL_SWAP_FADE_MS) - - const [settingsOpen, setSettingsOpen] = useState(false) - // react-native-safe-area-context always reports insets relative to the device's own fixed - // physical frame (the OS thinks the interface is still portrait-locked and never rotates, so it - // has no idea FakeLandscapeView below is rotating the content) - rotateInsets remaps them onto - // whichever edge they actually correspond to once visually rotated, using the same committed - // rotation FakeLandscapeView itself renders with, so `top`/`left`/`right` below always mean the - // screen's real, visual edges regardless of how the phone is being held. - const rotation = getViewRotation(panelLayout.orientationMode, panelLayout.p1OnRight, panelLayout.upsideDown) - const insets = rotateInsets(useSafeAreaInsets(), rotation) - const { colors: themeColors, dark } = useAutoPaperTheme() - const { set: setThemeColor } = useThemeSettings() - const p1Color = themeColors.primary - const p2Color = themeColors.secondary - - // Typed explicitly (rather than bare useDispatch()) because @tastic/profile's action creators - // (seatColorsActions below) return a plain `{ payload, type }` interface with no index signature, - // which react-redux's default `useDispatch()` (typed Dispatch) rejects - same fix - // already applied in hooks/useProfiles.tsx for this package's other action creators. - const dispatch = useDispatch() - // A seat's live color comes from one of three independently-remembered places depending on what's - // currently in that seat - a selected profile's pick in BoxHockey (or its own saved color without - // one), a CPU opponent's own remembered color, or (human, no profile) a guest's own remembered - // color - never one shared slot the way this used to work, which let a profile's live color drift - // from its saved one and made a CPU's color fight a guest's over a single remembered value. All - // three live in the `seatColors` Redux slice (see redux/seatColorsSlice.ts - rehydrated by the - // time this screen mounts, same PersistGate argument as Theme.tsx's own read of `state.theme`); a - // profile's own saved color is never written back to (see the profile-color effect below for why, - // and app/profiles.tsx's own ProfilesManager, the only place that's actually edited). - const guestColors = useSelector((state: RootState) => state.seatColors.lastGuestColor) - const cpuColor = useSelector((state: RootState) => state.seatColors.lastCpuColor) - // Each profile's own color pick in BoxHockey, by profile id, read through @tastic/profile's - // profileSeatColor (see createSeatColorsSlice's doc). It follows the profile, not the seat: kept - // through a switch to another profile and back, a move to the other seat, and a relaunch. - const profileColors = useSelector((state: RootState) => state.seatColors.profileColors) - - const p1Profile = profiles.find((p) => p.id === lastSelected[1]) ?? null - const p2Profile = p2IsHuman ? (profiles.find((p) => p.id === lastSelected[2]) ?? null) : null - - // Kept in sync via their own trivial effects (never read during render, only written - a plain - // render-body assignment trips this project's react-hooks/refs lint rule) purely so the - // focus-reset effect below can read each seat's CURRENT resolved profile / persisted guest / - // persisted CPU color / profile picks without listing profiles/guestColors/cpuColor/ - // profileColors themselves as its own dependencies - profiles is a brand new array from - // useProfiles() on every one of its own re-renders, and depending on any of these reactively - // there would re-fire that effect (see useFocusEffect's own re-run semantics: it re-invokes its - // callback immediately, while still focused, whenever the callback's identity changes) merely - // because one of them changed while this screen stays continuously focused. - const p1ProfileRef = useRef(p1Profile) - const p2ProfileRef = useRef(p2Profile) - const guestColorsRef = useRef(guestColors) - const cpuColorRef = useRef(cpuColor) - const profileColorsRef = useRef(profileColors) - useEffect(() => { - p1ProfileRef.current = p1Profile - }, [p1Profile]) - useEffect(() => { - p2ProfileRef.current = p2Profile - }, [p2Profile]) - useEffect(() => { - guestColorsRef.current = guestColors - }, [guestColors]) - useEffect(() => { - cpuColorRef.current = cpuColor - }, [cpuColor]) - useEffect(() => { - profileColorsRef.current = profileColors - }, [profileColors]) - - // Keeps each seat's live theme color matching what it's actually supposed to be right now. - // Mount-time and reactive only: lastSelected changing (a fresh tap-select in either seat button, - // the swap when a seat picks the other seat's profile, or something *outside* this screen's own - // pickers - e.g. deleting the currently-selected profile from Manage), a profile's pick changing, - // and the profiles roster itself resolving. Deliberately does NOT run on every render (profiles is - // a brand new array from useProfiles() on every one of its own re-renders, so it's read here, not - // depended on - profiles.length stands in as a coarse "profiles finished loading" signal instead). - useEffect(() => { - const nextP1 = p1Profile ? profileSeatColor(p1Profile, profileColors) : guestColors[1] - const nextP2 = avoidSeatColorClash(nextP1, p2Profile ? profileSeatColor(p2Profile, profileColors) : p2IsHuman ? guestColors[2] : cpuColor, SEAT_DEFAULT_COLORS) - if (nextP1.toLowerCase() === p1Color.toLowerCase() && nextP2.toLowerCase() === p2Color.toLowerCase()) return - setThemeColor({ color: { primary: nextP1, secondary: nextP2, tertiary: getThirdColor(nextP1, nextP2) } }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lastSelected[1], lastSelected[2], p2IsHuman, guestColors, cpuColor, profileColors, profiles.length]) - - // /loadout pushes /game and /profiles on top of itself and returns to this same screen via - // router.back()/safeBack() - a pop that reveals this SAME, still-mounted instance rather than a - // fresh mount (see the `ready`-reset useFocusEffect below, which exists for the same reason). On - // every genuine focus regain - initial mount, and returning here from /game or /profiles - - // re-derive each seat's live color from its authoritative source (profile, honoring its pick in - // BoxHockey / guest / CPU - see redux/seatColorsSlice.ts), so editing a profile's own saved color - // via /profiles is picked up the instant you're back (a new color for the profile drops its pick, - // see profileSeatColor). - // - // Deliberately triggers on lastSelected[seat]/p2IsHuman only - the same "a real reason to reapply - // this seat's color" identity the reactive effect above keys its own reapplication on - and reads - // profiles/guestColors/cpuColor/profileColors through the refs above instead of depending on - // them directly. useFocusEffect re-invokes this callback immediately, while still focused, - // whenever the callback's own identity changes (not just on an actual focus/blur transition) - so - // listing guestColors/cpuColor/profiles/profileColors here directly would reapply on every one - // of their changes even with no real navigation event. - useFocusEffect( - useCallback(() => { - const guest = guestColorsRef.current - const cpu = cpuColorRef.current - const p1Prof = p1ProfileRef.current - const p2Prof = p2ProfileRef.current - const picks = profileColorsRef.current - const nextP1 = p1Prof ? profileSeatColor(p1Prof, picks) : guest[1] - const nextP2 = avoidSeatColorClash(nextP1, p2Prof ? profileSeatColor(p2Prof, picks) : p2IsHuman ? guest[2] : cpu, SEAT_DEFAULT_COLORS) - setThemeColor({ color: { primary: nextP1, secondary: nextP2, tertiary: getThirdColor(nextP1, nextP2) } }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lastSelected[1], lastSelected[2], p2IsHuman, setThemeColor]) - ) - - // Routes a seat's own just-changed color to whichever slot actually owns it right now - kept as - // that profile's pick in BoxHockey (the "BoxHockey only" caption on the seat button's Color row) - // when a profile's selected there (see redux/seatColorsSlice.ts). Picking the profile's own color - // drops the pick. - const persistSeatColor = useCallback( - (seat: Player, hex: string) => { - const profile = seat === 1 ? p1Profile : p2Profile - if (profile) { - dispatch(seatColorsActions.setProfileColor({ profile, color: hex })) - return - } - if (seat === 2 && !p2IsHuman) { - dispatch(seatColorsActions.setLastCpuColor(hex)) - return - } - dispatch(seatColorsActions.setLastGuestColor({ seat, color: hex })) - }, - [p1Profile, p2Profile, p2IsHuman, dispatch] - ) - - // Picking the other slot's exact current color swaps the two instead of no-op'ing - only - // reachable at all when that slot's own picker allowed it (allowSwapTaken: vs-CPU only, where - // the "other" color there is only ever the CPU's, not a second real person's choice). Each side of - // a swap persists independently per its own seat rule above - a swap into a profile-selected seat - // is kept as that profile's pick, and the CPU/guest side it came from persists normally. - const handleP1ColorChange = useCallback( - (hex: string) => { - const swapped = hex.toLowerCase() === p2Color.toLowerCase() - const nextP2 = swapped ? p1Color : p2Color - setThemeColor({ color: { primary: hex, secondary: nextP2, tertiary: getThirdColor(hex, nextP2) } }) - persistSeatColor(1, hex) - if (swapped) persistSeatColor(2, nextP2) - }, - [setThemeColor, p1Color, p2Color, persistSeatColor] - ) - const handleP2ColorChange = useCallback( - (hex: string) => { - const swapped = hex.toLowerCase() === p1Color.toLowerCase() - const nextP1 = swapped ? p2Color : p1Color - setThemeColor({ color: { primary: nextP1, secondary: hex, tertiary: getThirdColor(nextP1, hex) } }) - persistSeatColor(2, hex) - if (swapped) persistSeatColor(1, nextP1) - }, - [setThemeColor, p1Color, p2Color, persistSeatColor] - ) - - // A seat's color follows its newly selected profile through the profile-color effect above (its - // pick, or its own color). Picking the other seat's profile swaps the two (see @tastic/profile's - // select), and each keeps its own pick, since a pick belongs to the profile, not the seat. Typed - // against @tastic/profile's own base Profile, not this app's own extended one (@/types' Profile, - // which adds controlScheme) - @tastic/hud's PlayerSetupPanel.onProfileSelect is typed against the - // base shape, and neither handler reads anything beyond .id anyway. - const handleP1ProfileSelect = useCallback((profile: BaseProfile | null) => selectProfile(1, profile?.id ?? null), [selectProfile]) - const handleP2ProfileSelect = useCallback((profile: BaseProfile | null) => selectProfile(2, profile?.id ?? null), [selectProfile]) - - // Unlike color above, this one *does* keep syncing back to a currently-selected profile's own - // saved control scheme for as long as it stays selected: control scheme has no "freely override it - // for one match" use case the way a paddle's color does. No swap-on-conflict needed here (unlike - // LightCycles' identically-shaped keyScheme handler) - pull/push isn't a shared physical resource - // the way a keyboard's WASD/arrow clusters are, so both players can happily use the same scheme at - // once. - const handleP1ControlSchemeChange = useCallback( - (value: ControlScheme) => { - setSettings({ controlScheme: { ...settings.controlScheme, 1: value } }) - if (lastSelected[1]) updateProfile(lastSelected[1], { controlScheme: value }) - }, - [setSettings, settings.controlScheme, lastSelected, updateProfile] - ) - const handleP2ControlSchemeChange = useCallback( - (value: ControlScheme) => { - setSettings({ controlScheme: { ...settings.controlScheme, 2: value } }) - if (lastSelected[2]) updateProfile(lastSelected[2], { controlScheme: value }) - }, - [setSettings, settings.controlScheme, lastSelected, updateProfile] - ) - - // Covers exactly the fields LoadoutSharedControls itself exposes - board shape, puck physics, - // match rules, and (vs-CPU only) AI difficulty, since that picker also lives in this shared row - // rather than a per-player panel the way LightCycles' cpuDifficulty does. Per-player stuff - // (colors, control scheme) and app-wide prefs (lock orientation) live outside this row and aren't - // "match settings" in the sense a player means when they ask to shuffle or reset the current - // round. - const handleRandomizeMatchSettings = useCallback(() => { - setSettings({ - boardVariant: pickRandom(BOARD_VARIANT_OPTIONS), - rink: Math.random() < 0.5, - friction: pickRandom(FRICTION_OPTIONS), - puckSize: pickRandom(PUCK_SIZE_OPTIONS), - interceptSpeed: pickRandom(INTERCEPT_SPEED_OPTIONS), - scoreToWin: pickRandom(SCORE_TO_WIN_OPTIONS), - shotPreviewLength: pickRandom(SHOT_PREVIEW_LENGTH_OPTIONS), - tipoffMode: pickRandom(TIPOFF_MODE_OPTIONS), - ...(gameMode === 'vsCpu' ? { aiDifficulty: pickRandom(AI_DIFFICULTY_OPTIONS) } : {}) - }) - }, [setSettings, gameMode]) - - const handleResetMatchSettings = useCallback(() => { - setSettings({ - boardVariant: DEFAULT_SETTINGS.boardVariant, - rink: DEFAULT_SETTINGS.rink, - friction: DEFAULT_SETTINGS.friction, - puckSize: DEFAULT_SETTINGS.puckSize, - interceptSpeed: DEFAULT_SETTINGS.interceptSpeed, - scoreToWin: DEFAULT_SETTINGS.scoreToWin, - shotPreviewLength: DEFAULT_SETTINGS.shotPreviewLength, - tipoffMode: DEFAULT_SETTINGS.tipoffMode, - ...(gameMode === 'vsCpu' ? { aiDifficulty: DEFAULT_SETTINGS.aiDifficulty } : {}) - }) - }, [setSettings, gameMode]) - - const [ready, setReady] = useState>({ 1: false, 2: false }) - - // Every time this screen (re)gains focus - first arrival from the title screen, or coming back - // here via the post-game "Loadout" button - Ready starts false again. Without this, popping back - // to an already-mounted loadout whose players both left it Ready would immediately re-trigger the - // all-ready effect below and bounce straight back into /game. - useFocusEffect( - useCallback(() => { - setReady({ 1: false, 2: false }) - }, []) - ) - - // Read through a ref rather than listed as a dependency of the effect below: this screen stays - // mounted (and still all Ready) under /game, so a settings change made there (toggling Lock - // Orientation or Edge Guard in /game's Settings dialog, or the lock recording its angle) would - // otherwise re-run that effect and push a second /game. - const settingsRef = useRef(settings) - useEffect(() => { - settingsRef.current = settings - }, [settings]) - - useEffect(() => { - if (!humanPlayers.every((p) => ready[p])) return - // `gameMode` (this screen's own route param), not settings.gameMode: the route param is the mode - // this loadout was opened for. - dispatch(liveplayActions.setActiveRoundSettings({ ...settingsRef.current, gameMode })) - router.push('/game') - }, [ready, humanPlayers, gameMode, dispatch]) - - const bg = dark ? '#000000' : '#FFFFFF' - const fg = dark ? '#FFFFFF' : '#000000' - const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)' - - const isPortrait = panelLayout.orientationMode === 'faceToFace' - // See @tastic/split-screen's needsSharedNeutralZone for the full reasoning - extracted there - // once this exact boolean turned up independently hand-duplicated across LightCycles/AirHockey/ - // Snake too. - const showMetaInSharedBand = needsSharedNeutralZone(panelLayout.orientationMode, humanPlayers.length) - - // Where P2's own press-away zone lives on screen - matches panelLayout (the already-committed, - // currently-painted layout), not the live orientationMode/p1OnRight, since it needs to agree with - // whichever arrangement is actually on screen right now, mid-fade included. Portrait always - // renders P2's panel in the top half (see DualZoneLayout below); landscape puts it on whichever - // side panelLayout.p1OnRight says isn't P1's. Only meaningful in two-player mode - vsCpu's P2/CPU - // slot shares controlsHost instead (see p2Host below), so it never needs a zone of its own. - const p2ZoneStyle = gameMode === 'twoPlayer' ? (isPortrait ? styles.p2ZoneTop : panelLayout.p1OnRight ? styles.p2ZoneLeft : styles.p2ZoneRight) : null - - // Vs-CPU only has one human slot, so its Ready toggle renders standalone below both panels - // instead of embedded in this panel. - const showReadyButton = gameMode === 'twoPlayer' - // P1's own panel and the shared controls row always share one host - mutually exclusive - // regardless of game mode, since both sit in the same near/bottom zone. Vs-CPU additionally - // folds P2/CPU's panel into this same host: only one human is ever driving both slots there, so - // having both YOU's and CPU's popovers open at once is just clutter. Two-player mode keeps P2 on - // its own independent host, since two real people editing at once is the whole point there - - // lobby.tsx (rather than PlayerSetupPanel's own internal fallback) owns it explicitly so the - // press-away zones below can close it without a P1-side tap ever reaching into P2's own popover, - // or vice versa. - const controlsHost = usePopoverHost() - const p2PanelHost = usePopoverHost() - const p2Host = gameMode === 'vsCpu' ? controlsHost : p2PanelHost - // Vs-CPU's own standalone Ready button (below, not per-panel - see showReadyButton) is centered - // under the whole row, not under either player's own trigger, so it doesn't share a center with - // an open popover the way a two-player panel's *embedded* Ready button does. A side player's - // popover is centered on that player's own (off-center) trigger instead, so it can't fully cover - // the wider gap between its edge and the standalone button's - leaving a sliver of the button's - // own border peeking out from beside/behind the popover. Faded out instead while any player's - // popover is open, rather than trying to reconcile the two different centers. - const anyPlayerPickerOpen = !!(controlsHost.openId?.startsWith('p1-') || controlsHost.openId?.startsWith('p2-')) - - // Orientation is derived live from how the phone is held (see useDeviceOrientation) now, not a - // per-round choice here - this row no longer takes orientationMode/orientationModeOptions/ - // onOrientationModeChange at all. - const sharedControlsRow = ( - setSettings({ boardVariant: value })} - rink={settings.rink} - rinkOption={RINK_OPTION} - onRinkChange={(value) => setSettings({ rink: value })} - friction={settings.friction} - frictionOptions={FRICTION_OPTIONS} - onFrictionChange={(value) => setSettings({ friction: value })} - interceptSpeed={settings.interceptSpeed} - interceptSpeedOptions={INTERCEPT_SPEED_OPTIONS} - onInterceptSpeedChange={(value) => setSettings({ interceptSpeed: value })} - puckSize={settings.puckSize} - puckSizeOptions={PUCK_SIZE_OPTIONS} - onPuckSizeChange={(value) => setSettings({ puckSize: value })} - scoreToWin={settings.scoreToWin} - scoreToWinOptions={SCORE_TO_WIN_OPTIONS} - onScoreToWinChange={(value) => setSettings({ scoreToWin: value })} - shotPreviewLength={settings.shotPreviewLength} - shotPreviewLengthOptions={SHOT_PREVIEW_LENGTH_OPTIONS} - onShotPreviewLengthChange={(value) => setSettings({ shotPreviewLength: value })} - tipoffMode={settings.tipoffMode} - tipoffModeOptions={TIPOFF_MODE_OPTIONS} - onTipoffModeChange={(value) => setSettings({ tipoffMode: value })} - // Omitted (leaving LoadoutSharedControls' own actionsRow unrendered) whenever - // showMetaInSharedBand is about to fold randomize/reset into its own merged header row below - // instead - otherwise both would render at once. - onRandomize={showMetaInSharedBand ? undefined : handleRandomizeMatchSettings} - onReset={showMetaInSharedBand ? undefined : handleResetMatchSettings} - accentColor={themeColors.tertiary} - mutedColor={fgMuted} - onAccentColor={themeColors.onTertiary} - dark={dark} - /> - ) - - // Only wrapped for showMetaInSharedBand (see its own comment above) - everywhere else back/ - // settings stay in their usual top corners (CornerActionButtons below) and randomize/reset stay - // in LoadoutSharedControls' own row above the trigger gauges. SharedActionBand owns the "is the - // open popover actually one of mine" self-elevation check internally, given just the resolved - // boolean below. - const sharedControlsPopoverOpen = controlsHost.openId !== null && LOADOUT_SHARED_CONTROLS_IDS.includes(controlsHost.openId) - const sharedControls = showMetaInSharedBand ? ( - setSettingsOpen(true)} onRandomize={handleRandomizeMatchSettings} onReset={handleResetMatchSettings} fg={fg} popoverOpen={sharedControlsPopoverOpen}> - {sharedControlsRow} - - ) : ( - sharedControlsRow - ) - - // Always 'P1'/'P2', regardless of game mode - see ProfilePicker's own doc for why the trigger uses - // this instead of "Player" (Player stays as the menu's own row, just not the idle label - using - // the same word for both would make it impossible to tell "nothing selected yet" from "Player - // deliberately selected" at a glance). - // Pull/push "feel" preference, human slots only (a CPU has no finger on the glass to have a - // "feel" about - see useAIOpponent.ts, which cancels this setting's sign back out regardless of - // what it's set to, so it's invisible to the CPU either way). PlayerSetupPanel itself has no - // opinion on this concept (see its own `secondPicker` doc) - this app constructs the same - // SectionedDropdown it always has and hands it in directly, sharing whichever host that panel's - // own `host` prop below resolves to so its popover keeps participating in the same - // open/elevate/press-away plumbing as the seat button above it. - const p1ControlSchemePicker = - // Each panel's seat button (one button, one dropdown: a Color row, then the profiles or the CPU's - // difficulty levels) places its own dropdown, inside this player's half in two-player mode (see - // @tastic/hud's PlayerSetupPanel), so there is no alignment to compute here. - const p1Panel = setReady((r) => ({ ...r, 1: !r[1] }))} dark={dark} showReadyButton={showReadyButton} secondPicker={p1ControlSchemePicker} secondPickerId='p1-controlScheme' profiles={profiles} selectedProfileId={lastSelected[1]} takenProfileId={p2IsHuman ? lastSelected[2] : null} guestLabel='P1' onProfileSelect={handleP1ProfileSelect} onManageProfiles={() => router.push('/profiles')} /> - // CPU slot (vsCpu, !p2IsHuman) gets no second picker at all - matches the old isHuman-gated - // showControlScheme check that used to live inside PlayerSetupPanel itself. - const p2ControlSchemePicker = p2IsHuman ? : undefined - const p2Panel = setReady((r) => ({ ...r, 2: !r[2] })) : undefined} dark={dark} showReadyButton={showReadyButton} secondPicker={p2ControlSchemePicker} secondPickerId={p2IsHuman ? 'p2-controlScheme' : undefined} profiles={p2IsHuman ? profiles : undefined} selectedProfileId={lastSelected[2]} takenProfileId={lastSelected[1]} guestLabel='P2' onProfileSelect={p2IsHuman ? handleP2ProfileSelect : undefined} cpuDifficulty={p2IsHuman ? undefined : settings.aiDifficulty} cpuDifficultyOptions={p2IsHuman ? undefined : AI_DIFFICULTY_OPTIONS} onCpuDifficultyChange={p2IsHuman ? undefined : (value: AIDifficulty) => setSettings({ aiDifficulty: value })} /> - - return ( - - {/* Everything below reads correctly no matter which way the phone is actually being held - - see @tastic/split-screen's FakeLandscapeView for why this is needed at all now that the app is - portrait-locked at the OS level (no more real OS rotation to rely on). Driven by panelLayout - (the same committed, already-fade-masked state DualZoneLayout itself renders from), not the - live orientationMode/p1OnRight/upsideDown above, so the visual rotation change and the panel - swap/reorder always land in the same fade-covered moment instead of one snapping ahead of the - other. SettingsDialog is deliberately left outside this wrapper - it renders as a centered - modal overlay via Portal, unaffected by (and not needing) this transform. */} - - {/* Press-away overlays for the loadout screen's own popovers (seat buttons, control - dropdowns) - see PressAwayOverlay's own comment for the paint-order trick that keeps real - controls directly tappable. P1's covers the whole screen (their own panel plus every shared - setting reads as "theirs" - see LoadoutSharedControls), while P2's is scoped to just their - own half (p2ZoneStyle) and, being a later sibling, takes priority over P1's within that rect. - - P2's overlay has to mount whenever *either* host has something open, not just p2Host - P1's - popover being open is exactly when P1's full-screen overlay is sitting underneath P2's zone - too, and if P2's own overlay were absent then (p2Host closed), a tap anywhere on P2's side - would fall straight through to P1's and close P1's dialog. Mounting it any time controlsHost - is open too - even though its own onPress (p2Host.close) is then a no-op - makes it "shield" - P2's zone from P1's overlay unconditionally, which is what actually keeps P2's side exempt - from P1's press-away regardless of whether P2 has anything open. */} - - {p2ZoneStyle && } - - {!showMetaInSharedBand && setSettingsOpen(true)} fg={fg} insets={insets} />} - - {gameMode === 'twoPlayer' ? ( - // See @tastic/split-screen's DualZoneLayout for the portrait-vs-landscape switch and its - // own note on why P1's zone stays unrotated while P2's rotates 180° in portrait. - - ) : ( - - {sharedControls} - - {!isPortrait && panelLayout.p1OnRight ? p2Panel : p1Panel} - {!isPortrait && panelLayout.p1OnRight ? p1Panel : p2Panel} - - setReady((r) => ({ ...r, 1: !r[1] }))} style={anyPlayerPickerOpen && styles.readyButtonHidden} /> - - )} - - - setSettingsOpen(false)} rotation={rotation} settings={settings} setSettings={setSettings} showHowToPlay /> - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1 - }, - p2ZoneLeft: { - bottom: 0, - left: 0, - position: 'absolute', - top: 0, - width: '50%' - }, - p2ZoneRight: { - bottom: 0, - position: 'absolute', - right: 0, - top: 0, - width: '50%' - }, - p2ZoneTop: { - height: '50%', - left: 0, - position: 'absolute', - right: 0, - top: 0 - }, - // Only used by vsCpu's stacked (non-rotated) layout below - two-player's own DualZoneLayout - // handles p1/p2 spacing itself. Matches PlayerSetupPanel's own pickerRow gap (12), not some - // wider "these are two separate panels" gap - vsCpu's human slot (seat button + control scheme) - // and CPU slot (seat button only) read as one evenly spaced row of triggers this way, rather than - // two independently-centered blocks whose different widths throw off screen-edge symmetry. - playersRow: { - flexDirection: 'row', - gap: 12 - }, - playersRowOpen: { - zIndex: 100 - }, - // See anyPlayerPickerOpen above - opacity 0 (not display:'none') so the button keeps its layout - // space and doesn't shift the row above it when it disappears/reappears. pointerEvents:'none' too - // - otherwise the sliver an open popover doesn't cover would still be a live, just-invisible tap - // target underneath it. - readyButtonHidden: { - opacity: 0, - pointerEvents: 'none' - }, - // Owns the flex-centering layout `container` used to apply directly - now one level deeper, since - // everything visible sits inside FakeLandscapeView, which needs a real (not shrink-wrapped) - // full-bleed box to size its own absolutely-positioned children (PressAwayOverlay, back/settings - // buttons) against correctly. - rotatable: { - alignItems: 'center', - flex: 1, - gap: 32, - justifyContent: 'center', - paddingHorizontal: 16 - }, - stackedZone: { - alignItems: 'center', - gap: 28 - } -}) diff --git a/src/app/profiles.tsx b/src/app/profiles.tsx index c5383a1..be78481 100644 --- a/src/app/profiles.tsx +++ b/src/app/profiles.tsx @@ -1,47 +1,3 @@ -import { defaultColors } from '@rific/auto-paper' -import { ProfilesScreen } from '@tastic/profile' +import { ProfilesRoute } from '@/shell/screens/ProfilesRoute' -import { useGameStats } from '@/hooks/useGameStats' -import { useProfiles } from '@/hooks/useProfiles' -import { ControlScheme } from '@/types' -import { safeBack } from '@/utils/navigation' - -// Neutral default (the swatch grid's own first color, not either seat's own current color) - a new -// profile isn't necessarily *for* whichever seat opened this screen (the whole point of a dedicated -// screen is P1 handing the phone to P2 to create theirs), and whoever it's for can always change -// their own profile's color later regardless. Mirrors LightCycles' identical NEW_PROFILE pattern -// (that sibling app has no controlScheme concept of its own, so the specific value doesn't need to -// match - just the "give a fresh profile a sensible default" shape). Matches -// redux/settingsSlice.ts's DEFAULT_SETTINGS.controlScheme, the app-wide default philosophy. -const NEW_PROFILE_CONTROL_SCHEME: ControlScheme = 'flick' - -// A real routed screen - reachable only from P1's dropdown (see ProfilePicker's own doc for why P2 -// can't host this: its zone is 180°-rotated in face-to-face mode, and the OS keyboard doesn't rotate -// with it). Reads useProfiles()/useGameStats() directly rather than taking them as props from -// loadout.tsx - there's nothing left for that screen to thread through once this is its own -// destination instead of conditionally-rendered state living on the loadout itself. Mirrors -// LightCycles' identically-shaped app/profiles.tsx, including its stats cleanup on delete - this app -// does have its own per-profile stats bucket (useGameStats' removeProfileStats), the same as -// LightCycles, so the earlier "BoxHockey has no per-profile stats to remove" note here was stale. -// The container View, back button, and theme-derived bg/fg all now live inside @tastic/profile's own -// ProfilesScreen - this only supplies what's genuinely app-specific: the extra controlScheme field -// this app's own createProfile needs beyond ProfileEditPatch's base name/color/tag shape. -export default function Profiles() { - const { profiles, createProfile, updateProfile, deleteProfile } = useProfiles() - // Only for wiring onDelete below - nothing else on this screen touches stats directly. - const { removeProfileStats } = useGameStats() - - return ( - createProfile({ ...patch, controlScheme: NEW_PROFILE_CONTROL_SCHEME })} - onSave={(id, patch) => updateProfile(id, patch)} - onDelete={(id) => { - deleteProfile(id) - removeProfileStats(id) - }} - onBack={safeBack} - /> - ) -} +export default ProfilesRoute diff --git a/src/app/stats.tsx b/src/app/stats.tsx new file mode 100644 index 0000000..a061300 --- /dev/null +++ b/src/app/stats.tsx @@ -0,0 +1,3 @@ +import { StatsScreen } from '@/shell/screens/StatsScreen' + +export default StatsScreen diff --git a/src/components/BoardHost.tsx b/src/components/BoardHost.tsx deleted file mode 100644 index a34b8be..0000000 --- a/src/components/BoardHost.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import Board, { BoardProps } from './Board' - -// Trivial passthrough on native - Skia's native bindings are ready via JSI before any JS even runs, -// so there's nothing to defer. See BoardHost.web.tsx for why web needs an actual wrapper here: it's -// not a symmetry-for-its-own-sake thing, native genuinely doesn't need this. -export default function BoardHost(props: BoardProps) { - return -} diff --git a/src/components/BoardHost.web.tsx b/src/components/BoardHost.web.tsx deleted file mode 100644 index f4e6cb9..0000000 --- a/src/components/BoardHost.web.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { loadSkiaWeb } from '@tastic/hud/skia' -import { lazy, Suspense } from 'react' - -import { BoardProps } from './Board' - -// Skia's web target renders through CanvasKit, a WASM build of Skia - and Board.tsx's own `Skia` -// import binds to `global.CanvasKit` once, synchronously, at MODULE-EVALUATION time (see -// @shopify/react-native-skia's Skia.web.ts: `export const Skia = JsiSkApi(global.CanvasKit)`) - not -// lazily, and not reactively. A plain `import Board from './Board'` here would already have -// evaluated that binding (against a not-yet-loaded, undefined CanvasKit) before ANY component in the -// app renders at all, since Metro evaluates the whole static import graph up front. A dynamic import -// is the one thing that actually defers module evaluation to runtime, so this only imports Board.tsx -// (and therefore @shopify/react-native-skia) AFTER loadSkiaWeb() has resolved and set -// global.CanvasKit for real. -const LazyBoard = lazy(() => loadSkiaWeb().then(() => import('./Board'))) - -export default function BoardHost(props: BoardProps) { - return ( - - - - ) -} diff --git a/src/components/GuideHost.tsx b/src/components/GuideHost.tsx deleted file mode 100644 index 0845e56..0000000 --- a/src/components/GuideHost.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useIsTouchPrimaryDevice, useRotation } from '@tastic/core' -import { GuideProvider } from '@tastic/hud/guide' -import { ReactNode, useCallback, useMemo } from 'react' -import { useDispatch, useSelector } from 'react-redux' - -import { GUIDE_VERSION } from '@/constants/guide' -import { getGuideSteps } from '@/constants/guideSteps' -import { useSettings } from '@/hooks/useSettings' -import { guideActions, selectGuideVersionSeen } from '@/redux/guideSlice' -import type { RootState } from '@/redux/store' - -interface Props { - children: ReactNode -} - -// Wires @tastic/hud/guide's provider to this app: the persisted "seen" version out of Redux, this -// app's own card content, and the live physical-hold rotation. Mounted once in _layout.tsx, wrapping -// the screen stack, so any screen can reach it through useGuide() (the Settings "How to Play" row) -// and useAutoShowGuide() (Home) without prop-drilling. -// -// Reads the rotation the same way Home does for its own Settings dialog, plain useRotation() (Lock -// Orientation freezes the shared reading itself, see _layout.tsx), so the guide's card sits at -// exactly the angle that dialog does. Its Portal renders outside any FakeLandscapeView, so unlike -// on-screen content it can't inherit that rotation and has to be handed it. `children` is the -// stable element _layout passes in, so a tilt-driven re-render here never re-renders the screen -// stack under it. -// -// The cards follow the live settings (the mode, each seat's shooting style, the tip-off, the goal target), so a -// replay after a player changes any of them in the loadout still describes the next match exactly. -export function GuideHost({ children }: Props) { - const { settings } = useSettings() - const rotation = useRotation() - const touch = useIsTouchPrimaryDevice() - const seenVersion = useSelector((state: RootState) => selectGuideVersionSeen(state)) - const dispatch = useDispatch() - - const { gameMode: mode, controlScheme: controls, tipoffMode: tipoff, scoreToWin } = settings - const steps = useMemo(() => getGuideSteps({ touch, mode, controls, tipoff, scoreToWin }), [touch, mode, controls, tipoff, scoreToWin]) - const onSeen = useCallback((version: number) => dispatch(guideActions.markSeen(version)), [dispatch]) - - return ( - - {children} - - ) -} diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx deleted file mode 100644 index 74e148d..0000000 --- a/src/components/SettingsDialog.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useUpdateErrorToast } from '@rific/toaster' -import { ViewRotation } from '@tastic/core' -import { BaseSettingsDialog } from '@tastic/hud' -import { useGuide } from '@tastic/hud/guide' - -import { release } from '@/constants/release' -import { Settings } from '@/types' - -export interface SettingsDialogProps { - visible: boolean - onDismiss: () => void - // Live physical-hold rotation (see @tastic/core's useRotation). BaseSettingsDialog lays the sheet - // out in its own frame turned by this (see @tastic/hud's RotatedFrame), so it reads right-side-up - // and fits the short edge however the phone is held. Defaults to 0 for a call site that doesn't - // track one. - rotation?: ViewRotation - // The persisted "next round" settings (not a round's own locked-in activeRoundSettings) - passed - // in rather than read internally via useSettings() so this stays a pure adapter, matching the - // cross-app SettingsDialog convention (see LightCycles' identical props-based contract). Each of - // this component's three call sites (index.tsx, loadout.tsx, game.tsx) already has its own - // settings/setSettings in scope for other reasons, so this is no new plumbing for any of them. - settings: Settings - setSettings: (update: Partial) => void - // Shows the "How to Play" row that replays the intro. Off by default: the in-game (/game) dialog - // leaves it out, since opening a full-screen guide over a live match (settings there already - // pauses play) is disruptive. Home and the loadout, where nothing is running, turn it on. - showHowToPlay?: boolean -} - -// Thin adapter over @tastic/hud's shared settings shell - BoxHockey's own Settings (lock -// orientation, edge guard) is the only thing that varies here; everything else (sound, haptics, -// appearance, update checking) is identical across every app using BaseSettingsDialog and lives -// entirely inside that package now. -export function SettingsDialog({ visible, onDismiss, rotation = 0, settings, setSettings, showHowToPlay = false }: SettingsDialogProps) { - const onUpdateError = useUpdateErrorToast() - const { open: openGuide } = useGuide() - - return setSettings({ lockOrientation: value })} deferBottomEdgeGestures={settings.deferBottomEdgeGestures} onDeferBottomEdgeGestures={(value) => setSettings({ deferBottomEdgeGestures: value })} onUpdateError={onUpdateError} onShowHowToPlay={showHowToPlay ? openGuide : undefined} /> -} diff --git a/src/games/airhockey/PlayScreen.tsx b/src/games/airhockey/PlayScreen.tsx new file mode 100644 index 0000000..ff85a90 --- /dev/null +++ b/src/games/airhockey/PlayScreen.tsx @@ -0,0 +1,451 @@ +import { useAutoPaperTheme } from '@rific/auto-paper' +import { useKeepAwake } from '@rific/core' +import { Button, IconButton, useVibration } from '@rific/feedback-press' +import { useToast } from '@rific/toaster' +import { broadcastDeviceUnlocks } from '@tastic/achievements' +import { rotateInsets, useBackgroundPause, useOrientationState } from '@tastic/core' +import { ConfirmDialog, overlayActionStyles, OverlayBackdrop, OverlayCard, useOverlayCardColors, useQuitConfirmation } from '@tastic/hud' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Platform, StyleSheet, useWindowDimensions, View } from 'react-native' +import { Icon, Text } from 'react-native-paper' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useDispatch } from 'react-redux' +import type { Action, Dispatch } from 'redux' + +import Board from '@/games/airhockey/components/BoardHost' +import KeyboardInputLayer from '@/games/airhockey/components/KeyboardInputLayer' +import TouchInputLayer from '@/games/airhockey/components/TouchInputLayer' +import { DESKTOP_WEB_MIN_WIDTH, FRICTION_LEVELS } from '@/games/airhockey/constants/game' +import { useAIOpponent } from '@/games/airhockey/hooks/useAIOpponent' +import { useGameState } from '@/games/airhockey/hooks/useGameState' +import { useGameStats } from '@/games/airhockey/hooks/useGameStats' +import { DEFAULT_SETTINGS } from '@/games/airhockey/redux/settingsSlice' +import { GameMode, GoalAxis, Player } from '@/games/airhockey/types' +import { computeBoardLayout, computeLocalContainerSize } from '@/games/airhockey/utils/board' +import { seatResultSpots } from '@/games/airhockey/utils/seatResultSpots' +import { SeatResultCards } from '@/shared/SeatResultCards' +import { SeatToasters } from '@/shared/SeatToasters' +import { useGameSound } from '@/shared/useGameSound' +import { SEAT_TOAST_STACKS, useUnlockToasts } from '@/shared/useUnlockToasts' +import { SettingsDialog } from '@/shell/components/SettingsDialog' +import { MONO_FONT } from '@/shell/fonts' +import { useActiveRound } from '@/shell/hooks/useActiveRound' +import { useProfiles } from '@/shell/hooks/useProfiles' +import { safeBack } from '@/shell/navigation' +import { liveplayActions } from '@/shell/redux/liveplaySlice' +import { humanSeatsFor } from '@/shell/types' + +// Converts a point expressed in the board's own LOCAL coordinate space (the same frame board.x/y +// lives in) into a real, absolute screen position - mirrors Pong's own localToScreen exactly (see +// its doc comment there), needed here for the same reason: floating UI (the back/settings chips +// below) lives OUTSIDE the rotated local container, so anchoring it to "the board's own top-left +// corner" only lands in the right real screen spot if this applies the SAME rotationDeg the +// container itself was rotated by. +function localToScreen(localX: number, localY: number, containerWidth: number, containerHeight: number, screenCenterX: number, screenCenterY: number, rotation: 0 | 90 | -90): { x: number; y: number } { + const lx = localX - containerWidth / 2 + const ly = localY - containerHeight / 2 + let dx: number + let dy: number + if (rotation === 90) { + dx = -ly + dy = lx + } else if (rotation === -90) { + dx = ly + dy = -lx + } else { + dx = lx + dy = ly + } + return { x: screenCenterX + dx, y: screenCenterY + dy } +} + +// Vs CPU has one human, seat 1. +const SEATS: readonly Player[] = [1, 2] +const SOLO_SEATS: readonly Player[] = [1] + +export default function GameScreen() { + // Committed once by the loadout when every human player readied up (see the shell's + // liveplaySlice.ts and useActiveRound) - falls back to Air Hockey's defaults against the CPU for a + // direct link to this screen, which never went through the loadout to commit anything. Lock + // Orientation applies to this round at once: the shell's OrientationBridge feeds the pack-wide + // setting to the shared orientation reading the board reads below. + const { mode, settings: roundSettings } = useActiveRound('airhockey', DEFAULT_SETTINGS) + const dispatch = useDispatch>() + const { recordMatchOutcome } = useGameStats() + const { profiles, lastSelected } = useProfiles() + const { clear: clearToasts } = useToast() + const showUnlockToasts = useUnlockToasts() + const scoreToWin = roundSettings.scoreToWin + const friction = FRICTION_LEVELS[roundSettings.friction] + const puckSize = roundSettings.puckSize + const paddleSize = roundSettings.paddleSize + const rink = roundSettings.rink + // Only P2 can be CPU-controlled - pass-and-play stays the default, single-player just swaps who + // controls P2's paddle (see useAIOpponent, which drives it through the exact same setPaddleTarget + // path a human's touch uses). + const aiPlayer: Player | null = mode === 'vsCpu' ? 2 : null + // Air Hockey's stats keep the mode as AirHockey recorded it. + const gameMode: GameMode = mode === 'twoPlayer' ? 'twoPlayer' : 'vsCpu' + const aiDifficulty = roundSettings.aiDifficulty + const humanPlayers = humanSeatsFor(mode) + + // Unlike BoxHockey (always pinned portrait, since its board is always vertical), AirHockey's + // board adapts to either orientation (see GoalAxis's own doc comment in types/index.ts), so the + // goal axis and touch mapping follow the shared reading. Lock Orientation freezes that reading + // app-wide (see OrientationBridge in _layout.tsx), the board included: while it's on, tilting the + // phone never flips the goal axis, and toggling it from this screen's own Settings dialog takes + // effect immediately rather than waiting for the next round. + // + // Except 2 Player on a phone, which always plays face-to-face (P1 at the bottom, P2 at the top) + // however the phone is held: the loadout's 2 Player panels are pinned to that same split, so each + // player keeps the same half of the phone from the loadout into the match. Turning the rink a + // quarter turn for a landscape hold used to give P1 a lengthwise half the loadout can't show. + const { orientationMode, p1OnRight } = useOrientationState() + const fixedSeats = Platform.OS !== 'web' && humanPlayers.length === 2 + const goalAxis: GoalAxis = orientationMode === 'sideBySide' && !fixedSeats ? 'horizontal' : 'vertical' + + const { width, height } = useWindowDimensions() + const insets = useSafeAreaInsets() + // computeBoardLayout's own local space is ALWAYS canonically portrait-shaped (see GoalSide's doc + // comment in types/index.ts) regardless of goalAxis - goalAxis only decides which real screen + // dimension feeds its short-vs-long split, not the shape of what it hands back. So this ±90° + // exists to visually turn that always-tall container into a landscape-looking one whenever + // goalAxis is 'horizontal' - unlike @tastic/core's own getViewRotation (which compensates + // specifically for a portrait-locked OS refusing to rotate its render surface), this need is + // unrelated to OS lock state and applies on every platform, web included. + const rotationDeg: 0 | 90 | -90 = goalAxis === 'horizontal' ? (p1OnRight ? 90 : -90) : 0 + + // Unlike the rotation above, THIS swap is genuinely native-only: computeBoardLayout expects + // screenWidth/screenHeight to reflect the VISUAL orientation directly (width > height once truly + // in landscape) - true on web, where the browser window itself is already the correct shape once + // wide (useOrientationState's own web fallback derives 'horizontal' from exactly that), so + // useWindowDimensions() there is used as-is. Native is portrait-locked at the OS level, so + // useWindowDimensions() always reports portrait W×H no matter how the phone is actually held - + // swapped here, at this one call site, to re-derive what genuine landscape dimensions would have + // been, rather than touching computeBoardLayout's own long-established S/L-split math. + const swapForOsPortraitLock = Platform.OS !== 'web' && goalAxis === 'horizontal' + const logicalWidth = swapForOsPortraitLock ? height : width + const logicalHeight = swapForOsPortraitLock ? width : height + // rotateInsets always returns a fresh object, so this has to be memoized on insets/rotationDeg + // itself - otherwise logicalInsets is a new reference every render, which busts the board + // useMemo below on every render regardless of whether anything actually changed, which in turn + // busts useGameState's own board-reference-equality check (its RESCALE effect, keyed on + // `board !== prevBoard`) and fires an unbounded dispatch loop. + const logicalInsets = useMemo(() => rotateInsets(insets, rotationDeg), [insets, rotationDeg]) + + // board (canonical LOCAL geometry - see GoalSide's doc comment in types/index.ts) and localSize + // (the container BEFORE the rotation below) are derived from the same S/L split, so they always + // agree with each other regardless of goalAxis. + const board = useMemo(() => computeBoardLayout(logicalWidth, logicalHeight, logicalInsets, goalAxis, puckSize, paddleSize, rink), [logicalWidth, logicalHeight, logicalInsets, goalAxis, puckSize, paddleSize, rink]) + const localSize = useMemo(() => computeLocalContainerSize(logicalWidth, logicalHeight, goalAxis), [logicalWidth, logicalHeight, goalAxis]) + // The local container is flex-centered within the full-screen viewport (see the `viewport` style + // below), so its own screen-space center always coincides with the screen's center - + // TouchInputLayer needs this (plus the rotation itself) to convert a touch's real screen + // position back into local board coordinates by hand (see its own screenToLocal comment for + // why this can't just rely on a zone's own e.x/e.y). Deliberately the RAW (physical, un-swapped) + // width/height, unlike the board geometry above - a real touch always arrives in real physical + // screen coordinates regardless of the CSS rotation trick, so this has to stay in that same + // physical space for TouchInputLayer's own un-rotation math to line up with it. + const screenCenterX = width / 2 + const screenCenterY = height / 2 + + // Settings/quit-confirm dialogs pause gameplay while open. useQuitConfirmation reads hasProgress + // lazily (only inside a real requestBack() call, via its own ref-mirrored getter - see that + // hook's own doc) rather than taking a plain boolean, which is exactly what makes this safe to + // call before `scores` exists yet: `scores` comes from useGameState's returned state, which + // itself needs `paused` (derived from confirmVisible) as an INPUT, so a plain-boolean argument + // computed from `scores` here would be a real circular render dependency. The closure below + // still reads whatever `scores` is bound to at the time the closure itself is (re)created each + // render, same as a hand-rolled onBackPress body always did before this hook existed. + const [settingsOpen, setSettingsOpen] = useState(false) + // Once the match is over (gameOver) its stats are already recorded and nothing is lost, so back + // exits at once, the same as the Game Over card's own buttons. + const { confirmVisible, requestBack, cancelBack } = useQuitConfirmation(() => phase !== 'gameOver' && (scores[0] > 0 || scores[1] > 0), safeBack) + // Leaving the app mid-match (home, app switch, a phone call, Control Center) latches a pause that + // only the Paused dialog's Resume clears - see @tastic/core's useBackgroundPause. Skipped once the + // match is over, or while a dialog already holds the pause by itself. + const { backgroundPaused, resume } = useBackgroundPause(() => phase !== 'gameOver' && !settingsOpen && !confirmVisible) + // Keeps the screen from dimming and locking while a match is up, unless Keep Awake is off in + // Settings (read live, so the toggle applies mid-match). Expo only does this by itself in + // development builds, and the OS auto-lock only counts screen touches, so tilt or keyboard + // steering, or watching the CPU, could let a release build lock mid-match. The setting and the + // expo-keep-awake module are @rific/core's, from Providers.tsx's KeepAwakeBridge. + useKeepAwake('game') + const paused = settingsOpen || confirmVisible || backgroundPaused + + const { state, setPaddleTarget, resetGame } = useGameState(board, scoreToWin, friction, paused) + const { phase, puck, paddles, scores, lastScoredGoal, bounceCount, goalDoorHitCount, lastGoalDoorSide } = state + + // Mirrors "an actual live rally, not paused" into liveplay.activelyPlaying for Providers.tsx's + // EdgeGuardBridge rather than calling useEdgeGestureGuard directly here: it stays mounted once for + // the app's lifetime, and this is just the one signal it has no other way to read. Unlocked between points (idle/goalScored/gameOver) and whenever a + // dialog is open (paused) - back/settings stay unconditionally visible here (unlike Snake/ + // LightCycles), so there's no button-visibility signal to key off instead. Reset to false on + // unmount too, so leaving this screen mid-rally by any path can't leave Edge Guard stuck on. + useEffect(() => { + dispatch(liveplayActions.setActivelyPlaying(phase === 'live' && !paused)) + return () => { + dispatch(liveplayActions.setActivelyPlaying(false)) + } + }, [phase, paused, dispatch]) + + // No toast may stay on screen during live play - only between rounds or at game over. Unlock + // toasts fire at game over and last 7s, so a quick restart would otherwise carry them into the + // next rally. + useEffect(() => { + if (phase === 'live') clearToasts() + }, [phase, clearToasts]) + + useAIOpponent(state, board, aiPlayer, aiDifficulty, paused, setPaddleTarget) + + const isDesktopWeb = Platform.OS === 'web' && width >= DESKTOP_WEB_MIN_WIDTH + // 2 Player: each seat's half of the screen and the angle it reads at, for its Game Over card + // (SeatResultCards) and its unlock toasts (SeatToasters) alike, so a player's toasts sit where + // their card does, facing the same way. + const seatSpots = seatResultSpots(rotationDeg, isDesktopWeb) + + const { dark, colors: themeColors } = useAutoPaperTheme() + const bg = dark ? '#000000' : '#FFFFFF' + const fg = dark ? '#FFFFFF' : '#000000' + // The Game Over card and the dialogs' score lines sit on @tastic/hud's OverlayCard, so their text + // takes its theme colors rather than this screen's literal black/white. + // Quit's outlined label is cardFg, the card's own text color, not Paper's default (the theme's + // primary), so neither seat's card shows another seat's color (Jay, 2026-10-20). + const { fg: cardFg, fgMuted } = useOverlayCardColors() + + const playerColor: Record = { 1: themeColors.primary, 2: themeColors.secondary } + const playerOnColor: Record = { 1: themeColors.onPrimary, 2: themeColors.onSecondary } + const winner: Player = scores[0] >= scoreToWin ? 1 : 2 + const loser: Player = winner === 1 ? 2 : 1 + + const vibration = useVibration() + const vibrationRef = useRef(vibration) + useEffect(() => { + vibrationRef.current = vibration + }) + + const playBounce = useGameSound(require('../../../assets/sounds/bounce.wav')) + const playGoal = useGameSound(require('../../../assets/sounds/goal.wav')) + const playWin = useGameSound(require('../../../assets/sounds/win.wav')) + const soundRef = useRef({ playBounce, playGoal, playWin }) + useEffect(() => { + soundRef.current = { playBounce, playGoal, playWin } + }) + + const firstBounceRender = useRef(true) + useEffect(() => { + if (firstBounceRender.current) { + firstBounceRender.current = false + return + } + vibrationRef.current.short() + soundRef.current.playBounce() + }, [bounceCount]) + + const prevScoresRef = useRef(scores) + useEffect(() => { + const [prevP1, prevP2] = prevScoresRef.current + prevScoresRef.current = scores + if (scores[0] !== prevP1 || scores[1] !== prevP2) { + vibrationRef.current.notification() + soundRef.current.playGoal() + } + }, [scores]) + + const prevPhaseRef = useRef(phase) + useEffect(() => { + if (prevPhaseRef.current !== 'gameOver' && phase === 'gameOver') { + soundRef.current.playWin() + // Recorded on the transition rather than in the reducer, which runs every physics step and + // has no notion of "this is new". prevPhaseRef guards a re-run from any other dep changing. + const matchWinner: Player = scores[0] >= scoreToWin ? 1 : 2 + const profileIds = { 1: lastSelected[1] ?? undefined, 2: aiPlayer === null ? (lastSelected[2] ?? undefined) : undefined } + const result = recordMatchOutcome({ winner: matchWinner, scores: [scores[0], scores[1]], goalDoorHits: goalDoorHitCount }, { gameMode, aiDifficulty, profileIds }) + if (aiPlayer === null) { + // Genuine two-seat pass-and-play: each seat's unlocks are toasts on that seat's own stack, + // in their half of the screen and turned to face them (SeatToasters, on seatSpots), so a + // player at the opposite end of the table (the far side of a portrait/vertical-goalAxis + // hold, same as real air hockey) reads their own the right way up, and players side by side + // each read theirs on their own side. The winner's seat also gets the device-wide unlocks + // that are really its own (all of them for a guest, only scope: 'device' ones for a profile; + // see @tastic/achievements' broadcastDeviceUnlocks). The loser gets only its own (audit F037). + const broadcast = broadcastDeviceUnlocks(SEATS, result.profiles, result.device, profileIds, { winners: [matchWinner] }) + showUnlockToasts(broadcast, SEATS, { 1: profiles.find((p) => p.id === profileIds[1])?.name, 2: profiles.find((p) => p.id === profileIds[2])?.name }, SEAT_TOAST_STACKS) + } else { + // vsCpu only ever has one human perspective (seat 1) to address - the shared, screen-fixed + // toast is fine here. + showUnlockToasts(broadcastDeviceUnlocks(SOLO_SEATS, result.profiles, result.device, profileIds), SOLO_SEATS, { 1: profiles.find((p) => p.id === profileIds[1])?.name }) + } + } + prevPhaseRef.current = phase + }, [phase, scores, scoreToWin, aiPlayer, aiDifficulty, goalDoorHitCount, lastSelected, gameMode, recordMatchOutcome, profiles, showUnlockToasts]) + + // ─── Floating UI placement ───────────────────────────────────────────────── + // Back/settings chips and the Game Over/quit-confirm cards all live OUTSIDE the board's own + // rotated container (see the JSX below), so each is individually positioned/rotated via + // rotationDeg rather than inheriting the container's own transform - same technique Pong's game + // screen uses for its own floating chips. + // + // localToScreen's containerWidth/Height MUST be localSize.width/height (the pre-rotation + // container's own S×L size - always portrait-shaped, per computeLocalContainerSize's own doc), + // NOT logicalWidth/logicalHeight: board.x/y live in that same S×L local frame (computeBoardLayout + // returns board.width as S, board.height as L - see utils/board.ts), and it's THAT view, sized to + // localSize and centered in the real viewport, that rotationDeg actually rotates - confirmed by + // reproducing the original bug in the browser preview (using logicalWidth/Height here sent the + // back chip well off-screen once desktop web's letterboxing made S×L diverge from + // logicalWidth×logicalHeight). + const buttonMargin = 16 + // With Settings.rink off on a screen with no top inset, the field starts right at the screen's + // edge, so the chips (24px half-height) are held 4px clear of it rather than hanging off-screen. + const buttonY = Math.max(board.y, 12) + buttonMargin + const backPos = localToScreen(board.x + buttonMargin, buttonY, localSize.width, localSize.height, screenCenterX, screenCenterY, rotationDeg) + const settingsPos = localToScreen(board.x + board.width - buttonMargin, buttonY, localSize.width, localSize.height, screenCenterX, screenCenterY, rotationDeg) + const rotationStyle = rotationDeg !== 0 ? { transform: [{ rotate: `${rotationDeg}deg` }] } : undefined + // Desktop web turns the rink a quarter turn to fit a wide window, but the page itself is always + // read upright there, so the back/settings chips, the Game Over card and the dialogs stay upright + // too rather than turning with it. The chips also leave the rink for the page's own top corners, + // the same spot as hud's CornerActionButtons on every other screen; following the rink would carry + // them down the page's left edge. + const overlayRotationDeg = isDesktopWeb ? 0 : rotationDeg + const overlayRotationStyle = isDesktopWeb ? undefined : rotationStyle + const backSlot = isDesktopWeb ? { top: 8 + insets.top, left: 8 + insets.left } : { left: backPos.x - 24, top: backPos.y - 24 } + const settingsSlot = isDesktopWeb ? { top: 8 + insets.top, right: 8 + insets.right } : { left: settingsPos.x - 24, top: settingsPos.y - 24 } + + const progressMessage = ( + <> + {scores[0]} + - + {scores[1]} + + ) + return ( + + {/* Local-space board + input area - sized to localSize (the container BEFORE rotation, + covering the whole screen so the rink pocket can reach into the safe-area insets; the + board geometry keeps the playing field itself inside them), centered on the screen, + then rotated ±90° for side-by-side + landscape so the SAME canonical local geometry (see GoalSide's own doc comment) maps + onto a wide screen correctly. Board's Skia canvas is written purely in local + coordinates and never branches on goalAxis at all - DOM/native hit-testing correctly + respects this rotation when deciding which zone a touch lands on, so TouchInputLayer's + two zones don't need to branch on axis either. Only the touch's own COORDINATE VALUE + needs explicit handling: TouchInputLayer converts e.absoluteX/Y back into local space + itself (see its own screenToLocal comment for why - verified in the browser preview + that react-native-gesture-handler's web e.x/e.y do NOT correctly invert an ancestor's + CSS rotation the way native touch delivery would), which is why it needs + screenCenterX/Y and rotationDeg passed in below, matching this same rotation exactly. + Rotation direction confirmed empirically in the browser preview (a hand-derived + clockwise/counter-clockwise expectation didn't match RN-web's actual rotate() sign, so + this is calibrated against the real render, not re-derived from theory): '90deg' puts + player 1 (local bottom) on the screen's RIGHT, '-90deg' puts them on the LEFT. */} + + + + {/* Zones themselves still live inside this same rotated container - DOM/native hit- + testing needs their rotated screen footprint to correctly decide WHICH zone a touch + landed on; only the resulting coordinate VALUE (screenToLocal, using + screenCenterX/Y + rotationDeg below) needs the explicit, non-rotation-relative fix. */} + + + + + {/* Web-only, additive keyboard input - see that component's own doc for why it needs no + rotation-awareness the way TouchInputLayer does. paddles[1]/paddles[2] are read fresh every + frame internally (not reactively), so passing the current `paddles` snapshot here on every + render is exactly right, not stale. */} + + + {/* Game Over overlay - card rotates as a unit via rotationDeg so it reads upright whichever + way the board itself is currently rotated (see rotationStyle above), matching Pong's own + Game Over card; previously left unrotated. Upright on desktop web (see overlayRotationDeg). + 2 Player gets one card per seat instead, each in that player's half and turned to face + them (SeatResultCards); this single card is Vs CPU's. */} + {phase === 'gameOver' && aiPlayer === null && resetGame(board)} onQuit={safeBack} />} + + {/* 2 Player's unlock toasts, a Toaster per seat in that seat's half, turned to face it (see + the gameOver effect above); every other toast stays on the app's main Toaster (_layout.tsx). */} + {aiPlayer === null && } + {phase === 'gameOver' && aiPlayer !== null && ( + + + + + Wins! + + + {scores[0]} + - + {scores[1]} + + {/* hud's action row, as in ConfirmDialog: Quit (outlined) on the left, Play Again (filled) on the right, side by side (Jay, 2026-10-20). */} + + + + + + + )} + + {/* Quit confirmation - now the fleet-shared @tastic/hud ConfirmDialog rather than a hand- + rolled overlay. Its rotation is the same physical-hold-compensating rotation the board/other + floating UI use (see rotationDeg's own comment above), not a fresh computation, except that + desktop web keeps it upright (see overlayRotationDeg). + Trades this dialog's previous secondary-accent Quit button and MONO_FONT styling for the + shared component's primary accent and default font - an accepted trade-off. */} + + {/* Shown when the app comes back from the background mid-match (see useBackgroundPause above): + play stays frozen until Resume, so a match never restarts before the player is back and ready. */} + + + {/* Back + settings - anchored to the board's own top corners in LOCAL space (board.x/y), then + mapped to a real screen position via localToScreen using the SAME rotationDeg the board's + own container above is rotated by, so the pair travels (not just spins) to track wherever + the board's rotated top edge actually lands on screen - same technique Pong's own + back/settings chips use. Previously pinned to the real screen's raw top-left/top-right + (insets.top/left/right, never rotated or moved): correct only at rotationDeg 0; once + goalAxis flipped to 'horizontal' the board spun ±90° underneath while these stayed put, + landing in the wrong corner relative to the now-rotated board and never turning to stay + legible. Unlike BoxHockey's centerline placement (needed there because its board splits + face-to-face with P2's half rotated 180°), AirHockey's board is never rotated PER PLAYER - + only as a single unit for goalAxis - so one shared, non-neutral corner pair is still + correct; it just has to move and rotate with that unit rotation instead of ignoring it. + Desktop web is the exception: the page's own top corners, upright (see overlayRotationDeg above). */} + + + + + setSettingsOpen(true)} accessibilityLabel='Settings' /> + + + {/* Same physical-hold-compensating rotation the board itself uses (see rotationDeg's own + comment above, and overlayRotationDeg for desktop web), not a separate reading; previously omitted entirely, + leaving this dialog's content stuck unrotated in the 'horizontal' goalAxis orientation while + index.tsx/loadout.tsx's own dialogs correctly rotated. */} + setSettingsOpen(false)} rotation={overlayRotationDeg} /> + + ) +} + +const styles = StyleSheet.create({ + backButton: { position: 'absolute' }, + chipButton: { margin: 0 }, + // Deliberately NOT position: 'absolute' - it needs to stay a normal flex child of `viewport` + // so that parent's alignItems/justifyContent: 'center' actually centers it (an absolutely + // positioned child ignores its parent's flex alignment entirely and defaults to pinning at + // (0,0) instead, which is what silently put the whole board off-center before this was caught + // in the browser preview). + localContainer: {}, + overlayScore: {}, + overlayTitle: { fontWeight: 'bold' }, + // The single Game Over card, as ConfirmDialog's: room from the screen's edges, and the card's full + // width allowance (OverlayCard's maxWidth, 360) so its action row has room to sit side by side. + resultBackdrop: { paddingHorizontal: 24 }, + resultCard: { width: '100%' }, + root: { flex: 1 }, + settingsButton: { position: 'absolute' }, + viewport: { alignItems: 'center', justifyContent: 'center', left: 0, position: 'absolute', top: 0 } +}) diff --git a/src/games/airhockey/StatsSections.tsx b/src/games/airhockey/StatsSections.tsx new file mode 100644 index 0000000..22eb351 --- /dev/null +++ b/src/games/airhockey/StatsSections.tsx @@ -0,0 +1,84 @@ +import { getAchievementCatalogRows } from '@tastic/achievements' +import { AchievementCatalogSection, ActivityStatSection, StatRow, StatSection } from '@tastic/hud' +import { useMemo } from 'react' + +import { ACHIEVEMENT_CATALOG } from '@/games/airhockey/constants/achievements' +import { useGameStats } from '@/games/airhockey/hooks/useGameStats' +import { getOverallTotals, getProfileRankings, getProfileStatsView } from '@/games/airhockey/utils/statsEngine' +import { DEFAULT_PROFILE_STATS } from '@/games/airhockey/utils/statsValidation' +import { PlayerRankingsSection } from '@/shared/PlayerRankings' +import { useProfiles } from '@/shell/hooks/useProfiles' + +// The stats for one profile (a profile id) or every player (null): its own bucket viewed through the +// same StatsState shape every predicate already reads, or the device-wide stats themselves. +function useStatsView(profileId: string | null) { + const { stats } = useGameStats() + const profileBucket = profileId ? (stats.profiles[profileId] ?? DEFAULT_PROFILE_STATS) : null + return { stats, profileBucket, statsView: profileBucket ? getProfileStatsView(profileBucket) : stats } +} + +// Air Hockey's sections on the shared stats screen (shell/screens/StatsScreen.tsx). +export function StatsSections({ profileId }: { profileId: string | null }) { + const { stats, profileBucket, statsView } = useStatsView(profileId) + const { profiles } = useProfiles() + + const overall = useMemo(() => getOverallTotals(statsView), [statsView]) + const rankings = useMemo(() => getProfileRankings(profiles, stats.profiles), [profiles, stats.profiles]) + const goalDiff = statsView.goals.scored - statsView.goals.conceded + + return ( + <> + + + + + + + + + + 0 ? `+${goalDiff}` : String(goalDiff)} /> + + + + + + + + + + + + {/* Everyone keeps the seat framing (whoever sat where); a selected profile switches to + that profile's own record, where p1Wins+p2Wins is always THIS profile's own win count. */} + + {profileBucket ? ( + <> + + + + + ) : ( + <> + + + + + )} + + + {/* A leaderboard across profiles is inherently a cross-profile question, so it only appears + on "Everyone" - once you've drilled into one profile it answers a different question. */} + {profileId === null && } + + + + ) +} + +// Air Hockey's achievements on the shared stats screen. +export function AchievementList({ profileId }: { profileId: string | null }) { + const { unlockedAchievements } = useGameStats() + const { stats, statsView } = useStatsView(profileId) + return +} diff --git a/src/games/airhockey/__tests__/achievements.test.ts b/src/games/airhockey/__tests__/achievements.test.ts new file mode 100644 index 0000000..2697e7a --- /dev/null +++ b/src/games/airhockey/__tests__/achievements.test.ts @@ -0,0 +1,80 @@ +import { ACHIEVEMENT_CATALOG } from '@/games/airhockey/constants/achievements' +import { StatsState } from '@/games/airhockey/types' +import { evaluateUnlockedIds, evaluateUnlockedIdsForProfile, unlockedKey } from '@/games/airhockey/utils/achievementEngine' +import { DEFAULT_PROFILE_STATS, DEFAULT_STATS } from '@/games/airhockey/utils/statsValidation' + +function stats(overrides: Partial): StatsState { + return { ...DEFAULT_STATS, ...overrides } +} + +describe('ACHIEVEMENT_CATALOG', () => { + it('has unique ids', () => { + const ids = ACHIEVEMENT_CATALOG.map((a) => a.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('gives every entry a title, description, icon and tier', () => { + for (const a of ACHIEVEMENT_CATALOG) { + expect(a.title.length).toBeGreaterThan(0) + expect(a.description.length).toBeGreaterThan(0) + expect(a.icon.length).toBeGreaterThan(0) + expect(['bronze', 'silver', 'gold']).toContain(a.tier) + } + }) + + it('unlocks nothing at all on a fresh blob', () => { + expect(evaluateUnlockedIds(DEFAULT_STATS).size).toBe(0) + }) + + it('reports progress between 0 and 1 wherever it is defined', () => { + const rich = stats({ goals: { scored: 30, conceded: 10 }, shutouts: 2, bestWinMargin: 4, goalDoorHits: 40 }) + for (const a of ACHIEVEMENT_CATALOG) { + const p = a.progress?.(rich) + if (p === undefined) continue + expect(p).toBeGreaterThanOrEqual(0) + expect(p).toBeLessThanOrEqual(1) + } + }) +}) + +describe('unlocking', () => { + it('unlocks the first-match one-offs on a first win', () => { + const s = stats({ vsCpu: { ...DEFAULT_STATS.vsCpu, played: 1, wins: 1 }, firstGameResult: 'win' }) + const ids = evaluateUnlockedIds(s) + expect(ids.has('first_game_played')).toBe(true) + expect(ids.has('first_ever_win')).toBe(true) + expect(ids.has('flawless_debut')).toBe(true) + }) + + it('leaves flawless_debut locked when the first match was a loss', () => { + const s = stats({ vsCpu: { ...DEFAULT_STATS.vsCpu, played: 5, wins: 4 }, firstGameResult: 'loss' }) + expect(evaluateUnlockedIds(s).has('flawless_debut')).toBe(false) + }) + + it('unlocks shutout tiers at their thresholds', () => { + expect(evaluateUnlockedIds(stats({ shutouts: 1 })).has('shutouts_bronze')).toBe(true) + expect(evaluateUnlockedIds(stats({ shutouts: 9 })).has('shutouts_silver')).toBe(false) + expect(evaluateUnlockedIds(stats({ shutouts: 10 })).has('shutouts_silver')).toBe(true) + }) + + it('unlocks a beat-the-CPU entry only for the difficulty actually beaten', () => { + const s = stats({ vsCpu: { ...DEFAULT_STATS.vsCpu, byDifficulty: { ...DEFAULT_STATS.vsCpu.byDifficulty, hard: { played: 1, wins: 1, losses: 0, draws: 0 } } } }) + const ids = evaluateUnlockedIds(s) + expect(ids.has('beat_cpu_hard')).toBe(true) + expect(ids.has('beat_cpu_easy')).toBe(false) + }) + + it('never reports the device-scoped achievement for a profile', () => { + const bucket = { ...DEFAULT_PROFILE_STATS, vsCpu: { ...DEFAULT_PROFILE_STATS.vsCpu, played: 1, wins: 1 } } + const ids = evaluateUnlockedIdsForProfile(bucket) + expect(ids.has('first_game_played')).toBe(true) + expect(ids.has('flawless_debut')).toBe(false) + }) +}) + +describe('unlockedKey', () => { + it('keys device-wide unlocks bare and profile unlocks by id', () => { + expect(unlockedKey('first_ever_win', null)).toBe('first_ever_win') + expect(unlockedKey('first_ever_win', 'alice')).toBe('alice:first_ever_win') + }) +}) diff --git a/src/games/airhockey/__tests__/aiOpponent.test.ts b/src/games/airhockey/__tests__/aiOpponent.test.ts new file mode 100644 index 0000000..2057efc --- /dev/null +++ b/src/games/airhockey/__tests__/aiOpponent.test.ts @@ -0,0 +1,118 @@ +import { MAX_PADDLE_SPEED } from '@/games/airhockey/constants/game' +import { computeAIPaddleTarget, rollAIJitter } from '@/games/airhockey/utils/aiOpponent' +import { centerPaddle, computeBoardLayout } from '@/games/airhockey/utils/board' + +const insets = { top: 0, bottom: 0, left: 0, right: 0 } +const board = computeBoardLayout(400, 900, insets, 'vertical', 'medium') + +describe('computeAIPaddleTarget - threat gate', () => { + it('chases a puck on its own half moving toward its own goal (player 2 defends top)', () => { + // Offset derived from paddleRadius (not a fixed pixel count) so this stays clear of the + // near-goal clamp band (clampToOwnHalf's minY = board.y + paddleRadius) regardless of paddle + // size - too close in and the predicted-position target legitimately overshoots past the + // puck's CURRENT position while still correctly chasing its PREDICTED one, which this + // assertion (measured against the current position) isn't meant to exercise. + const puck = { x: board.centerX, y: board.y + board.paddleRadius * 5, vx: 0, vy: -3 } // on P2's half, heading further into it + const ownPaddle = { ...centerPaddle(board, 2), vx: 0, vy: 0 } + const target = computeAIPaddleTarget(puck, ownPaddle, board, 2, 'hard') + // Should move toward the puck's predicted position, not stay at (or retreat toward) home. + const distToPuckBefore = Math.hypot(ownPaddle.x - puck.x, ownPaddle.y - puck.y) + const distToPuckAfter = Math.hypot(target.x - puck.x, target.y - puck.y) + expect(distToPuckAfter).toBeLessThan(distToPuckBefore) + }) + + it('retreats toward home when the puck is on its own half but moving AWAY (toward the opponent)', () => { + const puck = { x: board.centerX, y: board.y + 40, vx: 0, vy: 5 } // on P2's half, but heading toward P1 + const ownPaddle = { x: board.centerX + 60, y: board.y + 100, vx: 0, vy: 0 } // off its home spot + const home = centerPaddle(board, 2) + const target = computeAIPaddleTarget(puck, ownPaddle, board, 2, 'hard') + const distToHomeBefore = Math.hypot(ownPaddle.x - home.x, ownPaddle.y - home.y) + const distToHomeAfter = Math.hypot(target.x - home.x, target.y - home.y) + expect(distToHomeAfter).toBeLessThan(distToHomeBefore) + }) + + it('retreats toward home when the puck is entirely on the opponent half', () => { + const puck = { x: board.centerX, y: board.y + board.height - 40, vx: 0, vy: -8 } // deep on P1's half + const ownPaddle = { x: board.centerX + 60, y: board.y + 100, vx: 0, vy: 0 } + const home = centerPaddle(board, 2) + const target = computeAIPaddleTarget(puck, ownPaddle, board, 2, 'hard') + const distToHomeBefore = Math.hypot(ownPaddle.x - home.x, ownPaddle.y - home.y) + const distToHomeAfter = Math.hypot(target.x - home.x, target.y - home.y) + expect(distToHomeAfter).toBeLessThan(distToHomeBefore) + }) +}) + +describe('computeAIPaddleTarget - loose puck (kickoff / faceoff serve)', () => { + it('chases a stationary puck sitting dead center (game-start kickoff), instead of sitting home', () => { + const puck = { x: board.centerX, y: board.centerY, vx: 0, vy: 0 } + const ownPaddle = { ...centerPaddle(board, 2), vx: 0, vy: 0 } + const target = computeAIPaddleTarget(puck, ownPaddle, board, 2, 'hard') + const distToPuckBefore = Math.hypot(ownPaddle.x - puck.x, ownPaddle.y - puck.y) + const distToPuckAfter = Math.hypot(target.x - puck.x, target.y - puck.y) + expect(distToPuckAfter).toBeLessThan(distToPuckBefore) + }) + + it("chases a stationary puck sitting in its own faceoff zone (the post-goal 'won the draw' serve)", () => { + const puck = { x: board.centerX - board.width * 0.2, y: board.y + board.height * 0.35, vx: 0, vy: 0 } // P2's own half, near its own goal + const ownPaddle = { ...centerPaddle(board, 2), vx: 0, vy: 0 } + const target = computeAIPaddleTarget(puck, ownPaddle, board, 2, 'hard') + const distToPuckBefore = Math.hypot(ownPaddle.x - puck.x, ownPaddle.y - puck.y) + const distToPuckAfter = Math.hypot(target.x - puck.x, target.y - puck.y) + expect(distToPuckAfter).toBeLessThan(distToPuckBefore) + }) + + it("still retreats home for a stopped puck deep on the OPPONENT's half - that's their puck to reach first", () => { + const puck = { x: board.centerX, y: board.y + board.height - 30, vx: 0, vy: 0 } + const ownPaddle = { x: board.centerX + 60, y: board.y + 100, vx: 0, vy: 0 } + const home = centerPaddle(board, 2) + const target = computeAIPaddleTarget(puck, ownPaddle, board, 2, 'hard') + const distToHomeBefore = Math.hypot(ownPaddle.x - home.x, ownPaddle.y - home.y) + const distToHomeAfter = Math.hypot(target.x - home.x, target.y - home.y) + expect(distToHomeAfter).toBeLessThan(distToHomeBefore) + }) +}) + +describe('rollAIJitter', () => { + it('a harder difficulty rolls a smaller-magnitude jitter than an easier one, for the same raw random draw', () => { + const maxDraw = () => 1 // mag = 1 * aimJitterPx = full jitter + const easy = rollAIJitter('easy', maxDraw) + const hard = rollAIJitter('hard', maxDraw) + expect(Math.hypot(hard.x, hard.y)).toBeLessThan(Math.hypot(easy.x, easy.y)) + }) + + it('draws (0, 0) as a jitter offset are a no-op', () => { + const offset = rollAIJitter('easy', () => 0) + expect(offset).toEqual({ x: 0, y: 0 }) + }) +}) + +describe('computeAIPaddleTarget - difficulty tiers', () => { + it('a harder difficulty approaches its target faster than an easier one, when both are far from it', () => { + const puck = { x: board.centerX + board.width * 0.3, y: board.y + 20, vx: 0, vy: -1 } + const ownPaddle = { ...centerPaddle(board, 2), vx: 0, vy: 0 } // far from the puck's own corner + const easyTarget = computeAIPaddleTarget(puck, ownPaddle, board, 2, 'easy') + const hardTarget = computeAIPaddleTarget(puck, ownPaddle, board, 2, 'hard') + const easyStep = Math.hypot(easyTarget.x - ownPaddle.x, easyTarget.y - ownPaddle.y) + const hardStep = Math.hypot(hardTarget.x - ownPaddle.x, hardTarget.y - ownPaddle.y) + expect(hardStep).toBeGreaterThan(easyStep) + }) +}) + +describe('computeAIPaddleTarget - clamping', () => { + it('never returns a target that crosses the centerline, regardless of jitter', () => { + const puck = { x: board.centerX, y: board.y + 5, vx: 0, vy: -5 } + const ownPaddle = { ...centerPaddle(board, 2), vx: 0, vy: 0 } + const target = computeAIPaddleTarget(puck, ownPaddle, board, 2, 'easy', { x: 0, y: 1000 }) + expect(target.y).toBeLessThanOrEqual(board.centerY) + }) + + it("the per-tick step is bounded by the difficulty's own maxApproachSpeed fraction of MAX_PADDLE_SPEED", () => { + // Puck far across the board from the paddle's current position, so the raw (pre-clamp) target + // distance is large - the rate-of-approach clamp must be what actually limits the step. + const puck = { x: board.x + board.width * 0.9, y: board.y + 10, vx: 0, vy: -2 } + const ownPaddle = { x: board.x + board.width * 0.1, y: board.y + board.height * 0.4, vx: 0, vy: 0 } + const target = computeAIPaddleTarget(puck, ownPaddle, board, 2, 'medium') + const step = Math.hypot(target.x - ownPaddle.x, target.y - ownPaddle.y) + expect(step).toBeLessThanOrEqual(MAX_PADDLE_SPEED + 0.01) + }) +}) diff --git a/src/games/airhockey/__tests__/boardGeometry.test.ts b/src/games/airhockey/__tests__/boardGeometry.test.ts new file mode 100644 index 0000000..e41fab2 --- /dev/null +++ b/src/games/airhockey/__tests__/boardGeometry.test.ts @@ -0,0 +1,224 @@ +import { GOAL_OPEN_FRACTION } from '@/games/airhockey/constants/game' +import { GoalAxis, PaddleSizeLevel, PuckSizeLevel } from '@/games/airhockey/types' +import { centerPaddle, clampToOwnHalf, computeBoardLayout, playerGoalSide } from '@/games/airhockey/utils/board' +import { stepPhysics } from '@/games/airhockey/utils/physics' + +const insets = { top: 0, bottom: 0, left: 0, right: 0 } + +function simulateToRest(puck: { x: number; y: number; vx: number; vy: number }, board: ReturnType) { + let p = puck + for (let i = 0; i < 500; i++) { + const result = stepPhysics(p, board, 1) + p = { x: result.x, y: result.y, vx: result.vx, vy: result.vy } + if (result.scored !== null) return result.scored + if (result.stopped) return null + } + return null +} + +const sizes: PuckSizeLevel[] = ['small', 'medium', 'large'] + +describe('goal width always leaves GOAL_OPEN_FRACTION of open net around a centered paddle', () => { + const paddleSizes: PaddleSizeLevel[] = ['small', 'medium', 'large'] + // Every puckSize x paddleSize combination - paddleSize is an entirely independent setting (see + // LoadoutSharedControls), so this also guards the cross-combination cases (e.g. a small puck + // with a large paddle) that the old puckSize-only GOAL_WIDTH_RATIO table couldn't see at all. + for (const puckSize of sizes) { + for (const paddleSize of paddleSizes) { + it(`puckSize=${puckSize}, paddleSize=${paddleSize}`, () => { + const board = computeBoardLayout(400, 900, insets, 'vertical', puckSize, paddleSize) + // The span a single stationary, perfectly centered paddle blocks via circle-vs-circle + // contact - see GOAL_OPEN_FRACTION's own comment in constants/game.ts. + const blockingSpan = (board.paddleRadius + board.puckRadius) * 2 + expect(board.goalWidth).toBeGreaterThan(blockingSpan) + expect(board.goalWidth).toBeLessThan(board.width) + const openFraction = (board.goalWidth - blockingSpan) / board.goalWidth + expect(openFraction).toBeCloseTo(GOAL_OPEN_FRACTION, 5) + }) + } + } +}) + +describe('rotating the device (swapping which real dimension is short/long) produces geometrically equivalent local geometry', () => { + it.each(sizes)('vertical(W,H) and horizontal(H,W) agree on every geometry field, at puckSize=%s', (size) => { + const vertical = computeBoardLayout(400, 900, insets, 'vertical', size, 'medium', true) + const horizontal = computeBoardLayout(900, 400, insets, 'horizontal', size, 'medium', true) + expect(horizontal.x).toBeCloseTo(vertical.x, 5) + expect(horizontal.y).toBeCloseTo(vertical.y, 5) + expect(horizontal.width).toBeCloseTo(vertical.width, 5) + expect(horizontal.height).toBeCloseTo(vertical.height, 5) + expect(horizontal.goalWidth).toBeCloseTo(vertical.goalWidth, 5) + expect(horizontal.goalStart).toBeCloseTo(vertical.goalStart, 5) + expect(horizontal.goalEnd).toBeCloseTo(vertical.goalEnd, 5) + expect(horizontal.puckRadius).toBeCloseTo(vertical.puckRadius, 5) + expect(horizontal.paddleRadius).toBeCloseTo(vertical.paddleRadius, 5) + expect(horizontal.cornerRadius).toBeCloseTo(vertical.cornerRadius, 5) + expect(horizontal.behindGoalDepth).toBeCloseTo(vertical.behindGoalDepth, 5) + expect(horizontal.goalPosts).toEqual(vertical.goalPosts) + }) + + it('a mismatched pairing (same screen dims, different axis) does NOT coincidentally match', () => { + // Sanity check that the equivalence above is actually testing something real, not a tautology + // that happens to hold for every input. + const vertical = computeBoardLayout(400, 900, insets, 'vertical', 'medium') + const horizontal = computeBoardLayout(400, 900, insets, 'horizontal', 'medium') + expect(horizontal.width).not.toBeCloseTo(vertical.width, 0) + }) +}) + +describe('playerGoalSide', () => { + it('player 1 always defends the local bottom, player 2 the local top', () => { + expect(playerGoalSide(1)).toBe('bottom') + expect(playerGoalSide(2)).toBe('top') + }) +}) + +describe('goalPosts', () => { + it('are built for both goals unconditionally, positioned at the goal opening edges', () => { + const board = computeBoardLayout(400, 900, insets, 'vertical', 'medium') + expect(board.goalPosts).toHaveLength(4) + const p1Posts = board.goalPosts.filter((p) => p.player === 1) + const p2Posts = board.goalPosts.filter((p) => p.player === 2) + expect(p1Posts).toHaveLength(2) + expect(p2Posts).toHaveLength(2) + for (const post of p1Posts) expect(post.y).toBeCloseTo(board.y + board.height, 5) + for (const post of p2Posts) expect(post.y).toBeCloseTo(board.y, 5) + }) +}) + +describe('rink geometry (Settings.rink)', () => { + it.each(sizes)('rink on: cornerRadius and behindGoalDepth are both > 0 at puckSize=%s, and the extended boundary stays within the screen', (size) => { + const board = computeBoardLayout(400, 900, insets, 'vertical', size, 'medium', true) + expect(board.cornerRadius).toBeGreaterThan(0) + expect(board.behindGoalDepth).toBeGreaterThan(0) + expect(board.y - board.behindGoalDepth).toBeGreaterThanOrEqual(0) + expect(board.y + board.height + board.behindGoalDepth).toBeLessThanOrEqual(900) + }) + + it.each(sizes)('rink off: square corners, no pocket, and the goal lines sit at the safe-area edges at puckSize=%s', (size) => { + const notched = { top: 59, bottom: 34, left: 0, right: 0 } + const board = computeBoardLayout(400, 900, notched, 'vertical', size, 'medium', false) + expect(board.cornerRadius).toBe(0) + expect(board.behindGoalDepth).toBe(0) + expect(board.y).toBeCloseTo(notched.top, 5) + expect(board.y + board.height).toBeCloseTo(900 - notched.bottom, 5) + }) + + // Same as BoxHockey's rink: the pocket borrows the otherwise-empty safe-area space instead of + // pushing the goal lines forward, reaching the physical screen edge at both ends. + it('rink on: the pocket reaches into the safe-area insets, all the way to both screen edges', () => { + // Both insets deeper than the pocket's own puck-clearance minimum (68 at this size), so the + // larger one alone sets the depth. + const notched = { top: 80, bottom: 50, left: 0, right: 0 } + const board = computeBoardLayout(400, 900, notched, 'vertical', 'medium', 'medium', true) + expect(board.behindGoalDepth).toBeCloseTo(notched.top, 5) + // The larger inset's goal line stays exactly at its safe-area edge. + expect(board.y).toBeCloseTo(notched.top, 5) + expect(board.y - board.behindGoalDepth).toBeCloseTo(0, 5) + expect(board.y + board.height + board.behindGoalDepth).toBeCloseTo(900, 5) + }) + + it('rink on with equal insets: both goal lines stay exactly at their safe-area edges', () => { + const even = { top: 80, bottom: 80, left: 0, right: 0 } + const off = computeBoardLayout(400, 900, even, 'vertical', 'medium', 'medium', false) + const on = computeBoardLayout(400, 900, even, 'vertical', 'medium', 'medium', true) + expect(on.y).toBeCloseTo(off.y, 5) + expect(on.height).toBeCloseTo(off.height, 5) + }) + + it('a puck sailing wide of the goal (not banked into a corner) can reach the pocket', () => { + const board = computeBoardLayout(400, 900, insets, 'vertical', 'medium', 'medium', true) + let puck = { x: board.x + board.width * 0.25, y: board.y + board.height * 0.15, vx: 0, vy: -6 } + let reachedPocket = false + for (let i = 0; i < 300; i++) { + const result = stepPhysics(puck, board, 1) + puck = { x: result.x, y: result.y, vx: result.vx, vy: result.vy } + if (puck.y < board.y) reachedPocket = true + if (result.scored !== null || result.stopped) break + } + expect(reachedPocket).toBe(true) + }) + + it('rink off: a puck sailing wide of the goal stays in front of the goal line', () => { + const board = computeBoardLayout(400, 900, insets, 'vertical', 'medium', 'medium', false) + // Halfway between the side wall and the goal mouth, clear of the posts. + let puck = { x: board.x + board.goalStart / 2, y: board.y + board.height * 0.15, vx: 0, vy: -6 } + for (let i = 0; i < 300; i++) { + const result = stepPhysics(puck, board, 1) + puck = { x: result.x, y: result.y, vx: result.vx, vy: result.vy } + expect(puck.y - board.puckRadius).toBeGreaterThanOrEqual(board.y - 0.001) + if (result.scored !== null || result.stopped) break + } + }) + + it.each([true, false])('a dead-center shot at the goal still scores normally (rink %s)', (rink) => { + const board = computeBoardLayout(400, 900, insets, 'vertical', 'medium', 'medium', rink) + const scored = simulateToRest({ x: board.centerX, y: board.y + board.height * 0.75, vx: 0, vy: 22 }, board) + expect(scored).toBe(2) + }) +}) + +describe('centerPaddle', () => { + it('places each player on their own goal side, offset in from the line, on the board centerline in x', () => { + const board = computeBoardLayout(400, 900, insets, 'vertical', 'medium') + const p1 = centerPaddle(board, 1) + const p2 = centerPaddle(board, 2) + expect(p1.x).toBeCloseTo(board.centerX, 5) + expect(p2.x).toBeCloseTo(board.centerX, 5) + expect(p1.y).toBeLessThan(board.y + board.height) + expect(p1.y).toBeGreaterThan(board.centerY) + expect(p2.y).toBeGreaterThan(board.y) + expect(p2.y).toBeLessThan(board.centerY) + }) +}) + +describe('clampToOwnHalf', () => { + it('never lets a player cross the centerline into the opponent half', () => { + const board = computeBoardLayout(400, 900, insets, 'vertical', 'medium') + const r = board.paddleRadius + const p1AttemptingTop = clampToOwnHalf(board.centerX, board.y, board, 1, r) + expect(p1AttemptingTop.y).toBeGreaterThanOrEqual(board.centerY + r - 0.01) + const p2AttemptingBottom = clampToOwnHalf(board.centerX, board.y + board.height, board, 2, r) + expect(p2AttemptingBottom.y).toBeLessThanOrEqual(board.centerY - r + 0.01) + }) + + it('clamps to the outer board bounds', () => { + const board = computeBoardLayout(400, 900, insets, 'vertical', 'medium') + const r = board.paddleRadius + const clamped = clampToOwnHalf(board.x - 1000, board.y + board.height + 1000, board, 1, r) + expect(clamped.x).toBeCloseTo(board.x + r, 5) + expect(clamped.y).toBeCloseTo(board.y + board.height - r, 5) + }) +}) + +describe('desktop web letterboxing', () => { + const originalPlatform = require('react-native').Platform.OS + + afterEach(() => { + require('react-native').Platform.OS = originalPlatform + }) + + it('letterboxes to roughly TABLE_LONG_TO_SHORT_RATIO on wide desktop web viewports, for either axis', () => { + // Geometry is always canonical LOCAL space (see GoalSide's doc comment in types/index.ts): + // board.width is always the short axis and board.height always the long axis, for every + // goalAxis value - goalAxis only decides which real screen dimension feeds S vs. L, never + // how the result's own width/height are labeled. + // + // Only approximate, not exact: TABLE_LONG_TO_SHORT_RATIO governs the OUTER letterboxed + // container, but board.height is the inner PLAYING FIELD, shrunk by the rink's goal pocket + // depth at each end - same "field is inner, container is outer" distinction BoxHockey's own + // board.ts uses. A narrower letterboxed S makes that pocket a proportionally bigger bite out of + // L, so the field's own ratio drifts further from 2:1 than the outer container did. + require('react-native').Platform.OS = 'web' + const axes: GoalAxis[] = ['vertical', 'horizontal'] + for (const axis of axes) { + const unclamped = computeBoardLayout(2000, 1900, insets, axis, 'medium', 'medium', true) // both dims generous, so neither is the tight constraint + const clampedByWidth = computeBoardLayout(1600, 1000, insets, axis, 'medium', 'medium', true) // long dimension is the tight constraint here + // Letterboxing actually engaged: the clamped board is narrower/shorter than one with no + // tight constraint on either dimension. + expect(clampedByWidth.width).toBeLessThan(unclamped.width) + expect(clampedByWidth.height / clampedByWidth.width).toBeGreaterThan(1.5) + expect(clampedByWidth.height / clampedByWidth.width).toBeLessThan(2.1) + } + }) +}) diff --git a/src/games/airhockey/__tests__/controlScheme.test.ts b/src/games/airhockey/__tests__/controlScheme.test.ts new file mode 100644 index 0000000..9491d64 --- /dev/null +++ b/src/games/airhockey/__tests__/controlScheme.test.ts @@ -0,0 +1,58 @@ +import { assignSeatScheme } from '@tastic/input' + +import { DEFAULT_SETTINGS } from '@/games/airhockey/redux/settingsSlice' +import type { ControlScheme } from '@/games/airhockey/types' +import { resolveControlSchemeDirection } from '@/shared/controlScheme' + +describe('resolveControlSchemeDirection', () => { + it('returns null for the mouse scheme regardless of key', () => { + expect(resolveControlSchemeDirection({ key: 'w', code: 'KeyW' }, 'mouse')).toBeNull() + expect(resolveControlSchemeDirection({ key: 'ArrowUp', code: 'ArrowUp' }, 'mouse')).toBeNull() + }) + + it('resolves wasd by key', () => { + expect(resolveControlSchemeDirection({ key: 'w', code: 'KeyW' }, 'wasd')).toBe('up') + expect(resolveControlSchemeDirection({ key: 'd', code: 'KeyD' }, 'wasd')).toBe('right') + }) + + it('resolves arrows by key', () => { + expect(resolveControlSchemeDirection({ key: 'ArrowLeft', code: 'ArrowLeft' }, 'arrows')).toBe('left') + }) + + it('resolves ijkl by key', () => { + expect(resolveControlSchemeDirection({ key: 'j', code: 'KeyJ' }, 'ijkl')).toBe('left') + }) + + it('resolves numpad by code, independent of NumLock (.key may read as a digit or an arrow name)', () => { + expect(resolveControlSchemeDirection({ key: '4', code: 'Numpad4' }, 'numpad')).toBe('left') + expect(resolveControlSchemeDirection({ key: 'ArrowRight', code: 'Numpad6' }, 'numpad')).toBe('right') + }) + + it('returns null for a key not in the given scheme', () => { + expect(resolveControlSchemeDirection({ key: 'q', code: 'KeyQ' }, 'wasd')).toBeNull() + }) +}) + +// The loadout's two pickers write through assignSeatScheme (see loadout.tsx). +describe('loadout control scheme picks', () => { + const ALL_SCHEMES: ControlScheme[] = ['mouse', 'wasd', 'arrows', 'ijkl', 'numpad'] + + // Regression: a 1 Player pick of seat 2's scheme left both seats on it, and KeyboardInputLayer + // then sent every one of those keys to seat 1 in a later 2 Player game. + it("hands seat 2 seat 1's old scheme when seat 1 picks seat 2's", () => { + expect(assignSeatScheme(DEFAULT_SETTINGS.controlScheme, 1, 'wasd')).toEqual({ 1: 'wasd', 2: 'mouse' }) + }) + + it('never leaves both seats on one scheme, from any setup a pick can reach', () => { + const setups = ALL_SCHEMES.flatMap((p1) => ALL_SCHEMES.filter((p2) => p2 !== p1).map((p2) => ({ 1: p1, 2: p2 }))) + for (const current of setups) { + for (const seat of [1, 2] as const) { + for (const scheme of ALL_SCHEMES) { + const next = assignSeatScheme(current, seat, scheme) + expect(next[seat]).toBe(scheme) + expect(next[1]).not.toBe(next[2]) + } + } + } + }) +}) diff --git a/src/games/airhockey/__tests__/guideSteps.test.tsx b/src/games/airhockey/__tests__/guideSteps.test.tsx new file mode 100644 index 0000000..bc3c15b --- /dev/null +++ b/src/games/airhockey/__tests__/guideSteps.test.tsx @@ -0,0 +1,128 @@ +import { DirectionKeysHint, type SeatDevice, SeatDevicesHint, SwipeHint } from '@tastic/hud/guide' + +import { GuideScoreArcs } from '@/games/airhockey/components/GuideArt' +import { getGuideSteps } from '@/games/airhockey/constants/guideSteps' +import { DEFAULT_SETTINGS } from '@/games/airhockey/redux/settingsSlice' +import type { ControlScheme, Player } from '@/games/airhockey/types' + +const DEFAULT_SCHEMES = DEFAULT_SETTINGS.controlScheme +const ALL_SCHEMES: ControlScheme[] = ['mouse', 'wasd', 'arrows', 'ijkl', 'numpad'] +const SCORE_OPTIONS = [3, 5, 7, 10] +// The expected prose for each scheme, written out here rather than imported so a changed phrase +// shows up as a failing test. Keeps this game's own "the numpad", and the mouse is "the mouse". +const PHRASE: Record = { mouse: 'the mouse', wasd: 'WASD', arrows: 'the arrow keys', ijkl: 'IJKL', numpad: 'the numpad' } +const DEVICE: Record = { mouse: 'mouse', wasd: 'keyboard', arrows: 'keyboard', ijkl: 'keyboard', numpad: 'keyboard' } + +function steps(touch: boolean, controlScheme: Record = DEFAULT_SCHEMES, scoreToWin = DEFAULT_SETTINGS.scoreToWin) { + return getGuideSteps({ touch, controlScheme, scoreToWin }) +} + +function textOf(touch: boolean, controlScheme?: Record) { + return steps(touch, controlScheme) + .map((step) => `${step.title} ${step.body}`) + .join(' ') +} + +describe('getGuideSteps', () => { + it.each([ + ['touch', true], + ['desktop', false] + ])('gives the %s guide three complete, illustrated cards for every seat scheme and score setting', (_name, touch) => { + for (const p1 of ALL_SCHEMES) { + for (const p2 of ALL_SCHEMES) { + for (const scoreToWin of SCORE_OPTIONS) { + const cards = steps(touch, { 1: p1, 2: p2 }, scoreToWin) + + expect(cards).toHaveLength(3) + for (const step of cards) { + expect(step.title.trim().length).toBeGreaterThan(0) + // A card a casual player reads in a glance, not a wall of text. + expect(step.body.length).toBeLessThanOrEqual(90) + // The pager sizes every page to the tallest, so a card without art leaves a visible gap. + expect(step.art).toBeDefined() + expect(step.body).not.toMatch(/\u2014/) + } + } + } + } + }) + + it('headlines the live win condition and explains the score arcs, the scoreboard the game never prints as a number', () => { + for (const touch of [true, false]) { + for (const scoreToWin of SCORE_OPTIONS) { + const [, rules] = steps(touch, DEFAULT_SCHEMES, scoreToWin) + // BoxHockey's headline: both hockey games score goals, not points. + expect(rules.title).toBe(`First to ${scoreToWin} Goals Wins`) + // Board.tsx lights the scorer's arc in their OWN crease (scores[0] at player 1's bottom goal). + expect(rules.body).toBe('Each goal you score lights up one arc at your end.') + // One arc per goal needed, exactly like Board.tsx's ScoreWedges. + expect(rules.art).toMatchObject({ type: GuideScoreArcs, props: { count: scoreToWin } }) + } + } + }) + + it('teaches finger control on touch, and only says "your own half" on the two-player card (solo touches the whole table)', () => { + const [controls, , twoPlayer] = steps(true) + + expect(controls.body).toMatch(/finger/i) + expect(controls.body).not.toMatch(/half/i) + expect(twoPlayer.title).toBe('Two Players, One Phone') + expect(twoPlayer.body).toMatch(/your own half/i) + expect(textOf(true)).not.toMatch(/click|press|key|mouse|pointer|WASD/i) + }) + + it('never tells a desktop player to swipe, and describes the live scheme of player 1 on the controls card', () => { + expect(textOf(false)).not.toMatch(/swipe|finger|phone/i) + + const [mouseControls] = steps(false, { 1: 'mouse', 2: 'wasd' }) + expect(mouseControls.body).toMatch(/^Click and drag/) + // hud's shared mouse-drag picture, not a local copy. + expect(mouseControls.art).toMatchObject({ type: SwipeHint, props: { pointer: 'mouse', direction: 'up' } }) + expect(steps(false, { 1: 'wasd', 2: 'mouse' })[0].art).toMatchObject({ type: DirectionKeysHint, props: { labels: { up: 'W', left: 'A', down: 'S', right: 'D' } } }) + expect(steps(false, { 1: 'wasd', 2: 'mouse' })[0].body).toMatch(/^Player 1 moves with WASD\./) + expect(steps(false, { 1: 'arrows', 2: 'mouse' })[0].body).toMatch(/^Player 1 moves with the arrow keys\./) + expect(steps(false, { 1: 'numpad', 2: 'mouse' })[0].body).toMatch(/^Player 1 moves with the numpad\./) + }) + + it("names player 1 on a key-scheme controls card, since player 2 reads it too and never shares player 1's keys", () => { + for (const p1 of ALL_SCHEMES.filter((scheme) => scheme !== 'mouse')) { + for (const p2 of ALL_SCHEMES.filter((scheme) => scheme !== p1)) { + const [controls] = steps(false, { 1: p1, 2: p2 }) + expect(controls.body).toMatch(/^Player 1 /) + expect(controls.body).not.toMatch(/\byour\b/i) + } + } + expect(steps(false, { 1: 'ijkl', 2: 'wasd' })[0].body).toMatch(/^Player 1 moves with IJKL\./) + }) + + it('names what each seat actually plays with on desktop, in the fleet wording, and only says "One Keyboard" when nobody is on the mouse', () => { + const [, , defaults] = steps(false) + expect(defaults.title).toBe('Two Players, One Computer') + expect(defaults.body).toBe('Player 1 uses the mouse, player 2 uses WASD. Drag in your own half.') + + const [, , keysOnly] = steps(false, { 1: 'ijkl', 2: 'arrows' }) + expect(keysOnly.title).toBe('Two Players, One Keyboard') + expect(keysOnly.body).toBe('Player 1 uses IJKL, player 2 uses the arrow keys. Change them before a match.') + + const [, , bothMouse] = steps(false, { 1: 'mouse', 2: 'mouse' }) + expect(bothMouse.title).toBe('Two Players, One Computer') + expect(bothMouse.body).toBe('Both players use the mouse. Drag in your own half.') + }) + + it("follows the fleet template for every desktop seat pair, within 90 characters, with hud's SeatDevicesHint showing each seat's device", () => { + for (const p1 of ALL_SCHEMES) { + for (const p2 of ALL_SCHEMES) { + const [, , card] = steps(false, { 1: p1, 2: p2 }) + const seats: Record = { 1: p1, 2: p2 } + const anyMouse = seats[1] === 'mouse' || seats[2] === 'mouse' + const bothMouse = seats[1] === 'mouse' && seats[2] === 'mouse' + const tail = anyMouse ? ' Drag in your own half.' : ' Change them before a match.' + + expect(card.title).toBe(anyMouse ? 'Two Players, One Computer' : 'Two Players, One Keyboard') + expect(card.body).toBe(bothMouse ? `Both players use the mouse.${tail}` : `Player 1 uses ${PHRASE[seats[1]]}, player 2 uses ${PHRASE[seats[2]]}.${tail}`) + expect(card.body.length).toBeLessThanOrEqual(90) + expect(card.art).toMatchObject({ type: SeatDevicesHint, props: { devices: [DEVICE[seats[1]], DEVICE[seats[2]]] } }) + } + } + }) +}) diff --git a/src/games/airhockey/__tests__/physics.test.ts b/src/games/airhockey/__tests__/physics.test.ts new file mode 100644 index 0000000..8743033 --- /dev/null +++ b/src/games/airhockey/__tests__/physics.test.ts @@ -0,0 +1,326 @@ +import { MAX_PADDLE_SPEED, PADDLE_COLLISION_RESTITUTION } from '@/games/airhockey/constants/game' +import { BoardLayout } from '@/games/airhockey/types' +import { applyPaddleCollision, clampPaddleSpeed, computePaddleVelocity, stepPhysics } from '@/games/airhockey/utils/physics' + +// A standard test board with no goalposts/obstacles - isolates wall/goal-line behavior from +// goalpost deflection, same discipline BoxHockey's own physics.test.ts fixture uses. +const board: BoardLayout = { + x: 10, + y: 10, + width: 300, + height: 500, + goalAxis: 'vertical', + wallThickness: 8, + goalWidth: 80, + goalStart: 110, // (300 - 80) / 2 + goalEnd: 190, // goalStart + goalWidth + puckRadius: 18, + paddleRadius: 24, + centerX: 160, + centerY: 260, + obstacles: [], + goalPosts: [], + cornerRadius: 0, + behindGoalDepth: 0 +} + +const cx = board.x + board.width / 2 // 160 +const cy = board.y + board.height / 2 // 260 +const r = board.puckRadius // 18 + +describe('stepPhysics - wall bounces', () => { + it('bounces off left wall and reverses vx', () => { + const puck = { x: board.x + r + 1, y: cy, vx: -5, vy: 0 } + const result = stepPhysics(puck, board) + expect(result.x).toBeGreaterThanOrEqual(board.x + r) + expect(result.vx).toBeGreaterThan(0) + expect(result.bounced).toBe(true) + expect(result.scored).toBeNull() + }) + + it('bounces off right wall and reverses vx', () => { + const puck = { x: board.x + board.width - r - 1, y: cy, vx: 5, vy: 0 } + const result = stepPhysics(puck, board) + expect(result.x).toBeLessThanOrEqual(board.x + board.width - r) + expect(result.vx).toBeLessThan(0) + expect(result.bounced).toBe(true) + }) + + it('bounces off top wall outside the goal and reverses vy', () => { + const outsideGoalX = board.x + 30 // well left of goalStart (110) + const puck = { x: outsideGoalX, y: board.y + r + 1, vx: 0, vy: -5 } + const result = stepPhysics(puck, board) + expect(result.y).toBeGreaterThanOrEqual(board.y + r) + expect(result.vy).toBeGreaterThan(0) + expect(result.bounced).toBe(true) + expect(result.scored).toBeNull() + }) + + it('bounces off bottom wall outside the goal and reverses vy', () => { + const outsideGoalX = board.x + board.width - 30 + const puck = { x: outsideGoalX, y: board.y + board.height - r - 1, vx: 0, vy: 5 } + const result = stepPhysics(puck, board) + expect(result.y).toBeLessThanOrEqual(board.y + board.height - r) + expect(result.vy).toBeLessThan(0) + expect(result.bounced).toBe(true) + }) + + it('applies wall restitution (speed is reduced after bounce)', () => { + const speed = 10 + const puck = { x: board.x + r + 1, y: cy, vx: -speed, vy: 0 } + const result = stepPhysics(puck, board) + expect(Math.abs(result.vx)).toBeLessThan(speed) + }) + + it('does not bounce when puck is not near any wall', () => { + const puck = { x: cx, y: cy, vx: 2, vy: 2 } + const result = stepPhysics(puck, board) + expect(result.bounced).toBe(false) + expect(result.scored).toBeNull() + expect(result.stopped).toBe(false) + }) +}) + +describe('stepPhysics - goal detection', () => { + const goalAbsLeft = board.x + board.goalStart // 120 + const goalAbsRight = board.x + board.goalEnd // 200 + const safeX = (goalAbsLeft + goalAbsRight) / 2 // 160 - dead center + + it('scores P1 once the puck has fully cleared the top goal line (player 2 defends top)', () => { + const puck = { x: safeX, y: board.y + 5, vx: 0, vy: -50 } + const result = stepPhysics(puck, board) + expect(result.scored).toBe(1) + expect(result.stopped).toBe(false) + }) + + it('scores P2 once the puck has fully cleared the bottom goal line (player 1 defends bottom)', () => { + const puck = { x: safeX, y: board.y + board.height - 5, vx: 0, vy: 50 } + const result = stepPhysics(puck, board) + expect(result.scored).toBe(2) + }) + + it('does NOT score merely touching the goal line - has to clear it entirely', () => { + const puck = { x: safeX, y: board.y + r - 1, vx: 0, vy: -5 } + const result = stepPhysics(puck, board) + expect(result.scored).toBeNull() + expect(result.bounced).toBe(false) + }) + + it('bounces off the flat wall when the puck center is still outside the goal opening', () => { + const outsideX = goalAbsLeft - 10 + const puck = { x: outsideX, y: board.y + r - 1, vx: 0, vy: -5 } + const result = stepPhysics(puck, board) + expect(result.scored).toBeNull() + expect(result.bounced).toBe(true) + }) +}) + +// A rink pocket variant of the base fixture - the plain `board` above (cornerRadius/ +// behindGoalDepth both 0) has no pocket for a puck to end up behind, so the one-way door never +// has anything to gate; AirHockey's own boards always carry a real pocket (see computeBoardLayout's +// "unconditional" cornerRadius/behindGoalDepth), so this is the representative shape. +describe('stepPhysics - rink pocket door', () => { + const rinkBoard: BoardLayout = { ...board, cornerRadius: 40, behindGoalDepth: 60 } + + it("flags goalDoorHit: 'top' for a puck already behind the line bouncing off the door on its way back out", () => { + const puck = { x: cx, y: board.y - r - 1, vx: 0, vy: 5 } + const result = stepPhysics(puck, rinkBoard) + expect(result.bounced).toBe(true) + expect(result.goalDoorHit).toBe('top') + }) + + it("flags goalDoorHit: 'bottom' for a puck already behind the line bouncing off the door on its way back out", () => { + const puck = { x: cx, y: board.y + board.height + r + 1, vx: 0, vy: -5 } + const result = stepPhysics(puck, rinkBoard) + expect(result.bounced).toBe(true) + expect(result.goalDoorHit).toBe('bottom') + }) + + it('does NOT flag goalDoorHit - and does not even bounce - approaching the front line outside the goal x-range', () => { + // Clear of both the top-corner quadrant (x < rinkBoard.x + cornerRadius) and the goal opening + // (goalAbsLeft..goalAbsRight) - the flat front wall only exists across the goal opening itself + // now (see stepPhysicsOnce's own comment); outside it, the true boundary sits all the way out + // at outerTop (board.y - behindGoalDepth), so this puck sails straight past the front line + // toward the wider pocket wraparound with nothing stopping it at all. + const outsideGoalX = rinkBoard.x + rinkBoard.cornerRadius + 30 + const puck = { x: outsideGoalX, y: board.y - r - 1, vx: 0, vy: 5 } + const result = stepPhysics(puck, rinkBoard) + expect(result.bounced).toBe(false) + expect(result.goalDoorHit).toBeNull() + }) + + it('does not decay a puck already behind the line, off to the side of the goal mouth - friction is what would otherwise strand it there with no paddle able to follow', () => { + // Same off-to-the-side spot as the wraparound test above - outside the goal x-range, so + // neither the score check nor the one-way door ever see it - but this time already resting + // behind the front line rather than just crossing it, and moving purely sideways (parallel to + // the line) so nothing else this step (wall/corner/post) touches its velocity either. A strong + // friction coefficient makes any decay obvious if the exemption weren't working. + const outsideGoalX = rinkBoard.x + rinkBoard.cornerRadius + 30 + const puck = { x: outsideGoalX, y: board.y - r - 5, vx: 3, vy: 0 } + const result = stepPhysics(puck, rinkBoard, 1, 0.5) + expect(result.bounced).toBe(false) + expect(result.vx).toBeCloseTo(3, 5) + }) + + it('does decay the same puck normally once it is back in front of the line', () => { + const outsideGoalX = rinkBoard.x + rinkBoard.cornerRadius + 30 + const puck = { x: outsideGoalX, y: board.y + r + 5, vx: 3, vy: 0 } + const result = stepPhysics(puck, rinkBoard, 1, 0.5) + expect(result.vx).toBeLessThan(3) + }) + + // Regression: with a pocket, the true outer boundary (outerTop/outerBottom) is solid across the + // full width, goal mouth and post strips included. Behind-the-line pucks skip friction, so one + // that got past this wall would never slow down or come back. Containment only: with no sideways + // speed this puck slows to a stop in the pocket, and useGameState's TICK re-serves it (tested in + // useGameState.test.ts). + const outerTop = rinkBoard.y - rinkBoard.behindGoalDepth + const outerBottom = rinkBoard.y + rinkBoard.height + rinkBoard.behindGoalDepth + + it.each([ + ['top', board.y - r - 1, 5], + ['bottom', board.y + board.height + r + 1, -5] + ])('keeps a puck that bounced off the %s door inside the pocket on every following step', (_side, y, vy) => { + let puck = { x: cx, y, vx: 0, vy } + for (let i = 0; i < 300; i++) { + const result = stepPhysics(puck, rinkBoard) + expect(result.scored).toBeNull() + expect(result.y - r).toBeGreaterThanOrEqual(outerTop - 0.001) + expect(result.y + r).toBeLessThanOrEqual(outerBottom + 0.001) + puck = { x: result.x, y: result.y, vx: result.vx, vy: result.vy } + } + }) + + it('keeps every angled shot inside the rink, including shots that pass beside a goalpost into the pocket', () => { + const postRadius = r * 0.18 + const postedRinkBoard: BoardLayout = { + ...rinkBoard, + goalPosts: [ + { x: board.x + board.goalStart, y: board.y, radius: postRadius, player: 2 }, + { x: board.x + board.goalEnd, y: board.y, radius: postRadius, player: 2 }, + { x: board.x + board.goalStart, y: board.y + board.height, radius: postRadius, player: 1 }, + { x: board.x + board.goalEnd, y: board.y + board.height, radius: postRadius, player: 1 } + ] + } + let escaped = 0 + for (let x = board.x + r; x <= board.x + board.width - r; x += 2) { + for (const vx of [-4, -2, 0, 2, 4]) { + for (const vy of [-9, 9]) { + let puck = { x, y: vy < 0 ? board.y + board.height * 0.2 : board.y + board.height * 0.8, vx, vy } + for (let i = 0; i < 300; i++) { + const result = stepPhysics(puck, postedRinkBoard) + if (result.scored !== null) break + if (result.y - r < outerTop - 0.001 || result.y + r > outerBottom + 0.001) { + escaped++ + break + } + puck = { x: result.x, y: result.y, vx: result.vx, vy: result.vy } + } + } + } + } + expect(escaped).toBe(0) + }) +}) + +describe('applyPaddleCollision', () => { + const puckRadius = 18 + const paddleRadius = 24 + + it('is a no-op when the puck and paddle are not overlapping', () => { + const puck = { x: 0, y: 0, vx: 3, vy: 3 } + const paddle = { x: 500, y: 500, vx: 0, vy: 0 } + const result = applyPaddleCollision(puck, paddle, paddle, puckRadius, paddleRadius) + expect(result).toEqual(puck) + }) + + it('a stationary paddle produces an ordinary elastic bounce along the contact normal', () => { + // Puck approaching a stationary paddle head-on from the left. + const combined = puckRadius + paddleRadius + const puck = { x: -combined + 2, y: 0, vx: 5, vy: 0 } + const paddle = { x: 0, y: 0, vx: 0, vy: 0 } + const result = applyPaddleCollision(puck, paddle, paddle, puckRadius, paddleRadius) + // Normal points from paddle to puck, i.e. roughly (-1, 0) - puck's vx should reverse sign and + // scale by restitution, tangential vy stays 0. + expect(result.vx).toBeLessThan(0) + expect(Math.abs(result.vx)).toBeCloseTo(5 * PADDLE_COLLISION_RESTITUTION, 5) + expect(result.vy).toBeCloseTo(0, 5) + }) + + it('a moving paddle imparts its own velocity onto the puck - a resting puck gets launched by a fast paddle', () => { + const combined = puckRadius + paddleRadius + const puck = { x: combined - 2, y: 0, vx: 0, vy: 0 } + const slowPaddle = { x: 0, y: 0, vx: 8, vy: 0 } + const fastPaddle = { x: 0, y: 0, vx: 20, vy: 0 } + const slowResult = applyPaddleCollision(puck, slowPaddle, slowPaddle, puckRadius, paddleRadius) + const fastResult = applyPaddleCollision(puck, fastPaddle, fastPaddle, puckRadius, paddleRadius) + expect(slowResult.vx).toBeGreaterThan(0) // launched, not left at rest + expect(fastResult.vx).toBeGreaterThan(slowResult.vx) // a faster mallet hits harder + }) + + it("never mutates the paddle's own position or velocity - the paddle stays 100% finger-controlled", () => { + const combined = puckRadius + paddleRadius + const puck = { x: combined - 2, y: 0, vx: -3, vy: 1 } + const paddle = { x: 0, y: 0, vx: 12, vy: -4 } + const paddleSnapshot = { ...paddle } + applyPaddleCollision(puck, paddle, paddle, puckRadius, paddleRadius) + expect(paddle).toEqual(paddleSnapshot) + }) + + it('a puck already separating from the paddle (moving apart) is left unaffected on the normal axis', () => { + // Puck overlapping the paddle but already moving directly away from it. + const combined = puckRadius + paddleRadius + const puck = { x: combined - 2, y: 0, vx: 5, vy: 0 } // moving further in +x, away from paddle at origin + const paddle = { x: 0, y: 0, vx: 0, vy: 0 } + const result = applyPaddleCollision(puck, paddle, paddle, puckRadius, paddleRadius) + expect(result.vx).toBeCloseTo(5, 5) + expect(result.vy).toBeCloseTo(0, 5) + }) + + it('a fast swipe clean past the puck (neither endpoint overlapping) still registers a strike', () => { + // Paddle starts well clear on one side of the puck and ends well clear on the OTHER side, + // all within a single tick - a fast swipe, or a dropped frame's larger dt. Neither the start + // nor the end position overlaps the puck, so only a swept check along the path actually + // traveled catches this; a plain endpoint-only check would have missed it entirely (the + // tunneling bug this test guards against). + const combined = puckRadius + paddleRadius + const puck = { x: 0, y: 0, vx: 0, vy: 0 } + const prevPaddle = { x: -combined * 3, y: 0, vx: 0, vy: 0 } + const paddle = { x: combined * 3, y: 0, vx: 40, vy: 0 } + const result = applyPaddleCollision(puck, prevPaddle, paddle, puckRadius, paddleRadius) + expect(result.vx).toBeGreaterThan(0) // knocked forward, in the direction of the swipe + }) + + it('a paddle path that never comes near the puck is still a no-op', () => { + const puck = { x: 0, y: 0, vx: 0, vy: 0 } + const prevPaddle = { x: 500, y: 500, vx: 0, vy: 0 } + const paddle = { x: 600, y: 600, vx: 40, vy: 40 } + const result = applyPaddleCollision(puck, prevPaddle, paddle, puckRadius, paddleRadius) + expect(result).toEqual(puck) + }) +}) + +describe('computePaddleVelocity', () => { + it('derives velocity from the position delta over dt', () => { + const v = computePaddleVelocity(0, 0, 10, 0, 1) + expect(v.vx).toBeCloseTo(10, 5) + expect(v.vy).toBeCloseTo(0, 5) + }) + + it('returns zero velocity for a non-positive dt rather than dividing by zero', () => { + expect(computePaddleVelocity(0, 0, 10, 10, 0)).toEqual({ vx: 0, vy: 0 }) + }) +}) + +describe('clampPaddleSpeed', () => { + it('leaves a slow velocity untouched', () => { + expect(clampPaddleSpeed(3, 4)).toEqual({ vx: 3, vy: 4 }) + }) + + it('scales a too-fast velocity down to MAX_PADDLE_SPEED, preserving direction', () => { + const clamped = clampPaddleSpeed(300, 400) // speed 500, well over the cap + const speed = Math.hypot(clamped.vx, clamped.vy) + expect(speed).toBeCloseTo(MAX_PADDLE_SPEED, 5) + expect(clamped.vx / clamped.vy).toBeCloseTo(300 / 400, 5) + }) +}) diff --git a/src/games/airhockey/__tests__/seatResultSpots.test.ts b/src/games/airhockey/__tests__/seatResultSpots.test.ts new file mode 100644 index 0000000..4e50cc1 --- /dev/null +++ b/src/games/airhockey/__tests__/seatResultSpots.test.ts @@ -0,0 +1,17 @@ +import { seatResultSpots } from '@/games/airhockey/utils/seatResultSpots' + +// Where each 2 Player seat's Game Over card goes: their own end of the rink, facing them. +describe('seatResultSpots', () => { + it('puts player 1 at the bottom and player 2 across the table, turned to face them', () => { + expect(seatResultSpots(0, false)).toEqual({ 1: { rotation: 0, zone: 'bottom' }, 2: { rotation: 180, zone: 'top' } }) + }) + + it('splits a turned rink by the end each player defends, both upright', () => { + expect(seatResultSpots(90, false)).toEqual({ 1: { rotation: 0, zone: 'left' }, 2: { rotation: 0, zone: 'right' } }) + expect(seatResultSpots(-90, true)).toEqual({ 1: { rotation: 0, zone: 'right' }, 2: { rotation: 0, zone: 'left' } }) + }) + + it('keeps both cards upright on a computer screen', () => { + expect(seatResultSpots(0, true)).toEqual({ 1: { rotation: 0, zone: 'bottom' }, 2: { rotation: 0, zone: 'top' } }) + }) +}) diff --git a/src/games/airhockey/__tests__/settingsSlice.test.ts b/src/games/airhockey/__tests__/settingsSlice.test.ts new file mode 100644 index 0000000..a9f8391 --- /dev/null +++ b/src/games/airhockey/__tests__/settingsSlice.test.ts @@ -0,0 +1,18 @@ +import { DEFAULT_SETTINGS, playerSettings } from '@/games/airhockey/redux/settingsSlice' + +// The per-player behavior itself is shared/playerSettings.ts's, tested in playerSettings.test.tsx. +describe("Air Hockey's per-player settings", () => { + it('are saved under airhockeySettings and airhockeyControls', () => { + expect(Object.keys(playerSettings.reducers)).toEqual(['airhockeySettings', 'airhockeyControls']) + }) + + it('fill what a player never changed from the defaults', () => { + expect(playerSettings.compose({ sam: { paddleSize: 'large' } }, {}, { 1: 'sam', 2: null }, false)).toEqual({ ...DEFAULT_SETTINGS, paddleSize: 'large' }) + }) + + // The mouse and each key cluster can only steer one paddle. + it('move seat 2 to the next free control when both players saved the same one', () => { + expect(playerSettings.compose({}, { sam: 'mouse', riley: 'mouse' }, { 1: 'sam', 2: 'riley' }, true).controlScheme).toEqual({ 1: 'mouse', 2: 'wasd' }) + expect(playerSettings.compose({}, { sam: 'arrows', riley: 'arrows' }, { 1: 'sam', 2: 'riley' }, true).controlScheme).toEqual({ 1: 'arrows', 2: 'mouse' }) + }) +}) diff --git a/src/games/airhockey/__tests__/statsEngine.test.ts b/src/games/airhockey/__tests__/statsEngine.test.ts new file mode 100644 index 0000000..b4663a0 --- /dev/null +++ b/src/games/airhockey/__tests__/statsEngine.test.ts @@ -0,0 +1,202 @@ +import { Profile } from '@tastic/profile' + +import { StatsState } from '@/games/airhockey/types' +import { applyMatchOutcome, getOverallTotals, getProfileRankings, getProfileStatsView, MatchContext, MatchOutcome } from '@/games/airhockey/utils/statsEngine' +import { DEFAULT_PROFILE_STATS, DEFAULT_STATS } from '@/games/airhockey/utils/statsValidation' + +const VS_CPU: MatchContext = { gameMode: 'vsCpu', aiDifficulty: 'medium' } +const TWO_PLAYER: MatchContext = { gameMode: 'twoPlayer', aiDifficulty: 'medium' } + +function win(scores: [number, number], goalDoorHits = 0): MatchOutcome { + return { winner: scores[0] > scores[1] ? 1 : 2, scores, goalDoorHits } +} + +function play(prev: StatsState, outcome: MatchOutcome, context: MatchContext = VS_CPU, now = new Date(2026, 8, 2)): StatsState { + return applyMatchOutcome(prev, outcome, context, now) +} + +describe('applyMatchOutcome - vsCpu', () => { + it('records a human win without touching twoPlayer', () => { + const s = play(DEFAULT_STATS, win([7, 3])) + expect(s.vsCpu).toMatchObject({ played: 1, wins: 1, losses: 0 }) + expect(s.twoPlayer).toEqual({ played: 0, p1Wins: 0, p2Wins: 0 }) + }) + + it('records a human loss', () => { + const s = play(DEFAULT_STATS, win([2, 7])) + expect(s.vsCpu).toMatchObject({ played: 1, wins: 0, losses: 1 }) + }) + + it('buckets byDifficulty separately', () => { + let s = play(DEFAULT_STATS, win([7, 1]), { ...VS_CPU, aiDifficulty: 'easy' }) + s = play(s, win([7, 2]), { ...VS_CPU, aiDifficulty: 'hard' }) + expect(s.vsCpu.byDifficulty.easy.wins).toBe(1) + expect(s.vsCpu.byDifficulty.hard.wins).toBe(1) + expect(s.vsCpu.byDifficulty.medium.played).toBe(0) + }) + + it('tracks a win streak and resets it on a loss', () => { + let s = play(DEFAULT_STATS, win([7, 0])) + s = play(s, win([7, 1])) + expect(s.vsCpu).toMatchObject({ currentWinStreak: 2, bestWinStreak: 2 }) + s = play(s, win([3, 7])) + expect(s.vsCpu).toMatchObject({ currentWinStreak: 0, bestWinStreak: 2 }) + }) +}) + +describe('applyMatchOutcome - goals, shutouts and margins', () => { + it('accumulates goals for and against from seat 1s perspective', () => { + let s = play(DEFAULT_STATS, win([7, 3])) + s = play(s, win([2, 7])) + expect(s.goals).toEqual({ scored: 9, conceded: 10 }) + }) + + it('counts a shutout only on a win where nothing was conceded', () => { + let s = play(DEFAULT_STATS, win([7, 0])) + expect(s.shutouts).toBe(1) + // Losing 0-7 is not a shutout for seat 1. + s = play(s, win([0, 7])) + expect(s.shutouts).toBe(1) + }) + + it('tracks the best win margin, ignoring losses', () => { + let s = play(DEFAULT_STATS, win([7, 4])) + expect(s.bestWinMargin).toBe(3) + s = play(s, win([0, 7])) + expect(s.bestWinMargin).toBe(3) + s = play(s, win([7, 0])) + expect(s.bestWinMargin).toBe(7) + }) +}) + +describe('applyMatchOutcome - goal door', () => { + it('accumulates goal-door hits across matches', () => { + let s = play(DEFAULT_STATS, win([7, 3], 4)) + s = play(s, win([7, 1], 6)) + expect(s.goalDoorHits).toBe(10) + }) + + it('credits both profiles the same match total, since the door is shared', () => { + const s = play(DEFAULT_STATS, win([7, 1], 5), { ...TWO_PLAYER, profileIds: { 1: 'alice', 2: 'bob' } }) + expect(s.profiles.alice.goalDoorHits).toBe(5) + expect(s.profiles.bob.goalDoorHits).toBe(5) + }) +}) + +describe('applyMatchOutcome - twoPlayer seats', () => { + it('records seat wins without touching vsCpu', () => { + const s = play(DEFAULT_STATS, win([7, 5]), TWO_PLAYER) + expect(s.twoPlayer).toEqual({ played: 1, p1Wins: 1, p2Wins: 0 }) + expect(s.vsCpu.played).toBe(0) + }) + + it('credits seat 2 when seat 2 wins', () => { + const s = play(DEFAULT_STATS, win([4, 7]), TWO_PLAYER) + expect(s.twoPlayer).toEqual({ played: 1, p1Wins: 0, p2Wins: 1 }) + }) +}) + +describe('applyMatchOutcome - profiles', () => { + const withProfiles: MatchContext = { ...TWO_PLAYER, profileIds: { 1: 'alice', 2: 'bob' } } + + it('leaves profiles untouched when no ids are supplied', () => { + expect(play(DEFAULT_STATS, win([7, 1]), TWO_PLAYER).profiles).toEqual({}) + }) + + it("attributes the result to each seat's own profile", () => { + const s = play(DEFAULT_STATS, win([7, 1]), withProfiles) + expect(s.profiles.alice.twoPlayer).toEqual({ played: 1, p1Wins: 1, p2Wins: 0 }) + expect(s.profiles.bob.twoPlayer).toEqual({ played: 1, p1Wins: 0, p2Wins: 0 }) + }) + + it("records each profile's goals from its own side of the board", () => { + const s = play(DEFAULT_STATS, win([7, 1]), withProfiles) + expect(s.profiles.alice.goals).toEqual({ scored: 7, conceded: 1 }) + expect(s.profiles.bob.goals).toEqual({ scored: 1, conceded: 7 }) + }) + + it('credits a shutout to the winner only', () => { + const s = play(DEFAULT_STATS, win([7, 0]), withProfiles) + expect(s.profiles.alice.shutouts).toBe(1) + expect(s.profiles.bob.shutouts).toBe(0) + }) + + it("counts a profile's own wins regardless of which seat they sat in", () => { + let s = play(DEFAULT_STATS, win([7, 1]), { ...TWO_PLAYER, profileIds: { 1: 'alice' } }) + // Alice moves to seat 2 and wins again. + s = play(s, win([1, 7]), { ...TWO_PLAYER, profileIds: { 2: 'alice' } }) + const alice = s.profiles.alice + expect(alice.twoPlayer.p1Wins + alice.twoPlayer.p2Wins).toBe(2) + expect(alice.twoPlayer.played).toBe(2) + }) + + it('never creates a phantom CPU-seat profile entry in vsCpu', () => { + const s = play(DEFAULT_STATS, win([7, 1]), { ...VS_CPU, profileIds: { 1: 'alice' } }) + expect(Object.keys(s.profiles)).toEqual(['alice']) + }) +}) + +describe('firstGameResult', () => { + it('is set from the very first match, from seat 1s perspective', () => { + expect(play(DEFAULT_STATS, win([7, 2])).firstGameResult).toBe('win') + expect(play(DEFAULT_STATS, win([2, 7])).firstGameResult).toBe('loss') + }) + + it('is never overwritten by a later match', () => { + let s = play(DEFAULT_STATS, win([2, 7])) + s = play(s, win([7, 0])) + expect(s.firstGameResult).toBe('loss') + }) +}) + +describe('day streak', () => { + it('starts at 1 on the first day played', () => { + const s = play(DEFAULT_STATS, win([7, 0]), VS_CPU, new Date(2026, 8, 2)) + expect(s).toMatchObject({ distinctDaysPlayed: 1, currentDayStreak: 1, bestDayStreak: 1 }) + }) + + it('does not double-count two matches on the same day', () => { + let s = play(DEFAULT_STATS, win([7, 0]), VS_CPU, new Date(2026, 8, 2, 9)) + s = play(s, win([7, 1]), VS_CPU, new Date(2026, 8, 2, 21)) + expect(s.distinctDaysPlayed).toBe(1) + // The match itself still counted, even though the day didn't. + expect(s.vsCpu.played).toBe(2) + }) + + it('extends on a consecutive day and restarts after a gap', () => { + let s = play(DEFAULT_STATS, win([7, 0]), VS_CPU, new Date(2026, 8, 1)) + s = play(s, win([7, 0]), VS_CPU, new Date(2026, 8, 2)) + expect(s.currentDayStreak).toBe(2) + s = play(s, win([7, 0]), VS_CPU, new Date(2026, 8, 9)) + expect(s).toMatchObject({ currentDayStreak: 1, bestDayStreak: 2 }) + }) +}) + +describe('selectors', () => { + it('getOverallTotals sums both modes', () => { + let s = play(DEFAULT_STATS, win([7, 1])) + s = play(s, win([1, 7])) + s = play(s, win([7, 3]), TWO_PLAYER) + expect(getOverallTotals(s)).toEqual({ played: 3, wins: 2, losses: 1 }) + }) + + it('getProfileStatsView synthesizes a view with no nesting and a null firstGameResult', () => { + const s = play(DEFAULT_STATS, win([7, 1]), { ...TWO_PLAYER, profileIds: { 1: 'alice' } }) + const view = getProfileStatsView(s.profiles.alice) + expect(view.profiles).toEqual({}) + expect(view.firstGameResult).toBeNull() + expect(view.goals).toEqual({ scored: 7, conceded: 1 }) + }) + + it('getProfileRankings sorts by wins then win rate, and includes profiles with no games', () => { + const mk = (id: string, name: string): Profile => ({ id, name, color: '#fff', tag: '', createdAt: 0, updatedAt: 0 }) + const profiles = [mk('a', 'A'), mk('b', 'B'), mk('c', 'C')] + const statsMap = { + a: { ...DEFAULT_PROFILE_STATS, twoPlayer: { played: 4, p1Wins: 1, p2Wins: 0 } }, + b: { ...DEFAULT_PROFILE_STATS, twoPlayer: { played: 2, p1Wins: 2, p2Wins: 0 } } + } + const ranked = getProfileRankings(profiles, statsMap) + expect(ranked.map((r) => r.profile.id)).toEqual(['b', 'a', 'c']) + expect(ranked[2]).toMatchObject({ played: 0, wins: 0, winRate: 0 }) + }) +}) diff --git a/src/games/airhockey/__tests__/statsValidation.test.ts b/src/games/airhockey/__tests__/statsValidation.test.ts new file mode 100644 index 0000000..fa11f26 --- /dev/null +++ b/src/games/airhockey/__tests__/statsValidation.test.ts @@ -0,0 +1,62 @@ +import { DEFAULT_PROFILE_STATS, DEFAULT_STATS, isValidStats } from '@/games/airhockey/utils/statsValidation' + +describe('DEFAULT_STATS', () => { + it('is a zeroed blob that validates', () => { + expect(isValidStats(DEFAULT_STATS)).toBe(true) + expect(DEFAULT_STATS.profiles).toEqual({}) + expect(DEFAULT_STATS.firstGameResult).toBeNull() + }) + + it('zeroes every difficulty bucket', () => { + expect(Object.values(DEFAULT_STATS.vsCpu.byDifficulty).every((r) => r.played === 0)).toBe(true) + }) + + it('starts goal-door hits at zero', () => { + expect(DEFAULT_STATS.goalDoorHits).toBe(0) + }) + + it('DEFAULT_PROFILE_STATS carries no nesting of its own', () => { + expect('profiles' in DEFAULT_PROFILE_STATS).toBe(false) + }) +}) + +describe('isValidStats', () => { + it('accepts a blob with no profiles key - the additive-field case migrateStats backfills', () => { + const { profiles: _dropped, ...withoutProfiles } = DEFAULT_STATS + expect(isValidStats(withoutProfiles)).toBe(true) + }) + + it('accepts populated profile buckets', () => { + expect(isValidStats({ ...DEFAULT_STATS, profiles: { alice: DEFAULT_PROFILE_STATS } })).toBe(true) + }) + + it('rejects non-objects and arrays', () => { + expect(isValidStats(null)).toBe(false) + expect(isValidStats(undefined)).toBe(false) + expect(isValidStats('{}')).toBe(false) + expect(isValidStats([])).toBe(false) + }) + + it('rejects a missing or malformed vsCpu bucket', () => { + expect(isValidStats({ ...DEFAULT_STATS, vsCpu: undefined })).toBe(false) + expect(isValidStats({ ...DEFAULT_STATS, vsCpu: { ...DEFAULT_STATS.vsCpu, byDifficulty: {} } })).toBe(false) + }) + + it('rejects a malformed goals object', () => { + expect(isValidStats({ ...DEFAULT_STATS, goals: { scored: 1 } })).toBe(false) + }) + + it('rejects a non-numeric shutouts or bestWinMargin', () => { + expect(isValidStats({ ...DEFAULT_STATS, shutouts: '3' })).toBe(false) + expect(isValidStats({ ...DEFAULT_STATS, bestWinMargin: null })).toBe(false) + }) + + it('rejects an unrecognized firstGameResult', () => { + expect(isValidStats({ ...DEFAULT_STATS, firstGameResult: 'forfeit' })).toBe(false) + expect(isValidStats({ ...DEFAULT_STATS, firstGameResult: 'win' })).toBe(true) + }) + + it('rejects a profiles map whose bucket is malformed', () => { + expect(isValidStats({ ...DEFAULT_STATS, profiles: { alice: { vsCpu: 1 } } })).toBe(false) + }) +}) diff --git a/src/games/airhockey/__tests__/useGameState.test.ts b/src/games/airhockey/__tests__/useGameState.test.ts new file mode 100644 index 0000000..3026816 --- /dev/null +++ b/src/games/airhockey/__tests__/useGameState.test.ts @@ -0,0 +1,203 @@ +import { STOP_THRESHOLD } from '@/games/airhockey/constants/game' +import { makeReducer } from '@/games/airhockey/hooks/useGameState' +import { GameState } from '@/games/airhockey/types' +import { centerPaddle, computeBoardLayout, faceoffZoneCenter } from '@/games/airhockey/utils/board' + +const insets = { top: 0, bottom: 0, left: 0, right: 0 } +const board = computeBoardLayout(400, 900, insets, 'vertical', 'medium', 'medium', true) + +function idleState(): GameState { + return makeReducer(5, 0.986)( + // Seed via RESET so the fixture always matches makeInitialState's own shape, rather than + // hand-duplicating it here and risking drift. + { phase: 'idle', scores: [0, 0], puck: { x: 0, y: 0, vx: 0, vy: 0 }, paddles: { 1: { x: 0, y: 0, vx: 0, vy: 0 }, 2: { x: 0, y: 0, vx: 0, vy: 0 } }, lastScoredGoal: null, bounceCount: 0, goalDoorHitCount: 0, lastGoalDoorSide: null, pocketDoorHit: null }, + { type: 'RESET', board } + ) +} + +function liveState(): GameState { + const reducer = makeReducer(5, 0.986) + return reducer(idleState(), { type: 'START' }) +} + +describe('TICK ordering', () => { + it('commits paddle targets into velocity-bearing state and steps the puck in the same tick', () => { + const reducer = makeReducer(5, 0.986) + const state = liveState() + const targets = { 1: { x: board.centerX + 5, y: state.paddles[1].y }, 2: { x: state.paddles[2].x, y: state.paddles[2].y } } + const next = reducer(state, { type: 'TICK', board, dt: 1, targets }) + expect(next.paddles[1].x).toBeCloseTo(board.centerX + 5, 5) + expect(next.paddles[1].vx).toBeGreaterThan(0) // moved right this tick, so it picked up rightward velocity + }) + + it('a paddle striking the puck imparts velocity onto it within the same tick', () => { + const reducer = makeReducer(5, 0.986) + const state = liveState() + // Puck sits at center; player 1's paddle starts well below it and its target this tick moves + // straight up (purely vertical, same x as the puck) to just past overlap - an unambiguous + // head-on approach along the collision normal, not a graze. + const startY = board.centerY + board.puckRadius + board.paddleRadius + 40 + const targetY = board.centerY + board.puckRadius + board.paddleRadius - 5 + const targets = { 1: { x: board.centerX, y: targetY }, 2: state.paddles[2] } + const next = reducer({ ...state, paddles: { ...state.paddles, 1: { x: board.centerX, y: startY, vx: 0, vy: 0 } } }, { type: 'TICK', board, dt: 1, targets }) + expect(next.puck.vy).toBeLessThan(0) // knocked upward, away from the approaching paddle + }) + + it('a fast swipe past the puck in one tick still strikes it, instead of tunneling through', () => { + const reducer = makeReducer(5, 0.986) + const state = liveState() + // Player 1's paddle starts well to one side of the puck (which sits at center) and this + // tick's target jumps to well past the OTHER side - a fast swipe, or a dropped frame's larger + // dt - with neither position overlapping the puck. A plain start/end-of-tick check would see + // no contact at all; only a swept check along the path actually traveled catches it. + const combined = board.puckRadius + board.paddleRadius + const startX = board.centerX - combined * 3 + const targetX = board.centerX + combined * 3 + const targets = { 1: { x: targetX, y: board.centerY }, 2: state.paddles[2] } + const next = reducer({ ...state, paddles: { ...state.paddles, 1: { x: startX, y: board.centerY, vx: 0, vy: 0 } } }, { type: 'TICK', board, dt: 1, targets }) + expect(next.puck.vx).toBeGreaterThan(0) // knocked forward, in the direction of the swipe + }) + + it('TICK is a no-op outside the live phase', () => { + const reducer = makeReducer(5, 0.986) + const state = idleState() + const targets = { 1: state.paddles[1], 2: state.paddles[2] } + const next = reducer(state, { type: 'TICK', board, dt: 1, targets }) + expect(next).toBe(state) + }) +}) + +describe('scoring and re-serve', () => { + it("a goal increments the score, transitions to goalScored, and re-serves the puck at the scored-on player's own faceoff zone on GOAL_SERVE", () => { + const reducer = makeReducer(5, 0.986) + let state = liveState() + const targets = { 1: state.paddles[1], 2: state.paddles[2] } + // A fast puck already at the goal mouth, heading in - scores within one tick. + state = { ...state, puck: { x: board.centerX, y: board.y + 2, vx: 0, vy: -30 } } + const afterGoal = reducer(state, { type: 'TICK', board, dt: 1, targets }) + expect(afterGoal.phase).toBe('goalScored') + expect(afterGoal.scores[0]).toBe(1) + expect(afterGoal.lastScoredGoal).toBe('top') + + // Player 1 scored (into player 2's local-top goal), so player 2 is the scored-on player and + // gets the restart - see GOAL_SERVE's own comment for why this isn't a neutral center serve. + const expected = faceoffZoneCenter(board, 2, 0) + const served = reducer(afterGoal, { type: 'GOAL_SERVE', board, zoneIndex: 0 }) + expect(served.phase).toBe('live') + expect(served.puck.x).toBeCloseTo(expected.x, 5) + expect(served.puck.y).toBeCloseTo(expected.y, 5) + expect(served.puck.vx).toBe(0) + expect(served.puck.vy).toBe(0) + expect(served.lastScoredGoal).toBeNull() + }) + + it('reaching scoreToWin ends the game instead of transitioning to goalScored', () => { + const reducer = makeReducer(1, 0.986) // score to win = 1, so a single goal ends it + let state = liveState() + const targets = { 1: state.paddles[1], 2: state.paddles[2] } + state = { ...state, puck: { x: board.centerX, y: board.y + 2, vx: 0, vy: -30 } } + const after = reducer(state, { type: 'TICK', board, dt: 1, targets }) + expect(after.phase).toBe('gameOver') + expect(after.scores[0]).toBe(1) + }) + + it('GOAL_SERVE is a no-op outside the goalScored phase', () => { + const reducer = makeReducer(5, 0.986) + const state = liveState() + const next = reducer(state, { type: 'GOAL_SERVE', board, zoneIndex: 0 }) + expect(next).toBe(state) + }) +}) + +describe('START', () => { + it('flips idle to live without moving the puck or paddles', () => { + const reducer = makeReducer(5, 0.986) + const idle = idleState() + const started = reducer(idle, { type: 'START' }) + expect(started.phase).toBe('live') + expect(started.puck).toEqual(idle.puck) + expect(started.paddles).toEqual(idle.paddles) + }) + + it('is a no-op outside the idle phase', () => { + const reducer = makeReducer(5, 0.986) + const state = liveState() + const next = reducer(state, { type: 'START' }) + expect(next).toBe(state) + }) +}) + +describe('RESCALE', () => { + it('remaps the puck/paddles by their fractional position within the board, preserving phase and scores', () => { + const reducer = makeReducer(5, 0.986) + const state = { ...liveState(), scores: [2, 1] as [number, number] } + const doubled = computeBoardLayout(800, 1800, insets, 'vertical', 'medium', 'medium', true) + const next = reducer(state, { type: 'RESCALE', prevBoard: board, board: doubled }) + expect(next.phase).toBe('live') + expect(next.scores).toEqual([2, 1]) + // Puck started dead center (fraction 0.5, 0.5 of the field) - should still be dead center of + // the new, larger board. + expect(next.puck.x).toBeCloseTo(doubled.centerX, 5) + expect(next.puck.y).toBeCloseTo(doubled.centerY, 5) + }) + + it('falls back to dead center (rather than NaN) when rescaling from a degenerate zero-sized board', () => { + const reducer = makeReducer(5, 0.986) + const state = liveState() + const zeroBoard = computeBoardLayout(0, 0, insets, 'vertical', 'medium', 'medium', true) + const next = reducer(state, { type: 'RESCALE', prevBoard: zeroBoard, board }) + expect(Number.isNaN(next.puck.x)).toBe(false) + expect(Number.isNaN(next.puck.y)).toBe(false) + expect(next.puck.x).toBeCloseTo(board.centerX, 5) + expect(next.puck.y).toBeCloseTo(board.centerY, 5) + }) +}) + +describe('stranded puck re-serve', () => { + const r = board.puckRadius + const goalCX = board.x + (board.goalStart + board.goalEnd) / 2 + const homeTargets = { 1: centerPaddle(board, 1), 2: centerPaddle(board, 2) } + + // Regression: behind-the-line pucks skip friction, but each bounce between the one-way door and + // the pocket's back wall still costs WALL_RESTITUTION, so a puck with no sideways speed slows to + // a stop there, out of every paddle's reach. A goal never happens, so nothing used to serve again. + it.each([ + ['top', goalCX - 5, board.y - r - 1, 5, 2, 0], + ['bottom', goalCX + 5, board.y + board.height + r + 1, -5, 1, 1] + ] as const)('re-serves a puck that comes to rest in the %s pocket at the near faceoff zone in that end, with no goal and one door hit', (_side, x, y, vy, owner, zoneIndex) => { + const reducer = makeReducer(5, 0.986) + let state: GameState = { ...liveState(), puck: { x, y, vx: 0, vy } } + let ticks = 0 + while (state.phase === 'live' && ticks < 5000) { + state = reducer(state, { type: 'TICK', board, dt: 1, targets: homeTargets }) + ticks++ + const { puck } = state + const atRest = Math.abs(puck.vx) < STOP_THRESHOLD && Math.abs(puck.vy) < STOP_THRESHOLD + const behindLine = puck.y + r < board.y || puck.y - r > board.y + board.height + expect(atRest && behindLine).toBe(false) + } + const expected = faceoffZoneCenter(board, owner, zoneIndex) + expect(state.phase).toBe('idle') + expect(state.puck).toEqual({ x: expected.x, y: expected.y, vx: 0, vy: 0 }) + expect(state.scores).toEqual([0, 0]) + expect(state.lastScoredGoal).toBeNull() + expect(state.goalDoorHitCount).toBe(1) + expect(state.pocketDoorHit).toBeNull() + + // The usual serve beat (useGameState's GOAL_SERVE_PAUSE_MS timer) then puts it back in play. + const started = reducer(state, { type: 'START' }) + expect(started.phase).toBe('live') + expect(started.puck).toEqual(state.puck) + }) + + it('counts the door again on a later pocket visit', () => { + const reducer = makeReducer(5, 0.986) + const inPlay = reducer({ ...liveState(), pocketDoorHit: 'top' }, { type: 'TICK', board, dt: 1, targets: homeTargets }) + expect(inPlay.pocketDoorHit).toBeNull() + + const doorHit = reducer({ ...inPlay, puck: { x: goalCX, y: board.y - r - 1, vx: 0, vy: 5 } }, { type: 'TICK', board, dt: 1, targets: homeTargets }) + expect(doorHit.goalDoorHitCount).toBe(inPlay.goalDoorHitCount + 1) + expect(doorHit.lastGoalDoorSide).toBe('top') + expect(doorHit.pocketDoorHit).toBe('top') + }) +}) diff --git a/src/games/airhockey/art/icon-dark.png b/src/games/airhockey/art/icon-dark.png new file mode 100644 index 0000000..6e7a8b9 Binary files /dev/null and b/src/games/airhockey/art/icon-dark.png differ diff --git a/src/games/airhockey/art/icon-light.png b/src/games/airhockey/art/icon-light.png new file mode 100644 index 0000000..732725f Binary files /dev/null and b/src/games/airhockey/art/icon-light.png differ diff --git a/src/games/airhockey/components/Board.tsx b/src/games/airhockey/components/Board.tsx new file mode 100644 index 0000000..f14172c --- /dev/null +++ b/src/games/airhockey/components/Board.tsx @@ -0,0 +1,248 @@ +import { useAutoPaperTheme } from '@rific/auto-paper' +import { Canvas, Circle, Group, Line, Path, Rect, Skia, vec } from '@shopify/react-native-skia' +import { computeGoalWedges } from '@tastic/physics' +import { FaceoffCircle, GoalDoorFlash, ScoreWedges, ZoneLine } from '@tastic/sprites/hockey' +import { CelebrationRings, Disc, GoalPost, ObstacleRect } from '@tastic/sprites/shapes' +import { useEffect, useMemo } from 'react' +import { StyleSheet, View } from 'react-native' +import { cancelAnimation, Easing, useDerivedValue, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated' + +import { GOAL_LINE_STROKE_WIDTH } from '@/games/airhockey/constants/game' +import { BoardLayout, GamePhase, GoalSide, PaddleStates, Player, PuckState } from '@/games/airhockey/types' +import { faceoffZoneCenters, faceoffZoneRadius } from '@/games/airhockey/utils/board' + +// Flip to true locally to see the rink's true collision curve (corners + behind-goal pocket) while +// tuning cornerRadius/behindGoalDepth in computeBoardLayout - off by default, same as BoxHockey's, +// since normal play deliberately draws nothing back there (see rinkPerimeterPath's own comment). +const SHOW_RINK_DEBUG_PERIMETER = false + +export interface BoardProps { + board: BoardLayout + puck: PuckState + paddles: PaddleStates + phase: GamePhase + scores: [number, number] + scoreToWin: number + lastScoredGoal: GoalSide | null + // Same "counter + kind" shape as useGameState's own bounceCount - bumped whenever the puck + // struck the invisible one-way goal-mouth door (see physics.ts's goalDoorHit). Drives the + // goal-door flash below; never resets between rounds, so that effect diffs against a ref rather + // than expecting a return to 0. + goalDoorHitCount: number + lastGoalDoorSide: GoalSide | null + // Perimeter walls (all four sides, rink off only) - see game.tsx's isDesktopWeb. On a phone + // (native or mobile web) they'd sit redundantly right against the device's own physical edge, + // which already reads as the boundary; only desktop web, with no such edge, gets them. + showWalls: boolean +} + +export default function Board({ board, puck, paddles, phase, scores, scoreToWin, lastScoredGoal, goalDoorHitCount, lastGoalDoorSide, showWalls }: BoardProps) { + const { x, y, width, height, wallThickness, puckRadius, paddleRadius, goalStart, goalEnd, obstacles, goalPosts } = board + + const goalAbsLeft = x + goalStart + const goalAbsRight = x + goalEnd + const goalCX = x + width / 2 + + const { colors: themeColors, dark } = useAutoPaperTheme() + + // Retro-vector palette - same convention as BoxHockey/LightCycles: the board's own structure + // (walls/goal lines) is a literal black/white-by-appearance neutral, not a themed color, since + // it's scaffolding rather than a gameplay signal; a player's own puck/paddle/goal accents are + // the only saturated color on screen. + const wallColor = dark ? '#FFFFFF' : '#000000' + const lineColor = dark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.25)' + const centerLineColor = dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)' + const p1Color = themeColors.primary + const p2Color = themeColors.secondary + + // ─── Puck ring pulse ──────────────────────────────────────────────────────── + // A gentle "not live yet" breathe during the pre-game/post-goal pause - plain white and static + // once play is actually live, unlike BoxHockey's per-player-colored idle ring (there's no + // "whose turn" concept here to color it by). + const waiting = phase !== 'live' + const idlePulseT = useSharedValue(0) + useEffect(() => { + if (waiting) { + idlePulseT.value = 0 + idlePulseT.value = withRepeat(withTiming(1, { duration: 900, easing: Easing.inOut(Easing.sin) }), -1, true) + } else { + cancelAnimation(idlePulseT) + idlePulseT.value = 0 + } + }, [waiting]) // eslint-disable-line react-hooks/exhaustive-deps + const ringOpacity = useDerivedValue(() => (waiting ? 0.55 + idlePulseT.value * 0.35 : 1)) + + // ─── Goal crease / score arcs ────────────────────────────────────────────── + // The crease doubles as the score meter - see computeGoalWedges' own doc comment. Player 1 + // always sits at the local bottom (see playerGoalSide in utils/board.ts), so p1's crease/wedges + // are anchored at y+height/baseAngle 180, and p2's at y/baseAngle 0 - same convention BoxHockey + // uses, ported unchanged. + const goalCreaseRadius = board.goalWidth / 2 + + const p1CreaseFill = useMemo(() => { + const r = goalCreaseRadius + return Skia.PathBuilder.Make() + .addArc({ x: goalCX - r, y: y + height - r, width: r * 2, height: r * 2 }, 180, 180) + .close() + .detach() + }, [goalCX, y, height, goalCreaseRadius]) + + const p2CreaseFill = useMemo(() => { + const r = goalCreaseRadius + return Skia.PathBuilder.Make() + .addArc({ x: goalCX - r, y: y - r, width: r * 2, height: r * 2 }, 0, 180) + .close() + .detach() + }, [goalCX, y, goalCreaseRadius]) + + // Wedge angle math lives in @tastic/physics (computeGoalWedges); the Skia path-building + + // filled/unfilled-by-score rendering is now @tastic/sprites' ScoreWedges (see its "Score arcs" + // render usage below) - this just supplies the raw angle array each expects. + const p1WedgeAngles = useMemo(() => computeGoalWedges(scoreToWin, 180), [scoreToWin]) + const p2WedgeAngles = useMemo(() => computeGoalWedges(scoreToWin, 0), [scoreToWin]) + + // ─── Rink pocket perimeter (curved caps behind each goal) ────────────────── + // Ported from BoxHockey unchanged: two open arc-line-arc caps built off the TRUE outer boundary + // (extended by behindGoalDepth), null when Settings.rink is off. Never drawn in normal play - the + // pocket reads best with nothing drawn there at all, the puck just going all the way to the + // device's own physical edge - so it's kept only for SHOW_RINK_DEBUG_PERIMETER, and as the + // "rink is on" signal the desktop perimeter walls below key off. + const rinkPerimeterPath = useMemo(() => { + if (board.cornerRadius <= 0) return null + const cr = board.cornerRadius + const outerTop = y - board.behindGoalDepth + const outerBottom = y + height + board.behindGoalDepth + const left = x + const right = x + width + return Skia.PathBuilder.Make() + .moveTo(left, outerTop + cr) + .addArc({ x: left, y: outerTop, width: cr * 2, height: cr * 2 }, 180, 90) + .lineTo(right - cr, outerTop) + .addArc({ x: right - cr * 2, y: outerTop, width: cr * 2, height: cr * 2 }, 270, 90) + .moveTo(right, outerBottom - cr) + .addArc({ x: right - cr * 2, y: outerBottom - cr * 2, width: cr * 2, height: cr * 2 }, 0, 90) + .lineTo(left + cr, outerBottom) + .addArc({ x: left, y: outerBottom - cr * 2, width: cr * 2, height: cr * 2 }, 90, 90) + .detach() + }, [board.cornerRadius, board.behindGoalDepth, x, y, width, height]) + + // ─── Goal celebration rings ───────────────────────────────────────────────── + // Timing/opacity/stroke-width machinery (850/160/320ms stagger, 0.75 peak opacity, 2.5/2/1.5 + // stroke widths) now lives in @tastic/sprites' CelebrationRings - see its render usage below. + const celebCY = lastScoredGoal === 'top' ? y : y + height + const celebColor = lastScoredGoal === 'top' ? p2Color : p1Color + + // ─── Goal door flash (puck hit the invisible one-way door at the goal mouth) ──────────────── + // Two-layer color/opacity decay + hit-count/side gating (ported from BoxHockey unchanged) now + // lives in @tastic/sprites' GoalDoorFlash - see its render usage below. + + const paddleRingRadius = paddleRadius * 0.35 + + // ─── Zone lines ───────────────────────────────────────────────────────────── + // A rink's blue-line equivalent, ported from BoxHockey unchanged: a bold full-width bar, in + // each player's own color, sitting further out than the goal crease but short of center - real + // blue lines sit about 70% of the way from the goal line to center ice, hence the 0.72 + // fraction. Spans the full board width the way a real blue line spans the full rink, so it + // reads as "this whole zone is yours" rather than another marker for the goal itself. + const zoneLineFraction = 0.72 + const p1ZoneLineY = y + height - (height / 2) * zoneLineFraction + const p2ZoneLineY = y + (height / 2) * zoneLineFraction + + // ─── Rink faceoff circles ─────────────────────────────────────────────────── + // Purely decorative rink chrome, ported from BoxHockey's own faceoff-circle layout - unlike + // there, AirHockey has no turn-based draw for these to gate (a goal just re-centers the puck, + // see useGameState's GOAL_SERVE), so only the "permanent chrome" ring + center dot BoxHockey + // itself renders in every state alike ever shows here; none of its live/pulse/ripple states + // apply since there's no faceoff phase to drive them. + const faceoffRadius = faceoffZoneRadius(board) + const faceoffCenters: { cx: number; cy: number; color: string }[] = ([1, 2] as Player[]).flatMap((player) => faceoffZoneCenters(board, player).map((c) => ({ cx: c.x, cy: c.y, color: player === 1 ? p1Color : p2Color }))) + + return ( + + + {/* Goal crease background tint */} + + + + + + {/* Score arcs */} + + + + {/* Rink faceoff circles - see the comment above. */} + {faceoffCenters.map((c, i) => ( + + ))} + + {/* Centerline - marks the boundary neither paddle can cross (see clampToOwnHalf in + utils/board.ts) so it reads as a real rule of the table, not just decoration. */} + + + {/* Zone lines - see the comment above. */} + + + + {/* Goal lines - same as BoxHockey's: each player's own color when Settings.rink is on (a + painted line in front of an open pocket), plain wallColor when it's off + (board.behindGoalDepth is exactly 0 then), since with no pocket behind it this line IS + the solid boundary a puck stops dead at. */} + + + + + + {/* Goal door flash - fills in exactly the gap the two goal-line s above deliberately + leave open (goalAbsLeft..goalAbsRight). Invisible at rest until a real door hit snaps + both layers on - this is the ONLY visual cue that the gap isn't actually open. Rink only: + with no pocket there's no door to hit (gated the same way physics.ts gates it). */} + {board.behindGoalDepth > 0 && } + + {/* Perimeter walls - desktop web only (see showWalls), and only with Settings.rink off: + with the pocket on, a solid wall flanking the goal would mark a non-crossable boundary + right against a goal line that's marking a crossable one, so nothing is drawn there, + same as BoxHockey. SHOW_RINK_DEBUG_PERIMETER draws the rink's true collision curve. */} + {SHOW_RINK_DEBUG_PERIMETER && rinkPerimeterPath && } + {showWalls && !rinkPerimeterPath && ( + <> + + + + + + + + )} + + {/* Obstacles - always empty (an open rink has no center divider); kept so a future variant + could add some without touching this render loop. */} + {obstacles.map((obs, i) => ( + + ))} + + {/* Goal posts - small round pegs colored per-goal, not the neutral wallColor. */} + {goalPosts.map((post, i) => ( + + ))} + + {/* Paddles - a live, finger-controlled disc (unlike BoxHockey's swing-animated stick): + position is driven directly by state.paddles[player] every tick, no aim/swing state to + preview. A smaller, lighter inner ring gives it a "mallet knob" read. */} + + + + + + {/* Puck - flat black fill + a white ring that softly breathes while waiting to go live. */} + + + {/* Celebration rings */} + {lastScoredGoal !== null && } + + + ) +} + +const styles = StyleSheet.create({ + container: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0 } +}) diff --git a/src/games/airhockey/components/BoardHost.tsx b/src/games/airhockey/components/BoardHost.tsx new file mode 100644 index 0000000..4af679b --- /dev/null +++ b/src/games/airhockey/components/BoardHost.tsx @@ -0,0 +1,3 @@ +// Native draws the board directly: Skia's native bindings are ready before any JS runs. The web +// version (BoardHost.web.tsx) waits for CanvasKit first. +export { default } from './Board' diff --git a/src/games/airhockey/components/BoardHost.web.tsx b/src/games/airhockey/components/BoardHost.web.tsx new file mode 100644 index 0000000..75ee3c5 --- /dev/null +++ b/src/games/airhockey/components/BoardHost.web.tsx @@ -0,0 +1,4 @@ +import { lazySkia } from '@/shared/lazySkia' + +// Loads the board only once CanvasKit is ready (see lazySkia). +export default lazySkia(() => import('./Board')) diff --git a/src/games/airhockey/components/GuideArt.tsx b/src/games/airhockey/components/GuideArt.tsx new file mode 100644 index 0000000..f0ff9f7 --- /dev/null +++ b/src/games/airhockey/components/GuideArt.tsx @@ -0,0 +1,93 @@ +import { useAutoPaperTheme } from '@rific/auto-paper' +import { StyleSheet, View } from 'react-native' + +// AirHockey-specific illustration for the how-to-play cards (see constants/guideSteps.tsx). The +// generic ones (SwipeHint, DirectionKeysHint, SeatDiagram, SeatDevicesHint) live in @tastic/hud/guide; +// this one only makes sense for this game. Plain Views: no Skia, so it costs nothing to mount over the +// Home screen. Its color comes from the live theme (primary = P1), so it always matches the color +// player 1 has actually picked. + +const CREASE_RADIUS = 52 +const ARC_THICKNESS = 6 +// Pixels shaved off each segment's length so neighbors show a gap, like ScoreWedges' own gapDeg. +const ARC_GAP = 6 +const STAGE_WIDTH = 156 +const GOAL_LINE_Y = 76 + +interface GuideScoreArcsProps { + // settings.scoreToWin: the game draws exactly one arc per point needed (computeGoalWedges). + count: number + // How many to draw as already scored; the rest stay in the neutral line color. + filled: number +} + +// The in-game scoreboard, which is never a number during play: player 1's own goal crease, its +// half-circle split into one arc per point needed, the first few lit in their color. Mirrors +// Board.tsx's crease tint + ScoreWedges (filled from the left end, over the top, to the right: +// computeGoalWedges' baseAngle 180 for the local-bottom goal). Each arc is drawn as its chord, a +// straight rounded bar: close enough to read as the same picture without Skia. +export function GuideScoreArcs({ count, filled }: GuideScoreArcsProps) { + const { colors } = useAutoPaperTheme() + const cx = STAGE_WIDTH / 2 + const sweep = Math.PI / count + + const segments = Array.from({ length: count }, (_, i) => { + const mid = Math.PI + (i + 0.5) * sweep + const length = Math.max(ARC_THICKNESS, 2 * CREASE_RADIUS * Math.sin(sweep / 2) - ARC_GAP) + // The chord's own midpoint sits slightly inside the circle, at R·cos(sweep/2). + const inset = CREASE_RADIUS * Math.cos(sweep / 2) + return { + key: i, + left: cx + inset * Math.cos(mid) - length / 2, + top: GOAL_LINE_Y + inset * Math.sin(mid) - ARC_THICKNESS / 2, + width: length, + rotate: `${(mid * 180) / Math.PI + 90}deg`, + lit: i < filled + } + }) + + return ( + + + + {segments.map((s) => ( + + ))} + + ) +} + +const styles = StyleSheet.create({ + arc: { + borderRadius: ARC_THICKNESS / 2, + height: ARC_THICKNESS, + position: 'absolute' + }, + // Unscored arcs, in the neutral line color like Board.tsx's unfilled wedges. + arcUnlit: { + opacity: 0.3 + }, + // Board.tsx tints each crease at 0.28 opacity in its owner's color; same here. + crease: { + borderTopLeftRadius: CREASE_RADIUS, + borderTopRightRadius: CREASE_RADIUS, + height: CREASE_RADIUS, + left: STAGE_WIDTH / 2 - CREASE_RADIUS, + opacity: 0.28, + position: 'absolute', + top: GOAL_LINE_Y - CREASE_RADIUS, + width: CREASE_RADIUS * 2 + }, + goalLine: { + height: 4, + left: 8, + opacity: 0.6, + position: 'absolute', + top: GOAL_LINE_Y, + width: STAGE_WIDTH - 16 + }, + scoreStage: { + height: GOAL_LINE_Y + 12, + width: STAGE_WIDTH + } +}) diff --git a/src/games/airhockey/components/KeyboardInputLayer.tsx b/src/games/airhockey/components/KeyboardInputLayer.tsx new file mode 100644 index 0000000..e3d3c1a --- /dev/null +++ b/src/games/airhockey/components/KeyboardInputLayer.tsx @@ -0,0 +1,159 @@ +import { Direction } from '@tastic/input' +import { useEffect, useRef } from 'react' +import { Platform } from 'react-native' + +import { MAX_PADDLE_SPEED } from '@/games/airhockey/constants/game' +import { ControlScheme, Player } from '@/games/airhockey/types' +import { resolveControlSchemeDirection } from '@/shared/controlScheme' + +export interface KeyboardInputLayerProps { + enabled: boolean + humanPlayers: Player[] + controlScheme: Record + // Each player's CURRENT paddle position, read fresh every frame (via the ref below) as the + // baseline held keys nudge from - NOT a live prop this component reacts to render-to-render, the + // same way TouchInputLayer's own onPaddleTarget calls never wait for a render either. + paddlePos: Record + onPaddleTarget: (player: Player, x: number, y: number) => void +} + +// Web-only, and purely additive on top of TouchInputLayer's own always-on drag input - never +// disables it, same "scheme only ever ADDS a key mapping, never removes the pointer path" principle +// Snake's identically-named component documents. A seat left on 'mouse' (the default, see +// redux/settingsSlice.ts) is simply never read from below. +// +// Structurally different from Snake's/LightCycles' own KeyboardInputLayer despite the shared name: +// those resolve one keydown into one DISCRETE turn; a mallet instead needs CONTINUOUS motion for as +// long as a direction key stays HELD, since it's a drag-to-position paddle, not a turn-and-commit +// snake. So this tracks a live held-key SET per player (keydown adds, keyup removes) and drives it +// through its OWN plain requestAnimationFrame loop - deliberately NOT @tastic/core's useGameLoop, +// which (confirmed directly against Pong's identically-shaped component, in the browser, not just in +// theory) silently never ticks when a SECOND independent caller mounts it alongside useGameState's +// own internal one in the same tree; a bare rAF loop reading straight from refs sidesteps whatever +// that interaction is. Same nominal-frame-normalized `dt` convention useGameState's own tick loop +// uses (dt ≈ 1 for a normal ~16.7ms frame), so MAX_PADDLE_SPEED (a px-per-nominal-frame constant) +// means the same thing here as it does there - computing a fresh target position every frame from +// the paddle's own current position rather than the touch model's "finger position IS the target" +// mapping. This loop and useGameState's own tick loop are independent (uncorrelated rAF callbacks, +// same as touch's own async gesture stream already is relative to the physics tick), which is fine: +// onPaddleTarget is a plain ref write, read once per *its own* frame, regardless of which source +// last wrote it. +// +// Up/Down/Left/Right always drive local Y-/Y+/X-/X+ directly - the SAME absolute direction for +// BOTH seats, never flipped per player's own facing/goal side (player 1 defends local bottom, +// player 2 local top - see GoalSide's own doc comment in types/index.ts). This is deliberate, not +// an oversight: two people sharing one keyboard sit side by side looking at the SAME (possibly +// board-rotated) screen, so "up" reads the same physical way to both of them, exactly like any +// other shared-screen local-multiplayer game's screen-relative controls - there is no sense in +// which player 2's "up" should mean something different from player 1's "up" on the one screen +// they're both looking at. A held diagonal (e.g. Up+Left together) is normalized to the same speed +// as a single axis - otherwise it would cover sqrt(2)x the distance per frame. +// +// Deliberately NOT rotation-aware (unlike TouchInputLayer's screenToLocal): unlike Pong's paddle +// (a single degree of freedom), a mallet genuinely has both local axes to move on, so a rotation- +// aware remap IS geometrically possible here - but still skipped, for the same ergonomic reason +// Pong's own KeyboardInputLayer skips it: a shared keyboard has no "far seat" a second player could +// physically reach from the opposite side of the device, so this only really gets used seated side +// by side, and the local-axis mapping above already reads correctly there without any inversion. +const NOMINAL_FRAME_MS = 1000 / 60 +const MAX_DT = 2.5 +const DIAGONAL_SCALE = Math.SQRT1_2 + +export default function KeyboardInputLayer({ enabled, humanPlayers, controlScheme, paddlePos, onPaddleTarget }: KeyboardInputLayerProps) { + const heldRef = useRef>>({ 1: new Set(), 2: new Set() }) + const latestRef = useRef({ humanPlayers, controlScheme, paddlePos, onPaddleTarget }) + useEffect(() => { + latestRef.current = { humanPlayers, controlScheme, paddlePos, onPaddleTarget } + }) + + useEffect(() => { + if (!enabled || Platform.OS !== 'web') return + + // Captured once - heldRef.current's own identity never actually changes after the useRef + // initializer (only the Sets it points at are mutated in place, via .add/.delete/.clear), but + // this is what lets the cleanup below reference it without the linter having to take that on + // faith. + const held = heldRef.current + + const handleKeyDown = (e: KeyboardEvent) => { + const { humanPlayers, controlScheme } = latestRef.current + for (const player of humanPlayers) { + const direction = resolveControlSchemeDirection(e, controlScheme[player]) + if (!direction) continue + e.preventDefault() + held[player].add(direction) + return + } + } + + const handleKeyUp = (e: KeyboardEvent) => { + const { humanPlayers, controlScheme } = latestRef.current + for (const player of humanPlayers) { + const direction = resolveControlSchemeDirection(e, controlScheme[player]) + if (!direction) continue + held[player].delete(direction) + return + } + } + + // A held key's release never reaches us if focus leaves the page first (alt-tab, a devtools + // click, ...) - window never gets the keyup at all. Left unhandled, that key would read as + // "held" forever, driving the paddle in one direction with no way to stop it short of pressing + // and releasing that exact key again. Clearing on blur is the standard fix for this whole class + // of held-key bug. + const handleBlur = () => { + held[1].clear() + held[2].clear() + } + + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + window.addEventListener('blur', handleBlur) + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + window.removeEventListener('blur', handleBlur) + held[1].clear() + held[2].clear() + } + }, [enabled]) + + useEffect(() => { + if (!enabled || Platform.OS !== 'web') return + + let rafId: number | null = null + let active = true + let lastTimestamp: number | null = null + + const step = (timestamp: number) => { + if (!active) return + const dt = lastTimestamp === null ? 1 : Math.min(MAX_DT, Math.max(0, (timestamp - lastTimestamp) / NOMINAL_FRAME_MS)) + lastTimestamp = timestamp + + const { humanPlayers, controlScheme, paddlePos, onPaddleTarget } = latestRef.current + for (const player of humanPlayers) { + if (controlScheme[player] === 'mouse') continue + const held = heldRef.current[player] + let axisX = (held.has('right') ? 1 : 0) - (held.has('left') ? 1 : 0) + let axisY = (held.has('down') ? 1 : 0) - (held.has('up') ? 1 : 0) + if (axisX === 0 && axisY === 0) continue + if (axisX !== 0 && axisY !== 0) { + axisX *= DIAGONAL_SCALE + axisY *= DIAGONAL_SCALE + } + const pos = paddlePos[player] + onPaddleTarget(player, pos.x + axisX * MAX_PADDLE_SPEED * dt, pos.y + axisY * MAX_PADDLE_SPEED * dt) + } + + rafId = requestAnimationFrame(step) + } + + rafId = requestAnimationFrame(step) + return () => { + active = false + if (rafId !== null) cancelAnimationFrame(rafId) + } + }, [enabled]) + + return null +} diff --git a/src/games/airhockey/components/LoadoutSharedControls.tsx b/src/games/airhockey/components/LoadoutSharedControls.tsx new file mode 100644 index 0000000..d8689f3 --- /dev/null +++ b/src/games/airhockey/components/LoadoutSharedControls.tsx @@ -0,0 +1,142 @@ +import { IconButton } from '@rific/feedback-press' +import { MenuOption, PopoverHost, SectionedDropdown, usePopoverHost } from '@tastic/hud' +import { StyleSheet, View } from 'react-native' + +import { FrictionLevel, PaddleSizeLevel, PuckSizeLevel } from '@/games/airhockey/types' + +interface Props { + // Shared popover host - see PlayerSetupPanel's identical `host` prop. loadout.tsx always passes + // its own controlsHost here, in every game mode: this row sits in the same near/bottom zone as + // P1's own panel, so board/puck/paddle physics/score/difficulty need to be mutually exclusive with + // P1's color picker (only one of them can be open at a time), and - just as importantly - P1's + // press-away overlay only watches controlsHost.openId, so a dropdown opened on any *other* host + // would be invisible to it and never close on an outside tap. Omit only if some future caller + // genuinely wants this row fully independent (own host, own press-away wiring it'd have to + // provide itself). + host?: PopoverHost + // BoxHockey's rink toggle (see Settings.rink), as a one-item multi-select section: present in the + // value array = on, which is what gives it the checkbox-style highlight-when-on look. + rink: boolean + rinkOption: MenuOption<'on'> + onRinkChange: (value: boolean) => void + friction: FrictionLevel + frictionOptions: MenuOption[] + onFrictionChange: (value: FrictionLevel) => void + puckSize: PuckSizeLevel + puckSizeOptions: MenuOption[] + onPuckSizeChange: (value: PuckSizeLevel) => void + paddleSize: PaddleSizeLevel + paddleSizeOptions: MenuOption[] + onPaddleSizeChange: (value: PaddleSizeLevel) => void + scoreToWin: number + scoreToWinOptions: MenuOption[] + onScoreToWinChange: (value: number) => void + // Both one-shot actions on the settings above as a whole, not a value with a "current state" to + // show - rendered as plain IconButtons in their own row above the trigger row (not a + // SectionedDropdown trigger itself, which would misleadingly suggest a selected value to land + // on). Optional (and always passed as a pair) - loadout.tsx's own showMetaInSharedBand case + // renders these itself, inline in its merged back/dice/reset/settings header row, so this + // component leaves its own row out entirely rather than showing the same two actions twice. + onRandomize?: () => void + onReset?: () => void + accentColor: string + mutedColor: string + onAccentColor?: string + dark: boolean +} + +// Always-upright row of shared (not per-player) settings in the loadout screen. Shares P1's popover +// host (passed in by loadout.tsx in every game mode - see the `host` prop above) rather than owning +// an independent one, so this row participates in P1's mutual-exclusivity and press-away wiring the +// same way P1's own panel does. Pruned down from BoxHockey's version - no tipoffMode (no +// faceoff/coin-flip ceremony, the puck just re-serves - see GOAL_SERVE_PAUSE_MS), no +// shotPreviewLength (no aimed-shot preview at all), and no boardVariant (there's only one table +// layout), so BoxHockey's board popover keeps just its rink toggle here. Friction/puckSize/paddleSize +// each get their own trigger too, rather than sharing one bundled "puck physics" popover the way +// BoxHockey still does: with three real lists (plus rink/scoreToWin) all live at once in this +// app's loadout, a single combined popover buried unrelated settings behind one speedometer icon. +// aiDifficulty moved out to a per-seat picker (see PlayerSetupPanel), matching BoxHockey's/ +// LightCycles' identical cross-app convention. Exported so loadout.tsx's own SharedActionBand +// wrapper can run this same "is one of my own popovers open" membership check on itself. +export const LOADOUT_SHARED_CONTROLS_IDS = ['board', 'friction', 'puckSize', 'paddleSize', 'scoreToWin'] + +export function LoadoutSharedControls({ host: sharedHost, rink, rinkOption, onRinkChange, friction, frictionOptions, onFrictionChange, puckSize, puckSizeOptions, onPuckSizeChange, paddleSize, paddleSizeOptions, onPaddleSizeChange, scoreToWin, scoreToWinOptions, onScoreToWinChange, onRandomize, onReset, accentColor, mutedColor, onAccentColor, dark }: Props) { + const ownHost = usePopoverHost() + const host = sharedHost ?? ownHost + // See PlayerSetupPanel's identical ownPopoverOpen comment - elevating this row for *any* open + // popover on a shared host (rather than only its own) would tie it with whichever sibling panel + // actually has the open popover, letting DOM order wrongly decide which one paints on top. + const ownPopoverOpen = host.openId !== null && LOADOUT_SHARED_CONTROLS_IDS.includes(host.openId) + // Same fg convention as every other component keying off a `dark` prop - full-contrast, unlike + // mutedColor/accentColor above, which are tuned for a TriggerGauge's own unselected/selected + // states rather than a plain icon button. Matches BoxHockey's/LightCycles' identical actionsRow. + const fg = dark ? '#FFFFFF' : '#000000' + + return ( + + {onRandomize && onReset && ( + + + + + )} + + + {/* Board - BoxHockey's board popover (same icon and first-in-row placement), holding + just its rink toggle since there's no board variant to pick. A table layout later is a + single-select section added in front of it, not a UI reshuffle. */} + onRinkChange(value.length > 0) }]} accentColor={accentColor} mutedColor={mutedColor} onAccentColor={onAccentColor} dark={dark} /> + + {/* Friction, puck size, and paddle size each get their own standalone trigger - previously + bundled into one 'puckPhysics' popover (three sections behind a single speedometer icon), but + that buried puck size/paddle size behind a menu literally labeled for speed, and made every + one of the three an extra tap deep. Each is a real, independent match setting on par with + scoreToWin below, so each gets the same standalone-trigger treatment scoreToWin already had. */} + + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + // Above the trigger row - small, secondary-weight (size 18 vs. the triggers' own icon size) so + // Randomize/Reset read as quick actions on the settings below rather than a third setting of + // equal standing. On top (not below) to match loadout.tsx's own showMetaInSharedBand header row, + // which folds these same two actions in with back/settings above the triggers there too. + actionsRow: { + flexDirection: 'row', + gap: 12 + }, + container: { + alignItems: 'center', + gap: 4 + }, + // See SectionedDropdown's anchorOpen comment - this whole column is itself a sibling of the + // player panels in loadout.tsx, and React Native Web's per-view stacking contexts mean a popover + // escaping the row below needs this wrapper elevated, not just the popover content, to paint + // above a later sibling panel. + containerOpen: { + zIndex: 100 + }, + row: { + alignItems: 'center', + flexDirection: 'row', + // Safety net, not the primary fix - combining related pickers (see puckPhysics above) is what + // actually keeps this row from feeling cramped. But nothing capped its width before this, and a + // row of fixed-size triggers with no wrap just runs off the screen edge on a narrow device, + // taking the last trigger or two with it - untappable, not just clipped. Wrap is what turns + // that into "drops to a second centered line" instead. + flexWrap: 'wrap', + // Matches LightCycles' LobbySharedControls - wider than a plain icon row would need, to give + // each trigger's TriggerGauge arc (pulled in close around the icon itself - see TriggerGauge's + // own ARC_GAP) room to breathe against its neighbors instead of the arcs crowding each other. + gap: 20, + justifyContent: 'center' + } +}) diff --git a/src/games/airhockey/components/TouchInputLayer.tsx b/src/games/airhockey/components/TouchInputLayer.tsx new file mode 100644 index 0000000..e935a52 --- /dev/null +++ b/src/games/airhockey/components/TouchInputLayer.tsx @@ -0,0 +1,144 @@ +import { callWorkletCallback, useWorkletCallback } from '@rific/core' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { StyleSheet, View } from 'react-native' +import { Gesture, GestureDetector } from 'react-native-gesture-handler' +import { scheduleOnRN } from 'react-native-worklets' + +import { Player } from '@/games/airhockey/types' + +export interface TouchInputLayerProps { + // [1, 2] for two-player; [1] or [2] for vs-CPU, where the single human gets the whole board as + // their own input area (see the solo branch below) rather than a half of it. + humanPlayers: Player[] + enabled: boolean + // Local-space container size (see GoalSide's own doc comment in types/index.ts) - the SAME + // full-screen local viewport BoardHost renders into, sized via computeLocalContainerSize in utils/board.ts. + localWidth: number + localHeight: number + // The local container's own screen-space center (game.tsx's outer viewport is centered around + // this same point, since the container is flex-centered within it) and the rotation game.tsx + // applies to it (0 for face-to-face, ±90 for side-by-side - see game.tsx's own rotation + // direction comment). Needed to convert a touch's e.absoluteX/absoluteY (real screen position) + // back into local board coordinates by hand - see screenToLocal below for why this can't just + // use a zone's own e.x/e.y. + screenCenterX: number + screenCenterY: number + rotationDeg: 0 | 90 | -90 + // Called with LOCAL (board-space) coordinates. + onPaddleTarget: (player: Player, x: number, y: number) => void +} + +// Converts a real screen-space touch position back into local board coordinates by hand, using +// the exact inverse of the CSS rotation game.tsx applies to the local container for rendering. +// This turned out to be necessary (not just defensive) - verified directly in the browser +// preview: react-native-gesture-handler's web implementation reports a zone's own e.x/e.y as if +// no rotation had been applied to any ancestor at all (a dragging test at a fixed screen-Y moved +// the local coordinate along local-X, not local-Y, exactly the identity/no-rotation behavior), +// rather than correctly inverting it the way native touch delivery (or a browser's own DOM +// offsetX/Y) normally would for a transformed element. Working from e.absoluteX/absoluteY (real, +// unambiguous screen position, independent of which transformed view the gesture is attached to) +// and inverting the KNOWN rotation explicitly sidesteps that platform inconsistency entirely, +// rather than depending on it. +function screenToLocal(absX: number, absY: number, screenCenterX: number, screenCenterY: number, rotationDeg: 0 | 90 | -90, localWidth: number, localHeight: number): { x: number; y: number } { + 'worklet' + const dx = absX - screenCenterX + const dy = absY - screenCenterY + let lx: number + let ly: number + if (rotationDeg === 90) { + lx = dy + ly = -dx + } else if (rotationDeg === -90) { + lx = -dy + ly = dx + } else { + lx = dx + ly = dy + } + return { x: localWidth / 2 + lx, y: localHeight / 2 + ly } +} + +// Two independent single-finger Pan gestures, each on its OWN View (top half = player 2, bottom +// half = player 1 - always, regardless of GoalAxis: game.tsx rotates the whole local container +// ±90° for side-by-side landscape, so this same top/bottom split always ends up on the correct +// physical screen sides without this component ever branching on orientation itself for HIT- +// TESTING - DOM/native hit-testing already respects the rotation transform when deciding which +// zone a touch lands on, it's only the touch's own COORDINATE VALUE that needs the manual +// correction above) - modeled directly on LightCycles' TouchInputLayer, which deliberately does +// NOT use one shared view split via `hitSlop`: two fingers landing in the same native touch batch +// (a genuine simultaneous two-player touch, not two touches a few frames apart) raced on which +// recognizer claimed which pointer on a single shared view, and lost one or both. Two separate +// native views/recognizers have nothing left to race - each only ever sees the pointer that +// landed on it. +export default function TouchInputLayer({ humanPlayers, enabled, localWidth, localHeight, screenCenterX, screenCenterY, rotationDeg, onPaddleTarget }: TouchInputLayerProps) { + const solo = humanPlayers.length === 1 + + // Everything the gesture callbacks need, kept in a ref rather than closed over directly, so the + // callbacks - and therefore the Gesture objects built from them below - stay referentially + // stable across renders. See LightCycles' own TouchInputLayer for the original repro of what + // goes wrong (dropped touches) when a Gesture object is rebuilt on every layout/state tick. + const latestRef = useRef({ onPaddleTarget, localWidth, localHeight, screenCenterX, screenCenterY, rotationDeg }) + useEffect(() => { + latestRef.current = { onPaddleTarget, localWidth, localHeight, screenCenterX, screenCenterY, rotationDeg } + }) + + const handleTouch = useCallback((player: Player, absX: number, absY: number) => { + const { onPaddleTarget, localWidth, localHeight, screenCenterX, screenCenterY, rotationDeg } = latestRef.current + const local = screenToLocal(absX, absY, screenCenterX, screenCenterY, rotationDeg, localWidth, localHeight) + onPaddleTarget(player, local.x, local.y) + }, []) + + // The worklets below call handleTouch through a key and the module-level callWorkletCallback, never scheduleOnRN(handleTouch) + // directly: native code holds a scheduleOnRN target for as long as the gesture's cached worklet lives, which would pin this + // component in memory forever. + const touchKey = useWorkletCallback(handleTouch) + + const makePlayerGesture = useCallback( + (player: Player) => + Gesture.Pan() + .maxPointers(1) + .minDistance(0) + .hitSlop({}) + .enabled(enabled) + .onStart((e) => { + scheduleOnRN(callWorkletCallback, touchKey, player, e.absoluteX, e.absoluteY) + }) + .onUpdate((e) => { + scheduleOnRN(callWorkletCallback, touchKey, player, e.absoluteX, e.absoluteY) + }), + [enabled, touchKey] + ) + + const soloGesture = useMemo(() => (solo ? makePlayerGesture(humanPlayers[0]) : null), [solo, humanPlayers, makePlayerGesture]) + const p1Gesture = useMemo(() => (solo ? null : makePlayerGesture(1)), [solo, makePlayerGesture]) + const p2Gesture = useMemo(() => (solo ? null : makePlayerGesture(2)), [solo, makePlayerGesture]) + + if (solo) { + return ( + + {/* collapsable={false}: without a prop that makes it stand out, RN's view-flattening + optimization merges this plain View into its parent, and GestureDetector's ref then + attaches to nothing - the gesture silently never receives a single touch. */} + + + ) + } + + return ( + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + container: { position: 'absolute' }, + zone: { position: 'absolute' }, + zoneBottom: { bottom: 0, left: 0, right: 0, top: '50%' }, + zoneTop: { bottom: '50%', left: 0, right: 0, top: 0 } +}) diff --git a/src/games/airhockey/constants/achievements.ts b/src/games/airhockey/constants/achievements.ts new file mode 100644 index 0000000..6c347a5 --- /dev/null +++ b/src/games/airhockey/constants/achievements.ts @@ -0,0 +1,49 @@ +import { AchievementTier, tieredFamily } from '@tastic/achievements' + +import { AchievementDefinition, AiDifficulty, StatsState } from '@/games/airhockey/types' +import { getOverallTotals } from '@/games/airhockey/utils/statsEngine' + +export { ACHIEVEMENT_TIER_COLORS } from '@tastic/achievements' + +const AI_DIFFICULTY_TIER: Record = { easy: 'bronze', medium: 'silver', hard: 'gold' } +const AI_DIFFICULTY_LABEL: Record = { easy: 'Easy', medium: 'Medium', hard: 'Hard' } + +// Three different predicates, not one value against three thresholds - hand-written, and with no +// `progress`, since "40% of the way to beating Hard once" isn't a meaningful fraction. +function beatCpuFamily(): AchievementDefinition[] { + const titles: Record = { easy: 'Warmed Up', medium: 'Contender', hard: 'Table Legend' } + return (['easy', 'medium', 'hard'] as AiDifficulty[]).map((difficulty) => ({ + id: `beat_cpu_${difficulty}`, + title: titles[difficulty], + description: `Beat the CPU on ${AI_DIFFICULTY_LABEL[difficulty]} difficulty.`, + tier: AI_DIFFICULTY_TIER[difficulty], + icon: 'robot', + isUnlocked: (stats: StatsState) => stats.vsCpu.byDifficulty[difficulty].wins >= 1 + })) +} + +export const ACHIEVEMENT_CATALOG: AchievementDefinition[] = [ + { id: 'first_game_played', title: 'Drop the Puck', description: 'Play your first match.', tier: 'bronze', icon: 'flag-checkered', isUnlocked: (stats) => getOverallTotals(stats).played >= 1 }, + { id: 'first_ever_win', title: 'First Blood', description: 'Win any match, any time.', tier: 'bronze', icon: 'star', isUnlocked: (stats) => getOverallTotals(stats).wins >= 1 }, + { + id: 'flawless_debut', + title: 'Natural', + description: 'Win the very first match you ever play.', + tier: 'gold', + icon: 'star-circle', + // firstGameResult is a one-time device-wide flag with no per-profile analog, so it always + // evaluates against the real device stats regardless of which profile tab is selected. + scope: 'device', + isUnlocked: (stats) => stats.firstGameResult === 'win' + }, + ...tieredFamily({ id: 'games_played', titles: { bronze: 'Rookie', silver: 'Regular', gold: 'Veteran' }, description: (n) => `Play ${n} total matches.`, icon: 'hockey-sticks', thresholds: { bronze: 10, silver: 50, gold: 200 }, value: (stats) => getOverallTotals(stats).played }), + ...tieredFamily({ id: 'total_wins', titles: { bronze: 'Winner', silver: 'Big Winner', gold: 'Champion' }, description: (n) => `Win ${n} total matches.`, icon: 'trophy', thresholds: { bronze: 10, silver: 50, gold: 200 }, value: (stats) => getOverallTotals(stats).wins }), + ...tieredFamily({ id: 'goals_scored', titles: { bronze: 'Sniper', silver: 'Sharpshooter', gold: 'Goal Machine' }, description: (n) => `Score ${n} total goals.`, icon: 'hockey-puck', thresholds: { bronze: 50, silver: 250, gold: 1000 }, value: (stats) => stats.goals.scored }), + ...tieredFamily({ id: 'shutouts', titles: { bronze: 'Clean Sheet', silver: 'Brick Wall', gold: 'Untouchable' }, description: (n) => `Win ${n} matches without conceding a single goal.`, icon: 'shield-check', thresholds: { bronze: 1, silver: 10, gold: 40 }, value: (stats) => stats.shutouts }), + ...tieredFamily({ id: 'win_margin', titles: { bronze: 'Comfortable', silver: 'Dominant', gold: 'Humiliating' }, description: (n) => `Win a match by ${n} goals or more.`, icon: 'chart-line', thresholds: { bronze: 3, silver: 5, gold: 7 }, value: (stats) => stats.bestWinMargin }), + ...tieredFamily({ id: 'goal_door', titles: { bronze: 'Off the Post', silver: 'Rattled', gold: 'Woodwork Specialist' }, description: (n) => `Ring the goal door ${n} times.`, icon: 'door', thresholds: { bronze: 25, silver: 150, gold: 600 }, value: (stats) => stats.goalDoorHits }), + ...tieredFamily({ id: 'cpu_streak', titles: { bronze: 'On a Roll', silver: 'Hot Streak', gold: 'Unstoppable' }, description: (n) => `Win ${n} matches in a row against the CPU.`, icon: 'fire', thresholds: { bronze: 3, silver: 5, gold: 10 }, value: (stats) => stats.vsCpu.bestWinStreak }), + ...beatCpuFamily(), + ...tieredFamily({ id: 'local_matches', titles: { bronze: 'Pass the Phone', silver: 'Couch Champion', gold: 'Living Room Legend' }, description: (n) => `Play ${n} local two-player matches.`, icon: 'account-multiple', thresholds: { bronze: 10, silver: 50, gold: 150 }, value: (stats) => stats.twoPlayer.played }), + ...tieredFamily({ id: 'days_played', titles: { bronze: 'Regular Visitor', silver: 'Dedicated', gold: 'Devoted' }, description: (n) => `Play ${n} days in a row.`, icon: 'calendar-check', thresholds: { bronze: 3, silver: 14, gold: 30 }, value: (stats) => stats.bestDayStreak }) +] diff --git a/src/games/airhockey/constants/game.ts b/src/games/airhockey/constants/game.ts new file mode 100644 index 0000000..4136353 --- /dev/null +++ b/src/games/airhockey/constants/game.ts @@ -0,0 +1,130 @@ +import { FrictionLevel, PaddleSizeLevel, PuckSizeLevel } from '@/games/airhockey/types' + +// Real physical/motion constants, unchanged from BoxHockey - a wall bounce, a puck's own top +// speed, and "at rest" all mean the same thing regardless of what's driving the puck around. +export const WALL_RESTITUTION = 0.72 +export const MAX_SPEED = 22 +export const STOP_THRESHOLD = 0.12 +export const SCORE_TO_WIN = 5 + +// Per-round puck deceleration tier (see FrictionLevel and stepPhysics in utils/physics.ts, where +// frictionFactor = FRICTION_LEVELS[level] ** dt). Closer to 1 means less resistance, so the puck +// glides farther between touches. +export const FRICTION_LEVELS: Record = { + low: 0.992, + medium: 0.986, + high: 0.976 +} + +// Puck radius, as a fraction of the board's SHORT (goal) edge - see PuckSizeLevel and +// computeBoardLayout in utils/board.ts. Sized off the short edge specifically (not board.width +// directly, which means something different depending on GoalAxis) so the puck never jumps +// size discontinuously when the device rotates between face-to-face and side-by-side. +export const PUCK_SIZE_RATIOS: Record = { + small: 0.045, + medium: 0.058, + large: 0.074 +} + +// Paddle (mallet) radius, same short-edge-fraction convention as PUCK_SIZE_RATIOS. A real +// air-hockey mallet is only slightly bigger than the puck - 'medium' here is ~1.3x the 'medium' +// puck's radius, not a multiple of its diameter. The tiers exist for the same reason puck size's +// do: a bigger paddle is a bigger, more forgiving defensive/offensive surface, so it doubles as a +// difficulty/accessibility knob rather than being purely cosmetic. +export const PADDLE_SIZE_RATIOS: Record = { + small: 0.06, + medium: 0.075, + large: 0.095 +} + +// How much of the goal must stay open around a single stationary, perfectly centered paddle - +// replaces a prior flat-fraction-of-the-table-width GOAL_WIDTH_RATIO table (keyed only by +// puckSize) that let a defender camp dead center and block literally every shot forever at +// default settings: circle-vs-circle contact means a centered paddle blocks any shot whose +// centerline passes within (paddleRadius + puckRadius) of it on either side - a "blocking span" +// of 2x that sum - and the old table sized the goal as a flat fraction of the table's short edge +// with no reference to that span at all, let alone to paddleSize, which is an entirely +// independent user setting (see PaddleSizeLevel/LoadoutSharedControls) the old table couldn't see +// even in principle. Goal width is now DERIVED from the actual blocking span (see +// computeBoardLayout in utils/board.ts), so this fraction is the one number that actually decides +// how campable the goal is, for every puckSize/paddleSize combination alike. 0.3 leaves roughly +// the same ~30% gap around a centered paddle that a real air-hockey mallet/puck leaves around a +// real table's goal. +export const GOAL_OPEN_FRACTION = 0.3 + +// Radius of each rink faceoff circle, as a fraction of the board's width - ported from BoxHockey +// unchanged (its FACEOFF_ZONE_RADIUS_RATIO). AirHockey has no turn-based faceoff draw for these +// to gate, so Board.tsx only ever renders their permanent resting chrome (ring + center dot), +// never BoxHockey's live/pulse/ripple states - but useGameState's GOAL_SERVE does use their +// positions functionally, as the scored-on player's post-goal puck spawn (see faceoffZoneCenter +// in utils/board.ts). +export const FACEOFF_ZONE_RADIUS_RATIO = 0.15 + +export const WALL_THICKNESS = 10 +// Goal line stroke width (Board.tsx's 4 goal-line segments and 2 zone-line segments). +// Also reused as the perimeter wall/rink-pocket thickness fed into computeBoardLayout (see its +// wallThickness local) - same BoxHockey convention: "walls as thin as the goal line" stays a +// real equality rather than two independently-tuned literals that could drift apart. +export const GOAL_LINE_STROKE_WIDTH = 4 + +// Goal door flash (Board.tsx, driven by physics.ts's goalDoorHit/useGameState's goalDoorHitCount) +// - the invisible one-way door at the goal mouth (open front-to-back, solid back-to-front) flares +// right there to read as "that gap isn't actually open, that's what you just bounced off of". +// Ported from BoxHockey unchanged: two independently-timed layers, both snapping straight to full +// on impact (no fade-in tween - an impact reads as sudden). GOAL_DOOR_FLASH_COLOR_DECAY_MS is how +// fast the hot-white peak cools to the goal's own color AND how fast the wide soft glow halo +// (Board.tsx's own oversized-translucent-line-behind-the-crisp-one "brighter" technique, same as +// the puck's own idle glow) burns off; GOAL_DOOR_FLASH_OPACITY_DECAY_MS is how slowly the crisp +// core line, now settled into the goal's own color, lingers before fading to fully invisible - +// deliberately much longer, so the impact itself still reads instant while the afterglow lingers. +export const GOAL_DOOR_FLASH_COLOR_DECAY_MS = 120 +export const GOAL_DOOR_FLASH_OPACITY_DECAY_MS = 1200 +export const GOAL_DOOR_FLASH_GLOW_WIDTH_RATIO = 4 +export const GOAL_DOOR_FLASH_GLOW_PEAK_OPACITY = 0.55 + +// width:height of a real air-hockey table (~8ft x 4ft, a roughly 2:1 long:short ratio) - replaces +// BoxHockey's BOARD_ASPECT_RATIO (a much narrower 3.3:8 box-hockey table). Only used the same way: +// to fix the board's shape on desktop web (see computeBoardLayout's isDesktopLayout branch) - +// native/mobile web still just fill the device's own screen exactly. Expressed as long:short +// (not width:height) since which physical axis is "long" depends on the live GoalAxis, unlike +// BoxHockey's fixed-portrait assumption. +export const TABLE_LONG_TO_SHORT_RATIO = 2.0 + +// A paddle's velocity, for collision purposes, is derived from how far its committed position +// moved this physics tick divided by dt (see computePaddleVelocity in utils/physics.ts) - not +// from the gesture's own translation delta, which is cumulative since touch-down, not per-frame. +// Smoothed over this many ticks (a simple moving average) so a single jittery touch sample can't +// produce a wild, physically-implausible instantaneous velocity that then gets imparted onto the +// puck on contact. +export const PADDLE_VELOCITY_SMOOTHING_FRAMES = 3 + +// Caps how fast a paddle's own (smoothed) velocity is allowed to read before it's imparted onto +// the puck at contact (see applyPaddleCollision in utils/physics.ts) - a real mallet's max +// practical swing speed, in the same units as MAX_SPEED, so a paddle whose tracked position spikes +// from a single fast/jittery touch sample can't launch the puck harder than the puck's own +// integrator elsewhere assumes is possible. +export const MAX_PADDLE_SPEED = 26 + +// Livelier than a wall bounce (WALL_RESTITUTION 0.72) - a mallet strike should feel snappy, not +// like just another wall. +export const PADDLE_COLLISION_RESTITUTION = 0.85 + +// Brief pause after a goal before the puck (and both paddles) reset to center for the next serve +// - real air hockey re-serves fast, no faceoff ceremony. +export const GOAL_SERVE_PAUSE_MS = 900 + +// Threshold (in CSS px) above which web is treated as "desktop" rather than phone-shaped - the +// one context that fixes the board to TABLE_LONG_TO_SHORT_RATIO and draws literal perimeter +// walls, since there's no physical device edge of its own to lean on there. +export const DESKTOP_WEB_MIN_WIDTH = 768 + +// Also the app's own theme identity: redux/store.ts feeds these straight into @rific/auto-paper as +// the primary/secondary of its color triad (tertiary derived via getThirdColor) AND as +// redux/seatColorsSlice.ts's own default "remembered" seat colors, so a fresh install's live theme +// and its guest-color memory can never drift apart. Moved here (rather than living in +// components/Theme.tsx, where they lived pre-Redux) specifically so the Redux store module - which +// must seed its theme reducer with these - never has to import a React component file to get them; +// this is a plain, framework-free constants module, same role Snake's SNAKE_COLORS plays in its own +// utils/snakeEngine.ts. Values unchanged from Theme.tsx's own prior DEFAULT_P1_COLOR/DEFAULT_P2_COLOR. +export const DEFAULT_P1_COLOR = '#f44336' +export const DEFAULT_P2_COLOR = '#2196f3' diff --git a/src/constants/guide.ts b/src/games/airhockey/constants/guide.ts similarity index 100% rename from src/constants/guide.ts rename to src/games/airhockey/constants/guide.ts diff --git a/src/games/airhockey/constants/guideSteps.tsx b/src/games/airhockey/constants/guideSteps.tsx new file mode 100644 index 0000000..9ea0bce --- /dev/null +++ b/src/games/airhockey/constants/guideSteps.tsx @@ -0,0 +1,91 @@ +import { type DirectionKeyLabels, DirectionKeysHint, type GuideStep, type SeatDevice, SeatDevicesHint, SeatDiagram, SwipeHint } from '@tastic/hud/guide' +import type { KeyScheme } from '@tastic/input' + +import { GuideScoreArcs } from '@/games/airhockey/components/GuideArt' +import type { ControlScheme, Player } from '@/games/airhockey/types' + +// How each control scheme reads in a sentence, and on DirectionKeysHint's caps (undefined = draw the +// arrow keys as arrows). Deliberately not the loadout's own CONTROL_SCHEME_OPTIONS labels ('Arrows', +// 'Numpad'), which are picker labels, not prose. Numpad's caps are the digits @tastic/input's +// KEY_SCHEMES.numpad actually reads (Numpad8/4/2/6). +const SCHEME_PHRASE: Record = { mouse: 'the mouse', wasd: 'WASD', arrows: 'the arrow keys', ijkl: 'IJKL', numpad: 'the numpad' } +const KEY_CAPS: Record = { + wasd: { up: 'W', left: 'A', down: 'S', right: 'D' }, + arrows: undefined, + ijkl: { up: 'I', left: 'J', down: 'K', right: 'L' }, + numpad: { up: '8', left: '4', down: '2', right: '6' } +} + +interface GuideStepsInput { + // A touch screen vs. a desktop browser (useIsTouchPrimaryDevice, the same gate @tastic/hud's + // ControlSchemePicker uses, so desktop copy shows exactly where a seat can pick keys): a phone + // moves the paddle by finger, a desktop by mouse or key. + touch: boolean + // Each seat's live settings.controlScheme, only read on desktop. Seats pick their own in the + // loadout, so a replayed guide has to name the real ones, not the defaults (P1 mouse, P2 WASD). + controlScheme: Record + // Live settings.scoreToWin (3/5/7/10, default 5): the win card's headline and its arc count. + scoreToWin: number +} + +function controlsStep(touch: boolean, scheme: ControlScheme): GuideStep { + const title = 'Move Your Paddle' + if (touch) return { title, body: 'Your paddle follows your finger. Swipe fast to hit the puck harder.', art: } + // The pointer drives every seat (TouchInputLayer is always on), so the mouse copy can say "your". + if (scheme === 'mouse') return { title, body: 'Click and drag. Your paddle follows the pointer. Drag fast to hit harder.', art: } + // A key scheme is player 1's alone, and in two-player the pickers never let player 2 share it, so + // "your paddle" would be false for player 2. Naming player 1 stays true in solo too: the solo human + // is always seat 1 (humanPlayersFor). + return { title, body: `Player 1 moves with ${SCHEME_PHRASE[scheme]}. Moving into the puck hits it harder.`, art: } +} + +// What SeatDevicesHint draws for a seat: every key scheme is the one keyboard, 'mouse' is the mouse. +function seatDevice(scheme: ControlScheme): SeatDevice { + return scheme === 'mouse' ? 'mouse' : 'keyboard' +} + +// The fleet's one desktop two-player wording, kept identical across the games that have this card: +// the title says what the two players share, the body names each seat's live scheme, then either how +// the mouse seat plays (only its own half of the table moves its paddle, TouchInputLayer's two zones) +// or, keys only, that the schemes can be changed (the loadout's pickers). +function twoPlayerStep(touch: boolean, controlScheme: Record): GuideStep { + if (touch) return { title: 'Two Players, One Phone', body: 'Sit across from each other and keep your finger in your own half.', art: } + const anyMouse = controlScheme[1] === 'mouse' || controlScheme[2] === 'mouse' + // Both seats on the mouse is said once ("Both players use the mouse.") instead of twice. The + // loadout's pickers can't produce that pair (assignSeatScheme swaps), so only settings saved + // before they did still reach this branch. + const who = controlScheme[1] === 'mouse' && controlScheme[2] === 'mouse' ? 'Both players use the mouse.' : `Player 1 uses ${SCHEME_PHRASE[controlScheme[1]]}, player 2 uses ${SCHEME_PHRASE[controlScheme[2]]}.` + return { + // "One Keyboard" is only true when neither seat is on the mouse, and by default player 1 is. + title: anyMouse ? 'Two Players, One Computer' : 'Two Players, One Keyboard', + body: `${who} ${anyMouse ? 'Drag in your own half.' : 'Change them before a match.'}`, + art: + } +} + +// The three cards of AirHockey's how-to-play flow. Pure and hook-free so it's trivially testable; +// GuideHost feeds it useIsTouchPrimaryDevice() and the live settings, and memoizes the result. +// +// What's here is what a first-time player cannot work out alone: you don't have to grab your paddle +// (it jumps to your finger or pointer, TouchInputLayer + useGameState's setPaddleTarget) and a faster +// swing hits harder (applyPaddleCollision adds the paddle's velocity to the puck); the score is never +// a number during play, only arcs lit in front of each player's OWN goal (Board.tsx's ScoreWedges); +// and in two-player one phone is shared face to face, each player touching only their own half +// (TouchInputLayer's two zones). Copy rules (Jay, 2026-09-24): Title Case titles, short bodies, and +// only say what's true in every mode. "Your own half" is only a touch rule in two-player (solo +// touches anywhere on the table), so it lives on the two-player card, not the controls one. There's no +// card explaining One Player vs Two Player: those buttons are right behind the dialog. The desktop +// copy names each seat's live scheme, including 'mouse', and never says swipe or finger. +export function getGuideSteps({ touch, controlScheme, scoreToWin }: GuideStepsInput): GuideStep[] { + return [ + controlsStep(touch, controlScheme[1]), + { + // Same headline as BoxHockey's, the other hockey game. "At your end": Board.tsx draws each + // player's score in their OWN crease (scores[0] at player 1's bottom goal). + title: `First to ${scoreToWin} Goals Wins`, + body: 'Each goal you score lights up one arc at your end.', + art: + }, + twoPlayerStep(touch, controlScheme) + ] +} diff --git a/src/games/airhockey/hooks/useAIOpponent.ts b/src/games/airhockey/hooks/useAIOpponent.ts new file mode 100644 index 0000000..a8b7e41 --- /dev/null +++ b/src/games/airhockey/hooks/useAIOpponent.ts @@ -0,0 +1,35 @@ +import { useEffect, useRef } from 'react' + +import { BoardLayout, GameState, Player } from '@/games/airhockey/types' +import { AIDifficulty, computeAIPaddleTarget, rollAIJitter } from '@/games/airhockey/utils/aiOpponent' + +// How many physics ticks a single rolled jitter offset is held before rolling a new one. Rolling +// fresh every tick (60/sec) picked a wholly new random aim direction each ~16ms, which reads as +// the paddle visibly vibrating in place rather than as human-like aim imprecision - holding one +// offset for a short burst of ticks, then jumping to a new one, reads as a natural hand tremor. +const JITTER_HOLD_TICKS = 15 + +// Re-runs every time state.puck changes - i.e. once per physics tick, since puck is a fresh +// object each TICK dispatch - so this hook itself IS the AI's per-tick decision point, riding +// the same cadence useGameState's own useGameLoop already drives rather than a separate rAF/ +// interval loop of its own. `setPaddleTarget` is the EXACT SAME function TouchInputLayer's +// gesture callbacks call for a human - this is what preserves BoxHockey's "AI plays through the +// human's own input contract" principle at continuous granularity: the AI never touches +// PADDLE_TARGET dispatch, paddle velocity computation, or collision differently than a human's +// touch does, it only ever supplies a different SOURCE of target (x, y) coordinates. +export function useAIOpponent(state: GameState, board: BoardLayout, aiPlayer: Player | null, difficulty: AIDifficulty, paused: boolean, setPaddleTarget: (player: Player, x: number, y: number) => void): void { + const jitterRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) + const ticksUntilRerollRef = useRef(0) + + useEffect(() => { + if (aiPlayer === null || paused || state.phase !== 'live') return + if (ticksUntilRerollRef.current <= 0) { + jitterRef.current = rollAIJitter(difficulty) + ticksUntilRerollRef.current = JITTER_HOLD_TICKS + } + ticksUntilRerollRef.current -= 1 + const target = computeAIPaddleTarget(state.puck, state.paddles[aiPlayer], board, aiPlayer, difficulty, jitterRef.current) + setPaddleTarget(aiPlayer, target.x, target.y) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.puck, state.phase, aiPlayer, difficulty, paused, board, setPaddleTarget]) +} diff --git a/src/games/airhockey/hooks/useGameState.ts b/src/games/airhockey/hooks/useGameState.ts new file mode 100644 index 0000000..11c93e8 --- /dev/null +++ b/src/games/airhockey/hooks/useGameState.ts @@ -0,0 +1,252 @@ +import { useGameLoop } from '@tastic/core' +import { useCallback, useEffect, useReducer, useRef } from 'react' + +import { GOAL_SERVE_PAUSE_MS, PADDLE_VELOCITY_SMOOTHING_FRAMES } from '@/games/airhockey/constants/game' +import { BoardLayout, GameState, GoalSide, PaddleState, Player, PuckState } from '@/games/airhockey/types' +import { centerPaddle, centerPuck, clampToOwnHalf, faceoffZoneCenter } from '@/games/airhockey/utils/board' +import { applyPaddleCollision, clampPaddleSpeed, computePaddleVelocity, stepPhysics } from '@/games/airhockey/utils/physics' + +type PaddleTargets = Record + +type Action = { type: 'TICK'; board: BoardLayout; dt: number; targets: PaddleTargets } | { type: 'START' } | { type: 'GOAL_SERVE'; board: BoardLayout; zoneIndex: 0 | 1 } | { type: 'RESET'; board: BoardLayout } | { type: 'RESCALE'; prevBoard: BoardLayout; board: BoardLayout } + +// Puck/paddle positions are stored in absolute pixels tied to a specific BoardLayout, but board +// itself can change at any moment - not just once at mount: useSafeAreaInsets/useOrientationState +// both resolve asynchronously after game.tsx's first paint, a live browser/window resize can fire at +// any time, and rotating the device mid-rally genuinely changes GoalAxis. Remaps a point by its +// FRACTIONAL position within the old board's playing field onto the same fraction of the new +// one (and scales velocity by the same ratio, so an in-flight puck's speed stays proportionally +// consistent) - this is what actually keeps the puck/paddles sane across every one of those +// cases, in every phase, rather than only patching the pre-game settling window. +function rescalePoint(point: T, prevBoard: BoardLayout, board: BoardLayout): T { + // A degenerate zero-sized prevBoard (e.g. web's very first render, before hydration has a real + // window size to measure) has nothing sane to compute a fraction FROM - fall back to dead + // center rather than dividing by zero into NaN. + const degenerate = prevBoard.width <= 0 || prevBoard.height <= 0 + const fx = degenerate ? 0.5 : (point.x - prevBoard.x) / prevBoard.width + const fy = degenerate ? 0.5 : (point.y - prevBoard.y) / prevBoard.height + const scaleX = degenerate ? 1 : board.width / prevBoard.width + const scaleY = degenerate ? 1 : board.height / prevBoard.height + return { ...point, x: board.x + fx * board.width, y: board.y + fy * board.height, vx: point.vx * scaleX, vy: point.vy * scaleY } +} + +function makeInitialState(board: BoardLayout): GameState { + const puckPos = centerPuck(board) + const p1 = centerPaddle(board, 1) + const p2 = centerPaddle(board, 2) + return { + phase: 'idle', + scores: [0, 0], + puck: { ...puckPos, vx: 0, vy: 0 }, + paddles: { 1: { ...p1, vx: 0, vy: 0 }, 2: { ...p2, vx: 0, vy: 0 } }, + lastScoredGoal: null, + bounceCount: 0, + goalDoorHitCount: 0, + lastGoalDoorSide: null, + pocketDoorHit: null + } +} + +// The pocket the puck sits fully inside (entirely past that goal line), or null. No paddle can +// ever reach it there: clampToOwnHalf stops every paddle at the line. +function pocketSide(puck: PuckState, board: BoardLayout): GoalSide | null { + if (puck.y + board.puckRadius < board.y) return 'top' + if (puck.y - board.puckRadius > board.y + board.height) return 'bottom' + return null +} + +// Commits one player's paddle from its raw target position into a real, velocity-bearing +// PaddleState - an EMA over the instantaneous per-tick velocity (rather than a plain moving- +// average ring buffer) so a single jittery touch sample can't read as a wild instantaneous +// velocity: PADDLE_VELOCITY_SMOOTHING_FRAMES doubles as the EMA's effective window, expressed +// purely as a function of the PREVIOUS committed paddle state (already itself smoothed from the +// tick before) - no separate history buffer needs to live anywhere. +function commitPaddle(prev: PaddleState, target: { x: number; y: number }, dt: number): PaddleState { + const instant = computePaddleVelocity(prev.x, prev.y, target.x, target.y, dt) + const alpha = 1 / PADDLE_VELOCITY_SMOOTHING_FRAMES + const smoothed = clampPaddleSpeed(prev.vx + (instant.vx - prev.vx) * alpha, prev.vy + (instant.vy - prev.vy) * alpha) + return { x: target.x, y: target.y, vx: smoothed.vx, vy: smoothed.vy } +} + +export function makeReducer(scoreToWin: number, friction: number) { + return function reducer(state: GameState, action: Action): GameState { + switch (action.type) { + // idle -> live: the "get ready" beat, same pause GOAL_SERVE gives after a goal (see + // useGameState's own effect below). The puck is already on its spot (center from + // makeInitialState, or a faceoff zone after TICK re-served a stranded puck), so this only has + // to flip the phase, no repositioning needed. + case 'START': + if (state.phase !== 'idle') return state + return { ...state, phase: 'live' } + + case 'TICK': { + if (state.phase !== 'live') return state + + const paddles = { + 1: commitPaddle(state.paddles[1], action.targets[1], action.dt), + 2: commitPaddle(state.paddles[2], action.targets[2], action.dt) + } + + let puck: PuckState = state.puck + puck = applyPaddleCollision(puck, state.paddles[1], paddles[1], action.board.puckRadius, action.board.paddleRadius) + puck = applyPaddleCollision(puck, state.paddles[2], paddles[2], action.board.puckRadius, action.board.paddleRadius) + const result = stepPhysics(puck, action.board, action.dt, friction) + + // A puck rattling between a pocket's back wall and its one-way door hits the door again on + // every lap: only the first hit of each pocket visit counts (the stat and Board's flash). + const pocket = pocketSide(result, action.board) + const goalDoorHit = result.goalDoorHit !== state.pocketDoorHit ? result.goalDoorHit : null + const goalDoorHitCount = goalDoorHit ? state.goalDoorHitCount + 1 : state.goalDoorHitCount + const lastGoalDoorSide = goalDoorHit ?? state.lastGoalDoorSide + const pocketDoorHit = pocket === null ? null : (goalDoorHit ?? state.pocketDoorHit) + + if (result.scored !== null) { + const scores: [number, number] = [state.scores[0], state.scores[1]] + scores[result.scored - 1]++ + const nextPuck: PuckState = { x: result.x, y: result.y, vx: 0, vy: 0 } + // Player 1 scores by entering the LOCAL TOP goal (player 2's own crease - see + // playerGoalSide in utils/board.ts and stepPhysics' own scored:1/2 convention), so the + // celebration's physical side is the inverse of the scoring player number. + const lastScoredGoal = result.scored === 1 ? 'top' : 'bottom' + if (scores[result.scored - 1] >= scoreToWin) { + return { ...state, phase: 'gameOver', scores, puck: nextPuck, paddles, lastScoredGoal, goalDoorHitCount, lastGoalDoorSide, pocketDoorHit: null } + } + return { ...state, phase: 'goalScored', scores, puck: nextPuck, paddles, lastScoredGoal, goalDoorHitCount, lastGoalDoorSide, pocketDoorHit: null } + } + + const bounceCount = result.bounced ? state.bounceCount + 1 : state.bounceCount + + // Unlike BoxHockey, a stopped puck does not end anything here - there is no turn to pass. + // It simply sits there, mid-rally, until a paddle touches it again, same as a real table. + // Except behind a goal line, where no paddle can follow it. Pucks back there skip friction + // (see stepPhysicsOnce), but every bounce between the one-way door and the pocket's back + // wall still costs WALL_RESTITUTION, so one with little sideways speed slows to a stop + // without ever getting out. Re-serve it, with no goal, from the nearer faceoff zone in that + // end (the top pocket sits behind player 2's goal, the same mapping GOAL_SERVE uses for the + // scored-on player), after the usual 'idle' serve beat. + if (result.stopped && pocket !== null) { + const owner: Player = pocket === 'top' ? 2 : 1 + const goalCX = action.board.x + (action.board.goalStart + action.board.goalEnd) / 2 + const puckPos = faceoffZoneCenter(action.board, owner, result.x < goalCX ? 0 : 1) + return { ...state, phase: 'idle', puck: { ...puckPos, vx: 0, vy: 0 }, paddles, bounceCount, goalDoorHitCount, lastGoalDoorSide, pocketDoorHit: null } + } + + return { ...state, puck: result, paddles, bounceCount, goalDoorHitCount, lastGoalDoorSide, pocketDoorHit } + } + + // Post-goal re-serve - see useGameState's own GOAL_SERVE_PAUSE_MS timer. Unlike BoxHockey + // there's no possession/turn logic to resolve here (either player can go for the puck the + // instant it lands), but the SPOT itself isn't neutral: the puck spawns at one of the + // scored-on player's own rink faceoff zones (action.zoneIndex, randomized at the dispatch + // site below - reducers stay pure) rather than dead center, the same way a real rink awards + // the ensuing faceoff to whoever just conceded. + case 'GOAL_SERVE': { + if (state.phase !== 'goalScored') return state + const scoredOnPlayer: Player = state.lastScoredGoal === 'top' ? 2 : 1 + const puckPos = faceoffZoneCenter(action.board, scoredOnPlayer, action.zoneIndex) + const p1 = centerPaddle(action.board, 1) + const p2 = centerPaddle(action.board, 2) + return { + ...state, + phase: 'live', + puck: { ...puckPos, vx: 0, vy: 0 }, + paddles: { 1: { ...p1, vx: 0, vy: 0 }, 2: { ...p2, vx: 0, vy: 0 } }, + lastScoredGoal: null + } + } + + case 'RESET': + return makeInitialState(action.board) + + case 'RESCALE': + return { + ...state, + puck: rescalePoint(state.puck, action.prevBoard, action.board), + paddles: { 1: rescalePoint(state.paddles[1], action.prevBoard, action.board), 2: rescalePoint(state.paddles[2], action.prevBoard, action.board) } + } + + default: + return state + } + } +} + +export function useGameState(board: BoardLayout, scoreToWin: number, friction: number, paused: boolean) { + // Passing a freshly-made reducer on every render (rather than memoizing it) is safe and + // intentional: React always uses the CURRENT render's reducer argument to process any pending + // dispatch, and dispatch's own identity stays stable regardless - so scoreToWin/friction changes + // are picked up immediately, with no stale-closure ref indirection needed. + const [state, dispatch] = useReducer(makeReducer(scoreToWin, friction), board, makeInitialState) + + const boardRef = useRef(board) + useEffect(() => { + boardRef.current = board + }) + + // Raw, unthrottled paddle targets - written every touch sample (TouchInputLayer) or AI decision + // (useAIOpponent) via setPaddleTarget below, WITHOUT dispatching (a dispatch per touch sample + // would re-render well over 60 times a second on some devices). The physics tick reads whatever + // this ref currently holds once per animation frame - the same cadence stepPhysics itself runs + // at - and folds the read into the SAME TICK dispatch that also steps the puck, so a player's + // and the AI's target both flow through this identical commit path (see commitPaddle above). + const paddleTargetsRef = useRef({ 1: centerPaddle(board, 1), 2: centerPaddle(board, 2) }) + + const setPaddleTarget = useCallback((player: Player, x: number, y: number) => { + const clamped = clampToOwnHalf(x, y, boardRef.current, player, boardRef.current.paddleRadius) + paddleTargetsRef.current = { ...paddleTargetsRef.current, [player]: clamped } + }, []) + + useGameLoop((dt) => dispatch({ type: 'TICK', board: boardRef.current, dt, targets: paddleTargetsRef.current }), { enabled: state.phase === 'live' && !paused, maxDt: 2.5 }) + + // useReducer's lazy init (the `board` argument passed to useReducer below) only ever runs ONCE, + // at this hook's very first mount - but `board` itself can change again at any later point too: + // useSafeAreaInsets/useOrientationState both resolve asynchronously after game.tsx's first paint, a + // live window resize can fire at any time, and rotating the device mid-rally genuinely changes + // GoalAxis. Rescaling on every board change (see rescalePoint above), not just while idle, is + // what actually keeps the puck/paddles' absolute-pixel positions sane across all of those - + // including the pre-game settling window, where it's simply invisible since nothing's moved yet. + const prevBoardRef = useRef(board) + useEffect(() => { + const prevBoard = prevBoardRef.current + if (board === prevBoard) return + prevBoardRef.current = board + // A degenerate zero-sized prevBoard (web's very first render, before hydration has a real + // window size to measure) can only ever be true right at mount - real gameplay can't have + // happened against a board with 0 width. Re-centering fresh (each point at its OWN correct + // spot) rather than fractionally rescaling from it is what actually matters here: rescalePoint + // treats a degenerate prevBoard as "everything was at the 0.5,0.5 fraction" (its own last- + // resort anti-NaN fallback), which would collapse the puck AND BOTH paddles onto the exact + // same center point instead of each landing at its own distinct starting position. + if (prevBoard.width <= 0 || prevBoard.height <= 0) { + paddleTargetsRef.current = { 1: centerPaddle(board, 1), 2: centerPaddle(board, 2) } + dispatch({ type: 'RESET', board }) + return + } + paddleTargetsRef.current = { 1: rescalePoint({ ...paddleTargetsRef.current[1], vx: 0, vy: 0 }, prevBoard, board), 2: rescalePoint({ ...paddleTargetsRef.current[2], vx: 0, vy: 0 }, prevBoard, board) } + dispatch({ type: 'RESCALE', prevBoard, board }) + }, [board]) + + // Owns the idle->live and goalScored->live pauses itself, rather than pushing timer bookkeeping + // out to game.tsx - this is the game's own internal rhythm, not something the screen layer + // needs to orchestrate. Re-runs (and so restarts the pause from scratch) whenever `paused` + // toggles, so a settings/quit-confirm dialog opened mid-pause doesn't let the puck go live off + // a stale timer that counted time the dialog was covering the board. + useEffect(() => { + if (paused) return + if (state.phase === 'idle' || state.phase === 'goalScored') { + const t = setTimeout(() => { + if (state.phase === 'idle') dispatch({ type: 'START' }) + else dispatch({ type: 'GOAL_SERVE', board: boardRef.current, zoneIndex: Math.random() < 0.5 ? 0 : 1 }) + }, GOAL_SERVE_PAUSE_MS) + return () => clearTimeout(t) + } + return undefined + }, [state.phase, paused]) + + const resetGame = useCallback((currentBoard: BoardLayout) => { + paddleTargetsRef.current = { 1: centerPaddle(currentBoard, 1), 2: centerPaddle(currentBoard, 2) } + dispatch({ type: 'RESET', board: currentBoard }) + }, []) + + return { state, setPaddleTarget, resetGame } +} diff --git a/src/games/airhockey/hooks/useGameStats.tsx b/src/games/airhockey/hooks/useGameStats.tsx new file mode 100644 index 0000000..179b4f5 --- /dev/null +++ b/src/games/airhockey/hooks/useGameStats.tsx @@ -0,0 +1,21 @@ +import { useCallback } from 'react' + +import { ACHIEVEMENT_CATALOG } from '@/games/airhockey/constants/achievements' +import type { ProfileStats, StatsState } from '@/games/airhockey/types' +import { applyMatchOutcome, getProfileStatsView, type MatchContext, type MatchOutcome } from '@/games/airhockey/utils/statsEngine' +import { DEFAULT_STATS, isValidStats } from '@/games/airhockey/utils/statsValidation' +import { createGameStats } from '@/shared/gameStats' + +// Air Hockey's stats and achievements, under its own store key (see the shared createGameStats). +const gameStats = createGameStats({ key: 'airhockeyAchievements', defaultStats: DEFAULT_STATS, isValidStats, catalog: ACHIEVEMENT_CATALOG, getProfileStatsView }) + +export const achievementsReducer = gameStats.reducer +export const achievementsActions = gameStats.actions +export const GameStatsProvider = gameStats.Provider + +export function useGameStats() { + const { stats, unlockedAchievements, record, resetAll } = gameStats.useStats() + // Records one finished match: who sat where (context.profileIds) gives the per-seat unlocks. + const recordMatchOutcome = useCallback((outcome: MatchOutcome, context: MatchContext) => record((prev) => applyMatchOutcome(prev, outcome, context), context.profileIds), [record]) + return { stats, unlockedAchievements, recordMatchOutcome, resetAll } +} diff --git a/src/games/airhockey/index.ts b/src/games/airhockey/index.ts new file mode 100644 index 0000000..06f64f4 --- /dev/null +++ b/src/games/airhockey/index.ts @@ -0,0 +1,51 @@ +import { ControlScheme } from '@/games/airhockey/types' +import type { GameDefinition } from '@/shell/types' + +import { getGuideSteps } from './constants/guideSteps' +import { achievementsActions, achievementsReducer, GameStatsProvider } from './hooks/useGameStats' +import PlayScreen from './PlayScreen' +import guide, { GUIDE_VERSION, guideActions, selectGuideVersionSeen } from './redux/guideSlice' +import { playerSettings } from './redux/settingsSlice' +import { AchievementList, StatsSections } from './StatsSections' +import { useLoadout } from './useLoadout' + +// What Air Hockey's How to Play cards are built from (see getGuideSteps), flat so the guide's +// shallow compare sees no change while none of them change. +interface GuideInput { + control1: ControlScheme + control2: ControlScheme + scoreToWin: number +} + +// Air Hockey, from the AirHockey repo (never released on its own). Its store keys are its own +// (airhockeySettings, airhockeyControls, airhockeyAchievements, airhockeyGuide). +export const airHockey: GameDefinition = { + id: 'airhockey', + title: 'Air Hockey', + blurb: 'The arcade table classic. Guard your goal and fire back.', + icon: { light: require('./art/icon-light.png'), dark: require('./art/icon-dark.png') }, + seatTwo: ['cpu', 'person'], + // Face to face on a phone, never flipped for an upside-down hold; on the web a window seats the + // two players by its own shape (see the play screen's fixedSeats). + twoPlayerSeats: { faceToFaceOnWeb: false, followUpsideDown: false }, + reducers: { ...playerSettings.reducers, airhockeyAchievements: achievementsReducer, airhockeyGuide: guide }, + Provider: GameStatsProvider, + useLoadout, + PlayScreen, + StatsSections, + AchievementList, + resetStats: achievementsActions.reset, + guide: { + version: GUIDE_VERSION, + selectVersionSeen: (state) => selectGuideVersionSeen(state), + markSeen: guideActions.markSeen, + selectInput: (state): GuideInput => { + const s = playerSettings.select(state) + return { control1: s.controlScheme[1], control2: s.controlScheme[2], scoreToWin: s.scoreToWin } + }, + getSteps: (input, { touch }) => { + const { control1, control2, scoreToWin } = input as GuideInput + return getGuideSteps({ touch, controlScheme: { 1: control1, 2: control2 }, scoreToWin }) + } + } +} diff --git a/src/games/airhockey/redux/guideSlice.ts b/src/games/airhockey/redux/guideSlice.ts new file mode 100644 index 0000000..c686cda --- /dev/null +++ b/src/games/airhockey/redux/guideSlice.ts @@ -0,0 +1,12 @@ +import { GUIDE_VERSION } from '@/games/airhockey/constants/guide' +import { createGameGuideSlice } from '@/shell/redux/gameGuideSlice' + +// Which version of Air Hockey's How to Play cards this player has finished or skipped, under its +// own store key. New to everyone in PuckTastic, so a saved store without it reads as not seen (see +// createGameGuideSlice). +const slice = createGameGuideSlice('airhockeyGuide') + +export { GUIDE_VERSION } +export const guideActions = slice.actions +export const selectGuideVersionSeen = slice.selectVersionSeen +export default slice.reducer diff --git a/src/games/airhockey/redux/settingsSlice.ts b/src/games/airhockey/redux/settingsSlice.ts new file mode 100644 index 0000000..c36774d --- /dev/null +++ b/src/games/airhockey/redux/settingsSlice.ts @@ -0,0 +1,24 @@ +import { SCORE_TO_WIN } from '@/games/airhockey/constants/game' +import type { ControlScheme, Settings } from '@/games/airhockey/types' +import { createPlayerSettings } from '@/shared/playerSettings' + +// What a player who hasn't changed a thing plays with. Each player's own changes are saved over it +// (see below), so a field added later starts at its default for everyone. +export const DEFAULT_SETTINGS: Settings = { + scoreToWin: SCORE_TO_WIN, + aiDifficulty: 'medium', + friction: 'medium', + puckSize: 'medium', + paddleSize: 'medium', + // On by default - see its own doc comment on Settings. + rink: true, + // Asymmetric defaults (not both 'mouse') so two seats sharing one desktop's keyboard/mouse don't + // start out colliding - mirrors Snake's/Pong's identical reasoning for the same field. + controlScheme: { 1: 'mouse', 2: 'wasd' } +} + +// Air Hockey's loadout, per player (shared/playerSettings.ts): the match settings seat 1's player +// last set up, under airhockeySettings, and the controls each player last picked, under +// airhockeyControls. Two seats can't share the mouse or a key cluster, so when both players saved +// the same one, seat 2 takes the next free one in the loadout's menu order for that match. +export const playerSettings = createPlayerSettings({ id: 'airhockey', defaults: DEFAULT_SETTINGS, distinctControls: ['mouse', 'wasd', 'arrows', 'ijkl', 'numpad'] }) diff --git a/src/games/airhockey/types.ts b/src/games/airhockey/types.ts new file mode 100644 index 0000000..d0303c3 --- /dev/null +++ b/src/games/airhockey/types.ts @@ -0,0 +1,209 @@ +import { AchievementDefinition as BaseAchievementDefinition, DayStreakState, OutcomeRecord, RoundResult, WinStreakState } from '@tastic/achievements' + +import type { ControlScheme } from '@/shared/controlScheme' + +export type Player = 1 | 2 + +// A seat's desktop input: the mouse or a key cluster (see the shared controlScheme.ts). +export type { ControlScheme } + +// Dramatically simpler than BoxHockey's turn/faceoff/coinFlip machine - the puck and both +// paddles are always live simultaneously ('live'), a goal pauses briefly for a re-serve +// ('goalScored') then returns to 'live', with no possession/turn concept at all. 'idle' is the +// serve beat with the puck already on its spot: before the opening serve, and after a puck that +// came to rest behind a goal line is re-served (see useGameState's TICK). +export type GamePhase = 'idle' | 'live' | 'goalScored' | 'gameOver' + +export type GameMode = 'twoPlayer' | 'vsCpu' + +export type AiDifficulty = 'easy' | 'medium' | 'hard' + +// Per-round puck deceleration tier - see FRICTION_LEVELS in constants/game.ts (the numeric +// coefficient each level maps to) and stepPhysics in utils/physics.ts, where it's applied as +// frictionFactor = FRICTION_LEVELS[level] ** dt. +export type FrictionLevel = 'low' | 'medium' | 'high' + +// Per-round puck radius tier, as a fraction of the board's short (goal) edge - see +// PUCK_SIZE_RATIOS in constants/game.ts. Puck size doubles as a difficulty tier: a bigger puck +// also widens the goal to match (goal width is DERIVED from puck+paddle radii - see +// GOAL_OPEN_FRACTION - not an independent ratio). +export type PuckSizeLevel = 'small' | 'medium' | 'large' + +// Per-round paddle (mallet) radius tier, same short-edge-fraction convention as PuckSizeLevel - +// see PADDLE_SIZE_RATIOS in constants/game.ts. New relative to BoxHockey: the paddle now +// physically collides with the puck (see applyPaddleCollision in utils/physics.ts), so its size +// is a real gameplay knob, not just cosmetic. +export type PaddleSizeLevel = 'small' | 'medium' | 'large' + +// Air Hockey's own settings, under its own store key (redux/settingsSlice.ts). Seat 2 and the +// pack-wide settings (Lock Orientation, Edge Guard) are the shell's. +export interface Settings { + scoreToWin: number + aiDifficulty: AiDifficulty + // Same toggle as BoxHockey's Settings.rink, and the same geometry (see computeBoardLayout in + // utils/board.ts, which takes it as its own `rink` parameter). On: rounded outer corners and a + // real pocket behind each goal to skate around in (plus the one-way door guarding it), reaching + // into the safe-area insets so the goal lines stay at the safe-area edge. Neither the corners nor + // the pocket's boundary are drawn; the puck can just go there, all the way to the device's own + // physical edge. Off: square corners and a permeable goal line at the safe-area edge, with no + // depth behind it. On by default. + rink: boolean + friction: FrictionLevel + puckSize: PuckSizeLevel + paddleSize: PaddleSizeLevel + // Web-only per-seat picker (see PlayerSetupPanel's ControlSchemePicker usage) - which physical + // input drives each seat's paddle. 'mouse' (the default) keeps using TouchInputLayer's own drag + // input unchanged; any KeyScheme routes that seat's paddle through KeyboardInputLayer instead. + // Up/Down/Left/Right always drive local Y-/Y+/X-/X+ directly, the SAME absolute direction for + // both seats (not flipped per player's own facing/goal side) - matches how any shared-screen + // local-multiplayer game's controls read (screen-relative, not "forward" relative to each + // player), and is the only convention that stays consistent for two people sitting side by side + // sharing one keyboard, both looking at the same (possibly board-rotated) screen. + controlScheme: Record +} + +export interface Obstacle { + x: number + y: number + width: number + height: number +} + +// A small round peg at one edge of a goal mouth - same shape/role as BoxHockey's GoalPost. +// Deliberately its own shape, not an Obstacle: a post is round (circleVsCircle gives a true +// curved deflection unlike a rect's flat faces/hard corners) and colored per-goal rather than +// the neutral wall color. +export interface GoalPost { + x: number + y: number + radius: number + player: Player +} + +// Which physical screen axis the two goals sit on - 'vertical' (top/bottom) in face-to-face +// portrait, 'horizontal' (left/right) in side-by-side landscape. See computeBoardLayout in +// utils/board.ts, the one place this actually branches geometry. +export type GoalAxis = 'vertical' | 'horizontal' + +// Board/physics/AI all work in a single CANONICAL local space, always as if the device were +// face-to-face portrait: player 1 defends the local bottom, player 2 the local top (see +// playerGoalSide in utils/board.ts) - regardless of the real device orientation. GoalAxis records +// which REAL screen axis the goals actually sit on for exactly one purpose: game.tsx rotates the +// whole board+input tree by ±90° (a single React Native transform) when it's 'horizontal', so +// side-by-side landscape shows the same local geometry correctly mapped onto a wide screen. Every +// other consumer - physics, rendering inside the board, touch handling, the AI - reads/writes +// plain local coordinates and never has to branch on this at all; RNGH reports touch coordinates +// relative to the (already-rotated) view they land on, so no manual coordinate transform code is +// needed anywhere outside that one wrapping rotation. +export type GoalSide = 'top' | 'bottom' + +export interface BoardLayout { + x: number + y: number + width: number + height: number + goalAxis: GoalAxis + wallThickness: number + goalWidth: number + // Goal opening's [start, end] along the local x axis (the short/paddle-clamp axis). + goalStart: number + goalEnd: number + puckRadius: number + paddleRadius: number + centerX: number + centerY: number + // Always empty - an open rink has no center divider (unlike BoxHockey's classic/gauntlet + // variants) - but stepPhysics' obstacle-collision loop is otherwise reused unmodified, so the + // field/type stays rather than deleting a working, well-tested code path. + obstacles: Obstacle[] + goalPosts: GoalPost[] + // Both 0 unless Settings.rink is on - see computeBoardLayout in utils/board.ts. + cornerRadius: number // rounded-corner radius at the 4 outer boundary corners + behindGoalDepth: number // how far the true collision boundary extends past the goal line, behind each goal +} + +export interface PuckState { + x: number + y: number + vx: number + vy: number +} + +// A paddle's vx/vy are DERIVED each physics tick from how far its position moved since the last +// tick (see computePaddleVelocity in utils/physics.ts) - never driven directly by input. Needed +// only so applyPaddleCollision can impart a moving paddle's own motion onto the puck. +export interface PaddleState { + x: number + y: number + vx: number + vy: number +} + +export type PaddleStates = Record + +export interface GameState { + phase: GamePhase + scores: [number, number] + puck: PuckState + paddles: PaddleStates + lastScoredGoal: GoalSide | null + // Bumped on every TICK where stepPhysics reports a wall/goalpost/corner bounce - an ever- + // incrementing counter (not a plain boolean) so game.tsx's own bounce SFX/haptic effect gets a + // reliable "did a NEW bounce just happen" edge to key off of, the same reason BoxHockey's + // identical bounceCount exists: a boolean alone can't tell "just became true" apart from + // "already was true a tick ago" across renders. + bounceCount: number + // Same "counter + kind" shape as bounceCount above, ported from BoxHockey unchanged - bumped + // whenever the puck struck the invisible one-way goal-mouth door (see physics.ts's goalDoorHit). + // Drives Board.tsx's goal-door flash; never resets between rounds, so that effect diffs against + // a ref rather than expecting a return to 0. + goalDoorHitCount: number + lastGoalDoorSide: GoalSide | null + // The pocket whose door the puck has already hit since it last went fully behind that goal line, + // or null. A puck rattling between the door and the pocket's back wall hits the door on every lap, + // and only the first hit of each visit counts toward goalDoorHitCount (see useGameState's TICK). + pocketDoorHit: GoalSide | null +} + +// ─── Stats & achievements ─────────────────────────────────────────────────── +// Tier, unlock-map and definition types come from @tastic/achievements; the shapes below are +// AirHockey's own. +export type { AchievementTier, UnlockedAchievementsState } from '@tastic/achievements' +export type AchievementDefinition = BaseAchievementDefinition + +export type MatchRecord = OutcomeRecord + +// The human is always seat 1 in vsCpu mode. A draw is impossible (a match ends the instant one +// side reaches scoreToWin), so `draws` stays 0 forever - it rides along because +// @tastic/achievements' applyResult maintains the whole quadruple. +export interface VsCpuStats extends OutcomeRecord, WinStreakState { + byDifficulty: Record +} + +// Tallied by SEAT, not by person - so this deliberately isn't an OutcomeRecord. +export interface TwoPlayerStats { + played: number + p1Wins: number + p2Wins: number +} + +export interface GoalTotals { + scored: number + conceded: number +} + +export interface ProfileStats extends DayStreakState { + vsCpu: VsCpuStats + twoPlayer: TwoPlayerStats + goals: GoalTotals + shutouts: number + bestWinMargin: number + // Cumulative one-way goal-door hits (see GameState's own goalDoorHitCount) - the puck rattling + // the door instead of going in. Already tracked during play, so it costs nothing to persist. + goalDoorHits: number +} + +export interface StatsState extends ProfileStats { + profiles: Record + firstGameResult: RoundResult | null +} diff --git a/src/games/airhockey/useLoadout.tsx b/src/games/airhockey/useLoadout.tsx new file mode 100644 index 0000000..a4c10cb --- /dev/null +++ b/src/games/airhockey/useLoadout.tsx @@ -0,0 +1,115 @@ +import { ControlSchemePicker, type MenuOption, type PopoverHost } from '@tastic/hud' +import { assignSeatScheme } from '@tastic/input' +import { useCallback, useMemo } from 'react' + +import { LOADOUT_SHARED_CONTROLS_IDS, LoadoutSharedControls } from '@/games/airhockey/components/LoadoutSharedControls' +import { DEFAULT_SETTINGS, playerSettings } from '@/games/airhockey/redux/settingsSlice' +import { ControlScheme, FrictionLevel, PaddleSizeLevel, PuckSizeLevel } from '@/games/airhockey/types' +import { AIDifficulty } from '@/games/airhockey/utils/aiOpponent' +import type { GameLoadout, LoadoutOptionsProps, RoundMode, Seat, SeatControlLook } from '@/shell/types' + +// The rink toggle's single option, same as BoxHockey's RINK_OPTION: presence in a one-item +// multi-select's value array already means "on," so there's nothing an "off" row would add beyond +// what tapping the "on" row a second time (to un-toggle it) already does. +const RINK_OPTION: MenuOption<'on'> = { value: 'on', label: 'Rink', description: 'Rounded corners + net pocket', icon: 'rounded-corner' } + +const FRICTION_OPTIONS: MenuOption[] = [ + { value: 'high', label: 'Slow', description: 'Puck stops quickly', icon: 'speedometer-slow' }, + { value: 'medium', label: 'Medium', description: 'Standard glide', icon: 'speedometer-medium' }, + { value: 'low', label: 'Fast', description: 'Puck glides farther', icon: 'speedometer' } +] + +// Rewritten from BoxHockey's own puck-size descriptions, which referenced divider gaps that don't +// exist here - AirHockey's puck size instead scales the goal mouth itself (goal width is derived +// from puck+paddle radii - see GOAL_OPEN_FRACTION in constants/game.ts), so it's a +// forgiveness/difficulty knob on scoring rather than a squeeze-through one. +const PUCK_SIZE_OPTIONS: MenuOption[] = [ + { value: 'small', label: 'Small', description: 'Narrower goals, harder to score', icon: 'circle-small' }, + { value: 'medium', label: 'Medium', description: 'Standard size', icon: 'circle-medium' }, + { value: 'large', label: 'Large', description: 'Wider goals, easier to score', icon: 'circle-outline' } +] + +// New relative to BoxHockey - the paddle now physically collides with the puck (see +// applyPaddleCollision in utils/physics.ts), so its size is a real defense/precision knob, not just +// cosmetic. Same size-tier option shape as PUCK_SIZE_OPTIONS above. +const PADDLE_SIZE_OPTIONS: MenuOption[] = [ + { value: 'small', label: 'Small', description: 'Smaller mallet, more precision', icon: 'circle-small' }, + { value: 'medium', label: 'Medium', description: 'Standard size', icon: 'circle-medium' }, + { value: 'large', label: 'Large', description: 'Bigger mallet, easier defense', icon: 'circle-outline' } +] + +const SCORE_TO_WIN_OPTIONS: MenuOption[] = [3, 5, 7, 10].map((value) => ({ value, label: String(value) })) + +const AI_DIFFICULTY_OPTIONS: MenuOption[] = [ + { value: 'easy', label: 'Easy', icon: 'emoticon-happy-outline' }, + { value: 'medium', label: 'Medium', icon: 'emoticon-neutral-outline' }, + { value: 'hard', label: 'Hard', icon: 'emoticon-devil-outline' } +] + +// Left icon-less, mirroring Snake's/Pong's own CONTROL_SCHEME_OPTIONS exactly - there's no +// MaterialCommunityIcons glyph that reads as "WASD" or "IJKL" at a glance the other option lists +// above get from their icons, so mixing one iconned option (Mouse) in with four icon-less ones +// would look like an inconsistency rather than a real distinction. +const CONTROL_SCHEME_OPTIONS: MenuOption[] = [ + { value: 'mouse', label: 'Mouse' }, + { value: 'wasd', label: 'WASD' }, + { value: 'arrows', label: 'Arrows' }, + { value: 'ijkl', label: 'IJKL' }, + { value: 'numpad', label: 'Numpad' } +] + +function pickRandom(options: readonly { value: T }[]): T { + return options[Math.floor(Math.random() * options.length)].value +} + +// Air Hockey's part of the shared loadout (shell/screens/LoadoutScreen.tsx): its match options +// (rink, puck speed and size, paddle size, score to win), each seat's controls on desktop web, and +// the CPU's difficulty. +export function useLoadout(mode: RoundMode): GameLoadout { + // Seat 1's player's own setup, and each seat's player's controls (redux/settingsSlice.ts). + const { settings, setSettings } = playerSettings.useSettings() + const p2IsHuman = mode === 'twoPlayer' + + // Covers exactly the fields the match options row exposes, plus the CPU's difficulty against the + // CPU. Per-seat things (colors, controls) and pack-wide settings aren't "match settings" in the + // sense a player means when they shuffle or reset the next match. + const randomize = useCallback(() => { + setSettings({ + rink: Math.random() < 0.5, + friction: pickRandom(FRICTION_OPTIONS), + puckSize: pickRandom(PUCK_SIZE_OPTIONS), + paddleSize: pickRandom(PADDLE_SIZE_OPTIONS), + scoreToWin: pickRandom(SCORE_TO_WIN_OPTIONS), + ...(mode === 'vsCpu' ? { aiDifficulty: pickRandom(AI_DIFFICULTY_OPTIONS) } : {}) + }) + }, [setSettings, mode]) + + const reset = useCallback(() => { + setSettings({ + rink: DEFAULT_SETTINGS.rink, + friction: DEFAULT_SETTINGS.friction, + puckSize: DEFAULT_SETTINGS.puckSize, + paddleSize: DEFAULT_SETTINGS.paddleSize, + scoreToWin: DEFAULT_SETTINGS.scoreToWin, + ...(mode === 'vsCpu' ? { aiDifficulty: DEFAULT_SETTINGS.aiDifficulty } : {}) + }) + }, [setSettings, mode]) + + const options = useCallback(({ host, accentColor, mutedColor, onAccentColor, dark, onRandomize, onReset }: LoadoutOptionsProps) => setSettings({ rink: value })} friction={settings.friction} frictionOptions={FRICTION_OPTIONS} onFrictionChange={(value) => setSettings({ friction: value })} puckSize={settings.puckSize} puckSizeOptions={PUCK_SIZE_OPTIONS} onPuckSizeChange={(value) => setSettings({ puckSize: value })} paddleSize={settings.paddleSize} paddleSizeOptions={PADDLE_SIZE_OPTIONS} onPaddleSizeChange={(value) => setSettings({ paddleSize: value })} scoreToWin={settings.scoreToWin} scoreToWinOptions={SCORE_TO_WIN_OPTIONS} onScoreToWinChange={(value) => setSettings({ scoreToWin: value })} onRandomize={onRandomize} onReset={onReset} accentColor={accentColor} mutedColor={mutedColor} onAccentColor={onAccentColor} dark={dark} />, [settings, setSettings]) + + // A person's controls on desktop web: the mouse or a key cluster (ControlSchemePicker is null on + // touch-first devices). Picking the other seat's scheme hands it this seat's old one + // (@tastic/input's assignSeatScheme), so the two seats never share one; against the CPU, seat 1 + // can take any scheme. + const seatControl = useCallback( + (seat: Seat, host: PopoverHost, { seatColor, mutedColor, dark }: SeatControlLook) => { + const other: Seat = seat === 1 ? 2 : 1 + return setSettings({ controlScheme: assignSeatScheme(settings.controlScheme, seat, scheme) })} options={CONTROL_SCHEME_OPTIONS} takenValue={p2IsHuman ? settings.controlScheme[other] : undefined} accentColor={seatColor} mutedColor={mutedColor} dark={dark} /> + }, + [settings.controlScheme, setSettings, p2IsHuman] + ) + + const cpuDifficulty = useMemo(() => ({ value: settings.aiDifficulty, options: AI_DIFFICULTY_OPTIONS, onChange: (value: string) => setSettings({ aiDifficulty: value as AIDifficulty }) }), [settings.aiDifficulty, setSettings]) + + return { roundSettings: settings, options, optionIds: LOADOUT_SHARED_CONTROLS_IDS, randomize, reset, seatControl, cpuDifficulty } +} diff --git a/src/games/airhockey/utils/achievementEngine.ts b/src/games/airhockey/utils/achievementEngine.ts new file mode 100644 index 0000000..bc06081 --- /dev/null +++ b/src/games/airhockey/utils/achievementEngine.ts @@ -0,0 +1,10 @@ +import { createAchievementBindings } from '@tastic/achievements' + +import { ACHIEVEMENT_CATALOG } from '@/games/airhockey/constants/achievements' +import { getProfileStatsView } from '@/games/airhockey/utils/statsEngine' + +// Thin AirHockey-side bindings over @tastic/achievements' generic engine - the catalog is a +// module-level constant here, so pre-binding it keeps call sites reading as +// `evaluateUnlockedIds(stats)` rather than threading it through by hand. Re-exported under these +// exact names so no other call site in this app has to change its own import. +export const { evaluateUnlockedIds, evaluateUnlockedIdsForProfile, unlockedKey } = createAchievementBindings(ACHIEVEMENT_CATALOG, getProfileStatsView) diff --git a/src/games/airhockey/utils/aiOpponent.ts b/src/games/airhockey/utils/aiOpponent.ts new file mode 100644 index 0000000..5258716 --- /dev/null +++ b/src/games/airhockey/utils/aiOpponent.ts @@ -0,0 +1,103 @@ +import { MAX_PADDLE_SPEED } from '@/games/airhockey/constants/game' +import { BoardLayout, PaddleState, Player, PuckState } from '@/games/airhockey/types' +import { centerPaddle, clampToOwnHalf, playerGoalSide } from '@/games/airhockey/utils/board' + +export type AIDifficulty = 'easy' | 'medium' | 'hard' + +interface AITuning { + // How far ahead (ms) the AI predicts the puck's straight-line position - no bounce-forward + // simulation (unlike BoxHockey's planAIShot/simulateShot, which plans one whole shot in + // advance): a short lookahead re-predicted fresh every tick approximates watch-and-adjust play, + // which is both simpler and closer to how a real opponent actually defends than committing to a + // pre-planned trajectory. + interceptLookaheadMs: number + // Max magnitude (px) of the random offset periodically applied to the AI's own computed target + // (see rollAIJitter/useAIOpponent) - harder tiers see/aim more precisely. Same "blur the choice + // by difficulty" idea as BoxHockey's aimNoiseDeg/powerNoise, just applied to a position instead + // of an angle/power pair. + aimJitterPx: number + // Fraction of MAX_PADDLE_SPEED the AI's own target-approach rate is capped at. This alone is + // what produces believable reaction lag: a low-tier AI's target is computed just as promptly as + // a high-tier one's, it just physically can't get there as fast - no setTimeout scheduling + // needed, unlike BoxHockey's randomAIThinkDelayMs. + maxApproachSpeed: number + // 0..1 - how strongly the AI eases back toward its own home position (centerPaddle) when the + // puck isn't a threat, rather than snapping there instantly. + defendHomeBiasWhenFar: number +} + +const TUNING: Record = { + easy: { interceptLookaheadMs: 80, aimJitterPx: 18, maxApproachSpeed: 0.55, defendHomeBiasWhenFar: 0.35 }, + medium: { interceptLookaheadMs: 140, aimJitterPx: 9, maxApproachSpeed: 0.75, defendHomeBiasWhenFar: 0.55 }, + hard: { interceptLookaheadMs: 200, aimJitterPx: 2, maxApproachSpeed: 0.95, defendHomeBiasWhenFar: 0.75 } +} + +// dt=1 in @tastic/core's useGameLoop means "one nominal 60fps frame" (see computeClampedDt) - +// puck.vx/vy are already expressed per that same unit, so predicting `interceptLookaheadMs` +// ahead means converting it into the same frame-unit before scaling velocity by it. +const MS_PER_FRAME = 1000 / 60 + +// A puck slower than this reads as LOOSE - a fresh kickoff/faceoff serve (spawned at exactly +// vx=vy=0) or a rally puck that's coasted to a near-stop - rather than a puck genuinely in +// flight as part of a live exchange. Comfortably above STOP_THRESHOLD (0.12) so physics-residual +// creep never accidentally counts, but well below any speed a real shot/pass carries. +const LOOSE_PUCK_SPEED_THRESHOLD = 1.5 + +// Rolls a brand-new random aim-imprecision offset within tuning.aimJitterPx of (0,0). Split out +// from computeAIPaddleTarget (which just applies whatever offset it's given) so the CALLER +// controls how often a new one gets rolled - see useAIOpponent, which holds one for a short burst +// of ticks rather than re-rolling every single tick. +export function rollAIJitter(difficulty: AIDifficulty, random: () => number = Math.random): { x: number; y: number } { + const tuning = TUNING[difficulty] + const angle = random() * Math.PI * 2 + const mag = random() * tuning.aimJitterPx + return { x: Math.cos(angle) * mag, y: Math.sin(angle) * mag } +} + +// Pure, per-tick decision function - called once per physics tick for the AI's own seat (see +// useAIOpponent below) and returns a TARGET POSITION, exactly the shape a human's own drag +// produces (see TouchInputLayer's setPaddleTarget) - preserving BoxHockey's own "AI plays through +// the human's input contract" principle, just at continuous per-tick granularity instead of +// BoxHockey's one-shot gesture-script granularity. +export function computeAIPaddleTarget(puck: PuckState, ownPaddle: PaddleState, board: BoardLayout, aiPlayer: Player, difficulty: AIDifficulty, jitterOffset: { x: number; y: number } = { x: 0, y: 0 }): { x: number; y: number } { + const tuning = TUNING[difficulty] + const side = playerGoalSide(aiPlayer) + const lookaheadFrames = tuning.interceptLookaheadMs / MS_PER_FRAME + const predicted = { x: puck.x + puck.vx * lookaheadFrames, y: puck.y + puck.vy * lookaheadFrames } + + // Threat gate: worth chasing if the puck is either (a) on the AI's own half AND actually + // heading toward the AI's own goal line, or (b) LOOSE - a fresh kickoff/faceoff serve or a + // rally puck that's coasted to a stop - and not clearly the opponent's to reach first. (b) is + // what makes the AI push up and contest a neutral puck instead of always waiting at home for a + // shot that (a) alone would only ever recognize once it's already under way - the same instinct + // a human has to go for a puck sitting in the open rather than ceding first touch by default. + const onOwnHalf = side === 'bottom' ? puck.y > board.centerY : puck.y < board.centerY + const onOpponentHalf = side === 'bottom' ? puck.y < board.centerY : puck.y > board.centerY + const movingTowardOwnGoal = side === 'bottom' ? puck.vy > 0 : puck.vy < 0 + const isLooseAndReachable = Math.hypot(puck.vx, puck.vy) < LOOSE_PUCK_SPEED_THRESHOLD && !onOpponentHalf + const isThreat = (onOwnHalf && movingTowardOwnGoal) || isLooseAndReachable + + const home = centerPaddle(board, aiPlayer) + let targetX: number + let targetY: number + if (isThreat) { + targetX = predicted.x + targetY = predicted.y + } else { + targetX = ownPaddle.x + (home.x - ownPaddle.x) * tuning.defendHomeBiasWhenFar + targetY = ownPaddle.y + (home.y - ownPaddle.y) * tuning.defendHomeBiasWhenFar + } + + targetX += jitterOffset.x + targetY += jitterOffset.y + + const clamped = clampToOwnHalf(targetX, targetY, board, aiPlayer, board.paddleRadius) + + const maxStep = tuning.maxApproachSpeed * MAX_PADDLE_SPEED + const dx = clamped.x - ownPaddle.x + const dy = clamped.y - ownPaddle.y + const dist = Math.hypot(dx, dy) + if (dist <= maxStep || dist === 0) return clamped + const scale = maxStep / dist + return { x: ownPaddle.x + dx * scale, y: ownPaddle.y + dy * scale } +} diff --git a/src/games/airhockey/utils/board.ts b/src/games/airhockey/utils/board.ts new file mode 100644 index 0000000..0b034c4 --- /dev/null +++ b/src/games/airhockey/utils/board.ts @@ -0,0 +1,216 @@ +import { Platform } from 'react-native' +import { EdgeInsets } from 'react-native-safe-area-context' + +import { DESKTOP_WEB_MIN_WIDTH, FACEOFF_ZONE_RADIUS_RATIO, GOAL_LINE_STROKE_WIDTH, GOAL_OPEN_FRACTION, PADDLE_SIZE_RATIOS, PUCK_SIZE_RATIOS, TABLE_LONG_TO_SHORT_RATIO, WALL_THICKNESS } from '@/games/airhockey/constants/game' +import { BoardLayout, GoalAxis, GoalPost, GoalSide, PaddleSizeLevel, Player, PuckSizeLevel } from '@/games/airhockey/types' + +// Mirrors game.tsx's own isDesktopWeb - desktop web is the only context that fixes the board to +// its real-world aspect ratio (TABLE_LONG_TO_SHORT_RATIO) rather than filling the viewport +// exactly, and draws literal perimeter walls into the margin that leaves. Native and mobile web +// fill their container exactly, so the device's own physical edge already reads as the boundary +// with zero gap. +function isDesktopLayout(screenWidth: number): boolean { + return Platform.OS === 'web' && screenWidth >= DESKTOP_WEB_MIN_WIDTH +} + +function boardMargin(screenWidth: number): number { + return isDesktopLayout(screenWidth) ? WALL_THICKNESS : 0 +} + +export function computeViewportSize(screenWidth: number, screenHeight: number, insets: EdgeInsets): { width: number; height: number } { + const margin = boardMargin(screenWidth) + return { + width: screenWidth - insets.left - insets.right - margin * 2, + height: screenHeight - insets.top - insets.bottom - margin * 2 + } +} + +// The safe-area insets at each end of the local short (goal-line) and long (goal-to-goal) axes, +// following the same "which real dimension is short vs. long" split computeViewportSize's callers +// make from goalAxis below. +function axisInsets(insets: EdgeInsets, goalAxis: GoalAxis): { shortStart: number; longStart: number; longEnd: number } { + return goalAxis === 'vertical' ? { shortStart: insets.left, longStart: insets.top, longEnd: insets.bottom } : { shortStart: insets.top, longStart: insets.left, longEnd: insets.right } +} + +// The LOCAL board container - the whole screen, safe-area insets included, expressed in local +// terms: S-wide (short axis, where the goal sits) by L-tall (long axis, goal-to-goal), whichever +// real screen dimension is currently short vs. long given goalAxis. game.tsx uses this directly to +// size the View it rotates (see GoalAxis's own doc comment in types/index.ts): the container is +// always laid out S-wide by L-tall in local terms, then rotated ±90° onto the real screen when +// goalAxis is 'horizontal'. It covers the insets (rather than sitting inside them) so that, with +// Settings.rink on, the pocket behind each goal can reach into the otherwise-empty status-bar/home- +// indicator space, same as BoxHockey's own rink; computeBoardLayout keeps the playing field itself +// clear of them. +export function computeLocalContainerSize(screenWidth: number, screenHeight: number, goalAxis: GoalAxis): { width: number; height: number } { + return goalAxis === 'vertical' ? { width: screenWidth, height: screenHeight } : { width: screenHeight, height: screenWidth } +} + +// The board's own short/long extents (S, L) inside the safe area. Desktop web fixes them to the +// real-world aspect ratio (TABLE_LONG_TO_SHORT_RATIO) and letterboxes; native and mobile web fill +// the safe area exactly. +function computeFieldExtents(screenWidth: number, screenHeight: number, insets: EdgeInsets, goalAxis: GoalAxis): { S: number; L: number; availableShort: number; availableLong: number } { + const { width: availableWidth, height: availableHeight } = computeViewportSize(screenWidth, screenHeight, insets) + const availableShort = goalAxis === 'vertical' ? availableWidth : availableHeight + const availableLong = goalAxis === 'vertical' ? availableHeight : availableWidth + + let S = availableShort + let L = availableLong + if (isDesktopLayout(screenWidth)) { + L = S * TABLE_LONG_TO_SHORT_RATIO + if (L > availableLong) { + L = availableLong + S = L / TABLE_LONG_TO_SHORT_RATIO + } + } + return { S, L, availableShort, availableLong } +} + +// Which local side (always 'bottom' for player 1, 'top' for player 2 - see GoalSide's own doc +// comment) a given player defends. A plain function of the player number, not board state: this +// convention never varies board-to-board, so there's nothing to store on BoardLayout itself. +export function playerGoalSide(player: Player): GoalSide { + return player === 1 ? 'bottom' : 'top' +} + +function buildGoalPosts(x: number, y: number, height: number, goalStart: number, goalEnd: number, radius: number): GoalPost[] { + return [ + // Player 2 defends the local top line (y). + { x: x + goalStart, y, radius, player: 2 }, + { x: x + goalEnd, y, radius, player: 2 }, + // Player 1 defends the local bottom line (y + height). + { x: x + goalStart, y: y + height, radius, player: 1 }, + { x: x + goalEnd, y: y + height, radius, player: 1 } + ] +} + +// Single source of truth for board geometry, always expressed in the canonical LOCAL space (see +// GoalSide's own doc comment in types/index.ts) - goals on the local top/bottom regardless of the +// real device orientation, in the coordinates of computeLocalContainerSize's full-screen +// container. goalAxis is accepted only to decide which real screen dimension is currently "short" +// vs. "long" (so puck/paddle/goal sizing always derives from the true short edge, whichever real +// axis that happens to be right now) - it never branches the geometry itself beyond that, unlike +// BoxHockey's board.ts, which never needed to make that distinction at all since its board is +// permanently portrait. +export function computeBoardLayout(screenWidth: number, screenHeight: number, insets: EdgeInsets, goalAxis: GoalAxis, puckSize: PuckSizeLevel = 'medium', paddleSize: PaddleSizeLevel = 'medium', rink: boolean = false): BoardLayout { + const { S, L, availableShort, availableLong } = computeFieldExtents(screenWidth, screenHeight, insets, goalAxis) + const { shortStart, longStart, longEnd } = axisInsets(insets, goalAxis) + + const margin = boardMargin(screenWidth) + const puckRadius = S * PUCK_SIZE_RATIOS[puckSize] + const paddleRadius = S * PADDLE_SIZE_RATIOS[paddleSize] + // See GOAL_OPEN_FRACTION's own comment: derived from the actual paddle+puck blocking span (not + // an independent flat fraction of S) so the goal stays fair for every size combination. + const blockingSpan = (paddleRadius + puckRadius) * 2 + const goalWidth = blockingSpan / (1 - GOAL_OPEN_FRACTION) + const goalStart = (S - goalWidth) / 2 + const goalEnd = goalStart + goalWidth + + // Settings.rink only, with BoxHockey's exact formulas: rounded outer corners and a real pocket + // behind each goal the puck can skate into (see physics.ts's boardCornerQuadrant/skipGoalGap and + // the one-way door). Both 0 when it's off: square corners and a permeable goal line at the + // safe-area edge, where a puck whose center clears the posts and the line scores immediately. + // + // The depth must be >= 2*puckRadius + WALL_THICKNESS so a gradual (not single-frame) score can + // fully clear the line before meeting the pocket's back wall; 2.5x leaves real margin. It's also + // floored at both long-axis insets so the pocket reaches all the way to the physical screen edge + // on both ends (see the borrows below). + const behindGoalDepth = rink ? Math.max(puckRadius * 2.5 + WALL_THICKNESS, longStart, longEnd) : 0 + const cornerRadius = rink ? S * 0.12 : 0 + + // Rather than shrink the field by the full behindGoalDepth at both ends (which pushes each goal + // line forward, away from the screen edge), borrow from the safe-area insets first: that space + // is otherwise just empty background. Capped at each inset (can't borrow more than exists there); + // whatever's left over still shrinks the field, so this degrades gracefully on devices with + // small or no insets. With unequal insets (a notch vs. a home indicator) the smaller end can't + // borrow its full share of the shared depth, so that goal line sits slightly in from its edge. + const startBorrow = Math.min(behindGoalDepth, longStart) + const endBorrow = Math.min(behindGoalDepth, longEnd) + + const x = shortStart + margin + (availableShort - S) / 2 + const outerTop = longStart + margin + (availableLong - L) / 2 - startBorrow + const y = outerTop + behindGoalDepth + const width = S + const height = L - behindGoalDepth * 2 + startBorrow + endBorrow + + const postRadius = puckRadius * 0.18 + + return { + x, + y, + width, + height, + goalAxis, + wallThickness: GOAL_LINE_STROKE_WIDTH, + goalWidth, + goalStart, + goalEnd, + puckRadius, + paddleRadius, + centerX: x + width / 2, + centerY: y + height / 2, + obstacles: [], + goalPosts: buildGoalPosts(x, y, height, goalStart, goalEnd, postRadius), + cornerRadius, + behindGoalDepth + } +} + +export function centerPuck(board: BoardLayout): { x: number; y: number } { + return { x: board.centerX, y: board.centerY } +} + +// A player's defensive rest position - their own goal line, offset in - used to reset both +// paddles after a goal (see GOAL_SERVE in useGameState.ts) and as the AI's own "retreat and cover +// the net" target when the puck isn't a threat (see aiOpponent.ts). +const PADDLE_HOME_OFFSET_RATIO = 1.5 + +export function centerPaddle(board: BoardLayout, player: Player): { x: number; y: number } { + const offset = board.paddleRadius * PADDLE_HOME_OFFSET_RATIO + const y = playerGoalSide(player) === 'bottom' ? board.y + board.height - offset : board.y + offset + return { x: board.centerX, y } +} + +// Rink faceoff circle positions, ported from BoxHockey's own faceoffZoneCenters unchanged (same +// 0.27/0.42 offsets - BoxHockey's non-gauntlet faceoffOffsetYRatio) - two per player, flanking +// their own goal. Board.tsx renders both purely as static rink chrome (see FACEOFF_ZONE_RADIUS_ +// RATIO's own comment); useGameState's GOAL_SERVE is the only thing that actually PICKS one (see +// faceoffZoneCenter below) - the scored-on player's own zone, giving them the restart, same as a +// real rink awards the ensuing faceoff to whoever just conceded. There's no board-variant knob to +// key faceoffOffsetYRatio off, so it's inlined rather than carried as a BoardLayout field the way +// BoxHockey needs it to be. +const FACEOFF_OFFSET_Y_RATIO = 0.42 + +export function faceoffZoneCenters(board: BoardLayout, player: Player): { x: number; y: number }[] { + const goalCX = board.x + (board.goalStart + board.goalEnd) / 2 + const offsetX = board.width * 0.27 + const offsetY = (board.height / 2) * FACEOFF_OFFSET_Y_RATIO + const cy = playerGoalSide(player) === 'bottom' ? board.y + board.height - offsetY : board.y + offsetY + return [ + { x: goalCX - offsetX, y: cy }, + { x: goalCX + offsetX, y: cy } + ] +} + +export function faceoffZoneCenter(board: BoardLayout, player: Player, zoneIndex: 0 | 1): { x: number; y: number } { + return faceoffZoneCenters(board, player)[zoneIndex] +} + +export function faceoffZoneRadius(board: BoardLayout): number { + return board.width * FACEOFF_ZONE_RADIUS_RATIO +} + +// Clamps a target position to a player's own playable half - both the board's own outer bounds +// (minus radius) AND their own side of the local centerline (minus radius, so the paddle disc's +// own edge stops exactly at the centerline rather than overlapping it). Shared by +// TouchInputLayer's setPaddleTarget (a human's live drag) and aiOpponent's target selection - the +// same clamp rule applies to both input sources, so it lives here once. +export function clampToOwnHalf(x: number, y: number, board: BoardLayout, player: Player, radius: number): { x: number; y: number } { + const minX = board.x + radius + const maxX = board.x + board.width - radius + const minY = board.y + radius + const maxY = board.y + board.height - radius + const cx = Math.min(maxX, Math.max(minX, x)) + let cy = Math.min(maxY, Math.max(minY, y)) + cy = playerGoalSide(player) === 'bottom' ? Math.max(cy, board.centerY + radius) : Math.min(cy, board.centerY - radius) + return { x: cx, y: cy } +} diff --git a/src/games/airhockey/utils/physics.ts b/src/games/airhockey/utils/physics.ts new file mode 100644 index 0000000..1fe7aa9 --- /dev/null +++ b/src/games/airhockey/utils/physics.ts @@ -0,0 +1,335 @@ +import { applyFriction, boardCornerQuadrant, circleVsCircle, circleVsRect, circleVsRoundedCorner, reflectOffNormal } from '@tastic/physics' + +import { FRICTION_LEVELS, MAX_PADDLE_SPEED, PADDLE_COLLISION_RESTITUTION, STOP_THRESHOLD, WALL_RESTITUTION } from '@/games/airhockey/constants/game' +import { BoardLayout, PaddleState, PuckState } from '@/games/airhockey/types' + +export interface PhysicsResult extends PuckState { + scored: 1 | 2 | null + stopped: boolean + bounced: boolean + // Set only when this step's bounce was against the invisible one-way door at the goal mouth - + // see the door collision block below. Ported from BoxHockey unchanged: distinguishes a door hit + // from every other kind of bounce (wall/post/corner) so Board.tsx can flash just that side, only + // on that specific hit, instead of on every generic bounce. + goalDoorHit: 'top' | 'bottom' | null +} + +function stepPhysicsOnce(puck: PuckState, board: BoardLayout, dt: number, friction: number): PhysicsResult { + let { x, y, vx, vy } = puck + + const r = board.puckRadius + const goalAbsLeft = board.x + board.goalStart + const goalAbsRight = board.x + board.goalEnd + const wasBehindTop = puck.y + r < board.y + const wasBehindBottom = puck.y - r > board.y + board.height + + // A puck already mid-crossing the front line at the START of this frame (leading edge already + // past board.y/board.y+height, trailing edge not yet - scoring requires FULL clearance, by + // design, so this is a real, reachable in-between state) skips friction this frame instead of + // decaying normally. Without this, a puck that entered the goal mouth with only modest speed + // could decay below STOP_THRESHOLD before its trailing edge ever cleared the line - at which + // point nothing else acts on it there either (the flat wall is deliberately disabled for the + // whole corridor once centerInGoalX is true), so it would simply sit there forever: not scored + // (body not fully through) and not bounced back out. Skipping friction (rather than merely + // exempting the `stopped` check) is what actually guarantees eventual full clearance. + const centerInGoalX0 = x > goalAbsLeft && x < goalAbsRight + const midCrossing = centerInGoalX0 && ((y - r < board.y && y + r >= board.y) || (y + r > board.y + board.height && y - r <= board.y + board.height)) + + // A puck that's ALREADY fully behind a goal line gets the same exemption, for a related but + // distinct reason: it can end up back there WITHOUT scoring by wrapping around a rounded outer + // corner instead of crossing through the goal mouth (see boardCornerQuadrant below) - off to the + // side of centerInGoalX entirely, where neither the score check nor the one-way door ever apply. + // No paddle can ever follow it back there (clampToOwnHalf never lets one past board.y/board.y+ + // board.height), so friction decaying it to a stop would strand it, with nothing left to ever + // move it again. Staying frictionless instead keeps it moving until it rides the corner's curve + // back out the way it came in. Bounces back there still cost WALL_RESTITUTION, though, so one + // that does come to rest behind the line is re-served by useGameState's TICK. This never lets an + // actual score linger, since a legitimately scoring puck is already frozen by useGameState's own + // goalScored transition before its very next tick would even reach here. + const behindGoalLine = wasBehindTop || wasBehindBottom + + if (!midCrossing && !behindGoalLine) { + const frictioned = applyFriction({ x: vx, y: vy }, friction, dt) + vx = frictioned.x + vy = frictioned.y + } + + x += vx * dt + y += vy * dt + + // Center-only, deliberately lenient (vs. a full-body-clearance test): "within the goal's own + // x-range" for every purpose that cares about it below - the flat-wall skip, the score check, + // the goalpost collision gate, and the rink pocket's one-way door. + const centerInGoalX = x > goalAbsLeft && x < goalAbsRight + let bounced = false + let goalDoorHit: 'top' | 'bottom' | null = null + + // A puck that has ALREADY fully cleared the line this frame - BEFORE any corner/goalpost/wall + // correction below ever touches it - is an unambiguous, already-complete score: return + // immediately rather than risk a goalpost correction nudging the position back and un-scoring a + // puck that had already made it all the way through. + if (centerInGoalX && y + r < board.y && !wasBehindTop) return { x, y, vx, vy, scored: 1, stopped: false, bounced: false, goalDoorHit: null } + if (centerInGoalX && y - r > board.y + board.height && !wasBehindBottom) return { x, y, vx, vy, scored: 2, stopped: false, bounced: false, goalDoorHit: null } + + // Near one of the 4 rounded outer corners, a single concave arc check replaces BOTH the flat + // top/bottom and left/right wall checks below for this frame - this is also what opens the + // wraparound path behind the goal, since within a corner's quadrant the flat front-wall check + // (which would otherwise seal board.y everywhere outside the goal gap) simply doesn't run. + const corner = boardCornerQuadrant(x, y, board) + if (corner) { + const col = circleVsRoundedCorner(x, y, r, corner.arcCenterX, corner.arcCenterY, board.cornerRadius) + if (col.hit) { + x = col.x + y = col.y + // col.nx/ny point OUTWARD from the arc center (toward the wall, away from the playable ice), + // so a puck driving further into the wall has a velocity component ALONG that direction: + // dot > 0 is the case to remove/reflect. + const dot = vx * col.nx + vy * col.ny + if (dot > 0) { + vx -= (1 + WALL_RESTITUTION) * dot * col.nx + vy -= (1 + WALL_RESTITUTION) * dot * col.ny + } + bounced = true + } + } else { + // Checked BEFORE the flat wall below, and NOT gated on centerInGoalX at all - a post sits + // EXACTLY at the corridor's own boundary, so circleVsCircle's own distance test is what + // should decide this, not a same-threshold x-only proxy for it. + let postHit = false + for (const post of board.goalPosts) { + const col = circleVsCircle({ position: { x, y }, radius: r }, { position: { x: post.x, y: post.y }, radius: post.radius }) + if (col.hit) { + x += col.normal.x * col.depth + y += col.normal.y * col.depth + const reflected = reflectOffNormal({ x: vx, y: vy }, col.normal, WALL_RESTITUTION) + vx = reflected.x + vy = reflected.y + bounced = true + postHit = true + } + } + + // The TRUE outer boundary: board.y/board.height extended by board.behindGoalDepth. Whenever + // there's a pocket behind the goal line (Settings.rink on), skipGoalGap makes + // this wall solid across the FULL width outside the corners, goal mouth and post strips + // included: the goal is a SLOT (a scoring position, not a wall opening), so a puck sails past + // board.y there and meets this wall further back instead of leaving the rink. Pucks behind the + // line skip friction (see behindGoalLine above), so a gap here would let one escape for good. + // Only a pocketless board (behindGoalDepth 0, Settings.rink off) opens this wall, at the + // goal mouth (centerInGoalX, the same center-only threshold the score check and the goalpost + // check above use) and beside each post (nearPostX below), so clearing the line scores there. + const skipGoalGap = board.behindGoalDepth > 0 + const outerTop = board.y - board.behindGoalDepth + const outerBottom = board.y + board.height + board.behindGoalDepth + // Widens the pocketless near-line wall's own permeable zone in X to also exclude any post's + // real reach: the post's max reach in x is puckRadius + postRadius, but only at the post's own + // y; everywhere else its reach shrinks (a circle), while the wall's reach stays a constant + // puckRadius regardless of x, so without this the wall can fire first at some x-offsets + // instead of the post's own round shape. Off whenever skipGoalGap is on: that wall sits well + // behind every post, and a pure-x gap there is exactly the hole a puck used to escape through. + const nearPostX = !skipGoalGap && board.goalPosts.some((post) => Math.abs(x - post.x) < r + post.radius) + if (!postHit && y - r < outerTop && (skipGoalGap || (!centerInGoalX && !nearPostX))) { + y = outerTop + r + vy = Math.abs(vy) * WALL_RESTITUTION + bounced = true + } + + if (!postHit && y + r > outerBottom && (skipGoalGap || (!centerInGoalX && !nearPostX))) { + y = outerBottom - r + vy = -Math.abs(vy) * WALL_RESTITUTION + bounced = true + } + } + + // A one-way door at the goal mouth. Open front-to-back - entering (whether it scores on a + // fresh crossing or continues past into the pocket) is always unobstructed - but solid + // back-to-front within the goal's own x-range specifically: a puck that's already behind the + // line and drifts into the goal's x-range (e.g. wandering over from the wider pocket via a + // corner) can't just pass back out through the same slot it would otherwise score through. + // Clamped a hair past the exact line (not flush to it) - landing exactly at y0+r === board.y + // would make wasBehindTop's own strict "<" comparison false on the VERY NEXT step, which let a + // puck that just bounced off this door immediately satisfy the score condition's !wasBehindTop + // check while still moving in the wrong direction. + const DOOR_CLAMP_MARGIN = 1 + if (centerInGoalX && wasBehindTop && y + r >= board.y) { + y = board.y - r - DOOR_CLAMP_MARGIN + vy = -Math.abs(vy) * WALL_RESTITUTION + bounced = true + goalDoorHit = 'top' + } + if (centerInGoalX && wasBehindBottom && y - r <= board.y + board.height) { + y = board.y + board.height + r + DOOR_CLAMP_MARGIN + vy = Math.abs(vy) * WALL_RESTITUTION + bounced = true + goalDoorHit = 'bottom' + } + + // Same check as the early return above, run again here: the one-way door just above can push a + // puck that was behind the line back toward (or past) it, which the early check couldn't have + // seen yet. + if (centerInGoalX && y + r < board.y && !wasBehindTop) return { x, y, vx, vy, scored: 1, stopped: false, bounced: false, goalDoorHit: null } + if (centerInGoalX && y - r > board.y + board.height && !wasBehindBottom) return { x, y, vx, vy, scored: 2, stopped: false, bounced: false, goalDoorHit: null } + + if (!corner) { + if (x - r < board.x) { + x = board.x + r + vx = Math.abs(vx) * WALL_RESTITUTION + bounced = true + } + if (x + r > board.x + board.width) { + x = board.x + board.width - r + vx = -Math.abs(vx) * WALL_RESTITUTION + bounced = true + } + } + + // Always empty (an open rink has no center divider) - kept so a future variant could add + // obstacles without touching this loop, at zero runtime cost today. + for (const obs of board.obstacles) { + const col = circleVsRect({ position: { x, y }, radius: r }, obs) + if (col.hit) { + x += col.normal.x * col.depth + y += col.normal.y * col.depth + const reflected = reflectOffNormal({ x: vx, y: vy }, col.normal, WALL_RESTITUTION) + vx = reflected.x + vy = reflected.y + bounced = true + } + } + + const stopped = Math.abs(vx) < STOP_THRESHOLD && Math.abs(vy) < STOP_THRESHOLD + return { x, y, vx, vy, scored: null, stopped, bounced, goalDoorHit } +} + +// How far a single stepPhysicsOnce call is allowed to move the puck before sub-stepping kicks in +// - tightened well below the smallest features these checks need to catch cleanly (a goalpost's +// radius) rather than bounded by puckRadius itself, so a trajectory drifting almost exactly along +// a goal-line boundary doesn't get sampled a hair on the wrong side of it. A fast paddle strike is +// exactly the kind of large single-frame velocity change this protects against, so it's MORE +// load-bearing here than in BoxHockey, where the puck only ever got a fresh velocity from a +// discrete drag-release, not a live per-frame paddle contact. +const SUBSTEP_MAX_DISPLACEMENT_RATIO = 0.15 +const SUBSTEP_MAX_COUNT = 30 + +export function stepPhysics(puck: PuckState, board: BoardLayout, dt = 1, friction: number = FRICTION_LEVELS.medium): PhysicsResult { + const speed = Math.hypot(puck.vx, puck.vy) + const safeMaxDisplacement = board.puckRadius * SUBSTEP_MAX_DISPLACEMENT_RATIO + const substeps = speed * dt > safeMaxDisplacement ? Math.min(SUBSTEP_MAX_COUNT, Math.ceil((speed * dt) / safeMaxDisplacement)) : 1 + if (substeps <= 1) return stepPhysicsOnce(puck, board, dt, friction) + + const subDt = dt / substeps + let current = puck + let anyBounced = false + let lastGoalDoorHit: 'top' | 'bottom' | null = null + for (let i = 0; i < substeps; i++) { + const result = stepPhysicsOnce(current, board, subDt, friction) + anyBounced = anyBounced || result.bounced + lastGoalDoorHit = result.goalDoorHit ?? lastGoalDoorHit + // A score or a stop is final - stop sub-stepping immediately rather than continuing to + // integrate a puck that's already been removed from play (or come to rest) this frame. + if (result.scored !== null || result.stopped) return { ...result, bounced: anyBounced, goalDoorHit: lastGoalDoorHit } + current = { x: result.x, y: result.y, vx: result.vx, vy: result.vy } + } + return { ...current, scored: null, stopped: false, bounced: anyBounced, goalDoorHit: lastGoalDoorHit } +} + +// A paddle's velocity, for collision purposes, is derived from how far its committed position +// moved THIS tick divided by dt - not from a gesture's own cumulative translation delta (which is +// relative to touch-down, not per-frame). Called once per player per physics tick (see +// useGameState.ts's TICK handler); the caller is expected to smooth this over a few ticks (see +// PADDLE_VELOCITY_SMOOTHING_FRAMES) before using it, so a single jittery touch sample doesn't +// read as a wild instantaneous velocity. +export function computePaddleVelocity(prevX: number, prevY: number, x: number, y: number, dt: number): { vx: number; vy: number } { + if (dt <= 0) return { vx: 0, vy: 0 } + return { vx: (x - prevX) / dt, vy: (y - prevY) / dt } +} + +export function clampPaddleSpeed(vx: number, vy: number, maxSpeed: number = MAX_PADDLE_SPEED): { vx: number; vy: number } { + const speed = Math.hypot(vx, vy) + if (speed <= maxSpeed || speed === 0) return { vx, vy } + const scale = maxSpeed / speed + return { vx: vx * scale, vy: vy * scale } +} + +// Segment-vs-circle sweep - the FALLBACK path applyPaddleCollision below reaches for only when a +// plain endpoint check (puck vs. the paddle's position THIS tick) finds no overlap. A paddle's +// committed position can jump from p0 (last tick) to p1 (this tick) by more than a combined radius +// in a single tick - a fast swipe, or just a dropped frame's larger dt - clean over the puck +// without either endpoint ever overlapping it. Closed-form line-circle intersection (quadratic in +// t along the p0->p1 parametrization) rather than sampling, same as every other shape check in this +// file solves its geometry exactly instead of approximating it. Returns the paddle's own position +// at the first instant its circle would touch the (for this single sweep, stationary) puck, or +// null if the straight-line path never comes within combinedRadius at all. +function sweptCircleFirstContact(p0: { x: number; y: number }, p1: { x: number; y: number }, center: { x: number; y: number }, combinedRadius: number): { x: number; y: number } | null { + const dx = p1.x - p0.x + const dy = p1.y - p0.y + const a = dx * dx + dy * dy + if (a < 0.0001) return null // paddle didn't move this tick - no path to sweep + + const fx = p0.x - center.x + const fy = p0.y - center.y + const b = 2 * (fx * dx + fy * dy) + const c = fx * fx + fy * fy - combinedRadius * combinedRadius + + const discriminant = b * b - 4 * a * c + if (discriminant < 0) return null // path never comes within combinedRadius at all + + const sqrtDisc = Math.sqrt(discriminant) + const tEnter = (-b - sqrtDisc) / (2 * a) + const tExit = (-b + sqrtDisc) / (2 * a) + if (tExit < 0 || tEnter > 1) return null // the in-range interval falls entirely outside this tick's [0,1] + + const t = Math.min(1, Math.max(0, tEnter)) + return { x: p0.x + dx * t, y: p0.y + dy * t } +} + +// Live paddle-vs-puck collision - BoxHockey's paddle is purely decorative and never touches the +// physics engine at all; this is the one genuinely new collision primitive AirHockey needs. +// +// The paddle is treated as INFINITE MASS: its own vx/vy are never read back out of this function +// or modified by it - the live gesture in TouchInputLayer (or the AI) is the only thing that ever +// moves it, so a puck bouncing off it must never perturb next frame's finger-tracked position. +// @tastic/physics's resolveCircleCollision was considered and rejected for this: it's a +// SYMMETRIC, equal-mass exchange (verified by reading its source directly) that would also alter +// the paddle's own velocity - exactly the corruption this function exists to avoid. +// +// Instead this is the standard infinite-mass-vs-finite-mass elastic collision via a reference- +// frame shift, built from two already-exported, already-tested @tastic/physics primitives and no +// new physics math beyond the shift itself: transform into the paddle's own reference frame +// (subtract its velocity), reflect the puck's now-RELATIVE velocity off the contact normal +// exactly like reflectOffNormal already does against a stationary wall, then transform back (add +// the paddle's velocity back in). A fast-moving mallet imparts real velocity onto the puck (like +// a real strike, off a puck's own current motion); a paddle held still degenerates to an ordinary +// elastic wall bounce. +// +// `prevPaddle` is that same paddle's committed position from the START of this tick (before this +// tick's commitPaddle call moved it to `paddle`) - see sweptCircleFirstContact above for why a +// same-tick swept check, not just the two endpoints, is what actually catches a fast swipe. +export function applyPaddleCollision(puck: PuckState, prevPaddle: PaddleState, paddle: PaddleState, puckRadius: number, paddleRadius: number, restitution: number = PADDLE_COLLISION_RESTITUTION): PuckState { + const col = circleVsCircle({ position: { x: puck.x, y: puck.y }, radius: puckRadius }, { position: { x: paddle.x, y: paddle.y }, radius: paddleRadius }) + + let normal = col.normal + let depth = col.depth + + if (!col.hit) { + const contact = sweptCircleFirstContact(prevPaddle, paddle, { x: puck.x, y: puck.y }, puckRadius + paddleRadius) + if (!contact) return puck + + const dx = puck.x - contact.x + const dy = puck.y - contact.y + const dist = Math.hypot(dx, dy) + // dist is combinedRadius by construction (that's the point on the path sweptCircleFirstContact + // solves for), modulo floating point - depth is correctly ~0 at this first instant of contact, + // there's nothing yet to push the puck out of. + normal = dist < 0.001 ? { x: 0, y: -1 } : { x: dx / dist, y: dy / dist } + depth = 0 + } + + const x = puck.x + normal.x * depth + const y = puck.y + normal.y * depth + + const relativeVelocity = { x: puck.vx - paddle.vx, y: puck.vy - paddle.vy } + const reflected = reflectOffNormal(relativeVelocity, normal, restitution) + + return { x, y, vx: reflected.x + paddle.vx, vy: reflected.y + paddle.vy } +} diff --git a/src/games/airhockey/utils/seatResultSpots.ts b/src/games/airhockey/utils/seatResultSpots.ts new file mode 100644 index 0000000..798dfa5 --- /dev/null +++ b/src/games/airhockey/utils/seatResultSpots.ts @@ -0,0 +1,16 @@ +import type { Player } from '@/games/airhockey/types' +import type { SeatToastSpot } from '@/shared/SeatToasters' + +// One 2 Player seat's half of the screen and the angle it's turned to face them: the shape the +// shared result cards and unlock toasts take (SeatToasters' SeatToastSpot). +export type SeatSpot = SeatToastSpot + +/** Where each 2 Player seat's Game Over card goes, from the rink's rotation (game.tsx's `rotationDeg`). An upright rink (every 2 Player + * match on a phone, face to face however it's held) has player 1 at the bottom and player 2 at the top, each card facing its own end. A + * turned one (a browser window wider than tall) has player 1 at the end the rink's bottom turned to (left at 90, right at -90), both + * upright. A computer screen keeps both upright, since its players sit side by side whatever its shape. */ +export function seatResultSpots(rinkRotation: 0 | 90 | -90, desktopWeb: boolean): Record { + if (rinkRotation === 90) return { 1: { rotation: 0, zone: 'left' }, 2: { rotation: 0, zone: 'right' } } + if (rinkRotation === -90) return { 1: { rotation: 0, zone: 'right' }, 2: { rotation: 0, zone: 'left' } } + return { 1: { rotation: 0, zone: 'bottom' }, 2: { rotation: desktopWeb ? 0 : 180, zone: 'top' } } +} diff --git a/src/games/airhockey/utils/statsEngine.ts b/src/games/airhockey/utils/statsEngine.ts new file mode 100644 index 0000000..0b2aefb --- /dev/null +++ b/src/games/airhockey/utils/statsEngine.ts @@ -0,0 +1,109 @@ +import { applyDayPlayed, applyResult, applyResultToBucket, applyWinStreak, resultFor, RoundResult } from '@tastic/achievements' +import { Profile } from '@tastic/profile' + +import { AiDifficulty, GameMode, Player, ProfileStats, StatsState } from '@/games/airhockey/types' +import { DEFAULT_PROFILE_STATS } from '@/games/airhockey/utils/statsValidation' +import { type ProfileRanking, rankProfiles } from '@/shared/PlayerRankings' + +// One finished match, as the stats layer sees it - built by game.tsx the instant phase flips to +// 'gameOver', entirely from state that's already on screen. +export interface MatchOutcome { + winner: Player + // Final score, seat-indexed: [seat 1, seat 2]. + scores: [number, number] + // One-way goal-door hits this match (see GameState's goalDoorHitCount). + goalDoorHits: number +} + +export interface MatchContext { + gameMode: GameMode + aiDifficulty: AiDifficulty + // Seat -> profile id, present only for seats with a saved profile selected. vsCpu's CPU seat + // never has one, so the loop below only ever bumps the human's. + profileIds?: Partial> +} + +// AirHockey has no draws - a match ends the instant one side reaches scoreToWin - so this only ever +// returns 'win' or 'loss'. Routed through resultFor so the rule lives in one place. +function seatResult(outcome: MatchOutcome, seat: Player): RoundResult { + return resultFor(outcome.winner, seat) +} + +// `deviceWide` is not cosmetic: twoPlayer.p1Wins/p2Wins mean two different things. Device-wide +// they're a SEAT tally ("how often has seat 1 won"), the only well-defined framing on a shared +// device. In a profile's bucket they're that PERSON's own wins split by the seat they earned them +// from, so p1Wins + p2Wins is always their own win count regardless of where they sat. +function bumpForSeat(prev: ProfileStats, outcome: MatchOutcome, context: MatchContext, seat: Player, now: Date, deviceWide: boolean): ProfileStats { + const result = seatResult(outcome, seat) + const won = result === 'win' + const scored = outcome.scores[seat - 1] + const conceded = outcome.scores[seat === 1 ? 1 : 0] + + const vsCpu = + context.gameMode === 'vsCpu' + ? { + ...applyResult(prev.vsCpu, result), + byDifficulty: applyResultToBucket(prev.vsCpu.byDifficulty, context.aiDifficulty, result) as Record>, + ...applyWinStreak(prev.vsCpu, result) + } + : prev.vsCpu + + const twoPlayer = + context.gameMode === 'vsCpu' + ? prev.twoPlayer + : { + played: prev.twoPlayer.played + 1, + p1Wins: prev.twoPlayer.p1Wins + (deviceWide ? (outcome.winner === 1 ? 1 : 0) : seat === 1 && won ? 1 : 0), + p2Wins: prev.twoPlayer.p2Wins + (deviceWide ? (outcome.winner === 2 ? 1 : 0) : seat === 2 && won ? 1 : 0) + } + + return { + vsCpu, + twoPlayer, + goals: { scored: prev.goals.scored + scored, conceded: prev.goals.conceded + conceded }, + shutouts: prev.shutouts + (won && conceded === 0 ? 1 : 0), + bestWinMargin: won ? Math.max(prev.bestWinMargin, scored - conceded) : prev.bestWinMargin, + goalDoorHits: prev.goalDoorHits + outcome.goalDoorHits, + ...applyDayPlayed(prev, now) + } +} + +// `now` defaults to the real clock - overridable so tests can drive day boundaries directly. +export function applyMatchOutcome(prev: StatsState, outcome: MatchOutcome, context: MatchContext, now: Date = new Date()): StatsState { + const isFirstGameEver = prev.vsCpu.played + prev.twoPlayer.played === 0 + const firstGameResult = isFirstGameEver ? seatResult(outcome, 1) : prev.firstGameResult + const base = bumpForSeat(prev, outcome, context, 1, now, true) + + let profiles = prev.profiles + if (context.profileIds) { + for (const seat of [1, 2] as Player[]) { + const profileId = context.profileIds[seat] + if (!profileId) continue + profiles = { ...profiles, [profileId]: bumpForSeat(profiles[profileId] ?? DEFAULT_PROFILE_STATS, outcome, context, seat, now, false) } + } + } + + return { ...base, profiles, firstGameResult } +} + +export function getOverallTotals(stats: ProfileStats): { played: number; wins: number; losses: number } { + const twoPlayerWins = stats.twoPlayer.p1Wins + stats.twoPlayer.p2Wins + return { + played: stats.vsCpu.played + stats.twoPlayer.played, + wins: stats.vsCpu.wins + twoPlayerWins, + losses: stats.vsCpu.losses + (stats.twoPlayer.played - twoPlayerWins) + } +} + +// Synthesizes a full StatsState-shaped view from one profile's bucket, so the same predicates +// evaluate it unchanged. firstGameResult is never read through this view - the one achievement +// that touches it is scope:'device'. +export function getProfileStatsView(profileStats: ProfileStats): StatsState { + return { ...profileStats, profiles: {}, firstGameResult: null } +} + +// Sorted by wins then win rate, both descending - a profile with nothing recorded still appears at +// played: 0, so a freshly created one shows up immediately. +export function getProfileRankings(profiles: Profile[], profileStats: Record): ProfileRanking[] { + return rankProfiles(profiles, (profileId) => getOverallTotals(profileStats[profileId] ?? DEFAULT_PROFILE_STATS)) +} diff --git a/src/games/airhockey/utils/statsValidation.ts b/src/games/airhockey/utils/statsValidation.ts new file mode 100644 index 0000000..3b81df2 --- /dev/null +++ b/src/games/airhockey/utils/statsValidation.ts @@ -0,0 +1,96 @@ +import { DEFAULT_DAY_STREAK, DEFAULT_OUTCOME_RECORD, DEFAULT_WIN_STREAK } from '@tastic/achievements' + +import { AiDifficulty, GoalTotals, MatchRecord, ProfileStats, StatsState, TwoPlayerStats, VsCpuStats } from '@/games/airhockey/types' + +// Kept out of hooks/useGameStats.tsx so it's testable without dragging in +// @react-native-async-storage/async-storage, which throws at import time under Jest's plain Node +// environment. + +// A zeroed vsCpu bucket: an empty win/loss/draw record, an empty per-difficulty breakdown, and a +// zeroed win streak. Built from @tastic/achievements' own defaults rather than restating the +// literals, so this can't drift from the shapes those helpers actually produce. A function, not a +// constant - DEFAULT_PROFILE_STATS and DEFAULT_STATS below each need their own independent object, +// not two references to the same one (every consumer only ever spreads/reads these today, but a +// shared reference is a standing invitation for a future in-place mutation to corrupt both at once). +function createDefaultVsCpu(): VsCpuStats { + return { + ...DEFAULT_OUTCOME_RECORD, + byDifficulty: { + easy: { ...DEFAULT_OUTCOME_RECORD }, + medium: { ...DEFAULT_OUTCOME_RECORD }, + hard: { ...DEFAULT_OUTCOME_RECORD } + }, + ...DEFAULT_WIN_STREAK + } +} + +function createDefaultTwoPlayer(): TwoPlayerStats { + return { played: 0, p1Wins: 0, p2Wins: 0 } +} + +function createDefaultGoals(): GoalTotals { + return { scored: 0, conceded: 0 } +} + +// A fresh profile's starting bucket - same zeroed vsCpu/twoPlayer/goals shape as DEFAULT_STATS' own +// top-level fields, since ProfileStats mirrors StatsState's decomposable parts (see types/index.ts). +export const DEFAULT_PROFILE_STATS: ProfileStats = { + vsCpu: createDefaultVsCpu(), + twoPlayer: createDefaultTwoPlayer(), + goals: createDefaultGoals(), + shutouts: 0, + bestWinMargin: 0, + goalDoorHits: 0, + ...DEFAULT_DAY_STREAK +} + +// vsCpu/twoPlayer/goals are re-created here rather than spread straight from DEFAULT_PROFILE_STATS, +// specifically so the two don't end up aliasing the same nested objects - see createDefaultVsCpu's +// own doc. +export const DEFAULT_STATS: StatsState = { + ...DEFAULT_PROFILE_STATS, + vsCpu: createDefaultVsCpu(), + twoPlayer: createDefaultTwoPlayer(), + goals: createDefaultGoals(), + profiles: {}, + firstGameResult: null +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function isValidMatchRecord(value: unknown): value is MatchRecord { + if (!isRecord(value)) return false + return typeof value.played === 'number' && typeof value.wins === 'number' && typeof value.losses === 'number' && typeof value.draws === 'number' +} + +function isValidVsCpu(value: unknown): value is VsCpuStats { + if (!isValidMatchRecord(value)) return false + const v = value as unknown as Partial + const byDifficulty = v.byDifficulty as Partial> | undefined + return typeof v.currentWinStreak === 'number' && typeof v.bestWinStreak === 'number' && isRecord(byDifficulty) && isValidMatchRecord(byDifficulty.easy) && isValidMatchRecord(byDifficulty.medium) && isValidMatchRecord(byDifficulty.hard) +} + +function isValidTwoPlayer(value: unknown): value is TwoPlayerStats { + if (!isRecord(value)) return false + return typeof value.played === 'number' && typeof value.p1Wins === 'number' && typeof value.p2Wins === 'number' +} + +function isValidGoals(value: unknown): value is GoalTotals { + if (!isRecord(value)) return false + return typeof value.scored === 'number' && typeof value.conceded === 'number' +} + +function isValidProfileStats(value: unknown): value is ProfileStats { + if (!isRecord(value)) return false + const v = value as Partial + return isValidVsCpu(v.vsCpu) && isValidTwoPlayer(v.twoPlayer) && isValidGoals(v.goals) && typeof v.shutouts === 'number' && typeof v.bestWinMargin === 'number' && typeof v.goalDoorHits === 'number' && typeof v.distinctDaysPlayed === 'number' && typeof v.currentDayStreak === 'number' && typeof v.bestDayStreak === 'number' && (v.lastPlayedDate === null || typeof v.lastPlayedDate === 'string') +} + +export function isValidStats(value: unknown): value is StatsState { + if (!isValidProfileStats(value)) return false + const v = value as Partial + const profilesOk = v.profiles === undefined || (isRecord(v.profiles) && Object.values(v.profiles).every(isValidProfileStats)) + return profilesOk && (v.firstGameResult === null || v.firstGameResult === 'win' || v.firstGameResult === 'loss' || v.firstGameResult === 'draw') +} diff --git a/src/app/game.tsx b/src/games/boxhockey/PlayScreen.tsx similarity index 95% rename from src/app/game.tsx rename to src/games/boxhockey/PlayScreen.tsx index 1803a6e..9b8e022 100644 --- a/src/app/game.tsx +++ b/src/games/boxhockey/PlayScreen.tsx @@ -12,29 +12,29 @@ import { Icon, Text } from 'react-native-paper' import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated' import { type EdgeInsets, useSafeAreaInsets } from 'react-native-safe-area-context' import { scheduleOnRN } from 'react-native-worklets' -import { useDispatch, useSelector } from 'react-redux' - -import Board from '@/components/BoardHost' -import { SeatResultCards } from '@/components/SeatResultCards' -import { SeatToasters } from '@/components/SeatToasters' -import { SettingsDialog } from '@/components/SettingsDialog' -import { COIN_FLIP_TOTAL_MS, DESKTOP_WEB_MIN_WIDTH, FRICTION_LEVELS, GOAL_LINE_STROKE_WIDTH, INTERCEPT_SPEED_THRESHOLDS, MAX_FLICK_GESTURE_MS, MIN_SHOOT_DRAG, SHOT_PREVIEW_LENGTH_RATIOS, STRIKE_DURATION_MS } from '@/constants/game' -import { useAIOpponent } from '@/hooks/useAIOpponent' -import { useFaceoff } from '@/hooks/useFaceoff' -import { useGameSound } from '@/hooks/useGameSound' -import { canTripleTap, passesFlickShotGate, useGameState } from '@/hooks/useGameState' -import { useGameStats } from '@/hooks/useGameStats' -import { useProfiles } from '@/hooks/useProfiles' -import { useSettings } from '@/hooks/useSettings' -import { useTripleTap } from '@/hooks/useTripleTap' -import { SEAT_TOAST_STACKS, useUnlockToasts } from '@/hooks/useUnlockToasts' -import { liveplayActions } from '@/redux/liveplaySlice' -import { DEFAULT_SETTINGS } from '@/redux/settingsSlice' -import type { AppDispatch, RootState } from '@/redux/store' -import { BoardLayout, GamePhase, Player, Settings } from '@/types' -import { computeBoardLayout, faceoffZoneCenter, faceoffZoneCenters, faceoffZoneRadius } from '@/utils/board' -import { safeBack } from '@/utils/navigation' -import { seatToastSpots } from '@/utils/seatToastSpots' +import { useDispatch } from 'react-redux' +import type { Action, Dispatch } from 'redux' + +import Board from '@/games/boxhockey/components/BoardHost' +import { COIN_FLIP_TOTAL_MS, DESKTOP_WEB_MIN_WIDTH, FRICTION_LEVELS, GOAL_LINE_STROKE_WIDTH, INTERCEPT_SPEED_THRESHOLDS, MAX_FLICK_GESTURE_MS, MIN_SHOOT_DRAG, SHOT_PREVIEW_LENGTH_RATIOS, STRIKE_DURATION_MS } from '@/games/boxhockey/constants/game' +import { useAIOpponent } from '@/games/boxhockey/hooks/useAIOpponent' +import { useFaceoff } from '@/games/boxhockey/hooks/useFaceoff' +import { canTripleTap, passesFlickShotGate, useGameState } from '@/games/boxhockey/hooks/useGameState' +import { useGameStats } from '@/games/boxhockey/hooks/useGameStats' +import { useTripleTap } from '@/games/boxhockey/hooks/useTripleTap' +import { DEFAULT_SETTINGS } from '@/games/boxhockey/redux/settingsSlice' +import { BoardLayout, GameMode, GamePhase, Player } from '@/games/boxhockey/types' +import { computeBoardLayout, faceoffZoneCenter, faceoffZoneCenters, faceoffZoneRadius } from '@/games/boxhockey/utils/board' +import { seatToastSpots } from '@/games/boxhockey/utils/seatToastSpots' +import { SeatResultCards } from '@/shared/SeatResultCards' +import { SeatToasters } from '@/shared/SeatToasters' +import { useGameSound } from '@/shared/useGameSound' +import { SEAT_TOAST_STACKS, useUnlockToasts } from '@/shared/useUnlockToasts' +import { SettingsDialog } from '@/shell/components/SettingsDialog' +import { useActiveRound } from '@/shell/hooks/useActiveRound' +import { useProfiles } from '@/shell/hooks/useProfiles' +import { safeBack } from '@/shell/navigation' +import { liveplayActions } from '@/shell/redux/liveplaySlice' const MONO_FONT = Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' }) @@ -65,22 +65,15 @@ export default function GameScreen() { // tilt is still tracked (see MatchOverlays below), just isolated to a sibling of the board/gesture // tree so a live accelerometer reading never cascades a re-render into it - that rotation only // ever reaches the dialogs floating on top, so they stay legible however the phone is held. - // Committed once by /loadout when every human player readied up (liveplay.activeRoundSettings, see liveplaySlice.ts) - - // falls back to defaults for a direct/deep link to this screen, which never went through - // /loadout to commit anything. orientationMode is a /loadout-only layout setting (see - // types/index.ts) and never reaches here. - // settings/setSettings (the persisted "next round" defaults, not this round's own locked-in - // activeRoundSettings above) are only needed to hand off to MatchOverlays' own SettingsDialog - // below - its Lock Orientation/Edge Guard toggles edit the persisted defaults, not this - // already-underway round's own snapshot. - const { settings, setSettings } = useSettings() - const activeRoundSettings = useSelector((state: RootState) => state.liveplay.activeRoundSettings) - const dispatch = useDispatch() + // Committed once by the loadout when every human player readied up (see the shell's + // liveplaySlice.ts and useActiveRound) - falls back to Box Hockey's defaults against the CPU for a + // direct link to this screen, which never went through the loadout to commit anything. + const { mode, settings: roundSettings } = useActiveRound('boxhockey', DEFAULT_SETTINGS) + const dispatch = useDispatch>() const { recordMatchOutcome } = useGameStats() const { lastSelected, profiles } = useProfiles() const { clear: clearToasts } = useToast() const showUnlockToasts = useUnlockToasts() - const roundSettings = activeRoundSettings ?? DEFAULT_SETTINGS const scoreToWin = roundSettings.scoreToWin const layout = roundSettings.boardVariant const tipoffMode = roundSettings.tipoffMode @@ -90,7 +83,9 @@ export default function GameScreen() { const controlScheme = roundSettings.controlScheme // Only P2 can be CPU-controlled for now - pass-and-play stays the default, single-player just // swaps who takes P2's turns (see useAIOpponent, which plays the exact same gesture contract). - const aiPlayer: Player | null = roundSettings.gameMode === 'vsCpu' ? 2 : null + const aiPlayer: Player | null = mode === 'vsCpu' ? 2 : null + // Box Hockey's stats keep the mode as BoxHockey 1.0 recorded it. + const gameMode: GameMode = mode === 'twoPlayer' ? 'twoPlayer' : 'vsCpu' const aiDifficulty = roundSettings.aiDifficulty const { width, height } = useSettledWindowDimensions() @@ -269,15 +264,15 @@ export default function GameScreen() { // feedback for a given event always stay co-located and in sync. Ref-mirrored the same way // vibration is above, not called directly, so the memoized gesture callbacks further down don't // need these in their dependency arrays. - const playSelect = useGameSound(require('../../assets/sounds/select.wav'), { poolSize: 8 }) - const playBounce = useGameSound(require('../../assets/sounds/bounce.wav')) - const playWellAttract = useGameSound(require('../../assets/sounds/wellAttract.wav')) - const playWellRepel = useGameSound(require('../../assets/sounds/wellRepel.wav')) - const playFaceoffTick = useGameSound(require('../../assets/sounds/faceoffTick.wav')) - const playFaceoffGo = useGameSound(require('../../assets/sounds/faceoffGo.wav')) - const playFaceoffResolved = useGameSound(require('../../assets/sounds/faceoffResolved.wav')) - const playGoal = useGameSound(require('../../assets/sounds/goal.wav')) - const playWin = useGameSound(require('../../assets/sounds/win.wav')) + const playSelect = useGameSound(require('../../../assets/sounds/select.wav'), { poolSize: 8 }) + const playBounce = useGameSound(require('../../../assets/sounds/bounce.wav')) + const playWellAttract = useGameSound(require('./sounds/wellAttract.wav')) + const playWellRepel = useGameSound(require('./sounds/wellRepel.wav')) + const playFaceoffTick = useGameSound(require('./sounds/faceoffTick.wav')) + const playFaceoffGo = useGameSound(require('./sounds/faceoffGo.wav')) + const playFaceoffResolved = useGameSound(require('./sounds/faceoffResolved.wav')) + const playGoal = useGameSound(require('../../../assets/sounds/goal.wav')) + const playWin = useGameSound(require('../../../assets/sounds/win.wav')) const soundRef = useRef({ playSelect, playBounce, playWellAttract, playWellRepel, playFaceoffTick, playFaceoffGo, playFaceoffResolved, playGoal, playWin }) useEffect(() => { soundRef.current = { playSelect, playBounce, playWellAttract, playWellRepel, playFaceoffTick, playFaceoffGo, playFaceoffResolved, playGoal, playWin } @@ -501,7 +496,7 @@ export default function GameScreen() { // prevPhaseRef guards against a re-run from any other dep changing identity. const matchWinner: Player = scores[0] >= scoreToWin ? 1 : 2 const profileIds = { 1: lastSelected[1] ?? undefined, 2: aiPlayer === null ? (lastSelected[2] ?? undefined) : undefined } - const result = recordMatchOutcome({ winner: matchWinner, scores: [scores[0], scores[1]] }, { gameMode: roundSettings.gameMode, aiDifficulty, boardVariant: layout, profileIds }) + const result = recordMatchOutcome({ winner: matchWinner, scores: [scores[0], scores[1]] }, { gameMode, aiDifficulty, boardVariant: layout, profileIds }) if (aiPlayer === null) { // Genuine two-seat pass-and-play: each seat's unlocks are toasts on that seat's own stack, in // their half of the screen and turned to face them (MatchOverlays' SeatToasters), so a player @@ -528,7 +523,7 @@ export default function GameScreen() { setCoinFlipStarted(false) } prevPhaseRef.current = phase - }, [phase, scores, scoreToWin, aiPlayer, aiDifficulty, layout, lastSelected, roundSettings.gameMode, recordMatchOutcome, profiles, showUnlockToasts]) + }, [phase, scores, scoreToWin, aiPlayer, aiDifficulty, layout, lastSelected, gameMode, recordMatchOutcome, profiles, showUnlockToasts]) // ─── Gesture bridge ─────────────────────────────────────────────────────── // isUpsideDown-conditional reflection lives in these stable JS callbacks, not in the Gesture.* @@ -1029,7 +1024,7 @@ export default function GameScreen() { {/* Back + settings chips moved into MatchOverlays below (see its own comment) - they need the same live rotation-in-place treatment as its dialogs, which only that component's isolated tilt subscription provides. */} - resetGame(board)} confirmVisible={confirmVisible} cancelBack={cancelBack} settingsOpen={settingsOpen} onSettingsDismiss={() => setSettingsOpen(false)} onSettingsOpen={() => setSettingsOpen(true)} settings={settings} setSettings={setSettings} board={board} insets={insets} boardAreaMarginTop={boardAreaMarginTop} fg={fg} cornerButtonBg={cornerButtonBg} desktopWeb={isDesktopWeb} onBackPress={requestBack} backgroundPaused={backgroundPaused} onResume={resume} /> + resetGame(board)} confirmVisible={confirmVisible} cancelBack={cancelBack} settingsOpen={settingsOpen} onSettingsDismiss={() => setSettingsOpen(false)} onSettingsOpen={() => setSettingsOpen(true)} board={board} insets={insets} boardAreaMarginTop={boardAreaMarginTop} fg={fg} cornerButtonBg={cornerButtonBg} desktopWeb={isDesktopWeb} onBackPress={requestBack} backgroundPaused={backgroundPaused} onResume={resume} /> ) } @@ -1053,11 +1048,6 @@ interface MatchOverlaysProps { // Opens the settings dialog - lives here, not just onSettingsDismiss, now that the chip button // that triggers it (see the chip JSX below) is rendered here too, alongside the dialog itself. onSettingsOpen: () => void - // The persisted "next round" defaults - passed through from GameScreen's own useSettings() - // for its own SettingsDialog below (its Lock Orientation/Edge Guard toggles edit these, not the - // round's own locked-in activeRoundSettings). - settings: Settings - setSettings: (update: Partial) => void // GameScreen's own board layout/insets - needed here only for the back/settings chips' position // (see their own comment below), not for anything this component already renders. board: BoardLayout @@ -1088,7 +1078,7 @@ interface MatchOverlaysProps { // unrotated hit-zones already use. While Lock Orientation is on, this reading is frozen app-wide // (see _layout.tsx's OrientationProvider), so the overlays hold the locked angle along with the // board's own flip above. -function MatchOverlays({ phase, scores, winner, loser, playerColor, playerOnColor, twoPlayer, onResetGame, confirmVisible, cancelBack, settingsOpen, onSettingsDismiss, onSettingsOpen, settings, setSettings, board, insets, boardAreaMarginTop, fg, cornerButtonBg, desktopWeb, onBackPress, backgroundPaused, onResume }: MatchOverlaysProps) { +function MatchOverlays({ phase, scores, winner, loser, playerColor, playerOnColor, twoPlayer, onResetGame, confirmVisible, cancelBack, settingsOpen, onSettingsDismiss, onSettingsOpen, board, insets, boardAreaMarginTop, fg, cornerButtonBg, desktopWeb, onBackPress, backgroundPaused, onResume }: MatchOverlaysProps) { const rotation = useRotation() // The same live reading, whole, for where 2 Player's unlock toasts go (seatToastSpots). const orientation = useOrientationState() @@ -1226,7 +1216,7 @@ function MatchOverlays({ phase, scores, winner, loser, playerColor, playerOnColo - + {/* 2 Player's unlock toasts, a Toaster per seat turned to face it (see GameScreen's gameOver effect); every other toast stays on the app's main Toaster. */} diff --git a/src/games/boxhockey/StatsSections.tsx b/src/games/boxhockey/StatsSections.tsx new file mode 100644 index 0000000..499f734 --- /dev/null +++ b/src/games/boxhockey/StatsSections.tsx @@ -0,0 +1,87 @@ +import { getAchievementCatalogRows } from '@tastic/achievements' +import { AchievementCatalogSection, ActivityStatSection, StatRow, StatSection } from '@tastic/hud' +import { useMemo } from 'react' + +import { ACHIEVEMENT_CATALOG } from '@/games/boxhockey/constants/achievements' +import { useGameStats } from '@/games/boxhockey/hooks/useGameStats' +import { getOverallTotals, getProfileRankings, getProfileStatsView } from '@/games/boxhockey/utils/statsEngine' +import { DEFAULT_PROFILE_STATS } from '@/games/boxhockey/utils/statsValidation' +import { PlayerRankingsSection } from '@/shared/PlayerRankings' +import { useProfiles } from '@/shell/hooks/useProfiles' + +// The stats for one profile (a profile id) or every player (null): its own bucket viewed through the +// same StatsState shape every predicate already reads, or the device-wide stats themselves. +function useStatsView(profileId: string | null) { + const { stats } = useGameStats() + const profileBucket = profileId ? (stats.profiles[profileId] ?? DEFAULT_PROFILE_STATS) : null + return { stats, profileBucket, statsView: profileBucket ? getProfileStatsView(profileBucket) : stats } +} + +// Box Hockey's sections on the shared stats screen (shell/screens/StatsScreen.tsx). +export function StatsSections({ profileId }: { profileId: string | null }) { + const { stats, profileBucket, statsView } = useStatsView(profileId) + const { profiles } = useProfiles() + + const overall = useMemo(() => getOverallTotals(statsView), [statsView]) + const rankings = useMemo(() => getProfileRankings(profiles, stats.profiles), [profiles, stats.profiles]) + const goalDiff = statsView.goals.scored - statsView.goals.conceded + + return ( + <> + + + + + + + + + + 0 ? `+${goalDiff}` : String(goalDiff)} /> + + + + + + + + + + + {/* Everyone keeps the seat framing (whoever sat where); a selected profile switches to + that profile's own record, where p1Wins+p2Wins is always THIS profile's own win count. */} + + {profileBucket ? ( + <> + + + + + ) : ( + <> + + + + + )} + + + + {(['classic', 'gauntlet', 'gravity'] as const).map((variant) => ( + + ))} + + + {profileId === null && } + + + + ) +} + +// Box Hockey's achievements on the shared stats screen. +export function AchievementList({ profileId }: { profileId: string | null }) { + const { unlockedAchievements } = useGameStats() + const { stats, statsView } = useStatsView(profileId) + return +} diff --git a/src/components/Board.tsx b/src/games/boxhockey/components/Board.tsx similarity index 99% rename from src/components/Board.tsx rename to src/games/boxhockey/components/Board.tsx index 3a73515..9c98f45 100644 --- a/src/components/Board.tsx +++ b/src/games/boxhockey/components/Board.tsx @@ -1,17 +1,17 @@ import { useAutoPaperTheme } from '@rific/auto-paper' import { Canvas, Circle, Group, Line, Path, Rect, Skia, SkPath, vec } from '@shopify/react-native-skia' +import { computeGoalWedges } from '@tastic/physics' import { GoalDoorFlash, ScoreWedges, ZoneLine } from '@tastic/sprites/hockey' import { CelebrationRings, Disc, GoalPost, ObstacleRect } from '@tastic/sprites/shapes' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { StyleSheet, View } from 'react-native' import { cancelAnimation, Easing, interpolateColor, SharedValue, useDerivedValue, useFrameCallback, useSharedValue, withDelay, withRepeat, withSequence, withTiming } from 'react-native-reanimated' -import { COIN_FLIP_BASE_HALF_TURNS, COIN_FLIP_FADE_MS, COIN_FLIP_HOLD_MS, COIN_FLIP_MIN_SCALE_X, COIN_FLIP_SPIN_MS, COIN_FLIP_TRAVEL_MS, COLORS, CONTROL_SCHEME_SIGN, FACEOFF_COUNTDOWN_STEP_MS, FACEOFF_PRESS_RIPPLE_DURATION_MS, GOAL_BOARD_FLASH_DECAY_MS, GOAL_BOARD_FLASH_PEAK_OPACITY, GOAL_CREASE_FLASH_PEAK_OPACITY, GOAL_LINE_STROKE_WIDTH, GOAL_WEDGE_FLASH_FINAL_BLINKS, GOAL_WEDGE_FLASH_FINAL_MS, GOAL_WEDGE_FLASH_SEG_MS, GOAL_WEDGE_FLASH_STEP_MS, GOAL_WEDGE_SWEEP_BUDGET_MS, MAX_FLICK_VELOCITY, MAX_SPEED, MAX_STICK_PULLBACK_PIXELS, MIN_FLICK_VELOCITY, MIN_SHOOT_DRAG, PUCK_BURST_DURATION_MS, PUCK_BURST_MAX_DIST_RATIO, PUCK_BURST_SHARD_COUNT, PUCK_SCORE_FADE_DELAY_MS, PUCK_SCORE_FADE_MS, STRIKE_DURATION_MS, TRIPLE_TAP_SWIPE_FLASH_DURATION_MS, TRIPLE_TAP_TAPS } from '@/constants/game' -import { AimIndicator, BoardLayout, ControlScheme, FaceoffState, GamePhase, Player, PuckState, ShotPreviewLength, TripleTapState } from '@/types' -import { faceoffZoneCenter, faceoffZoneRadius } from '@/utils/board' -import { computeGoalWedges } from '@/utils/goalWedges' -import { computeAimVector, computeTrajectory, TrajectoryPoint } from '@/utils/physics' -import { stickBladeRadius } from '@/utils/stick' +import { COIN_FLIP_BASE_HALF_TURNS, COIN_FLIP_FADE_MS, COIN_FLIP_HOLD_MS, COIN_FLIP_MIN_SCALE_X, COIN_FLIP_SPIN_MS, COIN_FLIP_TRAVEL_MS, COLORS, CONTROL_SCHEME_SIGN, FACEOFF_COUNTDOWN_STEP_MS, FACEOFF_PRESS_RIPPLE_DURATION_MS, GOAL_BOARD_FLASH_DECAY_MS, GOAL_BOARD_FLASH_PEAK_OPACITY, GOAL_CREASE_FLASH_PEAK_OPACITY, GOAL_LINE_STROKE_WIDTH, GOAL_WEDGE_FLASH_FINAL_BLINKS, GOAL_WEDGE_FLASH_FINAL_MS, GOAL_WEDGE_FLASH_SEG_MS, GOAL_WEDGE_FLASH_STEP_MS, GOAL_WEDGE_SWEEP_BUDGET_MS, MAX_FLICK_VELOCITY, MAX_SPEED, MAX_STICK_PULLBACK_PIXELS, MIN_FLICK_VELOCITY, MIN_SHOOT_DRAG, PUCK_BURST_DURATION_MS, PUCK_BURST_MAX_DIST_RATIO, PUCK_BURST_SHARD_COUNT, PUCK_SCORE_FADE_DELAY_MS, PUCK_SCORE_FADE_MS, STRIKE_DURATION_MS, TRIPLE_TAP_SWIPE_FLASH_DURATION_MS, TRIPLE_TAP_TAPS } from '@/games/boxhockey/constants/game' +import { AimIndicator, BoardLayout, ControlScheme, FaceoffState, GamePhase, Player, PuckState, ShotPreviewLength, TripleTapState } from '@/games/boxhockey/types' +import { faceoffZoneCenter, faceoffZoneRadius } from '@/games/boxhockey/utils/board' +import { computeAimVector, computeTrajectory, TrajectoryPoint } from '@/games/boxhockey/utils/physics' +import { stickBladeRadius } from '@/games/boxhockey/utils/stick' import { GravityWellMarker } from './GravityWellMarker' diff --git a/src/games/boxhockey/components/BoardHost.tsx b/src/games/boxhockey/components/BoardHost.tsx new file mode 100644 index 0000000..4af679b --- /dev/null +++ b/src/games/boxhockey/components/BoardHost.tsx @@ -0,0 +1,3 @@ +// Native draws the board directly: Skia's native bindings are ready before any JS runs. The web +// version (BoardHost.web.tsx) waits for CanvasKit first. +export { default } from './Board' diff --git a/src/games/boxhockey/components/BoardHost.web.tsx b/src/games/boxhockey/components/BoardHost.web.tsx new file mode 100644 index 0000000..75ee3c5 --- /dev/null +++ b/src/games/boxhockey/components/BoardHost.web.tsx @@ -0,0 +1,4 @@ +import { lazySkia } from '@/shared/lazySkia' + +// Loads the board only once CanvasKit is ready (see lazySkia). +export default lazySkia(() => import('./Board')) diff --git a/src/components/GravityWellMarker.tsx b/src/games/boxhockey/components/GravityWellMarker.tsx similarity index 100% rename from src/components/GravityWellMarker.tsx rename to src/games/boxhockey/components/GravityWellMarker.tsx diff --git a/src/components/GuideArt.tsx b/src/games/boxhockey/components/GuideArt.tsx similarity index 98% rename from src/components/GuideArt.tsx rename to src/games/boxhockey/components/GuideArt.tsx index 372fef3..73c57cb 100644 --- a/src/components/GuideArt.tsx +++ b/src/games/boxhockey/components/GuideArt.tsx @@ -3,9 +3,9 @@ import { SwipeHint } from '@tastic/hud/guide' import { StyleSheet, View } from 'react-native' import { Icon, Text } from 'react-native-paper' -import { MONO_FONT } from '@/constants/fonts' -import { CONTROL_SCHEME_SIGN } from '@/constants/game' -import type { ControlScheme } from '@/types' +import { CONTROL_SCHEME_SIGN } from '@/games/boxhockey/constants/game' +import type { ControlScheme } from '@/games/boxhockey/types' +import { MONO_FONT } from '@/shell/fonts' // BoxHockey-specific illustrations for the how-to-play cards (see constants/guideSteps.tsx). The // generic SwipeHint lives in @tastic/hud/guide; these only make sense for this game. Plain Views and diff --git a/src/components/LoadoutSharedControls.tsx b/src/games/boxhockey/components/LoadoutSharedControls.tsx similarity index 99% rename from src/components/LoadoutSharedControls.tsx rename to src/games/boxhockey/components/LoadoutSharedControls.tsx index ad90678..f74f724 100644 --- a/src/components/LoadoutSharedControls.tsx +++ b/src/games/boxhockey/components/LoadoutSharedControls.tsx @@ -2,7 +2,7 @@ import { IconButton } from '@rific/feedback-press' import { MenuOption, PopoverHost, PopoverRotation, SectionedDropdown, usePopoverHost } from '@tastic/hud' import { StyleSheet, View } from 'react-native' -import { BoardVariant, FrictionLevel, InterceptSpeed, PuckSizeLevel, ShotPreviewLength, TipoffMode } from '@/types' +import { BoardVariant, FrictionLevel, InterceptSpeed, PuckSizeLevel, ShotPreviewLength, TipoffMode } from '@/games/boxhockey/types' interface Props { // Shared popover host - see PlayerSetupPanel's identical `host` prop. loadout.tsx always passes diff --git a/src/constants/achievements.ts b/src/games/boxhockey/constants/achievements.ts similarity index 97% rename from src/constants/achievements.ts rename to src/games/boxhockey/constants/achievements.ts index 2b8791c..c85e6ea 100644 --- a/src/constants/achievements.ts +++ b/src/games/boxhockey/constants/achievements.ts @@ -1,7 +1,7 @@ import { AchievementTier, tieredFamily } from '@tastic/achievements' -import { AchievementDefinition, AiDifficulty, StatsState } from '@/types' -import { getOverallTotals, getVariantsWon } from '@/utils/statsEngine' +import { AchievementDefinition, AiDifficulty, StatsState } from '@/games/boxhockey/types' +import { getOverallTotals, getVariantsWon } from '@/games/boxhockey/utils/statsEngine' export { ACHIEVEMENT_TIER_COLORS } from '@tastic/achievements' diff --git a/src/constants/game.ts b/src/games/boxhockey/constants/game.ts similarity index 95% rename from src/constants/game.ts rename to src/games/boxhockey/constants/game.ts index ce241bc..9eec6ca 100644 --- a/src/constants/game.ts +++ b/src/games/boxhockey/constants/game.ts @@ -1,4 +1,4 @@ -import { ControlScheme, FrictionLevel, InterceptSpeed, PuckSizeLevel, ShotPreviewLength } from '@/types' +import { ControlScheme, FrictionLevel, InterceptSpeed, PuckSizeLevel, ShotPreviewLength } from '@/games/boxhockey/types' // Per-round puck deceleration tier (see types/index.ts's FrictionLevel and stepPhysics in // utils/physics.ts, where frictionFactor = FRICTION_LEVELS[level] ** dt). Closer to 1 means less @@ -361,21 +361,3 @@ export const COIN_FLIP_BASE_HALF_TURNS = 8 // edge-on instant a literal zero-width invisible line for a frame; a small floor keeps a thin // sliver visible throughout the spin instead. export const COIN_FLIP_MIN_SCALE_X = 0.08 - -// Loadout player-panel area (see loadout.tsx): whenever a real device rotation changes -// orientationMode or which physical side P1 lands on (see useP1OnRight), the panels fade out, the -// layout underneath swaps while invisible, then they fade back in - masking what would otherwise -// be an instant jump-cut as panels reflow or swap sides. Short and symmetric on purpose: this is -// masking a reflow that already happened at the OS's own pace, not a deliberate reveal. -export const LOADOUT_PANEL_SWAP_FADE_MS = 180 - -// Player 1/2's own default color identity - seeds BOTH the app's default theme triad (see -// redux/store.ts, which feeds these straight into @rific/auto-paper's createThemeReducer as -// primary/secondary, tertiary derived via getThirdColor) and the fallback guest/CPU colors a fresh -// install starts with (see redux/seatColorsSlice.ts), so a fresh install's live theme and its -// remembered "next time" colors always agree with each other. Lives here rather than in -// components/Theme.tsx (where this used to be defined) specifically so redux/store.ts can import -// it without a store.ts -> Theme.tsx -> store.ts import cycle (Theme.tsx now reads live theme -// state back out of the store via useSelector). -export const DEFAULT_P1_COLOR = '#f44336' -export const DEFAULT_P2_COLOR = '#2196f3' diff --git a/src/games/boxhockey/constants/guide.ts b/src/games/boxhockey/constants/guide.ts new file mode 100644 index 0000000..8e44ad0 --- /dev/null +++ b/src/games/boxhockey/constants/guide.ts @@ -0,0 +1,5 @@ +// The version of the how-to-play flow (see @tastic/hud/guide) this build teaches. Bump it ONLY when a +// controls or rules change would leave the current cards teaching something wrong: every player, +// new and existing, sees the guide once more after a bump. A copy tweak or an added tip is not a +// reason to. +export const GUIDE_VERSION = 1 diff --git a/src/constants/guideSteps.tsx b/src/games/boxhockey/constants/guideSteps.tsx similarity index 96% rename from src/constants/guideSteps.tsx rename to src/games/boxhockey/constants/guideSteps.tsx index 1059c53..71bc10e 100644 --- a/src/constants/guideSteps.tsx +++ b/src/games/boxhockey/constants/guideSteps.tsx @@ -1,8 +1,8 @@ import type { GuideStep } from '@tastic/hud/guide' -import { GuideCoinArt, GuideFaceoffArt, GuideGoalArt, GuideShotArt, GuideTripleTapArt } from '@/components/GuideArt' -import { CONTROL_SCHEME_SIGN } from '@/constants/game' -import type { ControlScheme, GameMode, Player, TipoffMode } from '@/types' +import { GuideCoinArt, GuideFaceoffArt, GuideGoalArt, GuideShotArt, GuideTripleTapArt } from '@/games/boxhockey/components/GuideArt' +import { CONTROL_SCHEME_SIGN } from '@/games/boxhockey/constants/game' +import type { ControlScheme, GameMode, Player, TipoffMode } from '@/games/boxhockey/types' // How to shoot, for one scheme (the default is 'flick' for both seats, see // redux/settingsSlice.ts). Power model per resolveStrikeVelocity in useGameState.ts: 'flick' is @@ -48,7 +48,7 @@ function seatShot(player: Player, scheme: ControlScheme, input: 'touch' | 'deskt return `${CONTROL_SCHEME_SIGN[scheme] > 0 ? 'along' : 'against'} player ${player}’s ${SHOOT_GESTURE[scheme][input]}` } -// Solo, the reader is always player 1 (the CPU is player 2, utils/gameMode.ts) and has only player +// Solo, the reader is always player 1 (the CPU always sits in seat 2) and has only player // 1's picker in the loadout, so their own scheme is the whole story: the CPU's settings.controlScheme[2] // is a leftover it never uses. Otherwise, one "you" sentence when both seats share a scheme, and a // per-player one when they differ (each seat picks its own), since then no "you" sentence is true for @@ -109,7 +109,7 @@ interface GuideStepsInput { // A touch screen vs. a desktop browser: a phone shoots by swiping or dragging a finger, a desktop by // dragging the mouse (and races face-offs on the keyboard). touch: boolean - // The last-picked mode (settings.gameMode). Solo, only player 1's shooting style matters. + // How seat 2 is set up (the pack's opponent): against the CPU, only player 1's shooting style matters. mode: GameMode // Each seat's live shooting style (settings.controlScheme). Seats pick their own in the loadout, // so a replayed guide has to describe the real ones, not the defaults. diff --git a/src/hooks/useAIOpponent.ts b/src/games/boxhockey/hooks/useAIOpponent.ts similarity index 97% rename from src/hooks/useAIOpponent.ts rename to src/games/boxhockey/hooks/useAIOpponent.ts index 889c2f7..d7fab11 100644 --- a/src/hooks/useAIOpponent.ts +++ b/src/games/boxhockey/hooks/useAIOpponent.ts @@ -1,9 +1,9 @@ import { useEffect, useRef } from 'react' -import { CONTROL_SCHEME_SIGN, MAX_FLICK_VELOCITY, MAX_SPEED, MAX_STICK_PULLBACK_PIXELS, STICK_VELOCITY_SCALE } from '@/constants/game' -import { BoardLayout, ControlScheme, GameState, Player, PuckState } from '@/types' -import { AIDifficulty, planAIShot, randomAIThinkDelayMs, randomInterceptReactionMs } from '@/utils/aiOpponent' -import { planStickSwing } from '@/utils/stick' +import { CONTROL_SCHEME_SIGN, MAX_FLICK_VELOCITY, MAX_SPEED, MAX_STICK_PULLBACK_PIXELS, STICK_VELOCITY_SCALE } from '@/games/boxhockey/constants/game' +import { BoardLayout, ControlScheme, GameState, Player, PuckState } from '@/games/boxhockey/types' +import { AIDifficulty, planAIShot, randomAIThinkDelayMs, randomInterceptReactionMs } from '@/games/boxhockey/utils/aiOpponent' +import { planStickSwing } from '@/games/boxhockey/utils/stick' interface AIControls { tryAimStart: (touchX: number, touchY: number) => boolean diff --git a/src/hooks/useFaceoff.ts b/src/games/boxhockey/hooks/useFaceoff.ts similarity index 97% rename from src/hooks/useFaceoff.ts rename to src/games/boxhockey/hooks/useFaceoff.ts index fc352dd..f0206af 100644 --- a/src/hooks/useFaceoff.ts +++ b/src/games/boxhockey/hooks/useFaceoff.ts @@ -1,9 +1,9 @@ import { useEffect, useRef } from 'react' -import { FACEOFF_COUNTDOWN_STEP_MS, FACEOFF_HOLD_MAX_MS, FACEOFF_HOLD_MIN_MS, FACEOFF_TAP_TIMEOUT_MS } from '@/constants/game' -import { BoardLayout, GameState, Player } from '@/types' -import { AIDifficulty, randomFaceoffReactionMs, rollFaceoffWrongZone } from '@/utils/aiOpponent' -import { computeFaceoffKnock } from '@/utils/faceoff' +import { FACEOFF_COUNTDOWN_STEP_MS, FACEOFF_HOLD_MAX_MS, FACEOFF_HOLD_MIN_MS, FACEOFF_TAP_TIMEOUT_MS } from '@/games/boxhockey/constants/game' +import { BoardLayout, GameState, Player } from '@/games/boxhockey/types' +import { AIDifficulty, randomFaceoffReactionMs, rollFaceoffWrongZone } from '@/games/boxhockey/utils/aiOpponent' +import { computeFaceoffKnock } from '@/games/boxhockey/utils/faceoff' interface FaceoffControls { faceoffCount: (count: 2 | 1) => void diff --git a/src/hooks/useGameState.ts b/src/games/boxhockey/hooks/useGameState.ts similarity index 99% rename from src/hooks/useGameState.ts rename to src/games/boxhockey/hooks/useGameState.ts index d71893e..ade6578 100644 --- a/src/hooks/useGameState.ts +++ b/src/games/boxhockey/hooks/useGameState.ts @@ -1,11 +1,11 @@ import { useGameLoop } from '@tastic/core' import { useCallback, useEffect, useReducer, useRef } from 'react' -import { AIM_VELOCITY_SMOOTHING_ALPHA, CONTROL_SCHEME_SIGN, FACEOFF_GUESS_BONUS_MS, FACEOFF_MARGIN_MAX_MS, FACEOFF_REACTION_MAX_MS, MAX_FLICK_VELOCITY, MAX_SPEED, MAX_STICK_PULLBACK_PIXELS, MIN_FLICK_VELOCITY, MIN_SHOOT_DRAG, TRIPLE_TAP_CLASH_WINDOW_MS, TRIPLE_TAP_TAPS } from '@/constants/game' -import { AimIndicator, BoardLayout, ControlScheme, FaceoffState, GameState, Player, PuckState, TipoffMode, TripleTapState } from '@/types' -import { centerPuck, faceoffZoneCenter } from '@/utils/board' -import { computeAimVector, stepPhysics } from '@/utils/physics' -import { clampBladeToBoard, isBladeClear, resolveStickContact } from '@/utils/stick' +import { AIM_VELOCITY_SMOOTHING_ALPHA, CONTROL_SCHEME_SIGN, FACEOFF_GUESS_BONUS_MS, FACEOFF_MARGIN_MAX_MS, FACEOFF_REACTION_MAX_MS, MAX_FLICK_VELOCITY, MAX_SPEED, MAX_STICK_PULLBACK_PIXELS, MIN_FLICK_VELOCITY, MIN_SHOOT_DRAG, TRIPLE_TAP_CLASH_WINDOW_MS, TRIPLE_TAP_TAPS } from '@/games/boxhockey/constants/game' +import { AimIndicator, BoardLayout, ControlScheme, FaceoffState, GameState, Player, PuckState, TipoffMode, TripleTapState } from '@/games/boxhockey/types' +import { centerPuck, faceoffZoneCenter } from '@/games/boxhockey/utils/board' +import { computeAimVector, stepPhysics } from '@/games/boxhockey/utils/physics' +import { clampBladeToBoard, isBladeClear, resolveStickContact } from '@/games/boxhockey/utils/stick' type Action = | { type: 'AIM_START'; x: number; y: number } diff --git a/src/games/boxhockey/hooks/useGameStats.tsx b/src/games/boxhockey/hooks/useGameStats.tsx new file mode 100644 index 0000000..9fb064f --- /dev/null +++ b/src/games/boxhockey/hooks/useGameStats.tsx @@ -0,0 +1,21 @@ +import { useCallback } from 'react' + +import { ACHIEVEMENT_CATALOG } from '@/games/boxhockey/constants/achievements' +import type { ProfileStats, StatsState } from '@/games/boxhockey/types' +import { applyMatchOutcome, getProfileStatsView, type MatchContext, type MatchOutcome } from '@/games/boxhockey/utils/statsEngine' +import { DEFAULT_STATS, isValidStats } from '@/games/boxhockey/utils/statsValidation' +import { createGameStats } from '@/shared/gameStats' + +// Box Hockey's stats and achievements, under the `achievements` key BoxHockey 1.0 saved them under (see the shared createGameStats). +const gameStats = createGameStats({ key: 'boxhockeyAchievements', defaultStats: DEFAULT_STATS, isValidStats, catalog: ACHIEVEMENT_CATALOG, getProfileStatsView }) + +export const achievementsReducer = gameStats.reducer +export const achievementsActions = gameStats.actions +export const GameStatsProvider = gameStats.Provider + +export function useGameStats() { + const { stats, unlockedAchievements, record, resetAll } = gameStats.useStats() + // Records one finished match: who sat where (context.profileIds) gives the per-seat unlocks. + const recordMatchOutcome = useCallback((outcome: MatchOutcome, context: MatchContext) => record((prev) => applyMatchOutcome(prev, outcome, context), context.profileIds), [record]) + return { stats, unlockedAchievements, recordMatchOutcome, resetAll } +} diff --git a/src/hooks/useTripleTap.ts b/src/games/boxhockey/hooks/useTripleTap.ts similarity index 93% rename from src/hooks/useTripleTap.ts rename to src/games/boxhockey/hooks/useTripleTap.ts index b8d3b38..da0d25d 100644 --- a/src/hooks/useTripleTap.ts +++ b/src/games/boxhockey/hooks/useTripleTap.ts @@ -1,9 +1,9 @@ import { useEffect } from 'react' -import { TRIPLE_TAP_CLASH_WINDOW_MS, TRIPLE_TAP_TAPS } from '@/constants/game' -import { canTripleTap } from '@/hooks/useGameState' -import { GameState, Player } from '@/types' -import { AIDifficulty, planTripleTapSwipe, randomFaceoffReactionMs, randomTripleTapDragDurationMs } from '@/utils/aiOpponent' +import { TRIPLE_TAP_CLASH_WINDOW_MS, TRIPLE_TAP_TAPS } from '@/games/boxhockey/constants/game' +import { canTripleTap } from '@/games/boxhockey/hooks/useGameState' +import { GameState, Player } from '@/games/boxhockey/types' +import { AIDifficulty, planTripleTapSwipe, randomFaceoffReactionMs, randomTripleTapDragDurationMs } from '@/games/boxhockey/utils/aiOpponent' interface TripleTapControls { tripleTapPress: (player: Player, atMs: number) => void diff --git a/src/games/boxhockey/index.ts b/src/games/boxhockey/index.ts new file mode 100644 index 0000000..a035f60 --- /dev/null +++ b/src/games/boxhockey/index.ts @@ -0,0 +1,54 @@ +import { ControlScheme, TipoffMode } from '@/games/boxhockey/types' +import type { GameDefinition } from '@/shell/types' + +import { GUIDE_VERSION } from './constants/guide' +import { getGuideSteps } from './constants/guideSteps' +import { achievementsActions, achievementsReducer as achievements, GameStatsProvider } from './hooks/useGameStats' +import PlayScreen from './PlayScreen' +import guide, { guideActions, selectGuideVersionSeen } from './redux/guideSlice' +import { playerSettings } from './redux/settingsSlice' +import { AchievementList, StatsSections } from './StatsSections' +import { useLoadout } from './useLoadout' + +// What Box Hockey's How to Play cards are built from (see getGuideSteps), flat so the guide's +// shallow compare sees no change while none of them change. +interface GuideInput { + control1: ControlScheme + control2: ControlScheme + tipoff: TipoffMode + scoreToWin: number +} + +// Box Hockey, the pack's first game and the one BoxHockey 1.0 shipped as. Its store keys are its own +// (boxhockeySettings, boxhockeyControls, boxhockeyAchievements, boxhockeyGuide), like every game's. +// A 1.0 save is imported once (importBoxHockey1.ts). +export const boxHockey: GameDefinition = { + id: 'boxhockey', + title: 'Box Hockey', + blurb: 'The classic wooden table game. Take turns shooting.', + // The pack's own app icon is Box Hockey's, so its tile uses the app icon files. + icon: { light: require('../../../assets/images/icon-light.png'), dark: require('../../../assets/images/icon-dark.png') }, + seatTwo: ['cpu', 'person'], + // The board never turns a quarter turn, only flips upside down, on every platform. + twoPlayerSeats: { faceToFaceOnWeb: true, followUpsideDown: true }, + reducers: { ...playerSettings.reducers, boxhockeyAchievements: achievements, boxhockeyGuide: guide }, + Provider: GameStatsProvider, + useLoadout, + PlayScreen, + StatsSections, + AchievementList, + resetStats: achievementsActions.reset, + guide: { + version: GUIDE_VERSION, + selectVersionSeen: (state) => selectGuideVersionSeen(state), + markSeen: guideActions.markSeen, + selectInput: (state): GuideInput => { + const s = playerSettings.select(state) + return { control1: s.controlScheme[1], control2: s.controlScheme[2], tipoff: s.tipoffMode, scoreToWin: s.scoreToWin } + }, + getSteps: (input, { touch, mode }) => { + const { control1, control2, tipoff, scoreToWin } = input as GuideInput + return getGuideSteps({ touch, mode: mode === 'twoPlayer' ? 'twoPlayer' : 'vsCpu', controls: { 1: control1, 2: control2 }, tipoff, scoreToWin }) + } + } +} diff --git a/src/games/boxhockey/redux/guideSlice.ts b/src/games/boxhockey/redux/guideSlice.ts new file mode 100644 index 0000000..fdb9e98 --- /dev/null +++ b/src/games/boxhockey/redux/guideSlice.ts @@ -0,0 +1,11 @@ +import { GUIDE_VERSION } from '@/games/boxhockey/constants/guide' +import { createGameGuideSlice } from '@/shell/redux/gameGuideSlice' + +// Which version of Box Hockey's How to Play cards this player has finished or skipped, under its own +// store key, like every game's (see createGameGuideSlice: a saved store without it reads as not seen). +const slice = createGameGuideSlice('boxhockeyGuide') + +export { GUIDE_VERSION } +export const guideActions = slice.actions +export const selectGuideVersionSeen = slice.selectVersionSeen +export default slice.reducer diff --git a/src/redux/settingsSlice.ts b/src/games/boxhockey/redux/settingsSlice.ts similarity index 63% rename from src/redux/settingsSlice.ts rename to src/games/boxhockey/redux/settingsSlice.ts index 93f7557..21b48bb 100644 --- a/src/redux/settingsSlice.ts +++ b/src/games/boxhockey/redux/settingsSlice.ts @@ -1,19 +1,13 @@ -import { createSettingsSlice } from '@rific/core' +import { SCORE_TO_WIN } from '@/games/boxhockey/constants/game' +import type { ControlScheme, Settings } from '@/games/boxhockey/types' +import { createPlayerSettings } from '@/shared/playerSettings' -import { SCORE_TO_WIN } from '@/constants/game' -import type { Settings } from '@/types' - -// The persisted settings: a Redux slice like every other persisted value in this app, read and -// written only through hooks/useSettings.ts. Built on @rific/core's createSettingsSlice: `initialize` -// in 'merge' mode is the single write path (setSettings' partial patch), and its REHYDRATE handling -// fills any field added after a player's settings were saved from DEFAULT_SETTINGS, so no -// validation or migration is needed. +// What a player who hasn't changed a thing plays with. Each player's own changes are saved over it +// (see below), so a field added later starts at its default for everyone. export const DEFAULT_SETTINGS: Settings = { - gameMode: 'twoPlayer', boardVariant: 'classic', scoreToWin: SCORE_TO_WIN, aiDifficulty: 'medium', - lockOrientation: false, // The real hockey rule, and the more distinctive of the two - the user wants it to be what new // players see first rather than the plainer coin-flip fallback. tipoffMode: 'faceoff', @@ -35,15 +29,11 @@ export const DEFAULT_SETTINGS: Settings = { controlScheme: { 1: 'flick', 2: 'flick' }, // Off by default, matching every variant's original, pre-rink-work behavior - see its own doc // comment on Settings. - rink: false, - deferBottomEdgeGestures: false + rink: false } -const { actions, reducer } = createSettingsSlice('settings', { - initialState: DEFAULT_SETTINGS, - initializeMode: 'merge', - fieldSetters: [] -}) - -export const settingsActions = actions -export default reducer +// Box Hockey's loadout, per player (shared/playerSettings.ts): the match settings seat 1's player +// last set up, under boxhockeySettings, and the shot style each player last picked, under +// boxhockeyControls. Shot styles aren't a shared resource the way a keyboard's key clusters are, +// so both seats can play the same one. +export const playerSettings = createPlayerSettings({ id: 'boxhockey', defaults: DEFAULT_SETTINGS }) diff --git a/assets/sounds/faceoffGo.wav b/src/games/boxhockey/sounds/faceoffGo.wav similarity index 100% rename from assets/sounds/faceoffGo.wav rename to src/games/boxhockey/sounds/faceoffGo.wav diff --git a/assets/sounds/faceoffResolved.wav b/src/games/boxhockey/sounds/faceoffResolved.wav similarity index 100% rename from assets/sounds/faceoffResolved.wav rename to src/games/boxhockey/sounds/faceoffResolved.wav diff --git a/assets/sounds/faceoffTick.wav b/src/games/boxhockey/sounds/faceoffTick.wav similarity index 100% rename from assets/sounds/faceoffTick.wav rename to src/games/boxhockey/sounds/faceoffTick.wav diff --git a/assets/sounds/wellAttract.wav b/src/games/boxhockey/sounds/wellAttract.wav similarity index 100% rename from assets/sounds/wellAttract.wav rename to src/games/boxhockey/sounds/wellAttract.wav diff --git a/assets/sounds/wellRepel.wav b/src/games/boxhockey/sounds/wellRepel.wav similarity index 100% rename from assets/sounds/wellRepel.wav rename to src/games/boxhockey/sounds/wellRepel.wav diff --git a/src/types/index.ts b/src/games/boxhockey/types.ts similarity index 90% rename from src/types/index.ts rename to src/games/boxhockey/types.ts index 7d0d74e..a812470 100644 --- a/src/types/index.ts +++ b/src/games/boxhockey/types.ts @@ -1,5 +1,4 @@ import { AchievementDefinition as BaseAchievementDefinition, DayStreakState, OutcomeRecord, RoundResult, WinStreakState } from '@tastic/achievements' -import type { ViewRotation } from '@tastic/core' export type Player = 1 | 2 @@ -40,28 +39,9 @@ export type AiDifficulty = 'easy' | 'medium' | 'hard' // mid-slide too (an intercept), where a blade held in the puck's path deflects it. export type ControlScheme = 'pull' | 'push' | 'flick' | 'stick' -// A locally-saved player identity - name, color, and a short tag, entirely opt-in. A seat's own live -// round color, while this profile's selected there, is derived FROM this (see loadout.tsx's own -// profile-color effect) but never synced back TO it - a manual recolor or clash-swap made on that -// seat is kept as this profile's own pick in BoxHockey (redux/seatColorsSlice.ts's profileColors), -// not an edit to the saved "favorite" (that's app/profiles.tsx's ProfilesManager's own job). Mirrors LightCycles' identically- -// shaped Profile type (see @tastic/profile, the package both apps build this on), with controlScheme -// standing in for LightCycles' keyScheme as this app's own per-profile control preference. -export interface Profile { - id: string - name: string - color: string - // User-typed identity mark shown on the seat's own color button and in the profile switcher - one - // emoji, or up to MAX_TAG_LENGTH plain characters (see profilesValidation.ts's isValidTag). Can be - // empty (no tag yet), which just falls back to a generic icon wherever it's shown. - tag: string - // Synced back to the profile for as long as it stays selected (see loadout.tsx's - // handleP1ControlSchemeChange) - unlike color, a control-scheme preference has no "freely override - // it for one match" use case, so there's no favorite/override distinction worth drawing here. - controlScheme: ControlScheme - createdAt: number - updatedAt: number -} +// A player profile is the pack's (@tastic/profile's base Profile: name, color, tag), shared by +// every game. Box Hockey's own per-player settings and shot style live in redux/settingsSlice.ts. +export type { Profile } from '@tastic/profile' // How possession is decided at the start of a game and (for 'faceoff' only) after every goal - // see useFaceoff.ts. 'coinFlip' is the lighter-weight option: the winner and puck placement (a @@ -125,20 +105,14 @@ export type InterceptSpeed = 'low' | 'medium' | 'high' // preview at all. export type ShotPreviewLength = 'off' | 'short' | 'medium' | 'long' +// Box Hockey's own settings, stored under the `settings` key BoxHockey 1.0 saved them under (see +// redux/settingsSlice.ts). A 1.0 player's saved blob also still holds that build's gameMode, +// lockOrientation, lockedRotation and deferBottomEdgeGestures; nothing reads them here any more +// (seat 2 and the pack-wide settings are the shell's now, seeded from them once by pack.ts). export interface Settings { - gameMode: GameMode boardVariant: BoardVariant scoreToWin: number aiDifficulty: AiDifficulty - // Lock Orientation, app-wide: _layout.tsx's OrientationBridge hands it to @tastic/core's - // OrientationProvider (freezeWhileLocked), which freezes the one shared orientation reading for - // every screen, the in-game board, dialogs and the status bar alike. Off by default: all of those - // just follow however the device is actually being held. - lockOrientation: boolean - // The angle the lock is holding. Written by OrientationProvider's onLockChange (recorded on lock, - // cleared on unlock) and handed back as lockInitialValue, so a relaunch reopens in the locked - // orientation instead of portrait. Never set by the UI directly; absent while unlocked. - lockedRotation?: ViewRotation tipoffMode: TipoffMode friction: FrictionLevel shotPreviewLength: ShotPreviewLength @@ -162,12 +136,6 @@ export interface Settings { // posts and the line scores immediately, with no depth behind it to travel into. Off by default, // matching every other variant's original, pre-rink-work behavior. rink: boolean - // iOS-only. Off by default - deliberately opt-in rather than always-on, so it only kicks in for - // someone who's actually hit the problem and gone looking for a fix, instead of every player - // eating a "swipe twice to go home" surprise from day one. Mirrored into native UserDefaults (see - // Providers.tsx's EdgeGuardBridge) for @tastic/edge-guard's own config plugin swizzle of - // preferredScreenEdgesDeferringSystemGestures to read at runtime. - deferBottomEdgeGestures: boolean } export interface Obstacle { diff --git a/src/games/boxhockey/useLoadout.tsx b/src/games/boxhockey/useLoadout.tsx new file mode 100644 index 0000000..6886152 --- /dev/null +++ b/src/games/boxhockey/useLoadout.tsx @@ -0,0 +1,170 @@ +import { type MenuOption, type PopoverHost, SectionedDropdown } from '@tastic/hud' +import { useCallback, useMemo } from 'react' +import { Platform } from 'react-native' + +import { LOADOUT_SHARED_CONTROLS_IDS, LoadoutSharedControls } from '@/games/boxhockey/components/LoadoutSharedControls' +import { DEFAULT_SETTINGS, playerSettings } from '@/games/boxhockey/redux/settingsSlice' +import { BoardVariant, ControlScheme, FrictionLevel, InterceptSpeed, PuckSizeLevel, ShotPreviewLength, TipoffMode } from '@/games/boxhockey/types' +import { AI_DIFFICULTY_LABELS, AIDifficulty } from '@/games/boxhockey/utils/aiOpponent' +import { BOARD_VARIANT_LABELS } from '@/games/boxhockey/utils/board' +import type { GameLoadout, LoadoutOptionsProps, RoundMode, Seat, SeatControlLook } from '@/shell/types' + +const BOARD_VARIANT_DESCRIPTIONS: Record = { + classic: 'Off-center holes', + gauntlet: 'Three alternating walls', + gravity: 'Classic walls + corner pushes' +} +const BOARD_VARIANT_OPTIONS: MenuOption[] = (['classic', 'gauntlet', 'gravity'] as BoardVariant[]).map((value) => ({ value, label: BOARD_VARIANT_LABELS[value], description: BOARD_VARIANT_DESCRIPTIONS[value] })) + +// Independent of boardVariant, but rides along in the same popover as one of its rows - see +// LoadoutSharedControls' own comment. A single option rather than an on/off pair: presence in a +// one-item multi-select's value array already means "on," so there's nothing an "off" row would +// add beyond what tapping the "on" row a second time (to un-toggle it) already does. +const RINK_OPTION: MenuOption<'on'> = { value: 'on', label: 'Rink', description: 'Rounded corners + net pocket', icon: 'rounded-corner' } + +const FRICTION_OPTIONS: MenuOption[] = [ + { value: 'high', label: 'Slow', description: 'Puck stops quickly', icon: 'speedometer-slow' }, + { value: 'medium', label: 'Medium', description: 'Standard glide', icon: 'speedometer-medium' }, + { value: 'low', label: 'Fast', description: 'Puck glides farther', icon: 'speedometer' } +] + +const INTERCEPT_SPEED_OPTIONS: MenuOption[] = [ + { value: 'low', label: 'Patient', description: 'Only once it’s nearly stopped', icon: 'timer-sand-full' }, + { value: 'medium', label: 'Alert', description: 'Jump on a slow roll', icon: 'hand-back-right-outline' }, + { value: 'high', label: 'Aggressive', description: 'Take it while it’s still gliding', icon: 'lightning-bolt' } +] + +const PUCK_SIZE_OPTIONS: MenuOption[] = [ + { value: 'small', label: 'Small', description: 'Tighter goal & gaps', icon: 'circle-small' }, + { value: 'medium', label: 'Medium', description: 'Standard size', icon: 'circle-medium' }, + { value: 'large', label: 'Large', description: 'Wider goal & gaps', icon: 'circle-outline' } +] + +const SCORE_TO_WIN_OPTIONS: MenuOption[] = [3, 5, 7, 10].map((value) => ({ value, label: String(value) })) + +const SHOT_PREVIEW_LENGTH_OPTIONS: MenuOption[] = [ + { value: 'off', label: 'Off', description: 'No trajectory preview', icon: 'eye-off-outline' }, + { value: 'short', label: 'Short', description: 'Just the aim direction', icon: 'ray-start' }, + { value: 'medium', label: 'Medium', description: 'Occasionally reveals a bank', icon: 'ray-start-end' }, + { value: 'long', label: 'Long', description: 'Shows most bank shots', icon: 'ray-start-arrow' } +] + +const CONTROL_SCHEME_OPTIONS: MenuOption[] = [ + { value: 'flick', label: 'Flick', description: 'Swipe fast: power comes from speed', icon: 'gesture-swipe' }, + { value: 'stick', label: 'Stick', description: 'Swing into the puck to hit it', icon: 'hockey-sticks' }, + { value: 'pull', label: 'Pull', description: 'Drag back, release to fire', icon: 'arrow-collapse' }, + { value: 'push', label: 'Push', description: 'Drag toward your shot, release to fire', icon: 'arrow-expand' } +] + +const ALL_TIPOFF_MODE_OPTIONS: MenuOption[] = [ + { value: 'coinFlip', label: 'Coin Flip', description: 'Random pick starts, loser gets the puck', icon: 'hand-coin-outline' }, + { value: 'faceoff', label: 'Face-Off', description: 'Race the tap on every score', icon: 'hockey-puck' }, + { value: 'tripleTap', label: 'Triple Tap', description: 'Tap 3 times together, then race to swipe', icon: 'gesture-double-tap' } +] +// 'tripleTap' is touch/mobile only - no keyboard equivalent (see game.tsx), so it's hidden from the +// picker entirely on web rather than offered and left unplayable there. Computed once at module +// scope since Platform.OS never changes at runtime; also keeps handleRandomizeMatchSettings below +// from ever landing on it on web, with no separate edit needed there. +const TIPOFF_MODE_OPTIONS: MenuOption[] = Platform.OS === 'web' ? ALL_TIPOFF_MODE_OPTIONS.filter((o) => o.value !== 'tripleTap') : ALL_TIPOFF_MODE_OPTIONS + +const AI_DIFFICULTY_OPTIONS: MenuOption[] = [ + { value: 'easy', label: AI_DIFFICULTY_LABELS.easy, icon: 'emoticon-happy-outline' }, + { value: 'medium', label: AI_DIFFICULTY_LABELS.medium, icon: 'emoticon-neutral-outline' }, + { value: 'hard', label: AI_DIFFICULTY_LABELS.hard, icon: 'emoticon-devil-outline' } +] + +function pickRandom(options: readonly { value: T }[]): T { + return options[Math.floor(Math.random() * options.length)].value +} + +// Box Hockey's part of the shared loadout (shell/screens/LoadoutScreen.tsx): its match options +// (board, rink, puck physics, intercepts, score to win, shot preview, tip-off), each seat's shot +// style, and the CPU's difficulty. +export function useLoadout(mode: RoundMode): GameLoadout { + // Seat 1's player's own setup, and each seat's player's shot style (redux/settingsSlice.ts). + const { settings, setSettings } = playerSettings.useSettings() + + // Saved with the player in that seat. No swap on conflict: Flick, Stick, Pull and Push aren't a + // shared physical resource the way a keyboard's key clusters are, so both players can use the + // same one. + const setSeatControl = useCallback((seat: Seat, value: ControlScheme) => setSettings({ controlScheme: { ...settings.controlScheme, [seat]: value } }), [setSettings, settings.controlScheme]) + + // Covers exactly the fields the match options row exposes, plus the CPU's difficulty against the + // CPU. Per-seat things (colors, shot style) and pack-wide settings (Lock Orientation) aren't + // "match settings" in the sense a player means when they shuffle or reset the next match. + const randomize = useCallback(() => { + setSettings({ + boardVariant: pickRandom(BOARD_VARIANT_OPTIONS), + rink: Math.random() < 0.5, + friction: pickRandom(FRICTION_OPTIONS), + puckSize: pickRandom(PUCK_SIZE_OPTIONS), + interceptSpeed: pickRandom(INTERCEPT_SPEED_OPTIONS), + scoreToWin: pickRandom(SCORE_TO_WIN_OPTIONS), + shotPreviewLength: pickRandom(SHOT_PREVIEW_LENGTH_OPTIONS), + tipoffMode: pickRandom(TIPOFF_MODE_OPTIONS), + ...(mode === 'vsCpu' ? { aiDifficulty: pickRandom(AI_DIFFICULTY_OPTIONS) } : {}) + }) + }, [setSettings, mode]) + + const reset = useCallback(() => { + setSettings({ + boardVariant: DEFAULT_SETTINGS.boardVariant, + rink: DEFAULT_SETTINGS.rink, + friction: DEFAULT_SETTINGS.friction, + puckSize: DEFAULT_SETTINGS.puckSize, + interceptSpeed: DEFAULT_SETTINGS.interceptSpeed, + scoreToWin: DEFAULT_SETTINGS.scoreToWin, + shotPreviewLength: DEFAULT_SETTINGS.shotPreviewLength, + tipoffMode: DEFAULT_SETTINGS.tipoffMode, + ...(mode === 'vsCpu' ? { aiDifficulty: DEFAULT_SETTINGS.aiDifficulty } : {}) + }) + }, [setSettings, mode]) + + const options = useCallback( + ({ host, rotation, accentColor, mutedColor, onAccentColor, dark, onRandomize, onReset }: LoadoutOptionsProps) => ( + setSettings({ boardVariant: value })} + rink={settings.rink} + rinkOption={RINK_OPTION} + onRinkChange={(value) => setSettings({ rink: value })} + friction={settings.friction} + frictionOptions={FRICTION_OPTIONS} + onFrictionChange={(value) => setSettings({ friction: value })} + interceptSpeed={settings.interceptSpeed} + interceptSpeedOptions={INTERCEPT_SPEED_OPTIONS} + onInterceptSpeedChange={(value) => setSettings({ interceptSpeed: value })} + puckSize={settings.puckSize} + puckSizeOptions={PUCK_SIZE_OPTIONS} + onPuckSizeChange={(value) => setSettings({ puckSize: value })} + scoreToWin={settings.scoreToWin} + scoreToWinOptions={SCORE_TO_WIN_OPTIONS} + onScoreToWinChange={(value) => setSettings({ scoreToWin: value })} + shotPreviewLength={settings.shotPreviewLength} + shotPreviewLengthOptions={SHOT_PREVIEW_LENGTH_OPTIONS} + onShotPreviewLengthChange={(value) => setSettings({ shotPreviewLength: value })} + tipoffMode={settings.tipoffMode} + tipoffModeOptions={TIPOFF_MODE_OPTIONS} + onTipoffModeChange={(value) => setSettings({ tipoffMode: value })} + onRandomize={onRandomize} + onReset={onReset} + accentColor={accentColor} + mutedColor={mutedColor} + onAccentColor={onAccentColor} + dark={dark} + /> + ), + [settings, setSettings] + ) + + // A person's shot-style picker (Flick, Stick, Pull, Push) under their seat button. A CPU never + // gets one: useAIOpponent.ts cancels the scheme's sign back out, so it's invisible to the CPU. + const seatControl = useCallback((seat: Seat, host: PopoverHost, { accentColor, mutedColor, dark }: SeatControlLook) => setSeatControl(seat, value) }]} accentColor={accentColor} mutedColor={mutedColor} dark={dark} />, [settings.controlScheme, setSeatControl]) + + const cpuDifficulty = useMemo(() => ({ value: settings.aiDifficulty, options: AI_DIFFICULTY_OPTIONS, onChange: (value: string) => setSettings({ aiDifficulty: value as AIDifficulty }) }), [settings.aiDifficulty, setSettings]) + + return { roundSettings: settings, options, optionIds: LOADOUT_SHARED_CONTROLS_IDS, randomize, reset, seatControl, cpuDifficulty } +} diff --git a/src/utils/achievementEngine.ts b/src/games/boxhockey/utils/achievementEngine.ts similarity index 81% rename from src/utils/achievementEngine.ts rename to src/games/boxhockey/utils/achievementEngine.ts index baa251e..350c9a6 100644 --- a/src/utils/achievementEngine.ts +++ b/src/games/boxhockey/utils/achievementEngine.ts @@ -1,7 +1,7 @@ import { createAchievementBindings } from '@tastic/achievements' -import { ACHIEVEMENT_CATALOG } from '@/constants/achievements' -import { getProfileStatsView } from '@/utils/statsEngine' +import { ACHIEVEMENT_CATALOG } from '@/games/boxhockey/constants/achievements' +import { getProfileStatsView } from '@/games/boxhockey/utils/statsEngine' // Thin BoxHockey-side bindings over @tastic/achievements' generic createAchievementBindings // factory - the catalog and getProfileStatsView are module-level constants here, so pre-binding diff --git a/src/utils/aiOpponent.ts b/src/games/boxhockey/utils/aiOpponent.ts similarity index 98% rename from src/utils/aiOpponent.ts rename to src/games/boxhockey/utils/aiOpponent.ts index 5e38d0f..961d1b6 100644 --- a/src/utils/aiOpponent.ts +++ b/src/games/boxhockey/utils/aiOpponent.ts @@ -1,6 +1,6 @@ -import { FRICTION_LEVELS, MAX_FLICK_VELOCITY, MAX_SPEED, MAX_STICK_PULLBACK_PIXELS, MIN_FLICK_VELOCITY, MIN_SHOOT_DRAG } from '@/constants/game' -import { BoardLayout, Player, PuckState } from '@/types' -import { stepPhysics } from '@/utils/physics' +import { FRICTION_LEVELS, MAX_FLICK_VELOCITY, MAX_SPEED, MAX_STICK_PULLBACK_PIXELS, MIN_FLICK_VELOCITY, MIN_SHOOT_DRAG } from '@/games/boxhockey/constants/game' +import { BoardLayout, Player, PuckState } from '@/games/boxhockey/types' +import { stepPhysics } from '@/games/boxhockey/utils/physics' export type AIDifficulty = 'easy' | 'medium' | 'hard' diff --git a/src/utils/board.ts b/src/games/boxhockey/utils/board.ts similarity index 99% rename from src/utils/board.ts rename to src/games/boxhockey/utils/board.ts index 727ed11..ba5e426 100644 --- a/src/utils/board.ts +++ b/src/games/boxhockey/utils/board.ts @@ -1,8 +1,8 @@ import { Platform } from 'react-native' import { EdgeInsets } from 'react-native-safe-area-context' -import { BOARD_ASPECT_RATIO, DESKTOP_WEB_MIN_WIDTH, DIVIDER_HOLE_TO_PUCK_RADIUS, FACEOFF_ZONE_RADIUS_RATIO, GOAL_LINE_STROKE_WIDTH, GRAVITY_WELL_ATTRACT_STRENGTH, GRAVITY_WELL_CORNER_REPEL_STRENGTH, PUCK_SIZE_RATIOS, WALL_POST_RADIUS_RATIO, WALL_THICKNESS } from '@/constants/game' -import { BoardLayout, BoardVariant, GoalPost, GravityWell, Obstacle, Player, PuckSizeLevel, WallPost } from '@/types' +import { BOARD_ASPECT_RATIO, DESKTOP_WEB_MIN_WIDTH, DIVIDER_HOLE_TO_PUCK_RADIUS, FACEOFF_ZONE_RADIUS_RATIO, GOAL_LINE_STROKE_WIDTH, GRAVITY_WELL_ATTRACT_STRENGTH, GRAVITY_WELL_CORNER_REPEL_STRENGTH, PUCK_SIZE_RATIOS, WALL_POST_RADIUS_RATIO, WALL_THICKNESS } from '@/games/boxhockey/constants/game' +import { BoardLayout, BoardVariant, GoalPost, GravityWell, Obstacle, Player, PuckSizeLevel, WallPost } from '@/games/boxhockey/types' export const BOARD_VARIANT_LABELS: Record = { classic: 'Classic', diff --git a/src/utils/faceoff.ts b/src/games/boxhockey/utils/faceoff.ts similarity index 94% rename from src/utils/faceoff.ts rename to src/games/boxhockey/utils/faceoff.ts index 22e5ef7..2f15fd8 100644 --- a/src/utils/faceoff.ts +++ b/src/games/boxhockey/utils/faceoff.ts @@ -1,5 +1,5 @@ -import { FACEOFF_JITTER_MAX_DEG, FACEOFF_JITTER_MIN_DEG, FACEOFF_KNOCK_MAX_POWER, FACEOFF_KNOCK_MIN_POWER, FACEOFF_MARGIN_MAX_MS, FACEOFF_REACTION_MAX_MS, MAX_SPEED } from '@/constants/game' -import { BoardLayout, Player } from '@/types' +import { FACEOFF_JITTER_MAX_DEG, FACEOFF_JITTER_MIN_DEG, FACEOFF_KNOCK_MAX_POWER, FACEOFF_KNOCK_MIN_POWER, FACEOFF_MARGIN_MAX_MS, FACEOFF_REACTION_MAX_MS, MAX_SPEED } from '@/games/boxhockey/constants/game' +import { BoardLayout, Player } from '@/games/boxhockey/types' // The tip-off winner's knock, launched from center toward the goal THEY attack (same convention // as aiOpponent.ts's planAIShot: targetY = the goal that scores for the attacking player) - i.e. diff --git a/src/utils/physics.ts b/src/games/boxhockey/utils/physics.ts similarity index 99% rename from src/utils/physics.ts rename to src/games/boxhockey/utils/physics.ts index 2891f51..aa68a5d 100644 --- a/src/utils/physics.ts +++ b/src/games/boxhockey/utils/physics.ts @@ -1,8 +1,8 @@ import { computeAimVector as computeSharedAimVector } from '@tastic/input' import { applyFriction, applyGravityWells, boardCornerQuadrant, circleVsCircle as sharedCircleVsCircle, circleVsRect as sharedCircleVsRect, circleVsRoundedCorner, reflectOffNormal } from '@tastic/physics' -import { FRICTION_LEVELS, GRAVITY_WELL_MIN_DIST, MAX_SPEED, STOP_THRESHOLD, WALL_RESTITUTION } from '@/constants/game' -import { BoardLayout, GravityWell, PuckState } from '@/types' +import { FRICTION_LEVELS, GRAVITY_WELL_MIN_DIST, MAX_SPEED, STOP_THRESHOLD, WALL_RESTITUTION } from '@/games/boxhockey/constants/game' +import { BoardLayout, GravityWell, PuckState } from '@/games/boxhockey/types' export interface PhysicsResult extends PuckState { scored: 1 | 2 | null diff --git a/src/utils/seatToastSpots.ts b/src/games/boxhockey/utils/seatToastSpots.ts similarity index 93% rename from src/utils/seatToastSpots.ts rename to src/games/boxhockey/utils/seatToastSpots.ts index f4381c0..18e14ee 100644 --- a/src/utils/seatToastSpots.ts +++ b/src/games/boxhockey/utils/seatToastSpots.ts @@ -1,8 +1,8 @@ import { getViewRotation, type OrientationState } from '@tastic/core' import { Platform } from 'react-native' -import type { SeatToastSpot } from '@/components/SeatToasters' -import type { Player } from '@/types' +import type { Player } from '@/games/boxhockey/types' +import type { SeatToastSpot } from '@/shared/SeatToasters' /** Where each 2 Player seat's unlock toasts go (SeatToasters), from the live hold: the half of the screen that's theirs, turned the way they * read it. Side by side, each player's stack stays on their own half, so three toasts for one and one for the other are two stacks, one on diff --git a/src/utils/statsEngine.ts b/src/games/boxhockey/utils/statsEngine.ts similarity index 92% rename from src/utils/statsEngine.ts rename to src/games/boxhockey/utils/statsEngine.ts index b21f653..ebf4922 100644 --- a/src/utils/statsEngine.ts +++ b/src/games/boxhockey/utils/statsEngine.ts @@ -1,7 +1,8 @@ import { applyDayPlayed, applyResult, applyResultToBucket, applyWinStreak, countBucketsWithAWin, getMostPlayedKey, resultFor, RoundResult } from '@tastic/achievements' -import { AiDifficulty, BoardVariant, GameMode, Player, Profile, ProfileStats, StatsState } from '@/types' -import { DEFAULT_PROFILE_STATS } from '@/utils/statsValidation' +import { AiDifficulty, BoardVariant, GameMode, Player, Profile, ProfileStats, StatsState } from '@/games/boxhockey/types' +import { DEFAULT_PROFILE_STATS } from '@/games/boxhockey/utils/statsValidation' +import { type ProfileRanking, rankProfiles } from '@/shared/PlayerRankings' // One finished match, as the stats layer sees it. Built by game.tsx the instant phase flips to // 'gameOver' - everything here is already on screen at that moment, so nothing extra is tracked @@ -124,21 +125,8 @@ export function getFavoriteVariant(stats: ProfileStats): BoardVariant | null { return getMostPlayedKey(stats.variants) as BoardVariant | null } -export interface ProfileRanking { - profile: Profile - played: number - wins: number - losses: number - winRate: number -} - // Sorted by wins, then win rate, both descending - a profile with nothing recorded yet still // appears at played: 0, so a freshly created profile shows up immediately. export function getProfileRankings(profiles: Profile[], profileStats: Record): ProfileRanking[] { - return profiles - .map((profile) => { - const totals = getOverallTotals(profileStats[profile.id] ?? DEFAULT_PROFILE_STATS) - return { profile, ...totals, winRate: totals.played > 0 ? totals.wins / totals.played : 0 } - }) - .sort((a, b) => b.wins - a.wins || b.winRate - a.winRate) + return rankProfiles(profiles, (profileId) => getOverallTotals(profileStats[profileId] ?? DEFAULT_PROFILE_STATS)) } diff --git a/src/utils/statsValidation.ts b/src/games/boxhockey/utils/statsValidation.ts similarity index 98% rename from src/utils/statsValidation.ts rename to src/games/boxhockey/utils/statsValidation.ts index e581107..d5bc596 100644 --- a/src/utils/statsValidation.ts +++ b/src/games/boxhockey/utils/statsValidation.ts @@ -1,6 +1,6 @@ import { DEFAULT_DAY_STREAK, DEFAULT_OUTCOME_RECORD, DEFAULT_WIN_STREAK } from '@tastic/achievements' -import { AiDifficulty, BoardVariant, GoalTotals, MatchRecord, ProfileStats, StatsState, TwoPlayerStats, VsCpuStats } from '@/types' +import { AiDifficulty, BoardVariant, GoalTotals, MatchRecord, ProfileStats, StatsState, TwoPlayerStats, VsCpuStats } from '@/games/boxhockey/types' // Kept out of hooks/useGameStats.tsx so it's testable without dragging in // @react-native-async-storage/async-storage, which throws at import time under Jest's plain Node diff --git a/src/utils/stick.ts b/src/games/boxhockey/utils/stick.ts similarity index 98% rename from src/utils/stick.ts rename to src/games/boxhockey/utils/stick.ts index 8bd02d3..5191752 100644 --- a/src/utils/stick.ts +++ b/src/games/boxhockey/utils/stick.ts @@ -1,7 +1,7 @@ import { boardCornerQuadrant } from '@tastic/physics' -import { MAX_SPEED, STICK_BLADE_RADIUS_RATIO, STICK_CARRY, STICK_MIN_HIT_SPEED, STICK_RESTITUTION, STICK_VELOCITY_SCALE } from '@/constants/game' -import { BoardLayout } from '@/types' +import { MAX_SPEED, STICK_BLADE_RADIUS_RATIO, STICK_CARRY, STICK_MIN_HIT_SPEED, STICK_RESTITUTION, STICK_VELOCITY_SCALE } from '@/games/boxhockey/constants/game' +import { BoardLayout } from '@/games/boxhockey/types' // 'stick' scheme contact physics (see ControlScheme in types/index.ts): the finger is the stick // blade, a small circle, and a shot is what happens when it runs into the puck. Pure, so the diff --git a/src/games/breaker/art/icon-dark.png b/src/games/breaker/art/icon-dark.png new file mode 100644 index 0000000..ff9c8b2 Binary files /dev/null and b/src/games/breaker/art/icon-dark.png differ diff --git a/src/games/breaker/art/icon-light.png b/src/games/breaker/art/icon-light.png new file mode 100644 index 0000000..c2347bc Binary files /dev/null and b/src/games/breaker/art/icon-light.png differ diff --git a/src/games/breaker/index.ts b/src/games/breaker/index.ts new file mode 100644 index 0000000..5dfc36b --- /dev/null +++ b/src/games/breaker/index.ts @@ -0,0 +1,13 @@ +import { createPaddleGame } from '@/shared/paddleGame/createPaddleGame' + +// Breaker: Paddles with a field of bricks between the paddles, from Pong's Bricks toggle, and a +// round alone (seat 2 set to Nobody) that was Pong's Brickbreaker. Its icon is Pong's until Jay draws +// one of its own. +export const breaker = createPaddleGame({ + id: 'breaker', + title: 'Breaker', + blurb: 'The classic brick breaker, solo or head to head.', + icon: { light: require('./art/icon-light.png'), dark: require('./art/icon-dark.png') }, + hasBricks: true, + guideVersion: 1 +}) diff --git a/src/games/paddles/art/icon-dark.png b/src/games/paddles/art/icon-dark.png new file mode 100644 index 0000000..ff9c8b2 Binary files /dev/null and b/src/games/paddles/art/icon-dark.png differ diff --git a/src/games/paddles/art/icon-light.png b/src/games/paddles/art/icon-light.png new file mode 100644 index 0000000..c2347bc Binary files /dev/null and b/src/games/paddles/art/icon-light.png differ diff --git a/src/games/paddles/index.ts b/src/games/paddles/index.ts new file mode 100644 index 0000000..563926e --- /dev/null +++ b/src/games/paddles/index.ts @@ -0,0 +1,12 @@ +import { createPaddleGame } from '@/shared/paddleGame/createPaddleGame' + +// Paddles: Pong with a puck, from the Pong repo (never released on its own). No bricks and no solo +// round; Breaker is the same game with them. +export const paddles = createPaddleGame({ + id: 'paddles', + title: 'Paddles', + blurb: 'The arcade original. Get the puck past their paddle.', + icon: { light: require('./art/icon-light.png'), dark: require('./art/icon-dark.png') }, + hasBricks: false, + guideVersion: 1 +}) diff --git a/src/hooks/useGameSound.ts b/src/hooks/useGameSound.ts deleted file mode 100644 index b40c6b7..0000000 --- a/src/hooks/useGameSound.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Wraps useAudioPool with the app's own sound-enabled setting (Feedback.tsx/SettingsDialog) - -// for gameplay SFX (bounce, goal, faceoff, etc.) that play outside the button-press pipeline, which -// already gates its own `sound` callbacks on this same setting internally (see -// useFeedbackHandlers' `fire`). Was a hand-rolled wrapper, byte-for-byte identical to -// @rific/feedback-press/audio's own useGatedAudioPool - now a thin re-export under this app's own -// name. -export { useGatedAudioPool as useGameSound } from '@rific/feedback-press/audio' diff --git a/src/hooks/useGameStats.tsx b/src/hooks/useGameStats.tsx deleted file mode 100644 index f695a83..0000000 --- a/src/hooks/useGameStats.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { mapUnlocksBySeat, useAchievements } from '@tastic/achievements' -import { createContext, ReactNode, useCallback, useContext, useMemo } from 'react' - -import { ACHIEVEMENT_CATALOG } from '@/constants/achievements' -import { achievementsActions } from '@/redux/achievementsSlice' -import type { RootState } from '@/redux/store' -import { AchievementDefinition, Player, StatsState, UnlockedAchievementsState } from '@/types' -import { applyMatchOutcome, getProfileStatsView, MatchContext, MatchOutcome } from '@/utils/statsEngine' - -export interface RecordMatchResult { - // Device-wide newly-unlocked achievements (bare-id keyed). - device: AchievementDefinition[] - // Per-seat breakdown of what that seat's OWN saved profile newly unlocked - only seats with a - // profile selected ever get an entry. - profiles: Partial> -} - -interface GameStatsContextValue { - stats: StatsState - unlockedAchievements: UnlockedAchievementsState - recordMatchOutcome: (outcome: MatchOutcome, context: MatchContext) => RecordMatchResult - // Wipes both stored keys back to defaults - irreversible, so callers confirm with the player - // first (see achievements.tsx's reset confirmation). - resetAll: () => void - // Drops one profile's stats bucket and its unlock history, so a deleted profile leaves nothing - // orphaned behind. - removeProfileStats: (profileId: string) => void -} - -const GameStatsContext = createContext(null) - -interface Props { - children: ReactNode -} - -function profileViews(stats: StatsState): Record { - return Object.fromEntries(Object.entries(stats.profiles).map(([profileId, bucket]) => [profileId, getProfileStatsView(bucket)])) -} - -// Single source of truth for local stats/achievements (the store's `achievements` slice, see -// redux/achievementsSlice.ts), mounted once in _layout.tsx so useAchievements' mount-time unlock -// re-sweep runs once and every screen shares the same callbacks. -export function GameStatsProvider({ children }: Props) { - const { stats, unlockedAchievements, recordOutcome, resetAll, removeProfile } = useAchievements({ - catalog: ACHIEVEMENT_CATALOG, - select: (state) => state.achievements, - actions: achievementsActions, - profileViews - }) - - const recordMatchOutcome = useCallback( - (outcome: MatchOutcome, context: MatchContext): RecordMatchResult => { - const result = recordOutcome((prev) => applyMatchOutcome(prev, outcome, context)) - - // The package keys per-profile unlocks by profile id; this app thinks in seats, so translate - // back through the same profileIds map that produced them. - const profiles = mapUnlocksBySeat([1, 2] as Player[], context.profileIds, result.profiles) - - return { device: result.device, profiles } - }, - [recordOutcome] - ) - - const removeProfileStats = useCallback( - (profileId: string) => { - removeProfile(profileId, (prev) => { - if (!(profileId in prev.profiles)) return prev - const { [profileId]: _removed, ...rest } = prev.profiles - return { ...prev, profiles: rest } - }) - }, - [removeProfile] - ) - - const value = useMemo(() => ({ stats, unlockedAchievements, recordMatchOutcome, resetAll, removeProfileStats }), [stats, unlockedAchievements, recordMatchOutcome, resetAll, removeProfileStats]) - - return {children} -} - -export function useGameStats() { - const ctx = useContext(GameStatsContext) - if (!ctx) throw new Error('useGameStats must be used within a GameStatsProvider') - return ctx -} diff --git a/src/hooks/useProfiles.tsx b/src/hooks/useProfiles.tsx deleted file mode 100644 index 79d0f72..0000000 --- a/src/hooks/useProfiles.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { createProfileRecord, type Profile as BaseProfile, profilesActions, resolveInitialProfiles, useSharedProfilesSync } from '@tastic/profile' -import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' - -import { profileExtensionsActions } from '@/redux/profileExtensionsSlice' -import { profileSelectionActions } from '@/redux/profileSelectionSlice' -import { type AppDispatch, type RootState } from '@/redux/store' -import { ControlScheme, Player, Profile } from '@/types' - -// Same group id BoxHockey and LightCycles both declare in app.json's ios.entitlements (and any -// future @tastic game that wants into the same shared roster) - see @tastic/profile's own README -// for why only the BASE identity fields travel through this, never controlScheme. -const SHARED_GROUP_ID = 'group.com.infinitetoken.tastic' -// Matches Settings.controlScheme's own default (see redux/settingsSlice.ts's -// DEFAULT_SETTINGS) - what a profile that's never had one set locally on THIS app falls back to. -// That's exactly the state of any profile created on another @tastic app (LightCycles has no -// concept of controlScheme at all) the very first time it's selected here. -const DEFAULT_CONTROL_SCHEME: ControlScheme = 'flick' - -interface CreateProfileInput { - name: string - color: string - tag: string - controlScheme: ControlScheme -} - -interface ProfilesContextValue { - profiles: Profile[] - lastSelected: Record - createProfile: (input: CreateProfileInput) => Profile - updateProfile: (id: string, patch: Partial) => void - // Also clears lastSelected for any seat currently pointing at this id, so /loadout immediately - // reflects Player (no profile) for that seat rather than a dangling id until next reload. - deleteProfile: (id: string) => void - selectProfile: (seat: Player, profileId: string | null) => void - // True once the one-time initial reconciliation below (redux-persist's own already-rehydrated - // `profiles` against the shared App Group store) has completed - see ProfilesProvider's own doc. - // _layout.tsx's own GatedApp reads this directly to gate the 'profiles' splash key, same as it - // already does for useSettings()'s own `loaded`. - loaded: boolean -} - -const ProfilesContext = createContext(null) - -interface Props { - children: ReactNode -} - -// Single source of truth for saved player profiles, mounted once in _layout.tsx - same -// single-Provider rationale as useGameStats.tsx (expo-router keeps prior screens mounted, so a -// per-screen copy could go stale or clobber a concurrent update). -// -// Base identity fields (name/color/tag) now live in Redux (redux/store.ts's `profiles` key, -// @tastic/profile's own profilesReducer) - persisted locally the same way every other slice is -// (redux-persist), with the shared App Group roster layered on top as a separate, cross-app sync -// target (see resolveInitialProfiles/useSharedProfilesSync below) - no separate hand-rolled -// AsyncStorage fallback path needed for that case anymore. controlScheme is BoxHockey's own -// per-profile extension field (LightCycles' analogous field is keyScheme) and deliberately does -// NOT travel through the shared store - it lives entirely in its own redux/profileExtensionsSlice.ts, -// keyed by profile id, and merged onto the shared base roster in the `profiles` value below, falling -// back to DEFAULT_CONTROL_SCHEME the first time a profile created on another app shows up here. -// `lastSelected` is its own small redux/profileSelectionSlice.ts - which profile seat 1/2 last -// picked is naturally a per-app thing, never shared cross-app. -export function ProfilesProvider({ children }: Props) { - const baseProfiles = useSelector((state: RootState) => state.profiles) - const extensions = useSelector((state: RootState) => state.profileExtensions) - const lastSelected = useSelector((state: RootState) => state.profileSelection) - const dispatch = useDispatch() - const [loaded, setLoaded] = useState(false) - - useEffect(() => { - let cancelled = false - // PersistGate (an ancestor - see components/Providers.tsx) already blocks this component from - // mounting until redux-persist has rehydrated, so `baseProfiles` here is already whatever this - // device last had locally - resolveInitialProfiles reconciles that against the shared App - // Group store (if available), seeding the shared store from it on a first sync, or preferring - // the shared roster once anything has been shared. A one-time reconciliation against the - // mount-time snapshot, deliberately not a resync whenever `baseProfiles` changes afterward (the - // empty deps array below is intentional, not a missed dependency - resolved's own setAll - // dispatch would otherwise re-trigger this effect). - resolveInitialProfiles(SHARED_GROUP_ID, baseProfiles).then((resolved) => { - if (cancelled) return - dispatch(profilesActions.setAll(resolved)) - // A seat can still point at a profile a sibling app deleted since this device last ran. - dispatch(profileSelectionActions.pruneToRoster(resolved.map((p) => p.id))) - setLoaded(true) - }) - return () => { - cancelled = true - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // Mirrors local writes out to the shared store (syncToShared, called from each CRUD function - // below) and picks up remote writes a sibling @tastic app made while this one was backgrounded - // (onRemoteChange) - see @tastic/profile's own doc for both. controlScheme never travels through - // this - a remote change only ever replaces the base roster, same as before this migration. - const { syncToShared } = useSharedProfilesSync({ - groupId: SHARED_GROUP_ID, - onRemoteChange: useCallback( - (remote: BaseProfile[]) => { - dispatch(profilesActions.setAll(remote)) - // Same job as deleteProfile's clearProfile below, for profiles a sibling app deleted while this one was backgrounded. - dispatch(profileSelectionActions.pruneToRoster(remote.map((p) => p.id))) - }, - [dispatch] - ) - }) - - const createProfile = useCallback( - (input: CreateProfileInput) => { - const record = createProfileRecord(input) - dispatch(profilesActions.add(record)) - syncToShared([...baseProfiles, record]) - dispatch(profileExtensionsActions.set({ extension: { controlScheme: input.controlScheme }, id: record.id })) - return { ...record, controlScheme: input.controlScheme } - }, - [baseProfiles, dispatch, syncToShared] - ) - - const updateProfile = useCallback( - (id: string, patch: Partial) => { - // Routed to whichever slice actually owns each field - name/color/tag to the (possibly - // shared) base roster, controlScheme to this app's own local extension table. A single call - // can touch both (see ProfilesManager's onSave, which always patches all three base fields - // at once) or just one (loadout.tsx's own control-scheme sync-back, patch-of-one) - two - // independent dispatches in the same handler, not a stale-closure risk now that both live in - // Redux (see profileSelectionSlice.ts's identical reasoning). - const { controlScheme, ...baseChanges } = patch - if (Object.keys(baseChanges).length > 0) { - dispatch(profilesActions.update({ id, patch: baseChanges })) - syncToShared(baseProfiles.map((p) => (p.id === id ? { ...p, ...baseChanges, updatedAt: Date.now() } : p))) - } - if (controlScheme !== undefined) { - dispatch(profileExtensionsActions.set({ extension: { controlScheme }, id })) - } - }, - [baseProfiles, dispatch, syncToShared] - ) - - const deleteProfile = useCallback( - (id: string) => { - dispatch(profilesActions.remove(id)) - syncToShared(baseProfiles.filter((p) => p.id !== id)) - dispatch(profileExtensionsActions.remove(id)) - dispatch(profileSelectionActions.clearProfile(id)) - }, - [baseProfiles, dispatch, syncToShared] - ) - - const selectProfile = useCallback( - (seat: Player, profileId: string | null) => { - dispatch(profileSelectionActions.select({ profileId, seat })) - }, - [dispatch] - ) - - const profiles: Profile[] = baseProfiles.map((p) => ({ ...p, controlScheme: extensions[p.id]?.controlScheme ?? DEFAULT_CONTROL_SCHEME })) - - return {children} -} - -export function useProfiles() { - const ctx = useContext(ProfilesContext) - if (!ctx) throw new Error('useProfiles must be used within a ProfilesProvider') - return ctx -} diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts deleted file mode 100644 index d79d291..0000000 --- a/src/hooks/useSettings.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useCallback } from 'react' -import { useDispatch, useSelector } from 'react-redux' - -import { settingsActions } from '@/redux/settingsSlice' -import type { RootState } from '@/redux/store' -import type { Settings } from '@/types' - -// The one way screens read and write the persisted settings (redux/settingsSlice.ts). setSettings -// takes a partial patch, merged over the current settings. -export function useSettings(): { settings: Settings; setSettings: (patch: Partial) => void } { - const settings = useSelector((state: RootState) => state.settings) - const dispatch = useDispatch() - const setSettings = useCallback( - (patch: Partial) => { - dispatch(settingsActions.initialize(patch)) - }, - [dispatch] - ) - return { settings, setSettings } -} diff --git a/src/importBoxHockey1.ts b/src/importBoxHockey1.ts new file mode 100644 index 0000000..3779f1d --- /dev/null +++ b/src/importBoxHockey1.ts @@ -0,0 +1,91 @@ +import type { PersistedState } from 'redux-persist' + +import { seatPlayerKey } from '@/shared/playerSettings' + +// The one-time import of a BoxHockey 1.0 save into PuckTastic's store (pack.ts's migrate). 1.0 saved +// under 'boxhockey:root' with Box Hockey's keys unprefixed; the pack saves under 'pucktastic:root' +// with every game's keys named for it. On the first launch of a pack build (no pack save yet), a 1.0 +// save is copied over under the new names and shapes, so a 1.0 player keeps their stats, +// achievements, settings and preferences. Every later launch finds the pack save and does nothing. The 1.0 save is left in +// storage, unread from then on. +// +// Temporary (Jay, 2026-10-24): delete this file and pack.ts's migrate once nearly everyone has +// updated past 1.0, no earlier than six months after 2.0 ships and not before May 2027 (TODO.md has +// the steps). A 1.0 player who skips every update until then starts fresh. + +const OLD_KEY = 'boxhockey:root' + +// What 1.0's Box Hockey settings held that is the pack's now: Lock Orientation and its angle, and +// Edge Guard (appSettings), and the last mode picked on 1.0's Home (seat 2, opponent). Each seat's +// shot style is per player now (boxhockeyControls). +interface OneZeroSettings { + gameMode?: 'twoPlayer' | 'vsCpu' + lockOrientation?: boolean + lockedRotation?: unknown + deferBottomEdgeGestures?: boolean + controlScheme?: Record + [field: string]: unknown +} + +type Seat = 1 | 2 +const SEATS: readonly Seat[] = [1, 2] + +// 1.0's keys that are the pack's under the same names (the same slices, shell-wide now). +const SAME_KEYS = ['theme', 'haptic', 'sound', 'keepAwake', 'profiles', 'profileSelection', 'seatColors'] as const + +// 1.0's Box Hockey keys and their names in the pack. +const RENAMED_KEYS = { achievements: 'boxhockeyAchievements', guide: 'boxhockeyGuide' } as const + +export interface StorageReader { + getItem: (key: string) => Promise +} + +// redux-persist saves the root as a JSON object whose every value is itself a JSON string. +function readOneZeroSave(text: string): Record { + const outer = JSON.parse(text) as Record + return Object.fromEntries(Object.entries(outer).map(([key, value]) => [key, JSON.parse(value)])) +} + +// A 1.0 save under the pack's names, or nothing to import. +export function importBoxHockey1(saved: Record): Record { + const imported: Record = {} + for (const key of SAME_KEYS) if (saved[key] !== undefined) imported[key] = saved[key] + for (const [from, to] of Object.entries(RENAMED_KEYS)) if (saved[from] !== undefined) imported[to] = saved[from] + + const settings = saved.settings as OneZeroSettings | undefined + const selection = { 1: null, 2: null, ...(saved.profileSelection as Partial> | undefined) } + const player = (seat: Seat) => seatPlayerKey(selection, seat) + if (settings) { + const { gameMode, lockOrientation, lockedRotation, deferBottomEdgeGestures, controlScheme: _controlScheme, ...match } = settings + // 1.0 had one setup for everyone, last set up from seat 1, so it's seat 1's player's own now + // (the guest, or the profile 1.0 had in seat 1, who loads it). Anyone else starts on the defaults. + imported.boxhockeySettings = { [player(1)]: match } + imported.appSettings = { lockOrientation: lockOrientation ?? false, deferBottomEdgeGestures: deferBottomEdgeGestures ?? false, ...(lockedRotation !== undefined ? { lockedRotation } : {}) } + imported.opponent = { seatTwo: gameMode === 'twoPlayer' ? 'person' : 'cpu' } + } + + // Each seat's shot style goes to the player in it. A profile's own pick (1.0's profileExtensions, + // saved whenever it chose one) wins over the style it only inherited from its seat. + const controls: Record = {} + for (const seat of SEATS) if (settings?.controlScheme?.[seat]) controls[player(seat)] = settings.controlScheme[seat] + const extensions = saved.profileExtensions as Record | undefined + for (const [id, extension] of Object.entries(extensions ?? {})) if (extension?.controlScheme) controls[id] = extension.controlScheme + if (Object.keys(controls).length > 0) imported.boxhockeyControls = controls + return imported +} + +// pack.ts's migrate: redux-persist hands it the pack's own save (undefined on the first launch of a +// pack build), and rehydrates the store from what it returns. +export function createBoxHockey1Import(storage: StorageReader) { + return async function migrate(state: PersistedState): Promise { + if (state) return state + try { + const text = await storage.getItem(OLD_KEY) + if (!text) return state + return importBoxHockey1(readOneZeroSave(text)) as PersistedState + } catch { + // An unreadable 1.0 save starts fresh, like a new install. + return state + } + } +} diff --git a/src/pack.ts b/src/pack.ts new file mode 100644 index 0000000..b260af0 --- /dev/null +++ b/src/pack.ts @@ -0,0 +1,31 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +import { airHockey } from '@/games/airhockey' +import { boxHockey } from '@/games/boxhockey' +import { breaker } from '@/games/breaker' +import { paddles } from '@/games/paddles' +import { createBoxHockey1Import } from '@/importBoxHockey1' +import { createPackStore } from '@/shell/redux/createPackStore' +import type { GameDefinition, PackDefinition } from '@/shell/types' + +// PuckTastic: the pack's identity and its games, in Home's order. Box Hockey first: it's the star, +// and the game BoxHockey 1.0 shipped as. The App Store record, bundle id, EAS project and URL scheme +// all stay BoxHockey's (see app.json). +export const games: readonly GameDefinition[] = [boxHockey, airHockey, paddles, breaker] + +export const pack: PackDefinition = { + name: 'PuckTastic', + wordmark: ['PUCK', 'TASTIC'], + colorCaption: 'PuckTastic only', + games +} + +// The one store, saved under 'pucktastic:root', every game's keys named for it. A BoxHockey 1.0 save +// (under 'boxhockey:root', Box Hockey's keys unprefixed) is copied over once, on the first launch of a +// pack build (see importBoxHockey1.ts). Never change this prefix or a key now: every player would +// start over. +export const { store, persistor } = createPackStore({ + keyPrefix: 'pucktastic:', + gameReducers: Object.assign({}, ...games.map((g) => g.reducers)), + migrate: createBoxHockey1Import(AsyncStorage) +}) diff --git a/src/redux/achievementsSlice.ts b/src/redux/achievementsSlice.ts deleted file mode 100644 index 3731a59..0000000 --- a/src/redux/achievementsSlice.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createAchievementsSlice } from '@tastic/achievements' - -import type { StatsState } from '@/types' -import { DEFAULT_STATS, isValidStats } from '@/utils/statsValidation' - -// This game's stats and unlocked achievements, persisted with the rest of the store and read and -// written only through hooks/useGameStats.tsx (@tastic/achievements' useAchievements). On rehydrate, -// stats that fail isValidStats fall back to DEFAULT_STATS, and migrateStats backfills `profiles` onto -// stats saved without it. -const { actions, reducer } = createAchievementsSlice({ - defaultStats: DEFAULT_STATS, - isValidStats, - migrateStats: (stored) => ({ ...stored, profiles: stored.profiles ?? {} }) -}) - -export const achievementsActions = actions -export default reducer diff --git a/src/redux/guideSlice.ts b/src/redux/guideSlice.ts deleted file mode 100644 index 1d54138..0000000 --- a/src/redux/guideSlice.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { createGuideSlice } from '@tastic/hud/guide' - -import { GUIDE_VERSION } from '@/constants/guide' - -// Thin binding over @tastic/hud/guide's createGuideSlice factory: the persisted "which version of the -// how-to-play flow has this player finished or skipped" flag. A fresh install starts at 0 (the guide -// auto-shows on Home); a player who already had the app before this slice existed is stamped with -// GUIDE_VERSION on their first rehydrate, so an over-the-air update never greets them with a "how to -// play" screen (see the factory's own doc for how it tells the two apart, and LEGACY_STORAGE_KEYS -// below for the players it can't tell apart on its own). Mounted under `guide` in store.ts (that key -// is what the REHYDRATE payload is read from), and store.ts's persistConfig key is 'root', the -// factory's default persistKey, so no options beyond the version are needed. -const slice = createGuideSlice({ currentVersion: GUIDE_VERSION }) - -export const guideActions = slice.actions -export const selectGuideVersionSeen = slice.selectVersionSeen -export default slice.reducer - -// Every AsyncStorage key BoxHockey's shipped builds wrote, for store.ts's createReturningPlayerMigrate. -// The redux-persist root store only arrived in 2af01a6 (2026-09-14), after the last shipped update, so -// no shipped player has one: without this, their first launch of a guide build would look exactly -// like a fresh install. No root store but any of these keys means "played a shipped build". -// -// Read from the last shipped build, ad363aa (otaVersion 5, 2026-08-24); otaVersion 2 to 4 (7b197fb, -// a3824b2, ee5f699) wrote the same five, and otaVersion 1 (28ce396) wrote nothing at all. -// - boxhockey.settings: hooks/useGameSettings.tsx, on any settings change, including picking One -// Player or Two Player on Home, so every player who ever started a match has it. -// - boxhockey.appearance, boxhockey.playerColors: components/Theme.tsx, on an appearance or player -// color change. -// - boxhockey.vibrate, boxhockey.sound: components/Feedback.tsx, on a haptics or sound toggle. -// That build recorded no stats or achievements (useGameStats, boxhockey.stats and -// boxhockey.achievements arrived in 2d44b5f, 2026-09-05), and none of its @rific/@tastic packages -// wrote storage of their own. -// -// Literal strings, not today's constants: this is a record of what those builds wrote, and must not -// change if today's code ever renames a key. Today's build no longer writes boxhockey.settings at all -// (settings live in the Redux store since 2026-10-07), so a fresh install can't have it when the -// migrate runs. -// -// The root store itself moved from persist:root to boxhockey:root on 2026-10-19 (store.ts's keyPrefix: -// a colon, so it can never be one of these dotted keys). persist:root is deliberately not listed: no -// shipped build wrote it, and on the web it is the key every game on games.infinitetoken.com used to -// share, so finding it says nothing about this player having played BoxHockey. -export const LEGACY_STORAGE_KEYS: readonly string[] = ['boxhockey.appearance', 'boxhockey.playerColors', 'boxhockey.settings', 'boxhockey.sound', 'boxhockey.vibrate'] diff --git a/src/redux/liveplaySlice.ts b/src/redux/liveplaySlice.ts deleted file mode 100644 index bc0ae9e..0000000 --- a/src/redux/liveplaySlice.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { createSettingsSlice } from '@rific/core' - -import type { Settings } from '@/types' - -// Transient per-round state, blacklisted from redux-persist in store.ts: none of it may survive a -// relaunch. A crash or force-quit mid-round leaving `activelyPlaying` stuck true in storage would -// re-enable Edge Guard (see Providers.tsx's EdgeGuardBridge) the moment the app cold-launches back -// to the title screen, before any game screen could correct it. Built on @rific/core's -// createSettingsSlice like the persisted `settings` slice: one generated setter per field. -export type LiveplaySliceState = { - // Whether /game has a live, unpaused round in progress right now (not merely "the /game screen is - // mounted"). Written by game.tsx as its phase changes, and reset to false on that screen's unmount - // so leaving mid-round by any path can't leave it stuck true. - activelyPlaying: boolean - // The settings this round was started with: a snapshot of `settings`, committed by /loadout when - // every human player readies up, so changing a setting mid-match only affects the next match. - // null until a round has been committed (a direct link to /game falls back to the defaults). - activeRoundSettings: Settings | null -} - -const { actions, reducer } = createSettingsSlice('liveplay', { - initialState: { activelyPlaying: false, activeRoundSettings: null } -}) - -export const liveplayActions = actions -export default reducer diff --git a/src/redux/profileExtensionsSlice.ts b/src/redux/profileExtensionsSlice.ts deleted file mode 100644 index b308cf5..0000000 --- a/src/redux/profileExtensionsSlice.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createProfileExtensionSlice } from '@tastic/profile' - -import { ControlScheme } from '@/types' - -// This app's own per-profile extension data - controlScheme (LightCycles' analogous field is -// keyScheme) - kept entirely local, always, whether or not the shared App Group store is -// available: it never travels through @tastic/profile's shared base roster (a profile created on -// another @tastic game has no controlScheme of its own at all), same rationale as -// profileSelectionSlice's lastSelected. Keyed by profile id rather than by seat - unlike -// lastSelected, a controlScheme preference belongs to the PROFILE (see useProfiles.tsx's own merge -// into the public Profile shape), not to whichever seat currently has it selected. -// -// The Record reducer itself (set/remove) is @tastic/profile's own -// createProfileExtensionSlice factory - this file just supplies the namespace and the -// app-specific extension shape. -export interface ProfileExtension { - controlScheme: ControlScheme -} - -const { actions: profileExtensionsActions, reducer: profileExtensionsReducer } = createProfileExtensionSlice('controlScheme') - -export { profileExtensionsActions } -export default profileExtensionsReducer diff --git a/src/redux/profileSelectionSlice.ts b/src/redux/profileSelectionSlice.ts deleted file mode 100644 index d53b156..0000000 --- a/src/redux/profileSelectionSlice.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createProfileSelectionSlice } from '@tastic/profile' - -import { Player } from '@/types' - -// Which profile each seat last picked - always local, never part of the shared App Group roster -// (@tastic/profile's own profiles slice, see store.ts): who's selected on THIS device is naturally -// a per-app, per-device thing, not something a sibling @tastic game should see or influence. This -// was a hand-rolled Record reducer identical to 4 sibling apps' own copies -// before @tastic/profile's createProfileSelectionSlice factory existed to generalize it away. -export type ProfileSelectionState = Record - -export const defaultProfileSelectionState: ProfileSelectionState = { 1: null, 2: null } - -const { actions, reducer } = createProfileSelectionSlice('boxhockey', defaultProfileSelectionState) - -export const profileSelectionActions = actions -export default reducer diff --git a/src/redux/seatColorsSlice.ts b/src/redux/seatColorsSlice.ts deleted file mode 100644 index 8920e0c..0000000 --- a/src/redux/seatColorsSlice.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createSeatColorsSlice, type SeatColorsState as GenericSeatColorsState } from '@tastic/profile' - -import { DEFAULT_P1_COLOR, DEFAULT_P2_COLOR } from '@/constants/game' -import { Player } from '@/types' - -// The loadout's per-seat color memory: a thin binding over @tastic/profile's createSeatColorsSlice -// (see its doc for each field: a guest seat's last color, the CPU seat's last color, and each -// profile's own color pick in BoxHockey, kept by profile id). Not a setting: the player's settings -// live in settingsSlice.ts. -export type SeatColorsState = GenericSeatColorsState - -export const defaultSeatColorsState: SeatColorsState = { - lastGuestColor: { 1: DEFAULT_P1_COLOR, 2: DEFAULT_P2_COLOR }, - lastCpuColor: DEFAULT_P2_COLOR, - profileColors: {} -} - -const slice = createSeatColorsSlice('boxhockey', defaultSeatColorsState) - -export const seatColorsActions = slice.actions -export default slice.reducer diff --git a/src/redux/store.ts b/src/redux/store.ts deleted file mode 100644 index 7b929b2..0000000 --- a/src/redux/store.ts +++ /dev/null @@ -1,124 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' -import { combineReducers, configureStore, type Middleware } from '@reduxjs/toolkit' -import { createThemeReducer, getThirdColor } from '@rific/auto-paper' -import { keepAwakeReducer } from '@rific/core' -import { defaultSoundSettings, hapticReducer, soundReducer, type SoundSettings } from '@rific/feedback-press' -import { createReturningPlayerMigrate } from '@tastic/hud/guide' -import { profilesReducer } from '@tastic/profile' -import { FLUSH, PAUSE, PERSIST, persistReducer, persistStore, PURGE, REGISTER, REHYDRATE } from 'redux-persist' - -import { DEFAULT_P1_COLOR, DEFAULT_P2_COLOR } from '@/constants/game' - -import achievements from './achievementsSlice' -import guide, { LEGACY_STORAGE_KEYS } from './guideSlice' -import liveplay from './liveplaySlice' -import profileExtensions from './profileExtensionsSlice' -import profileSelection from './profileSelectionSlice' -import seatColors from './seatColorsSlice' -import settings from './settingsSlice' - -const hasError = (action: unknown): action is { error?: unknown } => typeof action === 'object' && action !== null && 'error' in action && Boolean(action.error) - -const errorMiddleware: Middleware = () => (next) => (action) => { - if (!hasError(action)) return next(action) - return action -} - -// @rific/feedback-press's own soundReducer defaults `enabled` to true unconditionally (that -// default isn't published with the dev-only override yet). Wrap it so a fresh install with no -// persisted preference (redux-persist finds nothing in AsyncStorage for the `sound` key) defaults -// muted in dev/simulator builds so Claude/local testing doesn't blast audio; production builds -// still default to sound on. All actual action handling still delegates to the package's reducer. -const initialSoundSettings: SoundSettings = { ...defaultSoundSettings, enabled: !__DEV__ } -const appSoundReducer = (state: SoundSettings = initialSoundSettings, action: { type: string }): SoundSettings => soundReducer(state, action) - -// Explicit primary/secondary triad (rather than a single seed expanded via harmony) so this app's -// red/blue identity is pinned exactly instead of drifting with whatever hue math a harmony offset -// would produce. Primary/secondary ARE player 1/player 2's own default colors (DEFAULT_P1_COLOR/ -// DEFAULT_P2_COLOR, see constants/game.ts) rather than independently-chosen hexes, so the app theme -// and the default guest colors (seatColorsSlice.ts's own defaultSeatColorsState) can never drift apart. -// Tertiary is still derived, not picked, via getThirdColor: the hue maximally distant from both -// primary and secondary (see @rific/auto-paper's README "Explicit triad" section). -const themeReducer = createThemeReducer({ - color: { primary: DEFAULT_P1_COLOR, secondary: DEFAULT_P2_COLOR, tertiary: getThirdColor(DEFAULT_P1_COLOR, DEFAULT_P2_COLOR) } -}) - -const rootReducer = combineReducers({ - // Stats and unlocked achievements (see achievementsSlice.ts). - achievements, - theme: themeReducer, - haptic: hapticReducer, - sound: appSoundReducer, - // @rific/core's Keep Awake setting (see Providers.tsx's KeepAwakeBridge), under the namespace its - // REHYDRATE handling expects. - keepAwake: keepAwakeReducer, - // The shared App Group roster (see @tastic/profile's resolveInitialProfiles/ - // useSharedProfilesSync, wired in hooks/useProfiles.tsx) is layered on top of this as a separate - // sync target - this slice persisting normally via redux-persist, same as every other key here, - // is what gives it local-fallback behavior for the platforms/builds where the shared store isn't - // available, with no separate hand-rolled AsyncStorage path needed for that case. - profiles: profilesReducer, - profileSelection, - // This app's own local controlScheme-per-profile-id extension table (LightCycles' analogous - // field is keyScheme) - deliberately outside the shared roster above; see - // profileExtensionsSlice.ts's own doc comment. - profileExtensions, - // /loadout's own dedicated "remember this color for next time" memory (guest, CPU, and each - // profile's pick in BoxHockey) - see seatColorsSlice.ts's own doc comment for why this is separate - // from `theme` above (theme always reflects whatever's live RIGHT NOW; this only holds what each - // source last picked). - seatColors, - // Which version of the how-to-play flow this player has finished or skipped: its own key rather - // than a Settings field, so it survives the stats screen's reset and the loadout's match-settings - // reset. Persisted like every other key here except liveplay. See guideSlice.ts's own doc. - guide, - // The persisted settings (see settingsSlice.ts). - settings, - // Transient per-round state, the one key blacklisted from persistence below (see liveplaySlice.ts). - liveplay -}) - -const persistConfig = { - key: 'root', - // On web every Tastic game shares one origin (games.infinitetoken.com) and so one localStorage, where AsyncStorage - // keeps this state under keyPrefix + key: the default prefix would make every game's state the same persist:root. - // REHYDRATE still reports key 'root', so guideSlice's persistKey is unchanged. It applies on native too; a save under - // the old persist:root is left in storage, never read (no shipped build had a root store, see LEGACY_STORAGE_KEYS). - // This is the only key the app writes itself (the shared profile roster is @tastic/profile's, shared on purpose). - keyPrefix: 'boxhockey:', - storage: AsyncStorage, - // redux-persist defaults `timeout` to 5000ms: a failsafe setTimeout scheduled on every PERSIST - // dispatch to force-resolve rehydrate if storage never responds. It's never cleared once - // rehydrate resolves normally (only guarded by an internal `_sealed` flag), so it sits as a - // pending timer for up to 5s after every store creation - under Jest that's a real open handle - // ("A worker process has failed to exit gracefully"), confirmed via `jest --detectOpenHandles` - // pointing straight at persistReducer.js's setTimeout. Disabling it (falsy timeout skips the - // setTimeout call entirely) is redux-persist's own documented way to opt out; AsyncStorage reads - // failing to ever resolve at all isn't a failure mode worth a 5s failsafe for. - timeout: 0, - // liveplay is per-round state that must never survive a relaunch (see liveplaySlice.ts). - blacklist: ['liveplay'], - // Grandfathers players from the shipped pre-redux builds past the guide: no root store yet, but a - // key those builds wrote, rehydrates as an existing player instead of a fresh install. A no-op once - // the root store exists. See LEGACY_STORAGE_KEYS in guideSlice.ts. - migrate: createReturningPlayerMigrate(AsyncStorage, LEGACY_STORAGE_KEYS) -} - -const persistedReducer = persistReducer(persistConfig, rootReducer) - -export const store = configureStore({ - middleware: (getDefaultMiddleware) => { - const defaultMiddleware = getDefaultMiddleware({ - immutableCheck: false, - serializableCheck: { - ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER] - } - }) - return defaultMiddleware.concat(errorMiddleware) - }, - reducer: persistedReducer -}) - -export const persistor = persistStore(store) -export type RootState = ReturnType -export type AppDispatch = typeof store.dispatch diff --git a/src/shared/PlayerRankings.tsx b/src/shared/PlayerRankings.tsx new file mode 100644 index 0000000..2bce4f2 --- /dev/null +++ b/src/shared/PlayerRankings.tsx @@ -0,0 +1,69 @@ +import { StatSection, useWellColors } from '@tastic/hud' +import { type Profile, ProfileChip } from '@tastic/profile' +import { StyleSheet, View } from 'react-native' +import { Text } from 'react-native-paper' + +import { MONO_FONT } from '@/shell/fonts' + +export interface ProfileRanking { + profile: Profile + played: number + wins: number + losses: number + winRate: number +} + +// Every saved profile's record in one game, most wins first (then best win rate), from the game's +// own per-profile totals. +export function rankProfiles(profiles: Profile[], totalsFor: (profileId: string) => { played: number; wins: number; losses: number }): ProfileRanking[] { + return profiles + .map((profile) => { + const totals = totalsFor(profile.id) + return { profile, ...totals, winRate: totals.played > 0 ? totals.wins / totals.played : 0 } + }) + .sort((a, b) => b.wins - a.wins || b.winRate - a.winRate) +} + +// A game's PLAYER RANKINGS section on the stats screen: one row per saved profile, a ProfileChip +// (the profile's own color and tag) plus its name and record. A leaderboard across profiles answers +// a cross-profile question, so a game shows it only on Everyone. @tastic/hud's StatRow has no +// leading identity chip, hence a row of its own. The text sits in a StatSection well, so it takes +// the well's own colors (useWellColors: onSurface, onSurfaceVariant), as StatRow does. +export function PlayerRankingsSection({ rankings }: { rankings: ProfileRanking[] }) { + const { fg, fgMuted } = useWellColors() + if (rankings.length === 0) return null + return ( + + {rankings.map(({ profile, wins, losses, played }) => ( + + + + + {profile.name} + + + + {wins}-{losses} + ({played} played) + + + ))} + + ) +} + +const styles = StyleSheet.create({ + boldText: { + fontWeight: 'bold' + }, + profileValue: { + alignItems: 'center', + flexDirection: 'row', + gap: 8 + }, + statRow: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between' + } +}) diff --git a/src/components/SeatResultCards.tsx b/src/shared/SeatResultCards.tsx similarity index 95% rename from src/components/SeatResultCards.tsx rename to src/shared/SeatResultCards.tsx index 664f92f..35a5f83 100644 --- a/src/components/SeatResultCards.tsx +++ b/src/shared/SeatResultCards.tsx @@ -1,12 +1,11 @@ import { Button } from '@rific/feedback-press' import { overlayActionStyles, OverlayBackdrop, OverlayCard, useOverlayCardColors } from '@tastic/hud' -import { Platform, StyleSheet, View } from 'react-native' +import { StyleSheet, View } from 'react-native' import { Icon, Text } from 'react-native-paper' -import type { SeatToastSpot } from '@/components/SeatToasters' -import type { Player } from '@/types' - -const MONO_FONT = Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' }) +import type { SeatToastSpot } from '@/shared/SeatToasters' +import { MONO_FONT } from '@/shell/fonts' +import type { Seat as Player } from '@/shell/types' const SEATS: readonly Player[] = [1, 2] diff --git a/src/components/SeatToasters.tsx b/src/shared/SeatToasters.tsx similarity index 98% rename from src/components/SeatToasters.tsx rename to src/shared/SeatToasters.tsx index 32c0755..55f866d 100644 --- a/src/components/SeatToasters.tsx +++ b/src/shared/SeatToasters.tsx @@ -4,7 +4,7 @@ import { type ReactNode, useCallback } from 'react' import { StyleSheet, useWindowDimensions, View } from 'react-native' import { type EdgeInsets, SafeAreaInsetsContext, useSafeAreaInsets } from 'react-native-safe-area-context' -import { SEAT_TOAST_STACKS } from '@/hooks/useUnlockToasts' +import { SEAT_TOAST_STACKS } from '@/shared/useUnlockToasts' /** Where one 2 Player seat's toasts go: the half of the screen that's theirs (the screen as the app lays it out, portrait-locked, so 'top' * is the device's own top half however it's held), and the angle they read the screen at. */ diff --git a/src/shared/controlScheme.ts b/src/shared/controlScheme.ts new file mode 100644 index 0000000..d813e17 --- /dev/null +++ b/src/shared/controlScheme.ts @@ -0,0 +1,15 @@ +import { Direction, KeyScheme, resolveSchemeDirection } from '@tastic/input' + +// Which physical input drives a seat's paddle or mallet on desktop web: the mouse (dragging, through +// the game's TouchInputLayer) or one of @tastic/input's key clusters (through its +// KeyboardInputLayer). Air Hockey and Paddles/Breaker share it. +export type ControlScheme = KeyScheme | 'mouse' + +// The one entry point a KeyboardInputLayer routes every keydown/keyup through. 'mouse' (the scheme +// with no keys of its own) always resolves to null; every other scheme is a KeyScheme, resolved by +// @tastic/input's own resolveSchemeDirection (which already knows numpad's NumLock-independent +// .code quirk - see that package's own doc). +export function resolveControlSchemeDirection(event: Pick, scheme: ControlScheme): Direction | null { + if (scheme === 'mouse') return null + return resolveSchemeDirection(event, scheme) +} diff --git a/src/shared/gameStats.tsx b/src/shared/gameStats.tsx new file mode 100644 index 0000000..5d81081 --- /dev/null +++ b/src/shared/gameStats.tsx @@ -0,0 +1,90 @@ +import { type AchievementDefinition, type AchievementsState, createAchievementsSlice, mapUnlocksBySeat, type UnlockedAchievementsState, useAchievements } from '@tastic/achievements' +import { createContext, ReactNode, useCallback, useContext, useMemo } from 'react' + +import { forgetProfileAchievements, onProfileRemoved } from '@/shell/redux/onProfileRemoved' +import type { Seat } from '@/shell/types' + +const SEATS: readonly Seat[] = [1, 2] + +// What a recorded result unlocked: device-wide, and per seat for each seat with a profile in it (the +// package keys per-profile unlocks by profile id; the games think in seats). +export interface RecordResult { + device: AchievementDefinition[] + profiles: Partial[]>> +} + +interface GameStatsContextValue { + stats: TStats + unlockedAchievements: UnlockedAchievementsState + // Applies a result to the stats and returns what it unlocked. `profileIds` maps each seat to the + // profile in it, for the per-seat breakdown. + record: (update: (prev: TStats) => TStats, profileIds?: Partial>) => RecordResult + // Wipes the game's stats and achievements back to defaults - irreversible, so callers confirm with + // the player first (the shell's stats screen reset). + resetAll: () => void +} + +interface GameStatsOptions { + // The store key, which is also where the stats are saved. + key: string + defaultStats: TStats + isValidStats?: (value: unknown) => boolean + catalog: AchievementDefinition[] + // A profile's own bucket seen through the same shape as the device-wide stats, so every + // achievement predicate reads either. + getProfileStatsView: (bucket: TBucket) => TStats +} + +// One game's stats and achievements: its store slice (@tastic/achievements' createAchievementsSlice +// under the game's own key, with a profile deleted on the Profiles screen taking its stats bucket and +// unlocks with it), and the context every screen reads them through, mounted once at the app root +// (GameDefinition.Provider) so useAchievements' mount-time unlock re-sweep runs once. Every game keeps +// its stats with a `profiles` bucket per profile beside the device-wide totals. +export function createGameStats }, TBucket>({ key, defaultStats, isValidStats, catalog, getProfileStatsView }: GameStatsOptions) { + const { actions, reducer: sliceReducer } = createAchievementsSlice({ + key, + defaultStats, + isValidStats, + // Backfills `profiles` onto stats saved without it. + migrateStats: (stored) => ({ ...stored, profiles: stored.profiles ?? {} }) + }) + + const dropProfileStats = (stats: TStats, profileId: string): TStats => { + if (!(profileId in stats.profiles)) return stats + const { [profileId]: _removed, ...rest } = stats.profiles + return { ...stats, profiles: rest } + } + const reducer = onProfileRemoved(sliceReducer, forgetProfileAchievements>(dropProfileStats)) + + const profileViews = (stats: TStats): Record => Object.fromEntries(Object.entries(stats.profiles).map(([profileId, bucket]) => [profileId, getProfileStatsView(bucket)])) + + const Context = createContext | null>(null) + + function Provider({ children }: { children: ReactNode }) { + const { stats, unlockedAchievements, recordOutcome, resetAll } = useAchievements>>({ + catalog, + select: (state) => state[key], + actions, + profileViews + }) + + const record = useCallback( + (update: (prev: TStats) => TStats, profileIds?: Partial>): RecordResult => { + const result = recordOutcome(update) + return { device: result.device, profiles: mapUnlocksBySeat(SEATS, profileIds, result.profiles) } + }, + [recordOutcome] + ) + + const value = useMemo(() => ({ stats, unlockedAchievements, record, resetAll }), [stats, unlockedAchievements, record, resetAll]) + return {children} + } + + function useStats(): GameStatsContextValue { + const ctx = useContext(Context) + if (!ctx) throw new Error(`The ${key} stats are read within their game's Provider`) + return ctx + } + + return { reducer, actions, Provider, useStats } +} diff --git a/src/shared/lazySkia.tsx b/src/shared/lazySkia.tsx new file mode 100644 index 0000000..6665836 --- /dev/null +++ b/src/shared/lazySkia.tsx @@ -0,0 +1,23 @@ +import { loadSkiaWeb } from '@tastic/hud/skia' +import { type ComponentType, lazy, Suspense } from 'react' + +// A Skia board for the web (every game's BoardHost.web.tsx). Skia's web target renders through +// CanvasKit, a WASM build of Skia, and a board's own `Skia` import binds to `global.CanvasKit` once, +// synchronously, at module-evaluation time (see @shopify/react-native-skia's Skia.web.ts: `export +// const Skia = JsiSkApi(global.CanvasKit)`) - not lazily, and not reactively. A plain import of the +// board would evaluate that binding against a not-yet-loaded, undefined CanvasKit before anything +// renders, since Metro evaluates the whole static import graph up front. A dynamic import is the one +// thing that defers module evaluation to runtime, so the board module (and Skia with it) is only +// imported once loadSkiaWeb() has resolved and set global.CanvasKit for real. On native there's +// nothing to defer (JSI is ready before any JS runs), so each game's BoardHost.tsx just re-exports +// its board. +export function lazySkia

(load: () => Promise<{ default: ComponentType

}>): ComponentType

{ + const LazyBoard = lazy(() => loadSkiaWeb().then(load)) + return function SkiaHost(props: P) { + return ( + + + + ) + } +} diff --git a/src/shared/paddleGame/PlayScreen.tsx b/src/shared/paddleGame/PlayScreen.tsx new file mode 100644 index 0000000..75c0f0d --- /dev/null +++ b/src/shared/paddleGame/PlayScreen.tsx @@ -0,0 +1,616 @@ +import { useAutoPaperTheme } from '@rific/auto-paper' +import { useKeepAwake } from '@rific/core' +import { Button, IconButton, useVibration } from '@rific/feedback-press' +import { useToast } from '@rific/toaster' +import { broadcastDeviceUnlocks } from '@tastic/achievements' +import { rotateInsets, useBackgroundPause, useOrientationState } from '@tastic/core' +import { ConfirmDialog, overlayActionStyles, OverlayBackdrop, OverlayCard, useOverlayCardColors, useQuitConfirmation } from '@tastic/hud' +import { ProfileChip } from '@tastic/profile' +import { needsSharedNeutralZone } from '@tastic/split-screen' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Platform, StyleSheet, useWindowDimensions, View } from 'react-native' +import { Icon, Text } from 'react-native-paper' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useDispatch } from 'react-redux' +import type { Action, Dispatch } from 'redux' + +import Board from '@/shared/paddleGame/components/BoardHost' +import KeyboardInputLayer from '@/shared/paddleGame/components/KeyboardInputLayer' +import TiltInputLayer from '@/shared/paddleGame/components/TiltInputLayer' +import TouchInputLayer from '@/shared/paddleGame/components/TouchInputLayer' +import { DESKTOP_WEB_MIN_WIDTH, SOLO_LIVES } from '@/shared/paddleGame/constants/game' +import { useAIOpponent } from '@/shared/paddleGame/hooks/useAIOpponent' +import { useGameState } from '@/shared/paddleGame/hooks/useGameState' +import type { PaddleGameRecorders } from '@/shared/paddleGame/stats' +import { Player, Settings } from '@/shared/paddleGame/types' +import { computeBoardLayout } from '@/shared/paddleGame/utils/board' +import { tiltControlActiveFor } from '@/shared/paddleGame/utils/gameMode' +import { seatResultSpots } from '@/shared/paddleGame/utils/seatResultSpots' +import { SeatResultCards } from '@/shared/SeatResultCards' +import { SeatToasters } from '@/shared/SeatToasters' +import { useGameSound } from '@/shared/useGameSound' +import { SEAT_TOAST_STACKS, useUnlockToasts } from '@/shared/useUnlockToasts' +import { SettingsDialog } from '@/shell/components/SettingsDialog' +import { MONO_FONT } from '@/shell/fonts' +import { useActiveRound } from '@/shell/hooks/useActiveRound' +import { useProfiles } from '@/shell/hooks/useProfiles' +import { safeBack } from '@/shell/navigation' +import { liveplayActions } from '@/shell/redux/liveplaySlice' +import { humanSeatsFor } from '@/shell/types' + +// Converts a point expressed in the board's own LOCAL coordinate space (the same frame board.x/y/ +// paddleX live in) into a real, absolute screen position - the exact inverse of TouchInputLayer's +// own screenToLocalY, generalized to both axes since floating UI (score labels, corner buttons) +// needs a full 2D screen position, not just a Y. Used to anchor floating UI to a point ON the board +// (e.g. "near board's own top-left corner") so it lands in the correct real screen location +// regardless of live rotation, mirroring how BoxHockey's MatchOverlays rotates its dialog card in +// place rather than reflowing layout. +function localToScreen(localX: number, localY: number, containerWidth: number, containerHeight: number, screenCenterX: number, screenCenterY: number, rotation: 0 | 90 | -90): { x: number; y: number } { + const lx = localX - containerWidth / 2 + const ly = localY - containerHeight / 2 + let dx: number + let dy: number + if (rotation === 90) { + dx = -ly + dy = lx + } else if (rotation === -90) { + dx = ly + dy = -lx + } else { + dx = lx + dy = ly + } + return { x: screenCenterX + dx, y: screenCenterY + dy } +} + +const SEATS: readonly Player[] = [1, 2] +// Vs CPU and Solo have one human, seat 1. +const SOLO_SEATS: readonly Player[] = [1] + +interface PaddlePlayScreenProps extends PaddleGameRecorders { + // The game's id, to read back the round its loadout committed (see useActiveRound). + game: string + // The game's default settings, for a play screen reached without a committed round. + defaults: Settings + // Whether every round has a brick field between the paddles (Breaker) or none (Paddles). A solo + // round always has one. + hasBricks: boolean +} + +// The paddle games' play screen (Paddles and Breaker, from Pong): each game's own PlayScreen hands +// it the game's id, defaults, bricks and stats recorders. +export function PaddlePlayScreen({ game, defaults, hasBricks, recordMatchOutcome, recordSoloOutcome }: PaddlePlayScreenProps) { + // Committed once by the loadout when every human player readied up (see the shell's + // liveplaySlice.ts and useActiveRound) - falls back to the game's defaults against the CPU for a + // direct link to this screen, which never went through the loadout to commit anything. + const { mode: gameMode, settings: roundSettings } = useActiveRound(game, defaults) + const dispatch = useDispatch>() + const { clear: clearToasts } = useToast() + const showUnlockToasts = useUnlockToasts() + const scoreToWin = roundSettings.scoreToWin + const paddleSize = roundSettings.paddleSize + const ballSpeed = roundSettings.ballSpeed + const isSolo = gameMode === 'solo' + // Only P2 can be CPU-controlled - pass-and-play stays the default, single-player just swaps who + // controls P2's paddle (see useAIOpponent, which drives it through the exact same setPaddleTarget + // path a human's touch uses). null for solo too - there's no seat 2 to control at all. + const aiPlayer: Player | null = gameMode === 'vsCpu' ? 2 : null + const aiDifficulty = roundSettings.aiDifficulty + const humanPlayers = humanSeatsFor(gameMode) + const tiltControlActive = tiltControlActiveFor(gameMode, roundSettings.tiltControlEnabled) + + // ─── Orientation / rotation ──────────────────────────────────────────────── + // orientationMode/upsideDown are live accelerometer tilt readings, frozen at their last value while + // the Lock Orientation setting is on. That lock is app-wide (_layout.tsx's OrientationProvider + // freezes the shared reading under `freezeWhileLocked`), so it's live settings, not roundSettings, + // and toggling it from this screen's own Settings dialog applies mid-round. The only per-screen + // lock left here is tilt control's, below. + // + // DELIBERATE DEVIATION from calling @tastic/core's getFixedZoneRotation directly: that + // function (verified against the installed package's actual source, src/rotation.ts) returns 0 for + // 'faceToFace' and ±90 for 'sideBySide' - the correct polarity for BoxHockey/AirHockey's own board, + // which is PORTRAIT-native in local space (goals/paddle-separation along local Y) and so needs zero + // rotation exactly when the device is held upright/portrait (faceToFace) and a compensating ±90 + // only when physically turned sideways (sideBySide) to still read correctly. Pong's board is the + // mirror image - LANDSCAPE-native in local space (paddle separation along local X, the long axis - + // see BoardLayout's own doc comment in types/index.ts) - so it needs the OPPOSITE polarity: zero + // rotation for sideBySide (the device is already physically turned so its long edge runs left- + // right, matching Pong's native wide shape with no correction needed) and ±90 for faceToFace (the + // device stays portrait/unrotated under app.json's OS-level portrait lock, so the wide board must + // still be rotated to present correctly to two players seated at its near/far ends - the same + // swap-and-rotate escape hatch FakeLandscapeView itself uses for wide content, just triggered by + // the opposite orientationMode, since FakeLandscapeView's own content - the home/loadout screens - + // is authored tall/stacked-native instead). A literal `getFixedZoneRotation(orientationMode, ...)` + // call here would rotate the board exactly backwards (rotating the one mode that's already correct, + // and leaving unrotated the one mode that genuinely needs it) - confirmed by cross-checking against + // AirHockey's own OLD (portrait-native) rotation wrapper, whose goalAxis === 'horizontal' branch + // (its own sideBySide-equivalent) is the one that rotates, mirroring getFixedZoneRotation's real + // convention for tall-native content exactly, which is the mirror image of what a wide-native board + // needs. + // + // The sign for faceToFace is keyed off `upsideDown` (the only live-varying signal in that mode - + // useOrientationState's own faceToFace reading hardcodes p1OnRight: true, so it can never + // actually distinguish anything) rather than derived from theory - like AirHockey's original + // rotation wrapper, whose own rotation SIGN was "confirmed empirically" against the real render + // rather than reasoned out in the abstract, this polarity should be spot-checked in a simulator by + // rotating through both faceToFace placements and flipped if player 1's paddle doesn't end up + // nearest whichever seat is holding the device's own "right-side-up" reading direction. + // `locked` = tiltControlActive: a per-screen lock on top of the app-wide Lock Orientation setting, + // forced whenever tilt steering is active even with that setting off. This same reading drives the + // board's own `rotation` below, and a sustained extreme steering tilt held past this hook's own + // ~275ms commit window could otherwise cross its dominance-ratio threshold and flip + // orientationMode/upsideDown, visually rotating the whole board mid-rally. roundSettings is frozen + // for the entire round (see useActiveRound), so this engages the + // instant this screen mounts (well before any actual tilt-steering begins) and never toggles again + // mid-round. + const { orientationMode, upsideDown } = useOrientationState(tiltControlActive) + // Player 1 sits at the visual bottom of a portrait/faceToFace hold (-90; the top when held upside + // down, 90): the natural single-hand grip for vsCpu's lone human, and, in 2 Player, the same half + // the loadout gives P1 (its panels flip with an upside-down hold too). 2 Player used to take the + // opposite sign, which put P1 at the top of the court after picking the bottom panel. + // + // 2 Player on a phone also plays face-to-face in a landscape hold (fixedSeats): the loadout pins + // 2 Player's panels top/bottom there, so each player keeps the same half of the phone from the + // loadout into the match. The unrotated side-by-side court split the phone lengthwise instead, + // which the loadout can't show. + const fixedSeats = Platform.OS !== 'web' && humanPlayers.length === 2 + const rotation: 0 | 90 | -90 = orientationMode !== 'faceToFace' && !fixedSeats ? 0 : upsideDown ? 90 : -90 + + const { width, height } = useWindowDimensions() + const insets = useSafeAreaInsets() + // Remaps insets onto the correct visual edge for the board's own rotated container - see + // rotateInsets' own doc comment in @tastic/core. Applied wherever this screen's floating + // UI needs to reason about the board's own (possibly rotated) edges, via computeBoardLayout below + // (board.x/y/width/height end up correctly inset once, not the real screen's raw insets). + const rotatedInsets = useMemo(() => rotateInsets(insets, rotation), [insets, rotation]) + + // Pre-rotation container dimensions: real width/height directly for sideBySide (no swap - local + // space maps straight onto the screen), SWAPPED for faceToFace (sizes the local rect correctly for + // the container that's about to be rotated 90°/-90° to display it) - see computeBoardLayout's own + // NOTE on the orientation layer in utils/board.ts. + const containerWidth = rotation === 0 ? width : height + const containerHeight = rotation === 0 ? height : width + + const board = useMemo(() => computeBoardLayout(containerWidth, containerHeight, rotatedInsets, paddleSize), [containerWidth, containerHeight, rotatedInsets, paddleSize]) + + // Settings/quit-confirm dialogs pause gameplay while open. + const [settingsOpen, setSettingsOpen] = useState(false) + // useQuitConfirmation reads hasProgress lazily (only inside a real requestBack() call, via its + // own ref-mirrored getter - see that hook's own doc) rather than taking a plain boolean, which is + // exactly what makes this safe to call before `state` exists yet: `state` (lives/bricks/scores) + // comes from useGameState, which itself needs `paused` (derived from confirmVisible) as an INPUT, + // so a plain-boolean argument computed from `state` here would be a real circular render + // dependency. hasProgressRef breaks the remaining direct textual reference to lives/bricks/scores + // (react-hooks/immutability flags reading a binding before its own declaration, even one only + // ever invoked later - see the mirroring effect right after `state` is destructured below) - + // it's reassigned every render via the same effect-based "ref mirrors latest value" idiom this + // file already uses for vibrationRef/soundRef, so requestBack still always calls whatever + // lives/bricks/scores were bound to as of the most recently committed render, same as before. + // scores stays [0,0] the whole round in solo (see useGameState's TICK case) - a lost life or a + // broken brick is solo's own equivalent of "there's progress worth confirming before losing it". + const hasProgressRef = useRef<() => boolean>(() => false) + const { confirmVisible, requestBack, cancelBack } = useQuitConfirmation(() => hasProgressRef.current(), safeBack) + // Leaving the app mid-match (home, app switch, a phone call, Control Center) latches a pause that + // only the Paused dialog's Resume clears - see @tastic/core's useBackgroundPause. Skipped once the + // match is over, or while a dialog already holds the pause by itself. + const { backgroundPaused, resume } = useBackgroundPause(() => phase !== 'gameOver' && !settingsOpen && !confirmVisible) + // Keeps the screen from dimming and locking while a match is up, unless Keep Awake is off in + // Settings (read live, so the toggle applies mid-match). Expo only does this by itself in + // development builds, and the OS auto-lock only counts screen touches, so tilt or keyboard + // steering, or watching the CPU, could let a release build lock mid-match. The setting and the + // expo-keep-awake module are @rific/core's, from Providers.tsx's KeepAwakeBridge. + useKeepAwake('game') + const paused = settingsOpen || confirmVisible || backgroundPaused + + const { state, setPaddleTarget, resetGame } = useGameState(board, scoreToWin, ballSpeed, gameMode, hasBricks, paused) + const { phase, ball, paddles, scores, bounceCount, bricks, lives } = state + + // See hasProgressRef's own comment above - mirrors the real hasProgress check into it every + // render, now that lives/bricks/scores actually exist to read. + useEffect(() => { + // Once the match is over (gameOver) its stats are already recorded and nothing is lost, so back + // exits at once, the same as the Game Over card's own buttons. + hasProgressRef.current = () => phase !== 'gameOver' && (isSolo ? lives < SOLO_LIVES || bricks.some((b) => b.destroyed) : scores[0] > 0 || scores[1] > 0) + }) + + // Mirrors "an actual live rally, not paused" into liveplay.activelyPlaying for Providers.tsx's + // EdgeGuardBridge rather than calling useEdgeGestureGuard directly here: it stays mounted once for + // the app's lifetime, and this is just the one signal it has no other way to read. Unlocked between points (idle/pointScored/gameOver) and whenever a + // dialog is open (paused) - back/settings stay unconditionally visible here (unlike Snake/ + // LightCycles), so there's no button-visibility signal to key off instead. Reset to false on + // unmount too, so leaving this screen mid-rally by any path can't leave Edge Guard stuck on. + useEffect(() => { + dispatch(liveplayActions.setActivelyPlaying(phase === 'live' && !paused)) + return () => { + dispatch(liveplayActions.setActivelyPlaying(false)) + } + }, [phase, paused, dispatch]) + + // No toast may stay on screen during live play - only between rounds or at game over. Unlock + // toasts fire at game over and last 7s, so a quick restart would otherwise carry them into the + // next rally. + useEffect(() => { + if (phase === 'live') clearToasts() + }, [phase, clearToasts]) + + useAIOpponent(state, board, aiPlayer, aiDifficulty, paused, setPaddleTarget) + + const isDesktopWeb = Platform.OS === 'web' && width >= DESKTOP_WEB_MIN_WIDTH + // 2 Player: each seat's half of the screen and the angle it reads at, for its Game Over card + // (SeatResultCards) and its unlock toasts (SeatToasters) alike, so a player's toasts sit where + // their card does, facing the same way. + const seatSpots = seatResultSpots(rotation, isDesktopWeb) + + const { dark, colors: themeColors } = useAutoPaperTheme() + const bg = dark ? '#000000' : '#FFFFFF' + const fg = dark ? '#FFFFFF' : '#000000' + // The Game Over card and the dialogs' score lines sit on @tastic/hud's OverlayCard, so their text + // takes its theme colors rather than this screen's literal black/white. + // Quit's outlined label is cardFg, the card's own text color, not Paper's default (the theme's + // primary), so neither seat's card shows another seat's color (Jay, 2026-10-20). + const { fg: cardFg, fgMuted } = useOverlayCardColors() + + const playerColor: Record = { 1: themeColors.primary, 2: themeColors.secondary } + const playerOnColor: Record = { 1: themeColors.onPrimary, 2: themeColors.onSecondary } + const winner: Player = scores[0] >= scoreToWin ? 1 : 2 + const loser: Player = winner === 1 ? 2 : 1 + + // Neither AirHockey nor BoxHockey renders a bare ProfileChip directly - both only ever consume it + // indirectly through ProfilePicker/ProfilesManager - so this is the one spot in the whole family + // that reads useProfiles() just to look a profile up by id, not to drive a picker of its own. + // Falls back to the plain 'P1'/'P2' label (same idle text ProfilePicker's own guestLabel would + // show) whenever the winning seat never had a profile selected for this round. + const { profiles, lastSelected } = useProfiles() + const winnerProfile = profiles.find((p) => p.id === lastSelected[winner]) ?? null + + const vibration = useVibration() + const vibrationRef = useRef(vibration) + // Mirrored every render so the stable effects below can read the latest vibration handle without + // needing it in their own deps - done inside an effect (no dependency array, so it reruns after + // every render) rather than as a bare assignment in the component body, matching AirHockey's own + // identical vibrationRef in its game.tsx: a bare `.current =` write during render is a render-time + // side effect the react-hooks/refs rule (rightly) flags, since React may render this component + // more than once for a single commit (e.g. under StrictMode or a Suspense retry) without any of + // those extra renders actually reaching the browser/device - an effect only fires once per real commit. + useEffect(() => { + vibrationRef.current = vibration + }) + + // Gameplay SFX - mirrors the haptics above one-for-one: each event fires its soundRef.current. + // playX() beside the matching vibrationRef.current.x(). Ref-mirrored the same way vibration is + // above, not called directly, so the effects below don't need these in their dependency arrays. + const playBounce = useGameSound(require('../../../assets/sounds/bounce.wav')) + const playGoal = useGameSound(require('../../../assets/sounds/goal.wav')) + const playWin = useGameSound(require('../../../assets/sounds/win.wav')) + const soundRef = useRef({ playBounce, playGoal, playWin }) + // See vibrationRef's own comment above for why this is an effect rather than a bare render-time assignment. + useEffect(() => { + soundRef.current = { playBounce, playGoal, playWin } + }) + + const firstBounceRender = useRef(true) + useEffect(() => { + if (firstBounceRender.current) { + firstBounceRender.current = false + return + } + vibrationRef.current.short() + soundRef.current.playBounce() + }, [bounceCount]) + + const prevScoresRef = useRef(scores) + useEffect(() => { + const [prevP1, prevP2] = prevScoresRef.current + prevScoresRef.current = scores + if (scores[0] !== prevP1 || scores[1] !== prevP2) { + vibrationRef.current.notification() + soundRef.current.playGoal() + } + }, [scores]) + + const prevPhaseRef = useRef(phase) + useEffect(() => { + if (prevPhaseRef.current !== 'gameOver' && phase === 'gameOver') { + soundRef.current.playWin() + // Recorded on the transition rather than in the reducer, which runs every physics step and + // has no notion of "this is new". prevPhaseRef guards a re-run from any other dep changing. + if (isSolo) { + // Solo has no opponent/score-tuple shape to fit into MatchOutcome - a genuinely separate + // recording path (see recordSoloOutcome), not a solo-shaped MatchOutcome. lives > 0 at + // gameOver means every brick was cleared (see useGameState's TICK case); lives <= 0 means + // the player ran out. + const result = recordSoloOutcome({ cleared: lives > 0, bricksDestroyed: bricks.filter((b) => b.destroyed).length }, lastSelected[1] ?? undefined) + showUnlockToasts(broadcastDeviceUnlocks(SOLO_SEATS, result.profiles, result.device, { 1: lastSelected[1] ?? undefined }), SOLO_SEATS, { 1: profiles.find((p) => p.id === lastSelected[1])?.name }) + } else { + const matchWinner: Player = scores[0] >= scoreToWin ? 1 : 2 + const profileIds = { 1: lastSelected[1] ?? undefined, 2: aiPlayer === null ? (lastSelected[2] ?? undefined) : undefined } + // bricksDestroyed is 0 whenever the Bricks toggle was off for this round - correctly a + // no-op add to the shared counter, not something that needs its own on/off branch here. + const result = recordMatchOutcome({ winner: matchWinner, scores: [scores[0], scores[1]] }, { gameMode, aiDifficulty, ballSpeed, profileIds, bricksDestroyed: bricks.filter((b) => b.destroyed).length }) + if (aiPlayer === null) { + // Genuine two-seat pass-and-play: each seat's unlocks are toasts on that seat's own stack, + // in their half of the screen and turned to face them (SeatToasters, on seatSpots), so a + // player seated opposite (the other end of a faceToFace hold) reads their own the right way + // up, and players side by side each read theirs on their own side. The winner's seat also + // gets the device-wide unlocks that are really its own (all of them for a guest, only + // scope: 'device' ones for a profile; see @tastic/achievements' broadcastDeviceUnlocks). + // The loser gets only its own (audit F037). + const broadcast = broadcastDeviceUnlocks(SEATS, result.profiles, result.device, profileIds, { winners: [matchWinner] }) + showUnlockToasts(broadcast, SEATS, { 1: profiles.find((p) => p.id === profileIds[1])?.name, 2: profiles.find((p) => p.id === profileIds[2])?.name }, SEAT_TOAST_STACKS) + } else { + // vsCpu only ever has one human perspective (seat 1) to address - see the rotation + // comment above for why this mode never enters a genuine faceToFace two-seat hold in + // the first place - so the shared, screen-fixed toast is fine here. + showUnlockToasts(broadcastDeviceUnlocks(SOLO_SEATS, result.profiles, result.device, profileIds), SOLO_SEATS, { 1: profiles.find((p) => p.id === profileIds[1])?.name }) + } + } + } + prevPhaseRef.current = phase + }, [phase, scores, scoreToWin, aiPlayer, aiDifficulty, ballSpeed, lastSelected, gameMode, recordMatchOutcome, isSolo, lives, bricks, recordSoloOutcome, profiles, showUnlockToasts]) + + // ─── Floating UI placement ───────────────────────────────────────────────── + // Score labels and the back/settings buttons all live OUTSIDE the board's own rotated container + // (they're floating UI, not part of the playing field - the board itself never rotates twice) and + // are individually positioned via localToScreen + their own small rotate transform, the same + // technique BoxHockey's MatchOverlays uses for its dialog card. + const screenCenterX = width / 2 + const screenCenterY = height / 2 + + // Anchored to the board's OWN rect (board.x/y/width/height, not the raw container) so placement + // automatically respects both the rotated safe-area insets AND any desktop-web letterboxing. + const scoreMargin = 20 + // Desktop web keeps back/settings in the page's own top corners (see backSlot below), which a + // court that fills the window puts right on top of these labels, so there each label sits further + // in along the court's edge, beside its chip instead of under it. + const scoreMarginAlong = isDesktopWeb ? 96 : scoreMargin + const p1ScorePos = localToScreen(board.x + scoreMarginAlong, board.y + scoreMargin, containerWidth, containerHeight, screenCenterX, screenCenterY, rotation) + const p2ScorePos = localToScreen(board.x + board.width - scoreMarginAlong, board.y + scoreMargin, containerWidth, containerHeight, screenCenterX, screenCenterY, rotation) + // Player 2 sits at the OPPOSITE end of a face-to-face hold (see the rotation comment above) - its + // own label needs the opposing 180° reading direction to stay upright from player 2's own seat. + // For sideBySide (rotation 0) this is trivially still 0 - both players share the same viewing + // angle there, so no relative flip is needed. + const p2Rotation: 0 | 90 | -90 = rotation === 90 ? -90 : rotation === -90 ? 90 : 0 + + const buttonMargin = 16 + const backPos = localToScreen(board.x + buttonMargin, board.y + board.height - buttonMargin, containerWidth, containerHeight, screenCenterX, screenCenterY, rotation) + const settingsPos = localToScreen(board.x + board.width - buttonMargin, board.y + board.height - buttonMargin, containerWidth, containerHeight, screenCenterX, screenCenterY, rotation) + + const rotationStyle = rotation !== 0 ? { transform: [{ rotate: `${rotation}deg` }] } : undefined + const p2RotationStyle = p2Rotation !== 0 ? { transform: [{ rotate: `${p2Rotation}deg` }] } : undefined + + // See @tastic/split-screen's needsSharedNeutralZone for the full reasoning. Board-corner + // positioning above (backPos/settingsPos + rotationStyle, P1's own reading direction) is fine for + // vsCpu/sideBySide, but breaks down for two-player faceToFace: unlike the score labels just above + // (two independent instances, each correctly rotated for its own player via p1/p2RotationStyle), + // there's only ONE back chip and ONE settings chip - a single shared element can't simultaneously + // read right-side-up for both players when they're seated on opposite ends. neutralZoneStyle below + // sidesteps that instead of trying to solve it: pinned to the real screen's own left/right edge, + // vertically centered, the same neutral midline LightCycles'/BoxHockey's in-match chips use - still + // carrying rotationStyle (not p2RotationStyle) since that ±90° reflects the whole board's own + // landscape-native-vs-portrait-hold orientation, not a P1-vs-P2 opposing flip, and stays needed + // even here for the icon to read upright at all. + const neutralZone = needsSharedNeutralZone(fixedSeats ? 'faceToFace' : orientationMode, humanPlayers.length) + // Desktop web: the page's own top corners, upright, the same spot as hud's CornerActionButtons on + // every other screen. Both players share one screen, keyboard and mouse there, so there's no far + // seat for the neutral edge to serve, and the page is always read upright. + const backSlot = { top: 8 + insets.top, left: 8 + insets.left } + const settingsSlot = { top: 8 + insets.top, right: 8 + insets.right } + + const progressMessage = isSolo ? ( + + {lives} {lives === 1 ? 'life' : 'lives'} · {bricks.filter((b) => b.destroyed).length} / {bricks.length} bricks + + ) : ( + <> + {scores[0]} + - + {scores[1]} + + ) + return ( + + {/* Board's own rotated container - see the rotation comment above for why, unlike + BoxHockey/AirHockey's portrait-native board, Pong's landscape-native board genuinely needs + to visually rotate for a face-to-face portrait hold. Sized to containerWidth/containerHeight + (the pre-swap dimensions - since a 90°/-90° rotation swaps which screen dimension is "width" + vs "height" for this box's own layout, it has to be laid out BEFORE the transform using + whichever pairing was fed into computeBoardLayout above) and centered on the real screen + exactly the way @tastic/core's own FakeLandscapeView centers its rotated content. */} + + + + + {/* Touch input - deliberately a SIBLING of the board's own rotated container, not nested + inside it: react-native-gesture-handler does not correct a Pan gesture's own coordinate + reporting for an ancestor View's CSS rotation, so keeping its root entirely outside that + rotated ancestor sidesteps the whole class of issue rather than fighting it. Its own zones + still cover the full real screen and invert each touch by hand (see its own file for why the + split axis itself has to vary with rotation, not just the coordinate math). Disabled outside + live play (paused, or the match has ended) same as AirHockey's own gestureEnabled gate. */} + + + {/* Web-only, additive keyboard input - see that component's own doc for why it needs no + rotation-awareness the way TouchInputLayer does. paddles[1].y/paddles[2].y are read fresh + every frame internally (not reactively), so passing the current `paddles` snapshot here on + every render is exactly right, not stale. */} + + + {/* Additive on top of TouchInputLayer's own always-on drag input, same precedent as + KeyboardInputLayer above - see that component's own doc for why this never disables touch. + Always mounted (inert whenever `enabled` is false), same convention as the other two layers. */} + + + {/* Score labels - plain react-native-paper Text, not on the Skia canvas, each individually + rotated to face its own player correctly (see p1/p2 rotation comment above) and anchored + near its own player's own corner of the board via localToScreen. */} + + + {scores[0]} + + + + + {scores[1]} + + + + {/* Back + settings - see neutralZone's own doc above for the two treatments. Board-corner + (vsCpu/sideBySide): floating chips near the board's own bottom corners (offset from the + score labels, which sit near the top corners), each rotated as a unit via the same + `rotation` so their icon reads upright given the current hold. Neutral-edge (two-player + faceToFace): pinned to the real screen's own left/right edge instead, vertically centered, + reachable and legible from either seat. Desktop web: the page's own top corners (see + backSlot above). */} + {isDesktopWeb ? ( + <> + + + + + setSettingsOpen(true)} accessibilityLabel='Settings' /> + + + ) : neutralZone ? ( + <> + + + + + setSettingsOpen(true)} accessibilityLabel='Settings' /> + + + ) : ( + <> + + + + + setSettingsOpen(true)} accessibilityLabel='Settings' /> + + + )} + + {/* Game Over overlay - solo has no opponent, so it gets its own branch entirely: lives > 0 + means every brick was cleared (a win), lives <= 0 means the player ran out (see + useGameState's TICK case) - there's no "loser" to speak of, so Play Again just uses + playerColor[1] rather than the loser's color the Vs CPU card below uses. Both cards put Quit + (outlined) and Play Again side by side on hud's action row, as ConfirmDialog does. + 2 Player gets one card per seat, each in that player's half and turned to face them + (SeatResultCards); Vs CPU keeps the single card. */} + {phase === 'gameOver' && + (isSolo ? ( + + + 0 ? 'trophy' : 'heart-broken'} size={64} color={playerColor[1]} /> + + {lives > 0 ? 'Cleared!' : 'Out of Lives'} + + + {bricks.filter((b) => b.destroyed).length} / {bricks.length} bricks + + {/* hud's action row, as in ConfirmDialog: Quit (outlined) on the left, Play Again (filled) on the right, side by side (Jay, 2026-10-20). */} + + + + + + + ) : aiPlayer === null ? ( + resetGame(board)} onQuit={safeBack} /> + ) : ( + + + + + Wins! + + {winnerProfile ? ( + + + + {winnerProfile.name} + + + ) : ( + + {winner === 1 ? 'P1' : 'P2'} + + )} + + {scores[0]} + - + {scores[1]} + + {/* hud's action row, as in ConfirmDialog: Quit (outlined) on the left, Play Again (filled) on the right, side by side (Jay, 2026-10-20). */} + + + + + + + ))} + + {/* 2 Player's unlock toasts, a Toaster per seat in that seat's half, turned to face it (see + the gameOver effect above); every other toast stays on the app's main Toaster (_layout.tsx). */} + {!isSolo && aiPlayer === null && } + + {/* Quit confirmation - shared @tastic/hud dialog; accepts its primary accent + default font + in place of this dialog's previous secondary-accent Quit button and MONO_FONT styling + (a deliberate trade-off for convergence onto the shared component). */} + + {/* Shown when the app comes back from the background mid-match (see useBackgroundPause above): + play stays frozen until Resume, so a match never restarts before the player is back and ready. */} + + + {/* rotation={rotation} - same physical-hold-compensating rotation the board itself uses (see + the rotation comment above), not a separate reading; previously omitted, leaving this + dialog's content stuck unrotated for a faceToFace hold while every other floating card on + this screen correctly rotated. */} + setSettingsOpen(false)} rotation={rotation} /> + + ) +} + +const styles = StyleSheet.create({ + chipButton: { margin: 0 }, + floatingChip: { position: 'absolute' }, + localContainer: { position: 'absolute' }, + // neutralZone's own back/settings position - full screen height at a fixed left/right edge, + // button centered vertically via justifyContent, same shape as LightCycles'/BoxHockey's own + // neutral-midline chip slots (see neutralZone's own doc above for why). + neutralSlot: { alignItems: 'center', bottom: 0, justifyContent: 'center', position: 'absolute', top: 0 }, + overlayScore: {}, + overlayTitle: { fontWeight: 'bold' }, + // The single Game Over card, as ConfirmDialog's: room from the screen's edges, and the card's full + // width allowance (OverlayCard's maxWidth, 360) so its action row has room to sit side by side. + resultBackdrop: { paddingHorizontal: 24 }, + resultCard: { width: '100%' }, + root: { flex: 1 }, + // Marginless - sits between the "Wins!" title and the score line, both of which already carry + // this card's own vertical gap (OverlayCard's own `gap`), so this row needs no extra spacing + // of its own to read as one more line in that same stack. + winnerRow: { + alignItems: 'center', + flexDirection: 'row', + gap: 8 + } +}) diff --git a/src/shared/paddleGame/__tests__/achievements.test.ts b/src/shared/paddleGame/__tests__/achievements.test.ts new file mode 100644 index 0000000..9fa82fc --- /dev/null +++ b/src/shared/paddleGame/__tests__/achievements.test.ts @@ -0,0 +1,114 @@ +import { createAchievementBindings } from '@tastic/achievements' + +import { paddleGameCatalog } from '@/shared/paddleGame/constants/achievements' +import { StatsState } from '@/shared/paddleGame/types' +import { getProfileStatsView } from '@/shared/paddleGame/utils/statsEngine' +import { DEFAULT_PROFILE_STATS, DEFAULT_STATS } from '@/shared/paddleGame/utils/statsValidation' + +// Breaker's catalog (Paddles' plus the brick achievements); most cases hold for both. +const ACHIEVEMENT_CATALOG = paddleGameCatalog({ hasBricks: true }) +const PADDLES_CATALOG = paddleGameCatalog({ hasBricks: false }) +const { evaluateUnlockedIds, evaluateUnlockedIdsForProfile, unlockedKey } = createAchievementBindings(ACHIEVEMENT_CATALOG, getProfileStatsView) + +function stats(overrides: Partial): StatsState { + return { ...DEFAULT_STATS, ...overrides } +} + +describe("the paddle games' catalogs", () => { + it('has unique ids', () => { + const ids = ACHIEVEMENT_CATALOG.map((a) => a.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('gives Paddles every achievement but the brick ones', () => { + const paddlesIds = new Set(PADDLES_CATALOG.map((a) => a.id)) + const brickIds = ACHIEVEMENT_CATALOG.map((a) => a.id).filter((id) => !paddlesIds.has(id)) + expect(brickIds.sort()).toEqual(['bricks_cleared_alone_bronze', 'bricks_cleared_alone_gold', 'bricks_cleared_alone_silver', 'bricks_destroyed_bronze', 'bricks_destroyed_gold', 'bricks_destroyed_silver']) + expect(PADDLES_CATALOG.every((a) => !/brick/i.test(a.description))).toBe(true) + }) + + // Titles may be idioms (Wrecking Ball); a description names the game's piece, the puck. + it('never says ball or Pong in a description', () => { + for (const a of ACHIEVEMENT_CATALOG) expect(a.description).not.toMatch(/\bball|\bpong\b/i) + }) + + it('gives every entry a title, description, icon and tier', () => { + for (const a of ACHIEVEMENT_CATALOG) { + expect(a.title.length).toBeGreaterThan(0) + expect(a.description.length).toBeGreaterThan(0) + expect(a.icon.length).toBeGreaterThan(0) + expect(['bronze', 'silver', 'gold']).toContain(a.tier) + } + }) + + it('unlocks nothing at all on a fresh blob', () => { + expect(evaluateUnlockedIds(DEFAULT_STATS).size).toBe(0) + }) + + it('reports progress between 0 and 1 wherever it is defined', () => { + const rich = stats({ points: { scored: 30, conceded: 10 }, shutouts: 2, bestWinMargin: 4 }) + for (const a of ACHIEVEMENT_CATALOG) { + const p = a.progress?.(rich) + if (p === undefined) continue + expect(p).toBeGreaterThanOrEqual(0) + expect(p).toBeLessThanOrEqual(1) + } + }) +}) + +describe('unlocking', () => { + it('unlocks the first-match one-offs on a first win', () => { + const s = stats({ vsCpu: { ...DEFAULT_STATS.vsCpu, played: 1, wins: 1 }, firstGameResult: 'win' }) + const ids = evaluateUnlockedIds(s) + expect(ids.has('first_game_played')).toBe(true) + expect(ids.has('first_ever_win')).toBe(true) + expect(ids.has('flawless_debut')).toBe(true) + }) + + it('leaves flawless_debut locked when the first match was a loss', () => { + const s = stats({ vsCpu: { ...DEFAULT_STATS.vsCpu, played: 5, wins: 4 }, firstGameResult: 'loss' }) + expect(evaluateUnlockedIds(s).has('flawless_debut')).toBe(false) + }) + + it('unlocks shutout tiers at their thresholds', () => { + expect(evaluateUnlockedIds(stats({ shutouts: 1 })).has('shutouts_bronze')).toBe(true) + expect(evaluateUnlockedIds(stats({ shutouts: 9 })).has('shutouts_silver')).toBe(false) + expect(evaluateUnlockedIds(stats({ shutouts: 10 })).has('shutouts_silver')).toBe(true) + }) + + it('unlocks a beat-the-CPU entry only for the difficulty actually beaten', () => { + const s = stats({ vsCpu: { ...DEFAULT_STATS.vsCpu, byDifficulty: { ...DEFAULT_STATS.vsCpu.byDifficulty, hard: { played: 1, wins: 1, losses: 0, draws: 0 } } } }) + const ids = evaluateUnlockedIds(s) + expect(ids.has('beat_cpu_hard')).toBe(true) + expect(ids.has('beat_cpu_easy')).toBe(false) + }) + + it('never reports the device-scoped achievement for a profile', () => { + const bucket = { ...DEFAULT_PROFILE_STATS, vsCpu: { ...DEFAULT_PROFILE_STATS.vsCpu, played: 1, wins: 1 } } + const ids = evaluateUnlockedIdsForProfile(bucket) + expect(ids.has('first_game_played')).toBe(true) + expect(ids.has('flawless_debut')).toBe(false) + }) + + it('unlocks bricks_cleared_alone only once a brick field has actually been cleared alone', () => { + expect(evaluateUnlockedIds(stats({ solo: { played: 3, cleared: 0 } })).has('bricks_cleared_alone_bronze')).toBe(false) + expect(evaluateUnlockedIds(stats({ solo: { played: 3, cleared: 1 } })).has('bricks_cleared_alone_bronze')).toBe(true) + }) + + it('unlocks bricks_cleared_alone tiers at their thresholds', () => { + expect(evaluateUnlockedIds(stats({ solo: { played: 10, cleared: 9 } })).has('bricks_cleared_alone_silver')).toBe(false) + expect(evaluateUnlockedIds(stats({ solo: { played: 10, cleared: 10 } })).has('bricks_cleared_alone_silver')).toBe(true) + }) + + it('unlocks bricks_destroyed tiers at their thresholds', () => { + expect(evaluateUnlockedIds(stats({ bricksDestroyed: 99 })).has('bricks_destroyed_bronze')).toBe(false) + expect(evaluateUnlockedIds(stats({ bricksDestroyed: 100 })).has('bricks_destroyed_bronze')).toBe(true) + }) +}) + +describe('unlockedKey', () => { + it('keys device-wide unlocks bare and profile unlocks by id', () => { + expect(unlockedKey('first_ever_win', null)).toBe('first_ever_win') + expect(unlockedKey('first_ever_win', 'alice')).toBe('alice:first_ever_win') + }) +}) diff --git a/src/shared/paddleGame/__tests__/aiOpponent.test.ts b/src/shared/paddleGame/__tests__/aiOpponent.test.ts new file mode 100644 index 0000000..cb3ddd1 --- /dev/null +++ b/src/shared/paddleGame/__tests__/aiOpponent.test.ts @@ -0,0 +1,92 @@ +import { MAX_PADDLE_SPEED } from '@/shared/paddleGame/constants/game' +import { computeAIPaddleTarget } from '@/shared/paddleGame/utils/aiOpponent' +import { centerPaddle, computeBoardLayout } from '@/shared/paddleGame/utils/board' + +const insets = { top: 0, bottom: 0, left: 0, right: 0 } +const board = computeBoardLayout(900, 400, insets, 'medium') + +// jitterMag = (random() * 2 - 1) * aimJitterPx - a plain 1D scalar offset (narrowed from +// AirHockey's polar angle+magnitude jitter, since there's only one axis left to blur once a +// paddle can't move in X at all). random()=0.5 is the zero point, NOT random()=0 the way +// AirHockey's own magnitude-only jitter formula worked - see aiOpponent.ts's own targetY line. +const noJitter = () => 0.5 + +describe('computeAIPaddleTarget - always tracks, no threat gate', () => { + it("predicts the ball's Y along its current trajectory in both directions - no AirHockey-style retreat-home-when-moving-away branch", () => { + const paddle = { ...centerPaddle(board, 2), vx: 0, vy: 0 } + const ballMovingDown = { x: board.centerX, y: board.centerY, vx: 0, vy: 5, speed: 5 } + const ballMovingUp = { x: board.centerX, y: board.centerY, vx: 0, vy: -5, speed: 5 } + const targetDown = computeAIPaddleTarget(ballMovingDown, paddle, board, 2, 'hard', noJitter) + const targetUp = computeAIPaddleTarget(ballMovingUp, paddle, board, 2, 'hard', noJitter) + // A downward-moving ball is tracked to a LOWER predicted Y than an upward-moving one, + // regardless of which direction counts as "toward" or "away" from player 2 - a fixed-X paddle + // has no edge of its own to retreat toward in the first place, so both simply predict forward. + expect(targetDown.y).toBeGreaterThan(board.centerY) + expect(targetUp.y).toBeLessThan(board.centerY) + }) + + it('tracks a ball heading toward the far edge exactly the same way as one heading toward its own edge', () => { + const paddle = { ...centerPaddle(board, 2), vx: 0, vy: 0 } + const towardP2 = { x: board.centerX, y: board.centerY, vx: 6, vy: 3, speed: Math.hypot(6, 3) } + const towardP1 = { x: board.centerX, y: board.centerY, vx: -6, vy: 3, speed: Math.hypot(6, 3) } + // vx never factors into computeAIPaddleTarget at all (only ball.vy does) - both should predict + // the identical Y. + const targetA = computeAIPaddleTarget(towardP2, paddle, board, 2, 'hard', noJitter) + const targetB = computeAIPaddleTarget(towardP1, paddle, board, 2, 'hard', noJitter) + expect(targetA.y).toBeCloseTo(targetB.y, 5) + }) +}) + +describe('computeAIPaddleTarget - difficulty tiers', () => { + it('a harder difficulty jitters its target less than an easier one, for the same raw random draw', () => { + const ball = { x: board.centerX, y: board.y + 40, vx: 0, vy: -3, speed: 3 } + const ownPaddle = { ...centerPaddle(board, 2), vx: 0, vy: 0 } + const maxJitter = () => 1 // (1*2-1)*aimJitterPx = full positive offset + const baseline = computeAIPaddleTarget(ball, ownPaddle, board, 2, 'hard', noJitter) + const easyJittered = computeAIPaddleTarget(ball, ownPaddle, board, 2, 'easy', maxJitter) + const hardJittered = computeAIPaddleTarget(ball, ownPaddle, board, 2, 'hard', maxJitter) + const easyOffset = Math.abs(easyJittered.y - baseline.y) + const hardOffset = Math.abs(hardJittered.y - baseline.y) + expect(hardOffset).toBeLessThan(easyOffset) + }) + + it('a harder difficulty approaches its target faster than an easier one, when both start far from it', () => { + const ball = { x: board.centerX, y: board.y + 20, vx: 0, vy: 0, speed: 0 } + const ownPaddle = { ...centerPaddle(board, 2), vx: 0, vy: 0 } // far below the ball's own y + const easyTarget = computeAIPaddleTarget(ball, ownPaddle, board, 2, 'easy', noJitter) + const hardTarget = computeAIPaddleTarget(ball, ownPaddle, board, 2, 'hard', noJitter) + const easyStep = Math.abs(easyTarget.y - ownPaddle.y) + const hardStep = Math.abs(hardTarget.y - ownPaddle.y) + expect(hardStep).toBeGreaterThan(easyStep) + }) +}) + +describe('computeAIPaddleTarget - clamping', () => { + it('never returns a target Y outside [paddleMinY, paddleMaxY], regardless of jitter', () => { + const ball = { x: board.centerX, y: board.y + 2, vx: 0, vy: -50, speed: 50 } + const ownPaddle = { ...centerPaddle(board, 2), vx: 0, vy: 0 } + const alwaysMaxJitter = () => 1 + const target = computeAIPaddleTarget(ball, ownPaddle, board, 2, 'easy', alwaysMaxJitter) + expect(target.y).toBeGreaterThanOrEqual(board.paddleMinY) + expect(target.y).toBeLessThanOrEqual(board.paddleMaxY) + }) + + it("the per-tick step is bounded by the difficulty's own maxApproachSpeed fraction of MAX_PADDLE_SPEED", () => { + // Ball far across the board from the paddle's current Y, so the raw (pre-clamp) target + // distance is large - the rate-of-approach clamp must be what actually limits the step. + const ball = { x: board.centerX, y: board.paddleMaxY, vx: 0, vy: 0, speed: 0 } + const ownPaddle = { x: board.paddleX[2], y: board.paddleMinY, vx: 0, vy: 0 } + const target = computeAIPaddleTarget(ball, ownPaddle, board, 2, 'medium', noJitter) + const step = Math.abs(target.y - ownPaddle.y) + expect(step).toBeLessThanOrEqual(MAX_PADDLE_SPEED + 0.01) + }) +}) + +describe('computeAIPaddleTarget - X is always the fixed paddleX, never computed', () => { + it('always returns board.paddleX[aiPlayer], regardless of ball or paddle position', () => { + const ball = { x: board.x, y: board.y, vx: 10, vy: 10, speed: Math.hypot(10, 10) } + const ownPaddle = { x: board.paddleX[2], y: board.y + 5, vx: 0, vy: 0 } + const target = computeAIPaddleTarget(ball, ownPaddle, board, 2, 'hard', noJitter) + expect(target.x).toBe(board.paddleX[2]) + }) +}) diff --git a/src/shared/paddleGame/__tests__/boardGeometry.test.ts b/src/shared/paddleGame/__tests__/boardGeometry.test.ts new file mode 100644 index 0000000..0a51fa5 --- /dev/null +++ b/src/shared/paddleGame/__tests__/boardGeometry.test.ts @@ -0,0 +1,171 @@ +import { PADDLE_HEIGHT_RATIOS, SHARED_BRICK_COLS, SHARED_BRICK_ROWS, SOLO_BRICK_COLS, SOLO_BRICK_ROWS, TABLE_WIDTH_TO_HEIGHT_RATIO } from '@/shared/paddleGame/constants/game' +import { Brick, PaddleSizeLevel } from '@/shared/paddleGame/types' +import { buildSharedBrickField, buildSoloBrickField, centerBall, centerPaddle, clampPaddleY, computeBoardLayout, rescaleBricks } from '@/shared/paddleGame/utils/board' + +const insets = { top: 0, bottom: 0, left: 0, right: 0 } +const sizes: PaddleSizeLevel[] = ['small', 'medium', 'large'] + +function withinBoard(brick: Brick, board: ReturnType): boolean { + return brick.x >= board.x && brick.y >= board.y && brick.x + brick.width <= board.x + board.width && brick.y + brick.height <= board.y + board.height +} + +describe('paddleHeight scales per PaddleSizeLevel tier', () => { + it.each(sizes)('matches PADDLE_HEIGHT_RATIOS at paddleSize=%s', (size) => { + const board = computeBoardLayout(900, 400, insets, size) + expect(board.paddleHeight).toBeCloseTo(board.height * PADDLE_HEIGHT_RATIOS[size], 5) + }) + + it('a larger tier produces a taller paddle than a smaller one', () => { + const small = computeBoardLayout(900, 400, insets, 'small') + const large = computeBoardLayout(900, 400, insets, 'large') + expect(large.paddleHeight).toBeGreaterThan(small.paddleHeight) + }) +}) + +describe('paddleX', () => { + it('player 1 always sits left of player 2, both inset from their own edge', () => { + const board = computeBoardLayout(900, 400, insets, 'medium') + expect(board.paddleX[1]).toBeLessThan(board.paddleX[2]) + expect(board.paddleX[1]).toBeGreaterThan(board.x) + expect(board.paddleX[2]).toBeLessThan(board.x + board.width) + }) + + it('stays proportionally symmetric around centerX regardless of paddleSize (inset is width-derived, not size-tiered)', () => { + const board = computeBoardLayout(900, 400, insets, 'medium') + const insetP1 = board.paddleX[1] - board.x + const insetP2 = board.x + board.width - board.paddleX[2] + expect(insetP1).toBeCloseTo(insetP2, 5) + }) +}) + +describe('ballRadius', () => { + it('scales with board HEIGHT, not width - the axis that stays meaningful across both seating orientations', () => { + const short = computeBoardLayout(900, 300, insets, 'medium') + const tall = computeBoardLayout(900, 600, insets, 'medium') + expect(tall.ballRadius).toBeGreaterThan(short.ballRadius) + }) +}) + +describe('paddleMinY / paddleMaxY', () => { + it('bracket centerY, inset by paddleHeight/2, spanning the FULL board height - no half-court clamp, unlike AirHockey', () => { + const board = computeBoardLayout(900, 400, insets, 'medium') + expect(board.paddleMinY).toBeCloseTo(board.y + board.paddleHeight / 2, 5) + expect(board.paddleMaxY).toBeCloseTo(board.y + board.height - board.paddleHeight / 2, 5) + expect(board.centerY).toBeGreaterThan(board.paddleMinY) + expect(board.centerY).toBeLessThan(board.paddleMaxY) + expect(board.paddleMaxY - board.paddleMinY).toBeCloseTo(board.height - board.paddleHeight, 5) + }) +}) + +describe('desktop web letterboxing', () => { + const originalPlatform = require('react-native').Platform.OS + + afterEach(() => { + require('react-native').Platform.OS = originalPlatform + }) + + it('fixes the board to TABLE_WIDTH_TO_HEIGHT_RATIO on wide desktop web viewports', () => { + require('react-native').Platform.OS = 'web' + const board = computeBoardLayout(2000, 900, insets, 'medium') // width is the tight constraint here + expect(board.width / board.height).toBeCloseTo(TABLE_WIDTH_TO_HEIGHT_RATIO, 3) + expect(board.width).toBeLessThan(2000) // letterboxing actually engaged, not just a coincidence + }) + + it('does not letterbox on native/mobile - the board fills the viewport exactly, whatever its shape', () => { + require('react-native').Platform.OS = 'ios' + const board = computeBoardLayout(2000, 900, insets, 'medium') + expect(board.width).toBeCloseTo(2000, 5) + expect(board.height).toBeCloseTo(900, 5) + }) +}) + +describe('centerBall / centerPaddle / clampPaddleY', () => { + it('centerBall places the ball at the exact board center', () => { + const board = computeBoardLayout(900, 400, insets, 'medium') + const c = centerBall(board) + expect(c.x).toBeCloseTo(board.centerX, 5) + expect(c.y).toBeCloseTo(board.centerY, 5) + }) + + it('centerPaddle places each player at their own FIXED X, board center Y', () => { + const board = computeBoardLayout(900, 400, insets, 'medium') + const p1 = centerPaddle(board, 1) + const p2 = centerPaddle(board, 2) + expect(p1.x).toBeCloseTo(board.paddleX[1], 5) + expect(p2.x).toBeCloseTo(board.paddleX[2], 5) + expect(p1.y).toBeCloseTo(board.centerY, 5) + expect(p2.y).toBeCloseTo(board.centerY, 5) + }) + + it('clampPaddleY clamps to [paddleMinY, paddleMaxY] and leaves an in-range value untouched', () => { + const board = computeBoardLayout(900, 400, insets, 'medium') + expect(clampPaddleY(board.y - 1000, board)).toBeCloseTo(board.paddleMinY, 5) + expect(clampPaddleY(board.y + board.height + 1000, board)).toBeCloseTo(board.paddleMaxY, 5) + expect(clampPaddleY(board.centerY, board)).toBeCloseTo(board.centerY, 5) + }) +}) + +describe('buildSoloBrickField', () => { + const board = computeBoardLayout(900, 400, insets, 'medium') + const bricks = buildSoloBrickField(board) + + it('produces exactly SOLO_BRICK_COLS * SOLO_BRICK_ROWS bricks', () => { + expect(bricks).toHaveLength(SOLO_BRICK_COLS * SOLO_BRICK_ROWS) + }) + + it('every brick starts undestroyed and fully within the board rect', () => { + expect(bricks.every((b) => !b.destroyed)).toBe(true) + expect(bricks.every((b) => withinBoard(b, board))).toBe(true) + }) + + it('no two bricks share a position - a real grid, not a stack of overlapping rects', () => { + const positions = new Set(bricks.map((b) => `${b.x},${b.y}`)) + expect(positions.size).toBe(bricks.length) + }) +}) + +describe('buildSharedBrickField', () => { + const board = computeBoardLayout(900, 400, insets, 'medium') + const bricks = buildSharedBrickField(board) + + it('produces exactly SHARED_BRICK_COLS * SHARED_BRICK_ROWS bricks, fewer than the solo field', () => { + expect(bricks).toHaveLength(SHARED_BRICK_COLS * SHARED_BRICK_ROWS) + expect(bricks.length).toBeLessThan(buildSoloBrickField(board).length) + }) + + it('every brick starts undestroyed and fully within the board rect', () => { + expect(bricks.every((b) => !b.destroyed)).toBe(true) + expect(bricks.every((b) => withinBoard(b, board))).toBe(true) + }) + + it("never overlaps either paddle's reachable column - centered on the court, paddles fixed at their own X", () => { + const halfW = board.paddleWidth / 2 + const p1Right = board.paddleX[1] + halfW + const p2Left = board.paddleX[2] - halfW + expect(bricks.every((b) => b.x >= p1Right && b.x + b.width <= p2Left)).toBe(true) + }) +}) + +describe('rescaleBricks', () => { + const board = computeBoardLayout(900, 400, insets, 'medium') + + it('remaps position and size fractionally, and carries destroyed state straight through', () => { + const bricks = buildSoloBrickField(board).map((b, i) => ({ ...b, destroyed: i === 0 })) + const doubled = computeBoardLayout(1800, 800, insets, 'medium') + const rescaled = rescaleBricks(bricks, board, doubled) + + expect(rescaled).toHaveLength(bricks.length) + expect(rescaled[0].destroyed).toBe(true) + expect(rescaled.slice(1).every((b) => !b.destroyed)).toBe(true) + // Doubling the board should double each brick's size along with its position. + expect(rescaled[1].width).toBeCloseTo(bricks[1].width * (doubled.width / board.width), 5) + expect(rescaled[1].height).toBeCloseTo(bricks[1].height * (doubled.height / board.height), 5) + }) + + it('falls back to a stable (non-NaN) result when rescaling from a degenerate zero-sized board', () => { + const bricks = buildSoloBrickField(board) + const zeroBoard = computeBoardLayout(0, 0, insets, 'medium') + const rescaled = rescaleBricks(bricks, zeroBoard, board) + expect(rescaled.every((b) => !Number.isNaN(b.x) && !Number.isNaN(b.y) && !Number.isNaN(b.width) && !Number.isNaN(b.height))).toBe(true) + }) +}) diff --git a/src/shared/paddleGame/__tests__/controlScheme.test.ts b/src/shared/paddleGame/__tests__/controlScheme.test.ts new file mode 100644 index 0000000..a8d1951 --- /dev/null +++ b/src/shared/paddleGame/__tests__/controlScheme.test.ts @@ -0,0 +1,60 @@ +import { assignSeatScheme } from '@tastic/input' + +import { resolveControlSchemeDirection } from '@/shared/controlScheme' +import { DEFAULT_SETTINGS } from '@/shared/paddleGame/settings' +import type { ControlScheme } from '@/shared/paddleGame/types' + +describe('resolveControlSchemeDirection', () => { + it('returns null for the mouse scheme regardless of key', () => { + expect(resolveControlSchemeDirection({ key: 'w', code: 'KeyW' }, 'mouse')).toBeNull() + expect(resolveControlSchemeDirection({ key: 'ArrowUp', code: 'ArrowUp' }, 'mouse')).toBeNull() + }) + + it('resolves wasd by key', () => { + expect(resolveControlSchemeDirection({ key: 'w', code: 'KeyW' }, 'wasd')).toBe('up') + expect(resolveControlSchemeDirection({ key: 's', code: 'KeyS' }, 'wasd')).toBe('down') + }) + + it('resolves arrows by key', () => { + expect(resolveControlSchemeDirection({ key: 'ArrowUp', code: 'ArrowUp' }, 'arrows')).toBe('up') + expect(resolveControlSchemeDirection({ key: 'ArrowDown', code: 'ArrowDown' }, 'arrows')).toBe('down') + }) + + it('resolves ijkl by key', () => { + expect(resolveControlSchemeDirection({ key: 'i', code: 'KeyI' }, 'ijkl')).toBe('up') + expect(resolveControlSchemeDirection({ key: 'k', code: 'KeyK' }, 'ijkl')).toBe('down') + }) + + it('resolves numpad by code, independent of NumLock (.key may read as a digit or an arrow name)', () => { + expect(resolveControlSchemeDirection({ key: '8', code: 'Numpad8' }, 'numpad')).toBe('up') + expect(resolveControlSchemeDirection({ key: 'ArrowDown', code: 'Numpad2' }, 'numpad')).toBe('down') + }) + + it('returns null for a key not in the given scheme', () => { + expect(resolveControlSchemeDirection({ key: 'q', code: 'KeyQ' }, 'wasd')).toBeNull() + }) +}) + +// The loadout's two pickers write through assignSeatScheme (see loadout.tsx). +describe('loadout control scheme picks', () => { + const ALL_SCHEMES: ControlScheme[] = ['mouse', 'wasd', 'arrows', 'ijkl', 'numpad'] + + // Regression: a 1 Player pick of seat 2's scheme left both seats on it, and KeyboardInputLayer + // then sent every one of those keys to seat 1 in a later 2 Player game. + it("hands seat 2 seat 1's old scheme when seat 1 picks seat 2's", () => { + expect(assignSeatScheme(DEFAULT_SETTINGS.controlScheme, 1, 'wasd')).toEqual({ 1: 'wasd', 2: 'mouse' }) + }) + + it('never leaves both seats on one scheme, from any setup a pick can reach', () => { + const setups = ALL_SCHEMES.flatMap((p1) => ALL_SCHEMES.filter((p2) => p2 !== p1).map((p2) => ({ 1: p1, 2: p2 }))) + for (const current of setups) { + for (const seat of [1, 2] as const) { + for (const scheme of ALL_SCHEMES) { + const next = assignSeatScheme(current, seat, scheme) + expect(next[seat]).toBe(scheme) + expect(next[1]).not.toBe(next[2]) + } + } + } + }) +}) diff --git a/src/shared/paddleGame/__tests__/gameMode.test.ts b/src/shared/paddleGame/__tests__/gameMode.test.ts new file mode 100644 index 0000000..b3efb0d --- /dev/null +++ b/src/shared/paddleGame/__tests__/gameMode.test.ts @@ -0,0 +1,29 @@ +import { bricksActiveFor, tiltControlActiveFor } from '@/shared/paddleGame/utils/gameMode' + +describe('bricksActiveFor', () => { + it('is unconditionally true for solo, whatever the game', () => { + expect(bricksActiveFor('solo', false)).toBe(true) + expect(bricksActiveFor('solo', true)).toBe(true) + }) + + it('follows the game for vsCpu and twoPlayer (Paddles none, Breaker always)', () => { + expect(bricksActiveFor('vsCpu', false)).toBe(false) + expect(bricksActiveFor('vsCpu', true)).toBe(true) + expect(bricksActiveFor('twoPlayer', false)).toBe(false) + expect(bricksActiveFor('twoPlayer', true)).toBe(true) + }) +}) + +describe('tiltControlActiveFor', () => { + it('is unconditionally false for twoPlayer, regardless of the stored toggle', () => { + expect(tiltControlActiveFor('twoPlayer', false)).toBe(false) + expect(tiltControlActiveFor('twoPlayer', true)).toBe(false) + }) + + it('follows the toggle for vsCpu and solo', () => { + expect(tiltControlActiveFor('vsCpu', false)).toBe(false) + expect(tiltControlActiveFor('vsCpu', true)).toBe(true) + expect(tiltControlActiveFor('solo', false)).toBe(false) + expect(tiltControlActiveFor('solo', true)).toBe(true) + }) +}) diff --git a/src/shared/paddleGame/__tests__/guideSteps.test.tsx b/src/shared/paddleGame/__tests__/guideSteps.test.tsx new file mode 100644 index 0000000..e45b56a --- /dev/null +++ b/src/shared/paddleGame/__tests__/guideSteps.test.tsx @@ -0,0 +1,171 @@ +import { SeatDevicesHint } from '@tastic/hud/guide' +import type { ReactElement } from 'react' + +import { GuideUpDownKeys } from '@/shared/paddleGame/components/GuideArt' +import { getGuideSteps } from '@/shared/paddleGame/constants/guideSteps' +import { DEFAULT_SETTINGS } from '@/shared/paddleGame/settings' +import type { ControlScheme, Player } from '@/shared/paddleGame/types' + +const DEFAULT_CONTROLS = DEFAULT_SETTINGS.controlScheme +const ALL_SCHEMES: ControlScheme[] = ['mouse', 'wasd', 'arrows', 'ijkl', 'numpad'] +// The loadout's own SCORE_TO_WIN_OPTIONS. +const SCORE_OPTIONS = [3, 5, 7, 10] +// How the desktop two-player card names each scheme: the paddle games' own phrases (a paddle only uses two keys, +// so "W and S", not "WASD"), and "the mouse" as everywhere in the fleet. +const PHRASE: Record = { mouse: 'the mouse', wasd: 'W and S', arrows: 'the arrow keys', ijkl: 'I and K', numpad: 'the numpad' } + +// Paddles' cards unless a case asks for Breaker's (hasBricks). +function stepsFor(touch: boolean, controls: Record = DEFAULT_CONTROLS, scoreToWin = DEFAULT_SETTINGS.scoreToWin, hasBricks = false) { + return getGuideSteps({ touch, controls, scoreToWin, hasBricks }) +} + +// The cards by what they teach: Breaker has one more, for playing alone, before the two-player card. +function cardsFor(touch: boolean, controls?: Record, hasBricks = false) { + const steps = stepsFor(touch, controls, undefined, hasBricks) + return { steer: steps[0], rules: steps[1], alone: hasBricks ? steps[2] : undefined, twoPlayer: steps[steps.length - 1] } +} + +function textOf(touch: boolean, controls?: Record, hasBricks = false) { + return stepsFor(touch, controls, undefined, hasBricks) + .map((step) => `${step.title} ${step.body}`) + .join(' ') +} + +// Every setting the copy reads, in every combination a player can reach, in both games. +const EVERY_INPUT = [true, false].flatMap((hasBricks) => [true, false].flatMap((touch) => ALL_SCHEMES.flatMap((p1) => ALL_SCHEMES.flatMap((p2) => SCORE_OPTIONS.map((scoreToWin) => ({ touch, controls: { 1: p1, 2: p2 }, scoreToWin, hasBricks })))))) + +describe('getGuideSteps', () => { + it('gives Paddles three and Breaker four complete, illustrated, short cards, whatever the live settings', () => { + for (const input of EVERY_INPUT) { + const steps = getGuideSteps(input) + + expect(steps).toHaveLength(input.hasBricks ? 4 : 3) + for (const step of steps) { + expect(step.title.trim().length).toBeGreaterThan(0) + // A card a casual player reads in a glance, not a wall of text. + expect(step.body.length).toBeLessThanOrEqual(90) + // The pager sizes every page to the tallest, so a card without art leaves a visible gap. + expect(step.art).toBeDefined() + // Jay's copy rules: no rhetorical questions, no em dashes. + expect(`${step.title} ${step.body}`).not.toMatch(/[?\u2013\u2014]/) + } + } + }) + + it('headlines the win condition at the live score to win, with bricks only in Breaker', () => { + for (const touch of [true, false]) { + for (const hasBricks of [false, true]) { + const { rules } = cardsFor(touch, undefined, hasBricks) + // "Points", so it can't read as five match wins. + expect(rules.title).toBe('First to 5 Points Wins') + expect(rules.body).toMatch(/^Get the puck past the other paddle to score\./) + expect(/brick/i.test(rules.body)).toBe(hasBricks) + } + expect(textOf(touch)).not.toMatch(/brick|nobody|alone|lives/i) + } + for (const scoreToWin of SCORE_OPTIONS) expect(stepsFor(true, DEFAULT_CONTROLS, scoreToWin)[1].title).toBe(`First to ${scoreToWin} Points Wins`) + }) + + it("teaches Breaker's round alone: Nobody in seat 2, every brick, three lives", () => { + for (const touch of [true, false]) { + const { alone } = cardsFor(touch, undefined, true) + expect(alone?.title).toBe('Play Alone') + expect(alone?.body).toBe('Pick Nobody for player 2, then clear every brick before your 3 lives run out.') + } + }) + + it('calls it a puck, never a ball, and never names Pong', () => { + for (const hasBricks of [false, true]) for (const touch of [true, false]) expect(textOf(touch, undefined, hasBricks)).not.toMatch(/\bball|pong|brickbreaker/i) + }) + + it('teaches dragging on touch, and only says "your own half" on the two-player card (One Player drags anywhere)', () => { + const { steer, rules, twoPlayer } = cardsFor(true) + + expect(steer.body).toMatch(/drag/i) + expect(steer.body).toMatch(/lines up with your finger/) + expect(steer.body).not.toMatch(/half|anywhere/i) + expect(rules.body).not.toMatch(/half/i) + expect(twoPlayer.title).toBe('Two Players, One Phone') + expect(twoPlayer.body).toMatch(/your own half/i) + }) + + it('never tells a touch player to press keys or click, and never tells a desktop player to swipe or touch', () => { + for (const p1 of ALL_SCHEMES) { + for (const p2 of ALL_SCHEMES) { + for (const hasBricks of [false, true]) { + expect(textOf(true, { 1: p1, 2: p2 }, hasBricks)).not.toMatch(/\bkeys?\b|keyboard|click|mouse|pointer/i) + expect(textOf(false, { 1: p1, 2: p2 }, hasBricks)).not.toMatch(/swipe|touch|finger|phone/i) + } + } + } + }) + + it("steers with player 1's live scheme on desktop, in words that stay true for a player 2 on a different one", () => { + // The pointer drag works for every seat whatever its scheme, so it can always be taught. + expect(stepsFor(false, { 1: 'mouse', 2: 'wasd' })[0].body).toBe('Click and drag. Your paddle lines up with the pointer.') + for (const p1 of ['wasd', 'arrows', 'ijkl', 'numpad'] as const) { + const [steer] = stepsFor(false, { 1: p1, 2: 'mouse' }) + expect(steer.body).toMatch(/Hold your up or down key/) + expect(steer.body).toMatch(/click and drag/) + } + }) + + it('names the controls each seat actually uses on the two-player desktop card', () => { + // The defaults: player 1 on the mouse, player 2 on WASD. Not "One Keyboard" then. + const { twoPlayer: defaults } = cardsFor(false) + expect(defaults.title).toBe('Two Players, One Computer') + expect(defaults.body).toMatch(/^Player 1 uses the mouse, player 2 uses W and S\./) + + const { twoPlayer: keysOnly } = cardsFor(false, { 1: 'arrows', 2: 'ijkl' }) + expect(keysOnly.title).toBe('Two Players, One Keyboard') + expect(keysOnly.body).toMatch(/^Player 1 uses the arrow keys, player 2 uses I and K\./) + expect(cardsFor(false, { 1: 'numpad', 2: 'wasd' }).twoPlayer.body).toMatch(/^Player 1 uses the numpad, player 2 uses W and S\./) + }) + + it("uses the fleet's desktop two-player wording for every pair of seat schemes, in 90 characters or fewer", () => { + for (const p1 of ALL_SCHEMES) { + for (const p2 of ALL_SCHEMES) { + const { twoPlayer } = cardsFor(false, { 1: p1, 2: p2 }) + const anyMouse = p1 === 'mouse' || p2 === 'mouse' + const bothMouse = p1 === 'mouse' && p2 === 'mouse' + + expect(twoPlayer.title).toBe(anyMouse ? 'Two Players, One Computer' : 'Two Players, One Keyboard') + expect(twoPlayer.body).toBe(bothMouse ? 'Both players use the mouse. Drag in your own half.' : `Player 1 uses ${PHRASE[p1]}, player 2 uses ${PHRASE[p2]}.${anyMouse ? ' Drag in your own half.' : ' Change them before a match.'}`) + expect(twoPlayer.body.length).toBeLessThanOrEqual(90) + } + } + }) + + it("draws each seat's live device on the desktop two-player card, and player 1's own keys on the steering card", () => { + for (const p1 of ALL_SCHEMES) { + for (const p2 of ALL_SCHEMES) { + const { steer, twoPlayer } = cardsFor(false, { 1: p1, 2: p2 }) + const seatsArt = twoPlayer.art as ReactElement<{ devices: string[] }> + + expect(seatsArt.type).toBe(SeatDevicesHint) + expect(seatsArt.props.devices).toEqual([p1 === 'mouse' ? 'mouse' : 'keyboard', p2 === 'mouse' ? 'mouse' : 'keyboard']) + if (p1 !== 'mouse') expect((steer.art as ReactElement).type).toBe(GuideUpDownKeys) + } + } + // The caps print the seat's own keys; the arrow keys draw as arrows (no labels). + expect((stepsFor(false, { 1: 'wasd', 2: 'mouse' })[0].art as ReactElement<{ labels?: object }>).props.labels).toEqual({ up: 'W', down: 'S' }) + expect((stepsFor(false, { 1: 'arrows', 2: 'mouse' })[0].art as ReactElement<{ labels?: object }>).props.labels).toBeUndefined() + }) + + it('teaches the own-half rule on the two-player desktop card whenever a seat is on the mouse, and never on the first two cards', () => { + // A two-player drag moves whichever paddle's half it starts in, so the default player 1 (on the + // mouse) dragging on the far side would move player 2's paddle. + expect(cardsFor(false).twoPlayer.body).toBe('Player 1 uses the mouse, player 2 uses W and S. Drag in your own half.') + + for (const p1 of ALL_SCHEMES) { + for (const p2 of ALL_SCHEMES) { + const { steer, rules, twoPlayer } = cardsFor(false, { 1: p1, 2: p2 }) + const anyMouse = p1 === 'mouse' || p2 === 'mouse' + + expect(steer.body).not.toMatch(/half|anywhere/i) + expect(rules.body).not.toMatch(/half/i) + expect(twoPlayer.body).toMatch(anyMouse ? /\. Drag in your own half\.$/ : /\. Change them before a match\.$/) + } + } + }) +}) diff --git a/src/shared/paddleGame/__tests__/physics.test.ts b/src/shared/paddleGame/__tests__/physics.test.ts new file mode 100644 index 0000000..56c8eff --- /dev/null +++ b/src/shared/paddleGame/__tests__/physics.test.ts @@ -0,0 +1,371 @@ +import { MAX_PADDLE_SPEED, MAX_TICK_SUBSTEPS, PADDLE_COLLISION_RESTITUTION, SERVE_ANGLE_MAX_DEG } from '@/shared/paddleGame/constants/game' +import { BoardLayout } from '@/shared/paddleGame/types' +import { applyBrickCollision, applyPaddleCollision, clampPaddleSpeed, computePaddleVelocity, computeServeVelocity, computeTickSubsteps, stepPhysics } from '@/shared/paddleGame/utils/physics' + +// A standard test board - isolates wall/edge-scoring behavior from paddle-collision behavior, same +// discipline AirHockey's own physics.test.ts fixture uses. Landscape-shaped (width > height, unlike +// AirHockey's portrait fixture): Pong's local space always has the paddles at local-left/-right +// (the long axis) and the walls at local-top/-bottom (the short axis). +const board: BoardLayout = { + x: 10, + y: 10, + width: 500, + height: 300, + wallThickness: 8, + ballRadius: 8, + paddleWidth: 10, + paddleHeight: 60, + paddleX: { 1: 30, 2: 480 }, + paddleMinY: 40, + paddleMaxY: 280, + centerX: 260, + centerY: 160 +} + +const cx = board.x + board.width / 2 // 260 +const cy = board.y + board.height / 2 // 160 +const r = board.ballRadius // 8 + +// Returns a value that lets a caller supply exactly N deterministic random() draws in order, +// repeating the final value forever after - needed because computeServeVelocity calls random() +// up to twice per invocation (once for angle, once for direction when towardPlayer is null), so a +// single constant-returning mock can't isolate one draw's effect from the other's. +function sequence(...values: number[]): () => number { + let i = 0 + return () => values[Math.min(i++, values.length - 1)] +} + +describe('stepPhysics - wall bounces', () => { + it('bounces off the top wall and reverses vy', () => { + const ball = { x: cx, y: board.y + r + 1, vx: 0, vy: -5, speed: 5 } + const result = stepPhysics(ball, board) + expect(result.y).toBeGreaterThanOrEqual(board.y + r) + expect(result.vy).toBeGreaterThan(0) + expect(result.bounced).toBe(true) + expect(result.scored).toBeNull() + }) + + it('bounces off the bottom wall and reverses vy', () => { + const ball = { x: cx, y: board.y + board.height - r - 1, vx: 0, vy: 5, speed: 5 } + const result = stepPhysics(ball, board) + expect(result.y).toBeLessThanOrEqual(board.y + board.height - r) + expect(result.vy).toBeLessThan(0) + expect(result.bounced).toBe(true) + }) + + it('is lossless - vy magnitude is exactly preserved and vx is untouched (WALL_RESTITUTION = 1, unlike AirHockey)', () => { + const ball = { x: cx, y: board.y + r + 1, vx: 3, vy: -7, speed: Math.hypot(3, 7) } + const result = stepPhysics(ball, board) + expect(Math.abs(result.vy)).toBeCloseTo(7, 5) + expect(result.vx).toBeCloseTo(3, 5) + }) + + it('does not bounce when the ball is not near a wall', () => { + const ball = { x: cx, y: cy, vx: 2, vy: 2, speed: Math.hypot(2, 2) } + const result = stepPhysics(ball, board) + expect(result.bounced).toBe(false) + expect(result.scored).toBeNull() + }) +}) + +describe('stepPhysics - edge scoring', () => { + it("scores P2 once the ball has fully cleared player 1's edge", () => { + const ball = { x: board.x + r - 5, y: cy, vx: -50, vy: 0, speed: 50 } + const result = stepPhysics(ball, board) + expect(result.scored).toBe(2) + }) + + it("scores P1 once the ball has fully cleared player 2's edge", () => { + const ball = { x: board.x + board.width - r + 5, y: cy, vx: 50, vy: 0, speed: 50 } + const result = stepPhysics(ball, board) + expect(result.scored).toBe(1) + }) + + it('does NOT score merely touching the edge - the ball has to clear it entirely, center included', () => { + const ball = { x: board.x - r + 1, y: cy, vx: 0, vy: 0, speed: 0 } + const result = stepPhysics(ball, board) + expect(result.scored).toBeNull() + }) + + it('does not score when the ball stays well within the board mid-court', () => { + const ball = { x: cx, y: cy, vx: 1, vy: 0, speed: 1 } + const result = stepPhysics(ball, board) + expect(result.scored).toBeNull() + }) +}) + +describe('stepPhysics - bounce jitter', () => { + it('jitters a wall bounce only when random is provided - classic Pong stays perfectly deterministic by default', () => { + const ball = { x: cx, y: board.y + r + 1, vx: 3, vy: -7, speed: Math.hypot(3, 7) } + const withoutJitter = stepPhysics(ball, board) + const withJitter = stepPhysics(ball, board, 1, false, () => 0.9) + expect(withoutJitter.vx).toBeCloseTo(3, 5) // no random passed -> vx untouched, exactly as every existing caller already expects + expect(withJitter.vx).not.toBeCloseTo(3, 5) // random passed -> the wall bounce got jittered too + expect(Math.hypot(withJitter.vx, withJitter.vy)).toBeCloseTo(Math.hypot(3, 7), 5) // magnitude still preserved - a direction-only nudge + }) + + it('different random draws produce different jittered angles off the same wall bounce', () => { + const ball = { x: cx, y: board.y + r + 1, vx: 3, vy: -7, speed: Math.hypot(3, 7) } + const a = stepPhysics(ball, board, 1, false, () => 0.9) + const b = stepPhysics(ball, board, 1, false, () => 0.1) + expect(a.vx).not.toBeCloseTo(b.vx, 5) + }) + + it('never jitters when the ball did not bounce at all this step', () => { + const ball = { x: cx, y: cy, vx: 2, vy: 2, speed: Math.hypot(2, 2) } + const result = stepPhysics(ball, board, 1, false, () => 0.9) + expect(result.vx).toBeCloseTo(2, 5) + expect(result.vy).toBeCloseTo(2, 5) + }) +}) + +describe('stepPhysics - solo far wall (farEdgeIsWall)', () => { + it('bounces off the far edge instead of scoring when true - solo has no second paddle to concede a point to', () => { + const ball = { x: board.x + board.width - r + 5, y: cy, vx: 50, vy: 0, speed: 50 } + const result = stepPhysics(ball, board, 1, true) + expect(result.scored).toBeNull() + expect(result.bounced).toBe(true) + expect(result.vx).toBeLessThan(0) // reflected back leftward, into the court + expect(result.x).toBeLessThanOrEqual(board.x + board.width - r) + }) + + it('still scores P2 off the near (P1) edge when true - only the far edge changes', () => { + const ball = { x: board.x + r - 5, y: cy, vx: -50, vy: 0, speed: 50 } + const result = stepPhysics(ball, board, 1, true) + expect(result.scored).toBe(2) + }) + + it('defaults to false - every existing caller/test keeps scoring at the far edge unchanged', () => { + const ball = { x: board.x + board.width - r + 5, y: cy, vx: 50, vy: 0, speed: 50 } + const result = stepPhysics(ball, board) + expect(result.scored).toBe(1) + }) +}) + +describe('applyPaddleCollision', () => { + const halfW = board.paddleWidth / 2 + const halfH = board.paddleHeight / 2 + const combined = r + halfW + const speedIncrement = 2 + const maxSpeed = 30 + + it('is a no-op when the ball and paddle are not overlapping', () => { + const ball = { x: 0, y: 0, vx: 3, vy: 3, speed: Math.hypot(3, 3) } + const paddle = { x: 500, y: 500, vx: 0, vy: 0 } + const result = applyPaddleCollision(ball, paddle, board, speedIncrement, maxSpeed) + expect(result).toEqual(ball) + }) + + it('a stationary paddle produces an ordinary elastic reflection along the contact normal, ratcheted to speed + speedIncrement', () => { + // Ball approaching a stationary paddle head-on from the right (paddle's right face). + const ball = { x: combined - 2, y: 0, vx: -5, vy: 0, speed: 5 } + const paddle = { x: 0, y: 0, vx: 0, vy: 0 } + const result = applyPaddleCollision(ball, paddle, board, speedIncrement, maxSpeed) + expect(result.vx).toBeGreaterThan(0) // reflected back toward +x, away from the paddle + expect(result.vy).toBeCloseTo(0, 5) // dead-center contact - no steer to apply + expect(result.speed).toBeCloseTo(5 + speedIncrement, 5) + expect(Math.hypot(result.vx, result.vy)).toBeCloseTo(5 + speedIncrement, 5) + }) + + it('a moving paddle launches a resting ball - direction reflects the contact, but magnitude is set by the ratchet, not by the paddle speed', () => { + const ball = { x: combined - 2, y: 0, vx: 0, vy: 0, speed: 0 } + const paddle = { x: 0, y: 0, vx: 8, vy: 0 } + const result = applyPaddleCollision(ball, paddle, board, speedIncrement, maxSpeed) + expect(result.vx).toBeGreaterThan(0) // launched, not left at rest + expect(result.speed).toBeCloseTo(speedIncrement, 5) // 0 + speedIncrement - the ratchet's own magnitude, not the paddle's velocity + }) + + it('a faster paddle and a slower paddle both ratchet to the IDENTICAL resulting speed - unlike AirHockey, where a faster mallet hits harder', () => { + // Pong's ball speed is a designed ratchet (see BALL_SPEED_TIERS), not a direct physical + // consequence of the paddle's own velocity like AirHockey's puck - post-collision speed comes + // from ball.speed + speedIncrement regardless of how fast the paddle that struck it was moving. + const ball = { x: combined - 2, y: 0, vx: 0, vy: 0, speed: 0 } + const slowPaddle = { x: 0, y: 0, vx: 8, vy: 0 } + const fastPaddle = { x: 0, y: 0, vx: 20, vy: 0 } + const slowResult = applyPaddleCollision(ball, slowPaddle, board, speedIncrement, maxSpeed) + const fastResult = applyPaddleCollision(ball, fastPaddle, board, speedIncrement, maxSpeed) + expect(slowResult.speed).toBeCloseTo(fastResult.speed, 5) + }) + + it("never mutates the paddle's own position or velocity - the paddle stays 100% finger/AI-controlled", () => { + const ball = { x: combined - 2, y: 0, vx: -3, vy: 1, speed: 4 } + const paddle = { x: 0, y: 0, vx: 12, vy: -4 } + const snapshot = { ...paddle } + applyPaddleCollision(ball, paddle, board, speedIncrement, maxSpeed) + expect(paddle).toEqual(snapshot) + }) + + it('ratchets speed by speedIncrement per hit and caps at maxSpeed across repeated hits', () => { + const paddle = { x: 0, y: 0, vx: 0, vy: 0 } + let ball = { x: combined - 2, y: 0, vx: -5, vy: 0, speed: 5 } + ball = applyPaddleCollision(ball, paddle, board, 3, 12) + expect(ball.speed).toBeCloseTo(8, 5) // 5 + 3 + ball = { ...ball, x: combined - 2, vx: -ball.speed, vy: 0 } // re-approach for a second strike + ball = applyPaddleCollision(ball, paddle, board, 3, 12) + expect(ball.speed).toBeCloseTo(11, 5) // 8 + 3 + ball = { ...ball, x: combined - 2, vx: -ball.speed, vy: 0 } + ball = applyPaddleCollision(ball, paddle, board, 3, 12) + expect(ball.speed).toBeCloseTo(12, 5) // capped at maxSpeed (12), not 14 + }) + + it("contact-offset steering biases vy toward the strike position along the paddle's own length", () => { + const paddle = { x: 0, y: 0, vx: 0, vy: 0 } + const struckNearTop = { x: combined - 2, y: -halfH * 0.6, vx: -5, vy: 0, speed: 5 } + const struckNearBottom = { x: combined - 2, y: halfH * 0.6, vx: -5, vy: 0, speed: 5 } + const resultTop = applyPaddleCollision(struckNearTop, paddle, board, speedIncrement, maxSpeed) + const resultBottom = applyPaddleCollision(struckNearBottom, paddle, board, speedIncrement, maxSpeed) + expect(resultTop.vy).toBeLessThan(0) // steered further toward the top + expect(resultBottom.vy).toBeGreaterThan(0) // steered further toward the bottom + }) + + it('a ball geometrically overlapping the paddle but already separating still gets pushed out and ratcheted - col.hit is a pure overlap test, not an approach-direction test', () => { + const ball = { x: combined - 2, y: 0, vx: 5, vy: 0, speed: 5 } // already moving further in +x, away from the paddle at origin + const paddle = { x: 0, y: 0, vx: 0, vy: 0 } + const result = applyPaddleCollision(ball, paddle, board, speedIncrement, maxSpeed) + // reflectOffNormal's own internal dot >= 0 check leaves an already-separating RELATIVE + // velocity untouched - but applyPaddleCollision still re-normalizes that (direction-preserved) + // result onto the ratcheted magnitude regardless, since col.hit alone (a pure geometric + // overlap test) is what gates the ratchet here, not whether the ball was approaching or + // separating. Direction stays +x (unchanged); only the MAGNITUDE moves to speed + increment. + expect(result.vx).toBeGreaterThan(0) + expect(result.speed).toBeCloseTo(5 + speedIncrement, 5) + expect(Math.hypot(result.vx, result.vy)).toBeCloseTo(5 + speedIncrement, 5) + }) +}) + +describe('applyBrickCollision', () => { + it('is a no-op when the ball overlaps no brick', () => { + const brick = { x: 0, y: 0, width: 10, height: 10, destroyed: false } + const ball = { x: 500, y: 500, vx: 3, vy: 3, speed: Math.hypot(3, 3) } + const result = applyBrickCollision(ball, [brick], board) + expect(result.ball).toEqual(ball) + expect(result.hitIndex).toBeNull() + }) + + it("reflects off the brick's face, reports the hit index, and is lossless - no ratchet, unlike a paddle hit", () => { + const brick = { x: cx, y: cy - 5, width: 10, height: 10, destroyed: false } + const ball = { x: cx - 6, y: cy, vx: 5, vy: 0, speed: 5 } // overlapping the brick's left face + // random: () => 0.5 -> exactly zero jitter (see the jitter tests below), so the reflection is + // pure geometry here, matching this test's own precise assertions. + const result = applyBrickCollision(ball, [brick], board, () => 0.5) + expect(result.hitIndex).toBe(0) + expect(result.ball.vx).toBeLessThan(0) // bounced back away from the brick + expect(result.ball.speed).toBeCloseTo(5, 5) + expect(Math.hypot(result.ball.vx, result.ball.vy)).toBeCloseTo(5, 5) + }) + + it('skips a destroyed brick entirely, even when geometrically overlapping', () => { + const brick = { x: cx, y: cy - 5, width: 10, height: 10, destroyed: true } + const ball = { x: cx - 6, y: cy, vx: 5, vy: 0, speed: 5 } + const result = applyBrickCollision(ball, [brick], board) + expect(result.ball).toEqual(ball) + expect(result.hitIndex).toBeNull() + }) + + it('resolves only the first overlapping brick in the array, not every one in a single call', () => { + const overlapping = { x: cx, y: cy - 5, width: 10, height: 10, destroyed: false } + const ball = { x: cx - 6, y: cy, vx: 5, vy: 0, speed: 5 } + const result = applyBrickCollision(ball, [overlapping, overlapping], board, () => 0.5) + expect(result.hitIndex).toBe(0) + }) + + it('applies a small random angle jitter per hit - the anti-stall device: a perfectly elastic, unsteered bounce could otherwise settle into a stable periodic orbit inside a partially-cleared pocket and never make further progress', () => { + const brick = { x: cx, y: cy - 5, width: 10, height: 10, destroyed: false } + const ball = { x: cx - 6, y: cy, vx: 5, vy: 0, speed: 5 } + const a = applyBrickCollision(ball, [brick], board, () => 0.9) + const b = applyBrickCollision(ball, [brick], board, () => 0.1) + expect(a.ball.vy).not.toBeCloseTo(b.ball.vy, 5) // different draws -> measurably different angles + // Jitter rotates the vector - it never changes its magnitude, so speed stays exact either way. + expect(Math.hypot(a.ball.vx, a.ball.vy)).toBeCloseTo(5, 5) + expect(Math.hypot(b.ball.vx, b.ball.vy)).toBeCloseTo(5, 5) + }) + + it('random defaults to Math.random when omitted, so live gameplay is naturally randomized without a caller having to opt in', () => { + const brick = { x: cx, y: cy - 5, width: 10, height: 10, destroyed: false } + const ball = { x: cx - 6, y: cy, vx: 5, vy: 0, speed: 5 } + const result = applyBrickCollision(ball, [brick], board) + expect(Math.hypot(result.ball.vx, result.ball.vy)).toBeCloseTo(5, 5) + }) +}) + +describe('computeServeVelocity', () => { + it('always produces the exact baseSpeed magnitude, regardless of angle', () => { + const v = computeServeVelocity(10, null, sequence(0.9, 0.5)) + expect(Math.hypot(v.vx, v.vy)).toBeCloseTo(10, 5) + }) + + it('constrains the angle to within ±SERVE_ANGLE_MAX_DEG of the horizontal', () => { + const v = computeServeVelocity(10, null, sequence(1, 0.5)) // random()=1 on the angle draw -> the max possible angle + const angleDeg = (Math.abs(Math.atan2(v.vy, Math.abs(v.vx))) * 180) / Math.PI + expect(angleDeg).toBeLessThanOrEqual(SERVE_ANGLE_MAX_DEG + 0.001) + expect(angleDeg).toBeCloseTo(SERVE_ANGLE_MAX_DEG, 3) + }) + + it('serves toward player 2 (positive vx) when towardPlayer is 2', () => { + const v = computeServeVelocity(10, 2, sequence(0.5)) + expect(v.vx).toBeGreaterThan(0) + }) + + it('serves toward player 1 (negative vx) when towardPlayer is 1', () => { + const v = computeServeVelocity(10, 1, sequence(0.5)) + expect(v.vx).toBeLessThan(0) + }) + + it('is a coin flip in direction when towardPlayer is null - the FIRST serve of a match', () => { + // goingRight = (2nd draw) < 0.5 - so a LOW 2nd draw serves right (toward P2), a HIGH one left. + const towardP2 = computeServeVelocity(10, null, sequence(0.5, 0.1)) + const towardP1 = computeServeVelocity(10, null, sequence(0.5, 0.9)) + expect(towardP1.vx).toBeLessThan(0) + expect(towardP2.vx).toBeGreaterThan(0) + }) +}) + +describe('computeTickSubsteps', () => { + const paddleWidth = 10 // safeDisplacement = paddleWidth * PADDLE_TUNNEL_SAFETY_RATIO(0.4) = 4 + + it('returns 1 substep when the per-tick displacement stays within the safety threshold', () => { + expect(computeTickSubsteps(3, 1, paddleWidth)).toBe(1) + }) + + it('returns more than 1 once displacement exceeds the safety threshold', () => { + expect(computeTickSubsteps(10, 1, paddleWidth)).toBeGreaterThan(1) + }) + + it('caps at MAX_TICK_SUBSTEPS regardless of how large the displacement gets', () => { + expect(computeTickSubsteps(10000, 2.5, paddleWidth)).toBe(MAX_TICK_SUBSTEPS) + }) +}) + +// Copied VERBATIM from AirHockey's own utils/physics.ts - same test bodies, since the functions +// themselves are unchanged. +describe('computePaddleVelocity', () => { + it('derives velocity from the position delta over dt', () => { + const v = computePaddleVelocity(0, 0, 10, 0, 1) + expect(v.vx).toBeCloseTo(10, 5) + expect(v.vy).toBeCloseTo(0, 5) + }) + + it('returns zero velocity for a non-positive dt rather than dividing by zero', () => { + expect(computePaddleVelocity(0, 0, 10, 10, 0)).toEqual({ vx: 0, vy: 0 }) + }) +}) + +describe('clampPaddleSpeed', () => { + it('leaves a slow velocity untouched', () => { + expect(clampPaddleSpeed(3, 4)).toEqual({ vx: 3, vy: 4 }) + }) + + it('scales a too-fast velocity down to MAX_PADDLE_SPEED, preserving direction', () => { + const clamped = clampPaddleSpeed(300, 400) // speed 500, well over the cap + const speed = Math.hypot(clamped.vx, clamped.vy) + expect(speed).toBeCloseTo(MAX_PADDLE_SPEED, 5) + expect(clamped.vx / clamped.vy).toBeCloseTo(300 / 400, 5) + }) +}) + +// Sanity check that PADDLE_COLLISION_RESTITUTION is genuinely 1 (lossless), the same way +// stepPhysics's wall bounces are - otherwise the ratchet's fixed-increment model and a +// restitution-driven decay would silently fight each other on every strike. +it('PADDLE_COLLISION_RESTITUTION is lossless', () => { + expect(PADDLE_COLLISION_RESTITUTION).toBe(1) +}) diff --git a/src/shared/paddleGame/__tests__/seatResultSpots.test.ts b/src/shared/paddleGame/__tests__/seatResultSpots.test.ts new file mode 100644 index 0000000..148625b --- /dev/null +++ b/src/shared/paddleGame/__tests__/seatResultSpots.test.ts @@ -0,0 +1,21 @@ +import { seatResultSpots } from '@/shared/paddleGame/utils/seatResultSpots' + +// Where each 2 Player seat's Game Over card goes: their own half (their touch zone), facing them. +describe('seatResultSpots', () => { + it('puts player 1 at the bottom of a turned court and player 2 across the table, turned to face them', () => { + expect(seatResultSpots(-90, false)).toEqual({ 1: { rotation: 0, zone: 'bottom' }, 2: { rotation: 180, zone: 'top' } }) + }) + + it('follows the court over when the phone is held upside down', () => { + expect(seatResultSpots(90, false)).toEqual({ 1: { rotation: 180, zone: 'top' }, 2: { rotation: 0, zone: 'bottom' } }) + }) + + it('splits an unturned court left and right, both upright', () => { + expect(seatResultSpots(0, false)).toEqual({ 1: { rotation: 0, zone: 'left' }, 2: { rotation: 0, zone: 'right' } }) + expect(seatResultSpots(0, true)).toEqual({ 1: { rotation: 0, zone: 'left' }, 2: { rotation: 0, zone: 'right' } }) + }) + + it('keeps both cards upright on a computer screen', () => { + expect(seatResultSpots(-90, true)).toEqual({ 1: { rotation: 0, zone: 'bottom' }, 2: { rotation: 0, zone: 'top' } }) + }) +}) diff --git a/src/shared/paddleGame/__tests__/settingsSlice.test.ts b/src/shared/paddleGame/__tests__/settingsSlice.test.ts new file mode 100644 index 0000000..051fc9a --- /dev/null +++ b/src/shared/paddleGame/__tests__/settingsSlice.test.ts @@ -0,0 +1,26 @@ +import { createPaddleSettings, DEFAULT_SETTINGS } from '@/shared/paddleGame/settings' +import type { ShellState } from '@/shell/redux/shellState' + +// Each paddle game keeps its own players' settings under its own keys; Breaker's stands in for both. +// The per-player behavior itself is shared/playerSettings.ts's, tested in playerSettings.test.tsx. +const breaker = createPaddleSettings('breaker') + +const state = (extra: Record) => ({ profileSelection: { 1: 'sam', 2: null }, opponent: { seatTwo: 'cpu' }, ...extra }) as unknown as ShellState + +describe("a paddle game's per-player settings", () => { + it("are saved under the game's own keys", () => { + expect(Object.keys(breaker.reducers)).toEqual(['breakerSettings', 'breakerControls']) + }) + + it('fill what a player never changed from the defaults', () => { + expect(breaker.select(state({ breakerSettings: { sam: { ballSpeed: 'slow' } } }))).toEqual({ ...DEFAULT_SETTINGS, ballSpeed: 'slow' }) + }) + + it("read only the game's own keys, not another paddle game's", () => { + expect(breaker.select(state({ paddlesSettings: { sam: { ballSpeed: 'fast' } }, paddlesControls: { sam: 'arrows' } }))).toEqual(DEFAULT_SETTINGS) + }) + + it('move seat 2 to the next free control when both players saved the same one', () => { + expect(breaker.compose({}, { sam: 'wasd', riley: 'wasd' }, { 1: 'sam', 2: 'riley' }, true).controlScheme).toEqual({ 1: 'wasd', 2: 'mouse' }) + }) +}) diff --git a/src/shared/paddleGame/__tests__/statsEngine.test.ts b/src/shared/paddleGame/__tests__/statsEngine.test.ts new file mode 100644 index 0000000..27af374 --- /dev/null +++ b/src/shared/paddleGame/__tests__/statsEngine.test.ts @@ -0,0 +1,267 @@ +import { Profile } from '@tastic/profile' + +import { StatsState } from '@/shared/paddleGame/types' +import { applyMatchOutcome, applySoloOutcome, getOverallTotals, getProfileRankings, getProfileStatsView, getSpeedsWon, MatchContext, MatchOutcome } from '@/shared/paddleGame/utils/statsEngine' +import { DEFAULT_PROFILE_STATS, DEFAULT_STATS } from '@/shared/paddleGame/utils/statsValidation' + +const VS_CPU: MatchContext = { gameMode: 'vsCpu', aiDifficulty: 'medium', ballSpeed: 'medium' } +const TWO_PLAYER: MatchContext = { gameMode: 'twoPlayer', aiDifficulty: 'medium', ballSpeed: 'medium' } + +function win(scores: [number, number]): MatchOutcome { + return { winner: scores[0] > scores[1] ? 1 : 2, scores } +} + +function play(prev: StatsState, outcome: MatchOutcome, context: MatchContext = VS_CPU, now = new Date(2026, 8, 2)): StatsState { + return applyMatchOutcome(prev, outcome, context, now) +} + +describe('applyMatchOutcome - vsCpu', () => { + it('records a human win without touching twoPlayer', () => { + const s = play(DEFAULT_STATS, win([7, 3])) + expect(s.vsCpu).toMatchObject({ played: 1, wins: 1, losses: 0 }) + expect(s.twoPlayer).toEqual({ played: 0, p1Wins: 0, p2Wins: 0 }) + }) + + it('records a human loss', () => { + const s = play(DEFAULT_STATS, win([2, 7])) + expect(s.vsCpu).toMatchObject({ played: 1, wins: 0, losses: 1 }) + }) + + it('buckets byDifficulty separately', () => { + let s = play(DEFAULT_STATS, win([7, 1]), { ...VS_CPU, aiDifficulty: 'easy' }) + s = play(s, win([7, 2]), { ...VS_CPU, aiDifficulty: 'hard' }) + expect(s.vsCpu.byDifficulty.easy.wins).toBe(1) + expect(s.vsCpu.byDifficulty.hard.wins).toBe(1) + expect(s.vsCpu.byDifficulty.medium.played).toBe(0) + }) + + it('tracks a win streak and resets it on a loss', () => { + let s = play(DEFAULT_STATS, win([7, 0])) + s = play(s, win([7, 1])) + expect(s.vsCpu).toMatchObject({ currentWinStreak: 2, bestWinStreak: 2 }) + s = play(s, win([3, 7])) + expect(s.vsCpu).toMatchObject({ currentWinStreak: 0, bestWinStreak: 2 }) + }) +}) + +describe('applyMatchOutcome - goals, shutouts and margins', () => { + it('accumulates goals for and against from seat 1s perspective', () => { + let s = play(DEFAULT_STATS, win([7, 3])) + s = play(s, win([2, 7])) + expect(s.points).toEqual({ scored: 9, conceded: 10 }) + }) + + it('counts a shutout only on a win where nothing was conceded', () => { + let s = play(DEFAULT_STATS, win([7, 0])) + expect(s.shutouts).toBe(1) + // Losing 0-7 is not a shutout for seat 1. + s = play(s, win([0, 7])) + expect(s.shutouts).toBe(1) + }) + + it('tracks the best win margin, ignoring losses', () => { + let s = play(DEFAULT_STATS, win([7, 4])) + expect(s.bestWinMargin).toBe(3) + s = play(s, win([0, 7])) + expect(s.bestWinMargin).toBe(3) + s = play(s, win([7, 0])) + expect(s.bestWinMargin).toBe(7) + }) +}) + +describe('applyMatchOutcome - ball speed', () => { + it('buckets each speed tier separately', () => { + let s = play(DEFAULT_STATS, win([7, 0]), { ...VS_CPU, ballSpeed: 'fast' }) + s = play(s, win([1, 7]), { ...VS_CPU, ballSpeed: 'slow' }) + expect(s.bySpeed.fast.wins).toBe(1) + expect(s.bySpeed.slow.losses).toBe(1) + expect(s.bySpeed.medium.played).toBe(0) + }) + + it('getSpeedsWon counts distinct speeds with at least one win', () => { + let s = play(DEFAULT_STATS, win([7, 0]), { ...VS_CPU, ballSpeed: 'slow' }) + expect(getSpeedsWon(s)).toBe(1) + // A loss at another speed does not count toward it. + s = play(s, win([0, 7]), { ...VS_CPU, ballSpeed: 'fast' }) + expect(getSpeedsWon(s)).toBe(1) + s = play(s, win([7, 2]), { ...VS_CPU, ballSpeed: 'fast' }) + expect(getSpeedsWon(s)).toBe(2) + }) +}) + +describe('applyMatchOutcome - twoPlayer seats', () => { + it('records seat wins without touching vsCpu', () => { + const s = play(DEFAULT_STATS, win([7, 5]), TWO_PLAYER) + expect(s.twoPlayer).toEqual({ played: 1, p1Wins: 1, p2Wins: 0 }) + expect(s.vsCpu.played).toBe(0) + }) + + it('credits seat 2 when seat 2 wins', () => { + const s = play(DEFAULT_STATS, win([4, 7]), TWO_PLAYER) + expect(s.twoPlayer).toEqual({ played: 1, p1Wins: 0, p2Wins: 1 }) + }) +}) + +describe('applyMatchOutcome - profiles', () => { + const withProfiles: MatchContext = { ...TWO_PLAYER, profileIds: { 1: 'alice', 2: 'bob' } } + + it('leaves profiles untouched when no ids are supplied', () => { + expect(play(DEFAULT_STATS, win([7, 1]), TWO_PLAYER).profiles).toEqual({}) + }) + + it("attributes the result to each seat's own profile", () => { + const s = play(DEFAULT_STATS, win([7, 1]), withProfiles) + expect(s.profiles.alice.twoPlayer).toEqual({ played: 1, p1Wins: 1, p2Wins: 0 }) + expect(s.profiles.bob.twoPlayer).toEqual({ played: 1, p1Wins: 0, p2Wins: 0 }) + }) + + it("records each profile's points from its own side of the table", () => { + const s = play(DEFAULT_STATS, win([7, 1]), withProfiles) + expect(s.profiles.alice.points).toEqual({ scored: 7, conceded: 1 }) + expect(s.profiles.bob.points).toEqual({ scored: 1, conceded: 7 }) + }) + + it('credits a shutout to the winner only', () => { + const s = play(DEFAULT_STATS, win([7, 0]), withProfiles) + expect(s.profiles.alice.shutouts).toBe(1) + expect(s.profiles.bob.shutouts).toBe(0) + }) + + it("counts a profile's own wins regardless of which seat they sat in", () => { + let s = play(DEFAULT_STATS, win([7, 1]), { ...TWO_PLAYER, profileIds: { 1: 'alice' } }) + // Alice moves to seat 2 and wins again. + s = play(s, win([1, 7]), { ...TWO_PLAYER, profileIds: { 2: 'alice' } }) + const alice = s.profiles.alice + expect(alice.twoPlayer.p1Wins + alice.twoPlayer.p2Wins).toBe(2) + expect(alice.twoPlayer.played).toBe(2) + }) + + it('never creates a phantom CPU-seat profile entry in vsCpu', () => { + const s = play(DEFAULT_STATS, win([7, 1]), { ...VS_CPU, profileIds: { 1: 'alice' } }) + expect(Object.keys(s.profiles)).toEqual(['alice']) + }) +}) + +describe('firstGameResult', () => { + it('is set from the very first match, from seat 1s perspective', () => { + expect(play(DEFAULT_STATS, win([7, 2])).firstGameResult).toBe('win') + expect(play(DEFAULT_STATS, win([2, 7])).firstGameResult).toBe('loss') + }) + + it('is never overwritten by a later match', () => { + let s = play(DEFAULT_STATS, win([2, 7])) + s = play(s, win([7, 0])) + expect(s.firstGameResult).toBe('loss') + }) +}) + +describe('day streak', () => { + it('starts at 1 on the first day played', () => { + const s = play(DEFAULT_STATS, win([7, 0]), VS_CPU, new Date(2026, 8, 2)) + expect(s).toMatchObject({ distinctDaysPlayed: 1, currentDayStreak: 1, bestDayStreak: 1 }) + }) + + it('does not double-count two matches on the same day', () => { + let s = play(DEFAULT_STATS, win([7, 0]), VS_CPU, new Date(2026, 8, 2, 9)) + s = play(s, win([7, 1]), VS_CPU, new Date(2026, 8, 2, 21)) + expect(s.distinctDaysPlayed).toBe(1) + // The match itself still counted, even though the day didn't. + expect(s.vsCpu.played).toBe(2) + }) + + it('extends on a consecutive day and restarts after a gap', () => { + let s = play(DEFAULT_STATS, win([7, 0]), VS_CPU, new Date(2026, 8, 1)) + s = play(s, win([7, 0]), VS_CPU, new Date(2026, 8, 2)) + expect(s.currentDayStreak).toBe(2) + s = play(s, win([7, 0]), VS_CPU, new Date(2026, 8, 9)) + expect(s).toMatchObject({ currentDayStreak: 1, bestDayStreak: 2 }) + }) +}) + +describe('applySoloOutcome', () => { + it('records a clear as a win and folds bricksDestroyed into the mode-agnostic counter', () => { + const s = applySoloOutcome(DEFAULT_STATS, { cleared: true, bricksDestroyed: 48 }, undefined, new Date(2026, 8, 2)) + expect(s.solo).toEqual({ played: 1, cleared: 1 }) + expect(s.bricksDestroyed).toBe(48) + }) + + it('records running out of lives as a play without a clear', () => { + const s = applySoloOutcome(DEFAULT_STATS, { cleared: false, bricksDestroyed: 12 }, undefined, new Date(2026, 8, 2)) + expect(s.solo).toEqual({ played: 1, cleared: 0 }) + expect(s.bricksDestroyed).toBe(12) + }) + + it('attributes the result to the given profile in addition to the device-wide bucket', () => { + const s = applySoloOutcome(DEFAULT_STATS, { cleared: true, bricksDestroyed: 48 }, 'alice', new Date(2026, 8, 2)) + expect(s.profiles.alice.solo).toEqual({ played: 1, cleared: 1 }) + expect(s.profiles.alice.bricksDestroyed).toBe(48) + }) + + it('never creates a profile entry when no profileId is given', () => { + expect(applySoloOutcome(DEFAULT_STATS, { cleared: true, bricksDestroyed: 48 }, undefined, new Date(2026, 8, 2)).profiles).toEqual({}) + }) + + it('does not touch the vsCpu or twoPlayer buckets', () => { + const s = applySoloOutcome(DEFAULT_STATS, { cleared: true, bricksDestroyed: 48 }, undefined, new Date(2026, 8, 2)) + expect(s.vsCpu.played).toBe(0) + expect(s.twoPlayer.played).toBe(0) + }) + + it('sets firstGameResult when a solo round is the very first game ever, on either outcome', () => { + expect(applySoloOutcome(DEFAULT_STATS, { cleared: true, bricksDestroyed: 48 }, undefined).firstGameResult).toBe('win') + expect(applySoloOutcome(DEFAULT_STATS, { cleared: false, bricksDestroyed: 0 }, undefined).firstGameResult).toBe('loss') + }) + + it("a solo debut's firstGameResult is never later overwritten by a non-solo match - the two paths share one first-ever check", () => { + let s = applySoloOutcome(DEFAULT_STATS, { cleared: false, bricksDestroyed: 5 }, undefined) + expect(s.firstGameResult).toBe('loss') + s = play(s, win([7, 1]), VS_CPU) + expect(s.firstGameResult).toBe('loss') + }) +}) + +describe('applyMatchOutcome - bricksDestroyed context (vsCpu/twoPlayer Bricks toggle)', () => { + it('folds an optional bricksDestroyed count into the shared, mode-agnostic counter', () => { + expect(play(DEFAULT_STATS, win([7, 1]), { ...VS_CPU, bricksDestroyed: 15 }).bricksDestroyed).toBe(15) + }) + + it('defaults to 0 when omitted (bricks were off this round), leaving the counter untouched', () => { + expect(play(DEFAULT_STATS, win([7, 1])).bricksDestroyed).toBe(0) + }) +}) + +describe('selectors', () => { + it('getOverallTotals sums both modes', () => { + let s = play(DEFAULT_STATS, win([7, 1])) + s = play(s, win([1, 7])) + s = play(s, win([7, 3]), TWO_PLAYER) + expect(getOverallTotals(s)).toEqual({ played: 3, wins: 2, losses: 1 }) + }) + + it('getOverallTotals folds in solo plays/clears too', () => { + let s = play(DEFAULT_STATS, win([7, 1])) + s = applySoloOutcome(s, { cleared: true, bricksDestroyed: 48 }, undefined) + s = applySoloOutcome(s, { cleared: false, bricksDestroyed: 10 }, undefined) + expect(getOverallTotals(s)).toEqual({ played: 3, wins: 2, losses: 1 }) + }) + + it('getProfileStatsView synthesizes a view with no nesting and a null firstGameResult', () => { + const s = play(DEFAULT_STATS, win([7, 1]), { ...TWO_PLAYER, profileIds: { 1: 'alice' } }) + const view = getProfileStatsView(s.profiles.alice) + expect(view.profiles).toEqual({}) + expect(view.firstGameResult).toBeNull() + expect(view.points).toEqual({ scored: 7, conceded: 1 }) + }) + + it('getProfileRankings sorts by wins then win rate, and includes profiles with no games', () => { + const mk = (id: string, name: string): Profile => ({ id, name, color: '#fff', tag: '', createdAt: 0, updatedAt: 0 }) + const profiles = [mk('a', 'A'), mk('b', 'B'), mk('c', 'C')] + const statsMap = { + a: { ...DEFAULT_PROFILE_STATS, twoPlayer: { played: 4, p1Wins: 1, p2Wins: 0 } }, + b: { ...DEFAULT_PROFILE_STATS, twoPlayer: { played: 2, p1Wins: 2, p2Wins: 0 } } + } + const ranked = getProfileRankings(profiles, statsMap) + expect(ranked.map((r) => r.profile.id)).toEqual(['b', 'a', 'c']) + expect(ranked[2]).toMatchObject({ played: 0, wins: 0, winRate: 0 }) + }) +}) diff --git a/src/shared/paddleGame/__tests__/statsValidation.test.ts b/src/shared/paddleGame/__tests__/statsValidation.test.ts new file mode 100644 index 0000000..26daf0f --- /dev/null +++ b/src/shared/paddleGame/__tests__/statsValidation.test.ts @@ -0,0 +1,80 @@ +import { DEFAULT_PROFILE_STATS, DEFAULT_STATS, isValidStats } from '@/shared/paddleGame/utils/statsValidation' + +describe('DEFAULT_STATS', () => { + it('is a zeroed blob that validates', () => { + expect(isValidStats(DEFAULT_STATS)).toBe(true) + expect(DEFAULT_STATS.profiles).toEqual({}) + expect(DEFAULT_STATS.firstGameResult).toBeNull() + }) + + it('zeroes every difficulty bucket', () => { + expect(Object.values(DEFAULT_STATS.vsCpu.byDifficulty).every((r) => r.played === 0)).toBe(true) + }) + + it('zeroes every ball-speed bucket', () => { + expect(Object.values(DEFAULT_STATS.bySpeed).every((r) => r.played === 0)).toBe(true) + }) + + it('DEFAULT_PROFILE_STATS carries no nesting of its own', () => { + expect('profiles' in DEFAULT_PROFILE_STATS).toBe(false) + }) + + it('zeroes the solo bucket and the mode-agnostic bricksDestroyed counter', () => { + expect(DEFAULT_STATS.solo).toEqual({ played: 0, cleared: 0 }) + expect(DEFAULT_STATS.bricksDestroyed).toBe(0) + }) +}) + +describe('isValidStats', () => { + it('accepts a blob with no profiles key - the additive-field case migrateStats backfills', () => { + const { profiles: _dropped, ...withoutProfiles } = DEFAULT_STATS + expect(isValidStats(withoutProfiles)).toBe(true) + }) + + it('accepts populated profile buckets', () => { + expect(isValidStats({ ...DEFAULT_STATS, profiles: { alice: DEFAULT_PROFILE_STATS } })).toBe(true) + }) + + it('rejects non-objects and arrays', () => { + expect(isValidStats(null)).toBe(false) + expect(isValidStats(undefined)).toBe(false) + expect(isValidStats('{}')).toBe(false) + expect(isValidStats([])).toBe(false) + }) + + it('rejects a missing or malformed vsCpu bucket', () => { + expect(isValidStats({ ...DEFAULT_STATS, vsCpu: undefined })).toBe(false) + expect(isValidStats({ ...DEFAULT_STATS, vsCpu: { ...DEFAULT_STATS.vsCpu, byDifficulty: {} } })).toBe(false) + }) + + it('rejects a missing or malformed solo bucket', () => { + expect(isValidStats({ ...DEFAULT_STATS, solo: undefined })).toBe(false) + expect(isValidStats({ ...DEFAULT_STATS, solo: { played: 1 } })).toBe(false) + }) + + it('rejects a non-numeric bricksDestroyed', () => { + expect(isValidStats({ ...DEFAULT_STATS, bricksDestroyed: '5' })).toBe(false) + }) + + it('rejects a malformed points object', () => { + expect(isValidStats({ ...DEFAULT_STATS, points: { scored: 1 } })).toBe(false) + }) + + it('rejects a bySpeed map missing a tier', () => { + expect(isValidStats({ ...DEFAULT_STATS, bySpeed: { slow: DEFAULT_STATS.bySpeed.slow } })).toBe(false) + }) + + it('rejects a non-numeric shutouts or bestWinMargin', () => { + expect(isValidStats({ ...DEFAULT_STATS, shutouts: '3' })).toBe(false) + expect(isValidStats({ ...DEFAULT_STATS, bestWinMargin: null })).toBe(false) + }) + + it('rejects an unrecognized firstGameResult', () => { + expect(isValidStats({ ...DEFAULT_STATS, firstGameResult: 'forfeit' })).toBe(false) + expect(isValidStats({ ...DEFAULT_STATS, firstGameResult: 'win' })).toBe(true) + }) + + it('rejects a profiles map whose bucket is malformed', () => { + expect(isValidStats({ ...DEFAULT_STATS, profiles: { alice: { vsCpu: 1 } } })).toBe(false) + }) +}) diff --git a/src/shared/paddleGame/__tests__/tiltInput.test.ts b/src/shared/paddleGame/__tests__/tiltInput.test.ts new file mode 100644 index 0000000..d39a346 --- /dev/null +++ b/src/shared/paddleGame/__tests__/tiltInput.test.ts @@ -0,0 +1,75 @@ +import { TILT_DEAD_ZONE_DEG, TILT_MAX_DEG } from '@/shared/paddleGame/constants/game' +import { calibrateTiltBaseline, computeTiltTargetY } from '@/shared/paddleGame/utils/tiltInput' + +const DEG_TO_RAD = Math.PI / 180 +const deg = (degrees: number) => degrees * DEG_TO_RAD + +const paddleMinY = 0 +const paddleMaxY = 200 +const boardCenterY = (paddleMinY + paddleMaxY) / 2 +const travelRadius = (paddleMaxY - paddleMinY) / 2 + +describe('calibrateTiltBaseline', () => { + it('stores beta/gamma/paddleY verbatim', () => { + expect(calibrateTiltBaseline(0.4, -0.2, 75)).toEqual({ beta: 0.4, gamma: -0.2, paddleY: 75 }) + }) +}) + +describe('computeTiltTargetY', () => { + it('returns the baseline paddleY unchanged when beta/gamma exactly match the baseline, even off-center', () => { + const baseline = calibrateTiltBaseline(deg(10), deg(-5), 60) + expect(computeTiltTargetY(deg(10), deg(-5), baseline, 0, paddleMinY, paddleMaxY)).toBeCloseTo(60, 5) + }) + + it('stays exactly at the baseline within the dead zone', () => { + const baseline = calibrateTiltBaseline(0, 0, boardCenterY) + expect(computeTiltTargetY(deg(TILT_DEAD_ZONE_DEG), 0, baseline, 0, paddleMinY, paddleMaxY)).toBeCloseTo(boardCenterY, 5) + expect(computeTiltTargetY(deg(TILT_DEAD_ZONE_DEG - 0.5), 0, baseline, 0, paddleMinY, paddleMaxY)).toBeCloseTo(boardCenterY, 5) + }) + + it('moves proportionally just beyond the dead zone, not in a single jump', () => { + const baseline = calibrateTiltBaseline(0, 0, boardCenterY) + const y = computeTiltTargetY(deg(TILT_DEAD_ZONE_DEG + 1), 0, baseline, 0, paddleMinY, paddleMaxY) + expect(y).toBeGreaterThan(boardCenterY) + expect(y).toBeLessThan(paddleMaxY) + }) + + it('pins at paddleMaxY/paddleMinY at TILT_MAX_DEG (baseline centered) and never overshoots beyond it', () => { + const baseline = calibrateTiltBaseline(0, 0, boardCenterY) + expect(computeTiltTargetY(deg(TILT_MAX_DEG), 0, baseline, 0, paddleMinY, paddleMaxY)).toBeCloseTo(paddleMaxY, 5) + expect(computeTiltTargetY(deg(TILT_MAX_DEG + 15), 0, baseline, 0, paddleMinY, paddleMaxY)).toBeCloseTo(paddleMaxY, 5) + expect(computeTiltTargetY(deg(-TILT_MAX_DEG), 0, baseline, 0, paddleMinY, paddleMaxY)).toBeCloseTo(paddleMinY, 5) + expect(computeTiltTargetY(deg(-(TILT_MAX_DEG + 15)), 0, baseline, 0, paddleMinY, paddleMaxY)).toBeCloseTo(paddleMinY, 5) + }) + + it('clamps to the board edge even when the baseline itself starts near it', () => { + // baseline.paddleY (180) + a full travelRadius (100) would overshoot to 280 without the clamp. + const baseline = calibrateTiltBaseline(0, 0, 180) + expect(computeTiltTargetY(deg(TILT_MAX_DEG), 0, baseline, 0, paddleMinY, paddleMaxY)).toBe(paddleMaxY) + }) + + it('rotation 0 drives Y from beta only - gamma alone produces no movement', () => { + const baseline = calibrateTiltBaseline(0, 0, boardCenterY) + expect(computeTiltTargetY(0, deg(TILT_MAX_DEG), baseline, 0, paddleMinY, paddleMaxY)).toBeCloseTo(boardCenterY, 5) + }) + + it('rotation ±90 drives Y from gamma only - beta alone produces no movement', () => { + const baseline = calibrateTiltBaseline(0, 0, boardCenterY) + expect(computeTiltTargetY(deg(TILT_MAX_DEG), 0, baseline, 90, paddleMinY, paddleMaxY)).toBeCloseTo(boardCenterY, 5) + expect(computeTiltTargetY(deg(TILT_MAX_DEG), 0, baseline, -90, paddleMinY, paddleMaxY)).toBeCloseTo(boardCenterY, 5) + }) + + it('rotation 90 and -90 invert sign for the same gamma delta', () => { + const baseline = calibrateTiltBaseline(0, 0, boardCenterY) + const at90 = computeTiltTargetY(0, deg(10), baseline, 90, paddleMinY, paddleMaxY) + const atMinus90 = computeTiltTargetY(0, deg(10), baseline, -90, paddleMinY, paddleMaxY) + expect(at90 - boardCenterY).toBeCloseTo(-(atMinus90 - boardCenterY), 5) + expect(Math.abs(at90 - boardCenterY)).toBeGreaterThan(0) + }) + + it('scales linearly with the travel range', () => { + const baseline = calibrateTiltBaseline(0, 0, boardCenterY) + const halfway = computeTiltTargetY(deg(TILT_DEAD_ZONE_DEG + (TILT_MAX_DEG - TILT_DEAD_ZONE_DEG) / 2), 0, baseline, 0, paddleMinY, paddleMaxY) + expect(halfway - boardCenterY).toBeCloseTo(travelRadius / 2, 5) + }) +}) diff --git a/src/shared/paddleGame/__tests__/useGameState.test.ts b/src/shared/paddleGame/__tests__/useGameState.test.ts new file mode 100644 index 0000000..e9e689a --- /dev/null +++ b/src/shared/paddleGame/__tests__/useGameState.test.ts @@ -0,0 +1,301 @@ +import { BALL_SPEED_TIERS, SOLO_LIVES } from '@/shared/paddleGame/constants/game' +import { makeReducer } from '@/shared/paddleGame/hooks/useGameState' +import { GameState } from '@/shared/paddleGame/types' +import { computeBoardLayout } from '@/shared/paddleGame/utils/board' + +const insets = { top: 0, bottom: 0, left: 0, right: 0 } +const board = computeBoardLayout(900, 400, insets, 'medium') + +function idleState(): GameState { + return makeReducer(5, 'medium')( + // Seed via RESET so the fixture always matches makeInitialState's own shape, rather than + // hand-duplicating it here and risking drift - same discipline AirHockey's own test file uses. + { phase: 'idle', scores: [0, 0], ball: { x: 0, y: 0, vx: 0, vy: 0, speed: 0 }, paddles: { 1: { x: 0, y: 0, vx: 0, vy: 0 }, 2: { x: 0, y: 0, vx: 0, vy: 0 } }, lastScoredBy: null, bounceCount: 0, bricks: [], lives: 0 }, + { type: 'RESET', board } + ) +} + +// Deterministic - pins the coin-flip/angle draws inside computeServeVelocity so a launched ball's +// exact velocity is reproducible across assertions. +function liveState(random: () => number = () => 0.5): GameState { + const reducer = makeReducer(5, 'medium') + return reducer(idleState(), { type: 'START', random }) +} + +describe('TICK ordering', () => { + it('commits paddle targets into velocity-bearing state within the same tick', () => { + const reducer = makeReducer(5, 'medium') + const state = liveState() + const targets = { 1: state.paddles[1].y + 5, 2: state.paddles[2].y } + const next = reducer(state, { type: 'TICK', board, dt: 1, targets }) + expect(next.paddles[1].y).toBeCloseTo(state.paddles[1].y + 5, 5) + expect(next.paddles[1].vy).toBeGreaterThan(0) // moved downward this tick, so it picked up downward velocity + expect(next.paddles[1].x).toBeCloseTo(board.paddleX[1], 5) // X is always the fixed paddleX, never a live input + }) + + it('a paddle already overlapping the ball at the start of a tick reflects it and ratchets its speed within that SAME tick', () => { + const reducer = makeReducer(5, 'medium') + const state = liveState() + const halfW = board.paddleWidth / 2 + // Ball placed just outside player 1's paddle rect (to its right, within collision reach), + // approaching it - an unambiguous, already-in-contact strike that the very first substep's + // applyPaddleCollision call must catch before any position integration happens. + const ball = { x: board.paddleX[1] + halfW + 3, y: board.centerY, vx: -5, vy: 0, speed: 5 } + const targets = { 1: state.paddles[1].y, 2: state.paddles[2].y } // paddle 1 already in place, doesn't move + const next = reducer({ ...state, ball }, { type: 'TICK', board, dt: 1, targets }) + expect(next.ball.vx).toBeGreaterThan(0) // reflected back toward +x, away from player 1's paddle + expect(next.ball.speed).toBeGreaterThan(ball.speed) // ratcheted up by the tier's own increment + }) + + it('TICK is a no-op outside the live phase', () => { + const reducer = makeReducer(5, 'medium') + const state = idleState() + const targets = { 1: state.paddles[1].y, 2: state.paddles[2].y } + const next = reducer(state, { type: 'TICK', board, dt: 1, targets }) + expect(next).toBe(state) + }) +}) + +describe('scoring and re-serve', () => { + // Positioned clear of player 2's paddle rect entirely, so the ball's own approach to the right + // edge can't accidentally collide with (and get deflected by) player 2's paddle first. + const clearOfPaddle2 = board.paddleX[2] + board.paddleWidth / 2 + board.ballRadius + 5 + const clearOfPaddle1 = board.paddleX[1] - board.paddleWidth / 2 - board.ballRadius - 5 + + it('a point increments the score, transitions to pointScored, and sets lastScoredBy to the SCORER', () => { + const reducer = makeReducer(5, 'medium') + let state = liveState() + const targets = { 1: state.paddles[1].y, 2: state.paddles[2].y } + state = { ...state, ball: { x: clearOfPaddle2, y: board.centerY, vx: 30, vy: 0, speed: 30 } } + const after = reducer(state, { type: 'TICK', board, dt: 1, targets }) + expect(after.phase).toBe('pointScored') + expect(after.scores).toEqual([1, 0]) + expect(after.lastScoredBy).toBe(1) + // Ball resets to dead center, fully at rest - the actual re-launch happens on the FOLLOWING + // POINT_SERVE, after the pause (see the design spec's note on this in useGameState.ts). + expect(after.ball.x).toBeCloseTo(board.centerX, 5) + expect(after.ball.y).toBeCloseTo(board.centerY, 5) + expect(after.ball.vx).toBe(0) + expect(after.ball.vy).toBe(0) + expect(after.ball.speed).toBe(0) + }) + + it('scores correctly for the opposite edge too', () => { + const reducer = makeReducer(5, 'medium') + let state = liveState() + const targets = { 1: state.paddles[1].y, 2: state.paddles[2].y } + state = { ...state, ball: { x: clearOfPaddle1, y: board.centerY, vx: -30, vy: 0, speed: 30 } } + const after = reducer(state, { type: 'TICK', board, dt: 1, targets }) + expect(after.scores).toEqual([0, 1]) + expect(after.lastScoredBy).toBe(2) + }) + + it('reaching scoreToWin ends the game instead of transitioning to pointScored', () => { + const reducer = makeReducer(1, 'medium') // score to win = 1, so a single point ends it + let state = liveState() + const targets = { 1: state.paddles[1].y, 2: state.paddles[2].y } + state = { ...state, ball: { x: clearOfPaddle2, y: board.centerY, vx: 30, vy: 0, speed: 30 } } + const after = reducer(state, { type: 'TICK', board, dt: 1, targets }) + expect(after.phase).toBe('gameOver') + expect(after.scores).toEqual([1, 0]) + }) + + it('POINT_SERVE re-centers both paddles and serves the ball at BASE speed toward whoever just conceded', () => { + const reducer = makeReducer(5, 'medium') + let state = liveState() + const targets = { 1: state.paddles[1].y, 2: state.paddles[2].y } + state = { ...state, ball: { x: clearOfPaddle2, y: board.centerY, vx: 30, vy: 0, speed: 30 } } + const afterPoint = reducer(state, { type: 'TICK', board, dt: 1, targets }) + expect(afterPoint.lastScoredBy).toBe(1) + + const served = reducer(afterPoint, { type: 'POINT_SERVE', board, random: () => 0.5 }) + expect(served.phase).toBe('live') + expect(served.ball.x).toBeCloseTo(board.centerX, 5) + expect(served.ball.y).toBeCloseTo(board.centerY, 5) + // Player 1 just scored, so the ball serves toward player 2 (the one who conceded) - positive + // vx means toward player 2. + expect(served.ball.vx).toBeGreaterThan(0) + // BASE speed, not 0 - unlike AirHockey's puck (which sits fully at rest until a paddle + // actually strikes it), Pong's serve actively launches the ball at this exact instant. + expect(served.ball.speed).toBeCloseTo(BALL_SPEED_TIERS.medium.base, 5) + expect(Math.hypot(served.ball.vx, served.ball.vy)).toBeCloseTo(BALL_SPEED_TIERS.medium.base, 5) + expect(served.paddles[1].y).toBeCloseTo(board.centerY, 5) + expect(served.paddles[2].y).toBeCloseTo(board.centerY, 5) + expect(served.lastScoredBy).toBeNull() + }) + + it('POINT_SERVE is a no-op outside the pointScored phase', () => { + const reducer = makeReducer(5, 'medium') + const state = liveState() + const next = reducer(state, { type: 'POINT_SERVE', board }) + expect(next).toBe(state) + }) +}) + +describe('START', () => { + it('flips idle to live and actively launches the ball at base speed via injectable random - unlike AirHockey, whose puck stays at rest on START', () => { + const reducer = makeReducer(5, 'medium') + const idle = idleState() + const started = reducer(idle, { type: 'START', random: () => 0.5 }) + expect(started.phase).toBe('live') + expect(started.ball.x).toBeCloseTo(idle.ball.x, 5) + expect(started.ball.y).toBeCloseTo(idle.ball.y, 5) + expect(started.ball.speed).toBeCloseTo(BALL_SPEED_TIERS.medium.base, 5) + expect(Math.hypot(started.ball.vx, started.ball.vy)).toBeCloseTo(BALL_SPEED_TIERS.medium.base, 5) + expect(started.paddles).toEqual(idle.paddles) + }) + + it('is a no-op outside the idle phase', () => { + const reducer = makeReducer(5, 'medium') + const state = liveState() + const next = reducer(state, { type: 'START' }) + expect(next).toBe(state) + }) +}) + +describe('RESCALE', () => { + it('remaps the ball fractionally with speed recomputed, and SNAPS each paddle X to the new fixed paddleX - never fractionally interpolated', () => { + const reducer = makeReducer(5, 'medium') + const state = { ...liveState(), scores: [2, 1] as [number, number] } + const doubled = computeBoardLayout(1800, 800, insets, 'medium') + const next = reducer(state, { type: 'RESCALE', prevBoard: board, board: doubled }) + expect(next.phase).toBe('live') + expect(next.scores).toEqual([2, 1]) + // Ball started dead center (fraction 0.5, 0.5 of the field) - should still be dead center of + // the new, larger board. + expect(next.ball.x).toBeCloseTo(doubled.centerX, 5) + expect(next.ball.y).toBeCloseTo(doubled.centerY, 5) + expect(next.ball.speed).toBeCloseTo(Math.hypot(next.ball.vx, next.ball.vy), 5) + expect(next.paddles[1].x).toBeCloseTo(doubled.paddleX[1], 5) + expect(next.paddles[2].x).toBeCloseTo(doubled.paddleX[2], 5) + // Paddle Y started at centerY (fraction 0.5) - should still be dead center of the new board. + expect(next.paddles[1].y).toBeCloseTo(doubled.centerY, 5) + expect(next.paddles[2].y).toBeCloseTo(doubled.centerY, 5) + }) + + it('falls back to dead center (rather than NaN) when rescaling from a degenerate zero-sized board', () => { + const reducer = makeReducer(5, 'medium') + const state = liveState() + const zeroBoard = computeBoardLayout(0, 0, insets, 'medium') + const next = reducer(state, { type: 'RESCALE', prevBoard: zeroBoard, board }) + expect(Number.isNaN(next.ball.x)).toBe(false) + expect(Number.isNaN(next.ball.y)).toBe(false) + expect(next.ball.x).toBeCloseTo(board.centerX, 5) + expect(next.ball.y).toBeCloseTo(board.centerY, 5) + expect(next.paddles[1].x).toBeCloseTo(board.paddleX[1], 5) + expect(next.paddles[1].y).toBeCloseTo(board.centerY, 5) + }) +}) + +describe('TICK substep loop - tunneling regression', () => { + it('a near-maxSpeed ball aimed at a stationary paddle does not tunnel through un-collided within one TICK at a large dt', () => { + const reducer = makeReducer(5, 'medium') + const state = liveState() + const maxSpeed = BALL_SPEED_TIERS.medium.max + const halfW = board.paddleWidth / 2 + // Ball just to the right of player 1's paddle rect, moving hard left, at a large dt - + // useGameLoop's own clamped-but-still-large maxDt (2.5), the case that follows a foreground + // stall - which a naive SINGLE-PASS stepPhysics call (speed * dt = 50, vs. a paddle only ~2% + // of board height thick) would displace clean over the paddle within. + const ball = { x: board.paddleX[1] + halfW + 20, y: board.centerY, vx: -maxSpeed, vy: 0, speed: maxSpeed } + const targets = { 1: board.centerY, 2: state.paddles[2].y } // paddle 1 held in place at center - a purely geometric catch, not a lucky moving intercept + const next = reducer({ ...state, ball }, { type: 'TICK', board, dt: 2.5, targets }) + // Asserting the ball ends up REFLECTED (moving back rightward, away from player 1's edge) + // rather than having scored past it is what actually proves the TICK-level substep loop + // caught a collision a single-pass integration would have missed entirely. + expect(next.ball.vx).toBeGreaterThan(0) + expect(next.scores).toEqual([0, 0]) + }) +}) + +describe('TICK substep loop - brick tunneling regression', () => { + it('a near-maxSpeed ball aimed at a stationary brick does not tunnel through un-collided within one TICK at a large dt', () => { + // bricksEnabled on a twoPlayer reducer - exercises the shared brick infrastructure via the + // Bricks toggle path, same substep/collision code solo also relies on. + const reducer = makeReducer(5, 'medium', 'twoPlayer', true) + const state = reducer(reducer({ phase: 'idle', scores: [0, 0], ball: { x: 0, y: 0, vx: 0, vy: 0, speed: 0 }, paddles: { 1: { x: 0, y: 0, vx: 0, vy: 0 }, 2: { x: 0, y: 0, vx: 0, vy: 0 } }, lastScoredBy: null, bounceCount: 0, bricks: [], lives: 0 }, { type: 'RESET', board }), { type: 'START', random: () => 0.5 }) + const maxSpeed = BALL_SPEED_TIERS.medium.max + // A single brick, mid-court and clear of both paddles - isolates brick-tunneling safety from + // paddle collision entirely, same discipline as the paddle tunneling test above. + const brick = { x: board.centerX - 10, y: board.centerY - 40, width: 20, height: 80, destroyed: false } + const targets = { 1: state.paddles[1].y, 2: state.paddles[2].y } + const ball = { x: brick.x + brick.width + 20, y: board.centerY, vx: -maxSpeed, vy: 0, speed: maxSpeed } + // random: () => 0.5 -> exactly zero brick-bounce jitter (see applyBrickCollision), keeping this + // test's reflection pure geometry. + const next = reducer({ ...state, bricks: [brick], ball }, { type: 'TICK', board, dt: 2.5, targets, random: () => 0.5 }) + expect(next.ball.vx).toBeGreaterThan(0) // reflected back rightward, away from the brick - not tunneled through + expect(next.bricks[0].destroyed).toBe(true) + }) +}) + +describe('solo/Brickbreaker', () => { + function soloIdleState(): GameState { + return makeReducer(5, 'medium', 'solo', false)({ phase: 'idle', scores: [0, 0], ball: { x: 0, y: 0, vx: 0, vy: 0, speed: 0 }, paddles: { 1: { x: 0, y: 0, vx: 0, vy: 0 }, 2: { x: 0, y: 0, vx: 0, vy: 0 } }, lastScoredBy: null, bounceCount: 0, bricks: [], lives: 0 }, { type: 'RESET', board }) + } + + function soloLiveState(random: () => number = () => 0.5): GameState { + return makeReducer(5, 'medium', 'solo', false)(soloIdleState(), { type: 'START', random }) + } + + it('RESET seeds a full brick field and SOLO_LIVES lives - bricksEnabled is irrelevant, solo is unconditionally Brickbreaker', () => { + const state = soloIdleState() + expect(state.lives).toBe(SOLO_LIVES) + expect(state.bricks.length).toBeGreaterThan(0) + expect(state.bricks.every((b) => !b.destroyed)).toBe(true) + }) + + it("START always serves rightward into the field - never the coin flip a two-paddle match's first serve gets", () => { + // random: () => 0.9 would serve LEFT (toward P1) if this were a coin-flip match (see + // physics.test.ts's 'is a coin flip in direction' test) - solo ignores that draw entirely. + const state = makeReducer(5, 'medium', 'solo', false)(soloIdleState(), { type: 'START', random: () => 0.9 }) + expect(state.ball.vx).toBeGreaterThan(0) + }) + + it('a miss decrements lives and transitions to pointScored while lives remain, with lastScoredBy fixed at 1', () => { + const reducer = makeReducer(5, 'medium', 'solo', false) + const state = soloLiveState() + const clearOfPaddle1 = board.paddleX[1] - board.paddleWidth / 2 - board.ballRadius - 5 + const targets = { 1: state.paddles[1].y, 2: state.paddles[2].y } + const after = reducer({ ...state, ball: { x: clearOfPaddle1, y: board.centerY, vx: -30, vy: 0, speed: 30 } }, { type: 'TICK', board, dt: 1, targets }) + expect(after.lives).toBe(SOLO_LIVES - 1) + expect(after.phase).toBe('pointScored') + // Not "who scored" (solo has no opponent) - fixed at 1 so POINT_SERVE's otherPlayer(lastScoredBy) + // still resolves to 2, correctly re-serving away from the player and into the field. + expect(after.lastScoredBy).toBe(1) + }) + + it('exhausting lives ends the round as a loss', () => { + const reducer = makeReducer(5, 'medium', 'solo', false) + const state = { ...soloLiveState(), lives: 1 } + const clearOfPaddle1 = board.paddleX[1] - board.paddleWidth / 2 - board.ballRadius - 5 + const targets = { 1: state.paddles[1].y, 2: state.paddles[2].y } + const after = reducer({ ...state, ball: { x: clearOfPaddle1, y: board.centerY, vx: -30, vy: 0, speed: 30 } }, { type: 'TICK', board, dt: 1, targets }) + expect(after.lives).toBe(0) + expect(after.phase).toBe('gameOver') + }) + + it('clearing every brick ends the round as a win, leaving lives untouched', () => { + const reducer = makeReducer(5, 'medium', 'solo', false) + const state = soloLiveState() + const bricks = state.bricks.map((b) => ({ ...b, destroyed: true })) + const targets = { 1: state.paddles[1].y, 2: state.paddles[2].y } + // Harmlessly mid-court, nowhere near an edge - the win comes from the bricks-cleared check + // itself, not an incidental score/edge event landing in the same tick. + const ball = { x: board.centerX, y: board.centerY, vx: 1, vy: 0, speed: 1 } + const after = reducer({ ...state, bricks, ball }, { type: 'TICK', board, dt: 1, targets }) + expect(after.phase).toBe('gameOver') + expect(after.lives).toBe(SOLO_LIVES) + }) + + it('never collides against paddle 2 - the far edge bounces with no speed ratchet, unlike a paddle hit', () => { + const reducer = makeReducer(5, 'medium', 'solo', false) + const state = soloLiveState() + const clearOfPaddle2 = board.paddleX[2] + board.paddleWidth / 2 + board.ballRadius + 5 + const targets = { 1: state.paddles[1].y, 2: state.paddles[2].y } + const ball = { x: clearOfPaddle2, y: board.centerY, vx: 30, vy: 0, speed: 30 } + const after = reducer({ ...state, ball }, { type: 'TICK', board, dt: 1, targets }) + expect(after.ball.vx).toBeLessThan(0) // bounced back leftward off the far wall + expect(after.ball.speed).toBeCloseTo(30, 5) // unchanged - a paddle hit would have ratcheted this up + expect(after.phase).toBe('live') + }) +}) diff --git a/src/shared/paddleGame/components/Board.tsx b/src/shared/paddleGame/components/Board.tsx new file mode 100644 index 0000000..e6be9f6 --- /dev/null +++ b/src/shared/paddleGame/components/Board.tsx @@ -0,0 +1,113 @@ +import { useAutoPaperTheme } from '@rific/auto-paper' +import { Canvas, DashPathEffect, Line, Rect, RoundedRect, vec } from '@shopify/react-native-skia' +import { Disc, ObstacleRect } from '@tastic/sprites/shapes' +import { useEffect } from 'react' +import { StyleSheet, View } from 'react-native' +import { cancelAnimation, Easing, useDerivedValue, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated' + +import { BallState, BoardLayout, Brick, GamePhase, PaddleStates } from '@/shared/paddleGame/types' + +export interface BoardProps { + board: BoardLayout + ball: BallState + paddles: PaddleStates + phase: GamePhase + // Perimeter top/bottom walls - see game.tsx's isDesktopWeb. On a phone (native or mobile web) + // they'd sit redundantly right against the device's own physical edge, which already reads as the + // boundary; only desktop web, with no such edge, gets them drawn. + showWalls: boolean + bricks: Brick[] + // Solo/Brickbreaker: no second paddle (see below), and the far edge renders as a solid wall - + // unconditionally, unlike showWalls above, since without it the new bounce-instead-of-score + // behavior at that edge would be inexplicable to the player. + soloMode: boolean +} + +export default function Board({ board, ball, paddles, phase, showWalls, bricks, soloMode }: BoardProps) { + const { x, y, width, height, wallThickness, ballRadius, paddleWidth, paddleHeight } = board + const { colors: themeColors, dark } = useAutoPaperTheme() + + // Retro-vector palette, same convention as the games this was cloned from: the board's own + // structure (walls/centerline) is a literal black/white-by-appearance neutral, not a themed + // color, since it's scaffolding rather than a gameplay signal - a player's own paddle color is the + // only saturated color on screen. The puck is Air Hockey's black disc with a white ring. + const wallColor = dark ? '#FFFFFF' : '#000000' + const centerLineColor = dark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.25)' + const p1Color = themeColors.primary + const p2Color = themeColors.secondary + + // A gentle "not live yet" breathe during the pre-game/post-point pause - plain and static once + // play is actually live. The whole puck pulses, not just its ring as Air Hockey's does, since a + // white ring alone doesn't show on a light theme. + const waiting = phase !== 'live' + const idlePulseT = useSharedValue(0) + useEffect(() => { + if (waiting) { + idlePulseT.value = 0 + idlePulseT.value = withRepeat(withTiming(1, { duration: 900, easing: Easing.inOut(Easing.sin) }), -1, true) + } else { + cancelAnimation(idlePulseT) + idlePulseT.value = 0 + } + }, [waiting]) // eslint-disable-line react-hooks/exhaustive-deps + const ballOpacity = useDerivedValue(() => (waiting ? 0.5 + idlePulseT.value * 0.5 : 1)) + + // ~0.3x paddleWidth corner radius, per the design spec - a small, subtle round rather than a + // fully pill-shaped bar. + const paddleCornerRadius = paddleWidth * 0.3 + + return ( + + + {/* Dashed center line at board.centerX - Pong has no "own half" clamp the way AirHockey's + solid centerline implied (see clampPaddleY in utils/board.ts, which spans the FULL board + height for both paddles), so this is purely a classic-Pong visual cue, not a rule + boundary. */} + + + + + {/* Perimeter top/bottom walls - desktop web only, see BoardProps' showWalls doc comment. */} + {showWalls && ( + <> + + + + )} + + {/* Solo/Brickbreaker's far wall - unconditional (not gated by showWalls above), since it + replaces P2's own edge/paddle rather than being a cosmetic desktop-web extra: without it + the far edge would look identical to a normal scoring edge and the new bounce behavior + there would make no visual sense. Same shape AirHockey uses for its own side walls. */} + {soloMode && } + + {/* Bricks - destructible, filtered before mapping so a destroyed one simply stops + rendering. Same fleet idiom AirHockey/BoxHockey use for their own (permanent) Obstacle + arrays, reusing the neutral wallColor rather than a separate palette. Keyed by position + (fixed for a brick's whole lifetime), not array index - filtering destroyed ones out + shifts later bricks' indices, which would otherwise make React reuse a Skia node across + two different bricks and read as one brick jumping to another's spot. */} + {bricks + .filter((b) => !b.destroyed) + .map((b) => ( + + ))} + + {/* Paddles - bars, not discs (see BoardLayout's own paddleWidth/paddleHeight doc comment in + types/index.ts): each only ever moves on Y, drawn centered on paddles[player].y at its + fixed board.paddleX[player]. P2 doesn't exist in solo - see soloMode above. */} + + {!soloMode && } + + {/* The ball, drawn as a puck (PuckTastic's paddle games): Air Hockey's flat black puck with + a white ring, so it reads on either theme, softly pulsing while waiting to go live. The + physics still call it the ball. */} + + + + ) +} + +const styles = StyleSheet.create({ + container: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0 } +}) diff --git a/src/shared/paddleGame/components/BoardHost.tsx b/src/shared/paddleGame/components/BoardHost.tsx new file mode 100644 index 0000000..4af679b --- /dev/null +++ b/src/shared/paddleGame/components/BoardHost.tsx @@ -0,0 +1,3 @@ +// Native draws the board directly: Skia's native bindings are ready before any JS runs. The web +// version (BoardHost.web.tsx) waits for CanvasKit first. +export { default } from './Board' diff --git a/src/shared/paddleGame/components/BoardHost.web.tsx b/src/shared/paddleGame/components/BoardHost.web.tsx new file mode 100644 index 0000000..75ee3c5 --- /dev/null +++ b/src/shared/paddleGame/components/BoardHost.web.tsx @@ -0,0 +1,4 @@ +import { lazySkia } from '@/shared/lazySkia' + +// Loads the board only once CanvasKit is ready (see lazySkia). +export default lazySkia(() => import('./Board')) diff --git a/src/shared/paddleGame/components/GuideArt.tsx b/src/shared/paddleGame/components/GuideArt.tsx new file mode 100644 index 0000000..a3fdccb --- /dev/null +++ b/src/shared/paddleGame/components/GuideArt.tsx @@ -0,0 +1,281 @@ +import { useAutoPaperTheme } from '@rific/auto-paper' +import { type DirectionKeyLabels, DirectionKeysHint } from '@tastic/hud/guide' +import { StyleSheet, View } from 'react-native' +import { Icon } from 'react-native-paper' + +// The paddle games' illustrations for the how-to-play cards (see constants/guideSteps.tsx). The shared +// art in @tastic/hud/guide is used wherever it fits: DirectionKeysHint (axis='vertical') for the key +// caps inside GuideUpDownKeys, and SeatDevicesHint for the desktop two-player card (used directly in +// guideSteps.tsx). What's here draws something only Pong has: SwipeHint (finger or pointer='mouse') +// draws a flick toward an arrowhead, but a Pong paddle isn't flicked one way, it lines up with +// wherever your finger or pointer is, so GuidePaddleDrag and GuideMouseDrag draw the paddle beside +// what moves it. And SeatDiagram labels P1 as the bottom half (which end each seat plays in a +// face-to-face match comes from how the phone is held, see game.tsx's rotation, so a label saying +// which half is whose could be wrong; GuideSplitPhone below avoids the same claim by drawing both +// halves in one neutral color). Plain Views and paper Icons only: no Skia, so they cost nothing to +// mount over the Home screen. Seat colors come from the live theme (primary = P1, secondary = P2), +// so they always match the colors the players have picked. + +const PADDLE_THICKNESS = 6 + +// A finger over a paddle that lines up beneath it: the "your paddle follows your finger" idea, +// drawn the way a phone held upright plays (the court is rotated so each paddle runs across one end +// of the screen and moves side to side; see game.tsx's rotation). The dotted line is the point: the +// paddle goes where the finger is, it doesn't need touching. +export function GuidePaddleDrag() { + const { colors } = useAutoPaperTheme() + + return ( + + + + + + + + {Array.from({ length: 4 }, (_, i) => ( + + ))} + + + + ) +} + +// The two caps a Pong seat's key scheme uses: KeyboardInputLayer only ever reads up and down. +export type UpDownKeyLabels = Pick + +// The desktop counterpart to GuidePaddleDrag for a seat on a key scheme: an upright paddle beside +// hud's up-over-down DirectionKeysHint, printed with that seat's own two keys (arrow icons when +// `labels` is omitted, i.e. the arrow keys themselves). A wide desktop window plays the court +// unrotated, paddles at the left and right edges moving up and down. +export function GuideUpDownKeys({ labels }: { labels?: UpDownKeyLabels }) { + const { colors } = useAutoPaperTheme() + + return ( + + + + + ) +} + +// The desktop counterpart to GuidePaddleDrag for a seat left on 'mouse': TouchInputLayer's own +// drag input, driven by the pointer instead of a finger. +export function GuideMouseDrag() { + const { colors } = useAutoPaperTheme() + + return ( + + + + + ) +} + +// Player 1's shot getting past player 2's paddle, the one way to score in a versus match. Drawn in +// the court's own native shape (wide, a paddle at each end), not rotated for any particular hold. +export function GuideScorePoint() { + const { colors } = useAutoPaperTheme() + + return ( + + + + + {TRAIL.map(([left, top], i) => ( + + ))} + + + ) +} + +// Breaker's brick field: a block of bricks across the middle of the court, one already broken, with +// both paddles for a versus round or only player 1's (and the far wall) for a round alone, the way +// the play screen draws them. +export function GuideBrickField({ alone = false }: { alone?: boolean }) { + const { colors } = useAutoPaperTheme() + const rows = [0, 1, 2, 3, 4] + const cols = alone ? [0, 1, 2, 3] : [0, 1] + const left = alone ? 62 : 70 + const label = alone ? 'One paddle facing a field of bricks' : 'A block of bricks between the two paddles' + + return ( + + + {alone ? : } + {rows.flatMap((row) => cols.map((col) => (row === 2 && col === 0 ? null : )))} + + + ) +} + +// The two-player touch seating: one phone, split in half, a paddle in each half. Both halves are +// drawn identically in the neutral onSurface color, with no seat colors and no labels, so the art +// makes no claim about which end is whose. That claim isn't settled: the two-player loadout seats +// P1 at the bottom, but game.tsx's rotation puts P1's paddle and touch zone at the top for the +// common hold (see the file-level note on SeatDiagram). If a device check settles it, seat tints (or +// hud's SeatDiagram) can come back here. +export function GuideSplitPhone() { + const { colors } = useAutoPaperTheme() + + return ( + + + + + + + + + + ) +} + +// GuideBrickField's bricks. +const BRICK_W = 12 +const BRICK_H = 14 + +// GuideScorePoint's puck path, from player 1's paddle out past player 2's: [left, top] of each dot. +const TRAIL: [number, number][] = [ + [26, 44], + [48, 49], + [70, 54], + [92, 59], + [114, 64], + [136, 69] +] + +const styles = StyleSheet.create({ + bottomHalf: { + justifyContent: 'flex-end' + }, + brick: { + borderRadius: 2, + height: BRICK_H, + opacity: 0.6, + position: 'absolute', + width: BRICK_W + }, + centerLine: { + bottom: 8, + left: 81, + opacity: 0.25, + position: 'absolute', + top: 8, + width: 2 + }, + court: { + borderRadius: 8, + borderWidth: 2, + height: 104, + width: 168 + }, + courtPaddle: { + borderRadius: 2, + height: 32, + position: 'absolute', + width: PADDLE_THICKNESS + }, + courtPaddleLeft: { + left: 12, + top: 30 + }, + courtPaddleRight: { + left: 144, + top: 18 + }, + desktopRow: { + alignItems: 'center', + flexDirection: 'row', + gap: 24 + }, + device: { + borderRadius: 16, + borderWidth: 2, + height: 120, + overflow: 'hidden', + width: 88 + }, + devicePaddle: { + alignSelf: 'center', + borderRadius: 2, + height: PADDLE_THICKNESS, + marginVertical: 12, + width: 34 + }, + divider: { + height: 2, + opacity: 0.4 + }, + dragStage: { + alignItems: 'center', + gap: 6 + }, + // Solo's far wall, in place of player 2's paddle. + farWall: { + bottom: 0, + position: 'absolute', + right: 0, + top: 0, + width: 4 + }, + finger: { + borderRadius: 14, + borderWidth: 3, + height: 28, + width: 28 + }, + fingerRow: { + alignItems: 'center', + flexDirection: 'row', + gap: 8 + }, + guideDot: { + borderRadius: 2, + height: 4, + opacity: 0.5, + width: 4 + }, + guideLine: { + alignItems: 'center', + gap: 5 + }, + half: { + flex: 1 + }, + paddleAcross: { + borderRadius: 2, + height: PADDLE_THICKNESS + 2, + width: 72 + }, + paddleUpright: { + borderRadius: 2, + height: 72, + width: PADDLE_THICKNESS + 2 + }, + // The puck: the play screen's black disc with a white ring, so it reads on either theme. + puck: { + backgroundColor: '#000000', + borderColor: '#FFFFFF', + borderRadius: 6, + borderWidth: 1.5, + height: 12, + left: 149, + position: 'absolute', + top: 69, + width: 12 + }, + // GuideBrickField's puck, on its way from player 1's paddle to the gap a broken brick left. + puckAtBricks: { + left: 40, + top: 47 + }, + trailDot: { + borderRadius: 3, + height: 6, + position: 'absolute', + width: 6 + } +}) diff --git a/src/shared/paddleGame/components/KeyboardInputLayer.tsx b/src/shared/paddleGame/components/KeyboardInputLayer.tsx new file mode 100644 index 0000000..c5aadd6 --- /dev/null +++ b/src/shared/paddleGame/components/KeyboardInputLayer.tsx @@ -0,0 +1,144 @@ +import { Direction } from '@tastic/input' +import { useEffect, useRef } from 'react' +import { Platform } from 'react-native' + +import { resolveControlSchemeDirection } from '@/shared/controlScheme' +import { MAX_PADDLE_SPEED } from '@/shared/paddleGame/constants/game' +import { ControlScheme, Player } from '@/shared/paddleGame/types' + +export interface KeyboardInputLayerProps { + enabled: boolean + humanPlayers: Player[] + controlScheme: Record + // Each player's CURRENT paddle Y, read fresh every frame (via the ref below) as the baseline a + // held key nudges from - NOT a live prop this component reacts to render-to-render, the same way + // TouchInputLayer's own onPaddleTargetY calls never wait for a render either. + paddleY: Record + onPaddleTargetY: (player: Player, y: number) => void +} + +// Web-only, and purely additive on top of TouchInputLayer's own always-on drag input - never +// disables it, same "scheme only ever ADDS a key mapping, never removes the pointer path" principle +// Snake's identically-named component documents. A seat left on 'mouse' (the default, see +// redux/settingsSlice.ts) is simply never read from below. +// +// Structurally different from Snake's/LightCycles' own KeyboardInputLayer despite the shared name: +// those resolve one keydown into one DISCRETE turn: Pong's paddle instead needs CONTINUOUS motion +// for as long as a direction key stays HELD, since it's a drag-to-position paddle, not a turn-and- +// commit snake. So this tracks a live held-key SET per player (keydown adds, keyup removes) and +// drives it through its OWN plain requestAnimationFrame loop (deliberately NOT @tastic/core's +// useGameLoop - this component's rAF callback needs the CURRENT paddle position and held-key set on +// every frame regardless of React's own render/effect cadence, and a bare rAF loop reading straight +// from refs is the simplest way to guarantee that; nothing here depends on useGameLoop's specific +// dt-smoothing beyond what computeClampedDt itself does, which the local nowMs-delta math below +// mirrors directly). Same nominal-frame-normalized `dt` convention useGameState's own tick loop uses +// (dt ≈ 1 for a normal ~16.7ms frame), so MAX_PADDLE_SPEED (a px-per-nominal-frame constant) means +// the same thing here as it does there - computing a fresh target Y every frame from the paddle's +// own current Y rather than the touch model's "finger position IS the target" mapping. This loop and +// useGameState's own tick loop are independent (uncorrelated rAF callbacks, same as touch's own +// async gesture stream already is relative to the physics tick), which is fine: onPaddleTargetY is a +// plain ref write, read once per *its own* frame, regardless of which source last wrote it. +// +// Deliberately NOT rotation-aware (unlike TouchInputLayer's screenToLocalY): a Pong paddle has +// exactly one degree of freedom (local Y), so there is no *other* local axis "real screen up" could +// ever be remapped onto if the board happens to be visually rotated (narrow-window faceToFace on +// web) - inverting the mapping there would only ever trade "moves, just rotated-feeling" for "dead +// keys", never an actual fix. Up/Down always drive local Y directly. Also not a realistic scenario +// to over-invest in: a shared *keyboard* (unlike a shared touchscreen) has no "far seat" a second +// player could physically reach from the opposite side of the device, so a keyboard scheme is +// really only ever used seated side by side - where Pong's board is unrotated anyway. +const NOMINAL_FRAME_MS = 1000 / 60 +const MAX_DT = 2.5 + +export default function KeyboardInputLayer({ enabled, humanPlayers, controlScheme, paddleY, onPaddleTargetY }: KeyboardInputLayerProps) { + const heldRef = useRef>>({ 1: new Set(), 2: new Set() }) + const latestRef = useRef({ humanPlayers, controlScheme, paddleY, onPaddleTargetY }) + useEffect(() => { + latestRef.current = { humanPlayers, controlScheme, paddleY, onPaddleTargetY } + }) + + useEffect(() => { + if (!enabled || Platform.OS !== 'web') return + + // Captured once - heldRef.current's own identity never actually changes after the useRef + // initializer (only the Sets it points at are mutated in place, via .add/.delete/.clear), but + // this is what lets the cleanup below reference it without the linter having to take that on + // faith. + const held = heldRef.current + + const handleKeyDown = (e: KeyboardEvent) => { + const { humanPlayers, controlScheme } = latestRef.current + for (const player of humanPlayers) { + const direction = resolveControlSchemeDirection(e, controlScheme[player]) + if (!direction) continue + e.preventDefault() + held[player].add(direction) + return + } + } + + const handleKeyUp = (e: KeyboardEvent) => { + const { humanPlayers, controlScheme } = latestRef.current + for (const player of humanPlayers) { + const direction = resolveControlSchemeDirection(e, controlScheme[player]) + if (!direction) continue + held[player].delete(direction) + return + } + } + + // A held key's release never reaches us if focus leaves the page first (alt-tab, a devtools + // click, ...) - window never gets the keyup at all. Left unhandled, that key would read as + // "held" forever, driving the paddle in one direction with no way to stop it short of pressing + // and releasing that exact key again. Clearing on blur is the standard fix for this whole class + // of held-key bug. + const handleBlur = () => { + held[1].clear() + held[2].clear() + } + + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + window.addEventListener('blur', handleBlur) + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + window.removeEventListener('blur', handleBlur) + held[1].clear() + held[2].clear() + } + }, [enabled]) + + useEffect(() => { + if (!enabled || Platform.OS !== 'web') return + + let rafId: number | null = null + let active = true + let lastTimestamp: number | null = null + + const step = (timestamp: number) => { + if (!active) return + const dt = lastTimestamp === null ? 1 : Math.min(MAX_DT, Math.max(0, (timestamp - lastTimestamp) / NOMINAL_FRAME_MS)) + lastTimestamp = timestamp + + const { humanPlayers, controlScheme, paddleY, onPaddleTargetY } = latestRef.current + for (const player of humanPlayers) { + if (controlScheme[player] === 'mouse') continue + const held = heldRef.current[player] + const axisY = (held.has('down') ? 1 : 0) - (held.has('up') ? 1 : 0) + if (axisY === 0) continue + onPaddleTargetY(player, paddleY[player] + axisY * MAX_PADDLE_SPEED * dt) + } + + rafId = requestAnimationFrame(step) + } + + rafId = requestAnimationFrame(step) + return () => { + active = false + if (rafId !== null) cancelAnimationFrame(rafId) + } + }, [enabled]) + + return null +} diff --git a/src/shared/paddleGame/components/LoadoutSharedControls.tsx b/src/shared/paddleGame/components/LoadoutSharedControls.tsx new file mode 100644 index 0000000..3731646 --- /dev/null +++ b/src/shared/paddleGame/components/LoadoutSharedControls.tsx @@ -0,0 +1,114 @@ +import { IconButton } from '@rific/feedback-press' +import { MenuOption, PopoverHost, SectionedDropdown, usePopoverHost } from '@tastic/hud' +import { StyleSheet, View } from 'react-native' + +import { BallSpeedLevel, PaddleSizeLevel } from '@/shared/paddleGame/types' + +interface Props { + // Shared popover host - see PlayerSetupPanel's identical `host` prop. The loadout passes seat 1's + // host here, so this row's menus and seat 1's are mutually exclusive (only one open at a time) + // and seat 1's press-away overlay, which only watches that host's openId, closes them too. + host?: PopoverHost + paddleSize: PaddleSizeLevel + paddleSizeOptions: MenuOption[] + onPaddleSizeChange: (value: PaddleSizeLevel) => void + ballSpeed: BallSpeedLevel + ballSpeedOptions: MenuOption[] + onBallSpeedChange: (value: BallSpeedLevel) => void + scoreToWin: number + scoreToWinOptions: MenuOption[] + onScoreToWinChange: (value: number) => void + // Omitted while the round has two people (one tilt reading can't steer two paddles, see + // tiltControlActiveFor) and on the web (no accelerometer readings there). An explicit presence + // check below, not a truthy one: the toggle is off by default. + tiltControlEnabled?: boolean + tiltControlOption?: MenuOption<'tiltControl'> + onTiltControlEnabledChange?: (value: boolean) => void + // Undefined while the loadout folds Randomize and Reset into its shared action band instead. + onRandomize?: () => void + onReset?: () => void + accentColor: string + mutedColor: string + onAccentColor?: string + dark: boolean +} + +// The popover ids this row opens, so the loadout can elevate it while one is open. +export const LOADOUT_SHARED_CONTROLS_IDS = ['paddleBallPhysics', 'scoreToWin', 'tiltControl'] + +// The paddle games' always-upright row of match settings: paddle size and puck speed in one menu, +// score to win, and tilt steering where it applies. Paddles and Breaker share it; neither has a +// Bricks toggle (Paddles never has bricks, Breaker always does). +export function LoadoutSharedControls({ host: sharedHost, paddleSize, paddleSizeOptions, onPaddleSizeChange, ballSpeed, ballSpeedOptions, onBallSpeedChange, scoreToWin, scoreToWinOptions, onScoreToWinChange, tiltControlEnabled, tiltControlOption, onTiltControlEnabledChange, onRandomize, onReset, accentColor, mutedColor, onAccentColor, dark }: Props) { + const ownHost = usePopoverHost() + const host = sharedHost ?? ownHost + // Elevated only for this row's own menus: elevating it for any open menu on a shared host would + // tie it with the panel that really has the open menu, and DOM order would decide which paints on top. + const ownPopoverOpen = host.openId !== null && LOADOUT_SHARED_CONTROLS_IDS.includes(host.openId) + const showTiltControl = tiltControlEnabled !== undefined && !!tiltControlOption && !!onTiltControlEnabledChange + const fg = dark ? '#FFFFFF' : '#000000' + + return ( + + {onRandomize && onReset && ( + + + + + )} + + + {/* Paddle size and puck speed both govern how the paddles and puck behave, so they share + one popover as two sections. autoDismiss is off so picking one doesn't close the popover + before the other has been looked at; press-away or the trigger closes it. */} + + + + + {/* A single boolean as a one-option 'multi' section: presence in the value array is "on". */} + {showTiltControl && onTiltControlEnabledChange!(value.includes(tiltControlOption!.value)) }]} accentColor={accentColor} mutedColor={mutedColor} onAccentColor={onAccentColor} dark={dark} />} + + + ) +} + +const styles = StyleSheet.create({ + // Above the trigger row, smaller than the triggers, so Randomize and Reset read as quick actions + // on the settings below rather than settings of their own (Air Hockey's and Box Hockey's row). + actionsRow: { + flexDirection: 'row', + gap: 12 + }, + container: { + alignItems: 'center', + gap: 4 + }, + // A popover escaping the row has to paint above the later sibling panels, and React Native Web's + // per-view stacking contexts mean the wrapper itself needs the elevation. + containerOpen: { + zIndex: 100 + }, + row: { + alignItems: 'center', + flexDirection: 'row', + // Wraps to a second centered line on a narrow device instead of running off the screen edge. + flexWrap: 'wrap', + // Room for each trigger's gauge arc against its neighbors. + gap: 20, + justifyContent: 'center' + } +}) diff --git a/src/shared/paddleGame/components/TiltInputLayer.tsx b/src/shared/paddleGame/components/TiltInputLayer.tsx new file mode 100644 index 0000000..9cb7fa3 --- /dev/null +++ b/src/shared/paddleGame/components/TiltInputLayer.tsx @@ -0,0 +1,95 @@ +import { DeviceMotion } from 'expo-sensors' +import { useEffect, useRef } from 'react' +import { Platform } from 'react-native' + +import { ORIENTATION_SENSOR_DEFAULT_INTERVAL_MS, TILT_UPDATE_INTERVAL_MS } from '@/shared/paddleGame/constants/game' +import { BoardLayout, GamePhase, Player } from '@/shared/paddleGame/types' +import { calibrateTiltBaseline, computeTiltTargetY, TiltBaseline } from '@/shared/paddleGame/utils/tiltInput' + +export interface TiltInputLayerProps { + player: Player + // tiltControlActiveFor(...) && !paused && phase !== 'gameOver' - same `enabled` convention as + // Touch/KeyboardInputLayer's own prop of the same name. + enabled: boolean + // For detecting a FRESH entry into 'live' (every serve, not just the first) as a recalibration + // trigger - see the phase effect below. + phase: GamePhase + // Frozen for the whole round whenever tilt is active - see game.tsx's own + // useOrientationState(locked) call, which forces this never to change mid-round once + // tilt control is on, so this component never has to react to it changing. + rotation: 0 | 90 | -90 + board: BoardLayout + // This player's CURRENT paddle Y, mirrored every render (not read once) - same pattern + // KeyboardInputLayer's own `paddleY` prop uses, since (re)calibration needs the FRESH value at + // the exact moment it fires, not a stale render-time snapshot. + paddleY: number + onPaddleTargetY: (player: Player, y: number) => void +} + +// Additive on top of TouchInputLayer's own always-on drag input - never disables it, same +// "adds a method, never removes one" precedent KeyboardInputLayer already established (see that +// component's own doc comment). Native-only: expo-sensors never populates DeviceMotionMeasurement's +// own `rotation` field on web (confirmed against ExponentDeviceMotion.web.js - an explicit +// "TODO: Bacon: Can rotation be calculated?" left where it emits everything except that field), so +// there is no tilt signal to read there at all - mirrors KeyboardInputLayer's own Platform gate, +// just inverted (that component is web-only; this one is native-only). +// +// Two independent effects, mirroring KeyboardInputLayer's own split: one owns the sensor +// subscription's lifecycle, the other only watches `phase` to flag a recalibration. +export default function TiltInputLayer({ player, enabled, phase, rotation, board, paddleY, onPaddleTargetY }: TiltInputLayerProps) { + const latestRef = useRef({ player, rotation, board, paddleY, onPaddleTargetY }) + useEffect(() => { + latestRef.current = { player, rotation, board, paddleY, onPaddleTargetY } + }) + + const baselineRef = useRef(null) + // Starts true so the very first sample after enabling calibrates rather than steers from a + // baseline of {0, 0} - see the listener below, which never moves the paddle on a calibrating + // sample. + const needsCalibrationRef = useRef(true) + + useEffect(() => { + if (!enabled || Platform.OS === 'web') return + + needsCalibrationRef.current = true + DeviceMotion.setUpdateInterval(TILT_UPDATE_INTERVAL_MS) + + const subscription = DeviceMotion.addListener((measurement) => { + const { rotation: sensorRotation } = measurement + if (!sensorRotation) return + const { player, rotation, board, paddleY, onPaddleTargetY } = latestRef.current + + if (needsCalibrationRef.current) { + baselineRef.current = calibrateTiltBaseline(sensorRotation.beta, sensorRotation.gamma, paddleY) + needsCalibrationRef.current = false + return + } + + const baseline = baselineRef.current + if (!baseline) return + const y = computeTiltTargetY(sensorRotation.beta, sensorRotation.gamma, baseline, rotation, board.paddleMinY, board.paddleMaxY) + onPaddleTargetY(player, y) + }) + + // Restores the shared DeviceMotion singleton's default sampling rate on every disable (pause, + // game over, or unmount) - not just final teardown - since expo-sensors' setUpdateInterval is a + // GLOBAL setting on that one singleton, not scoped to this listener (see TILT_UPDATE_INTERVAL_MS's + // own doc in constants/game.ts). Left elevated, it would keep @tastic/core's own + // useOrientationState sampling faster than it needs for the rest of the app session. + return () => { + subscription.remove() + DeviceMotion.setUpdateInterval(ORIENTATION_SENSOR_DEFAULT_INTERVAL_MS) + } + }, [enabled]) + + const prevPhaseRef = useRef(phase) + useEffect(() => { + // Every fresh transition into 'live' - the initial serve (idle->live) AND every point's re-serve + // (pointScored->live) - flags a recalibration, giving the player a natural moment to resettle + // their grip between points, mirroring the game's own SERVE_PAUSE_MS pause rhythm. + if (phase === 'live' && prevPhaseRef.current !== 'live') needsCalibrationRef.current = true + prevPhaseRef.current = phase + }, [phase]) + + return null +} diff --git a/src/shared/paddleGame/components/TouchInputLayer.tsx b/src/shared/paddleGame/components/TouchInputLayer.tsx new file mode 100644 index 0000000..944babf --- /dev/null +++ b/src/shared/paddleGame/components/TouchInputLayer.tsx @@ -0,0 +1,154 @@ +import { callWorkletCallback, useWorkletCallback } from '@rific/core' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { StyleSheet, useWindowDimensions, View } from 'react-native' +import { Gesture, GestureDetector } from 'react-native-gesture-handler' +import { scheduleOnRN } from 'react-native-worklets' + +import { Player } from '@/shared/paddleGame/types' + +export interface TouchInputLayerProps { + // [1, 2] for two-player; [1] or [2] for vs-CPU, where the single human gets the whole board as + // their own input area (see the solo branch below) rather than half of it. + humanPlayers: Player[] + enabled: boolean + // The LOCAL (pre-rotation) container's own height - the same value game.tsx feeds as + // computeBoardLayout's own screenHeight/screenWidth arg (whichever ends up "height" after the + // orientation-driven swap - see game.tsx's own comment on containerWidth/containerHeight). Needed + // to convert a touch's real screen position back into the local Y board.ts's paddleMinY/paddleMaxY + // already assume - see screenToLocalY below. + containerHeight: number + // The SAME rotation game.tsx applies to the board's own container (0 for sideBySide, ±90 for + // faceToFace - see game.tsx's own doc comment on why this is the mirror image of + // @tastic/split-screen's own getFixedZoneRotation/getViewRotation convention). Needed to invert a + // touch's real e.absoluteX/absoluteY back into this same local space by hand, since RNGH does not + // correct for an ancestor View's rotation on its own. + rotation: 0 | 90 | -90 + // Called with the LOCAL (board-space) Y only - a Pong paddle only ever moves on one axis (see + // PaddleState's own doc comment in types/index.ts: x is permanently fixed by board.paddleX). + onPaddleTargetY: (player: Player, y: number) => void +} + +// Converts a real screen-space touch position back into the local board's Y coordinate by hand, +// using the exact inverse of the CSS rotation game.tsx applies to the board's own container. +// Mirrors AirHockey's old screenToLocal technique (see AirHockey/src/components/TouchInputLayer.tsx) +// - working from e.absoluteX/absoluteY (real, unambiguous screen position, independent of which +// transformed view the gesture is physically attached to) and inverting the KNOWN rotation +// explicitly, rather than trusting RNGH's own e.x/e.y to have already accounted for an ancestor's +// rotation (verified in AirHockey's own browser-preview testing that it does not). Only Y is +// extracted (unlike AirHockey, which needed both axes) since a Pong paddle never moves in X. +function screenToLocalY(absX: number, absY: number, screenCenterX: number, screenCenterY: number, rotation: 0 | 90 | -90, containerHeight: number): number { + 'worklet' + const dx = absX - screenCenterX + const dy = absY - screenCenterY + let ly: number + if (rotation === 90) ly = -dx + else if (rotation === -90) ly = dx + else ly = dy + return containerHeight / 2 + ly +} + +// Two independent single-finger Pan gestures, each on its OWN View - modeled directly on +// AirHockey's TouchInputLayer, which deliberately does NOT use one shared view split via `hitSlop`: +// two fingers landing in the same native touch batch (a genuine simultaneous two-player touch, not +// two touches a few frames apart) raced on which recognizer claimed which pointer on a single +// shared view, and lost one or both - a documented real bug. Two separate native views/recognizers +// have nothing left to race. +// +// Zones split the UNROTATED real screen (not the rotated local board space) - deliberately, and +// deliberately NOT always left/right: the split axis has to be whichever REAL axis stays +// PERPENDICULAR to the direction local Y ends up mapping onto once rotation is applied, or roughly +// half of each paddle's travel range becomes physically unreachable from its own zone. Concretely, +// for rotation === 0 (sideBySide), local Y maps straight onto real Y, so the two zones split real X +// (left/right - each paddle slides vertically within its own left-or-right half, the classic +// side-by-side arrangement). For rotation === ±90 (faceToFace), local Y maps onto real X instead, so +// the zones split real Y (top/bottom) - each paddle instead slides HORIZONTALLY within its own +// top-or-bottom half. Root causes traced by hand: with rotation === 90, local-left (paddle 1's own +// side) maps to a NEGATIVE real Y offset (near the screen's top), and rotation === -90 flips that to +// the bottom - so "zone A" (whichever physical half is assigned first) is always paddle 1's zone, +// and "zone B" is always paddle 2's, regardless of which real edge each one currently occupies. +// +// This part of the layout is a deliberate, worked-through departure from a plan note that described +// the split as unconditionally left/right - see game.tsx's/this phase's own report for the full +// reasoning (a literal always-left/right split leaves roughly half of each paddle's local-Y travel +// range unreachable once rotation !== 0). +export default function TouchInputLayer({ humanPlayers, enabled, containerHeight, rotation, onPaddleTargetY }: TouchInputLayerProps) { + const solo = humanPlayers.length === 1 + const { width: screenWidth, height: screenHeight } = useWindowDimensions() + + // Everything the gesture callbacks need, kept in a ref rather than closed over directly, so the + // callbacks - and therefore the Gesture objects built from them below - stay referentially stable + // across renders (a Gesture object rebuilt on every layout/state tick can silently drop touches - + // see AirHockey's own TouchInputLayer for the original repro). + const latestRef = useRef({ onPaddleTargetY, screenWidth, screenHeight, containerHeight, rotation }) + useEffect(() => { + latestRef.current = { onPaddleTargetY, screenWidth, screenHeight, containerHeight, rotation } + }) + + const handleTouch = useCallback((player: Player, absX: number, absY: number) => { + const { onPaddleTargetY, screenWidth, screenHeight, containerHeight, rotation } = latestRef.current + const y = screenToLocalY(absX, absY, screenWidth / 2, screenHeight / 2, rotation, containerHeight) + onPaddleTargetY(player, y) + }, []) + + // The worklets below call handleTouch through a key and the module-level callWorkletCallback, never scheduleOnRN(handleTouch) + // directly: native code holds a scheduleOnRN target for as long as the gesture's cached worklet lives, which would pin this + // component in memory forever. + const touchKey = useWorkletCallback(handleTouch) + + const makePlayerGesture = useCallback( + (player: Player) => + Gesture.Pan() + .maxPointers(1) + .minDistance(0) + .hitSlop({}) + .enabled(enabled) + .onStart((e) => { + scheduleOnRN(callWorkletCallback, touchKey, player, e.absoluteX, e.absoluteY) + }) + .onUpdate((e) => { + scheduleOnRN(callWorkletCallback, touchKey, player, e.absoluteX, e.absoluteY) + }), + [enabled, touchKey] + ) + + const soloGesture = useMemo(() => (solo ? makePlayerGesture(humanPlayers[0]) : null), [solo, humanPlayers, makePlayerGesture]) + // zoneA is always player 1's zone, zoneB always player 2's - see the file-level comment above for + // why the physical half each one occupies (not the assignment itself) is what varies with rotation. + const zoneAGesture = useMemo(() => (solo ? null : makePlayerGesture(1)), [solo, makePlayerGesture]) + const zoneBGesture = useMemo(() => (solo ? null : makePlayerGesture(2)), [solo, makePlayerGesture]) + + if (solo) { + return ( + + {/* collapsable={false}: without a prop that makes it stand out, RN's view-flattening + optimization merges this plain View into its parent, and GestureDetector's ref then + attaches to nothing - the gesture silently never receives a single touch. */} + + + ) + } + + const zoneAStyle = rotation === 0 ? styles.zoneLeft : rotation === 90 ? styles.zoneTop : styles.zoneBottom + const zoneBStyle = rotation === 0 ? styles.zoneRight : rotation === 90 ? styles.zoneBottom : styles.zoneTop + + return ( + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + container: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0 }, + zone: { position: 'absolute' }, + zoneBottom: { bottom: 0, height: '50%', left: 0, right: 0 }, + zoneFull: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0 }, + zoneLeft: { bottom: 0, left: 0, top: 0, width: '50%' }, + zoneRight: { bottom: 0, right: 0, top: 0, width: '50%' }, + zoneTop: { height: '50%', left: 0, right: 0, top: 0 } +}) diff --git a/src/shared/paddleGame/constants/achievements.ts b/src/shared/paddleGame/constants/achievements.ts new file mode 100644 index 0000000..2e87d3a --- /dev/null +++ b/src/shared/paddleGame/constants/achievements.ts @@ -0,0 +1,63 @@ +import { AchievementTier, tieredFamily } from '@tastic/achievements' + +import { AchievementDefinition, AiDifficulty, StatsState } from '@/shared/paddleGame/types' +import { getOverallTotals, getSpeedsWon } from '@/shared/paddleGame/utils/statsEngine' + +export { ACHIEVEMENT_TIER_COLORS } from '@tastic/achievements' + +// Every puck-speed tier in BallSpeedLevel - hardcoded rather than imported so this stays a +// dependency-light, Jest-safe constants file. +const TOTAL_SPEED_TIERS = 3 + +const AI_DIFFICULTY_TIER: Record = { easy: 'bronze', medium: 'silver', hard: 'gold' } +const AI_DIFFICULTY_LABEL: Record = { easy: 'Easy', medium: 'Medium', hard: 'Hard' } + +// Three different predicates, not one value against three thresholds - hand-written, with no +// `progress`, since a fraction of "beat Hard once" isn't meaningful. +function beatCpuFamily(): AchievementDefinition[] { + const titles: Record = { easy: 'Warmed Up', medium: 'Contender', hard: 'Paddle Master' } + return (['easy', 'medium', 'hard'] as AiDifficulty[]).map((difficulty) => ({ + id: `beat_cpu_${difficulty}`, + title: titles[difficulty], + description: `Beat the CPU on ${AI_DIFFICULTY_LABEL[difficulty]} difficulty.`, + tier: AI_DIFFICULTY_TIER[difficulty], + icon: 'robot', + isUnlocked: (stats: StatsState) => stats.vsCpu.byDifficulty[difficulty].wins >= 1 + })) +} + +// What every paddle game unlocks: matches, wins, points, the CPU and two-player play. +const MATCH_ACHIEVEMENTS: AchievementDefinition[] = [ + { id: 'first_game_played', title: 'First Serve', description: 'Play your first match.', tier: 'bronze', icon: 'flag-checkered', isUnlocked: (stats) => getOverallTotals(stats).played >= 1 }, + { id: 'first_ever_win', title: 'First Point', description: 'Win any match, any time.', tier: 'bronze', icon: 'star', isUnlocked: (stats) => getOverallTotals(stats).wins >= 1 }, + { + id: 'flawless_debut', + title: 'Natural', + description: 'Win the very first match you ever play.', + tier: 'gold', + icon: 'star-circle', + // firstGameResult is a one-time device-wide flag with no per-profile analog, so it always + // evaluates against the real device stats regardless of which profile tab is selected. + scope: 'device', + isUnlocked: (stats) => stats.firstGameResult === 'win' + }, + ...tieredFamily({ id: 'games_played', titles: { bronze: 'Rookie', silver: 'Regular', gold: 'Veteran' }, description: (n) => `Play ${n} total matches.`, icon: 'table-tennis', thresholds: { bronze: 10, silver: 50, gold: 200 }, value: (stats) => getOverallTotals(stats).played }), + ...tieredFamily({ id: 'total_wins', titles: { bronze: 'Winner', silver: 'Big Winner', gold: 'Champion' }, description: (n) => `Win ${n} total matches.`, icon: 'trophy', thresholds: { bronze: 10, silver: 50, gold: 200 }, value: (stats) => getOverallTotals(stats).wins }), + ...tieredFamily({ id: 'points_scored', titles: { bronze: 'Rallying', silver: 'Point Machine', gold: 'Relentless' }, description: (n) => `Score ${n} total points.`, icon: 'circle-double', thresholds: { bronze: 50, silver: 250, gold: 1000 }, value: (stats) => stats.points.scored }), + ...tieredFamily({ id: 'shutouts', titles: { bronze: 'Clean Sheet', silver: 'Brick Wall', gold: 'Untouchable' }, description: (n) => `Win ${n} matches without conceding a single point.`, icon: 'shield-check', thresholds: { bronze: 1, silver: 10, gold: 40 }, value: (stats) => stats.shutouts }), + ...tieredFamily({ id: 'win_margin', titles: { bronze: 'Comfortable', silver: 'Dominant', gold: 'Humiliating' }, description: (n) => `Win a match by ${n} points or more.`, icon: 'chart-line', thresholds: { bronze: 3, silver: 5, gold: 7 }, value: (stats) => stats.bestWinMargin }), + ...tieredFamily({ id: 'speed_demon', titles: { bronze: 'Finding Pace', silver: 'Up to Speed', gold: 'Every Gear' }, description: (n) => (n === TOTAL_SPEED_TIERS ? 'Win at every puck speed.' : `Win at ${n} different puck speeds.`), icon: 'speedometer', thresholds: { bronze: 1, silver: 2, gold: TOTAL_SPEED_TIERS }, value: getSpeedsWon }), + ...tieredFamily({ id: 'cpu_streak', titles: { bronze: 'On a Roll', silver: 'Hot Streak', gold: 'Unstoppable' }, description: (n) => `Win ${n} matches in a row against the CPU.`, icon: 'fire', thresholds: { bronze: 3, silver: 5, gold: 10 }, value: (stats) => stats.vsCpu.bestWinStreak }), + ...beatCpuFamily(), + ...tieredFamily({ id: 'local_matches', titles: { bronze: 'Pass the Phone', silver: 'Couch Champion', gold: 'Living Room Legend' }, description: (n) => `Play ${n} local two-player matches.`, icon: 'account-multiple', thresholds: { bronze: 10, silver: 50, gold: 150 }, value: (stats) => stats.twoPlayer.played }) +] + +// Breaker's own: clearing the bricks alone (seat 2 set to Nobody) and breaking bricks in any round. +const BRICK_ACHIEVEMENTS: AchievementDefinition[] = [...tieredFamily({ id: 'bricks_cleared_alone', titles: { bronze: 'Brickbreaker', silver: 'Wrecking Crew', gold: 'Nothing Left Standing' }, description: (n) => (n === 1 ? 'Clear every brick on your own.' : `Clear every brick on your own ${n} times.`), icon: 'view-grid-outline', thresholds: { bronze: 1, silver: 10, gold: 50 }, value: (stats) => stats.solo.cleared }), ...tieredFamily({ id: 'bricks_destroyed', titles: { bronze: 'Breaking Ground', silver: 'Demolition Crew', gold: 'Wrecking Ball' }, description: (n) => `Break ${n} total bricks.`, icon: 'wall', thresholds: { bronze: 100, silver: 500, gold: 2000 }, value: (stats) => stats.bricksDestroyed })] + +const DAY_ACHIEVEMENTS: AchievementDefinition[] = [...tieredFamily({ id: 'days_played', titles: { bronze: 'Regular Visitor', silver: 'Dedicated', gold: 'Devoted' }, description: (n) => `Play ${n} days in a row.`, icon: 'calendar-check', thresholds: { bronze: 3, silver: 14, gold: 30 }, value: (stats) => stats.bestDayStreak })] + +// A paddle game's catalog: Paddles' without the brick achievements, Breaker's with them. +export function paddleGameCatalog({ hasBricks }: { hasBricks: boolean }): AchievementDefinition[] { + return [...MATCH_ACHIEVEMENTS, ...(hasBricks ? BRICK_ACHIEVEMENTS : []), ...DAY_ACHIEVEMENTS] +} diff --git a/src/shared/paddleGame/constants/game.ts b/src/shared/paddleGame/constants/game.ts new file mode 100644 index 0000000..6265353 --- /dev/null +++ b/src/shared/paddleGame/constants/game.ts @@ -0,0 +1,124 @@ +import { BallSpeedLevel, PaddleSizeLevel } from '@/shared/paddleGame/types' + +export const WALL_THICKNESS = 10 + +// width:height for the outer letterboxed container on desktop web only - a much wider, shallower +// shape than AirHockey's TABLE_LONG_TO_SHORT_RATIO (a real air-hockey table's ~2:1), matching a +// classic Pong court's own proportions instead. +export const TABLE_WIDTH_TO_HEIGHT_RATIO = 16 / 9 +export const DESKTOP_WEB_MIN_WIDTH = 768 + +// Ball/paddle sizing, all as a fraction of board HEIGHT (unlike AirHockey's short-edge-fraction +// convention) - Pong's court is fixed wide/landscape-native in local space (paddles always at +// local-left/local-right along the long axis), so height is the axis that stays meaningful across +// both seating orientations once game.tsx swaps which real screen dimension feeds computeBoardLayout. +export const BALL_RADIUS_RATIO = 0.018 +export const PADDLE_WIDTH_RATIO = 0.022 +export const PADDLE_INSET_RATIO = 0.035 +export const PADDLE_HEIGHT_RATIOS: Record = { small: 0.14, medium: 0.18, large: 0.24 } + +export const PADDLE_VELOCITY_SMOOTHING_FRAMES = 3 // unchanged from AirHockey +export const MAX_PADDLE_SPEED = 26 // unchanged from AirHockey + +// How many frames the AI's own aim-error wanders over (see useAIOpponent) - a fresh Math.random() +// draw is re-EMA'd into a persisted value at this rate rather than fed straight into +// computeAIPaddleTarget's jitter formula each tick, so the AI's imprecision drifts smoothly like a +// real opponent's wavering aim instead of snapping to a brand-new independent offset 60 times a +// second, which is what actually read as paddle shake rather than "slightly imprecise aim". +export const AI_AIM_WANDER_SMOOTHING_FRAMES = 20 + +// Wall bounces are lossless by design - direction flips, magnitude untouched, matching a classic +// Pong court's own flat top/bottom walls. Unlike AirHockey's WALL_RESTITUTION (0.72, a real +// mallet-and-ice feel), any restitution below 1 here would make the ball quietly bleed speed on +// every wall bounce despite the whole speed model being an explicit, deliberate ratchet elsewhere +// - the two mechanisms would fight each other. Ratchet-on-hit-only lives entirely in +// applyPaddleCollision. +export const WALL_RESTITUTION = 1 +export const PADDLE_COLLISION_RESTITUTION = 1 +// Extra vy nudge from WHERE on the paddle the ball struck (see applyPaddleCollision) - without +// this, a ball met by a paddle holding still reflects at vy≈0 off a pure geometric bounce and can +// rally dead-straight along the same horizontal line forever, which reads as broken rather than +// as a legitimate rally. +export const PADDLE_STEER_FACTOR = 0.6 + +export const BALL_SPEED_TIERS: Record = { + slow: { base: 6, increment: 1.0, max: 14 }, + medium: { base: 8, increment: 1.4, max: 20 }, + fast: { base: 11, increment: 1.8, max: 26 } +} + +export const SCORE_TO_WIN = 5 +export const SERVE_PAUSE_MS = 900 +export const SERVE_ANGLE_MAX_DEG = 30 + +// Sizes the TICK-level substep loop (see computeTickSubsteps below and useGameState.ts's TICK +// handler) against the thinnest feature a fast ball must not tunnel through - the paddle BAR, not +// the wall. A wall spans the board's full width so a single large-dt step can never skip past it +// entirely, but a paddle is only ~2% of board height thick, and the ball's own max speed is capped +// well above what a single 60fps frame's worth of displacement could safely resolve against +// something that thin. +export const PADDLE_TUNNEL_SAFETY_RATIO = 0.4 +export const MAX_TICK_SUBSTEPS = 8 + +// Brick field sizing - all fractions of board height/width, same idiom as the ratios above. Solo's +// field fills most of the court (runway near paddle 1, margin before the far wall); the shared +// vsCpu/twoPlayer field is a narrower block centered on the court so it can never overlap either +// paddle's reachable area, since a paddle only ever moves in Y at a fixed X. +export const BRICK_GAP_RATIO = 0.006 // of board.height, applied to both axes +export const SOLO_BRICK_COLS = 6 +export const SOLO_BRICK_ROWS = 8 +export const SOLO_BRICK_FIELD_X_START_RATIO = 0.3 +export const SOLO_BRICK_FIELD_X_END_RATIO = 0.92 +export const SOLO_BRICK_FIELD_Y_MARGIN_RATIO = 0.06 +export const SHARED_BRICK_COLS = 3 +export const SHARED_BRICK_ROWS = 8 +export const SHARED_BRICK_FIELD_WIDTH_RATIO = 0.24 // total width, centered on board.centerX +export const SHARED_BRICK_FIELD_Y_MARGIN_RATIO = 0.08 + +// Solo/Brickbreaker only. +export const SOLO_LIVES = 3 + +// ─── Tilt control (vsCpu/solo only - see tiltControlActiveFor in utils/gameMode.ts) ──────────── + +// Fast DeviceMotion sampling while a tilt-controlled paddle is actively steering - much finer +// grained than @tastic/core's own useOrientationState (see ORIENTATION_SENSOR_DEFAULT_INTERVAL_MS +// below), which only needs to detect coarse hold changes every ~100ms. Continuous paddle steering +// reads as noticeably laggier/steppier at that rate, so TiltInputLayer requests this faster one +// ONLY while its own subscription is active - expo-sensors' DeviceMotion.setUpdateInterval is a +// GLOBAL setting on the shared sensor singleton, not scoped per listener, so this has to be paired +// with restoring the default below whenever tilt control isn't active. +export const TILT_UPDATE_INTERVAL_MS = 20 + +// Local mirror of @tastic/core's own useOrientationState.tsx UPDATE_INTERVAL_MS (100) - that +// constant isn't exported by the package, so it can't be imported directly. TiltInputLayer restores +// the shared DeviceMotion singleton to exactly this rate whenever its own subscription tears down +// (disabled, paused, or unmounted), so the app's baseline orientation-hold-detection sampling rate +// is never left elevated for the rest of the session after a tilt-controlled round ends. +export const ORIENTATION_SENSOR_DEFAULT_INTERVAL_MS = 100 + +// Degrees of beta/gamma drift from the calibrated baseline (see utils/tiltInput.ts) that produce +// ZERO paddle movement - absorbs natural hand tremor and grip micro-adjustments that would +// otherwise read as constant low-amplitude paddle jitter rather than deliberate steering. +export const TILT_DEAD_ZONE_DEG = 2 + +// Degrees of beta/gamma drift (beyond the dead zone above) at which the paddle reaches the extreme +// of its own travel range (paddleMinY/paddleMaxY). A first-guess tuning value - like the axis +// mapping itself (see tiltInput.ts's own doc), how far someone naturally tilts a device while +// holding it is something to spot-check on a real device/simulator, not derive from first +// principles. +export const TILT_MAX_DEG = 25 + +// A brick or wall bounce (unlike a paddle hit) has no steering and is perfectly elastic - with +// zero jitter, the ball can settle into a stable periodic orbit inside a partially-cleared pocket +// (bouncing the same closed path between the remaining brick faces and/or the top/bottom walls +// forever) and never make further progress - confirmed live: a ball can get trapped bouncing +// between the court's own top/bottom walls with too few brick hits along the way for brick-only +// jitter to ever break the cycle. A few degrees of random angle nudge per bounce is enough to +// de-sync any such cycle within a handful of bounces without reading as anything other than +// physics to a player - same idea as computeServeVelocity's own SERVE_ANGLE_MAX_DEG randomization. +// Applied to EVERY reflective surface (bricks, top/bottom walls, solo's far wall) whenever bricks +// are active for the round - never to classic Pong's own wall bounces, which stay exactly as +// deterministic/lossless as they've always been (see WALL_RESTITUTION's own doc comment): that +// simpler geometry can't form a closed loop the way a brick field can, so there's nothing to guard +// against there, and two competing players don't want their rally's wall bounces made unpredictable. +export const BOUNCE_JITTER_MAX_DEG = 4 diff --git a/src/shared/paddleGame/constants/guideSteps.tsx b/src/shared/paddleGame/constants/guideSteps.tsx new file mode 100644 index 0000000..004781f --- /dev/null +++ b/src/shared/paddleGame/constants/guideSteps.tsx @@ -0,0 +1,125 @@ +import { type GuideStep, type SeatDevice, SeatDevicesHint } from '@tastic/hud/guide' +import type { KeyScheme } from '@tastic/input' + +import { GuideBrickField, GuideMouseDrag, GuidePaddleDrag, GuideScorePoint, GuideSplitPhone, GuideUpDownKeys, type UpDownKeyLabels } from '@/shared/paddleGame/components/GuideArt' +import { SOLO_LIVES } from '@/shared/paddleGame/constants/game' +import type { ControlScheme, Player } from '@/shared/paddleGame/types' + +// How each seat's control scheme reads in a sentence, and on GuideUpDownKeys' two caps (undefined = +// draw the arrow keys as arrows). Only up and down: KeyboardInputLayer never reads left/right, since +// a paddle here has one axis. Deliberately not the loadout's own CONTROL_SCHEME_OPTIONS labels +// ('Mouse', 'Arrows'), which are picker labels, not prose. +const SEAT_PHRASE: Record = { mouse: 'the mouse', wasd: 'W and S', arrows: 'the arrow keys', ijkl: 'I and K', numpad: 'the numpad' } +const KEY_CAPS: Record = { + wasd: { up: 'W', down: 'S' }, + arrows: undefined, + ijkl: { up: 'I', down: 'K' }, + numpad: { up: '8', down: '2' } +} + +// What SeatDevicesHint draws for each seat on the desktop two-player card: every key scheme is the +// keyboard, 'mouse' is the pointer drag. +function seatDevice(scheme: ControlScheme): SeatDevice { + return scheme === 'mouse' ? 'mouse' : 'keyboard' +} + +interface GuideStepsInput { + // A touch screen vs. a desktop browser: a phone drags with a finger, a desktop with the mouse or keys. + touch: boolean + // Each seat's live control scheme (settings.controlScheme), only read on desktop. Seats pick their + // own in the loadout, so a replayed guide has to name the real ones, not the defaults. + controls: Record + // The live settings.scoreToWin (3/5/7/10, picked in the loadout), for the same reason. + scoreToWin: number + // Breaker (a brick field every round, and a solo round with seat 2 set to Nobody) or Paddles. + hasBricks: boolean +} + +// A paddle game's how-to-play cards: Paddles' three, and Breaker's four (its bricks, and playing +// alone). Pure and hook-free so it's trivially testable; GuideHost feeds it the device and the live +// settings. +// +// What's here is what a first-time player cannot work out alone (none of it is printed anywhere in +// the game): the paddle lines up with your finger (absolute, not nudged, and you never have to touch +// the paddle itself); you score by getting the puck past the other paddle, first to scoreToWin wins; +// in Breaker, bricks sit between the paddles and break, and with Nobody in seat 2 you clear them +// alone; and in two-player each player drags in their own half of one phone. Copy rules (Jay, +// 2026-09-24): Title Case titles, bodies of 90 characters or fewer, and only say what's true for +// every reader. The cards show before the loadout, while seat 2 can still be anything, so the +// steering card never says "your half" (one player drags anywhere) and the scoring card is followed +// by Breaker's card for playing alone. "Points", because a bare "First to 5 Wins" can read as five +// match wins. Tilt steering is off by default, so it gets no card. +// +// Desktop: the pointer drag is on for EVERY seat whatever its scheme (KeyboardInputLayer only ever +// adds keys on top of TouchInputLayer, never replaces it), so "click and drag" is true for anyone +// reading the steering card, and naming a key scheme there has to stay generic ("your up or down +// key"): two-player readers include a player 2 whose keys differ from player 1's. The two-player +// card names each seat's real controls in the fleet's shared wording (the same title and sentences +// every desktop two-player card in the fleet uses, with this game's own SEAT_PHRASE), and whenever a +// seat is on the mouse it also carries the own-half rule, which the steering card can't (one player +// drags anywhere). +export function getGuideSteps({ touch, controls, scoreToWin, hasBricks }: GuideStepsInput): GuideStep[] { + const p1 = controls[1] + const anyMouse = controls[1] === 'mouse' || controls[2] === 'mouse' + const bothMouse = controls[1] === 'mouse' && controls[2] === 'mouse' + + const steering: GuideStep = touch + ? { + title: 'Move Your Paddle', + body: 'Touch and drag. Your paddle lines up with your finger and follows it.', + art: + } + : p1 === 'mouse' + ? { + title: 'Move Your Paddle', + body: 'Click and drag. Your paddle lines up with the pointer.', + art: + } + : { + title: 'Move Your Paddle', + body: 'Hold your up or down key to move your paddle, or click and drag.', + art: + } + + const scoring: GuideStep = hasBricks + ? { + title: `First to ${scoreToWin} Points Wins`, + body: 'Get the puck past the other paddle to score. Bricks in the middle break when hit.', + art: + } + : { + title: `First to ${scoreToWin} Points Wins`, + body: 'Get the puck past the other paddle to score.', + art: + } + + const alone: GuideStep[] = hasBricks + ? [ + { + title: 'Play Alone', + body: `Pick Nobody for player 2, then clear every brick before your ${SOLO_LIVES} lives run out.`, + art: + } + ] + : [] + + const twoPlayers: GuideStep = touch + ? { + title: 'Two Players, One Phone', + body: 'Each of you drags in your own half of the screen.', + art: + } + : { + // "One Keyboard" only when it's true: the default desktop setup puts player 1 on the mouse. + title: anyMouse ? 'Two Players, One Computer' : 'Two Players, One Keyboard', + // With a mouse seat, the second sentence is the own-half rule instead: a two-player drag + // moves whichever paddle's half it starts in (TouchInputLayer's two zones), so a mouse + // player who drags on the far side moves the other player's paddle. The two sentences + // together run past 90 characters, and the loadout's control pickers are in plain view. Both + // seats on the mouse is said once ("Both players use the mouse.") instead of twice. + body: bothMouse ? 'Both players use the mouse. Drag in your own half.' : `Player 1 uses ${SEAT_PHRASE[controls[1]]}, player 2 uses ${SEAT_PHRASE[controls[2]]}. ${anyMouse ? 'Drag in your own half.' : 'Change them before a match.'}`, + art: + } + + return [steering, scoring, ...alone, twoPlayers] +} diff --git a/src/shared/paddleGame/createPaddleGame.tsx b/src/shared/paddleGame/createPaddleGame.tsx new file mode 100644 index 0000000..29f3dc4 --- /dev/null +++ b/src/shared/paddleGame/createPaddleGame.tsx @@ -0,0 +1,82 @@ +import type { ImageSourcePropType } from 'react-native' + +import { getGuideSteps } from '@/shared/paddleGame/constants/guideSteps' +import { PaddlePlayScreen } from '@/shared/paddleGame/PlayScreen' +import { createPaddleSettings, DEFAULT_SETTINGS } from '@/shared/paddleGame/settings' +import { createPaddleGameStats } from '@/shared/paddleGame/stats' +import type { ControlScheme } from '@/shared/paddleGame/types' +import { usePaddleLoadout } from '@/shared/paddleGame/usePaddleLoadout' +import { createGameGuideSlice } from '@/shell/redux/gameGuideSlice' +import type { GameDefinition, RoundMode } from '@/shell/types' + +interface PaddleGameOptions { + id: string + title: string + blurb: string + icon: { light: ImageSourcePropType; dark: ImageSourcePropType } + // A brick field every round, and a solo round with seat 2 set to Nobody (Breaker), or neither + // (Paddles). + hasBricks: boolean + // The version of the game's How to Play cards. Bump it ONLY when a controls or rules change would + // leave the cards teaching something wrong: every player sees them once more after a bump. + guideVersion: number +} + +// What a paddle game's How to Play cards are built from (see getGuideSteps), flat so the guide's +// shallow compare sees no change while none of them change. +interface GuideInput { + control1: ControlScheme + control2: ControlScheme + scoreToWin: number +} + +// One of the pack's paddle games (Paddles and Breaker, both from Pong): one engine, one play screen, +// one stats shape, with the game's own store keys (`Settings`, `Controls`, +// `Achievements`, `Guide`) so each keeps its own settings, stats, achievements and How to +// Play. +export function createPaddleGame({ id, title, blurb, icon, hasBricks, guideVersion }: PaddleGameOptions): GameDefinition { + const settings = createPaddleSettings(id) + const stats = createPaddleGameStats({ key: `${id}Achievements`, hasBricks }) + const guide = createGameGuideSlice(`${id}Guide`) + + function useLoadout(mode: RoundMode) { + const { settings: current, setSettings } = settings.useSettings() + return usePaddleLoadout(mode, current, setSettings) + } + + function PlayScreen() { + const { recordMatchOutcome, recordSoloOutcome } = stats.useGameStats() + return + } + + return { + id, + title, + blurb, + icon, + seatTwo: hasBricks ? ['cpu', 'person', 'nobody'] : ['cpu', 'person'], + // Face to face on a phone, P1 at the bottom unless the phone is held upside down; on the web a + // window seats the two players by its own shape (see the play screen's fixedSeats). + twoPlayerSeats: { faceToFaceOnWeb: false, followUpsideDown: true }, + reducers: { ...settings.reducers, [`${id}Achievements`]: stats.reducer, [`${id}Guide`]: guide.reducer }, + Provider: stats.Provider, + useLoadout, + PlayScreen, + StatsSections: stats.StatsSections, + AchievementList: stats.AchievementList, + resetStats: stats.actions.reset, + guide: { + version: guideVersion, + selectVersionSeen: (state) => guide.selectVersionSeen(state), + markSeen: guide.actions.markSeen, + selectInput: (state): GuideInput => { + const s = settings.select(state) + return { control1: s.controlScheme[1], control2: s.controlScheme[2], scoreToWin: s.scoreToWin } + }, + getSteps: (input, { touch }) => { + const { control1, control2, scoreToWin } = input as GuideInput + return getGuideSteps({ touch, hasBricks, controls: { 1: control1, 2: control2 }, scoreToWin }) + } + } + } +} diff --git a/src/shared/paddleGame/hooks/useAIOpponent.ts b/src/shared/paddleGame/hooks/useAIOpponent.ts new file mode 100644 index 0000000..3b3936c --- /dev/null +++ b/src/shared/paddleGame/hooks/useAIOpponent.ts @@ -0,0 +1,36 @@ +import { useEffect, useRef } from 'react' + +import { AI_AIM_WANDER_SMOOTHING_FRAMES } from '@/shared/paddleGame/constants/game' +import { BoardLayout, GameState, Player } from '@/shared/paddleGame/types' +import { AIDifficulty, computeAIPaddleTarget } from '@/shared/paddleGame/utils/aiOpponent' + +// Re-runs every time state.ball changes - i.e. once per physics tick, since ball is a fresh object +// each TICK dispatch - so this hook itself IS the AI's per-tick decision point, riding the same +// cadence useGameState's own useGameLoop already drives rather than a separate rAF/interval loop +// of its own. setPaddleTarget is the EXACT SAME function TouchInputLayer's gesture callbacks call +// for a human - this is what preserves AirHockey's "AI plays through the human's own input +// contract" principle at continuous granularity: the AI never touches TICK dispatch, paddle +// velocity computation, or collision differently than a human's touch does, it only ever supplies +// a different SOURCE of a target Y (setPaddleTarget's own public signature simplifies from +// AirHockey's (player, x, y) to (player, y) - see useGameState.ts - since a Pong paddle's X is +// permanently fixed by board.paddleX[player], never a live input). +export function useAIOpponent(state: GameState, board: BoardLayout, aiPlayer: Player | null, difficulty: AIDifficulty, paused: boolean, setPaddleTarget: (player: Player, y: number) => void): void { + // Smoothed noise source for computeAIPaddleTarget's own `random` param (see AI_AIM_WANDER_SMOOTHING_ + // FRAMES's own doc comment) - persisted across ticks and re-EMA'd toward a fresh Math.random() draw + // each time, rather than passing Math.random itself straight through. computeAIPaddleTarget only + // ever calls `random()` once per invocation, so smoothing what that single call returns is enough to + // turn its jitter into a slow wander without touching computeAIPaddleTarget (or its own maxApproach + // Speed step-limiting, which must keep reacting at full speed to the ball's genuine, non-jittery + // motion) at all. Seeded at 0.5 - the zero-jitter point (see aiOpponent.test.ts's own noJitter + // convention) - rather than an initial Math.random() call, which render-time purity rules forbid; + // the EMA below pulls it toward real draws within the very first live tick regardless. + const wanderRef = useRef(0.5) + + useEffect(() => { + if (aiPlayer === null || paused || state.phase !== 'live') return + wanderRef.current += (Math.random() - wanderRef.current) / AI_AIM_WANDER_SMOOTHING_FRAMES + const target = computeAIPaddleTarget(state.ball, state.paddles[aiPlayer], board, aiPlayer, difficulty, () => wanderRef.current) + setPaddleTarget(aiPlayer, target.y) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.ball, state.phase, aiPlayer, difficulty, paused, board, setPaddleTarget]) +} diff --git a/src/shared/paddleGame/hooks/useGameState.ts b/src/shared/paddleGame/hooks/useGameState.ts new file mode 100644 index 0000000..73efce1 --- /dev/null +++ b/src/shared/paddleGame/hooks/useGameState.ts @@ -0,0 +1,333 @@ +import { useGameLoop } from '@tastic/core' +import { useCallback, useEffect, useReducer, useRef } from 'react' + +import { BALL_SPEED_TIERS, PADDLE_VELOCITY_SMOOTHING_FRAMES, SERVE_PAUSE_MS, SOLO_LIVES } from '@/shared/paddleGame/constants/game' +import { BallSpeedLevel, BallState, BoardLayout, Brick, GameMode, GameState, PaddleState, Player } from '@/shared/paddleGame/types' +import { buildSharedBrickField, buildSoloBrickField, centerBall, centerPaddle, clampPaddleY, rescaleBricks } from '@/shared/paddleGame/utils/board' +import { bricksActiveFor } from '@/shared/paddleGame/utils/gameMode' +import { applyBrickCollision, applyPaddleCollision, clampPaddleSpeed, computePaddleVelocity, computeServeVelocity, computeTickSubsteps, stepPhysics } from '@/shared/paddleGame/utils/physics' + +// setPaddleTarget's public signature simplifies from AirHockey's (player, x, y) to (player, y) - +// a Pong paddle's X is permanently fixed by board.paddleX[player] for the whole match, so there's +// nothing left for a live input to ever set it to. paddleTargetsRef mirrors that: bare Y values, +// not {x, y} points. +type PaddleTargets = Record + +// TICK's own random is only ever consulted for brick-bounce jitter (see applyBrickCollision) - +// optional and Math.random-defaulted the same way START/POINT_SERVE's already are, so tests can +// inject determinism without the live useGameLoop dispatch needing to pass anything explicit. +type Action = { type: 'TICK'; board: BoardLayout; dt: number; targets: PaddleTargets; random?: () => number } | { type: 'START'; random?: () => number } | { type: 'POINT_SERVE'; board: BoardLayout; random?: () => number } | { type: 'RESET'; board: BoardLayout } | { type: 'RESCALE'; prevBoard: BoardLayout; board: BoardLayout } + +function makeInitialState(board: BoardLayout, mode: GameMode, bricksActive: boolean): GameState { + const ballPos = centerBall(board) + const p1 = centerPaddle(board, 1) + const p2 = centerPaddle(board, 2) + return { + phase: 'idle', + scores: [0, 0], + // speed: 0 - unlike AirHockey's puck (which also legitimately sits at rest pre-START), Pong's + // ball genuinely gets launched by START itself (see the 'START' case below), so a resting + // speed of 0 here is momentary by design, not a steady state anything else needs to handle. + ball: { ...ballPos, vx: 0, vy: 0, speed: 0 }, + paddles: { 1: { ...p1, vx: 0, vy: 0 }, 2: { ...p2, vx: 0, vy: 0 } }, + lastScoredBy: null, + bounceCount: 0, + bricks: bricksActive ? (mode === 'solo' ? buildSoloBrickField(board) : buildSharedBrickField(board)) : [], + lives: mode === 'solo' ? SOLO_LIVES : 0 + } +} + +// Commits one player's paddle from its raw target Y into a real, velocity-bearing PaddleState - +// an EMA over the instantaneous per-tick velocity, same technique AirHockey's own commitPaddle +// uses (see its own doc comment for the full rationale). targetX is never a live input here - it's +// always board.paddleX[player], read fresh each commit so a mid-rally RESCALE's new fixed X is +// picked up automatically on the very next TICK, with no separate reconciliation step needed. +function commitPaddle(prev: PaddleState, targetY: number, board: BoardLayout, player: Player, dt: number): PaddleState { + const targetX = board.paddleX[player] + const instant = computePaddleVelocity(prev.x, prev.y, targetX, targetY, dt) + const alpha = 1 / PADDLE_VELOCITY_SMOOTHING_FRAMES + const smoothed = clampPaddleSpeed(prev.vx + (instant.vx - prev.vx) * alpha, prev.vy + (instant.vy - prev.vy) * alpha) + return { x: targetX, y: targetY, vx: smoothed.vx, vy: smoothed.vy } +} + +function otherPlayer(p: Player): Player { + return p === 1 ? 2 : 1 +} + +// Board-change remap, split into a ball-specific and a paddle-specific half rather than one +// generic rescalePoint the way AirHockey's single function handles both puck and paddle - a Pong +// paddle's X is fixed geometry, not a live coordinate, so it must SNAP to the new board's own +// paddleX[player] rather than fractionally interpolate from wherever it happened to be (fractional +// interpolation would leave it off the fixed line the moment the board's own aspect ratio changed +// even slightly). The ball has no such fixed axis, so it rescales fractionally in both dimensions, +// same as AirHockey's puck. +function rescaleBall(ball: BallState, prevBoard: BoardLayout, board: BoardLayout): BallState { + const degenerate = prevBoard.width <= 0 || prevBoard.height <= 0 + const fx = degenerate ? 0.5 : (ball.x - prevBoard.x) / prevBoard.width + const fy = degenerate ? 0.5 : (ball.y - prevBoard.y) / prevBoard.height + const vx = ball.vx * (degenerate ? 1 : board.width / prevBoard.width) + const vy = ball.vy * (degenerate ? 1 : board.height / prevBoard.height) + // speed is recomputed from the rescaled vx/vy rather than carried forward unchanged - a resize + // isn't a "hit" (applyPaddleCollision's own ratchet never runs here), but the rescaled vx/vy's + // own magnitude genuinely did just change, so speed (the ratchet's source of truth - see + // BallState's doc comment in types/index.ts) has to track that or the two would silently drift + // apart from each other. + return { x: board.x + fx * board.width, y: board.y + fy * board.height, vx, vy, speed: Math.hypot(vx, vy) } +} + +function rescalePaddleY(paddle: PaddleState, prevBoard: BoardLayout, board: BoardLayout, player: Player): PaddleState { + const degenerate = prevBoard.height <= 0 + const fy = degenerate ? 0.5 : (paddle.y - prevBoard.y) / prevBoard.height + return { x: board.paddleX[player], y: board.y + fy * board.height, vx: 0, vy: 0 } +} + +export function makeReducer(scoreToWin: number, ballSpeedLevel: BallSpeedLevel, mode: GameMode = 'twoPlayer', hasBricks: boolean = false) { + const tier = BALL_SPEED_TIERS[ballSpeedLevel] + const isSolo = mode === 'solo' + const bricksActive = bricksActiveFor(mode, hasBricks) + + return function reducer(state: GameState, action: Action): GameState { + switch (action.type) { + // idle -> live: the pre-game "get ready" beat, same pause POINT_SERVE gives after a point + // (see useGameState's own effect below). Unlike AirHockey's own START (which only has to + // flip the phase, since the puck legitimately sits at rest until struck), Pong's ball has no + // such mechanic - this is the very first thing that ever puts it in motion, via + // computeServeVelocity with towardPlayer: null (a coin-flip direction, since neither player + // has conceded anything yet). Solo has no "neither player" - always serve toward 2 (rightward, + // away from paddle 1) so the opening serve heads into the brick field rather than sometimes + // serving straight back at the player. + case 'START': { + if (state.phase !== 'idle') return state + const velocity = computeServeVelocity(tier.base, isSolo ? 2 : null, action.random) + return { ...state, phase: 'live', ball: { ...state.ball, vx: velocity.vx, vy: velocity.vy, speed: tier.base } } + } + + case 'TICK': { + if (state.phase !== 'live') return state + + const paddles = { + 1: commitPaddle(state.paddles[1], action.targets[1], action.board, 1, action.dt), + // Committed unconditionally even in solo - harmless (paddle 2 is never collided against + // or rendered there), and simpler than special-casing it out of a shape every other + // action already assumes exists. + 2: commitPaddle(state.paddles[2], action.targets[2], action.board, 2, action.dt) + } + + // Substepping lives HERE, not inside stepPhysics itself (unlike AirHockey) - each substep + // needs its own fresh applyPaddleCollision check against a thin paddle bar, not just wall + // integration, so the loop has to live at the layer that already owns both paddles' state. + const substeps = computeTickSubsteps(state.ball.speed, action.dt, action.board.paddleWidth) + const subDt = action.dt / substeps + + let ball: BallState = state.ball + let bricks: Brick[] = state.bricks + let anyBounced = false + let scoredBy: 1 | 2 | null = null + + for (let i = 0; i < substeps; i++) { + ball = applyPaddleCollision(ball, paddles[1], action.board, tier.increment, tier.max) + // Solo has no second paddle to collide against - the far edge is a wall instead (see + // farEdgeIsWall below). + if (!isSolo) ball = applyPaddleCollision(ball, paddles[2], action.board, tier.increment, tier.max) + + if (bricksActive) { + const hit = applyBrickCollision(ball, bricks, action.board, action.random) + ball = hit.ball + if (hit.hitIndex !== null) { + bricks = bricks.map((b, idx) => (idx === hit.hitIndex ? { ...b, destroyed: true } : b)) + anyBounced = true + } + } + + // Wall-bounce jitter only when bricksActive - see stepPhysics' own doc: classic Pong's + // wall bounces stay exactly as deterministic as they've always been otherwise. + const result = stepPhysics(ball, action.board, subDt, isSolo, bricksActive ? action.random : undefined) + anyBounced = anyBounced || result.bounced + ball = { x: result.x, y: result.y, vx: result.vx, vy: result.vy, speed: result.speed } + // A score is final - stop sub-stepping immediately rather than continuing to integrate a + // ball that's already left play this frame, same discipline AirHockey's own stepPhysics + // substep loop uses for a scored/stopped puck. + if (result.scored !== null) { + scoredBy = result.scored + break + } + } + + // Checked before scoredBy: if the last brick is destroyed in an early substep and the ball + // also gets past paddle 1 in a later substep of the SAME tick, the win takes priority over + // the miss that happened to follow it. + if (isSolo && bricksActive && bricks.every((b) => b.destroyed)) { + return { ...state, phase: 'gameOver', ball, paddles, bricks, bounceCount: anyBounced ? state.bounceCount + 1 : state.bounceCount } + } + + if (scoredBy !== null) { + // At rest, dead center - the actual re-launch happens on the FOLLOWING POINT_SERVE, once + // SERVE_PAUSE_MS has given both players a beat to see the score change (see + // useGameState's own effect below). speed: 0 here, not tier.base - a scored ball isn't + // "in play" again until POINT_SERVE actually serves it. + const nextBall: BallState = { ...centerBall(action.board), vx: 0, vy: 0, speed: 0 } + + if (isSolo) { + // scoredBy is always 2 in solo (farEdgeIsWall means P2's edge never scores) - i.e. a + // miss, not an opponent's point. lastScoredBy stays 1 regardless so POINT_SERVE's + // otherPlayer(lastScoredBy) still resolves to 2, correctly serving away from the player + // and into the field, with no mode-branching needed inside POINT_SERVE itself. + const lives = state.lives - 1 + if (lives <= 0) return { ...state, phase: 'gameOver', ball: nextBall, paddles, bricks, lives, lastScoredBy: 1 } + return { ...state, phase: 'pointScored', ball: nextBall, paddles, bricks, lives, lastScoredBy: 1 } + } + + const scores: [number, number] = [state.scores[0], state.scores[1]] + scores[scoredBy - 1]++ + if (scores[scoredBy - 1] >= scoreToWin) { + return { ...state, phase: 'gameOver', scores, ball: nextBall, paddles, bricks, lastScoredBy: scoredBy } + } + return { ...state, phase: 'pointScored', scores, ball: nextBall, paddles, bricks, lastScoredBy: scoredBy } + } + + return { ...state, ball, paddles, bricks, bounceCount: anyBounced ? state.bounceCount + 1 : state.bounceCount } + } + + // Post-point re-serve - see useGameState's own SERVE_PAUSE_MS timer. Re-centers both + // paddles (real Pong re-serves fresh, no possession/turn logic to carry over) and serves the + // ball toward whoever just conceded (otherPlayer(lastScoredBy) - the scorer doesn't also get + // to receive their own serve). + case 'POINT_SERVE': { + if (state.phase !== 'pointScored') return state + const p1 = centerPaddle(action.board, 1) + const p2 = centerPaddle(action.board, 2) + const velocity = computeServeVelocity(tier.base, otherPlayer(state.lastScoredBy!), action.random) + return { + ...state, + phase: 'live', + ball: { ...centerBall(action.board), vx: velocity.vx, vy: velocity.vy, speed: tier.base }, + paddles: { 1: { ...p1, vx: 0, vy: 0 }, 2: { ...p2, vx: 0, vy: 0 } }, + lastScoredBy: null + } + } + + case 'RESET': + return makeInitialState(action.board, mode, bricksActive) + + case 'RESCALE': + return { + ...state, + ball: rescaleBall(state.ball, action.prevBoard, action.board), + paddles: { 1: rescalePaddleY(state.paddles[1], action.prevBoard, action.board, 1), 2: rescalePaddleY(state.paddles[2], action.prevBoard, action.board, 2) }, + bricks: rescaleBricks(state.bricks, action.prevBoard, action.board) + } + + default: + return state + } + } +} + +export function useGameState(board: BoardLayout, scoreToWin: number, ballSpeedLevel: BallSpeedLevel, mode: GameMode, hasBricks: boolean, paused: boolean) { + // Mirrored every render so the stable useReducer wrapper below always dispatches through the + // latest settings-derived reducer, without needing to recreate useReducer itself (which would + // reset `state` back to its initializer whenever scoreToWin/ballSpeedLevel changed). + const reducerRef = useRef(makeReducer(scoreToWin, ballSpeedLevel, mode, hasBricks)) + // An effect (no dependency array, so it reruns after every render) rather than a bare + // `.current =` assignment in the component body - matching BoxHockey's own identical reducerRef + // in its useGameState.ts: React may render a component more than once for a single commit (e.g. + // under StrictMode or a Suspense retry) without every one of those renders actually reaching the + // device, so a bare render-time write here could mirror in a stale/discarded reducer; an effect + // only fires once per real commit. + useEffect(() => { + reducerRef.current = makeReducer(scoreToWin, ballSpeedLevel, mode, hasBricks) + }) + + const bricksActive = bricksActiveFor(mode, hasBricks) + const [state, dispatch] = useReducer( + // eslint-disable-next-line react-hooks/refs -- see reducerRef's own comment above; this wrapper is only ever invoked from dispatch (an event handler), never during render + (s: GameState, a: Action) => reducerRef.current(s, a), + board, + // Wrapped rather than passed bare: React's lazy-init calls this with exactly one argument + // (the board), but makeInitialState now also needs mode/bricksActive. + (b) => makeInitialState(b, mode, bricksActive) + ) + + const boardRef = useRef(board) + // Mirrored every render so the physics rAF loop below always reads the current board layout + // without needing it in that effect's own deps - an effect (no dependency array) rather than a + // bare render-time assignment, same reasoning as reducerRef's own comment above and matching + // BoxHockey's identical boardRef. + useEffect(() => { + boardRef.current = board + }) + + // Raw, unthrottled paddle target Ys - written every touch sample (TouchInputLayer) or AI + // decision (useAIOpponent) via setPaddleTarget below, WITHOUT dispatching (a dispatch per touch + // sample would re-render well over 60 times a second on some devices). The physics tick reads + // whatever this ref currently holds once per animation frame - the same cadence stepPhysics + // itself runs at - and folds the read into the SAME TICK dispatch that also steps the ball, so a + // player's and the AI's target both flow through this identical commit path (see commitPaddle + // above). Same pattern as AirHockey's own paddleTargetsRef, just bare Ys instead of {x, y} + // points. + const paddleTargetsRef = useRef({ 1: centerPaddle(board, 1).y, 2: centerPaddle(board, 2).y }) + + const setPaddleTarget = useCallback((player: Player, y: number) => { + const clamped = clampPaddleY(y, boardRef.current) + paddleTargetsRef.current = { ...paddleTargetsRef.current, [player]: clamped } + }, []) + + useGameLoop((dt) => dispatch({ type: 'TICK', board: boardRef.current, dt, targets: paddleTargetsRef.current }), { enabled: state.phase === 'live' && !paused, maxDt: 2.5 }) + + // useReducer's lazy init (the `board` argument passed to useReducer above) only ever runs ONCE, + // at this hook's very first mount - but `board` itself can change again at any later point too: + // useSafeAreaInsets/useOrientationState both resolve asynchronously after game.tsx's + // first paint, a live window resize can fire at any time, and rotating the device mid-rally + // genuinely changes which real dimensions feed computeBoardLayout. Rescaling on every board + // change (see rescaleBall/rescalePaddleY above), not just while idle, is what actually keeps the + // ball/paddles' absolute-pixel positions sane across all of those - including the pre-game + // settling window, where it's simply invisible since nothing's moved yet. Mirrors AirHockey's own + // identical effect. + const prevBoardRef = useRef(board) + useEffect(() => { + const prevBoard = prevBoardRef.current + if (board === prevBoard) return + prevBoardRef.current = board + // A degenerate zero-sized prevBoard (web's very first render, before hydration has a real + // window size to measure) can only ever be true right at mount - real gameplay can't have + // happened against a board with 0 width. Re-centering fresh (each point at its OWN correct + // spot) rather than fractionally rescaling from it is what actually matters here: rescaleBall/ + // rescalePaddleY both treat a degenerate prevBoard as "everything was at the 0.5,0.5 fraction" + // (their own last-resort anti-NaN fallback), which would collapse the ball AND BOTH paddles + // onto the same center Y instead of each landing at its own distinct starting position. + if (prevBoard.width <= 0 || prevBoard.height <= 0) { + paddleTargetsRef.current = { 1: centerPaddle(board, 1).y, 2: centerPaddle(board, 2).y } + dispatch({ type: 'RESET', board }) + return + } + paddleTargetsRef.current = { + 1: rescalePaddleY({ x: 0, y: paddleTargetsRef.current[1], vx: 0, vy: 0 }, prevBoard, board, 1).y, + 2: rescalePaddleY({ x: 0, y: paddleTargetsRef.current[2], vx: 0, vy: 0 }, prevBoard, board, 2).y + } + dispatch({ type: 'RESCALE', prevBoard, board }) + }, [board]) + + // Owns the idle->live and pointScored->live pauses itself, rather than pushing timer bookkeeping + // out to game.tsx - this is the game's own internal rhythm, not something the screen layer needs + // to orchestrate. Re-runs (and so restarts the pause from scratch) whenever `paused` toggles, so + // a settings/quit-confirm dialog opened mid-pause doesn't let the ball go live off a stale timer + // that counted time the dialog was covering the board. Mirrors AirHockey's own identical effect. + useEffect(() => { + if (paused) return undefined + if (state.phase === 'idle' || state.phase === 'pointScored') { + const t = setTimeout(() => { + if (state.phase === 'idle') dispatch({ type: 'START' }) + else dispatch({ type: 'POINT_SERVE', board: boardRef.current }) + }, SERVE_PAUSE_MS) + return () => clearTimeout(t) + } + return undefined + }, [state.phase, paused]) + + const resetGame = useCallback((currentBoard: BoardLayout) => { + paddleTargetsRef.current = { 1: centerPaddle(currentBoard, 1).y, 2: centerPaddle(currentBoard, 2).y } + dispatch({ type: 'RESET', board: currentBoard }) + }, []) + + return { state, setPaddleTarget, resetGame } +} diff --git a/src/shared/paddleGame/settings.ts b/src/shared/paddleGame/settings.ts new file mode 100644 index 0000000..d390edc --- /dev/null +++ b/src/shared/paddleGame/settings.ts @@ -0,0 +1,25 @@ +import { SCORE_TO_WIN } from '@/shared/paddleGame/constants/game' +import type { ControlScheme, Settings } from '@/shared/paddleGame/types' +import { createPlayerSettings } from '@/shared/playerSettings' + +// Pong's defaults, minus what the shell keeps pack-wide (seat 2, Lock Orientation, Edge Guard) and +// what the game decides (bricks): what a player who hasn't changed a thing plays with. +export const DEFAULT_SETTINGS: Settings = { + scoreToWin: SCORE_TO_WIN, + aiDifficulty: 'medium', + paddleSize: 'medium', + ballSpeed: 'medium', + // Asymmetric defaults (not both 'mouse') so two seats sharing one desktop's keyboard and mouse + // don't start out colliding. + controlScheme: { 1: 'mouse', 2: 'wasd' }, + tiltControlEnabled: false +} + +// One paddle game's loadout, per player (shared/playerSettings.ts), under its own store keys: the +// match settings seat 1's player last set up (Paddles' paddlesSettings, Breaker's breakerSettings) +// and the controls each player last picked (paddlesControls, breakerControls). Two seats can't +// share the mouse or a key cluster, so when both players saved the same one, seat 2 takes the next +// free one in the loadout's menu order for that match. +export function createPaddleSettings(id: string) { + return createPlayerSettings({ id, defaults: DEFAULT_SETTINGS, distinctControls: ['mouse', 'wasd', 'arrows', 'ijkl', 'numpad'] }) +} diff --git a/src/shared/paddleGame/stats.tsx b/src/shared/paddleGame/stats.tsx new file mode 100644 index 0000000..462d548 --- /dev/null +++ b/src/shared/paddleGame/stats.tsx @@ -0,0 +1,125 @@ +import { getAchievementCatalogRows } from '@tastic/achievements' +import { AchievementCatalogSection, ActivityStatSection, StatRow, StatSection } from '@tastic/hud' +import { useCallback, useMemo } from 'react' + +import { createGameStats, type RecordResult } from '@/shared/gameStats' +import { paddleGameCatalog } from '@/shared/paddleGame/constants/achievements' +import type { ProfileStats, StatsState } from '@/shared/paddleGame/types' +import { applyMatchOutcome, applySoloOutcome, getOverallTotals, getProfileRankings, getProfileStatsView, type MatchContext, type MatchOutcome } from '@/shared/paddleGame/utils/statsEngine' +import { DEFAULT_PROFILE_STATS, DEFAULT_STATS, isValidStats } from '@/shared/paddleGame/utils/statsValidation' +import { PlayerRankingsSection } from '@/shared/PlayerRankings' +import { useProfiles } from '@/shell/hooks/useProfiles' + +const SPEEDS = ['slow', 'medium', 'fast'] as const + +// What a paddle game's play screen records a finished round through. +export interface PaddleGameRecorders { + recordMatchOutcome: (outcome: MatchOutcome, context: MatchContext) => RecordResult + // A solo round (Breaker with seat 2 set to Nobody): seat 1 is the only player. + recordSoloOutcome: (result: { cleared: boolean; bricksDestroyed: number }, profileId?: string) => RecordResult +} + +// One paddle game's stats and achievements, under its own store key (Paddles' paddlesAchievements, +// Breaker's breakerAchievements): the store slice and context (the shared createGameStats), the +// recorders its play screen calls, and its sections on the shared stats screen. Both games save the +// same stats shape; Paddles' solo record and brick count just stay at zero, and only a game with +// bricks shows them or has their achievements. +export function createPaddleGameStats({ key, hasBricks }: { key: string; hasBricks: boolean }) { + const catalog = paddleGameCatalog({ hasBricks }) + const gameStats = createGameStats({ key, defaultStats: DEFAULT_STATS, isValidStats, catalog, getProfileStatsView }) + + function useGameStats() { + const { stats, unlockedAchievements, record, resetAll } = gameStats.useStats() + // One finished match: who sat where (context.profileIds) gives the per-seat unlocks. + const recordMatchOutcome: PaddleGameRecorders['recordMatchOutcome'] = useCallback((outcome, context) => record((prev) => applyMatchOutcome(prev, outcome, context), context.profileIds), [record]) + const recordSoloOutcome: PaddleGameRecorders['recordSoloOutcome'] = useCallback((result, profileId) => record((prev) => applySoloOutcome(prev, result, profileId), profileId ? { 1: profileId } : undefined), [record]) + return { stats, unlockedAchievements, recordMatchOutcome, recordSoloOutcome, resetAll } + } + + // The stats for one profile (a profile id) or every player (null): its own bucket viewed through + // the same StatsState shape every predicate already reads, or the device-wide stats themselves. + function useStatsView(profileId: string | null) { + const { stats } = useGameStats() + const profileBucket = profileId ? (stats.profiles[profileId] ?? DEFAULT_PROFILE_STATS) : null + return { stats, profileBucket, statsView: profileBucket ? getProfileStatsView(profileBucket) : stats } + } + + function StatsSections({ profileId }: { profileId: string | null }) { + const { stats, profileBucket, statsView } = useStatsView(profileId) + const { profiles } = useProfiles() + + const overall = useMemo(() => getOverallTotals(statsView), [statsView]) + const rankings = useMemo(() => getProfileRankings(profiles, stats.profiles), [profiles, stats.profiles]) + const pointDiff = statsView.points.scored - statsView.points.conceded + + return ( + <> + + + + + + + + + + 0 ? `+${pointDiff}` : String(pointDiff)} /> + + + + + + + + + + + {/* Everyone keeps the seat framing (whoever sat where); a selected profile switches to that + profile's own record, where p1Wins+p2Wins is always THIS profile's own win count. */} + + {profileBucket ? ( + <> + + + + + ) : ( + <> + + + + + )} + + + {hasBricks && ( + + + + + + )} + + + {SPEEDS.map((speed) => ( + + ))} + + + {/* A leaderboard across profiles answers a cross-profile question, so it only appears on + Everyone. */} + {profileId === null && } + + + + ) + } + + function AchievementList({ profileId }: { profileId: string | null }) { + const { unlockedAchievements } = useGameStats() + const { stats, statsView } = useStatsView(profileId) + return + } + + return { reducer: gameStats.reducer, actions: gameStats.actions, Provider: gameStats.Provider, useGameStats, StatsSections, AchievementList, catalog } +} diff --git a/src/shared/paddleGame/types.ts b/src/shared/paddleGame/types.ts new file mode 100644 index 0000000..4e2d4d2 --- /dev/null +++ b/src/shared/paddleGame/types.ts @@ -0,0 +1,194 @@ +import { AchievementDefinition as BaseAchievementDefinition, DayStreakState, OutcomeRecord, RoundResult, WinStreakState } from '@tastic/achievements' + +import type { ControlScheme } from '@/shared/controlScheme' +import type { RoundMode } from '@/shell/types' + +export type Player = 1 | 2 + +// A seat's desktop input: the mouse or a key cluster (see the shared controlScheme.ts). +export type { ControlScheme } + +// Dramatically simpler than AirHockey's own phase machine, which needed a 'goalScored' pause for +// puck+paddle re-centering AND a distinct goal-mouth/post/corner geometry underneath it. Pong's +// underlying mechanic is the same shape (score, brief pause, re-serve) so the phase names mirror +// it 1:1, just renamed to match this game's own "point" vocabulary rather than "goal" - see the +// naming-convention note at the top of the design spec this file implements. +export type GamePhase = 'idle' | 'live' | 'pointScored' | 'gameOver' + +// The shell's round mode: seat 2 is the CPU, a second person, or nobody (Breaker's solo round). +export type GameMode = RoundMode + +export type AiDifficulty = 'easy' | 'medium' | 'hard' + +export type PaddleSizeLevel = 'small' | 'medium' | 'large' + +// Ball pace tier - governs BOTH the base serve speed and the per-hit ratchet increment (see +// BALL_SPEED_TIERS in constants/game.ts). Replaces AirHockey's FrictionLevel outright: Pong's +// ball has no deceleration model at all, so there's nothing for a "friction tier" to modulate - +// pace here is entirely a function of how fast the ball starts and how much each strike adds. +export type BallSpeedLevel = 'slow' | 'medium' | 'fast' + +// One paddle game's own settings, under that game's own store key (Paddles' paddlesSettings, +// Breaker's breakerSettings). Seat 2 and the pack-wide settings (Lock Orientation, Edge Guard) are +// the shell's; whether a round has bricks is the game's (Paddles never, Breaker always). +export interface Settings { + scoreToWin: number + aiDifficulty: AiDifficulty + paddleSize: PaddleSizeLevel + ballSpeed: BallSpeedLevel + // Web-only per-seat picker (the loadout's ControlSchemePicker): which physical input drives each + // seat's paddle. 'mouse' keeps TouchInputLayer's drag input only; any key scheme adds + // KeyboardInputLayer's keys on top. + controlScheme: Record + // Opt-in device-tilt paddle steering, only while one person plays (see tiltControlActiveFor in + // utils/gameMode.ts): two people share one device through TouchInputLayer's split touch zones, + // and one tilt reading can't steer two paddles. Adds to TouchInputLayer's drag input, never + // replaces it. Off by default. + tiltControlEnabled: boolean +} + +// A single destructible brick, in the same LOCAL space as BoardLayout. Deliberately its own type +// rather than a generic Obstacle - every obstacle in this game is destructible, so there's no +// non-destructible shape worth factoring out separately (unlike AirHockey/BoxHockey's permanent +// Obstacle, which this otherwise mirrors). +export interface Brick { + x: number + y: number + width: number + height: number + destroyed: boolean +} + +// Single source of truth for court geometry, always expressed in canonical LOCAL space: player 1 +// is always at local-left, player 2 always at local-right, the ball always travels along the +// local X axis - regardless of how the device is physically held. Unlike AirHockey's BoardLayout, +// there is no GoalAxis/rotation field here at all: the rendering layer (game.tsx) is solely +// responsible for rotating this local rectangle onto the real screen via @tastic/split-screen's +// getFixedZoneRotation (see the design spec's board.ts section), so board/physics/AI code never +// needs to branch on real device orientation. +export interface BoardLayout { + x: number + y: number + width: number + height: number + wallThickness: number + ballRadius: number + paddleWidth: number // thickness (short dimension) - a fixed ratio of board height, not tiered + paddleHeight: number // length (long dimension) - tiered by PaddleSizeLevel + // Each player's fixed X (their paddle rect's center-x) in LOCAL space - always paddleX[1] < + // paddleX[2], for the whole match. A Pong paddle never moves in X at all (unlike AirHockey's + // free-roam mallet), so this is computed once per BoardLayout rather than tracked as live state. + paddleX: Record + // A paddle's CENTER travel range in local Y, already inset by paddleHeight/2 from the top/bottom + // walls so the paddle's own edge - not its center - is what actually touches a wall. Spans the + // FULL board height: unlike AirHockey's own-half clamp (a mallet defends one goal), a Pong + // paddle has always covered the entire court vertically, since it's the only thing defending + // its edge. + paddleMinY: number + paddleMaxY: number + centerX: number + centerY: number +} + +export interface BallState { + x: number + y: number + vx: number + vy: number + // Tracked scalar speed - the per-hit ratchet's own source of truth (see applyPaddleCollision in + // utils/physics.ts). Re-projected onto vx/vy on each hit rather than re-derived via hypot() every + // hit, so many ratchets in a row can't accumulate float drift the way repeatedly reconstructing + // it from vx/vy would. + speed: number +} + +// Only y is ever live in practice - x is always board.paddleX[player], fixed for the whole match. +// Kept as a full 2D PaddleState (not narrowed to a bare Y scalar) rather than introduce a second, +// narrower shape: computePaddleVelocity/clampPaddleSpeed copy VERBATIM from AirHockey (see +// utils/physics.ts) and are still genuinely 2D, so PaddleState keeping their existing (x, y, vx, +// vy) signature lets those two functions come across unmodified. vx is simply always 0 in +// practice since x never changes tick to tick. +export interface PaddleState { + x: number + y: number + vx: number + vy: number +} + +export type PaddleStates = Record + +export interface GameState { + phase: GamePhase + scores: [number, number] + ball: BallState + paddles: PaddleStates + lastScoredBy: Player | null + // Bumped on every TICK where stepPhysics reports a wall bounce - an ever-incrementing counter + // (not a plain boolean), same reason AirHockey's identical bounceCount exists: a boolean alone + // can't tell "a NEW bounce just happened" apart from "one already happened a tick ago" across + // renders, which is what a bounce SFX/haptic effect actually needs to key off of. + bounceCount: number + // Always present (empty when bricks aren't active this round) so the TICK substep loop never + // needs a conditional just to skip the brick pass. Lives in GameState, not BoardLayout: unlike + // AirHockey/BoxHockey's permanently-static obstacles, bricks are destructible round-progress + // state, and BoardLayout is recomputed on every resize/rotation - storing destroyed-state there + // would silently regenerate a fully-intact field on a mid-round rotation. Rescaled (not + // regenerated) on RESCALE - see rescaleBricks in utils/board.ts. + bricks: Brick[] + // Meaningful only for 'solo' (0 otherwise) - decremented on a miss, <= 0 ends the round as a loss. + lives: number +} + +// ─── Stats & achievements ─────────────────────────────────────────────────── +export type { AchievementTier, UnlockedAchievementsState } from '@tastic/achievements' +export type AchievementDefinition = BaseAchievementDefinition + +export type MatchRecord = OutcomeRecord + +// The human is always seat 1 in vsCpu mode. A match ends the instant one side reaches scoreToWin, +// so a draw is impossible and `draws` stays 0 - it rides along because @tastic/achievements' +// applyResult maintains the whole quadruple. +export interface VsCpuStats extends OutcomeRecord, WinStreakState { + byDifficulty: Record +} + +// Tallied by SEAT, not by person - so this deliberately isn't an OutcomeRecord. +export interface TwoPlayerStats { + played: number + p1Wins: number + p2Wins: number +} + +// Solo/Brickbreaker has no opponent and no score tuple, so it's neither an OutcomeRecord (vsCpu) +// nor a seat tally (twoPlayer) - its own minimal shape instead. `cleared` is solo's "win": every +// brick destroyed before running out of lives. +export interface SoloStats { + played: number + cleared: number +} + +// Points for and against across every match, both modes. +export interface PointTotals { + scored: number + conceded: number +} + +export interface ProfileStats extends DayStreakState { + vsCpu: VsCpuStats + twoPlayer: TwoPlayerStats + solo: SoloStats + points: PointTotals + shutouts: number + bestWinMargin: number + // Per ball-speed tier - the closest thing Pong has to a difficulty modifier the player picks + // themselves, so "win at every speed" is a meaningful goal. + bySpeed: Record + // Career total, mode-agnostic - bricks can be destroyed in solo OR the vsCpu/twoPlayer Bricks + // toggle, and there's no reason to fragment one counter into two for that. + bricksDestroyed: number +} + +export interface StatsState extends ProfileStats { + profiles: Record + firstGameResult: RoundResult | null +} diff --git a/src/shared/paddleGame/usePaddleLoadout.tsx b/src/shared/paddleGame/usePaddleLoadout.tsx new file mode 100644 index 0000000..7e5cb29 --- /dev/null +++ b/src/shared/paddleGame/usePaddleLoadout.tsx @@ -0,0 +1,97 @@ +import { ControlSchemePicker, type MenuOption, type PopoverHost } from '@tastic/hud' +import { assignSeatScheme } from '@tastic/input' +import { useCallback, useMemo } from 'react' +import { Platform } from 'react-native' + +import { LOADOUT_SHARED_CONTROLS_IDS, LoadoutSharedControls } from '@/shared/paddleGame/components/LoadoutSharedControls' +import { DEFAULT_SETTINGS } from '@/shared/paddleGame/settings' +import type { AiDifficulty, BallSpeedLevel, ControlScheme, PaddleSizeLevel, Settings } from '@/shared/paddleGame/types' +import { tiltControlActiveFor } from '@/shared/paddleGame/utils/gameMode' +import type { GameLoadout, LoadoutOptionsProps, RoundMode, Seat, SeatControlLook } from '@/shell/types' + +const PADDLE_SIZE_OPTIONS: MenuOption[] = [ + { value: 'small', label: 'Small', description: 'Shorter paddle, more precision', icon: 'circle-small' }, + { value: 'medium', label: 'Medium', description: 'Standard size', icon: 'circle-medium' }, + { value: 'large', label: 'Large', description: 'Taller paddle, easier defense', icon: 'circle-outline' } +] + +// The puck has no friction here: its speed tier sets both the serve speed and how much each paddle +// hit adds (see BALL_SPEED_TIERS in constants/game.ts). +const BALL_SPEED_OPTIONS: MenuOption[] = [ + { value: 'slow', label: 'Slow', description: 'Gentle pace, ramps up slowly', icon: 'speedometer-slow' }, + { value: 'medium', label: 'Medium', description: 'Standard pace', icon: 'speedometer-medium' }, + { value: 'fast', label: 'Fast', description: 'Quick pace, ramps up fast', icon: 'speedometer' } +] + +const SCORE_TO_WIN_OPTIONS: MenuOption[] = [3, 5, 7, 10].map((value) => ({ value, label: String(value) })) + +const AI_DIFFICULTY_OPTIONS: MenuOption[] = [ + { value: 'easy', label: 'Easy', icon: 'emoticon-happy-outline' }, + { value: 'medium', label: 'Medium', icon: 'emoticon-neutral-outline' }, + { value: 'hard', label: 'Hard', icon: 'emoticon-devil-outline' } +] + +const TILT_CONTROL_OPTION: MenuOption<'tiltControl'> = { value: 'tiltControl', label: 'Tilt Control', icon: 'screen-rotation' } + +// Icon-less, like the other games' pickers: no icon reads as "WASD" or "IJKL" at a glance. +const CONTROL_SCHEME_OPTIONS: MenuOption[] = [ + { value: 'mouse', label: 'Mouse' }, + { value: 'wasd', label: 'WASD' }, + { value: 'arrows', label: 'Arrows' }, + { value: 'ijkl', label: 'IJKL' }, + { value: 'numpad', label: 'Numpad' } +] + +function pickRandom(options: readonly { value: T }[]): T { + return options[Math.floor(Math.random() * options.length)].value +} + +// A paddle game's part of the shared loadout (shell/screens/LoadoutScreen.tsx), from the game's own +// settings: its match options (paddle size, puck speed, score to win, and tilt steering while one +// person plays on a phone), each seat's controls on desktop web, and the CPU's difficulty. +export function usePaddleLoadout(mode: RoundMode, settings: Settings, setSettings: (patch: Partial) => void): GameLoadout { + const p2IsHuman = mode === 'twoPlayer' + // Tilt steering needs an accelerometer reading, which the web never has. + const offerTilt = Platform.OS !== 'web' && tiltControlActiveFor(mode, true) + + // Covers exactly the match options, plus the CPU's difficulty against the CPU. Per-seat things + // (colors, controls) and tilt steering (a preference, not a match setting) aren't shuffled or reset. + const randomize = useCallback(() => { + setSettings({ + paddleSize: pickRandom(PADDLE_SIZE_OPTIONS), + ballSpeed: pickRandom(BALL_SPEED_OPTIONS), + scoreToWin: pickRandom(SCORE_TO_WIN_OPTIONS), + ...(mode === 'vsCpu' ? { aiDifficulty: pickRandom(AI_DIFFICULTY_OPTIONS) } : {}) + }) + }, [setSettings, mode]) + + const reset = useCallback(() => { + setSettings({ + paddleSize: DEFAULT_SETTINGS.paddleSize, + ballSpeed: DEFAULT_SETTINGS.ballSpeed, + scoreToWin: DEFAULT_SETTINGS.scoreToWin, + ...(mode === 'vsCpu' ? { aiDifficulty: DEFAULT_SETTINGS.aiDifficulty } : {}) + }) + }, [setSettings, mode]) + + const options = useCallback( + ({ host, accentColor, mutedColor, onAccentColor, dark, onRandomize, onReset }: LoadoutOptionsProps) => setSettings({ paddleSize: value })} ballSpeed={settings.ballSpeed} ballSpeedOptions={BALL_SPEED_OPTIONS} onBallSpeedChange={(value) => setSettings({ ballSpeed: value })} scoreToWin={settings.scoreToWin} scoreToWinOptions={SCORE_TO_WIN_OPTIONS} onScoreToWinChange={(value) => setSettings({ scoreToWin: value })} tiltControlEnabled={offerTilt ? settings.tiltControlEnabled : undefined} tiltControlOption={offerTilt ? TILT_CONTROL_OPTION : undefined} onTiltControlEnabledChange={offerTilt ? (value) => setSettings({ tiltControlEnabled: value }) : undefined} onRandomize={onRandomize} onReset={onReset} accentColor={accentColor} mutedColor={mutedColor} onAccentColor={onAccentColor} dark={dark} />, + [settings, setSettings, offerTilt] + ) + + // A person's controls on desktop web: the mouse or a key cluster (ControlSchemePicker is null on + // touch-first devices). Picking the other seat's scheme hands it this seat's old one + // (@tastic/input's assignSeatScheme), so the two seats never share one; against the CPU or alone, + // seat 1 can take any scheme. + const seatControl = useCallback( + (seat: Seat, host: PopoverHost, { seatColor, mutedColor, dark }: SeatControlLook) => { + const other: Seat = seat === 1 ? 2 : 1 + return setSettings({ controlScheme: assignSeatScheme(settings.controlScheme, seat, scheme) })} options={CONTROL_SCHEME_OPTIONS} takenValue={p2IsHuman ? settings.controlScheme[other] : undefined} accentColor={seatColor} mutedColor={mutedColor} dark={dark} /> + }, + [settings.controlScheme, setSettings, p2IsHuman] + ) + + const cpuDifficulty = useMemo(() => ({ value: settings.aiDifficulty, options: AI_DIFFICULTY_OPTIONS, onChange: (value: string) => setSettings({ aiDifficulty: value as AiDifficulty }) }), [settings.aiDifficulty, setSettings]) + + return { roundSettings: settings, options, optionIds: LOADOUT_SHARED_CONTROLS_IDS, randomize, reset, seatControl, cpuDifficulty } +} diff --git a/src/shared/paddleGame/utils/aiOpponent.ts b/src/shared/paddleGame/utils/aiOpponent.ts new file mode 100644 index 0000000..e5f3819 --- /dev/null +++ b/src/shared/paddleGame/utils/aiOpponent.ts @@ -0,0 +1,52 @@ +import { MAX_PADDLE_SPEED } from '@/shared/paddleGame/constants/game' +import { BallState, BoardLayout, PaddleState, Player } from '@/shared/paddleGame/types' +import { clampPaddleY } from '@/shared/paddleGame/utils/board' + +export type AIDifficulty = 'easy' | 'medium' | 'hard' + +export interface AITuning { + // How far ahead (ms) the AI predicts the ball's straight-line Y position - no wall-bounce-aware + // lookahead in v1 (see the design spec), same "watch and adjust" philosophy AirHockey's own AI + // uses rather than committing to a pre-planned trajectory. + interceptLookaheadMs: number + // Random offset (px) applied to the AI's own predicted Y each tick. Narrowed from AirHockey's + // polar (angle + magnitude) jitter to a plain 1D scalar offset - there's only one axis left to + // blur once a paddle can't move in X at all. + aimJitterPx: number + // Fraction of MAX_PADDLE_SPEED the AI's own target-approach rate is capped at - same mechanism + // as AirHockey's identical field: a low-tier AI's target is computed just as promptly as a + // high-tier one's, it just physically can't get there as fast, which alone is what produces + // believable reaction lag with no setTimeout scheduling needed. + maxApproachSpeed: number +} + +const TUNING: Record = { + easy: { interceptLookaheadMs: 80, aimJitterPx: 22, maxApproachSpeed: 0.5 }, + medium: { interceptLookaheadMs: 140, aimJitterPx: 10, maxApproachSpeed: 0.72 }, + hard: { interceptLookaheadMs: 200, aimJitterPx: 3, maxApproachSpeed: 0.92 } +} + +// dt=1 in @tastic/core's useGameLoop means "one nominal 60fps frame" - ball.vx/vy are already +// expressed per that same unit, so predicting interceptLookaheadMs ahead means converting it into +// the same frame-unit before scaling velocity by it. Unchanged from AirHockey. +const MS_PER_FRAME = 1000 / 60 + +// Pure, per-tick decision function - called once per physics tick for the AI's own seat (see +// useAIOpponent) and returns a TARGET POSITION, exactly the shape a human's own drag produces (see +// TouchInputLayer's setPaddleTarget) - preserving AirHockey's own "AI plays through the human's +// input contract" principle. Drops AirHockey's entire isThreat/onOwnHalf/movingTowardOwnGoal/ +// defendHomeBiasWhenFar branch outright: there is no "own half to retreat to" once a paddle sits +// at a fixed local X with nothing behind it to defend by falling back - every ball, regardless of +// which direction it's moving, is simply tracked straight-line, always. +export function computeAIPaddleTarget(ball: BallState, ownPaddle: PaddleState, board: BoardLayout, aiPlayer: Player, difficulty: AIDifficulty, random: () => number = Math.random): { x: number; y: number } { + const tuning = TUNING[difficulty] + const lookaheadFrames = tuning.interceptLookaheadMs / MS_PER_FRAME + let targetY = ball.y + ball.vy * lookaheadFrames + targetY += (random() * 2 - 1) * tuning.aimJitterPx + + const clampedY = clampPaddleY(targetY, board) + const maxStep = tuning.maxApproachSpeed * MAX_PADDLE_SPEED + const dy = clampedY - ownPaddle.y + if (Math.abs(dy) <= maxStep) return { x: board.paddleX[aiPlayer], y: clampedY } + return { x: board.paddleX[aiPlayer], y: ownPaddle.y + Math.sign(dy) * maxStep } +} diff --git a/src/shared/paddleGame/utils/board.ts b/src/shared/paddleGame/utils/board.ts new file mode 100644 index 0000000..0474834 --- /dev/null +++ b/src/shared/paddleGame/utils/board.ts @@ -0,0 +1,145 @@ +import { Platform } from 'react-native' +import { EdgeInsets } from 'react-native-safe-area-context' + +import { BALL_RADIUS_RATIO, BRICK_GAP_RATIO, DESKTOP_WEB_MIN_WIDTH, PADDLE_HEIGHT_RATIOS, PADDLE_INSET_RATIO, PADDLE_WIDTH_RATIO, SHARED_BRICK_COLS, SHARED_BRICK_FIELD_WIDTH_RATIO, SHARED_BRICK_FIELD_Y_MARGIN_RATIO, SHARED_BRICK_ROWS, SOLO_BRICK_COLS, SOLO_BRICK_FIELD_X_END_RATIO, SOLO_BRICK_FIELD_X_START_RATIO, SOLO_BRICK_FIELD_Y_MARGIN_RATIO, SOLO_BRICK_ROWS, TABLE_WIDTH_TO_HEIGHT_RATIO, WALL_THICKNESS } from '@/shared/paddleGame/constants/game' +import { BoardLayout, Brick, PaddleSizeLevel, Player } from '@/shared/paddleGame/types' + +// Mirrors AirHockey's own isDesktopLayout - desktop web is the only context that fixes the board +// to a fixed aspect ratio (TABLE_WIDTH_TO_HEIGHT_RATIO) rather than filling the viewport exactly. +// Native and mobile web fill their container exactly, so the device's own physical edge already +// reads as the boundary with zero gap. +function isDesktopLayout(screenWidth: number): boolean { + return Platform.OS === 'web' && screenWidth >= DESKTOP_WEB_MIN_WIDTH +} + +export function computeViewportSize(screenWidth: number, screenHeight: number, insets: EdgeInsets): { width: number; height: number } { + return { width: screenWidth - insets.left - insets.right, height: screenHeight - insets.top - insets.bottom } +} + +// No goalAxis/rotation parameter at all - unlike AirHockey's computeBoardLayout, board/physics/AI +// stay in canonical local space always (paddles at local-left/local-right, ball travels along +// local X). The rendering layer (game.tsx) is what rotates this local space onto the real screen +// via @tastic/split-screen's getFixedZoneRotation - see the design spec's note below - this +// function never needs to know about real device orientation; game.tsx alone decides which real +// width/height to feed it based on the live orientationMode. +export function computeBoardLayout(screenWidth: number, screenHeight: number, insets: EdgeInsets, paddleSize: PaddleSizeLevel = 'medium'): BoardLayout { + const { width: availableWidth, height: availableHeight } = computeViewportSize(screenWidth, screenHeight, insets) + + let width = availableWidth + let height = availableHeight + if (isDesktopLayout(screenWidth)) { + height = width / TABLE_WIDTH_TO_HEIGHT_RATIO + if (height > availableHeight) { + height = availableHeight + width = height * TABLE_WIDTH_TO_HEIGHT_RATIO + } + } + + const x = insets.left + (availableWidth - width) / 2 + const y = insets.top + (availableHeight - height) / 2 + + const ballRadius = height * BALL_RADIUS_RATIO + const paddleHeight = height * PADDLE_HEIGHT_RATIOS[paddleSize] + const paddleWidth = height * PADDLE_WIDTH_RATIO + const paddleInset = width * PADDLE_INSET_RATIO + + return { + x, + y, + width, + height, + wallThickness: WALL_THICKNESS, + ballRadius, + paddleWidth, + paddleHeight, + paddleX: { 1: x + paddleInset, 2: x + width - paddleInset }, + paddleMinY: y + paddleHeight / 2, + paddleMaxY: y + height - paddleHeight / 2, + centerX: x + width / 2, + centerY: y + height / 2 + } +} + +export function centerBall(board: BoardLayout): { x: number; y: number } { + return { x: board.centerX, y: board.centerY } +} + +export function centerPaddle(board: BoardLayout, player: Player): { x: number; y: number } { + return { x: board.paddleX[player], y: board.centerY } +} + +// The full equivalent of AirHockey's clampToOwnHalf, just narrower: a Pong paddle only ever moves +// on Y (its X is permanently fixed by board.paddleX), and it covers the full court height rather +// than half of it - there's no "own half" concept left once nothing behind the paddle needs +// defending. Shared by TouchInputLayer's setPaddleTarget (a human's live drag) and aiOpponent's +// target selection, same as AirHockey. +export function clampPaddleY(y: number, board: BoardLayout): number { + return Math.min(board.paddleMaxY, Math.max(board.paddleMinY, y)) +} + +// Evenly divides [x0,x1]×[y0,y1] into a cols×rows grid, each cell inset by `gap` (split evenly on +// both sides) so neighboring bricks never touch. x/y are top-left corners, matching @tastic/ +// physics's circleVsRect and ObstacleRect's own rect convention (not center-based, unlike a paddle +// rect - see applyPaddleCollision's own center-to-corner conversion in utils/physics.ts). +function packBrickGrid(x0: number, y0: number, x1: number, y1: number, cols: number, rows: number, gap: number): Omit[] { + const cellWidth = (x1 - x0) / cols + const cellHeight = (y1 - y0) / rows + const width = cellWidth - gap + const height = cellHeight - gap + const bricks: Omit[] = [] + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + bricks.push({ x: x0 + col * cellWidth + gap / 2, y: y0 + row * cellHeight + gap / 2, width, height }) + } + } + return bricks +} + +// Full-court field for solo/Brickbreaker - leaves runway near paddle 1 and a margin before the far +// wall, rather than spanning edge to edge. +export function buildSoloBrickField(board: BoardLayout): Brick[] { + const gap = board.height * BRICK_GAP_RATIO + const x0 = board.x + board.width * SOLO_BRICK_FIELD_X_START_RATIO + const x1 = board.x + board.width * SOLO_BRICK_FIELD_X_END_RATIO + const y0 = board.y + board.height * SOLO_BRICK_FIELD_Y_MARGIN_RATIO + const y1 = board.y + board.height * (1 - SOLO_BRICK_FIELD_Y_MARGIN_RATIO) + return packBrickGrid(x0, y0, x1, y1, SOLO_BRICK_COLS, SOLO_BRICK_ROWS, gap).map((b) => ({ ...b, destroyed: false })) +} + +// Smaller mid-court field for the vsCpu/twoPlayer Bricks toggle, centered on the court. A paddle +// only ever moves in Y at a fixed X (board.paddleX), so a block centered on centerX can never +// overlap either paddle's reachable area regardless of its height. +export function buildSharedBrickField(board: BoardLayout): Brick[] { + const gap = board.height * BRICK_GAP_RATIO + const halfWidth = (board.width * SHARED_BRICK_FIELD_WIDTH_RATIO) / 2 + const x0 = board.centerX - halfWidth + const x1 = board.centerX + halfWidth + const y0 = board.y + board.height * SHARED_BRICK_FIELD_Y_MARGIN_RATIO + const y1 = board.y + board.height * (1 - SHARED_BRICK_FIELD_Y_MARGIN_RATIO) + return packBrickGrid(x0, y0, x1, y1, SHARED_BRICK_COLS, SHARED_BRICK_ROWS, gap).map((b) => ({ ...b, destroyed: false })) +} + +// Board-change remap for bricks - same fractional-remap technique as useGameState.ts's own +// rescaleBall (kept here rather than there since it's pure board-geometry math, no game-state +// concerns). destroyed carries straight through unchanged; only geometry is remapped. +export function rescaleBricks(bricks: Brick[], prevBoard: BoardLayout, board: BoardLayout): Brick[] { + const degenerate = prevBoard.width <= 0 || prevBoard.height <= 0 + const scaleX = degenerate ? 1 : board.width / prevBoard.width + const scaleY = degenerate ? 1 : board.height / prevBoard.height + return bricks.map((b) => { + const fx = degenerate ? 0.5 : (b.x - prevBoard.x) / prevBoard.width + const fy = degenerate ? 0.5 : (b.y - prevBoard.y) / prevBoard.height + return { ...b, x: board.x + fx * board.width, y: board.y + fy * board.height, width: b.width * scaleX, height: b.height * scaleY } + }) +} + +// NOTE on the orientation layer built on top of this (see the plan file + BoxHockey source for +// exact @tastic/split-screen usage): when the live orientationMode is 'sideBySide', local space +// maps directly onto the screen (width=local width, height=local height, screenWidth/screenHeight +// passed straight through). When 'faceToFace', the container hosting the board+TouchInputLayer +// tree is rotated 90°/-90° via getFixedZoneRotation, and computeBoardLayout must be called with +// the SWAPPED dimensions (screenHeight passed as screenWidth and vice versa) so the local rect +// still comes out sized correctly for its own rotated container - mirrors AirHockey's original +// computeLocalContainerSize's short/long swap, just resolved one level up in game.tsx rather than +// inside board.ts itself (board.ts stays orientation-agnostic; game.tsx decides which real +// width/height to feed it based on live orientationMode). diff --git a/src/shared/paddleGame/utils/gameMode.ts b/src/shared/paddleGame/utils/gameMode.ts new file mode 100644 index 0000000..995b655 --- /dev/null +++ b/src/shared/paddleGame/utils/gameMode.ts @@ -0,0 +1,16 @@ +import { GameMode } from '@/shared/paddleGame/types' + +// Whether a round has a brick field: always in solo (only Breaker has one: seat 2 set to Nobody), +// otherwise whatever the game says (Paddles never, Breaker always). Read by the play screen's board +// and state setup. +export function bricksActiveFor(gameMode: GameMode, gameHasBricks: boolean): boolean { + return gameMode === 'solo' || gameHasBricks +} + +// Whether a round's paddle can be steered by tilting the device: never with two people, who share +// one device through TouchInputLayer's split touch zones (one tilt reading can't steer two paddles). +// Read by the play screen (TiltInputLayer, and the orientation lock it forces) and the loadout +// (whether to show the toggle at all). +export function tiltControlActiveFor(gameMode: GameMode, tiltControlEnabled: boolean): boolean { + return gameMode !== 'twoPlayer' && tiltControlEnabled +} diff --git a/src/shared/paddleGame/utils/physics.ts b/src/shared/paddleGame/utils/physics.ts new file mode 100644 index 0000000..a02ef72 --- /dev/null +++ b/src/shared/paddleGame/utils/physics.ts @@ -0,0 +1,202 @@ +import { circleVsRect, reflectOffNormal } from '@tastic/physics' + +import { BOUNCE_JITTER_MAX_DEG, MAX_PADDLE_SPEED, MAX_TICK_SUBSTEPS, PADDLE_COLLISION_RESTITUTION, PADDLE_STEER_FACTOR, PADDLE_TUNNEL_SAFETY_RATIO, SERVE_ANGLE_MAX_DEG, WALL_RESTITUTION } from '@/shared/paddleGame/constants/game' +import { BallState, BoardLayout, Brick, PaddleState, Player } from '@/shared/paddleGame/types' + +export interface PhysicsResult extends BallState { + scored: 1 | 2 | null + bounced: boolean +} + +// Rotates a velocity vector by a small random angle, preserving its magnitude exactly - see +// BOUNCE_JITTER_MAX_DEG's own doc comment for why this exists. Shared by stepPhysics' wall bounces +// and applyBrickCollision below, the two reflective surfaces that need it. +function applyBounceJitter(vx: number, vy: number, random: () => number): { vx: number; vy: number } { + const jitterRad = ((random() * 2 - 1) * BOUNCE_JITTER_MAX_DEG * Math.PI) / 180 + const cos = Math.cos(jitterRad) + const sin = Math.sin(jitterRad) + return { vx: vx * cos - vy * sin, vy: vx * sin + vy * cos } +} + +// Flat top/bottom walls, full width, always solid - no goal-mouth gap in them anywhere, unlike +// AirHockey's own front walls. Left/right edges ARE the score lines: fully clearing either one is +// an immediate point, with no goal-post/corner ambiguity to resolve first. +// +// Single-pass - no internal substepping here at all, unlike AirHockey's own stepPhysics (which +// substeps internally against goalpost/corner geometry). The substep loop that actually matters +// for Pong - not skipping clean over the thin paddle bar - lives in useGameState.ts's TICK handler +// instead (see computeTickSubsteps below), since each substep also needs fresh paddle-collision +// detection against both paddles, not just wall integration. +// +// farEdgeIsWall (solo/Brickbreaker only, default false so every existing caller is byte-identical): +// P2's edge bounces instead of scoring, since solo has no second paddle to concede a point to. +// random (also default-omitted): when provided, a wall/far-wall bounce gets the same anti-stall +// jitter applyBrickCollision applies to a brick hit - see BOUNCE_JITTER_MAX_DEG's own doc. The +// caller (useGameState.ts's TICK case) only ever passes it when bricksActive; classic Pong's own +// wall bounces stay exactly as deterministic as they've always been. +export function stepPhysics(ball: BallState, board: BoardLayout, dt = 1, farEdgeIsWall = false, random?: () => number): PhysicsResult { + const r = board.ballRadius + const { speed } = ball + let { x, y, vx, vy } = ball + x += vx * dt + y += vy * dt + + let bounced = false + if (y - r < board.y) { + y = board.y + r + // Collision.normal's own convention (see @tastic/physics's collision.ts) is outward from the + // solid, into the ball's own free space - for the TOP wall the solid sits above board.y, so + // "outward into free space" points DOWNWARD, (0, 1), not up. + vy = reflectOffNormal({ x: vx, y: vy }, { x: 0, y: 1 }, WALL_RESTITUTION).y + bounced = true + } else if (y + r > board.y + board.height) { + y = board.y + board.height - r + // Same convention, mirrored: the BOTTOM wall's solid sits below board.y + board.height, so + // outward into free space points UPWARD, (0, -1). + vy = reflectOffNormal({ x: vx, y: vy }, { x: 0, y: -1 }, WALL_RESTITUTION).y + bounced = true + } + + if (farEdgeIsWall && x + r > board.x + board.width) { + x = board.x + board.width - r + // Solid sits to the right of this edge, so outward into free space points LEFT, (-1, 0). + vx = reflectOffNormal({ x: vx, y: vy }, { x: -1, y: 0 }, WALL_RESTITUTION).x + bounced = true + } + + if (bounced && random) { + const jittered = applyBounceJitter(vx, vy, random) + vx = jittered.vx + vy = jittered.vy + } + + if (x + r < board.x) return { x, y, vx, vy, speed, scored: 2, bounced } // cleared P1's edge - P2 scores (or a solo miss) + // Unreachable once farEdgeIsWall is true - the block above already bounces the ball back in bounds first. + if (!farEdgeIsWall && x - r > board.x + board.width) return { x, y, vx, vy, speed, scored: 1, bounced } // cleared P2's edge - P1 scores + + return { x, y, vx, vy, speed, scored: null, bounced } +} + +// Ball-vs-paddle: circle (ball) vs. axis-aligned rect (paddle bar), via @tastic/physics's already- +// published circleVsRect + reflectOffNormal - unlike AirHockey's circle-vs-circle mallet model, +// a Pong paddle is a bar, not a disc, so this is the one genuinely new collision shape Pong needs. +// No new primitive was needed in Game-Physics for it. +// +// This is also where the per-hit speed ratchet lives - it's the one unambiguous place a "hit" is +// detected (a wall bounce, by contrast, never ratchets: see WALL_RESTITUTION's own doc comment in +// constants/game.ts for why the two would otherwise fight each other). +export function applyPaddleCollision(ball: BallState, paddle: PaddleState, board: BoardLayout, speedIncrement: number, maxSpeed: number): BallState { + const halfW = board.paddleWidth / 2 + const halfH = board.paddleHeight / 2 + const rect = { x: paddle.x - halfW, y: paddle.y - halfH, width: board.paddleWidth, height: board.paddleHeight } + const col = circleVsRect({ position: { x: ball.x, y: ball.y }, radius: board.ballRadius }, rect) + if (!col.hit) return ball + + const x = ball.x + col.normal.x * col.depth + const y = ball.y + col.normal.y * col.depth + + // Same infinite-mass-vs-finite-mass reference-frame-shift technique AirHockey's own + // applyPaddleCollision uses (see its own doc comment for the full derivation): transform into + // the paddle's own reference frame, reflect the RELATIVE velocity off the contact normal, then + // transform back. The paddle's own vx/vy are never read back out or modified by this function - + // only the live gesture (or the AI) ever moves it. + const relativeVelocity = { x: ball.vx - paddle.vx, y: ball.vy - paddle.vy } + const reflected = reflectOffNormal(relativeVelocity, col.normal, PADDLE_COLLISION_RESTITUTION) + const vx = reflected.x + paddle.vx + let vy = reflected.y + paddle.vy + + // Classic Pong "paddle steering": -1 at the paddle's top edge to +1 at its bottom edge, added on + // top of the geometric reflection. Without this, a paddle held perfectly still reflects the ball + // at vy≈0 off the pure geometric bounce (the ball approaches along the board's long axis, so the + // contact normal is itself nearly horizontal) and a rally can settle into a dead-straight, + // unwinnable line forever. Only ever nudges vy - it never flips vx's sign, so a strike can never + // send the ball back out the side it came in from. + const contactOffset = Math.max(-1, Math.min(1, (y - paddle.y) / halfH)) + vy += contactOffset * Math.abs(vx) * PADDLE_STEER_FACTOR + + // speed is the ratchet's own tracked source of truth (see BallState's doc comment in + // types/index.ts) - bumped by a fixed increment per hit and capped at maxSpeed, then vx/vy are + // re-projected onto it (direction from the just-computed reflection+steer, magnitude from the + // ratchet) rather than left to whatever magnitude the geometric reflection happened to produce. + const nextSpeed = Math.min(maxSpeed, ball.speed + speedIncrement) + const dir = Math.hypot(vx, vy) || 1 + return { x, y, vx: (vx / dir) * nextSpeed, vy: (vy / dir) * nextSpeed, speed: nextSpeed } +} + +// Ball-vs-brick, same circleVsRect + reflectOffNormal idiom AirHockey/BoxHockey use for their own +// static Obstacle arrays - the one difference is bricks are destructible, so this is a plain loop +// (no spatial partitioning, matching the fleet convention) that stops at the FIRST live hit rather +// than resolving every overlapping brick in one call, same discipline applyPaddleCollision uses +// against a single paddle. Deliberately no speed ratchet here (unlike a paddle hit) - a dense brick +// field with a per-hit ratchet would rocket the ball to max speed almost immediately. +// +// random defaults to Math.random so live gameplay is naturally randomized; callers that need +// determinism (tests, or a caller wanting a specific angle) can inject their own. () => 0.5 +// produces exactly zero jitter - see BOUNCE_JITTER_MAX_DEG's own doc for why a non-zero default +// here isn't optional: a perfectly elastic, unsteered bounce can settle into a stable periodic +// orbit inside a partially-cleared pocket and never make further progress. +export function applyBrickCollision(ball: BallState, bricks: Brick[], board: BoardLayout, random: () => number = Math.random): { ball: BallState; hitIndex: number | null } { + for (let i = 0; i < bricks.length; i++) { + const brick = bricks[i] + if (brick.destroyed) continue + const col = circleVsRect({ position: { x: ball.x, y: ball.y }, radius: board.ballRadius }, brick) + if (!col.hit) continue + + const x = ball.x + col.normal.x * col.depth + const y = ball.y + col.normal.y * col.depth + const reflected = reflectOffNormal({ x: ball.vx, y: ball.vy }, col.normal, WALL_RESTITUTION) + // A rotation preserves magnitude exactly, so ball.speed (the ratchet's own source of truth) + // stays accurate with no recomputation - this jitter is a direction-only nudge. + const { vx, vy } = applyBounceJitter(reflected.x, reflected.y, random) + return { ball: { x, y, vx, vy, speed: ball.speed }, hitIndex: i } + } + return { ball, hitIndex: null } +} + +// Serve direction/angle for START and POINT_SERVE. towardPlayer null means "first serve of the +// match, direction is a coin flip"; otherwise the ball travels toward whoever just conceded - real +// Pong (and every other paddle sport) serves to the loser, not the winner, so the player who just +// gave up a point gets the very next chance at the ball rather than having to chase it down. +export function computeServeVelocity(baseSpeed: number, towardPlayer: Player | null, random: () => number = Math.random): { vx: number; vy: number } { + const angleRad = ((random() * 2 - 1) * SERVE_ANGLE_MAX_DEG * Math.PI) / 180 + const goingRight = towardPlayer === null ? random() < 0.5 : towardPlayer === 2 + const dirSign = goingRight ? 1 : -1 + return { vx: Math.cos(angleRad) * baseSpeed * dirSign, vy: Math.sin(angleRad) * baseSpeed } +} + +// Guards against a fast ball skipping clean over the paddle's thin width within a single TICK's +// displacement - a real risk at capped max speeds against a bar only ~2% of board height thick, +// especially under useGameLoop's clamped-but-still-large maxDt (2.5) after a foreground stall (the +// app backgrounded mid-rally, or a slow frame on an older device). Unlike AirHockey's own substep +// loop (internal to stepPhysics, sized off puckRadius against goalpost/corner geometry), this one +// is sized off paddleWidth and is deliberately just a step COUNT - the actual substep loop lives +// in useGameState.ts's TICK handler, since each substep also needs its own fresh paddle-collision +// check, not just wall integration. +export function computeTickSubsteps(speed: number, dt: number, paddleWidth: number): number { + const displacement = speed * dt + const safeDisplacement = paddleWidth * PADDLE_TUNNEL_SAFETY_RATIO + if (displacement <= safeDisplacement) return 1 + return Math.min(MAX_TICK_SUBSTEPS, Math.ceil(displacement / safeDisplacement)) +} + +// Copied VERBATIM from AirHockey's own utils/physics.ts - still 2D, still needed for the smoothed- +// velocity-imparts-momentum feel, even though a Pong paddle's vx component is always 0 in practice +// (its x never changes tick to tick - see PaddleState's doc comment in types/index.ts). +// +// A paddle's velocity, for collision purposes, is derived from how far its committed position +// moved THIS tick divided by dt - not from a gesture's own cumulative translation delta (which is +// relative to touch-down, not per-frame). Called once per player per physics tick (see +// useGameState.ts's TICK handler); the caller is expected to smooth this over a few ticks (see +// PADDLE_VELOCITY_SMOOTHING_FRAMES) before using it, so a single jittery touch sample doesn't read +// as a wild instantaneous velocity. +export function computePaddleVelocity(prevX: number, prevY: number, x: number, y: number, dt: number): { vx: number; vy: number } { + if (dt <= 0) return { vx: 0, vy: 0 } + return { vx: (x - prevX) / dt, vy: (y - prevY) / dt } +} + +export function clampPaddleSpeed(vx: number, vy: number, maxSpeed: number = MAX_PADDLE_SPEED): { vx: number; vy: number } { + const speed = Math.hypot(vx, vy) + if (speed <= maxSpeed || speed === 0) return { vx, vy } + const scale = maxSpeed / speed + return { vx: vx * scale, vy: vy * scale } +} diff --git a/src/shared/paddleGame/utils/seatResultSpots.ts b/src/shared/paddleGame/utils/seatResultSpots.ts new file mode 100644 index 0000000..613c726 --- /dev/null +++ b/src/shared/paddleGame/utils/seatResultSpots.ts @@ -0,0 +1,20 @@ +import type { Player } from '@/shared/paddleGame/types' + +/** One 2 Player seat's Game Over card: the half of the screen that's theirs (the screen as the app lays it out, portrait-locked, so 'top' + * is the device's own top half however it's held) and the angle it's turned to face them. */ +export interface SeatSpot { + rotation: 0 | 90 | 180 | -90 + zone: 'bottom' | 'left' | 'right' | 'top' +} + +/** Where each 2 Player seat's Game Over card goes, from the court's rotation (game.tsx's `rotation`): each player's half is their touch zone + * (TouchInputLayer's zoneA for player 1), and the card faces that half's outer edge, where they sit. A turned court (every 2 Player match on + * a phone, face to face) puts player 1 at the bottom (the top when held upside down) and player 2 across from them; an unturned one (a + * browser window wider than tall) is side by side, player 1 on the left, both upright. A computer screen keeps both upright, since its + * players sit side by side whatever its shape. */ +export function seatResultSpots(courtRotation: 0 | 90 | -90, desktopWeb: boolean): Record { + if (courtRotation === 0) return { 1: { rotation: 0, zone: 'left' }, 2: { rotation: 0, zone: 'right' } } + const p1AtBottom = courtRotation === -90 + if (desktopWeb) return p1AtBottom ? { 1: { rotation: 0, zone: 'bottom' }, 2: { rotation: 0, zone: 'top' } } : { 1: { rotation: 0, zone: 'top' }, 2: { rotation: 0, zone: 'bottom' } } + return p1AtBottom ? { 1: { rotation: 0, zone: 'bottom' }, 2: { rotation: 180, zone: 'top' } } : { 1: { rotation: 180, zone: 'top' }, 2: { rotation: 0, zone: 'bottom' } } +} diff --git a/src/shared/paddleGame/utils/statsEngine.ts b/src/shared/paddleGame/utils/statsEngine.ts new file mode 100644 index 0000000..a6c4a76 --- /dev/null +++ b/src/shared/paddleGame/utils/statsEngine.ts @@ -0,0 +1,145 @@ +import { applyDayPlayed, applyResult, applyResultToBucket, applyWinStreak, countBucketsWithAWin, resultFor, RoundResult } from '@tastic/achievements' +import { Profile } from '@tastic/profile' + +import { AiDifficulty, BallSpeedLevel, GameMode, Player, ProfileStats, SoloStats, StatsState } from '@/shared/paddleGame/types' +import { DEFAULT_PROFILE_STATS } from '@/shared/paddleGame/utils/statsValidation' +import { type ProfileRanking, rankProfiles } from '@/shared/PlayerRankings' + +// One finished match, as the stats layer sees it - built by game.tsx the instant phase flips to +// 'gameOver', entirely from state that's already on screen. +export interface MatchOutcome { + winner: Player + // Final score, seat-indexed: [seat 1, seat 2]. + scores: [number, number] +} + +export interface MatchContext { + gameMode: GameMode + aiDifficulty: AiDifficulty + ballSpeed: BallSpeedLevel + // Seat -> profile id, present only for seats with a saved profile selected. vsCpu's CPU seat + // never has one, so the loop below only ever bumps the human's. + profileIds?: Partial> + // Only meaningful for the vsCpu/twoPlayer Bricks toggle - folded into the same mode-agnostic + // ProfileStats.bricksDestroyed counter solo also feeds (see applySoloOutcome), purely additive + // and otherwise doesn't touch this outcome's win-condition/scores handling at all. + bricksDestroyed?: number +} + +// Pong has no draws - a match ends the instant one side reaches scoreToWin - so this only ever +// returns 'win' or 'loss'. Routed through resultFor so the rule lives in one place. +function seatResult(outcome: MatchOutcome, seat: Player): RoundResult { + return resultFor(outcome.winner, seat) +} + +// `deviceWide` is not cosmetic: twoPlayer.p1Wins/p2Wins mean two different things. Device-wide +// they're a SEAT tally ("how often has seat 1 won"), the only well-defined framing on a shared +// device. In a profile's bucket they're that PERSON's own wins split by the seat they earned them +// from, so p1Wins + p2Wins is always their own win count regardless of where they sat. +function bumpForSeat(prev: ProfileStats, outcome: MatchOutcome, context: MatchContext, seat: Player, now: Date, deviceWide: boolean): ProfileStats { + const result = seatResult(outcome, seat) + const won = result === 'win' + const scored = outcome.scores[seat - 1] + const conceded = outcome.scores[seat === 1 ? 1 : 0] + + const vsCpu = + context.gameMode === 'vsCpu' + ? { + ...applyResult(prev.vsCpu, result), + byDifficulty: applyResultToBucket(prev.vsCpu.byDifficulty, context.aiDifficulty, result) as Record>, + ...applyWinStreak(prev.vsCpu, result) + } + : prev.vsCpu + + const twoPlayer = + context.gameMode === 'vsCpu' + ? prev.twoPlayer + : { + played: prev.twoPlayer.played + 1, + p1Wins: prev.twoPlayer.p1Wins + (deviceWide ? (outcome.winner === 1 ? 1 : 0) : seat === 1 && won ? 1 : 0), + p2Wins: prev.twoPlayer.p2Wins + (deviceWide ? (outcome.winner === 2 ? 1 : 0) : seat === 2 && won ? 1 : 0) + } + + return { + vsCpu, + twoPlayer, + // Untouched by a two-paddle match outcome - only applySoloOutcome's own bumpSoloForSeat below + // ever changes this bucket. + solo: prev.solo, + points: { scored: prev.points.scored + scored, conceded: prev.points.conceded + conceded }, + shutouts: prev.shutouts + (won && conceded === 0 ? 1 : 0), + bestWinMargin: won ? Math.max(prev.bestWinMargin, scored - conceded) : prev.bestWinMargin, + bySpeed: applyResultToBucket(prev.bySpeed, context.ballSpeed, result) as Record>, + bricksDestroyed: prev.bricksDestroyed + (context.bricksDestroyed ?? 0), + ...applyDayPlayed(prev, now) + } +} + +// Solo's own bump, parallel to bumpForSeat above rather than routed through it - solo has no +// opponent/score-tuple shape (MatchOutcome/MatchContext) to fit into, just a cleared/bricksDestroyed +// result. +function bumpSoloForSeat(prev: ProfileStats, result: { cleared: boolean; bricksDestroyed: number }, now: Date): ProfileStats { + const solo: SoloStats = { played: prev.solo.played + 1, cleared: prev.solo.cleared + (result.cleared ? 1 : 0) } + return { ...prev, solo, bricksDestroyed: prev.bricksDestroyed + result.bricksDestroyed, ...applyDayPlayed(prev, now) } +} + +// `now` defaults to the real clock - overridable so tests can drive day boundaries directly. +export function applyMatchOutcome(prev: StatsState, outcome: MatchOutcome, context: MatchContext, now: Date = new Date()): StatsState { + // + prev.solo.played: a device's very first game ever being a SOLO round must still count as + // "first ever" here, or a later non-solo game would wrongly re-treat itself as the debut (see + // applySoloOutcome's own identical extension, the other direction). + const isFirstGameEver = prev.vsCpu.played + prev.twoPlayer.played + prev.solo.played === 0 + const firstGameResult = isFirstGameEver ? seatResult(outcome, 1) : prev.firstGameResult + const base = bumpForSeat(prev, outcome, context, 1, now, true) + + let profiles = prev.profiles + if (context.profileIds) { + for (const seat of [1, 2] as Player[]) { + const profileId = context.profileIds[seat] + if (!profileId) continue + profiles = { ...profiles, [profileId]: bumpForSeat(profiles[profileId] ?? DEFAULT_PROFILE_STATS, outcome, context, seat, now, false) } + } + } + + return { ...base, profiles, firstGameResult } +} + +// Solo's own outcome recorder, parallel to applyMatchOutcome above rather than routed through it - +// see bumpSoloForSeat's own doc for why. profileId is singular (not a per-seat map) since solo only +// ever has the one human seat. +export function applySoloOutcome(prev: StatsState, result: { cleared: boolean; bricksDestroyed: number }, profileId: string | undefined, now: Date = new Date()): StatsState { + const isFirstGameEver = prev.vsCpu.played + prev.twoPlayer.played + prev.solo.played === 0 + const firstGameResult = isFirstGameEver ? (result.cleared ? 'win' : 'loss') : prev.firstGameResult + const base = bumpSoloForSeat(prev, result, now) + + let profiles = prev.profiles + if (profileId) profiles = { ...profiles, [profileId]: bumpSoloForSeat(profiles[profileId] ?? DEFAULT_PROFILE_STATS, result, now) } + + return { ...base, profiles, firstGameResult } +} + +export function getOverallTotals(stats: ProfileStats): { played: number; wins: number; losses: number } { + const twoPlayerWins = stats.twoPlayer.p1Wins + stats.twoPlayer.p2Wins + return { + played: stats.vsCpu.played + stats.twoPlayer.played + stats.solo.played, + wins: stats.vsCpu.wins + twoPlayerWins + stats.solo.cleared, + losses: stats.vsCpu.losses + (stats.twoPlayer.played - twoPlayerWins) + (stats.solo.played - stats.solo.cleared) + } +} + +// Synthesizes a full StatsState-shaped view from one profile's bucket, so the same predicates +// evaluate it unchanged. firstGameResult is never read through this view - the one achievement +// that touches it is scope:'device'. +export function getProfileStatsView(profileStats: ProfileStats): StatsState { + return { ...profileStats, profiles: {}, firstGameResult: null } +} + +// How many of the three puck-speed tiers have at least one win - the "win at every speed" family. +export function getSpeedsWon(stats: ProfileStats): number { + return countBucketsWithAWin(stats.bySpeed) +} + +// Every saved profile's record in this game, most wins first (see the shared rankProfiles). +export function getProfileRankings(profiles: Profile[], profileStats: Record): ProfileRanking[] { + return rankProfiles(profiles, (profileId) => getOverallTotals(profileStats[profileId] ?? DEFAULT_PROFILE_STATS)) +} diff --git a/src/shared/paddleGame/utils/statsValidation.ts b/src/shared/paddleGame/utils/statsValidation.ts new file mode 100644 index 0000000..96ac642 --- /dev/null +++ b/src/shared/paddleGame/utils/statsValidation.ts @@ -0,0 +1,123 @@ +import { DEFAULT_DAY_STREAK, DEFAULT_OUTCOME_RECORD, DEFAULT_WIN_STREAK } from '@tastic/achievements' + +import { AiDifficulty, BallSpeedLevel, MatchRecord, PointTotals, ProfileStats, SoloStats, StatsState, TwoPlayerStats, VsCpuStats } from '@/shared/paddleGame/types' + +// Kept out of hooks/useGameStats.tsx so it's testable without dragging in +// @react-native-async-storage/async-storage, which throws at import time under Jest's plain Node +// environment. + +// Each bucket is built by its own factory function, not a plain module constant - DEFAULT_PROFILE_STATS +// and DEFAULT_STATS below each need their own independent objects, not two references to the same +// one (every consumer only ever spreads/reads these today, but a shared reference is a standing +// invitation for a future in-place mutation to corrupt both at once - see DEFAULT_STATS' own doc). +function createDefaultVsCpu(): VsCpuStats { + return { + ...DEFAULT_OUTCOME_RECORD, + byDifficulty: { + easy: { ...DEFAULT_OUTCOME_RECORD }, + medium: { ...DEFAULT_OUTCOME_RECORD }, + hard: { ...DEFAULT_OUTCOME_RECORD } + }, + ...DEFAULT_WIN_STREAK + } +} + +function createDefaultTwoPlayer(): TwoPlayerStats { + return { played: 0, p1Wins: 0, p2Wins: 0 } +} + +function createDefaultSolo(): SoloStats { + return { played: 0, cleared: 0 } +} + +function createDefaultPoints(): PointTotals { + return { scored: 0, conceded: 0 } +} + +function createDefaultBySpeed(): Record { + return { + slow: { ...DEFAULT_OUTCOME_RECORD }, + medium: { ...DEFAULT_OUTCOME_RECORD }, + fast: { ...DEFAULT_OUTCOME_RECORD } + } +} + +// A fresh profile's starting bucket - same zeroed vsCpu/twoPlayer/points/bySpeed shape as +// DEFAULT_STATS' own top-level fields, since ProfileStats mirrors StatsState's decomposable parts +// (see types/index.ts). +export const DEFAULT_PROFILE_STATS: ProfileStats = { + vsCpu: createDefaultVsCpu(), + twoPlayer: createDefaultTwoPlayer(), + solo: createDefaultSolo(), + points: createDefaultPoints(), + shutouts: 0, + bestWinMargin: 0, + bySpeed: createDefaultBySpeed(), + bricksDestroyed: 0, + ...DEFAULT_DAY_STREAK +} + +// vsCpu/twoPlayer/solo/points/bySpeed are re-created here rather than spread straight from +// DEFAULT_PROFILE_STATS, specifically so the two don't end up aliasing the same nested objects - +// see createDefaultVsCpu's own doc above. shutouts/bestWinMargin/bricksDestroyed/the +// DEFAULT_DAY_STREAK fields are plain numbers/strings, so aliasing them via the spread below is +// harmless. +export const DEFAULT_STATS: StatsState = { + ...DEFAULT_PROFILE_STATS, + vsCpu: createDefaultVsCpu(), + twoPlayer: createDefaultTwoPlayer(), + solo: createDefaultSolo(), + points: createDefaultPoints(), + bySpeed: createDefaultBySpeed(), + profiles: {}, + firstGameResult: null +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function isValidMatchRecord(value: unknown): value is MatchRecord { + if (!isRecord(value)) return false + return typeof value.played === 'number' && typeof value.wins === 'number' && typeof value.losses === 'number' && typeof value.draws === 'number' +} + +function isValidVsCpu(value: unknown): value is VsCpuStats { + if (!isValidMatchRecord(value)) return false + const v = value as unknown as Partial + const byDifficulty = v.byDifficulty as Partial> | undefined + return typeof v.currentWinStreak === 'number' && typeof v.bestWinStreak === 'number' && isRecord(byDifficulty) && isValidMatchRecord(byDifficulty.easy) && isValidMatchRecord(byDifficulty.medium) && isValidMatchRecord(byDifficulty.hard) +} + +function isValidTwoPlayer(value: unknown): value is TwoPlayerStats { + if (!isRecord(value)) return false + return typeof value.played === 'number' && typeof value.p1Wins === 'number' && typeof value.p2Wins === 'number' +} + +function isValidSolo(value: unknown): value is SoloStats { + if (!isRecord(value)) return false + return typeof value.played === 'number' && typeof value.cleared === 'number' +} + +function isValidPoints(value: unknown): value is PointTotals { + if (!isRecord(value)) return false + return typeof value.scored === 'number' && typeof value.conceded === 'number' +} + +function isValidBySpeed(value: unknown): boolean { + if (!isRecord(value)) return false + return isValidMatchRecord(value.slow) && isValidMatchRecord(value.medium) && isValidMatchRecord(value.fast) +} + +function isValidProfileStats(value: unknown): value is ProfileStats { + if (!isRecord(value)) return false + const v = value as Partial + return isValidVsCpu(v.vsCpu) && isValidTwoPlayer(v.twoPlayer) && isValidSolo(v.solo) && isValidPoints(v.points) && typeof v.shutouts === 'number' && typeof v.bestWinMargin === 'number' && isValidBySpeed(v.bySpeed) && typeof v.bricksDestroyed === 'number' && typeof v.distinctDaysPlayed === 'number' && typeof v.currentDayStreak === 'number' && typeof v.bestDayStreak === 'number' && (v.lastPlayedDate === null || typeof v.lastPlayedDate === 'string') +} + +export function isValidStats(value: unknown): value is StatsState { + if (!isValidProfileStats(value)) return false + const v = value as Partial + const profilesOk = v.profiles === undefined || (isRecord(v.profiles) && Object.values(v.profiles).every(isValidProfileStats)) + return profilesOk && (v.firstGameResult === null || v.firstGameResult === 'win' || v.firstGameResult === 'loss' || v.firstGameResult === 'draw') +} diff --git a/src/shared/paddleGame/utils/tiltInput.ts b/src/shared/paddleGame/utils/tiltInput.ts new file mode 100644 index 0000000..a8bdd21 --- /dev/null +++ b/src/shared/paddleGame/utils/tiltInput.ts @@ -0,0 +1,65 @@ +import { TILT_DEAD_ZONE_DEG, TILT_MAX_DEG } from '@/shared/paddleGame/constants/game' + +// expo-sensors' DeviceMotionMeasurement.rotation carries no unit annotation in its own type (unlike +// rotationRate, which is explicitly documented as degrees/s) - confirmed directly against the +// native source that beta/gamma are RADIANS on both platforms: iOS's DeviceMotionModule.swift +// passes CMAttitude.pitch/.roll straight through with no conversion (its own rotationRate sibling +// explicitly calls radiansToDegrees, rotation does not), and Android's DeviceMotionModule.kt feeds +// SensorManager.getOrientation()'s raw radian output through unconverted the same way. Converted to +// degrees here (not at the sensor-read call site) so TILT_DEAD_ZONE_DEG/TILT_MAX_DEG and this +// module's own tests can stay in the more intuitive unit. +const RAD_TO_DEG = 180 / Math.PI + +// Captured once per calibration point (every fresh 'live' entry, i.e. every serve, plus resuming +// from a paused settings dialog - see TiltInputLayer's own doc for why). beta/gamma are stored RAW +// (radians, as read from the sensor) - only ever used as a subtraction baseline, so there's no +// reason to convert them until a delta is actually computed. paddleY is the paddle's CURRENT +// position at the moment of calibration, not board center - a calibrate-then-steer-by-delta scheme +// has to start exactly where the paddle already is, or calibrating would visibly snap it to center +// on every single serve. +export interface TiltBaseline { + beta: number + gamma: number + paddleY: number +} + +export function calibrateTiltBaseline(beta: number, gamma: number, paddleY: number): TiltBaseline { + return { beta, gamma, paddleY } +} + +// AXIS MAPPING - reasoned by direct structural symmetry with TouchInputLayer's own screenToLocalY, +// NOT independently derived or device-verified. For rotation === 0 (sideBySide), local Y already +// maps onto real screen Y for touch (screenToLocalY's own `dy` branch) - the tilt equivalent of +// "forward/back" motion is `beta` (pitch), so beta drives Y directly. For rotation === ±90 +// (faceToFace), local Y maps onto real screen X for touch (screenToLocalY's own `dx` branch, +// negated for 90 and not for -90) - the tilt equivalent of "sideways" motion is `gamma` (roll, the +// "steering wheel" tilt), with the identical negation pattern. Isolated into this one small +// function specifically so a wrong guess (this DOES need spot-checking on a real device or +// simulator - see game.tsx's own board-rotation-polarity comment for the exact same class of +// caveat) is a one-line fix, not scattered logic. +function tiltDeltaForRotation(deltaBetaDeg: number, deltaGammaDeg: number, rotation: 0 | 90 | -90): number { + if (rotation === 90) return -deltaGammaDeg + if (rotation === -90) return deltaGammaDeg + return deltaBetaDeg +} + +// beta/gamma: the CURRENT DeviceMotionMeasurement.rotation reading (radians - see RAD_TO_DEG's own +// doc above). paddleMinY/paddleMaxY: this player's travel range (BoardLayout's own fields). +// Internally clamped to that range - defense in depth mirroring utils/board.ts's own clampPaddleY, +// even though setPaddleTarget clamps again downstream. +export function computeTiltTargetY(beta: number, gamma: number, baseline: TiltBaseline, rotation: 0 | 90 | -90, paddleMinY: number, paddleMaxY: number): number { + const deltaBetaDeg = (beta - baseline.beta) * RAD_TO_DEG + const deltaGammaDeg = (gamma - baseline.gamma) * RAD_TO_DEG + const raw = tiltDeltaForRotation(deltaBetaDeg, deltaGammaDeg, rotation) + + const magnitude = Math.abs(raw) + const deadZoned = magnitude <= TILT_DEAD_ZONE_DEG ? 0 : Math.sign(raw) * (magnitude - TILT_DEAD_ZONE_DEG) + + const usableDeg = TILT_MAX_DEG - TILT_DEAD_ZONE_DEG + const fraction = Math.max(-1, Math.min(1, deadZoned / usableDeg)) + + const travelRadius = (paddleMaxY - paddleMinY) / 2 + const y = baseline.paddleY + fraction * travelRadius + + return Math.min(paddleMaxY, Math.max(paddleMinY, y)) +} diff --git a/src/shared/playerSettings.ts b/src/shared/playerSettings.ts new file mode 100644 index 0000000..f24017f --- /dev/null +++ b/src/shared/playerSettings.ts @@ -0,0 +1,112 @@ +import { useCallback, useMemo } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import type { UnknownAction } from 'redux' + +import { onProfileRemoved } from '@/shell/redux/onProfileRemoved' +import type { ShellState } from '@/shell/redux/shellState' +import type { Seat } from '@/shell/types' + +const SEATS: readonly Seat[] = [1, 2] + +// Who a seat's saved settings belong to: the profile picked for it, or that seat's guest. Seat 1's +// and seat 2's guests are different people, so each keeps its own. +export function seatPlayerKey(selection: Record, seat: Seat): string { + return selection[seat] ?? `guest:${seat}` +} + +// A game's settings with each seat's controls (Box Hockey's shot style, the desktop keys elsewhere). +type WithControls = { controlScheme: Record } + +// Saved per player: the match settings each player last set up as seat 1 (only what they changed; +// the rest comes from the game's defaults, so a setting added later starts at its default), and +// the controls each player last played with, in either seat. +export type PlayerSettingsState = Record>> +export type PlayerControlsState = Record + +interface PlayerSettingsOptions, C> { + // The game's id: its store keys are `${id}Settings` and `${id}Controls`. + id: string + defaults: S + // When the two seats can't share one control (a desktop key cluster), every control in menu order: + // if both seats would get the same one, seat 2 takes the first one seat 1 isn't using (shown and + // played, never saved). Leave out where both can use the same (Box Hockey's shot styles). + distinctControls?: readonly C[] +} + +// One game's loadout, kept per player (Jay, 2026-10-24): the match settings follow seat 1's player, +// so each person's own setup loads when they're picked; each seat's controls follow the player in +// that seat. A seat's color pick is the shell's (seatColors, by profile). Seat 2 itself (the CPU, a +// person or nobody) is pack-wide, and so is everything in appSettings. +export function createPlayerSettings, C>({ id, defaults, distinctControls }: PlayerSettingsOptions) { + const settingsKey = `${id}Settings` + const controlsKey = `${id}Controls` + const PATCH = `${settingsKey}/patch` + const SET_CONTROL = `${controlsKey}/set` + + const patch = (player: string, changes: Partial>) => ({ type: PATCH, payload: { player, changes } }) + const setControl = (player: string, control: C) => ({ type: SET_CONTROL, payload: { player, control } }) + + function forget(state: Record, profileId: string): Record { + if (!(profileId in state)) return state + const { [profileId]: _removed, ...rest } = state + return rest + } + + const settingsReducer = onProfileRemoved((state: PlayerSettingsState = {}, action: UnknownAction): PlayerSettingsState => { + if (action.type !== PATCH) return state + const { player, changes } = action.payload as ReturnType['payload'] + return { ...state, [player]: { ...state[player], ...changes } } + }, forget) + + const controlsReducer = onProfileRemoved((state: PlayerControlsState = {}, action: UnknownAction): PlayerControlsState => { + if (action.type !== SET_CONTROL) return state + const { player, control } = action.payload as ReturnType['payload'] + return state[player] === control ? state : { ...state, [player]: control } + }, forget) + + // The settings the loadout shows and a round plays with, from the saved state: seat 1's player's + // own setup over the defaults, and each seat's player's controls (seat 2's only for a person). + function compose(saved: PlayerSettingsState | undefined, controls: PlayerControlsState | undefined, selection: Record, seatTwoIsPerson: boolean): S { + const control1 = controls?.[seatPlayerKey(selection, 1)] ?? defaults.controlScheme[1] + let control2 = (seatTwoIsPerson ? controls?.[seatPlayerKey(selection, 2)] : undefined) ?? defaults.controlScheme[2] + if (distinctControls && control2 === control1) control2 = distinctControls.find((c) => c !== control1) ?? control2 + return { ...defaults, ...saved?.[seatPlayerKey(selection, 1)], controlScheme: { 1: control1, 2: control2 } } as S + } + + // The same, read straight from the store (a game's How to Play reads it this way). + function select(state: ShellState): S { + const root = state as unknown as Record + return compose(root[settingsKey] as PlayerSettingsState | undefined, root[controlsKey] as PlayerControlsState | undefined, state.profileSelection, state.opponent.seatTwo === 'person') + } + + // The loadout's settings and their one write path. A patch's match settings go to seat 1's player; + // a changed control goes to that seat's player (seat 2's only for a person), so a swap of desktop + // keys between the seats saves both players' new keys. + function useSettings(): { settings: S; setSettings: (patch: Partial) => void } { + const selection = useSelector((state: ShellState) => state.profileSelection) + const seatTwoIsPerson = useSelector((state: ShellState) => state.opponent.seatTwo === 'person') + const saved = useSelector((state: Record) => state[settingsKey] as PlayerSettingsState | undefined) + const controls = useSelector((state: Record) => state[controlsKey] as PlayerControlsState | undefined) + const dispatch = useDispatch() + + const settings = useMemo(() => compose(saved, controls, selection, seatTwoIsPerson), [saved, controls, selection, seatTwoIsPerson]) + + const setSettings = useCallback( + (next: Partial) => { + const { controlScheme, ...changes } = next + if (Object.keys(changes).length > 0) dispatch(patch(seatPlayerKey(selection, 1), changes as Partial>)) + if (!controlScheme) return + for (const seat of SEATS) { + if (seat === 2 && !seatTwoIsPerson) continue + if (controlScheme[seat] !== settings.controlScheme[seat]) dispatch(setControl(seatPlayerKey(selection, seat), controlScheme[seat])) + } + }, + [dispatch, selection, seatTwoIsPerson, settings.controlScheme] + ) + + return { settings, setSettings } + } + + // `reducers` is what a game mounts (its GameDefinition's reducers); the two are also named for tests. + return { reducers: { [settingsKey]: settingsReducer, [controlsKey]: controlsReducer }, settingsReducer, controlsReducer, actions: { patch, setControl }, compose, select, useSettings } +} diff --git a/src/shared/useGameSound.ts b/src/shared/useGameSound.ts new file mode 100644 index 0000000..93f6573 --- /dev/null +++ b/src/shared/useGameSound.ts @@ -0,0 +1,4 @@ +// Gameplay sounds (bounce, goal, face-off and the like) that play outside the button-press pipeline, +// gated on the player's Sound setting: @rific/feedback-press/audio's useGatedAudioPool, under the +// name every game's play screen uses. Call once per distinct clip. +export { useGatedAudioPool as useGameSound } from '@rific/feedback-press/audio' diff --git a/src/hooks/useUnlockToasts.ts b/src/shared/useUnlockToasts.ts similarity index 96% rename from src/hooks/useUnlockToasts.ts rename to src/shared/useUnlockToasts.ts index 65d63ae..b723ad3 100644 --- a/src/hooks/useUnlockToasts.ts +++ b/src/shared/useUnlockToasts.ts @@ -1,9 +1,7 @@ import { useToast } from '@rific/toaster' -import type { AchievementTier } from '@tastic/achievements' +import { ACHIEVEMENT_TIER_COLORS, type AchievementTier } from '@tastic/achievements' import { useCallback } from 'react' -import { ACHIEVEMENT_TIER_COLORS } from '@/constants/achievements' - // The only fields a toast reads, so this game's own AchievementDefinition fits as is. interface UnlockedAchievement { id: string diff --git a/src/shell/PackContext.tsx b/src/shell/PackContext.tsx new file mode 100644 index 0000000..3c955d7 --- /dev/null +++ b/src/shell/PackContext.tsx @@ -0,0 +1,22 @@ +import { createContext, ReactNode, useContext } from 'react' + +import type { GameDefinition, PackDefinition } from '@/shell/types' + +// The pack the shell is running: handed in once at the root (see RootLayout), so nothing in shell/ +// ever imports pack.ts or a game. Games reach the shell only through this registry. +const PackContext = createContext(null) + +export function PackProvider({ pack, children }: { pack: PackDefinition; children: ReactNode }) { + return {children} +} + +export function usePack(): PackDefinition { + const pack = useContext(PackContext) + if (!pack) throw new Error('usePack must be used within a PackProvider') + return pack +} + +// A game by id, or undefined for an id the pack doesn't have (a stale link). +export function usePackGame(id: string | undefined): GameDefinition | undefined { + return usePack().games.find((g) => g.id === id) +} diff --git a/src/shell/RootLayout.tsx b/src/shell/RootLayout.tsx new file mode 100644 index 0000000..deb6954 --- /dev/null +++ b/src/shell/RootLayout.tsx @@ -0,0 +1,168 @@ +import { useAutoPaperTheme } from '@rific/auto-paper' +import { useHapticSettings } from '@rific/feedback-press' +import { Toaster, ToastProvider } from '@rific/toaster' +import { OrientationProvider, useRotationAwareStatusBar, useThemedRootBackground } from '@tastic/core' +import * as Haptics from 'expo-haptics' +import { Stack } from 'expo-router' +import { DeviceMotion } from 'expo-sensors' +import { ReactNode } from 'react' +import { StyleSheet } from 'react-native' +import { GestureHandlerRootView } from 'react-native-gesture-handler' +import * as RNPaper from 'react-native-paper' +import { SafeAreaProvider } from 'react-native-safe-area-context' +import type { Persistor } from 'redux-persist' + +import { Fonts } from '@/shell/components/Fonts' +import { GuideHost } from '@/shell/components/GuideHost' +import { Providers } from '@/shell/components/Providers' +import { Theme } from '@/shell/components/Theme' +import { UpdateDialog } from '@/shell/components/UpdateDialog' +import { useAppSettings } from '@/shell/hooks/useAppSettings' +import { ProfilesProvider, useProfiles } from '@/shell/hooks/useProfiles' +import { PackProvider } from '@/shell/PackContext' +import type { PackStore } from '@/shell/redux/createPackStore' +import { SplashGate } from '@/shell/splashGate' +import type { GameDefinition, PackDefinition } from '@/shell/types' + +// The pack's root: every provider the shell needs, the pack's games' own providers, and the stack. +// src/app/_layout.tsx hands it the pack (pack.ts) and its store, so nothing in shell/ imports a +// game or the pack itself. + +// Sits below Theme so it can read the live theme. useThemedRootBackground (@tastic/core) keeps both +// the screens' contentStyle and the OS root window's own background on the live theme, which is what +// stops white flashing through the display's rounded corners during a push/pop transition. Passing +// false disables iOS's swipe-back gesture: otherwise it competes with the game's own pan gestures on +// a play screen and wins (confirmed on-device in LightCycles, where it kicked the player back to the title +// screen mid-round instead of turning their cycle). Web has no such OS-level gesture. +// +// useRotationAwareStatusBar (@tastic/core) hides the OS status bar whenever the content is rotated +// off portrait. It does it through screen options because that is the only way left: an app linked +// against the iOS 27 SDK can't set the status bar app-wide, which is all React Native's StatusBar +// (and expo-status-bar) does on iOS, and with @tastic/core's config plugin on, every mounted +// StatusBar logs an error. So nothing in the app mounts one (Theme passes auto-paper's Provider +// statusBar={false}). No `locked` argument: OrientationBridge already freezes the reading it +// follows. A play screen hides the bar for the whole round, whatever the angle (a screen's own +// options win over screenOptions); index is listed first so it stays the stack's first route. +const PLAY_OPTIONS = { statusBarHidden: true } + +function AppStack() { + const { dark } = useAutoPaperTheme() + const rootBackground = useThemedRootBackground(dark, false) + const statusBar = useRotationAwareStatusBar() + return ( + + + + + ) +} + +// Wires the persisted Lock Orientation setting into @tastic/core's OrientationProvider. Its own +// component because RootLayout renders the Redux store (inside Providers) and so can't call +// useAppSettings() itself. Providers' PersistGate holds rendering until the store has rehydrated, so +// lockInitialValue carries the saved lock from the very first render. +// +// freezeWhileLocked makes the lock app-wide: while appSettings.lockOrientation is on, the ONE shared +// orientation reading freezes for every consumer (every screen, every board, hud dialogs, the +// status bar), including anything that mounts after the lock, so no call site ever takes a `locked` +// argument of its own (one would freeze on the pre-sensor default at mount). The Settings dialog +// toggle only writes appSettings.lockOrientation, which reaches the provider as a changed +// lockInitialValue; the provider reports the angle it froze on through onLockChange (`rotation`, +// cleared on unlock), persisted as appSettings.lockedRotation and handed back here so a relaunch reopens +// in the locked orientation instead of portrait. +// +// Mounted once, here, rather than per screen: one app-lifetime sensor subscription is what lets the +// committed orientation survive navigation. deviceMotion is the app's own expo-sensors import, handed +// in because @tastic/core never imports expo-sensors itself (apps without tilt tracking would +// otherwise need it installed just to bundle). +function OrientationBridge({ children }: { children: ReactNode }) { + const { settings, setSettings } = useAppSettings() + return ( + setSettings({ lockOrientation: next.locked, lockedRotation: next.rotation })}> + {children} + + ) +} + +// ProfilesProvider reconciles the local roster with the shared App Group one before `loaded` flips, +// so a screen mounting earlier would read (and could persist) the wrong profiles. This holds +// everything nested inside it, and the splash screen, until that's done, while ProfilesProvider sits +// above it and starts reconciling immediately. Settings and stats need no gate: they live in the +// Redux store, which Providers' PersistGate already holds until rehydrated. +function GatedApp({ children }: { children: ReactNode }) { + const { loaded } = useProfiles() + return ( + + {children} + + ) +} + +// The toaster only gets expo-haptics while the player's Vibrate setting is on, so its haptic ticks +// follow the same setting as every other haptic in the app. Today they can't fire anyway (the only +// controls that tick, the history and clear buttons, are off below), but this keeps it that way if +// either is ever shown. Must render inside Providers, whose FeedbackBridge provides the setting. +function HapticGatedToastProvider({ children }: { children: ReactNode }) { + const { settings } = useHapticSettings() + return ( + + {children} + + ) +} + +// Every game's own root provider (its stats and achievements context), nested in pack order. +function GameProviders({ games, children }: { games: readonly GameDefinition[]; children: ReactNode }) { + return games.reduceRight((inner, game) => {inner}, children) +} + +interface RootLayoutProps { + pack: PackDefinition + store: PackStore + persistor: Persistor +} + +export function RootLayout({ pack, store, persistor }: RootLayoutProps) { + return ( + + + + + + + + + + + + {/* Wraps the stack (rather than sitting beside it like UpdateDialog) + because Home's tiles and the loadout's How to Play row reach it through + context (see GuideHost). Inside the toast provider and OrientationBridge + because the card renders through a Portal and rotates with the shared + reading, and inside the gate so it mounts together with Home rather than + before it. */} + + + + {/* historyButton/clearButton off: there's no toast history to open, and a + control row for it above the toast stack would go nowhere. limit pins the + package's current default (3) so a future toaster release can't change it. */} + + + + + + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + root: { flex: 1 } +}) diff --git a/src/components/Fonts.tsx b/src/shell/components/Fonts.tsx similarity index 97% rename from src/components/Fonts.tsx rename to src/shell/components/Fonts.tsx index f7fcdd8..90f7ca4 100644 --- a/src/components/Fonts.tsx +++ b/src/shell/components/Fonts.tsx @@ -5,7 +5,7 @@ import Head from 'expo-router/head' import { ReactNode } from 'react' import { Platform } from 'react-native' -import { SplashGate } from '@/utils/splashGate' +import { SplashGate } from '@/shell/splashGate' interface Props { children: ReactNode diff --git a/src/shell/components/GuideHost.tsx b/src/shell/components/GuideHost.tsx new file mode 100644 index 0000000..247a1a0 --- /dev/null +++ b/src/shell/components/GuideHost.tsx @@ -0,0 +1,108 @@ +import { useIsTouchPrimaryDevice, useRotation } from '@tastic/core' +import { GuideProvider, useGuide } from '@tastic/hud/guide' +import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { shallowEqual, useDispatch, useSelector, useStore } from 'react-redux' + +import { usePack } from '@/shell/PackContext' +import type { ShellState } from '@/shell/redux/shellState' +import { roundModeFor, type SeatTwo } from '@/shell/types' + +interface GameGuideContextValue { + // Shows a game's How to Play cards if this player hasn't finished or skipped this version yet, + // then runs `next` once they close; runs `next` at once otherwise. Home calls it on a tile tap, so + // a game's cards show the first time it's picked, before its loadout opens. + openFirstTime: (gameId: string, next: () => void) => void + // Shows a game's cards now, seen or not (a tile's rules button, a loadout's How to Play row). + openFor: (gameId: string) => void +} + +const GameGuideContext = createContext(null) + +// The seat-2 choice the cards describe: the one the player last picked, or the CPU in a game that +// has no room for it (a saved 'nobody' in a game without a solo mode). +function seatTwoIn(seatTwo: SeatTwo, choices: readonly SeatTwo[]): SeatTwo { + return choices.includes(seatTwo) ? seatTwo : 'cpu' +} + +interface Props { + children: ReactNode +} + +// One @tastic/hud GuideProvider for the whole pack, showing whichever game's cards were asked for +// last. Each game's cards come from its own registry entry (GameDefinition.guide), built from the +// live settings and seat 2 so a replay after any loadout change describes the next match. Mounted +// around the stack in RootLayout, inside the toast provider and OrientationBridge because the card +// renders through a Portal and turns with the shared reading (`rotation`, the same one Home hands +// its Settings dialog), and inside the profiles gate so it mounts together with Home. +export function GuideHost({ children }: Props) { + const pack = usePack() + const [gameId, setGameId] = useState(pack.games[0].id) + const game = pack.games.find((g) => g.id === gameId) ?? pack.games[0] + + const rotation = useRotation() + const touch = useIsTouchPrimaryDevice() + const seatTwo = useSelector((state: ShellState) => state.opponent.seatTwo) + const input = useSelector((state: ShellState) => game.guide.selectInput(state), shallowEqual) + const seenVersion = useSelector((state: ShellState) => game.guide.selectVersionSeen(state)) + const dispatch = useDispatch() + + const mode = roundModeFor(seatTwoIn(seatTwo, game.seatTwo)) + const steps = useMemo(() => game.guide.getSteps(input, { touch, mode }), [game, input, touch, mode]) + const onSeen = useCallback((version: number) => dispatch(game.guide.markSeen(version)), [dispatch, game]) + + return ( + + {children} + + ) +} + +// Inside GuideProvider, so it can open the dialog and watch it close. +function GameGuideBridge({ onPick, children }: { onPick: (gameId: string) => void; children: ReactNode }) { + const pack = usePack() + const store = useStore() + const { isOpen, open } = useGuide() + const pendingNext = useRef<(() => void) | null>(null) + const wasOpen = useRef(false) + + // When cards opened by openFirstTime close (finished, skipped or dismissed), carry on to where the + // tap was going. + useEffect(() => { + if (wasOpen.current && !isOpen && pendingNext.current) { + const next = pendingNext.current + pendingNext.current = null + next() + } + wasOpen.current = isOpen + }, [isOpen]) + + const openFor = useCallback( + (gameId: string) => { + onPick(gameId) + open() + }, + [onPick, open] + ) + + const openFirstTime = useCallback( + (gameId: string, next: () => void) => { + const game = pack.games.find((g) => g.id === gameId) + if (!game || game.guide.selectVersionSeen(store.getState()) >= game.guide.version) { + next() + return + } + pendingNext.current = next + openFor(gameId) + }, + [pack, store, openFor] + ) + + const value = useMemo(() => ({ openFirstTime, openFor }), [openFirstTime, openFor]) + return {children} +} + +export function useGameGuide(): GameGuideContextValue { + const ctx = useContext(GameGuideContext) + if (!ctx) throw new Error('useGameGuide must be used within a GuideHost') + return ctx +} diff --git a/src/components/HeroTitle.tsx b/src/shell/components/HeroTitle.tsx similarity index 71% rename from src/components/HeroTitle.tsx rename to src/shell/components/HeroTitle.tsx index de61391..b85728e 100644 --- a/src/components/HeroTitle.tsx +++ b/src/shell/components/HeroTitle.tsx @@ -3,9 +3,11 @@ import { useEffect } from 'react' import { StyleSheet, View } from 'react-native' import Animated, { Easing, useAnimatedStyle, useReducedMotion, useSharedValue, withDelay, withRepeat, withSequence, withTiming } from 'react-native-reanimated' -import { MONO_FONT } from '@/constants/fonts' +import { MONO_FONT } from '@/shell/fonts' export interface HeroTitleProps { + // The pack's wordmark, one word per line (see PackDefinition.wordmark). + words: readonly [string, string] color: string // The live theme's p1/p2 colors - whatever the players actually picked (see Theme.tsx), not a // fixed pair - so the goal-line accent beneath the title always matches the real in-game colors. @@ -13,18 +15,7 @@ export interface HeroTitleProps { p2Color: string } -const WORD_TOP = 'BOX' -const WORD_BOTTOM = 'HOCKEY' -const TOTAL_LETTERS = WORD_TOP.length + WORD_BOTTOM.length - -const TOTAL_STAGGER_MS = getStaggeredWordDuration(TOTAL_LETTERS) - -// The goal-line accent (and the puck that drops onto it) stay hidden until the wordmark has mostly -// landed - one "power on" beat once the letters settle, rather than motion competing with letters -// still flying in. -const LINE_DELAY_MS = Math.round(TOTAL_STAGGER_MS * 0.7) const LINE_DURATION_MS = 280 -const PUCK_DELAY_MS = LINE_DELAY_MS + LINE_DURATION_MS + 60 // The puck's vertical position is one continuous chain, not a separate drop-in followed by a // separate idle animation: it eases down from PUCK_DROP_OFFSET to the top of its bounce range, // then keeps ping-ponging between top and bottom forever - same withRepeat idiom Board.tsx uses @@ -35,19 +26,25 @@ const PUCK_DROP_OFFSET = -18 const BOUNCE_DURATION_MS = 550 const BOUNCE_HEIGHT = 8 -// Bespoke hero for this game (as opposed to LightCycles' light-trail wordmark): two staggered +// The pack's hero (as opposed to LightCycles' light-trail wordmark): two staggered // lines of letters, then a goal-line accent - two colored bars sliding in from each side to meet // in the middle, echoing the two goals on the real board - with a small puck dropping onto the // seam between them once the bars land, then bouncing there for as long as the screen exists (see // puckY above) as one continuous motion rather than a drop-in that hands off to a separate idle // loop. The wordmark and bars themselves are a one-time entrance, not part of the loop. -export function HeroTitle({ color, p1Color, p2Color }: HeroTitleProps) { +export function HeroTitle({ words, color, p1Color, p2Color }: HeroTitleProps) { + const [wordTop, wordBottom] = words + // The goal-line accent (and the puck that drops onto it) stay hidden until the wordmark has mostly + // landed - one "power on" beat once the letters settle, rather than motion competing with letters + // still flying in. + const lineDelayMs = Math.round(getStaggeredWordDuration(wordTop.length + wordBottom.length) * 0.7) + const puckDelayMs = lineDelayMs + LINE_DURATION_MS + 60 const reducedMotion = useReducedMotion() const lineProgress = useSharedValue(reducedMotion ? 1 : 0) useEffect(() => { if (reducedMotion) return - lineProgress.value = withDelay(LINE_DELAY_MS, withTiming(1, { duration: LINE_DURATION_MS, easing: Easing.out(Easing.cubic) })) + lineProgress.value = withDelay(lineDelayMs, withTiming(1, { duration: LINE_DURATION_MS, easing: Easing.out(Easing.cubic) })) // eslint-disable-next-line react-hooks/exhaustive-deps }, [reducedMotion]) @@ -58,8 +55,8 @@ export function HeroTitle({ color, p1Color, p2Color }: HeroTitleProps) { const puckY = useSharedValue(reducedMotion ? -BOUNCE_HEIGHT / 2 : PUCK_DROP_OFFSET) useEffect(() => { if (reducedMotion) return - puckProgress.value = withDelay(PUCK_DELAY_MS, withTiming(1, { duration: PUCK_DROP_MS, easing: Easing.out(Easing.cubic) })) - puckY.value = withDelay(PUCK_DELAY_MS, withSequence(withTiming(-BOUNCE_HEIGHT / 2, { duration: PUCK_DROP_MS, easing: Easing.out(Easing.cubic) }), withRepeat(withTiming(BOUNCE_HEIGHT / 2, { duration: BOUNCE_DURATION_MS, easing: Easing.inOut(Easing.sin) }), -1, true))) + puckProgress.value = withDelay(puckDelayMs, withTiming(1, { duration: PUCK_DROP_MS, easing: Easing.out(Easing.cubic) })) + puckY.value = withDelay(puckDelayMs, withSequence(withTiming(-BOUNCE_HEIGHT / 2, { duration: PUCK_DROP_MS, easing: Easing.out(Easing.cubic) }), withRepeat(withTiming(BOUNCE_HEIGHT / 2, { duration: BOUNCE_DURATION_MS, easing: Easing.inOut(Easing.sin) }), -1, true))) // eslint-disable-next-line react-hooks/exhaustive-deps }, [reducedMotion]) @@ -72,8 +69,8 @@ export function HeroTitle({ color, p1Color, p2Color }: HeroTitleProps) { return ( - - + + diff --git a/src/components/Providers.tsx b/src/shell/components/Providers.tsx similarity index 70% rename from src/components/Providers.tsx rename to src/shell/components/Providers.tsx index 32c8857..e340b75 100644 --- a/src/components/Providers.tsx +++ b/src/shell/components/Providers.tsx @@ -6,9 +6,11 @@ import * as ExpoKeepAwake from 'expo-keep-awake' import { ReactNode, useCallback } from 'react' import * as RNPaper from 'react-native-paper' import { Provider as ReduxProvider, useDispatch, useSelector } from 'react-redux' +import type { Persistor } from 'redux-persist' import { PersistGate } from 'redux-persist/integration/react' -import { persistor, type RootState, store } from '@/redux/store' +import type { PackStore } from '@/shell/redux/createPackStore' +import type { ShellState } from '@/shell/redux/shellState' interface Props { children: ReactNode @@ -18,8 +20,8 @@ interface Props { // hapticActions/soundActions - same shape as Expo-Starter/Snake's FeedbackBridge, converged onto // fleet-wide rather than this app's old direct-AsyncStorage version. const FeedbackBridge = ({ children }: Props) => { - const haptic = useSelector((state: RootState) => state.haptic) - const sound = useSelector((state: RootState) => state.sound) + const haptic = useSelector((state: ShellState) => state.haptic) + const sound = useSelector((state: ShellState) => state.sound) const dispatch = useDispatch() const onChange = useCallback((next: HapticSettings) => dispatch(hapticActions.initialize(next)), [dispatch]) const onSoundChange = useCallback((next: SoundSettings) => dispatch(soundActions.initialize(next)), [dispatch]) @@ -27,8 +29,8 @@ const FeedbackBridge = ({ children }: Props) => { // Generic UI feedback sounds, wired into every Button/IconButton/etc. in the package (see // useFeedbackHandlers' `fire`, which already gates these on the sound-enabled setting itself - // no need to re-check it here). - const playSelection = useAudioPool(require('../../assets/sounds/select.wav')) - const playNotification = useAudioPool(require('../../assets/sounds/notification.wav')) + const playSelection = useAudioPool(require('../../../assets/sounds/select.wav')) + const playNotification = useAudioPool(require('../../../assets/sounds/notification.wav')) const bridgeProps = useFeedbackBridgeProps({ initialValue: haptic, onChange, soundInitialValue: sound, onSoundChange, sound: { selection: playSelection, notification: playNotification } }) @@ -40,21 +42,21 @@ const FeedbackBridge = ({ children }: Props) => { } // The one place @tastic/edge-guard's hook is called, mounted once for the app's lifetime rather than -// per screen. settings.deferBottomEdgeGestures is the player's opt-in; liveplay.activelyPlaying is -// /game's report that a live round is in progress right now (see liveplaySlice.ts). Combining them +// per screen. appSettings.deferBottomEdgeGestures is the player's opt-in; liveplay.activelyPlaying is +// a play screen's report that a live round is in progress right now (see liveplaySlice.ts). Combining them // here keeps Edge Guard both opt-in and scoped to actual play. const EdgeGuardBridge = ({ children }: Props) => { - const deferBottomEdgeGestures = useSelector((state: RootState) => state.settings.deferBottomEdgeGestures) - const activelyPlaying = useSelector((state: RootState) => state.liveplay.activelyPlaying) + const deferBottomEdgeGestures = useSelector((state: ShellState) => state.appSettings.deferBottomEdgeGestures) + const activelyPlaying = useSelector((state: ShellState) => state.liveplay.activelyPlaying) useEdgeGestureGuard(deferBottomEdgeGestures && activelyPlaying) return <>{children} } // @rific/core's Keep Awake setting (Settings dialog), persisted as the store's `keepAwake` slice. -// Handing it expo-keep-awake is what turns the feature on: /game's useKeepAwake('game') and hud's +// Handing it expo-keep-awake is what turns the feature on: each play screen's useKeepAwake('game') and hud's // Keep Awake row both read the module from here. const KeepAwakeBridge = ({ children }: Props) => { - const keepAwake = useSelector((state: RootState) => state.keepAwake) + const keepAwake = useSelector((state: ShellState) => state.keepAwake) const dispatch = useDispatch() const onChange = useCallback((settings: KeepAwakeSettings) => dispatch(keepAwakeActions.initialize(settings)), [dispatch]) return ( @@ -64,7 +66,12 @@ const KeepAwakeBridge = ({ children }: Props) => { ) } -export function Providers({ children }: Props) { +interface ProvidersProps extends Props { + store: PackStore + persistor: Persistor +} + +export function Providers({ store, persistor, children }: ProvidersProps) { return ( diff --git a/src/shell/components/SeatTwoPanel.tsx b/src/shell/components/SeatTwoPanel.tsx new file mode 100644 index 0000000..2bbddfe --- /dev/null +++ b/src/shell/components/SeatTwoPanel.tsx @@ -0,0 +1,130 @@ +import type { SeedColor } from '@rific/auto-paper' +import { type MenuOption, type PopoverHost, ReadyButton, useZoneClampedAlign } from '@tastic/hud' +import { getOptionPickerContentSize, OptionPicker, type OptionPickerOption, type Profile, type ProfilePickerColorSection } from '@tastic/profile' +import { ReactNode, useMemo } from 'react' +import { StyleSheet, View } from 'react-native' + +import type { SeatTwo } from '@/shell/types' + +// Seat 2's one button: who sits there (a CPU at a difficulty, nobody where the game has a solo +// mode, a guest, or a profile) and, on top, the seat's Color row. It replaces the choice between +// One Player and Two Player that Home used to ask before the loadout: picking a person here is what +// makes the round 2 Player, and the loadout turns to face both players. +// +// Built on @tastic/profile's OptionPicker, so it opens, places itself and closes like seat 1's +// ProfilePicker beside it; written so it can move into @tastic/hud's PlayerSetupPanel once the pack +// layout's shell moves into the shared packages (see the PuckTastic plan). Laid out like +// PlayerSetupPanel: the seat button, a person's controls picker under it, and their Ready button. + +const CPU_PREFIX = 'cpu:' +const PROFILE_PREFIX = 'profile:' +const NOBODY = 'nobody' +const GUEST = 'guest' + +const GUEST_ICON = 'account-outline' +const NOBODY_ICON = 'account-off-outline' +const CPU_ICON = 'robot' + +export function getSeatTwoPopoverId(idPrefix: string): string { + return `${idPrefix}-seat` +} + +export interface SeatTwoPanelProps { + idPrefix: string + host: PopoverHost + // Seat 2's choices in this game (GameDefinition.seatTwo), in menu order. + choices: readonly SeatTwo[] + seatTwo: SeatTwo + onSeatTwoChange: (seatTwo: SeatTwo) => void + cpuDifficulty: { value: string; options: MenuOption[]; onChange: (value: string) => void } + profiles: Profile[] + selectedProfileId: string | null + // Seat 1's profile: picking it here swaps the two seats (see @tastic/profile's select). + onProfileSelect: (profileId: string | null) => void + color: string + onColorChange: (hex: string) => void + swatches: SeedColor[] + takenColor?: string + allowSwapTaken?: boolean + // Shown under the Color row while a profile is in the seat (its pick is kept for this pack only). + colorCaption?: string + dark: boolean + // A person's controls picker (see GameLoadout.seatControl). + secondPicker?: ReactNode + secondPickerId?: string + ready?: boolean + onToggleReady?: () => void + showReadyButton?: boolean +} + +export function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDifficulty, profiles, selectedProfileId, onProfileSelect, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, dark, secondPicker, secondPickerId, ready, onToggleReady, showReadyButton = true }: SeatTwoPanelProps) { + const isPerson = seatTwo === 'person' + const selectedProfile = isPerson ? (profiles.find((p) => p.id === selectedProfileId) ?? null) : null + + const options = useMemo[]>(() => { + const list: OptionPickerOption[] = [] + if (choices.includes('cpu')) list.push(...cpuDifficulty.options.map((o) => ({ value: `${CPU_PREFIX}${o.value}`, label: o.label, icon: o.icon }))) + if (choices.includes('nobody')) list.push({ value: NOBODY, label: 'Nobody', icon: NOBODY_ICON }) + if (choices.includes('person')) { + list.push({ value: GUEST, label: 'Player 2', icon: GUEST_ICON }) + list.push(...[...profiles].sort((a, b) => a.name.localeCompare(b.name)).map((p) => ({ value: `${PROFILE_PREFIX}${p.id}`, label: p.name, icon: 'account', color: p.color }))) + } + return list + }, [choices, cpuDifficulty.options, profiles]) + + const value = seatTwo === 'cpu' ? `${CPU_PREFIX}${cpuDifficulty.value}` : seatTwo === 'nobody' ? NOBODY : selectedProfile ? `${PROFILE_PREFIX}${selectedProfile.id}` : GUEST + const icon = seatTwo === 'cpu' ? CPU_ICON : seatTwo === 'nobody' ? NOBODY_ICON : selectedProfile ? 'account' : GUEST_ICON + + const handleChange = (next: string) => { + if (next.startsWith(CPU_PREFIX)) { + cpuDifficulty.onChange(next.slice(CPU_PREFIX.length)) + onSeatTwoChange('cpu') + } else if (next === NOBODY) { + onSeatTwoChange('nobody') + } else { + onProfileSelect(next.startsWith(PROFILE_PREFIX) ? next.slice(PROFILE_PREFIX.length) : null) + onSeatTwoChange('person') + } + } + + // Nobody has no color to pick: there's nothing of theirs on the board. + const colorSection: ProfilePickerColorSection | undefined = seatTwo === 'nobody' ? undefined : { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: selectedProfile ? colorCaption : undefined } + + const pickerId = getSeatTwoPopoverId(idPrefix) + const size = getOptionPickerContentSize(options.length, colorSection) + // Kept inside this player's half in 2 Player, like PlayerSetupPanel's own seat button. + const align = useZoneClampedAlign(host.openId === pickerId, size.width, size.height) + + const ownPopoverOpen = host.openId?.startsWith(`${idPrefix}-`) ?? false + const pickerRowOpen = secondPickerId !== undefined && host.openId === secondPickerId + + return ( + + + {isPerson && secondPicker && {secondPicker}} + {isPerson && showReadyButton && onToggleReady && } + + ) +} + +// The same rhythm and stacking as @tastic/hud's PlayerSetupPanel, so seat 2 lines up with seat 1. +const styles = StyleSheet.create({ + panel: { + alignItems: 'center', + gap: 12 + }, + // A popover escaping this panel needs the panel itself elevated to paint above a later sibling. + panelOpen: { + zIndex: 100 + }, + pickerRow: { + alignItems: 'center', + flexDirection: 'row', + gap: 12 + }, + // Lifts the controls picker's popover above the Ready button below it (each View is its own + // stacking context on the web). + pickerRowOpen: { + zIndex: 100 + } +}) diff --git a/src/shell/components/SettingsDialog.tsx b/src/shell/components/SettingsDialog.tsx new file mode 100644 index 0000000..0b7701c --- /dev/null +++ b/src/shell/components/SettingsDialog.tsx @@ -0,0 +1,29 @@ +import { useUpdateErrorToast } from '@rific/toaster' +import { ViewRotation } from '@tastic/core' +import { BaseSettingsDialog } from '@tastic/hud' + +import { release } from '@/constants/release' +import { useAppSettings } from '@/shell/hooks/useAppSettings' + +export interface SettingsDialogProps { + visible: boolean + onDismiss: () => void + // Live physical-hold rotation (see @tastic/core's useRotation). BaseSettingsDialog lays the sheet + // out in its own frame turned by this (see @tastic/hud's RotatedFrame), so it reads right-side-up + // and fits the short edge however the phone is held. Defaults to 0 for a call site that doesn't + // track one. + rotation?: ViewRotation + // Shows the How to Play row, which opens this game's cards (a loadout passes its game's opener). + // Left out on Home, where each tile's rules button does it, and in a match, where opening + // Settings pauses play. + onShowHowToPlay?: () => void +} + +// The pack-wide Settings dialog: everything in it applies to every game (Lock Orientation, Edge +// Guard, theme, sound, haptics, Keep Awake), so it reads and writes the app settings itself. +export function SettingsDialog({ visible, onDismiss, rotation = 0, onShowHowToPlay }: SettingsDialogProps) { + const onUpdateError = useUpdateErrorToast() + const { settings, setSettings } = useAppSettings() + + return setSettings({ lockOrientation: value })} deferBottomEdgeGestures={settings.deferBottomEdgeGestures} onDeferBottomEdgeGestures={(value) => setSettings({ deferBottomEdgeGestures: value })} onUpdateError={onUpdateError} onShowHowToPlay={onShowHowToPlay} /> +} diff --git a/src/components/Theme.tsx b/src/shell/components/Theme.tsx similarity index 95% rename from src/components/Theme.tsx rename to src/shell/components/Theme.tsx index 356e02e..e036e51 100644 --- a/src/components/Theme.tsx +++ b/src/shell/components/Theme.tsx @@ -5,8 +5,8 @@ import * as SplashScreen from 'expo-splash-screen' import { ReactNode, useCallback } from 'react' import { shallowEqual, useDispatch, useSelector } from 'react-redux' -import { type RootState } from '@/redux/store' -import { markSplashReady } from '@/utils/splashGate' +import type { ShellState } from '@/shell/redux/shellState' +import { markSplashReady } from '@/shell/splashGate' SplashScreen.preventAutoHideAsync() // iOS defaults `fade` to false (Android always fades regardless of this flag), so without this the @@ -25,7 +25,7 @@ export function Theme({ children }: Props) { // rehydrated, so by the time this mounts `settings` is already the real persisted (or, on a fresh // install, default) triad - no async gate/loading state needed here the way the old AsyncStorage // version required. - const settings = useSelector((state: RootState) => state.theme, shallowEqual) + const settings = useSelector((state: ShellState) => state.theme, shallowEqual) const dispatch = useDispatch() // A one-shot callback, not a boolean, so there's nothing for useSplashReady to watch - PersistGate diff --git a/src/components/UpdateDialog.tsx b/src/shell/components/UpdateDialog.tsx similarity index 100% rename from src/components/UpdateDialog.tsx rename to src/shell/components/UpdateDialog.tsx diff --git a/src/constants/fonts.ts b/src/shell/fonts.ts similarity index 100% rename from src/constants/fonts.ts rename to src/shell/fonts.ts diff --git a/src/shell/hooks/useActiveRound.ts b/src/shell/hooks/useActiveRound.ts new file mode 100644 index 0000000..f2b59f1 --- /dev/null +++ b/src/shell/hooks/useActiveRound.ts @@ -0,0 +1,13 @@ +import { useSelector } from 'react-redux' + +import type { ShellState } from '@/shell/redux/shellState' +import type { RoundMode } from '@/shell/types' + +// The round a play screen is playing: committed by the loadout when every human player readied up +// (see liveplaySlice.ts). A play screen reached without one (a direct link, a web reload) plays its +// game's defaults against the CPU. +export function useActiveRound(game: string, defaults: S): { mode: RoundMode; settings: S } { + const round = useSelector((state: ShellState) => state.liveplay.activeRound) + if (!round || round.game !== game) return { mode: 'vsCpu', settings: defaults } + return { mode: round.mode, settings: { ...defaults, ...(round.settings as Partial) } } +} diff --git a/src/shell/hooks/useAppSettings.ts b/src/shell/hooks/useAppSettings.ts new file mode 100644 index 0000000..c048c30 --- /dev/null +++ b/src/shell/hooks/useAppSettings.ts @@ -0,0 +1,19 @@ +import { useCallback } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import { type AppSettings, appSettingsActions } from '@/shell/redux/appSettingsSlice' +import type { ShellState } from '@/shell/redux/shellState' + +// The one way the pack-wide settings are read and written (Lock Orientation, Edge Guard). Writes +// merge, so a patch only changes the fields it names. +export function useAppSettings(): { settings: AppSettings; setSettings: (patch: Partial) => void } { + const settings = useSelector((state: ShellState) => state.appSettings) + const dispatch = useDispatch() + const setSettings = useCallback( + (patch: Partial) => { + dispatch(appSettingsActions.initialize(patch)) + }, + [dispatch] + ) + return { settings, setSettings } +} diff --git a/src/shell/hooks/useOpponent.ts b/src/shell/hooks/useOpponent.ts new file mode 100644 index 0000000..520f523 --- /dev/null +++ b/src/shell/hooks/useOpponent.ts @@ -0,0 +1,14 @@ +import { useCallback } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import { opponentActions } from '@/shell/redux/opponentSlice' +import type { ShellState } from '@/shell/redux/shellState' +import type { SeatTwo } from '@/shell/types' + +// Who sits in seat 2 across the pack (see opponentSlice.ts). +export function useOpponent(): { seatTwo: SeatTwo; setSeatTwo: (seatTwo: SeatTwo) => void } { + const seatTwo = useSelector((state: ShellState) => state.opponent.seatTwo) + const dispatch = useDispatch() + const setSeatTwo = useCallback((next: SeatTwo) => dispatch(opponentActions.setSeatTwo(next)), [dispatch]) + return { seatTwo, setSeatTwo } +} diff --git a/src/shell/hooks/useProfiles.tsx b/src/shell/hooks/useProfiles.tsx new file mode 100644 index 0000000..7de8a90 --- /dev/null +++ b/src/shell/hooks/useProfiles.tsx @@ -0,0 +1,134 @@ +import { type CreateProfileInput, createProfileRecord, Profile, profilesActions, resolveInitialProfiles, useSharedProfilesSync } from '@tastic/profile' +import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import type { Action, Dispatch } from 'redux' + +import { profileSelectionActions } from '@/shell/redux/profileSelectionSlice' +import type { ShellState } from '@/shell/redux/shellState' +import type { Seat } from '@/shell/types' + +// Same group id BoxHockey and LightCycles both declare in app.json's ios.entitlements (and any +// future @tastic game that wants into the same shared roster) - see @tastic/profile's own README +// for why only the base identity fields travel through this. +const SHARED_GROUP_ID = 'group.com.infinitetoken.tastic' + +interface ProfilesContextValue { + profiles: Profile[] + lastSelected: Record + // True once the one-time initial reconciliation below (redux-persist's own already-rehydrated + // `profiles` against the shared App Group store) has completed - see ProfilesProvider's own doc. + // RootLayout's GatedApp reads this to hold its children back (see splashGate.ts's + // 'profiles' gate) until real profile data exists, rather than the empty starting state. + loaded: boolean + createProfile: (input: CreateProfileInput) => Profile + updateProfile: (id: string, patch: Partial) => void + // Also clears lastSelected for any seat currently pointing at this id, so /loadout immediately + // reflects Player (no profile) for that seat rather than a dangling id until next reload. + deleteProfile: (id: string) => void + selectProfile: (seat: Seat, profileId: string | null) => void +} + +const ProfilesContext = createContext(null) + +interface Props { + children: ReactNode +} + +// Single source of truth for saved player profiles, mounted once at the root (RootLayout): expo-router +// keeps prior screens mounted, so a per-screen copy could go stale or clobber a concurrent update. +// +// `profiles` lives in Redux (the shell's `profiles` key, @tastic/profile's own profilesReducer), +// persisted locally like every other key, with the shared App Group roster layered on top as a +// separate, cross-app sync target (see resolveInitialProfiles/useSharedProfilesSync below). +// `lastSelected` is profileSelectionSlice.ts: which profile seat 1/2 last picked is a per-app thing, +// never shared cross-app. Only the base identity fields live here; a game that keeps something per +// profile (Box Hockey's control scheme) keeps it in its own slice. +// +// Gating stays external (RootLayout's GatedApp holds the app behind the 'profiles' splash gate +// until `loaded`); this provider just exposes `loaded` and always renders `children` at once. +export function ProfilesProvider({ children }: Props) { + const profiles = useSelector((state: ShellState) => state.profiles) + const lastSelected = useSelector((state: ShellState) => state.profileSelection) + const dispatch = useDispatch>() + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + let cancelled = false + // PersistGate (an ancestor - see components/Providers.tsx) already blocks this component from + // mounting until redux-persist has rehydrated, so `profiles` here is already whatever this + // device last had locally - resolveInitialProfiles reconciles that against the shared App + // Group store (if available), seeding the shared store from it on a first sync, or preferring + // the shared roster once anything has been shared. A one-time reconciliation against the + // mount-time snapshot, deliberately not a resync whenever `profiles` changes afterward (the + // empty deps array below is intentional, not a missed dependency - resolveInitialProfiles' own + // setAll dispatch would otherwise re-trigger this effect). + resolveInitialProfiles(SHARED_GROUP_ID, profiles).then((resolved) => { + if (cancelled) return + dispatch(profilesActions.setAll(resolved)) + // A seat can still point at a profile a sibling app deleted since this device last ran. + dispatch(profileSelectionActions.pruneToRoster(resolved.map((p) => p.id))) + setLoaded(true) + }) + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Mirrors local writes out to the shared store (syncToShared, called from each CRUD function + // below) and picks up remote writes a sibling @tastic app made while this one was backgrounded + // (onRemoteChange) - see @tastic/profile's own doc for both. + const { syncToShared } = useSharedProfilesSync({ + groupId: SHARED_GROUP_ID, + onRemoteChange: useCallback( + (remote: Profile[]) => { + dispatch(profilesActions.setAll(remote)) + // Same job as deleteProfile's clearProfile below, for profiles a sibling app deleted while this one was backgrounded. + dispatch(profileSelectionActions.pruneToRoster(remote.map((p) => p.id))) + }, + [dispatch] + ) + }) + + const createProfile = useCallback( + (input: CreateProfileInput) => { + const record = createProfileRecord(input) + dispatch(profilesActions.add(record)) + syncToShared([...profiles, record]) + return record + }, + [profiles, dispatch, syncToShared] + ) + + const updateProfile = useCallback( + (id: string, patch: Partial) => { + dispatch(profilesActions.update({ id, patch })) + syncToShared(profiles.map((p) => (p.id === id ? { ...p, ...patch, updatedAt: Date.now() } : p))) + }, + [profiles, dispatch, syncToShared] + ) + + const deleteProfile = useCallback( + (id: string) => { + dispatch(profilesActions.remove(id)) + syncToShared(profiles.filter((p) => p.id !== id)) + dispatch(profileSelectionActions.clearProfile(id)) + }, + [profiles, dispatch, syncToShared] + ) + + const selectProfile = useCallback( + (seat: Seat, profileId: string | null) => { + dispatch(profileSelectionActions.select({ profileId, seat })) + }, + [dispatch] + ) + + return {children} +} + +export function useProfiles() { + const ctx = useContext(ProfilesContext) + if (!ctx) throw new Error('useProfiles must be used within a ProfilesProvider') + return ctx +} diff --git a/src/shell/hooks/useSeatColors.ts b/src/shell/hooks/useSeatColors.ts new file mode 100644 index 0000000..464d057 --- /dev/null +++ b/src/shell/hooks/useSeatColors.ts @@ -0,0 +1,147 @@ +import { getThirdColor, useAutoPaperTheme, useThemeSettings } from '@rific/auto-paper' +import { avoidSeatColorClash, profileSeatColor } from '@tastic/profile' +import { useFocusEffect } from 'expo-router' +import { useCallback, useEffect, useRef } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import type { Action, Dispatch } from 'redux' + +import { useProfiles } from '@/shell/hooks/useProfiles' +import { seatColorsActions } from '@/shell/redux/seatColorsSlice' +import type { ShellState } from '@/shell/redux/shellState' +import { DEFAULT_SEAT_COLORS } from '@/shell/seatColors' +import type { Seat } from '@/shell/types' + +// What @tastic/profile's avoidSeatColorClash moves seat 2 to when both seats land on one color: a +// profile picked for one seat can be saved as the other seat's color, with no swap step behind it. +const SEAT_DEFAULT_COLORS = [DEFAULT_SEAT_COLORS[1], DEFAULT_SEAT_COLORS[2]] as const + +// Both seats' live colors (the theme's primary and secondary) and the handlers that change them, +// for Home's who's-playing button and the loadout's seat buttons. `p2IsHuman` says whether seat 2 +// is a person (a guest or a profile) or the CPU, which decides where seat 2's color comes from. +export function useSeatColors(p2IsHuman: boolean) { + const { colors: themeColors } = useAutoPaperTheme() + const { set: setThemeColor } = useThemeSettings() + const p1Color = themeColors.primary + const p2Color = themeColors.secondary + const { profiles, lastSelected } = useProfiles() + + // Typed explicitly because @tastic/profile's action creators return a plain `{ payload, type }` + // interface with no index signature, which react-redux's default Dispatch rejects. + const dispatch = useDispatch>() + // A seat's live color comes from one of three independently-remembered places depending on what's + // in that seat - a selected profile's pick in this pack (or its own saved color without one), the + // CPU's own remembered color, or a guest's own remembered color - all in the `seatColors` slice + // (seatColorsSlice.ts). A profile's own saved color is never written back to; only the Profiles + // screen edits it. + const guestColors = useSelector((state: ShellState) => state.seatColors.lastGuestColor) + const cpuColor = useSelector((state: ShellState) => state.seatColors.lastCpuColor) + // Each profile's own color pick in this pack, by profile id, read through @tastic/profile's + // profileSeatColor. It follows the profile, not the seat: kept through a switch to another profile + // and back, a move to the other seat, and a relaunch. + const profileColors = useSelector((state: ShellState) => state.seatColors.profileColors) + + const p1Profile = profiles.find((p) => p.id === lastSelected[1]) ?? null + const p2Profile = p2IsHuman ? (profiles.find((p) => p.id === lastSelected[2]) ?? null) : null + + // Mirrored for the focus effect below, so it can read each seat's current profile and remembered + // colors without depending on them (profiles is a new array on every useProfiles() render, and + // useFocusEffect re-runs whenever its callback's identity changes, even while still focused). + const p1ProfileRef = useRef(p1Profile) + const p2ProfileRef = useRef(p2Profile) + const guestColorsRef = useRef(guestColors) + const cpuColorRef = useRef(cpuColor) + const profileColorsRef = useRef(profileColors) + useEffect(() => { + p1ProfileRef.current = p1Profile + }, [p1Profile]) + useEffect(() => { + p2ProfileRef.current = p2Profile + }, [p2Profile]) + useEffect(() => { + guestColorsRef.current = guestColors + }, [guestColors]) + useEffect(() => { + cpuColorRef.current = cpuColor + }, [cpuColor]) + useEffect(() => { + profileColorsRef.current = profileColors + }, [profileColors]) + + // Keeps each seat's live theme color matching what it's supposed to be right now: on mount, and + // when a seat's profile changes (a pick, the swap when a seat picks the other seat's profile, or a + // deletion from Manage), when seat 2 changes between the CPU and a person, when a profile's pick + // changes, and when the roster resolves. profiles itself is read, not depended on (a new array on + // every render); profiles.length stands in as a coarse "profiles finished loading" signal. + useEffect(() => { + const nextP1 = p1Profile ? profileSeatColor(p1Profile, profileColors) : guestColors[1] + const nextP2 = avoidSeatColorClash(nextP1, p2Profile ? profileSeatColor(p2Profile, profileColors) : p2IsHuman ? guestColors[2] : cpuColor, SEAT_DEFAULT_COLORS) + if (nextP1.toLowerCase() === p1Color.toLowerCase() && nextP2.toLowerCase() === p2Color.toLowerCase()) return + setThemeColor({ color: { primary: nextP1, secondary: nextP2, tertiary: getThirdColor(nextP1, nextP2) } }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastSelected[1], lastSelected[2], p2IsHuman, guestColors, cpuColor, profileColors, profiles.length]) + + // The loadout pushes the play screen and Profiles on top of itself and comes back via + // router.back()/safeBack() - a pop that reveals this same, still-mounted screen. On every focus + // regain, re-derive each seat's live color from its authoritative source, so editing a profile's + // own color on the Profiles screen is picked up the instant you're back (a new color for the + // profile drops its pick, see profileSeatColor). Keyed only on what is a real reason to reapply; + // everything else is read through the refs above. + useFocusEffect( + useCallback(() => { + const guest = guestColorsRef.current + const cpu = cpuColorRef.current + const p1Prof = p1ProfileRef.current + const p2Prof = p2ProfileRef.current + const picks = profileColorsRef.current + const nextP1 = p1Prof ? profileSeatColor(p1Prof, picks) : guest[1] + const nextP2 = avoidSeatColorClash(nextP1, p2Prof ? profileSeatColor(p2Prof, picks) : p2IsHuman ? guest[2] : cpu, SEAT_DEFAULT_COLORS) + setThemeColor({ color: { primary: nextP1, secondary: nextP2, tertiary: getThirdColor(nextP1, nextP2) } }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastSelected[1], lastSelected[2], p2IsHuman, setThemeColor]) + ) + + // Routes a seat's just-changed color to whichever slot owns it right now - kept as that profile's + // pick in this pack (the pack's caption on the seat button's Color row) when a profile's in the + // seat. Picking the profile's own color drops the pick. + const persistSeatColor = useCallback( + (seat: Seat, hex: string) => { + const profile = seat === 1 ? p1Profile : p2Profile + if (profile) { + dispatch(seatColorsActions.setProfileColor({ profile, color: hex })) + return + } + if (seat === 2 && !p2IsHuman) { + dispatch(seatColorsActions.setLastCpuColor(hex)) + return + } + dispatch(seatColorsActions.setLastGuestColor({ seat, color: hex })) + }, + [p1Profile, p2Profile, p2IsHuman, dispatch] + ) + + // Picking the other seat's exact current color swaps the two instead of no-op'ing - only reachable + // where that picker allows it (allowSwapTaken: against the CPU only, where the other color is the + // CPU's, not a second person's choice). Each side of a swap persists by its own seat's rule above. + const handleP1ColorChange = useCallback( + (hex: string) => { + const swapped = hex.toLowerCase() === p2Color.toLowerCase() + const nextP2 = swapped ? p1Color : p2Color + setThemeColor({ color: { primary: hex, secondary: nextP2, tertiary: getThirdColor(hex, nextP2) } }) + persistSeatColor(1, hex) + if (swapped) persistSeatColor(2, nextP2) + }, + [setThemeColor, p1Color, p2Color, persistSeatColor] + ) + const handleP2ColorChange = useCallback( + (hex: string) => { + const swapped = hex.toLowerCase() === p1Color.toLowerCase() + const nextP1 = swapped ? p2Color : p1Color + setThemeColor({ color: { primary: nextP1, secondary: hex, tertiary: getThirdColor(nextP1, hex) } }) + persistSeatColor(2, hex) + if (swapped) persistSeatColor(1, nextP1) + }, + [setThemeColor, p1Color, p2Color, persistSeatColor] + ) + + return { p1Color, p2Color, handleP1ColorChange, handleP2ColorChange } +} diff --git a/src/utils/navigation.ts b/src/shell/navigation.ts similarity index 100% rename from src/utils/navigation.ts rename to src/shell/navigation.ts diff --git a/src/shell/redux/appSettingsSlice.ts b/src/shell/redux/appSettingsSlice.ts new file mode 100644 index 0000000..7b106dd --- /dev/null +++ b/src/shell/redux/appSettingsSlice.ts @@ -0,0 +1,34 @@ +import { createSettingsSlice } from '@rific/core' +import type { ViewRotation } from '@tastic/core' + +// Settings that apply to the whole pack rather than one game. A game's own rules (board, score to +// win, controls) live in that game's own settings slice. +export interface AppSettings { + // Lock Orientation: RootLayout's OrientationBridge hands it to @tastic/core's OrientationProvider + // (freezeWhileLocked), which freezes the one shared orientation reading for every screen, every + // board, dialogs and the status bar alike. Off by default: all of those just follow however the + // device is actually being held. + lockOrientation: boolean + // The angle the lock is holding. Written by OrientationProvider's onLockChange (recorded on lock, + // cleared on unlock) and handed back as lockInitialValue, so a relaunch reopens in the locked + // orientation instead of portrait. Never set by the UI directly; absent while unlocked. + lockedRotation?: ViewRotation + // Edge Guard's opt-in (see Providers.tsx's EdgeGuardBridge). + deferBottomEdgeGestures: boolean +} + +export const DEFAULT_APP_SETTINGS: AppSettings = { + lockOrientation: false, + deferBottomEdgeGestures: false +} + +// Merge mode, like every settings slice in the pack: REHYDRATE fills any field added since a +// player's settings were saved from the defaults. +const { actions, reducer } = createSettingsSlice('appSettings', { + initialState: DEFAULT_APP_SETTINGS, + initializeMode: 'merge', + fieldSetters: [] +}) + +export const appSettingsActions = actions +export default reducer diff --git a/src/shell/redux/createPackStore.ts b/src/shell/redux/createPackStore.ts new file mode 100644 index 0000000..9487393 --- /dev/null +++ b/src/shell/redux/createPackStore.ts @@ -0,0 +1,69 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { combineReducers, configureStore, type Middleware, type Reducer } from '@reduxjs/toolkit' +import { FLUSH, PAUSE, PERSIST, type PersistedState, persistReducer, persistStore, PURGE, REGISTER, REHYDRATE } from 'redux-persist' + +import { shellReducers } from './shellState' + +const hasError = (action: unknown): action is { error?: unknown } => typeof action === 'object' && action !== null && 'error' in action && Boolean(action.error) + +const errorMiddleware: Middleware = () => (next) => (action) => { + if (!hasError(action)) return next(action) + return action +} + +export interface PackStoreConfig { + // On web every Tastic app shares one origin (games.infinitetoken.com) and so one localStorage, + // where AsyncStorage keeps this state under keyPrefix + 'root': the default prefix would make every + // app's state the same persist:root. REHYDRATE still reports key 'root'. It applies on native too. + // This is the only key the pack writes itself (the shared profile roster is @tastic/profile's, + // shared on purpose). + keyPrefix: string + // Every game's own store keys (see GameDefinition.reducers), beside the shell's. + gameReducers: Record + // redux-persist's migrate, run on the saved store before it rehydrates (see pack.ts). + migrate?: (state: PersistedState, version: number) => Promise +} + +// The pack's one Redux store, persisted with redux-persist: the shell's keys (shellState.ts) plus +// every game's own. +export function createPackStore({ keyPrefix, gameReducers, migrate }: PackStoreConfig) { + const rootReducer = combineReducers({ ...shellReducers, ...gameReducers }) + + const persistedReducer = persistReducer( + { + key: 'root', + keyPrefix, + storage: AsyncStorage, + // redux-persist defaults `timeout` to 5000ms: a failsafe setTimeout scheduled on every PERSIST + // dispatch to force-resolve rehydrate if storage never responds. It's never cleared once + // rehydrate resolves normally (only guarded by an internal `_sealed` flag), so it sits as a + // pending timer for up to 5s after every store creation - under Jest that's a real open handle + // ("A worker process has failed to exit gracefully"). Disabling it (falsy timeout skips the + // setTimeout call entirely) is redux-persist's own documented way to opt out; AsyncStorage reads + // failing to ever resolve at all isn't a failure mode worth a 5s failsafe for. + timeout: 0, + // liveplay is per-round state that must never survive a relaunch (see liveplaySlice.ts). + blacklist: ['liveplay'], + migrate + }, + rootReducer + ) + + const store = configureStore({ + middleware: (getDefaultMiddleware) => { + const defaultMiddleware = getDefaultMiddleware({ + immutableCheck: false, + serializableCheck: { + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER] + } + }) + return defaultMiddleware.concat(errorMiddleware) + }, + reducer: persistedReducer + }) + + const persistor = persistStore(store) + return { store, persistor } +} + +export type PackStore = ReturnType['store'] diff --git a/src/shell/redux/gameGuideSlice.ts b/src/shell/redux/gameGuideSlice.ts new file mode 100644 index 0000000..dfcfc66 --- /dev/null +++ b/src/shell/redux/gameGuideSlice.ts @@ -0,0 +1,39 @@ +import type { Action } from 'redux' + +const REHYDRATE = 'persist/REHYDRATE' + +export interface GameGuideState { + versionSeen: number +} + +// Which version of one game's How to Play cards this player has finished or skipped, under the +// game's own store key. Unlike @tastic/hud's createGuideSlice, a saved store without this key reads +// as "not seen": a game added to a pack in an update is new to everyone, so its cards show on the +// first tap of its tile even for a player who has had the pack for months. (createGuideSlice reads +// the missing key as a returning player and stamps it seen, which is right only for a guide that +// ships with the app's first store.) +export function createGameGuideSlice(mountKey: string) { + const MARK_SEEN = `${mountKey}/markSeen` + const initialState: GameGuideState = { versionSeen: 0 } + + function markSeen(version: number) { + return { type: MARK_SEEN, payload: version } + } + + function reducer(state: GameGuideState = initialState, action: Action & { payload?: unknown }): GameGuideState { + if (action.type === REHYDRATE) { + const persisted = (action.payload as Record | undefined)?.[mountKey] + return typeof persisted?.versionSeen === 'number' ? { versionSeen: persisted.versionSeen } : state + } + if (action.type === MARK_SEEN && typeof action.payload === 'number') { + return { versionSeen: Math.max(state.versionSeen, action.payload) } + } + return state + } + + function selectVersionSeen(rootState: object): number { + return (rootState as Record)[mountKey]?.versionSeen ?? 0 + } + + return { actions: { markSeen }, reducer, selectVersionSeen } +} diff --git a/src/shell/redux/liveplaySlice.ts b/src/shell/redux/liveplaySlice.ts new file mode 100644 index 0000000..4206bf1 --- /dev/null +++ b/src/shell/redux/liveplaySlice.ts @@ -0,0 +1,34 @@ +import { createSettingsSlice } from '@rific/core' + +import type { RoundMode } from '@/shell/types' + +// The round a loadout committed: which game, how seat 2 was set up, and a snapshot of that game's +// settings, so changing a setting mid-match only affects the next match. The game's play screen +// reads its own settings back out (see useActiveRound). +export interface ActiveRound { + game: string + mode: RoundMode + settings: object +} + +// Transient per-round state, blacklisted from redux-persist in createPackStore.ts: none of it may +// survive a relaunch. A crash or force-quit mid-round leaving `activelyPlaying` stuck true in +// storage would re-enable Edge Guard (see Providers.tsx's EdgeGuardBridge) the moment the app +// cold-launches back to Home, before any play screen could correct it. Built on @rific/core's +// createSettingsSlice: one generated setter per field. +export type LiveplaySliceState = { + // Whether a play screen has a live, unpaused round in progress right now (not merely "a play + // screen is mounted"). Written by the play screen as its phase changes, and reset to false on its + // unmount so leaving mid-round by any path can't leave it stuck true. + activelyPlaying: boolean + // null until a round has been committed (a direct link to a play screen falls back to the game's + // defaults and a CPU opponent). + activeRound: ActiveRound | null +} + +const { actions, reducer } = createSettingsSlice('liveplay', { + initialState: { activelyPlaying: false, activeRound: null } +}) + +export const liveplayActions = actions +export default reducer diff --git a/src/shell/redux/onProfileRemoved.ts b/src/shell/redux/onProfileRemoved.ts new file mode 100644 index 0000000..40cad1a --- /dev/null +++ b/src/shell/redux/onProfileRemoved.ts @@ -0,0 +1,25 @@ +import { removeProfileUnlocks, type UnlockedAchievementsState } from '@tastic/achievements' +import { profilesActions } from '@tastic/profile' +import type { Reducer, UnknownAction } from 'redux' + +// Wraps one of a game's reducers so it forgets a profile the moment the Profiles screen deletes it +// (the shell's deleteProfile dispatches @tastic/profile's profilesActions.remove), with no need for +// the shell to know which games keep what per profile. A profile a sibling app deleted (a roster +// sync) is deliberately not cleaned up this way: a failed shared-store read reports an empty roster, +// which would erase real players' data for good. +export function onProfileRemoved(reducer: Reducer, forget: (state: S, profileId: string) => S): Reducer { + return (state: S | undefined, action: UnknownAction) => { + const next = reducer(state, action) + return profilesActions.remove.match(action) ? forget(next, action.payload) : next + } +} + +// For a createAchievementsSlice state: drops the profile's unlocks, and its stats through the game's +// own `dropStats` (where a game keeps a per-profile bucket). +export function forgetProfileAchievements(dropStats: (stats: T, profileId: string) => T) { + return (state: S, profileId: string): S => { + const stats = dropStats(state.stats, profileId) + const unlocked = removeProfileUnlocks(state.unlocked, profileId) + return stats === state.stats && unlocked === state.unlocked ? state : { ...state, stats, unlocked } + } +} diff --git a/src/shell/redux/opponentSlice.ts b/src/shell/redux/opponentSlice.ts new file mode 100644 index 0000000..b0044f2 --- /dev/null +++ b/src/shell/redux/opponentSlice.ts @@ -0,0 +1,21 @@ +import { createSettingsSlice } from '@rific/core' + +import type { SeatTwo } from '@/shell/types' + +// Who sits in seat 2, for the whole pack: picked from seat 2's own button in any game's loadout and +// kept from one game to the next and across relaunches, since it's usually the same two people. +// A fresh install starts on the CPU, so one person (App Review included) can tap a game and play. +// A game without 'nobody' among its seat-2 choices treats a saved 'nobody' as the CPU (see +// LoadoutScreen's seatTwoFor). +export interface OpponentState { + seatTwo: SeatTwo +} + +export const DEFAULT_OPPONENT: OpponentState = { seatTwo: 'cpu' } + +const { actions, reducer } = createSettingsSlice('opponent', { + initialState: DEFAULT_OPPONENT +}) + +export const opponentActions = actions +export default reducer diff --git a/src/shell/redux/profileSelectionSlice.ts b/src/shell/redux/profileSelectionSlice.ts new file mode 100644 index 0000000..07615db --- /dev/null +++ b/src/shell/redux/profileSelectionSlice.ts @@ -0,0 +1,17 @@ +import { createProfileSelectionSlice } from '@tastic/profile' + +import type { Seat } from '@/shell/types' + +// Which profile each seat last picked, for the whole pack: seat 1 is Home's who's-playing button +// and seat 1 in every loadout, seat 2 is seat 2 in every loadout. Always local, never part of the +// shared App Group roster (@tastic/profile's own profiles slice, see createPackStore.ts): who's +// selected on THIS device is a per-app, per-device thing a sibling @tastic app shouldn't see. +export type ProfileSelectionState = Record + +export const defaultProfileSelectionState: ProfileSelectionState = { 1: null, 2: null } + +// 'pack' only prefixes the action types; the state is stored under profileSelection. +const { actions, reducer } = createProfileSelectionSlice('pack', defaultProfileSelectionState) + +export const profileSelectionActions = actions +export default reducer diff --git a/src/shell/redux/seatColorsSlice.ts b/src/shell/redux/seatColorsSlice.ts new file mode 100644 index 0000000..f624c60 --- /dev/null +++ b/src/shell/redux/seatColorsSlice.ts @@ -0,0 +1,22 @@ +import { createSeatColorsSlice, type SeatColorsState as GenericSeatColorsState } from '@tastic/profile' + +import { DEFAULT_SEAT_COLORS } from '@/shell/seatColors' +import type { Seat } from '@/shell/types' + +// The loadout's per-seat color memory: a thin binding over @tastic/profile's createSeatColorsSlice +// (see its doc for each field: a guest seat's last color, the CPU seat's last color, and each +// profile's own color pick in this pack, kept by profile id). One memory for every game in the +// pack, so a player's color follows them from one game to the next. +export type SeatColorsState = GenericSeatColorsState + +export const defaultSeatColorsState: SeatColorsState = { + lastGuestColor: { 1: DEFAULT_SEAT_COLORS[1], 2: DEFAULT_SEAT_COLORS[2] }, + lastCpuColor: DEFAULT_SEAT_COLORS[2], + profileColors: {} +} + +// 'pack' only prefixes the action types; the state is stored under seatColors. +const slice = createSeatColorsSlice('pack', defaultSeatColorsState) + +export const seatColorsActions = slice.actions +export default slice.reducer diff --git a/src/shell/redux/shellState.ts b/src/shell/redux/shellState.ts new file mode 100644 index 0000000..1ea6dbc --- /dev/null +++ b/src/shell/redux/shellState.ts @@ -0,0 +1,56 @@ +import { createThemeReducer, getThirdColor } from '@rific/auto-paper' +import { keepAwakeReducer } from '@rific/core' +import { defaultSoundSettings, hapticReducer, soundReducer, type SoundSettings } from '@rific/feedback-press' +import { profilesReducer } from '@tastic/profile' + +import { DEFAULT_SEAT_COLORS } from '@/shell/seatColors' + +import appSettings from './appSettingsSlice' +import liveplay from './liveplaySlice' +import opponent from './opponentSlice' +import profileSelection from './profileSelectionSlice' +import seatColors from './seatColorsSlice' + +// @rific/feedback-press's own soundReducer defaults `enabled` to true unconditionally (that +// default isn't published with the dev-only override yet). Wrap it so a fresh install with no +// persisted preference (redux-persist finds nothing in AsyncStorage for the `sound` key) defaults +// muted in dev/simulator builds so Claude/local testing doesn't blast audio; production builds +// still default to sound on. All actual action handling still delegates to the package's reducer. +const initialSoundSettings: SoundSettings = { ...defaultSoundSettings, enabled: !__DEV__ } +const appSoundReducer = (state: SoundSettings = initialSoundSettings, action: { type: string }): SoundSettings => soundReducer(state, action) + +// Explicit primary/secondary triad (rather than a single seed expanded via harmony) so the pack's +// red/blue identity is pinned exactly instead of drifting with whatever hue math a harmony offset +// would produce. Primary/secondary ARE player 1/player 2's own default colors (DEFAULT_SEAT_COLORS), +// so the theme and the default guest colors (seatColorsSlice.ts) can never drift apart. Tertiary is +// derived, not picked, via getThirdColor: the hue maximally distant from both (see +// @rific/auto-paper's README "Explicit triad" section). +const themeReducer = createThemeReducer({ + color: { primary: DEFAULT_SEAT_COLORS[1], secondary: DEFAULT_SEAT_COLORS[2], tertiary: getThirdColor(DEFAULT_SEAT_COLORS[1], DEFAULT_SEAT_COLORS[2]) } +}) + +// The store keys every pack has, whatever its games. Each game adds its own keys beside these (see +// GameDefinition.reducers). +export const shellReducers = { + theme: themeReducer, + haptic: hapticReducer, + sound: appSoundReducer, + // @rific/core's Keep Awake setting (see Providers.tsx's KeepAwakeBridge), under the namespace its + // REHYDRATE handling expects. + keepAwake: keepAwakeReducer, + // The shared App Group roster (see @tastic/profile's resolveInitialProfiles/ + // useSharedProfilesSync, wired in hooks/useProfiles.tsx) is layered on top of this as a separate + // sync target - this slice persisting normally via redux-persist is what gives it local-fallback + // behavior where the shared store isn't available, with no separate AsyncStorage path. + profiles: profilesReducer, + profileSelection, + // The loadout's "remember this color for next time" memory (guest, CPU, and each profile's pick + // in this pack) - separate from `theme` above, which always reflects whatever's live right now. + seatColors, + appSettings, + opponent, + // Transient per-round state, the one key blacklisted from persistence (see liveplaySlice.ts). + liveplay +} + +export type ShellState = { [K in keyof typeof shellReducers]: ReturnType<(typeof shellReducers)[K]> } diff --git a/src/shell/screens/GameRoute.tsx b/src/shell/screens/GameRoute.tsx new file mode 100644 index 0000000..9782988 --- /dev/null +++ b/src/shell/screens/GameRoute.tsx @@ -0,0 +1,21 @@ +import { Redirect, useLocalSearchParams } from 'expo-router' + +import { usePackGame } from '@/shell/PackContext' +import { LoadoutScreen } from '@/shell/screens/LoadoutScreen' + +// The [game] routes: a game's loadout and its play screen, by id. An id the pack doesn't have (a +// stale link, a web URL typed by hand) goes Home. +export function LoadoutRoute() { + const { game: id } = useLocalSearchParams<{ game: string }>() + const game = usePackGame(id) + if (!game) return + return +} + +export function PlayRoute() { + const { game: id } = useLocalSearchParams<{ game: string }>() + const game = usePackGame(id) + if (!game) return + const { PlayScreen } = game + return +} diff --git a/src/shell/screens/HomeScreen.tsx b/src/shell/screens/HomeScreen.tsx new file mode 100644 index 0000000..02343da --- /dev/null +++ b/src/shell/screens/HomeScreen.tsx @@ -0,0 +1,173 @@ +import { defaultColors, useAutoPaperTheme } from '@rific/auto-paper' +import { IconButton, Pressable } from '@rific/feedback-press' +import { FakeLandscapeView, rotateInsets, useRotation } from '@tastic/core' +import { PressAwayOverlay, usePopoverHost, useWellColors, WELL_SHADOW } from '@tastic/hud' +import { type Profile, ProfilePicker } from '@tastic/profile' +import { router } from 'expo-router' +import { useCallback, useState } from 'react' +import { Image, ScrollView, StyleSheet, View } from 'react-native' +import { Icon, Text } from 'react-native-paper' +import { useSafeAreaInsets } from 'react-native-safe-area-context' + +import { useGameGuide } from '@/shell/components/GuideHost' +import { HeroTitle } from '@/shell/components/HeroTitle' +import { SettingsDialog } from '@/shell/components/SettingsDialog' +import { useOpponent } from '@/shell/hooks/useOpponent' +import { useProfiles } from '@/shell/hooks/useProfiles' +import { useSeatColors } from '@/shell/hooks/useSeatColors' +import { usePack } from '@/shell/PackContext' +import type { GameDefinition } from '@/shell/types' + +// Home: who's playing (seat 1, the same seat 1 every loadout starts with), then every game in the +// pack as a row in a fixed order, Solitaire's list: each game on its own well, its small app icon +// beside its name and blurb. A tap opens that game's loadout, after its How to Play cards the +// first time it's picked. Seat 2 is chosen in the loadout, from seat 2's own button. +export function HomeScreen() { + const pack = usePack() + const rotation = useRotation() + const insets = rotateInsets(useSafeAreaInsets(), rotation) + const { dark } = useAutoPaperTheme() + const { openFirstTime, openFor } = useGameGuide() + const [settingsOpen, setSettingsOpen] = useState(false) + + const { profiles, lastSelected, selectProfile } = useProfiles() + const { seatTwo } = useOpponent() + const { p1Color, p2Color, handleP1ColorChange } = useSeatColors(seatTwo === 'person') + const pickerHost = usePopoverHost() + const selectedProfile = profiles.find((p) => p.id === lastSelected[1]) ?? null + + const bg = dark ? '#000000' : '#FFFFFF' + const fg = dark ? '#FFFFFF' : '#000000' + + const openGame = useCallback((game: GameDefinition) => openFirstTime(game.id, () => router.push({ pathname: '/[game]/loadout', params: { game: game.id } })), [openFirstTime]) + + return ( + + {/* Reads correctly however the phone is held: see @tastic/core's FakeLandscapeView. The + Settings dialog stays outside it: it renders through a Portal, turned by its own `rotation`. */} + + + + + + {/* Who's playing: seat 1's button, the same one each loadout starts with. Lifted above the + tiles while its menu is open, so the menu paints over them. */} + + selectProfile(1, profile?.id ?? null)} onManage={() => router.push('/profiles')} colorSection={{ value: p1Color, onChange: handleP1ColorChange, swatches: defaultColors, caption: selectedProfile ? pack.colorCaption : undefined }} /> + + + + {pack.games.map((game) => ( + openGame(game)} onRules={() => openFor(game.id)} /> + ))} + + + + router.push('/stats')} accessibilityLabel='Stats & Achievements' /> + setSettingsOpen(true)} accessibilityLabel='Settings' /> + + + setSettingsOpen(false)} rotation={rotation} /> + + ) +} + +interface GameRowProps { + game: GameDefinition + dark: boolean + onPress: () => void + onRules: () => void +} + +// One game on its own well: its icon at app-icon size (the art was drawn as an app icon), its name +// and one-line blurb, and a rules button at the end that shows its How to Play cards without +// opening the game. +function GameRow({ game, dark, onPress, onRules }: GameRowProps) { + const well = useWellColors() + return ( + // The rules button sits beside the row's own button, laid over its end, not inside it: a button + // inside a button is invalid on the web. + + + + + + {game.title} + + + {game.blurb} + + + + + + + + ) +} + +// The icon's size on a row: an app icon's, with its rounding. +const ROW_ICON_SIZE = 56 +// Room at a row's end for the rules button laid over it. +const RULES_BUTTON_SPACE = 44 + +const styles = StyleSheet.create({ + container: { + flex: 1 + }, + content: { + alignItems: 'center', + gap: 24 + }, + corner: { + position: 'absolute' + }, + // Solitaire's list: one column, never wider than a phone's, centred on a wide screen. + list: { + gap: 14, + maxWidth: 480, + width: '100%' + }, + picker: { + alignItems: 'center' + }, + pickerOpen: { + zIndex: 100 + }, + rotatable: { + flex: 1 + }, + // A well (@tastic/hud's wellSurface): the theme's surface on MD3's level-1 shadow, the same surface + // as Solitaire's game rows, the stats screen's sections and every dialog. + row: { + alignItems: 'center', + borderRadius: 12, + boxShadow: WELL_SHADOW, + flexDirection: 'row', + gap: 14, + padding: 14, + paddingRight: RULES_BUTTON_SPACE + }, + rowIcon: { + borderRadius: ROW_ICON_SIZE * 0.22, + height: ROW_ICON_SIZE, + width: ROW_ICON_SIZE + }, + rowText: { + flex: 1, + gap: 2 + }, + rowTitle: { + fontWeight: 'bold' + }, + rulesButton: { + bottom: 0, + justifyContent: 'center', + position: 'absolute', + right: 14, + top: 0 + }, + scroll: { + flex: 1 + } +}) diff --git a/src/shell/screens/LoadoutScreen.tsx b/src/shell/screens/LoadoutScreen.tsx new file mode 100644 index 0000000..62fa767 --- /dev/null +++ b/src/shell/screens/LoadoutScreen.tsx @@ -0,0 +1,271 @@ +import { defaultColors, useAutoPaperTheme } from '@rific/auto-paper' +import { FakeLandscapeView, getViewRotation, rotateInsets, useOrientationState } from '@tastic/core' +import { CornerActionButtons, PlayerSetupPanel, PressAwayOverlay, ReadyButton, SharedActionBand, usePopoverHost } from '@tastic/hud' +import type { Profile } from '@tastic/profile' +import { DualZoneLayout, needsSharedNeutralZone, useDualZoneLayout } from '@tastic/split-screen' +import { router, useFocusEffect } from 'expo-router' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Platform, StyleSheet, View } from 'react-native' +import Animated from 'react-native-reanimated' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useDispatch } from 'react-redux' +import type { Action, Dispatch } from 'redux' + +import { useGameGuide } from '@/shell/components/GuideHost' +import { SeatTwoPanel } from '@/shell/components/SeatTwoPanel' +import { SettingsDialog } from '@/shell/components/SettingsDialog' +import { useOpponent } from '@/shell/hooks/useOpponent' +import { useProfiles } from '@/shell/hooks/useProfiles' +import { useSeatColors } from '@/shell/hooks/useSeatColors' +import { safeBack } from '@/shell/navigation' +import { usePack } from '@/shell/PackContext' +import { liveplayActions } from '@/shell/redux/liveplaySlice' +import { type GameDefinition, humanSeatsFor, roundModeFor, type Seat, type SeatTwo } from '@/shell/types' + +// The player-panel area: whenever a real device rotation changes orientationMode or which physical +// side P1 lands on, the panels fade out, the layout underneath swaps while invisible, then they fade +// back in - masking what would otherwise be an instant jump-cut as panels reflow or swap sides. +// Short and symmetric on purpose: this is masking a reflow that already happened at the OS's own +// pace, not a deliberate reveal. +const PANEL_SWAP_FADE_MS = 180 + +// Seat 2's saved choice in this game: a saved 'nobody' reads as the CPU in a game without a solo +// mode, and so does anything else the game doesn't offer. +export function seatTwoFor(saved: SeatTwo, choices: readonly SeatTwo[]): SeatTwo { + return choices.includes(saved) ? saved : 'cpu' +} + +// Every game's loadout: seat 1 (the player picked on Home), seat 2 (the CPU, a second person, or +// nobody, from seat 2's own button), the game's own match options, and Ready. Remounted per game +// (the route keys it by id), so a game's useLoadout hook only ever runs for that one game. +export function LoadoutScreen({ game }: { game: GameDefinition }) { + const pack = usePack() + const { seatTwo: savedSeatTwo, setSeatTwo } = useOpponent() + const seatTwo = seatTwoFor(savedSeatTwo, game.seatTwo) + const mode = roundModeFor(seatTwo) + const humanSeats = useMemo(() => humanSeatsFor(mode), [mode]) + const p2IsHuman = mode === 'twoPlayer' + // A CPU takes a seat on the board; nobody doesn't (solo). + const p2Exists = mode !== 'solo' + + const loadout = game.useLoadout(mode) + const { openFor } = useGameGuide() + + const { profiles, lastSelected, selectProfile } = useProfiles() + // orientationMode just follows the device's current physical tilt (see @tastic/core's + // useOrientationState), CPU games included - how you're holding the phone right now decides the + // panel arrangement, not a stored per-round choice. Lock Orientation pins it app-wide by freezing + // the shared reading itself (RootLayout's OrientationBridge), so this read takes no `locked` + // argument. The OS is portrait-locked; FakeLandscapeView below fakes the rest in JS. + const { orientationMode, p1OnRight, upsideDown, resolved: p1OnRightResolved } = useOrientationState() + + // The panel area's own layout lags one fade behind the live orientation above - see + // useDualZoneLayout's own comment. 2 Player seats its players the way the game's own board does + // (GameDefinition.twoPlayerSeats): face to face however the phone is held, P1 in the bottom half + // and P2 in the top, flipped for an upside-down hold where the game flips too, so each player gets + // the same half here and in the match. On the web a game may seat them side by side instead. + const fixedSeats = p2IsHuman && (game.twoPlayerSeats.faceToFaceOnWeb || Platform.OS !== 'web') + const fixedUpsideDown = game.twoPlayerSeats.followUpsideDown && orientationMode === 'faceToFace' && upsideDown + const { panelLayout, panelFadeStyle } = useDualZoneLayout(fixedSeats ? 'faceToFace' : orientationMode, fixedSeats ? true : p1OnRight, fixedSeats || p1OnRightResolved, fixedSeats ? fixedUpsideDown : upsideDown, PANEL_SWAP_FADE_MS) + + const [settingsOpen, setSettingsOpen] = useState(false) + // Safe-area insets are relative to the device's fixed portrait frame; rotateInsets remaps them + // onto whichever edge they land on once FakeLandscapeView turns the content, so `top`/`left`/ + // `right` below always mean the screen's real, visual edges. + const rotation = getViewRotation(panelLayout.orientationMode, panelLayout.p1OnRight, panelLayout.upsideDown) + const insets = rotateInsets(useSafeAreaInsets(), rotation) + const { colors: themeColors, dark } = useAutoPaperTheme() + const { p1Color, p2Color, handleP1ColorChange, handleP2ColorChange } = useSeatColors(p2IsHuman) + const dispatch = useDispatch>() + + // A seat's color follows its newly selected profile through the profile-color effect above. + // Picking the other seat's profile swaps the two (see @tastic/profile's select). + const handleP1ProfileSelect = useCallback((profile: Profile | null) => selectProfile(1, profile?.id ?? null), [selectProfile]) + const handleP2ProfileSelect = useCallback((profileId: string | null) => selectProfile(2, profileId), [selectProfile]) + + const [ready, setReady] = useState>({ 1: false, 2: false }) + + // Every time this screen (re)gains focus - arrival from Home, or coming back from a match - Ready + // starts false again. Without this, popping back to a loadout whose players both left it Ready + // would immediately re-trigger the all-ready effect below and bounce straight back into the match. + useFocusEffect( + useCallback(() => { + setReady({ 1: false, 2: false }) + }, [setReady]) + ) + // A change of who's in seat 2 starts Ready over too: otherwise player 1, ready and waiting on + // player 2, would be launched into a match the moment seat 2 switched to the CPU. + const changeSeatTwo = useCallback( + (next: SeatTwo) => { + setReady({ 1: false, 2: false }) + setSeatTwo(next) + }, + [setSeatTwo, setReady] + ) + + // Read through a ref rather than listed as a dependency of the effect below: this screen stays + // mounted (and still all Ready) under the play screen, so a settings change made there would + // otherwise re-run that effect and push a second match. + const roundSettingsRef = useRef(loadout.roundSettings) + useEffect(() => { + roundSettingsRef.current = loadout.roundSettings + }, [loadout.roundSettings]) + + useEffect(() => { + if (!humanSeats.every((s) => ready[s])) return + dispatch(liveplayActions.setActiveRound({ game: game.id, mode, settings: roundSettingsRef.current })) + router.push({ pathname: '/[game]/play', params: { game: game.id } }) + }, [ready, humanSeats, game.id, mode, dispatch]) + + const bg = dark ? '#000000' : '#FFFFFF' + const fg = dark ? '#FFFFFF' : '#000000' + const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)' + + const isPortrait = panelLayout.orientationMode === 'faceToFace' + // See @tastic/split-screen's needsSharedNeutralZone: whether back/settings/randomize/reset fold + // into one band between the two players instead of sitting in the corners. + const showMetaInSharedBand = needsSharedNeutralZone(panelLayout.orientationMode, humanSeats.length) + + // Where P2's own press-away zone lives on screen - matches panelLayout (what's actually painted + // right now, mid-fade included). Portrait puts P2's panel in the top half; landscape on whichever + // side isn't P1's. Only in 2 Player - otherwise seat 2 shares seat 1's host (see p2Host below). + const p2ZoneStyle = p2IsHuman ? (isPortrait ? styles.p2ZoneTop : panelLayout.p1OnRight ? styles.p2ZoneLeft : styles.p2ZoneRight) : null + + // With one human, the one Ready button sits under both panels instead of in each. + const showReadyButton = p2IsHuman + // P1's panel and the match options share one host - they sit in the same near/bottom zone. With + // one human, seat 2's panel joins it too (one person drives both, so two open menus at once would + // be clutter). 2 Player gives P2 their own host so two people can pick at once, each press-away + // zone closing only its own player's menu. + const controlsHost = usePopoverHost() + const p2PanelHost = usePopoverHost() + const p2Host = p2IsHuman ? p2PanelHost : controlsHost + // The one-human Ready button is centered under the whole row, not under either seat button, so an + // open seat menu can't fully cover it; it fades out while one is open instead. + const anyPlayerPickerOpen = !!(controlsHost.openId?.startsWith('p1-') || controlsHost.openId?.startsWith('p2-')) + + const optionsRow = loadout.options({ + host: controlsHost, + rotation, + accentColor: themeColors.tertiary, + mutedColor: fgMuted, + onAccentColor: themeColors.onTertiary, + dark, + // Left out (the row's own actions unrendered) whenever the shared band below carries them. + onRandomize: showMetaInSharedBand ? undefined : loadout.randomize, + onReset: showMetaInSharedBand ? undefined : loadout.reset + }) + + const optionsPopoverOpen = controlsHost.openId !== null && loadout.optionIds.includes(controlsHost.openId) + const sharedControls = showMetaInSharedBand ? ( + setSettingsOpen(true)} onRandomize={loadout.randomize} onReset={loadout.reset} fg={fg} popoverOpen={optionsPopoverOpen}> + {optionsRow} + + ) : ( + optionsRow + ) + + const controlLook = { accentColor: themeColors.tertiary, mutedColor: fgMuted, dark } + const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Color }) + const p1Panel = setReady((r) => ({ ...r, 1: !r[1] }))} dark={dark} showReadyButton={showReadyButton} secondPicker={p1Control} secondPickerId={p1Control ? 'p1-control' : undefined} profiles={profiles} selectedProfileId={lastSelected[1]} takenProfileId={p2IsHuman ? lastSelected[2] : null} guestLabel='P1' onProfileSelect={handleP1ProfileSelect} onManageProfiles={() => router.push('/profiles')} /> + const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Color }) : undefined + const p2Panel = setReady((r) => ({ ...r, 2: !r[2] }))} showReadyButton={showReadyButton} /> + + return ( + + {/* Driven by panelLayout (the same committed, fade-masked state DualZoneLayout renders from), + so the rotation change and the panel swap land in the same fade-covered moment. The Settings + dialog stays outside: it renders through a Portal, turned by its own `rotation`. */} + + {/* Press-away overlays for the loadout's own menus. P1's covers the whole screen (their + panel plus the match options read as theirs), while P2's covers only their half and, as a + later sibling, takes priority there. P2's mounts whenever either host has a menu open, so it + always shields P2's half from P1's press-away. */} + + {p2ZoneStyle && } + + {!showMetaInSharedBand && setSettingsOpen(true)} fg={fg} insets={insets} />} + + {p2IsHuman ? ( + + ) : ( + + {sharedControls} + + {!isPortrait && panelLayout.p1OnRight ? p2Panel : p1Panel} + {!isPortrait && panelLayout.p1OnRight ? p1Panel : p2Panel} + + setReady((r) => ({ ...r, 1: !r[1] }))} style={anyPlayerPickerOpen && styles.readyButtonHidden} /> + + )} + + + setSettingsOpen(false)} rotation={rotation} onShowHowToPlay={() => openFor(game.id)} /> + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1 + }, + p2ZoneLeft: { + bottom: 0, + left: 0, + position: 'absolute', + top: 0, + width: '50%' + }, + p2ZoneRight: { + bottom: 0, + position: 'absolute', + right: 0, + top: 0, + width: '50%' + }, + p2ZoneTop: { + height: '50%', + left: 0, + position: 'absolute', + right: 0, + top: 0 + }, + // The one-human row: seat 1's panel and seat 2's button read as one evenly spaced row of buttons, + // at PlayerSetupPanel's own pickerRow gap (12). + playersRow: { + flexDirection: 'row', + gap: 12 + }, + playersRowOpen: { + zIndex: 100 + }, + // See anyPlayerPickerOpen: opacity 0 (not display:'none') keeps the button's layout space, and + // pointerEvents:'none' keeps the sliver an open menu doesn't cover from being a hidden tap target. + readyButtonHidden: { + opacity: 0, + pointerEvents: 'none' + }, + // A real full-bleed box for FakeLandscapeView to size its absolutely-positioned children against. + rotatable: { + alignItems: 'center', + flex: 1, + gap: 32, + justifyContent: 'center', + paddingHorizontal: 16 + }, + stackedZone: { + alignItems: 'center', + gap: 28 + } +}) diff --git a/src/shell/screens/ProfilesRoute.tsx b/src/shell/screens/ProfilesRoute.tsx new file mode 100644 index 0000000..8a325bf --- /dev/null +++ b/src/shell/screens/ProfilesRoute.tsx @@ -0,0 +1,14 @@ +import { defaultColors } from '@rific/auto-paper' +import { ProfilesScreen } from '@tastic/profile' + +import { useProfiles } from '@/shell/hooks/useProfiles' +import { safeBack } from '@/shell/navigation' + +// The Profiles screen (Manage, from either seat 1 button). Deleting a profile also removes its +// stats and achievements in every game: each game's reducers listen for the removal (see +// onProfileRemoved.ts). +export function ProfilesRoute() { + const { profiles, createProfile, updateProfile, deleteProfile } = useProfiles() + + return createProfile(patch)} onSave={(id, patch) => updateProfile(id, patch)} onDelete={deleteProfile} onBack={safeBack} /> +} diff --git a/src/shell/screens/StatsScreen.tsx b/src/shell/screens/StatsScreen.tsx new file mode 100644 index 0000000..5f95647 --- /dev/null +++ b/src/shell/screens/StatsScreen.tsx @@ -0,0 +1,129 @@ +import { useAutoPaperTheme } from '@rific/auto-paper' +import { FakeLandscapeView, rotateInsets, useRotation } from '@tastic/core' +import { BaseStatsScreen, PressAwayOverlay, usePopoverHost } from '@tastic/hud' +import { OptionPicker, type OptionPickerOption } from '@tastic/profile' +import { useMemo, useState } from 'react' +import { StyleSheet, View } from 'react-native' +import { SegmentedButtons, Text } from 'react-native-paper' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useDispatch } from 'react-redux' + +import { useProfiles } from '@/shell/hooks/useProfiles' +import { safeBack } from '@/shell/navigation' +import { usePack } from '@/shell/PackContext' +import type { GameDefinition } from '@/shell/types' + +// Two tabs, as on Solitaire's stats screen: a page with every game's stats above the achievements +// would leave the achievements out of sight at the bottom. +type StatsTab = 'stats' | 'achievements' +const TABS = [ + { value: 'stats', label: 'Stats', icon: 'chart-box-outline' }, + { value: 'achievements', label: 'Achievements', icon: 'trophy-outline' } +] + +// The pickers' values for Everyone and All Games (neither is ever a profile or game id). +const EVERYONE = '__everyone__' +const ALL_GAMES = '__all__' + +const PLAYER_PICKER_ID = 'stats-player' +const GAME_PICKER_ID = 'stats-game' + +// Every game's stats and achievements on one screen, filtered by player (Everyone, or one profile) +// and by game (All Games, or one), like Solitaire's. Each game keeps its own stats and its own +// achievements; this screen only shows them side by side. With two seats there's no one player to +// open on, so it opens on Everyone. Guests count only toward Everyone: two guests in one match can't +// be told apart. +export function StatsScreen() { + const pack = usePack() + const { dark, colors } = useAutoPaperTheme() + // Turned with the phone like every other screen: rotateInsets remaps the device's raw safe-area + // reading onto the edges they land on once FakeLandscapeView turns the content. + const rotation = useRotation() + const insets = rotateInsets(useSafeAreaInsets(), rotation) + const { profiles } = useProfiles() + const dispatch = useDispatch() + const pickerHost = usePopoverHost() + + const [tab, setTab] = useState('stats') + const [gameId, setGameId] = useState(ALL_GAMES) + // Self-heals to Everyone if the selected profile is deleted while this screen is mounted. + const [selectedProfileId, setSelectedProfileId] = useState(null) + const profileId = selectedProfileId && profiles.some((p) => p.id === selectedProfileId) ? selectedProfileId : null + + const games = gameId === ALL_GAMES ? pack.games : pack.games.filter((g) => g.id === gameId) + + const playerOptions = useMemo[]>(() => [{ value: EVERYONE, label: 'Everyone', icon: 'account-group' }, ...profiles.map((p) => ({ value: p.id, label: p.name, icon: 'account', color: p.color }))], [profiles]) + const gameOptions = useMemo[]>(() => [{ value: ALL_GAMES, label: 'All Games', icon: 'view-grid-outline' }, ...pack.games.map((g) => ({ value: g.id, label: g.title }))], [pack.games]) + + // Reset clears every player's stats and achievements in the games shown (one game, or all of them). + const resetScope = games.length === 1 ? games[0].title : 'every game' + const onReset = () => games.forEach((g) => dispatch(g.resetStats())) + + return ( + + + {/* Inside the page, not before it: BaseStatsScreen paints an opaque full-screen container that + would hide a tap catcher placed underneath it. Over the stat sections and under the open + filters (pressAway's zIndex sits below filtersOpen's), so a tap anywhere on the page but a + menu closes it. */} + + setTab(value as StatsTab)} buttons={TABS} style={styles.tabs} /> + + + {profiles.length > 0 && setSelectedProfileId(value === EVERYONE ? null : value)} color={colors.primary} dark={dark} icon={profileId ? 'account' : 'account-group'} />} + + + + {games.map((game) => ( + 1} /> + ))} + + + ) +} + +// One game's part of the page: its own sections, under its name when more than one game is shown. +function GameStats({ game, tab, profileId, showTitle }: { game: GameDefinition; tab: StatsTab; profileId: string | null; showTitle: boolean }) { + const { colors } = useAutoPaperTheme() + const { StatsSections, AchievementList } = game + return ( + + {showTitle && ( + + {game.title} + + )} + {tab === 'stats' ? : } + + ) +} + +const styles = StyleSheet.create({ + filters: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + justifyContent: 'center' + }, + filtersOpen: { + zIndex: 100 + }, + game: { + gap: 16 + }, + gameTitle: { + fontWeight: 'bold', + marginTop: 8 + }, + pressAway: { + zIndex: 50 + }, + // A real full-bleed box for FakeLandscapeView to size its turned content against. + rotatable: { + flex: 1 + }, + // SegmentedButtons' own rounding. + tabs: { + borderRadius: 20 + } +}) diff --git a/src/shell/seatColors.ts b/src/shell/seatColors.ts new file mode 100644 index 0000000..768018a --- /dev/null +++ b/src/shell/seatColors.ts @@ -0,0 +1,8 @@ +import type { Seat } from '@/shell/types' + +// Player 1's and player 2's own default colors. They seed both the pack's default theme triad (see +// createPackStore.ts, which feeds them to @rific/auto-paper's createThemeReducer as primary/ +// secondary, tertiary derived via getThirdColor) and the guest/CPU colors a fresh install starts +// with (see seatColorsSlice.ts), so a fresh install's live theme and its remembered colors agree. +// Also what @tastic/profile's avoidSeatColorClash moves seat 2 to when both seats land on one color. +export const DEFAULT_SEAT_COLORS: Record = { 1: '#f44336', 2: '#2196f3' } diff --git a/src/utils/splashGate.ts b/src/shell/splashGate.ts similarity index 100% rename from src/utils/splashGate.ts rename to src/shell/splashGate.ts diff --git a/src/shell/types.ts b/src/shell/types.ts new file mode 100644 index 0000000..dbf7935 --- /dev/null +++ b/src/shell/types.ts @@ -0,0 +1,115 @@ +import type { MenuOption, PopoverHost } from '@tastic/hud' +import type { GuideStep } from '@tastic/hud/guide' +import type { ComponentType, ReactNode } from 'react' +import type { ImageSourcePropType } from 'react-native' +import type { Action, Reducer } from 'redux' + +import type { ShellState } from '@/shell/redux/shellState' + +// The two seats every game in a pack shares: seat 1 is whoever picked a player on Home, seat 2 is +// the CPU, a second person, or (only in a game with a solo mode) nobody at all. +export type Seat = 1 | 2 + +// Who sits in seat 2. One value for the whole pack (shell/redux/opponentSlice.ts), so the same two +// people stay set up from one game to the next. +export type SeatTwo = 'cpu' | 'person' | 'nobody' + +// What a round is, from seat 2: a CPU opponent, two people on one device, or one player alone. +export type RoundMode = 'vsCpu' | 'twoPlayer' | 'solo' + +export function roundModeFor(seatTwo: SeatTwo): RoundMode { + return seatTwo === 'person' ? 'twoPlayer' : seatTwo === 'nobody' ? 'solo' : 'vsCpu' +} + +// The players who take part in a round and have to press Ready: a CPU and nobody don't. +export function humanSeatsFor(mode: RoundMode): Seat[] { + return mode === 'twoPlayer' ? [1, 2] : [1] +} + +// What the loadout's match-options row is handed (see GameLoadout.options): the popover host it +// shares with seat 1's panel, the screen's rotation, and the colors the row draws with. +export interface LoadoutOptionsProps { + host: PopoverHost + rotation: number + accentColor: string + mutedColor: string + onAccentColor: string + dark: boolean + // Undefined while the loadout folds randomize/reset into its shared action band instead. + onRandomize?: () => void + onReset?: () => void +} + +// The colors a seat's controls picker draws with: the loadout's tertiary accent, or the seat's own. +export interface SeatControlLook { + accentColor: string + seatColor: string + mutedColor: string + dark: boolean +} + +// One game's part of the shared loadout (shell/screens/LoadoutScreen.tsx), built by the game's +// own useLoadout hook from its own saved settings. +export interface GameLoadout { + // The settings snapshot committed as the round's when every human player readies up. + roundSettings: object + // The match-options row (board, rules, puck): the game's own controls, drawn under the shared + // popover host so its menus open, elevate and close like every other loadout menu. + options: (props: LoadoutOptionsProps) => ReactNode + // The popover ids the options row opens, so the loadout can elevate the row while one is open. + optionIds: readonly string[] + randomize: () => void + reset: () => void + // A human seat's controls picker (shot style, keys), or undefined when the game has none. + seatControl: (seat: Seat, host: PopoverHost, look: SeatControlLook) => ReactNode | undefined + // Seat 2's CPU difficulty, saved per game since each game's CPU plays differently. + cpuDifficulty: { value: string; options: MenuOption[]; onChange: (value: string) => void } +} + +// What a game's How to Play cards are built from: the live settings (through the game's own +// selector, so the guide host never has to know their shape), how seat 2 is set up, and whether +// the device is touch-first. +export interface GameGuide { + version: number + selectVersionSeen: (state: ShellState) => number + markSeen: (version: number) => Action + // Returns a value compared with shallowEqual, so keep it a flat object of the settings read. + selectInput: (state: ShellState) => object + getSteps: (input: object, context: { touch: boolean; mode: RoundMode }) => GuideStep[] +} + +export interface GameDefinition { + id: string + title: string + // One line under the tile's name on Home. + blurb: string + icon: { light: ImageSourcePropType; dark: ImageSourcePropType } + // Seat 2's choices in this game, in menu order. 'nobody' only where the game has a solo mode. + seatTwo: readonly SeatTwo[] + // How the 2 Player loadout seats its players, to match where the game puts them: always face + // to face (or only off the web, where a desktop window seats them side by side), and whether an + // upside-down hold swaps who is at the bottom. + twoPlayerSeats: { faceToFaceOnWeb: boolean; followUpsideDown: boolean } + // The store keys this game mounts (settings, achievements, guide), merged into the pack's store. + reducers: Record + // Mounted at the app root for the game's lifetime (its stats and achievements context). + Provider: ComponentType<{ children: ReactNode }> + useLoadout: (mode: RoundMode) => GameLoadout + PlayScreen: ComponentType + // The game's stats sections and its achievement list for the stats screen, for one profile + // (null is Everyone). + StatsSections: ComponentType<{ profileId: string | null }> + AchievementList: ComponentType<{ profileId: string | null }> + // Clears the game's stats and achievements (the stats screen's Reset). + resetStats: () => Action + guide: GameGuide +} + +export interface PackDefinition { + name: string + // The Home wordmark's two lines. + wordmark: readonly [string, string] + // A seat's color pick for a profile is kept for this pack only; the seat button says so. + colorCaption: string + games: readonly GameDefinition[] +} diff --git a/src/utils/gameMode.ts b/src/utils/gameMode.ts deleted file mode 100644 index 88cbe3b..0000000 --- a/src/utils/gameMode.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { GameMode, Player } from '@/types' - -// Extracted so /loadout can parse its own mode route param the same way, without duplicating -// this ternary. -export function parseGameMode(value: string | undefined): GameMode { - return value === 'vsCpu' ? 'vsCpu' : 'twoPlayer' -} - -// CPU always plays player 2 (see useAIOpponent) - the human is player 1 whenever gameMode is -// 'vsCpu'. Takes just the gameMode field (not the full Settings) so /loadout can call this -// before it has assembled a complete settings object of its own. -export function humanPlayersFor(gameMode: GameMode): Player[] { - return gameMode === 'vsCpu' ? [1] : [1, 2] -} diff --git a/src/utils/goalWedges.ts b/src/utils/goalWedges.ts deleted file mode 100644 index 7d503ce..0000000 --- a/src/utils/goalWedges.ts +++ /dev/null @@ -1 +0,0 @@ -export { computeGoalWedges, type GoalWedge } from '@tastic/physics' diff --git a/store.config.json b/store.config.json index eade94c..4e6c160 100644 --- a/store.config.json +++ b/store.config.json @@ -12,20 +12,19 @@ ], "info": { "en-US": { - "title": "BoxHockeyTastic", - "subtitle": "Tabletop Box Hockey. No Ads.", - "promoText": "Flick the puck through the gaps and into the other player's goal. Play a friend across the table or the CPU, with no ads and no tracking.", - "description": "Physics-driven tabletop box hockey. Flick the puck through the gaps and into the other player's goal, against a friend across the table or the CPU. No ads, no accounts.\n\n• Two Player face to face on one device, or One Player against the CPU (Easy, Medium, or Hard)\n• Take turns shooting, and choose how you shoot, from a fast flick to a slingshot pull\n• Three tip-offs: a Face-Off reaction race, a Coin Flip, or Triple Tap\n• Three boards: Classic, Gauntlet, and Gravity, plus a Rink option with rounded corners and a net behind each goal\n• Set the puck speed, the puck size, and the score to win\n• Player profiles, stats, and 33 achievements\n• Plays offline\n\nPart of the Tastic family of games. Your player profiles are shared with the other Tastic games on your device.\n\nOpen source on GitHub: https://github.com/jayrdeaton/BoxHockey", + "title": "PuckTastic", + "subtitle": "Four Puck Games. No Ads.", + "promoText": "Box Hockey, Air Hockey, Paddles and Breaker in one app. Play a friend across the table or the CPU, with no ads and no tracking.", + "description": "Four tabletop puck games in one app: Box Hockey, Air Hockey, Paddles and Breaker. Play a friend face to face on one device, or the CPU. No ads, no accounts.\n\n• Box Hockey: the classic wooden table game. Take turns shooting the puck through the gaps and into their goal. Three tip-offs (a Face-Off reaction race, a Coin Flip, or Triple Tap), three boards (Classic, Gauntlet, and Gravity), a Rink option, and four ways to shoot\n• Air Hockey: the arcade table classic. Guard your goal and fire back, both players at once\n• Paddles: the arcade original. Get the puck past their paddle\n• Breaker: the classic brick breaker. Clear every brick on your own, or play head to head with bricks in the middle\n• Two Player face to face on one device, or One Player against the CPU (Easy, Medium, or Hard)\n• Set each game's score to win, puck speed, and more\n• Player profiles, stats, and 138 achievements across the four games\n• Plays offline\n\nPart of the Tastic family of games. Your player profiles are shared with the other Tastic games on your device.\n\nOpen source on GitHub: https://github.com/jayrdeaton/BoxHockey", "keywords": [ - "air", + "air hockey", + "box hockey", "puck", + "paddle", + "bricks", "table", - "flick", - "physics", "arcade", "two player", - "multiplayer", - "local", "versus", "pass and play", "offline" diff --git a/store/README.md b/store/README.md index ac2af6e..17d0350 100644 --- a/store/README.md +++ b/store/README.md @@ -49,13 +49,13 @@ To redo them: 4. The skill's `compose.mjs store/screenshots.json` (writes all three sets) 5. Clear Sam and Riley out of the simulators' shared Tastic roster, or Solitaire's next reshoot on them shows them too: BoxHockey copies its seeded profiles into the empty App Group (`@tastic/profile`'s `resolveInitialProfiles`). Shut both simulators down, then remove `sharedProfiles` from `group.com.infinitetoken.tastic.plist` in each one's `data/Containers/Shared/AppGroup//Library/Preferences/` (`plutil -remove sharedProfiles `) -The seed writes `boxhockey:root`, which only builds from 2026-10-19 on read: on an older install (the simulators' 2026-10-18 build, as of 2026-10-20) the app ignores it and opens on the guide with sound on, so rebuild, or for a one-off write the same seed under `persist:root`. +The seed writes `pucktastic:root` with the pack's store keys (`boxhockeySettings`, `boxhockeyAchievements`, `opponent` and so on), which only PuckTastic builds (from 2026-10-24) read: on an older install the app ignores it and opens with sound on and How to Play unseen, so rebuild first. Simulator notes from this run: a fresh launch after `reseed.sh` (or after a reboot) can sit on the splash for a minute or two, and `simctl launch` can block that long, so run it in the background. The simulator's touch ring shows in screen recordings for a moment after each tap or swipe. The recorder only writes a frame when the screen changes, so on a still Classic board the skill's `frames.mjs` sheet and grab disagree on timestamps; pull the exact frame with ffmpeg's `select` instead. ## Header and search results art (uploaded by hand, optional, iOS 27+) -`store/screenshots/header.png` (3840 x 1646) and `store/screenshots/search.png` (3840 x 2560), gitignored like the screenshots. `node store/creative/build.mjs` draws both. Board.tsx is Skia, which doesn't render outside the app, so the header is an SVG redraw of Board's own shapes from the app's real geometry and colors, not a bundled `Board`: `computeBoardLayout` on the iPhone 17 Pro Max layout (Classic, medium puck, Rink off), `faceoffZoneCenters`, `computeGoalWedges`, `DEFAULT_P1_COLOR`/`DEFAULT_P2_COLOR` and Board's dark-mode white walls and lines, with the perimeter walls Board draws on desktop web. +`store/screenshots/header.png` (3840 x 1646) and `store/screenshots/search.png` (3840 x 2560), gitignored like the screenshots. `node store/creative/build.mjs` draws both. Board.tsx is Skia, which doesn't render outside the app, so the header is an SVG redraw of Board's own shapes from the app's real geometry and colors, not a bundled `Board`: `computeBoardLayout` on the iPhone 17 Pro Max layout (Classic, medium puck, Rink off), `faceoffZoneCenters`, `computeGoalWedges`, `DEFAULT_SEAT_COLORS` (`src/shell/seatColors.ts`) and Board's dark-mode white walls and lines, with the perimeter walls Board draws on desktop web. - **Header:** the whole box on its side, red end left and blue end right, red on match point (4 of 5 arcs lit), and the puck just through a divider hole on its way into the blue goal, with fading copies of where it was. The flight is a real shot played out on `stepPhysics` (the script fails if it ever stops scoring). No words: the app's name sits right under it. - **Search results:** "Tabletop Box Hockey / Play a Friend or the CPU" in SF Mono Bold (the screenshots' caption font) beside the in-play capture in a phone (`store/screenshots/raw/iphone/01-play.png`, so take the screenshots first). diff --git a/store/creative/build.mjs b/store/creative/build.mjs index b5e8296..e0cffd4 100644 --- a/store/creative/build.mjs +++ b/store/creative/build.mjs @@ -3,7 +3,7 @@ // needs CanvasKit, Reanimated and the theme provider), so the header redraws Board's own shapes as SVG from the app's // real geometry and colors: computeBoardLayout on the iPhone 17 Pro Max layout (the screenshots' device), Classic, // medium puck, Rink off, faceoffZoneCenters/faceoffZoneRadius for the circles, computeGoalWedges for the score arcs, -// and the dark theme's colors (DEFAULT_P1_COLOR/DEFAULT_P2_COLOR, Board's white walls and 25% white lines). The puck's +// and the dark theme's colors (the shell's DEFAULT_SEAT_COLORS, Board's white walls and 25% white lines). The puck's // flight is a real shot played out frame by frame on the app's own stepPhysics. The search art's phone shows the // in-play screenshot capture (store/screenshots/raw/iphone/01-play.png, so take the screenshots first). // @@ -30,10 +30,11 @@ const CAPTURE = path.join(OUT_DIR, 'raw', 'iphone', '01-play.png') const FONT = '/System/Library/Fonts/SFNSMono.ttf' const ENTRY = ` -export { computeBoardLayout, faceoffZoneCenters, faceoffZoneRadius } from '@/utils/board' -export { stepPhysics } from '@/utils/physics' -export { computeGoalWedges } from '@/utils/goalWedges' -export { DEFAULT_P1_COLOR, DEFAULT_P2_COLOR, FRICTION_LEVELS, GOAL_LINE_STROKE_WIDTH, MAX_SPEED } from '@/constants/game' +export { computeBoardLayout, faceoffZoneCenters, faceoffZoneRadius } from '@/games/boxhockey/utils/board' +export { stepPhysics } from '@/games/boxhockey/utils/physics' +export { computeGoalWedges } from '@tastic/physics' +export { FRICTION_LEVELS, GOAL_LINE_STROKE_WIDTH, MAX_SPEED } from '@/games/boxhockey/constants/game' +export { DEFAULT_SEAT_COLORS } from '@/shell/seatColors' ` const RN_STUB = "export const Platform = { OS: 'ios', select: (o) => o.ios ?? o.native ?? o.default }" @@ -57,8 +58,8 @@ try { // The iPhone 17 Pro Max's screen and safe area, as in store/seed/generate.mjs. const board = m.computeBoardLayout(440, 956, { top: 62, bottom: 34, left: 0, right: 0 }, 'classic', 'medium', false) const { x, y, width, height, puckRadius, goalWidth } = board - const p1 = m.DEFAULT_P1_COLOR - const p2 = m.DEFAULT_P2_COLOR + const p1 = m.DEFAULT_SEAT_COLORS[1] + const p2 = m.DEFAULT_SEAT_COLORS[2] // Board.tsx's dark-mode wallColor and lineColor. const wall = '#FFFFFF' const line = 'rgba(255,255,255,0.25)' diff --git a/store/seed/generate.mjs b/store/seed/generate.mjs index 8f4faf3..2451744 100644 --- a/store/seed/generate.mjs +++ b/store/seed/generate.mjs @@ -18,14 +18,14 @@ const ROOT = path.join(HERE, '../..') const ENTRY = ` import { newlyUnlocked, unlockedKey } from '@tastic/achievements' -import { ACHIEVEMENT_CATALOG } from '@/constants/achievements' -import { FACEOFF_MARGIN_MAX_MS, FRICTION_LEVELS, MAX_SPEED } from '@/constants/game' -import { planAIShot, randomFaceoffReactionMs } from '@/utils/aiOpponent' -import { centerPuck, computeBoardLayout } from '@/utils/board' -import { computeFaceoffKnock } from '@/utils/faceoff' -import { stepPhysics } from '@/utils/physics' -import { applyMatchOutcome, getProfileStatsView } from '@/utils/statsEngine' -import { DEFAULT_STATS } from '@/utils/statsValidation' +import { ACHIEVEMENT_CATALOG } from '@/games/boxhockey/constants/achievements' +import { FACEOFF_MARGIN_MAX_MS, FRICTION_LEVELS, MAX_SPEED } from '@/games/boxhockey/constants/game' +import { planAIShot, randomFaceoffReactionMs } from '@/games/boxhockey/utils/aiOpponent' +import { centerPuck, computeBoardLayout } from '@/games/boxhockey/utils/board' +import { computeFaceoffKnock } from '@/games/boxhockey/utils/faceoff' +import { stepPhysics } from '@/games/boxhockey/utils/physics' +import { applyMatchOutcome, getProfileStatsView } from '@/games/boxhockey/utils/statsEngine' +import { DEFAULT_STATS } from '@/games/boxhockey/utils/statsValidation' let rng = 20261018 const rand = () => ((rng = (rng * 1103515245 + 12345) & 0x7fffffff), rng / 0x7fffffff) diff --git a/store/seed/write-store.py b/store/seed/write-store.py index d5b4f74..9d555c1 100755 --- a/store/seed/write-store.py +++ b/store/seed/write-store.py @@ -13,9 +13,10 @@ The store is redux-persist on @react-native-async-storage: on iOS, Library/Application Support//RCTAsyncLocalStorage_V1, with values over 1024 characters in a file named by the MD5 of the key. -The key is src/redux/store.ts's persistConfig keyPrefix plus its key ('boxhockey:root'); -keep it in step with that file, or the app ignores everything this writes. Slices left out keep their -initial state, and settings missing a field get it from DEFAULT_SETTINGS. +The key is src/pack.ts's keyPrefix plus redux-persist's key ('pucktastic:root'), and the slices are +the store keys the pack mounts (Box Hockey's are boxhockey*); keep both in step with the app, or it +ignores everything this writes. Slices left out keep their initial state, and settings missing a +field get it from DEFAULT_SETTINGS. """ import argparse import hashlib @@ -25,7 +26,7 @@ import subprocess BUNDLE = 'com.infinitetoken.BoxHockey' -KEY = 'boxhockey:root' +KEY = 'pucktastic:root' HERE = os.path.dirname(os.path.abspath(__file__)) parser = argparse.ArgumentParser() @@ -41,24 +42,31 @@ seed = json.load(open(os.path.join(HERE, 'seed.json'))) sam, riley = (p['id'] for p in seed['profiles']) -extensions = {pid: {'controlScheme': args.scheme} for pid in (sam, riley)} root = { - 'achievements': {'stats': seed['stats'], 'unlocked': seed['unlocked']}, + 'boxhockeyAchievements': {'stats': seed['stats'], 'unlocked': seed['unlocked']}, 'sound': {'enabled': False}, - 'guide': {'versionSeen': 1}, + # Every game's How to Play seen, so no cards open over a shot. + 'boxhockeyGuide': {'versionSeen': 1}, + 'airhockeyGuide': {'versionSeen': 1}, + 'paddlesGuide': {'versionSeen': 1}, + 'breakerGuide': {'versionSeen': 1}, 'profiles': seed['profiles'], - 'profileExtensions': extensions, 'profileSelection': {'1': sam, '2': riley if args.mode == 'twoPlayer' else None}, - 'settings': { - 'gameMode': args.mode, - 'boardVariant': args.board, - 'rink': args.rink, - 'shotPreviewLength': args.preview, - 'aiDifficulty': args.difficulty, - 'tipoffMode': args.tipoff, - 'controlScheme': {'1': args.scheme, '2': args.scheme}, + # Seat 2, for the whole pack: a person in Two Player, the CPU otherwise. + 'opponent': {'seatTwo': 'person' if args.mode == 'twoPlayer' else 'cpu'}, + # Box Hockey's loadout is per player (src/shared/playerSettings.ts): the match settings are + # seat 1's player's (Sam's), and each player keeps their own shot style. + 'boxhockeySettings': { + sam: { + 'boardVariant': args.board, + 'rink': args.rink, + 'shotPreviewLength': args.preview, + 'aiDifficulty': args.difficulty, + 'tipoffMode': args.tipoff, + }, }, + 'boxhockeyControls': {sam: args.scheme, riley: args.scheme}, } value = {k: json.dumps(v) for k, v in root.items()} value['_persist'] = json.dumps({'version': -1, 'rehydrated': True}) diff --git a/tsconfig.json b/tsconfig.json index 4164300..3624787 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,14 +2,7 @@ "extends": "@infinitetoken/tsconfig/expo", "compilerOptions": { "paths": { - "@/*": ["./*"], - "@/app/*": ["./src/app/*"], - "@/components/*": ["./src/components/*"], - "@/constants/*": ["./src/constants/*"], - "@/hooks/*": ["./src/hooks/*"], - "@/redux/*": ["./src/redux/*"], - "@/types": ["./src/types/index.ts"], - "@/utils/*": ["./src/utils/*"] + "@/*": ["./src/*"] } }, "include": ["**/*.ts", "**/*.tsx"]