Compare commits
213 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c201cc2d3 | |||
| c30a6a7bcd | |||
| c4354774ad | |||
| 8a44f77fb1 | |||
| 9ebd9a304f | |||
| b223a9c1f2 | |||
| 6be49ec14a | |||
| 793b408e3f | |||
| 213e8abf28 | |||
| bac2f3a5c7 | |||
| 1e38d9d2a2 | |||
| 419c1ceb48 | |||
| f6bde5871a | |||
| 9c56a7f987 | |||
| 2e8efab2aa | |||
| 0f45ce743f | |||
| 7794cd5dbd | |||
| c0cb6454ac | |||
| 2f45a9bbf5 | |||
| 11a61322e8 | |||
| cb1bc1a865 | |||
| 622cb14813 | |||
| 4afea98e77 | |||
| 79f3cc85dd | |||
| 4052f865c9 | |||
| 5887f790c6 | |||
| 6fc5d3ed97 | |||
| 0eaf30cd8b | |||
| f1d5e8d4ca | |||
| 7763aa2e0a | |||
| 500f06b538 | |||
| 85227c2175 | |||
| a570b318d7 | |||
| 99e32d9491 | |||
| 74022e8181 | |||
| d0b5164e6d | |||
| defc39c0f3 | |||
| a9844b3a4f | |||
| 77b8498850 | |||
| 4c34aba66d | |||
| 2bf4ed2af8 | |||
| 5afeac3c14 | |||
| 39e3c0b30f | |||
| d749718584 | |||
| 922a66835a | |||
| 0d4a96e785 | |||
| a3e10bc12b | |||
| 49c482f2ba | |||
| 0ad7a7892b | |||
| 989b423714 | |||
| 13f703a3ec | |||
| aa7c8e038b | |||
| 0469b6cec9 | |||
| ef88ca4235 | |||
| 1adbe1c98a | |||
| b97299ce0a | |||
| 93eeffb1ad | |||
| 081ad9240f | |||
| 7d3b92048b | |||
| 3c425a4e68 | |||
| 4ae90080e8 | |||
| 2cdcd543a4 | |||
| 71f8ee0e16 | |||
| 92634705b3 | |||
| 7aee4fe712 | |||
| 0e4ce974f0 | |||
| 4ddcee95d9 | |||
| 4e1f7b6007 | |||
| 00f3deb5b2 | |||
| b8037c48e9 | |||
| a3dfe25d13 | |||
| 50a834c4fc | |||
| f00332fca5 | |||
| 384936f106 | |||
| 81966dac0d | |||
| 7c8e4f1735 | |||
| b9b9363468 | |||
| 11ecfb1bcf | |||
| 605f4e52fe | |||
| a45e649374 | |||
| 3f32c95b35 | |||
| 2919bdf691 | |||
| 6192dfc568 | |||
| de57399301 | |||
| c6e6326b50 | |||
| cf59b6d0da | |||
| d836b1f068 | |||
| 6071a28dd9 | |||
| 03fa16ded2 | |||
| b8eb0a8549 | |||
| 5dac0214ea | |||
| 3ddb7c8ceb | |||
| 03d4b6c4f2 | |||
| 55b551f214 | |||
| 6fe17c1cfd | |||
| 93ccb572e5 | |||
| 03aa1e6dbc | |||
| 059fb67d26 | |||
| eec7f1d5b5 | |||
| 5ab16fbbf3 | |||
| a74f7037ff | |||
| 18cf251c7e | |||
| 5de5488b24 | |||
| 83887b0516 | |||
| ec24c4cfae | |||
| 002461e7cb | |||
| d12e75ae5c | |||
| a480379fa5 | |||
| c37d0d15a6 | |||
| 79ccfd661a | |||
| 67e8c23020 | |||
| 94f0c8308d | |||
| c77b68eed2 | |||
| 80820ae9c4 | |||
| 5288b7a718 | |||
| 4643830512 | |||
| ef04de67c0 | |||
| 5847cceba6 | |||
| f62b86027c | |||
| 8e5018d3b2 | |||
| 8df17f5ae7 | |||
| dd31ce681f | |||
| fd9a963b27 | |||
| 672d252492 | |||
| bc4e00520e | |||
| d777d1bc98 | |||
| 4cd97124da | |||
| 74345fdb2f | |||
| 7e7abdee3d | |||
| 9ed2127494 | |||
| 30608ae8ed | |||
| ae43014cf2 | |||
| ea8d3dd0f3 | |||
| 02231ea1f9 | |||
| effc704613 | |||
| 51fb1fd1cb | |||
| 1988e1b849 | |||
| 1b782f65d1 | |||
| 6c4eddece7 | |||
| cf0524a211 | |||
| a796f279a5 | |||
| efc491bad4 | |||
| 8d04bbbdbe | |||
| f6c08f8afa | |||
| 3197c53fcc | |||
| 7b793149b3 | |||
| 24c938728a | |||
| 1c358a3c79 | |||
| 3bbed8875c | |||
| a3e6ff34db | |||
| 0f7fc673eb | |||
| 646c95a86f | |||
| 87a8974c8c | |||
| 9b5df28b93 | |||
| e876e290da | |||
| f26f033b14 | |||
| 565f323179 | |||
| 82b2aeb294 | |||
| 6b59658f00 | |||
| 2e1e4416b3 | |||
| e92a2c571c | |||
| d3a418b5ee | |||
| c9945107e9 | |||
| 4c70133ca9 | |||
| 0df942cb9d | |||
| 37a63f068b | |||
| cc8638e8b2 | |||
| fd20081ce8 | |||
| cf9d409166 | |||
| 7d8ac49fe2 | |||
| dc3be6564b | |||
| 337e27f2b5 | |||
| c400437662 | |||
| e8941e8ef6 | |||
| 169823980f | |||
| aa7376b357 | |||
| 6a0e88cbf1 | |||
| 01b7e1cea2 | |||
| 910f43e0a5 | |||
| 6bf630bb40 | |||
| 5e71b3f44a | |||
| 5ffab157d7 | |||
| c6e791d18f | |||
| 5a30376f2c | |||
| 373219ecfa | |||
| 1ef1400699 | |||
| 7966d07158 | |||
| 9ffab3d2dd | |||
| dbcbd8928b | |||
| a659611897 | |||
| 78b4716a2a | |||
| 08e26e28d0 | |||
| b1c61a7888 | |||
| e951a3b00a | |||
| 62b5aab753 | |||
| 7b307ffe22 | |||
| edee9f7030 | |||
| 71949890da | |||
| 5ae233ff62 | |||
| 19400a78e5 | |||
| 497d6979d0 | |||
| 59eab8afea | |||
| 74b84eb5ac | |||
| bfc864cc7c | |||
| 6c067a3ae6 | |||
| 503fed5fdb | |||
| 32cb3eeba3 | |||
| 7e49e85495 | |||
| c3d7984d7a | |||
| b024518f5e | |||
| 83c1e9aa6c | |||
| 8a6cb02dc0 | |||
| 91237c252c |
+29
-11
@@ -26,19 +26,37 @@ test:
|
||||
script:
|
||||
- npm run test
|
||||
|
||||
pages:
|
||||
deploy-nsite:
|
||||
stage: deploy
|
||||
timeout: 5 minutes
|
||||
timeout: 10 minutes
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
when: never
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
|
||||
variables:
|
||||
NSYTE_VERSION: "v0.24.1"
|
||||
script:
|
||||
# Build the web app
|
||||
- npm ci
|
||||
- npm run build
|
||||
- rm -rf public
|
||||
- mv dist public
|
||||
artifacts:
|
||||
paths:
|
||||
- public
|
||||
only:
|
||||
variables:
|
||||
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
|
||||
- cp dist/index.html dist/404.html
|
||||
|
||||
# Download nsyte binary
|
||||
- curl -fsSL "https://github.com/sandwichfarm/nsyte/releases/download/${NSYTE_VERSION}/nsyte-linux" -o /usr/local/bin/nsyte
|
||||
- chmod +x /usr/local/bin/nsyte
|
||||
|
||||
# Deploy to nsite via nsyte using the nbunksec credential
|
||||
- >-
|
||||
nsyte deploy ./dist
|
||||
-i
|
||||
--sec "$NSITE_NBUNKSEC"
|
||||
--name ditto
|
||||
--relays "wss://relay.ditto.pub,wss://relay.nsite.lol,wss://relay.dreamith.to,wss://relay.primal.net"
|
||||
--servers "https://blossom.primal.net,https://blossom.ditto.pub,https://blossom.dreamith.to"
|
||||
--fallback "/index.html"
|
||||
--publish-server-list
|
||||
--use-fallback-relays
|
||||
--use-fallback-servers
|
||||
|
||||
build-apk:
|
||||
stage: build
|
||||
@@ -198,4 +216,4 @@ publish-zapstore:
|
||||
- VERSION="${CI_COMMIT_TAG#v}"
|
||||
- sed -i "2i release_source:\ ./${APK_PATH}" zapstore.yaml
|
||||
- sed -i "2i version:\ ${VERSION}" zapstore.yaml
|
||||
- zsp publish -y --skip-metadata --skip-preview zapstore.yaml
|
||||
- zsp publish --quiet --skip-metadata --skip-preview zapstore.yaml
|
||||
|
||||
@@ -294,14 +294,16 @@ When adding support for a new Nostr event kind to the application, the kind must
|
||||
- `WELL_KNOWN_KIND_LABELS` in `src/components/ExternalContentHeader.tsx` -- used in addressable event preview headers
|
||||
- The icon fallback in `AddressableEventPreview` in the same file
|
||||
|
||||
6. **Inline embeds / quote posts** -- events can be quoted inline via `nostr:nevent1...` or `nostr:naddr1...` URIs in note content. Both `EmbeddedNote` and `EmbeddedNaddr` render a compact card (author + title/content preview) for all kinds automatically — no per-kind registration needed. The same components are reused by CommentContext hover cards and the reply composer.
|
||||
6. **Embedded note cards** (`src/components/EmbeddedNote.tsx`, `src/components/EmbeddedNaddr.tsx`) -- these are the small preview cards shown inside quote posts, reply context indicators, and CommentContext hover cards. They are **separate components** from `NoteCard` and render a minimal card (author + title/content preview + attachment indicators). Basic rendering works for all kinds automatically, but kinds whose media lives in tags rather than in the `content` field (e.g. kind 20 photos via `imeta` tags) may need attachment indicator logic added to `EmbeddedNoteCard`.
|
||||
|
||||
> **Note**: Do not confuse these with the `compact` prop on `NoteCard`. The `compact` prop simply hides action buttons on a full `NoteCard`; `EmbeddedNote`/`EmbeddedNaddr` are entirely different components with their own rendering logic.
|
||||
|
||||
7. **Reply composer** (`src/components/ReplyComposeModal.tsx`):
|
||||
- The `EmbeddedPost` component delegates to the shared `EmbeddedNote`/`EmbeddedNaddr` components — no per-kind registration needed
|
||||
|
||||
#### Why so many places?
|
||||
|
||||
These are genuinely different UI contexts (feed cards, detail pages, inline embeds, reply previews, comment context labels) with different rendering requirements. However, several of them maintain independent kind-to-label maps that could theoretically be unified. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points.
|
||||
These are genuinely different UI contexts (feed cards, detail pages, embedded note cards, reply previews, comment context labels) with different rendering requirements. However, several of them maintain independent kind-to-label maps that could theoretically be unified. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points.
|
||||
|
||||
### NIP.md
|
||||
|
||||
@@ -693,6 +695,27 @@ export function MyComponent() {
|
||||
|
||||
The `useCurrentUser` hook should be used to ensure that the user is logged in before they are able to publish Nostr events.
|
||||
|
||||
### Mutating Replaceable Events (CRITICAL)
|
||||
|
||||
Replaceable (kind 10000-19999) and addressable (kind 30000-39999) events require a read-modify-write cycle: fetch the current event, modify its tags, then publish a new version. **Never read from TanStack Query cache before mutating** -- the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
|
||||
|
||||
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation:
|
||||
|
||||
```typescript
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
// Inside a mutation function:
|
||||
const freshEvent = await fetchFreshEvent(nostr, {
|
||||
kinds: [10003],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
const currentTags = freshEvent?.tags ?? [];
|
||||
// ...modify tags...
|
||||
await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newTags });
|
||||
```
|
||||
|
||||
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
|
||||
|
||||
### Nostr Login
|
||||
|
||||
To enable login with Nostr, simply use the `LoginArea` component already included in this project.
|
||||
@@ -954,6 +977,16 @@ const defaultConfig: AppConfig = {
|
||||
|
||||
The app uses NIP-65 compatible relay management with automatic sync when users log in. Local storage persists user preferences and relay configurations.
|
||||
|
||||
### Adding a New AppConfig Value
|
||||
|
||||
Adding a new configuration field requires updates in **three places**. Missing any of them will cause build failures or runtime issues.
|
||||
|
||||
1. **TypeScript interface** (`src/contexts/AppContext.ts`): Add the field to the `AppConfig` interface with a JSDoc comment.
|
||||
|
||||
2. **Zod schema** (`src/lib/schemas.ts`): Add the same field to `AppConfigSchema`. The `DittoConfigSchema` (used to validate the build-time `ditto.json` file) is derived from `AppConfigSchema` with `.strict()` mode, so any field present in `ditto.json` but missing from the Zod schema will cause a build error.
|
||||
|
||||
3. **Default value** (`src/contexts/AppContext.ts`): If the field is required (not optional), add a default value in `defaultConfig`. Optional fields (`?` in the interface, `.optional()` in Zod) can be omitted from the default.
|
||||
|
||||
### Relay Management
|
||||
|
||||
The project includes a complete NIP-65 relay management system:
|
||||
@@ -1315,7 +1348,7 @@ After adding or removing plugins, run `npx cap sync` to update the native projec
|
||||
The project uses GitLab CI (`.gitlab-ci.yml`) with the following stages:
|
||||
|
||||
1. **test** - Runs `npm run test` on every commit (skipped for tags)
|
||||
2. **deploy** - Builds and deploys to GitLab Pages (default branch only)
|
||||
2. **deploy** - Builds and deploys to nsite via nsyte (`deploy-nsite` job, default branch only)
|
||||
3. **build** - Builds a signed release APK (`build-apk` job, tags only)
|
||||
4. **release** - Creates a GitLab Release with the APK artifact (tags only)
|
||||
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only)
|
||||
@@ -1355,19 +1388,69 @@ NIP-46 bunker signing requires two keys: the **user's key** (held by Amber) and
|
||||
The `publish-zapstore` job restores the client key from `ZAPSTORE_CLIENT_KEY` into `~/.config/zsp/bunker-keys/<bunker-pubkey>.key` before running `zsp`, so the bunker recognizes the CI runner as an already-authorized client.
|
||||
|
||||
**Initial setup (one-time):**
|
||||
1. Generate a client key: `nak key generate` (save the hex output)
|
||||
2. Store it as `ZAPSTORE_CLIENT_KEY` in GitLab CI/CD variables
|
||||
3. Get a bunker URL from Amber (with `secret` param for first connection)
|
||||
4. Authorize the client key locally using `nak`:
|
||||
```bash
|
||||
export NOSTR_CLIENT_KEY="<the hex client key>"
|
||||
nak event --sec "bunker://<pubkey>?relay=...&secret=<secret>" -c "test"
|
||||
```
|
||||
5. Approve the connection on Amber when prompted
|
||||
6. Store the bunker URL **without the `secret` param** as `ZAPSTORE_BUNKER_URL` in GitLab CI/CD variables (the secret is single-use and no longer needed after authorization)
|
||||
|
||||
Run the NIP-46 client-initiated auth script:
|
||||
|
||||
```bash
|
||||
node scripts/nip46-auth.mjs
|
||||
```
|
||||
|
||||
This generates a `nostrconnect://` URI. Import/paste it into Amber and approve the connection. The script will then output the `bunker://` URI and client key hex, and write the client key to `~/.config/zsp/bunker-keys/`. Update the GitLab CI/CD variables with the printed values.
|
||||
|
||||
The script accepts options:
|
||||
- `--relay <url>` -- relay for NIP-46 communication (default: `wss://relay.ditto.pub`)
|
||||
- `--name <name>` -- app name shown to the signer (default: `Ditto`)
|
||||
- `--timeout <sec>` -- how long to wait for approval (default: 300)
|
||||
|
||||
**Key points:**
|
||||
- The `secret` in bunker URLs is **single-use** -- it is consumed on first connection and cannot be reused
|
||||
- The `ZAPSTORE_CLIENT_KEY` must be authorized locally first by connecting to the bunker with a fresh secret and approving on Amber
|
||||
- After authorization, the bunker recognizes the client key and no secret or manual approval is needed for CI runs
|
||||
- If the client key is rotated, the authorization step must be repeated with a new bunker URL secret
|
||||
- If the client key is rotated, run the script again and update the GitLab CI/CD variables
|
||||
|
||||
### nsite Publishing
|
||||
|
||||
The project automatically deploys the web app to [nsite](https://nsite.run) on every push to the default branch using [nsyte](https://github.com/sandwichfarm/nsyte). The `deploy-nsite` CI job builds the Vite app and uploads the `dist/` directory to Blossom servers, publishing site manifest events to Nostr relays.
|
||||
|
||||
nsyte uses a NIP-46 bunker credential called `nbunksec` -- a bech32-encoded string that bundles the bunker pubkey, client secret key, and relay info into a single self-contained token. This is passed to nsyte via `--sec`.
|
||||
|
||||
**GitLab CI/CD Variables** (Settings > CI/CD > Variables):
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `NSITE_NBUNKSEC` | nbunksec credential from `nsyte ci`. Must start with `nbunksec1`. | Yes | Yes | Yes |
|
||||
|
||||
#### Initial Setup (one-time)
|
||||
|
||||
1. Install nsyte locally:
|
||||
```bash
|
||||
curl -fsSL https://nsyte.run/get/install.sh | bash
|
||||
```
|
||||
|
||||
2. Generate the CI credential:
|
||||
```bash
|
||||
nsyte ci
|
||||
```
|
||||
This will guide you through connecting a NIP-46 bunker (e.g. Amber) and output an `nbunksec1...` string. The credential is shown only once.
|
||||
|
||||
3. Add the `nbunksec1...` value as the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**.
|
||||
|
||||
#### Configured Relays and Servers
|
||||
|
||||
The deploy job publishes to these relays:
|
||||
- `wss://relay.ditto.pub`
|
||||
- `wss://relay.nsite.lol`
|
||||
- `wss://relay.dreamith.to`
|
||||
- `wss://relay.primal.net`
|
||||
|
||||
And uploads blobs to these Blossom servers:
|
||||
- `https://blossom.primal.net`
|
||||
- `https://blossom.ditto.pub`
|
||||
- `https://blossom.dreamith.to`
|
||||
|
||||
The `--use-fallback-relays` and `--use-fallback-servers` flags also include nsyte's built-in defaults for broader coverage. The `--fallback "/index.html"` flag enables SPA client-side routing.
|
||||
|
||||
#### Credential Rotation
|
||||
|
||||
To rotate the nsite credential:
|
||||
1. Revoke the old bunker connection in your signer app
|
||||
2. Run `nsyte ci` again to generate a new `nbunksec1...` string
|
||||
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings
|
||||
+113
-1
@@ -1,5 +1,117 @@
|
||||
# Changelog
|
||||
|
||||
## [2.2.8] - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
|
||||
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
|
||||
|
||||
### Changed
|
||||
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
|
||||
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
|
||||
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
|
||||
|
||||
### Fixed
|
||||
- Notification dot not clearing after marking notifications as read
|
||||
- Followers/following modal staying open after navigating to a profile
|
||||
|
||||
## [2.2.7] - 2026-03-31
|
||||
|
||||
### Fixed
|
||||
- Nushu script in encrypted letters now renders correctly on Android and iOS
|
||||
|
||||
## [2.2.6] - 2026-03-31
|
||||
|
||||
### Added
|
||||
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
|
||||
- Zap receipts and profile metadata events now render in feeds and detail pages
|
||||
- Remote signer callback page for NIP-46 login flows (Amber, Primal)
|
||||
|
||||
### Changed
|
||||
- Post action buttons extracted into a reusable PostActionBar component
|
||||
- Badge detail page streamlined with unified tab bar
|
||||
|
||||
### Fixed
|
||||
- Hashtags now support accented and Unicode characters
|
||||
- Letter compose opens correctly from notifications and the letters page
|
||||
- Letter font picker loads fonts so each option previews in the correct typeface
|
||||
- Zap comment positioned inside the right column instead of floating with offset
|
||||
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
|
||||
|
||||
## [2.2.5] - 2026-03-30
|
||||
|
||||
### Fixed
|
||||
- Crash when dragging profile tabs to reorder them
|
||||
|
||||
## [2.2.4] - 2026-03-30
|
||||
|
||||
### Changed
|
||||
- Profiles now have an emoji reaction button instead of a zap button -- express yourself with any emoji or custom emoji right on someone's profile
|
||||
- Zap moved to the profile overflow menu so it's still one tap away
|
||||
|
||||
### Fixed
|
||||
- Crash on the notifications page caused by malformed badge award tags
|
||||
- Deleting a badge now also deletes all awards you issued for it
|
||||
- Custom emoji reactions missing their image tag no longer render as broken shortcodes
|
||||
- Deletion requests for addressable events now include both `e` and `a` tags for broader relay compatibility
|
||||
- Profile reactions no longer collapse into a single grouped notification
|
||||
- Oversized reaction emoji in comment context headers
|
||||
|
||||
## [2.2.3] - 2026-03-30
|
||||
|
||||
### Added
|
||||
- Letters now have an overflow menu, reply button, and a grid layout for browsing
|
||||
- Independent feed toggles for comments and generic reposts in content settings
|
||||
- Sidebar items are now visible to logged-out users so newcomers can explore everything
|
||||
|
||||
### Changed
|
||||
- Compose textarea expands smoothly as you type instead of snapping to a new height
|
||||
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
|
||||
|
||||
### Fixed
|
||||
- Feed gaps when replies are disabled no longer cause missing posts
|
||||
- Avatar shape no longer flashes on load
|
||||
- Top bar arc no longer flickers during navigation transitions
|
||||
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
|
||||
- Notification rendering for badges and letters
|
||||
- Duplicate React keys in content settings
|
||||
- Layout rendering warning when switching views
|
||||
|
||||
## [2.2.2] - 2026-03-29
|
||||
|
||||
### Added
|
||||
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
|
||||
- Pull-to-refresh on all feed pages
|
||||
- 3D tilt effect on badge images -- hover over badges to see them pop
|
||||
- Multi-select badge awarding with indicators for already-sent badges
|
||||
- Badge list recovery dialog for restoring kind 10008 profile badge lists
|
||||
- Compact badge row preview in embedded profile badges events
|
||||
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
|
||||
- Release notes now included in Zapstore publishing
|
||||
- Changelog link in the app footer
|
||||
|
||||
### Changed
|
||||
- "Vines" renamed to "Divines" everywhere in the app
|
||||
- Custom emojis appear first in the emoji picker, right after recent
|
||||
- Threaded comment view now shows the parent event as a NoteCard with kind action headers
|
||||
|
||||
### Fixed
|
||||
- Delete post dialog no longer freezes the feed on desktop
|
||||
- Amber login on Android now properly retries when returning from the background
|
||||
- Key downloads on Android save to the correct location
|
||||
- Custom emoji SVGs render correctly in the emoji-mart picker
|
||||
- Double-tap reactions now properly show the emoji on the post
|
||||
- Emoji shortcode autocomplete text and highlight colors
|
||||
- Profile skeleton no longer flickers for brand-new users with no metadata
|
||||
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
|
||||
- Badge notifications are now clickable
|
||||
- Custom profile tab form no longer retains fields from a previously edited tab
|
||||
- Double line under profile tabs in edit mode
|
||||
- Inconsistent use of "geocache" vs "treasures" terminology
|
||||
- Search page "N new posts" pill no longer shows unfiltered count
|
||||
- Stale-cache overwrites in replaceable event mutations
|
||||
- Click-through on delete confirmation and note menu items
|
||||
|
||||
## [2.2.1] - 2026-03-28
|
||||
|
||||
### Fixed
|
||||
@@ -17,7 +129,7 @@
|
||||
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
|
||||
- Blobbi shop and inventory system with items that affect your pet's stats
|
||||
- Daily missions with reroll, care streaks, and stage-based rewards
|
||||
- Immersive full-screen vines experience on both mobile and desktop with floating controls
|
||||
- Immersive full-screen divines experience on both mobile and desktop with floating controls
|
||||
- NIP-11 relay information panel on the network settings page
|
||||
- Link preview cards now display inside quoted posts instead of raw URLs
|
||||
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
|
||||
|
||||
@@ -2,12 +2,31 @@
|
||||
|
||||
## Event Kinds Overview
|
||||
|
||||
### Ditto Kinds
|
||||
|
||||
| Kind | Name | Description |
|
||||
|-------|----------------------|-------------------------------------------------------|
|
||||
| 36767 | Theme Definition | Shareable, named custom UI theme |
|
||||
| 16767 | Active Profile Theme | The user's currently active theme (one per user) |
|
||||
| 16769 | Profile Tabs | The user's custom profile page tabs (one per user) |
|
||||
| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery |
|
||||
|
||||
### Community Kinds
|
||||
|
||||
These event kinds were created by community contributors and are supported by Ditto. Full specifications are maintained by their respective authors.
|
||||
|
||||
| Kind | Name | Description | Spec |
|
||||
|-------|------------------------|------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| 3367 | Color Moment | Color palette post expressing a mood | [NIP](https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md) |
|
||||
| 4223 | Weather Reading | Sensor readings from a weather station | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
|
||||
| 7516 | Found Log | Log entry recording a user finding a geocache | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
|
||||
| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery | [NIP](https://gitlab.com/chad.curtis/lief/-/blob/main/NIP.md) |
|
||||
| 11125 | Blobbonaut Profile | Owner profile with coins, achievements, and inventory | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 14919 | Blobbi Interaction | Individual pet interaction (feed, play, clean, etc.) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 14920 | Blobbi Breeding | Breeding event between two adult Blobbis | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 14921 | Blobbi Record | Immutable lifecycle record (birth, evolution, adoption) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 16158 | Weather Station | Weather station metadata (location, sensors, connectivity) | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
|
||||
| 31124 | Blobbi Pet State | Current state of a virtual Blobbi pet (addressable) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
|
||||
| 37516 | Geocache | Geocache listing for real-world treasure hunting | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -294,3 +313,51 @@ The `shape` field is added to the JSON content of a kind 0 event alongside stand
|
||||
- The `shape` field is purely cosmetic and has no protocol-level significance.
|
||||
- Clients MAY choose not to support this extension, in which case avatars render as circles as usual.
|
||||
|
||||
---
|
||||
|
||||
## Community NIP Specifications
|
||||
|
||||
The following specifications are maintained by their respective authors. Ditto implements these kinds but does not own the specs. See each link for the full event structure, tags, and client behavior.
|
||||
|
||||
### Color Moments (Kind 3367)
|
||||
|
||||
**Author:** Chad Curtis
|
||||
**Spec:** https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md
|
||||
**App:** https://espy.you
|
||||
|
||||
Color palette posts capturing 3-6 colors from a beautiful moment, optionally accompanied by an emoji and layout preference. Supports horizontal, vertical, grid, star, checkerboard, and diagonal stripe layouts. A form of pre-verbal visual communication through color and emotion.
|
||||
|
||||
### Geocaching (Kinds 37516, 7516)
|
||||
|
||||
**Author:** Chad Curtis
|
||||
**Spec:** https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md
|
||||
**App:** https://treasures.to
|
||||
|
||||
NIP-GC defines geocaching on Nostr. Kind 37516 (addressable) is a geocache listing with location (geohash), difficulty/terrain scores, size, and type. Kind 7516 is a found log recording a successful visit. The spec also covers comment logs (kind 1111 via NIP-22), verified finds with cryptographic proof (kind 7517), and cache retirement.
|
||||
|
||||
### Personal Letters (Kind 8211)
|
||||
|
||||
**Author:** Chad Curtis
|
||||
**Spec:** https://gitlab.com/chad.curtis/lief/-/blob/main/NIP.md
|
||||
**App:** https://lief.to
|
||||
|
||||
NIP-44 encrypted personal letters with visual stationery, hand-drawn stickers, decorative frames, and custom fonts. Letters render as 5:4 landscape postcards. The privacy model is intentionally postcard-like: sender/recipient metadata is visible, content is encrypted.
|
||||
|
||||
### Weather Station (Kinds 4223, 16158)
|
||||
|
||||
**Author:** Sam Thomson
|
||||
**Spec:** https://github.com/nostr-protocol/nips/pull/2163
|
||||
**App:** https://weather.shakespeare.wtf
|
||||
**Firmware:** https://github.com/samthomson/weather-station
|
||||
|
||||
Kind 16158 (replaceable) describes a weather station's configuration: name, geohash location, elevation, power source, connectivity, and sensor inventory. Kind 4223 (regular) carries individual sensor readings as 3-parameter tags `[sensor_type, value, model]`, enabling historical queries and cross-station comparison. Each station has its own keypair.
|
||||
|
||||
### Blobbi Virtual Pet (Kinds 31124, 14919, 14920, 14921, 11125)
|
||||
|
||||
**Author:** Danifra
|
||||
**Spec:** https://github.com/Danidfra/nostr-pet/blob/production/NIP.md
|
||||
**App:** https://nostr-pet.vercel.app
|
||||
**See also:** [Blobbi tag schema](docs/blobbi/blobbi-tag-schema.md) (Ditto-specific integration details)
|
||||
|
||||
NIP-BB defines a virtual pet lifecycle on Nostr. Kind 31124 (addressable) holds the current pet state across three stages (egg, baby, adult) with stats, appearance, and personality traits. Kind 14919 logs individual interactions, kind 14920 records breeding events, kind 14921 stores immutable lifecycle records, and kind 11125 (replaceable) holds the owner's profile with coins, achievements, and inventory.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Made by [Soapbox](https://soapbox.pub).
|
||||
## Features
|
||||
|
||||
- **Theming** -- 9 built-in theme presets, 19 CSS token properties for full customization, and the ability to publish and share themes as Nostr events
|
||||
- **Infinite Content Types** -- Text notes, articles, short-form videos (Vines), live streams, polls, follow packs, color moments, magic decks, geocaching, and Webxdc mini-apps
|
||||
- **Infinite Content Types** -- Text notes, articles, short-form videos (Divines), live streams, polls, follow packs, color moments, magic decks, geocaching, and Webxdc mini-apps
|
||||
- **Lightning Payments** -- Zap posts and profiles with sats via Nostr Wallet Connect (NWC) or WebLN
|
||||
- **Private Messaging** -- End-to-end encrypted DMs (NIP-04 and NIP-17)
|
||||
- **Comments** -- Comment on anything: posts, URLs, profiles, hashtags, books, and more (NIP-22)
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.2.1"
|
||||
versionName "2.2.8"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -35,8 +35,9 @@ android {
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+13
-6
@@ -5,12 +5,19 @@
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
# Keep Capacitor classes (WebView JS bridge)
|
||||
-keep class com.getcapacitor.** { *; }
|
||||
-keep class pub.ditto.app.** { *; }
|
||||
|
||||
# Keep WebView JS interfaces
|
||||
-keepclassmembers class * {
|
||||
@android.webkit.JavascriptInterface <methods>;
|
||||
}
|
||||
|
||||
# Keep OkHttp (used by Capacitor)
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-keep class okhttp3.** { *; }
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
|
||||
@@ -303,7 +303,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
MARKETING_VERSION = 2.2.8;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -325,7 +325,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.1;
|
||||
MARKETING_VERSION = 2.2.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -47,5 +47,9 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Ditto needs access to your photo library to upload images to your posts and profile.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Ditto needs access to your microphone to record voice messages.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array>
|
||||
<!-- Crash / performance data via Sentry/GlitchTip -->
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeCrashData</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- Performance / analytics data via Plausible -->
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypePerformanceData</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<!-- UserDefaults — used by Capacitor/WKWebView for localStorage -->
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<!-- CA92.1: Access info from same app -->
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- File timestamp APIs — used by @capacitor/filesystem -->
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<!-- C617.1: Access file timestamps inside app container -->
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- Disk space APIs — used by WKWebView / file operations -->
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<!-- E174.1: Check available disk space before writing -->
|
||||
<string>E174.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
Generated
+15
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.1.1",
|
||||
"version": "2.2.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.1.1",
|
||||
"version": "2.2.7",
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
@@ -38,6 +38,7 @@
|
||||
"@fontsource/courier-prime": "^5.2.8",
|
||||
"@fontsource/creepster": "^5.2.7",
|
||||
"@fontsource/luckiest-guy": "^5.2.8",
|
||||
"@fontsource/noto-sans-nushu": "^5.2.6",
|
||||
"@fontsource/pacifico": "^5.2.7",
|
||||
"@fontsource/permanent-marker": "^5.2.7",
|
||||
"@fontsource/pirata-one": "^5.2.8",
|
||||
@@ -989,6 +990,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/noto-sans-nushu": {
|
||||
"version": "5.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/noto-sans-nushu/-/noto-sans-nushu-5.2.6.tgz",
|
||||
"integrity": "sha512-YZswFaWI+EspK69GAg0o53WPXsaYu89dhbjwMYvIFVaRTSYKfcLSdTVCksPQ4ClyXpbWEAmsP+MxRlYlV4kM5g==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/pacifico": {
|
||||
"version": "5.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/pacifico/-/pacifico-5.2.7.tgz",
|
||||
@@ -6044,9 +6054,9 @@
|
||||
"license": "unlicense"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.11",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||
"version": "0.8.12",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
|
||||
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.2.1",
|
||||
"version": "2.2.8",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
@@ -44,6 +44,7 @@
|
||||
"@fontsource/courier-prime": "^5.2.8",
|
||||
"@fontsource/creepster": "^5.2.7",
|
||||
"@fontsource/luckiest-guy": "^5.2.8",
|
||||
"@fontsource/noto-sans-nushu": "^5.2.6",
|
||||
"@fontsource/pacifico": "^5.2.7",
|
||||
"@fontsource/permanent-marker": "^5.2.7",
|
||||
"@fontsource/pirata-one": "^5.2.8",
|
||||
|
||||
+113
-1
@@ -1,5 +1,117 @@
|
||||
# Changelog
|
||||
|
||||
## [2.2.8] - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
|
||||
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
|
||||
|
||||
### Changed
|
||||
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
|
||||
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
|
||||
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
|
||||
|
||||
### Fixed
|
||||
- Notification dot not clearing after marking notifications as read
|
||||
- Followers/following modal staying open after navigating to a profile
|
||||
|
||||
## [2.2.7] - 2026-03-31
|
||||
|
||||
### Fixed
|
||||
- Nushu script in encrypted letters now renders correctly on Android and iOS
|
||||
|
||||
## [2.2.6] - 2026-03-31
|
||||
|
||||
### Added
|
||||
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
|
||||
- Zap receipts and profile metadata events now render in feeds and detail pages
|
||||
- Remote signer callback page for NIP-46 login flows (Amber, Primal)
|
||||
|
||||
### Changed
|
||||
- Post action buttons extracted into a reusable PostActionBar component
|
||||
- Badge detail page streamlined with unified tab bar
|
||||
|
||||
### Fixed
|
||||
- Hashtags now support accented and Unicode characters
|
||||
- Letter compose opens correctly from notifications and the letters page
|
||||
- Letter font picker loads fonts so each option previews in the correct typeface
|
||||
- Zap comment positioned inside the right column instead of floating with offset
|
||||
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
|
||||
|
||||
## [2.2.5] - 2026-03-30
|
||||
|
||||
### Fixed
|
||||
- Crash when dragging profile tabs to reorder them
|
||||
|
||||
## [2.2.4] - 2026-03-30
|
||||
|
||||
### Changed
|
||||
- Profiles now have an emoji reaction button instead of a zap button -- express yourself with any emoji or custom emoji right on someone's profile
|
||||
- Zap moved to the profile overflow menu so it's still one tap away
|
||||
|
||||
### Fixed
|
||||
- Crash on the notifications page caused by malformed badge award tags
|
||||
- Deleting a badge now also deletes all awards you issued for it
|
||||
- Custom emoji reactions missing their image tag no longer render as broken shortcodes
|
||||
- Deletion requests for addressable events now include both `e` and `a` tags for broader relay compatibility
|
||||
- Profile reactions no longer collapse into a single grouped notification
|
||||
- Oversized reaction emoji in comment context headers
|
||||
|
||||
## [2.2.3] - 2026-03-30
|
||||
|
||||
### Added
|
||||
- Letters now have an overflow menu, reply button, and a grid layout for browsing
|
||||
- Independent feed toggles for comments and generic reposts in content settings
|
||||
- Sidebar items are now visible to logged-out users so newcomers can explore everything
|
||||
|
||||
### Changed
|
||||
- Compose textarea expands smoothly as you type instead of snapping to a new height
|
||||
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
|
||||
|
||||
### Fixed
|
||||
- Feed gaps when replies are disabled no longer cause missing posts
|
||||
- Avatar shape no longer flashes on load
|
||||
- Top bar arc no longer flickers during navigation transitions
|
||||
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
|
||||
- Notification rendering for badges and letters
|
||||
- Duplicate React keys in content settings
|
||||
- Layout rendering warning when switching views
|
||||
|
||||
## [2.2.2] - 2026-03-29
|
||||
|
||||
### Added
|
||||
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
|
||||
- Pull-to-refresh on all feed pages
|
||||
- 3D tilt effect on badge images -- hover over badges to see them pop
|
||||
- Multi-select badge awarding with indicators for already-sent badges
|
||||
- Badge list recovery dialog for restoring kind 10008 profile badge lists
|
||||
- Compact badge row preview in embedded profile badges events
|
||||
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
|
||||
- Release notes now included in Zapstore publishing
|
||||
- Changelog link in the app footer
|
||||
|
||||
### Changed
|
||||
- "Vines" renamed to "Divines" everywhere in the app
|
||||
- Custom emojis appear first in the emoji picker, right after recent
|
||||
- Threaded comment view now shows the parent event as a NoteCard with kind action headers
|
||||
|
||||
### Fixed
|
||||
- Delete post dialog no longer freezes the feed on desktop
|
||||
- Amber login on Android now properly retries when returning from the background
|
||||
- Key downloads on Android save to the correct location
|
||||
- Custom emoji SVGs render correctly in the emoji-mart picker
|
||||
- Double-tap reactions now properly show the emoji on the post
|
||||
- Emoji shortcode autocomplete text and highlight colors
|
||||
- Profile skeleton no longer flickers for brand-new users with no metadata
|
||||
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
|
||||
- Badge notifications are now clickable
|
||||
- Custom profile tab form no longer retains fields from a previously edited tab
|
||||
- Double line under profile tabs in edit mode
|
||||
- Inconsistent use of "geocache" vs "treasures" terminology
|
||||
- Search page "N new posts" pill no longer shows unfiltered count
|
||||
- Stale-cache overwrites in replaceable event mutations
|
||||
- Click-through on delete confirmation and note menu items
|
||||
|
||||
## [2.2.1] - 2026-03-28
|
||||
|
||||
### Fixed
|
||||
@@ -17,7 +129,7 @@
|
||||
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
|
||||
- Blobbi shop and inventory system with items that affect your pet's stats
|
||||
- Daily missions with reroll, care streaks, and stage-based rewards
|
||||
- Immersive full-screen vines experience on both mobile and desktop with floating controls
|
||||
- Immersive full-screen divines experience on both mobile and desktop with floating controls
|
||||
- NIP-11 relay information panel on the network settings page
|
||||
- Link preview cards now display inside quoted posts instead of raw URLs
|
||||
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* NIP-46 Client-Initiated Auth Script
|
||||
*
|
||||
* Generates an ephemeral client keypair and a `nostrconnect://` URI.
|
||||
* Import the URI into a remote signer app (e.g. Amber) to authorize
|
||||
* the client key. Once authorized, the script outputs:
|
||||
*
|
||||
* - bunker:// URI (for ZAPSTORE_BUNKER_URL)
|
||||
* - client secret key hex (for ZAPSTORE_CLIENT_KEY)
|
||||
*
|
||||
* It also writes the client key to ~/.config/zsp/bunker-keys/<bunkerPubkey>.key
|
||||
* so that `zsp` can use it immediately.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/nip46-auth.mjs [--relay wss://relay.example.com] [--name MyApp] [--timeout 300]
|
||||
*/
|
||||
|
||||
import { NPool, NRelay1, NConnectSigner, NSecSigner } from '@nostrify/nostrify';
|
||||
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { bytesToHex } from '@noble/hashes/utils';
|
||||
import QRCode from 'qrcode';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI args
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const result = {
|
||||
relays: [],
|
||||
name: 'Ditto',
|
||||
timeout: 300, // seconds
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '--relay':
|
||||
result.relays.push(args[++i]);
|
||||
break;
|
||||
case '--name':
|
||||
result.name = args[++i];
|
||||
break;
|
||||
case '--timeout':
|
||||
result.timeout = parseInt(args[++i], 10);
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
console.log(`Usage: node scripts/nip46-auth.mjs [options]
|
||||
|
||||
Options:
|
||||
--relay <url> Relay URL for NIP-46 communication (repeatable)
|
||||
Default: wss://relay.ditto.pub
|
||||
--name <name> Application name shown to the signer
|
||||
Default: Ditto
|
||||
--timeout <sec> How long to wait for signer approval (seconds)
|
||||
Default: 300 (5 minutes)
|
||||
--help, -h Show this help message
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.relays.length === 0) {
|
||||
result.relays.push('wss://relay.ditto.pub');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs();
|
||||
|
||||
// 1. Generate ephemeral client keypair
|
||||
const clientSecretKey = generateSecretKey();
|
||||
const clientPubkey = getPublicKey(clientSecretKey);
|
||||
const clientHex = bytesToHex(clientSecretKey);
|
||||
|
||||
console.log('');
|
||||
console.log('=== NIP-46 Client-Initiated Auth ===');
|
||||
console.log('');
|
||||
console.log(`Client pubkey: ${clientPubkey}`);
|
||||
console.log(`Relay(s): ${opts.relays.join(', ')}`);
|
||||
console.log(`Timeout: ${opts.timeout}s`);
|
||||
console.log('');
|
||||
|
||||
// 2. Generate random secret
|
||||
const randomBytes = new Uint8Array(4);
|
||||
crypto.getRandomValues(randomBytes);
|
||||
const secret = Array.from(randomBytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
// 3. Build nostrconnect:// URI
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const relay of opts.relays) {
|
||||
searchParams.append('relay', relay);
|
||||
}
|
||||
searchParams.set('secret', secret);
|
||||
searchParams.set('name', opts.name);
|
||||
|
||||
const nostrConnectURI = `nostrconnect://${clientPubkey}?${searchParams.toString()}`;
|
||||
|
||||
console.log('Scan this QR code with your signer app (e.g. Amber):');
|
||||
console.log('');
|
||||
console.log(await QRCode.toString(nostrConnectURI, { type: 'terminal', small: true }));
|
||||
console.log('Or import this URI manually:');
|
||||
console.log('');
|
||||
console.log(` ${nostrConnectURI}`);
|
||||
console.log('');
|
||||
console.log('Waiting for signer to approve the connection...');
|
||||
console.log('');
|
||||
|
||||
// 4. Set up relay pool
|
||||
const pool = new NPool({
|
||||
open: (url) => new NRelay1(url),
|
||||
reqRouter: async (filters) => new Map(opts.relays.map((r) => [r, filters])),
|
||||
eventRouter: async () => opts.relays,
|
||||
});
|
||||
|
||||
const clientSigner = new NSecSigner(clientSecretKey);
|
||||
const relayGroup = pool.group(opts.relays);
|
||||
|
||||
// 5. Subscribe and wait for the signer's response
|
||||
const signal = AbortSignal.timeout(opts.timeout * 1000);
|
||||
|
||||
const sub = relayGroup.req(
|
||||
[{ kinds: [24133], '#p': [clientPubkey], limit: 1 }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
let bunkerPubkey;
|
||||
let userPubkey;
|
||||
|
||||
try {
|
||||
for await (const msg of sub) {
|
||||
if (msg[0] === 'CLOSED') {
|
||||
throw new Error('Relay closed the subscription before signer responded');
|
||||
}
|
||||
if (msg[0] === 'EVENT') {
|
||||
const event = msg[2];
|
||||
|
||||
let decrypted;
|
||||
try {
|
||||
decrypted = await clientSigner.nip44.decrypt(event.pubkey, event.content);
|
||||
} catch {
|
||||
// Could not decrypt -- not for us, skip
|
||||
continue;
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(decrypted);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (response.result !== secret && response.result !== 'ack') {
|
||||
continue;
|
||||
}
|
||||
|
||||
bunkerPubkey = event.pubkey;
|
||||
|
||||
console.log(`Signer responded! Bunker pubkey: ${bunkerPubkey}`);
|
||||
console.log('');
|
||||
|
||||
// 6. Get user pubkey via the now-established connection
|
||||
const signer = new NConnectSigner({
|
||||
relay: relayGroup,
|
||||
pubkey: bunkerPubkey,
|
||||
signer: clientSigner,
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
console.log('Requesting user public key...');
|
||||
userPubkey = await signer.getPublicKey();
|
||||
console.log(`User pubkey: ${userPubkey}`);
|
||||
console.log('');
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
||||
console.error(`Timed out after ${opts.timeout}s waiting for signer approval.`);
|
||||
console.error('Make sure you imported the nostrconnect:// URI into your signer app.');
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!bunkerPubkey || !userPubkey) {
|
||||
console.error('Failed to establish connection with remote signer.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 7. Build bunker:// URI (for CI)
|
||||
const bunkerParams = new URLSearchParams();
|
||||
for (const relay of opts.relays) {
|
||||
bunkerParams.append('relay', relay);
|
||||
}
|
||||
const bunkerURI = `bunker://${bunkerPubkey}?${bunkerParams.toString()}`;
|
||||
|
||||
// 8. Write client key to zsp config
|
||||
const zspDir = path.join(os.homedir(), '.config', 'zsp', 'bunker-keys');
|
||||
const zspKeyFile = path.join(zspDir, `${bunkerPubkey}.key`);
|
||||
|
||||
fs.mkdirSync(zspDir, { recursive: true });
|
||||
fs.writeFileSync(zspKeyFile, clientHex + '\n', { mode: 0o600 });
|
||||
|
||||
// 9. Print results
|
||||
console.log('=== Connection Established ===');
|
||||
console.log('');
|
||||
console.log('Bunker URI (ZAPSTORE_BUNKER_URL):');
|
||||
console.log(` ${bunkerURI}`);
|
||||
console.log('');
|
||||
console.log('Client secret key hex (ZAPSTORE_CLIENT_KEY):');
|
||||
console.log(` ${clientHex}`);
|
||||
console.log('');
|
||||
console.log(`User pubkey: ${userPubkey}`);
|
||||
console.log(`User npub: ${nip19.npubEncode(userPubkey)}`);
|
||||
console.log('');
|
||||
console.log(`zsp client key written to: ${zspKeyFile}`);
|
||||
console.log('');
|
||||
console.log('Next steps:');
|
||||
console.log(' 1. Update ZAPSTORE_BUNKER_URL in GitLab CI/CD variables');
|
||||
console.log(' 2. Update ZAPSTORE_CLIENT_KEY in GitLab CI/CD variables');
|
||||
console.log('');
|
||||
|
||||
// Clean up
|
||||
pool.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
+6
-5
@@ -26,7 +26,7 @@ import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
|
||||
import AppRouter from "./AppRouter";
|
||||
|
||||
const dmConfig: DMConfig = {
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
protocolMode: PROTOCOL_MODE.NIP04_OR_NIP17,
|
||||
};
|
||||
|
||||
@@ -59,7 +59,9 @@ const hardcodedConfig: AppConfig = {
|
||||
},
|
||||
feedSettings: {
|
||||
feedIncludePosts: true,
|
||||
feedIncludeComments: true,
|
||||
feedIncludeReposts: true,
|
||||
feedIncludeGenericReposts: true,
|
||||
feedIncludeArticles: true,
|
||||
showArticles: true,
|
||||
showEvents: true,
|
||||
@@ -118,11 +120,10 @@ const hardcodedConfig: AppConfig = {
|
||||
"feed",
|
||||
"notifications",
|
||||
"search",
|
||||
"bookmarks",
|
||||
"profile",
|
||||
"photos",
|
||||
"videos",
|
||||
"themes",
|
||||
"letters",
|
||||
"badges",
|
||||
"blobbi",
|
||||
"theme",
|
||||
"settings",
|
||||
"help",
|
||||
|
||||
@@ -45,6 +45,7 @@ const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default:
|
||||
const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ default: m.HashtagPage })));
|
||||
const HelpPage = lazy(() => import("./pages/HelpPage").then(m => ({ default: m.HelpPage })));
|
||||
const KindFeedPage = lazy(() => import("./pages/KindFeedPage").then(m => ({ default: m.KindFeedPage })));
|
||||
const LetterComposePage = lazy(() => import("./pages/LetterComposePage").then(m => ({ default: m.LetterComposePage })));
|
||||
const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage").then(m => ({ default: m.LetterPreferencesPage })));
|
||||
const LettersPage = lazy(() => import("./pages/LettersPage").then(m => ({ default: m.LettersPage })));
|
||||
const MagicSettingsPage = lazy(() => import("./pages/MagicSettingsPage").then(m => ({ default: m.MagicSettingsPage })));
|
||||
@@ -70,6 +71,7 @@ const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(
|
||||
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
|
||||
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
|
||||
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
|
||||
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
|
||||
|
||||
const pollsDef = getExtraKindDef("polls")!;
|
||||
const colorsDef = getExtraKindDef("colors")!;
|
||||
@@ -237,6 +239,7 @@ export function AppRouter() {
|
||||
<Route path="/bluesky" element={<BlueskyPage />} />
|
||||
<Route path="/wikipedia" element={<WikipediaPage />} />
|
||||
<Route path="/letters" element={<LettersPage />} />
|
||||
<Route path="/letters/compose" element={<LetterComposePage />} />
|
||||
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
|
||||
<Route path="/help" element={<HelpPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
@@ -249,6 +252,8 @@ export function AppRouter() {
|
||||
/>
|
||||
<Route path="/i/*" element={<ExternalContentPage />} />
|
||||
|
||||
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
|
||||
<Route path="/remoteloginsuccess" element={<RemoteLoginSuccessPage />} />
|
||||
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
|
||||
<Route path="/:nip19" element={<NIP19Page />} />
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { InventoryAction, DirectAction } from '../lib/blobbi-action-utils';
|
||||
|
||||
interface BlobbiActionsModalProps {
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { HatchTasksResult } from '../hooks/useHatchTasks';
|
||||
import type { EvolveTasksResult } from '../hooks/useEvolveTasks';
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { ExternalLink, Check, Loader2, ChevronRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
@@ -48,7 +49,7 @@ function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
|
||||
navigate(task.actionTarget);
|
||||
break;
|
||||
case 'external_link':
|
||||
window.open(task.actionTarget, '_blank', 'noopener,noreferrer');
|
||||
openUrl(task.actionTarget);
|
||||
break;
|
||||
case 'open_modal':
|
||||
if (task.actionTarget === 'blobbi_post') {
|
||||
|
||||
@@ -9,14 +9,14 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useAudioPlayback } from '../hooks/useAudioPlayback';
|
||||
import type { AudioSource } from './PlayMusicModal';
|
||||
import type { SelectedTrack } from './PlayMusicModal';
|
||||
|
||||
// Re-export for external use
|
||||
export type { AudioSource as MusicTrackSource } from './PlayMusicModal';
|
||||
export type { SelectedTrack } from './PlayMusicModal';
|
||||
|
||||
interface InlineMusicPlayerProps {
|
||||
/** The selected track source */
|
||||
source: AudioSource;
|
||||
/** The selected track */
|
||||
selection: SelectedTrack;
|
||||
/** Called when user wants to change the track */
|
||||
onChangeTrack: () => void;
|
||||
/** Called when user closes the player */
|
||||
@@ -34,7 +34,7 @@ interface InlineMusicPlayerProps {
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function InlineMusicPlayer({
|
||||
source,
|
||||
selection,
|
||||
onChangeTrack,
|
||||
onClose,
|
||||
onPlaybackStart,
|
||||
@@ -64,20 +64,20 @@ export function InlineMusicPlayer({
|
||||
// that requires explicit user action (play button) to restart
|
||||
useEffect(() => {
|
||||
if (isPublished && playbackState === 'idle') {
|
||||
load(source.url, true);
|
||||
load(selection.url, true);
|
||||
onPlaybackStart?.();
|
||||
}
|
||||
}, [isPublished, playbackState, source.url, load, onPlaybackStart]);
|
||||
}, [isPublished, playbackState, selection.url, load, onPlaybackStart]);
|
||||
|
||||
// Force reload when source URL changes while already playing/paused
|
||||
useEffect(() => {
|
||||
// Only trigger reload if we're in an active playback state with a different URL
|
||||
if (isPublished && (playbackState === 'playing' || playbackState === 'paused')) {
|
||||
// The load function will check if URL changed and reload if needed
|
||||
load(source.url, true);
|
||||
load(selection.url, true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only react to source.url changes
|
||||
}, [source.url]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only react to selection.url changes
|
||||
}, [selection.url]);
|
||||
|
||||
// Notify on playback state changes
|
||||
useEffect(() => {
|
||||
@@ -99,20 +99,15 @@ export function InlineMusicPlayer({
|
||||
// Handle play/pause toggle
|
||||
const handleToggle = useCallback(async () => {
|
||||
if (playbackState === 'idle' || playbackState === 'stopped') {
|
||||
load(source.url, true);
|
||||
load(selection.url, true);
|
||||
} else {
|
||||
await toggle();
|
||||
}
|
||||
}, [playbackState, source.url, load, toggle]);
|
||||
}, [playbackState, selection.url, load, toggle]);
|
||||
|
||||
// Track title
|
||||
const trackTitle = source.type === 'builtin'
|
||||
? source.track?.title ?? 'Unknown Track'
|
||||
: source.file?.name ?? 'Uploaded Track';
|
||||
|
||||
const trackArtist = source.type === 'builtin'
|
||||
? source.track?.artist
|
||||
: undefined;
|
||||
// Track info
|
||||
const trackTitle = selection.track.title;
|
||||
const trackArtist = selection.track.artist;
|
||||
|
||||
const isLoading = playbackState === 'loading' || isPublishing;
|
||||
const hasError = playbackState === 'error';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/blobbi/actions/components/PlayMusicModal.tsx
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Music, Upload, Play, Pause, Check, Loader2, Volume2, X, AlertCircle } from 'lucide-react';
|
||||
import { Music, Play, Pause, Check, Loader2, Volume2, AlertCircle } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -10,30 +10,29 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
getAllBuiltInTracks,
|
||||
getAllTracks,
|
||||
formatTrackDuration,
|
||||
type BuiltInTrack,
|
||||
} from '../lib/blobbi-builtin-tracks';
|
||||
type BlobbiTrack,
|
||||
} from '../lib/blobbi-track-catalog';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Audio source for the music player
|
||||
* Selected track for the music player
|
||||
*/
|
||||
export type AudioSource =
|
||||
| { type: 'builtin'; track: BuiltInTrack; url: string }
|
||||
| { type: 'uploaded'; file: File; url: string };
|
||||
export interface SelectedTrack {
|
||||
track: BlobbiTrack;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface PlayMusicModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Called with the selected audio source when user confirms */
|
||||
onConfirm: (source: AudioSource) => void;
|
||||
/** Called with the selected track when user confirms */
|
||||
onConfirm: (selection: SelectedTrack) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
@@ -45,102 +44,53 @@ export function PlayMusicModal({
|
||||
onConfirm,
|
||||
isLoading,
|
||||
}: PlayMusicModalProps) {
|
||||
const [selectedSource, setSelectedSource] = useState<AudioSource | null>(null);
|
||||
const [selectedTrack, setSelectedTrack] = useState<SelectedTrack | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [builtInError, setBuiltInError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const builtInTracks = getAllBuiltInTracks();
|
||||
// Track the current audio source URL to detect changes
|
||||
const currentAudioUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Cleanup audio on unmount or modal close
|
||||
const tracks = getAllTracks();
|
||||
|
||||
// Cleanup audio on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
// Revoke object URL if it was an uploaded file
|
||||
if (selectedSource?.type === 'uploaded') {
|
||||
URL.revokeObjectURL(selectedSource.url);
|
||||
}
|
||||
};
|
||||
}, [selectedSource]);
|
||||
}, []);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedSource(null);
|
||||
setSelectedTrack(null);
|
||||
setIsPlaying(false);
|
||||
setUploadError(null);
|
||||
setBuiltInError(null);
|
||||
setError(null);
|
||||
currentAudioUrlRef.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Handle selecting a built-in track
|
||||
const handleSelectBuiltIn = useCallback((track: BuiltInTrack) => {
|
||||
// Handle selecting a track
|
||||
const handleSelectTrack = useCallback((track: BlobbiTrack) => {
|
||||
// Stop current playback
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
|
||||
// Revoke previous URL if uploaded
|
||||
if (selectedSource?.type === 'uploaded') {
|
||||
URL.revokeObjectURL(selectedSource.url);
|
||||
}
|
||||
|
||||
setSelectedSource({ type: 'builtin', track, url: track.path });
|
||||
setBuiltInError(null);
|
||||
}, [selectedSource]);
|
||||
|
||||
// Handle file upload
|
||||
const handleFileUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/mp4'];
|
||||
if (!validTypes.includes(file.type) && !file.name.match(/\.(mp3|wav|ogg|m4a)$/i)) {
|
||||
setUploadError('Please upload an MP3, WAV, OGG, or M4A file.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 10MB)
|
||||
const maxSize = 10 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
setUploadError('File is too large. Maximum size is 10MB.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop current playback
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
|
||||
// Revoke previous URL if uploaded
|
||||
if (selectedSource?.type === 'uploaded') {
|
||||
URL.revokeObjectURL(selectedSource.url);
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
setSelectedSource({ type: 'uploaded', file, url });
|
||||
setUploadError(null);
|
||||
}, [selectedSource]);
|
||||
|
||||
// Track the current audio source URL to detect changes
|
||||
const currentAudioUrlRef = useRef<string | null>(null);
|
||||
setSelectedTrack({ track, url: track.url });
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Handle play/pause preview
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
if (!selectedSource) return;
|
||||
if (!selectedTrack) return;
|
||||
|
||||
const audioUrl = selectedSource.type === 'builtin'
|
||||
? selectedSource.track.path
|
||||
: selectedSource.url;
|
||||
const audioUrl = selectedTrack.url;
|
||||
|
||||
// Check if we need to create a new Audio instance (source changed or first time)
|
||||
const needsNewAudio = !audioRef.current || currentAudioUrlRef.current !== audioUrl;
|
||||
@@ -159,9 +109,7 @@ export function PlayMusicModal({
|
||||
|
||||
audioRef.current.onended = () => setIsPlaying(false);
|
||||
audioRef.current.onerror = () => {
|
||||
if (selectedSource.type === 'builtin') {
|
||||
setBuiltInError('This track is not available yet. Try uploading your own music!');
|
||||
}
|
||||
setError('Failed to load this track. Please try another one.');
|
||||
setIsPlaying(false);
|
||||
};
|
||||
}
|
||||
@@ -173,26 +121,24 @@ export function PlayMusicModal({
|
||||
} else {
|
||||
// Start playback (either new source or resuming)
|
||||
audioRef.current?.play().catch(() => {
|
||||
if (selectedSource.type === 'builtin') {
|
||||
setBuiltInError('This track is not available yet. Try uploading your own music!');
|
||||
}
|
||||
setError('Failed to play this track. Please try another one.');
|
||||
setIsPlaying(false);
|
||||
});
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}, [selectedSource, isPlaying]);
|
||||
}, [selectedTrack, isPlaying]);
|
||||
|
||||
// Handle confirm
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!selectedSource) return;
|
||||
if (!selectedTrack) return;
|
||||
|
||||
// Stop playback
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
onConfirm(selectedSource);
|
||||
}, [selectedSource, onConfirm]);
|
||||
onConfirm(selectedTrack);
|
||||
}, [selectedTrack, onConfirm]);
|
||||
|
||||
// Handle close
|
||||
const handleClose = useCallback((isOpen: boolean) => {
|
||||
@@ -202,12 +148,6 @@ export function PlayMusicModal({
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
}, [onOpenChange]);
|
||||
|
||||
const selectedName = selectedSource?.type === 'builtin'
|
||||
? selectedSource.track.title
|
||||
: selectedSource?.type === 'uploaded'
|
||||
? selectedSource.file.name
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
@@ -227,116 +167,32 @@ export function PlayMusicModal({
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content */}
|
||||
{/* Content - Track List */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<Tabs defaultValue="builtin" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="builtin">Built-in</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="upload"
|
||||
disabled
|
||||
className="gap-1.5 data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed"
|
||||
>
|
||||
Upload
|
||||
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 font-normal">
|
||||
Soon
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Built-in Tracks Tab */}
|
||||
<TabsContent value="builtin" className="mt-4">
|
||||
<div className="grid gap-2">
|
||||
{builtInTracks.map((track) => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
isSelected={selectedSource?.type === 'builtin' && selectedSource.track.id === track.id}
|
||||
onSelect={() => handleSelectBuiltIn(track)}
|
||||
/>
|
||||
))}
|
||||
<div className="grid gap-2">
|
||||
{tracks.map((track) => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
isSelected={selectedTrack?.track.id === track.id}
|
||||
onSelect={() => handleSelectTrack(track)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">{error}</p>
|
||||
</div>
|
||||
{builtInError && (
|
||||
<div className="mt-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">{builtInError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Upload Tab */}
|
||||
<TabsContent value="upload" className="mt-4">
|
||||
<div className="space-y-4">
|
||||
{/* Upload Area */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
"w-full p-8 rounded-xl border-2 border-dashed transition-colors",
|
||||
"hover:border-primary/50 hover:bg-primary/5",
|
||||
"flex flex-col items-center justify-center gap-3",
|
||||
selectedSource?.type === 'uploaded'
|
||||
? "border-primary/30 bg-primary/5"
|
||||
: "border-border"
|
||||
)}
|
||||
>
|
||||
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
|
||||
<Upload className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-medium">Upload Audio File</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
MP3, WAV, OGG, M4A (max 10MB)
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Upload Error */}
|
||||
{uploadError && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30">
|
||||
<div className="flex items-start gap-2">
|
||||
<X className="size-4 text-destructive mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-destructive">{uploadError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploaded File Display */}
|
||||
{selectedSource?.type === 'uploaded' && (
|
||||
<div className="p-4 rounded-xl border bg-card/60">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Music className="size-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{selectedSource.file.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(selectedSource.file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
<Check className="size-5 text-primary shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t bg-muted/30">
|
||||
{/* Preview Controls */}
|
||||
{selectedSource && (
|
||||
{selectedTrack && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-card border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
@@ -352,7 +208,7 @@ export function PlayMusicModal({
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate text-sm">{selectedName}</p>
|
||||
<p className="font-medium truncate text-sm">{selectedTrack.track.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isPlaying ? 'Now playing...' : 'Click to preview'}
|
||||
</p>
|
||||
@@ -376,7 +232,7 @@ export function PlayMusicModal({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedSource || isLoading}
|
||||
disabled={!selectedTrack || isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? (
|
||||
@@ -401,7 +257,7 @@ export function PlayMusicModal({
|
||||
// ─── Track Row Component ──────────────────────────────────────────────────────
|
||||
|
||||
interface TrackRowProps {
|
||||
track: BuiltInTrack;
|
||||
track: BlobbiTrack;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { StartIncubationMode } from '../hooks/useBlobbiIncubation';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { ExternalLink, Check, Loader2, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
@@ -62,7 +63,7 @@ function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
|
||||
navigate(task.actionTarget);
|
||||
break;
|
||||
case 'external_link':
|
||||
window.open(task.actionTarget, '_blank', 'noopener,noreferrer');
|
||||
openUrl(task.actionTarget);
|
||||
break;
|
||||
case 'open_modal':
|
||||
if (task.actionTarget === 'blobbi_post') {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { HatchTask, HatchTasksResult } from './useHatchTasks';
|
||||
import type { EvolveTasksResult } from './useEvolveTasks';
|
||||
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
|
||||
|
||||
@@ -23,8 +23,8 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
updateBlobbiTags,
|
||||
} from '@/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import {
|
||||
clampStat,
|
||||
applyStat,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
|
||||
import type { DailyMissionAction } from '../lib/daily-missions';
|
||||
import { getStreakTagUpdates } from '../lib/blobbi-streak';
|
||||
import { calculateActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
|
||||
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
|
||||
|
||||
@@ -50,6 +51,8 @@ export interface DirectActionRequest {
|
||||
export interface DirectActionResult {
|
||||
action: DirectAction;
|
||||
happinessChange: number;
|
||||
xpGained: number;
|
||||
newXP: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,6 +132,9 @@ export function useBlobbiDirectAction({
|
||||
const happinessDelta = DIRECT_ACTION_HAPPINESS_EFFECTS[action];
|
||||
const newHappiness = applyStat(statsAfterDecay.happiness, happinessDelta);
|
||||
|
||||
// Track if happiness actually changed
|
||||
const happinessChanged = newHappiness !== statsAfterDecay.happiness;
|
||||
|
||||
// Build stats update
|
||||
const isEgg = canonical.companion.stage === 'egg';
|
||||
const statsUpdate: Record<string, string> = {
|
||||
@@ -161,9 +167,16 @@ export function useBlobbiDirectAction({
|
||||
// Get streak updates (will only update if needed based on day)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
// ─── Apply XP Gain (ONLY if happiness actually changed) ───
|
||||
// Direct actions modify happiness. Only grant XP if happiness actually increased.
|
||||
const xpGained = happinessChanged ? calculateActionXP(action) : 0;
|
||||
const currentXP = canonical.companion.experience ?? 0;
|
||||
const newXP = applyXPGain(currentXP, xpGained);
|
||||
|
||||
const blobbiTags = updateBlobbiTags(updatedTags, {
|
||||
...statsUpdate,
|
||||
...streakUpdates,
|
||||
experience: newXP.toString(),
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
@@ -185,13 +198,16 @@ export function useBlobbiDirectAction({
|
||||
return {
|
||||
action,
|
||||
happinessChange: happinessDelta,
|
||||
xpGained,
|
||||
newXP,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ action, happinessChange }) => {
|
||||
onSuccess: ({ action, happinessChange, xpGained }) => {
|
||||
const actionMeta = DIRECT_ACTION_METADATA[action];
|
||||
const xpText = formatXPGain(xpGained);
|
||||
toast({
|
||||
title: `${actionMeta.label} complete!`,
|
||||
description: `Your Blobbi's happiness increased by ${happinessChange}!`,
|
||||
description: `Your Blobbi's happiness increased by ${happinessChange}! ${xpText}`,
|
||||
});
|
||||
|
||||
// Track daily mission progress
|
||||
|
||||
@@ -21,12 +21,12 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
updateBlobbiTags,
|
||||
} from '@/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -62,7 +62,7 @@ export interface UseStartIncubationParams {
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/lib/blobbi').StorageItem[];
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
@@ -325,7 +325,7 @@ export interface UseStopIncubationParams {
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/lib/blobbi').StorageItem[];
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
@@ -476,7 +476,7 @@ export interface UseStartEvolutionParams {
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/lib/blobbi').StorageItem[];
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
@@ -627,7 +627,7 @@ export interface UseStopEvolutionParams {
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/lib/blobbi').StorageItem[];
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
@@ -780,7 +780,7 @@ export interface UseSyncTaskCompletionsParams {
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: import('@/lib/blobbi').StorageItem[];
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
|
||||
@@ -19,14 +19,14 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStage } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStage } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
STAT_MAX,
|
||||
updateBlobbiTags,
|
||||
DEFAULT_EGG_STATS,
|
||||
} from '@/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
|
||||
import { validateAndRepairBlobbiTags } from '@/lib/blobbi-tag-schema';
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { validateAndRepairBlobbiTags } from '@/blobbi/core/lib/blobbi-tag-schema';
|
||||
import { getStreakTagUpdates } from '../lib/blobbi-streak';
|
||||
|
||||
// ─── Content Helpers ──────────────────────────────────────────────────────────
|
||||
@@ -56,7 +56,7 @@ export interface CanonicalActionResult {
|
||||
/** Latest profile tags after migration */
|
||||
profileAllTags: string[][];
|
||||
/** Latest profile storage after migration */
|
||||
profileStorage: import('@/lib/blobbi').StorageItem[];
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,14 +157,13 @@ export function useBlobbiHatch({
|
||||
});
|
||||
|
||||
// ─── Calculate Baby Stats ───
|
||||
// Baby inherits the decayed health from the egg
|
||||
// Other stats start fresh at 100 for the new life stage
|
||||
// All stats reset to 100 when hatching — the baby starts fresh
|
||||
const babyStats = {
|
||||
hunger: DEFAULT_EGG_STATS.hunger, // Start full
|
||||
happiness: DEFAULT_EGG_STATS.happiness, // Start happy
|
||||
health: decayResult.stats.health, // Inherit from egg
|
||||
hygiene: DEFAULT_EGG_STATS.hygiene, // Start clean
|
||||
energy: DEFAULT_EGG_STATS.energy, // Start energized
|
||||
hunger: STAT_MAX,
|
||||
happiness: STAT_MAX,
|
||||
health: STAT_MAX,
|
||||
hygiene: STAT_MAX,
|
||||
energy: STAT_MAX,
|
||||
};
|
||||
|
||||
// ─── Build Updated Tags ───
|
||||
|
||||
@@ -6,15 +6,15 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbiTags,
|
||||
updateBlobbonautTags,
|
||||
createStorageTags,
|
||||
} from '@/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import {
|
||||
applyItemEffects,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
|
||||
import type { DailyMissionAction } from '../lib/daily-missions';
|
||||
import { getStreakTagUpdates } from '../lib/blobbi-streak';
|
||||
import { calculateInventoryActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
|
||||
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
|
||||
|
||||
@@ -52,7 +53,10 @@ export interface UseItemResult {
|
||||
itemName: string;
|
||||
action: InventoryAction;
|
||||
quantity: number;
|
||||
effectiveItemCount: number; // How many items actually changed stats (may be less than quantity due to caps)
|
||||
statsChanged: Record<string, number>;
|
||||
xpGained: number;
|
||||
newXP: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,7 +74,7 @@ export interface UseBlobbiUseInventoryItemParams {
|
||||
/** Latest profile tags after migration (use instead of profile.allTags) */
|
||||
profileAllTags: string[][];
|
||||
/** Latest profile storage after migration (use instead of profile.storage) */
|
||||
profileStorage: import('@/lib/blobbi').StorageItem[];
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
@@ -186,14 +190,49 @@ export function useBlobbiUseInventoryItem({
|
||||
// Start with decayed stats as the base
|
||||
const statsAfterDecay = decayResult.stats;
|
||||
|
||||
// ─── Validate Play Energy Requirements ───
|
||||
// For play actions, validate the Blobbi has enough energy AFTER decay
|
||||
if (action === 'play') {
|
||||
const energyCost = Math.abs(shopItem.effect.energy ?? 0);
|
||||
const currentEnergy = statsAfterDecay.energy;
|
||||
|
||||
if (energyCost > 0 && currentEnergy < energyCost) {
|
||||
throw new Error(
|
||||
`Your Blobbi needs at least ${energyCost} energy to play with this toy (current: ${currentEnergy})`
|
||||
);
|
||||
}
|
||||
|
||||
// Also check if playing would have any effect at all
|
||||
// If happiness is maxed AND we can't spend energy, playing is pointless
|
||||
const happinessGain = shopItem.effect.happiness ?? 0;
|
||||
const currentHappiness = statsAfterDecay.happiness;
|
||||
const wouldGainHappiness = happinessGain > 0 && currentHappiness < 100;
|
||||
const wouldSpendEnergy = energyCost > 0 && currentEnergy >= energyCost;
|
||||
|
||||
if (!wouldGainHappiness && !wouldSpendEnergy) {
|
||||
throw new Error(
|
||||
'Playing would have no effect - your Blobbi is already at maximum happiness and has no energy to spend'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Apply Item Effects ───
|
||||
// Apply effects multiple times (once per quantity) to simulate using items in sequence.
|
||||
// This ensures proper clamping at each step, e.g., using 5 health items when at 90 health
|
||||
// won't give more than 100 health total.
|
||||
//
|
||||
// CRITICAL: Track the number of items that actually produced INTENDED stat changes for XP.
|
||||
// XP counting is action-aware - only count positive intended effects, NOT negative side effects:
|
||||
// - feed: count when hunger/energy/health/happiness INCREASE (NOT when hygiene decreases)
|
||||
// - clean: count when hygiene or happiness INCREASES
|
||||
// - medicine: count when health/energy/happiness INCREASE (NOT negative side effects)
|
||||
// - play: EXCEPTION - count when happiness increases OR energy decreases (both are intended effects)
|
||||
//
|
||||
// Use canonical companion stage for egg checks
|
||||
const isEggCompanion = canonical.companion.stage === 'egg';
|
||||
const statsUpdate: Record<string, string> = {};
|
||||
const statsChanged: Record<string, number> = {};
|
||||
let effectiveItemCount = 0; // Number of items that produced intended effects
|
||||
|
||||
if (isEggCompanion && action === 'medicine') {
|
||||
// Egg medicine handling:
|
||||
@@ -203,9 +242,15 @@ export function useBlobbiUseInventoryItem({
|
||||
|
||||
const healthDelta = shopItem.effect.health ?? 0;
|
||||
// Apply health effect N times in sequence with clamping at each step
|
||||
// Only count items that actually INCREASED health (positive effect only)
|
||||
let currentHealth = statsAfterDecay.health ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const prevHealth = currentHealth;
|
||||
currentHealth = applyStat(currentHealth, healthDelta);
|
||||
// Only count as effective if health increased (not just changed)
|
||||
if (healthDelta > 0 && currentHealth > prevHealth) {
|
||||
effectiveItemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
statsUpdate.health = currentHealth.toString();
|
||||
@@ -228,11 +273,20 @@ export function useBlobbiUseInventoryItem({
|
||||
const happinessDelta = shopItem.effect.happiness ?? 0;
|
||||
|
||||
// Apply effects N times in sequence
|
||||
// Only count items that INCREASED hygiene or happiness (positive effects only)
|
||||
let currentHygiene = statsAfterDecay.hygiene ?? 0;
|
||||
let currentHappiness = statsAfterDecay.happiness ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const prevHygiene = currentHygiene;
|
||||
const prevHappiness = currentHappiness;
|
||||
currentHygiene = applyStat(currentHygiene, hygieneDelta);
|
||||
currentHappiness = applyStat(currentHappiness, happinessDelta);
|
||||
// Count as effective if hygiene OR happiness increased (positive effects only)
|
||||
const hygieneIncreased = hygieneDelta > 0 && currentHygiene > prevHygiene;
|
||||
const happinessIncreased = happinessDelta > 0 && currentHappiness > prevHappiness;
|
||||
if (hygieneIncreased || happinessIncreased) {
|
||||
effectiveItemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
statsUpdate.hygiene = currentHygiene.toString();
|
||||
@@ -252,9 +306,49 @@ export function useBlobbiUseInventoryItem({
|
||||
} else {
|
||||
// Normal stats application for baby/adult
|
||||
// Apply item effects N times in sequence ON TOP of decayed stats
|
||||
// Use action-aware effectiveness checking for XP calculation
|
||||
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
|
||||
const effect = shopItem.effect;
|
||||
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentStats = applyItemEffects(currentStats, shopItem.effect);
|
||||
const prevStats = { ...currentStats };
|
||||
currentStats = applyItemEffects(currentStats, effect);
|
||||
|
||||
// Action-aware effectiveness check:
|
||||
// Only count INTENDED positive effects, not negative side effects
|
||||
let isEffective = false;
|
||||
|
||||
if (action === 'feed') {
|
||||
// Feed: count when hunger/energy/health/happiness INCREASE
|
||||
// Do NOT count hygiene decrease (that's a side effect)
|
||||
const hungerIncreased = (effect.hunger ?? 0) > 0 && (currentStats.hunger ?? 0) > (prevStats.hunger ?? 0);
|
||||
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
|
||||
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
isEffective = hungerIncreased || energyIncreased || healthIncreased || happinessIncreased;
|
||||
} else if (action === 'clean') {
|
||||
// Clean: count when hygiene or happiness INCREASES
|
||||
const hygieneIncreased = (effect.hygiene ?? 0) > 0 && (currentStats.hygiene ?? 0) > (prevStats.hygiene ?? 0);
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
isEffective = hygieneIncreased || happinessIncreased;
|
||||
} else if (action === 'medicine') {
|
||||
// Medicine: count when health/energy/happiness INCREASE
|
||||
// Do NOT count negative side effects (like happiness decrease on Super Medicine)
|
||||
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
|
||||
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
isEffective = healthIncreased || energyIncreased || happinessIncreased;
|
||||
} else if (action === 'play') {
|
||||
// Play: EXCEPTION - both happiness increase AND energy decrease are intended effects
|
||||
// Playing naturally consumes energy, so energy decrease counts as valid
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
const energyDecreased = (effect.energy ?? 0) < 0 && (currentStats.energy ?? 0) < (prevStats.energy ?? 0);
|
||||
isEffective = happinessIncreased || energyDecreased;
|
||||
}
|
||||
|
||||
if (isEffective) {
|
||||
effectiveItemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
|
||||
@@ -288,9 +382,18 @@ export function useBlobbiUseInventoryItem({
|
||||
// Get streak updates (will only update if needed based on day)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
// ─── Apply XP Gain (Based on effective item count) ───
|
||||
// Only grant XP for items that actually changed stats.
|
||||
// If user used 100 food items but hunger capped at item #4, only 4 items were effective.
|
||||
// This prevents XP farming by mass-using items after stats are already maxed.
|
||||
const xpGained = effectiveItemCount > 0 ? calculateInventoryActionXP(action, effectiveItemCount) : 0;
|
||||
const currentXP = canonical.companion.experience ?? 0;
|
||||
const newXP = applyXPGain(currentXP, xpGained);
|
||||
|
||||
const blobbiTags = updateBlobbiTags(updatedTags, {
|
||||
...statsUpdate,
|
||||
...streakUpdates,
|
||||
experience: newXP.toString(),
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
@@ -330,15 +433,19 @@ export function useBlobbiUseInventoryItem({
|
||||
itemName: shopItem.name,
|
||||
action,
|
||||
quantity,
|
||||
effectiveItemCount, // How many items actually changed stats
|
||||
statsChanged,
|
||||
xpGained,
|
||||
newXP,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ itemName, action, quantity }) => {
|
||||
onSuccess: ({ itemName, action, quantity, xpGained }) => {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
|
||||
const xpText = formatXPGain(xpGained);
|
||||
toast({
|
||||
title: `${actionMeta.label} successful!`,
|
||||
description: `Used ${itemName}${quantityText} on your Blobbi.`,
|
||||
description: `Used ${itemName}${quantityText} on your Blobbi. ${xpText}`,
|
||||
});
|
||||
|
||||
// Track daily mission progress
|
||||
|
||||
@@ -14,11 +14,11 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbonautProfile } from '@/lib/blobbi';
|
||||
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbonautTags,
|
||||
} from '@/lib/blobbi';
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
getTodayDateString,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import {
|
||||
KIND_THEME_DEFINITION,
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
*
|
||||
* CRITICAL ARCHITECTURE:
|
||||
* - PERSISTENT TASKS: Based on Nostr events, can be cached in tags
|
||||
* - DYNAMIC TASKS: Based on current stats, NEVER stored in tags
|
||||
*
|
||||
* Tags are only cache for persistent tasks. Source of truth = Nostr events.
|
||||
* All persistent tasks are computed dynamically from events with created_at >= state_started_at.
|
||||
*
|
||||
* Note: Egg stats no longer decay, so there are no dynamic tasks for hatching.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -16,7 +17,7 @@ import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -38,9 +39,6 @@ export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi', 'ditto', 'nostr'];
|
||||
/** Prefix text for Blobbi hatch post */
|
||||
export const BLOBBI_POST_PREFIX = 'Hello Nostr! Posting to hatch';
|
||||
|
||||
/** Stat threshold for hatch dynamic task (health, hygiene, happiness >= 70) */
|
||||
export const HATCH_STAT_THRESHOLD = 70;
|
||||
|
||||
// Legacy export for backwards compatibility
|
||||
export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS;
|
||||
|
||||
@@ -158,8 +156,8 @@ export const isValidBlobbiPost = isValidHatchPost;
|
||||
* 3. Create Post (kind 1) - ≥1 valid Blobbi hatch post after start
|
||||
* 4. Interactions - 7 total (tracked via companion.tasks cache)
|
||||
*
|
||||
* DYNAMIC TASK (stat-based, NEVER cached):
|
||||
* 5. Maintain Stats - health >= 70, hygiene >= 70, happiness >= 70
|
||||
* Note: Egg stats no longer decay, so the "maintain stats" dynamic task
|
||||
* has been removed. The baby/adult evolve equivalent is still in useEvolveTasks.
|
||||
*
|
||||
* @param companion - The Blobbi companion (must be incubating)
|
||||
* @param interactionCount - Current interaction count from companion tasks cache
|
||||
@@ -323,32 +321,6 @@ export function useHatchTasks(
|
||||
// No action - just interact with Blobbi
|
||||
});
|
||||
|
||||
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
|
||||
// 6. Maintain Stats - health >= 70, hygiene >= 70, happiness >= 70
|
||||
const stats = companion?.stats ?? {};
|
||||
const health = stats.health ?? 0;
|
||||
const hygiene = stats.hygiene ?? 0;
|
||||
const happiness = stats.happiness ?? 0;
|
||||
|
||||
const statsOk =
|
||||
health >= HATCH_STAT_THRESHOLD &&
|
||||
hygiene >= HATCH_STAT_THRESHOLD &&
|
||||
happiness >= HATCH_STAT_THRESHOLD;
|
||||
|
||||
// Calculate minimum stat for progress display
|
||||
const minStat = Math.min(health, hygiene, happiness);
|
||||
|
||||
tasks.push({
|
||||
id: 'maintain_stats',
|
||||
name: 'Keep Egg Healthy',
|
||||
description: `Keep health, hygiene & happiness above ${HATCH_STAT_THRESHOLD}`,
|
||||
current: statsOk ? HATCH_STAT_THRESHOLD : minStat,
|
||||
required: HATCH_STAT_THRESHOLD,
|
||||
completed: statsOk,
|
||||
type: 'dynamic', // CRITICAL: Never persist this task
|
||||
// No action - just care for your Blobbi
|
||||
});
|
||||
|
||||
// ─── Compute Completion States ───
|
||||
const persistentTasks = tasks.filter(t => t.type === 'persistent');
|
||||
const dynamicTasks = tasks.filter(t => t.type === 'dynamic');
|
||||
|
||||
@@ -13,7 +13,6 @@ export { BlobbiPostModal } from './components/BlobbiPostModal';
|
||||
export { StartIncubationDialog } from './components/StartIncubationDialog';
|
||||
export { StartEvolutionDialog } from './components/StartEvolutionDialog';
|
||||
export { BlobbiMissionsModal } from './components/BlobbiMissionsModal';
|
||||
export type { AudioSource } from './components/PlayMusicModal';
|
||||
|
||||
// Hooks
|
||||
export { useBlobbiUseInventoryItem } from './hooks/useBlobbiUseInventoryItem';
|
||||
@@ -61,7 +60,6 @@ export {
|
||||
KIND_THEME_DEFINITION,
|
||||
KIND_COLOR_MOMENT,
|
||||
HATCH_REQUIRED_INTERACTIONS,
|
||||
HATCH_STAT_THRESHOLD,
|
||||
REQUIRED_INTERACTIONS, // Legacy export
|
||||
BLOBBI_POST_PREFIX,
|
||||
BLOBBI_POST_REQUIRED_HASHTAGS,
|
||||
@@ -88,14 +86,14 @@ export type { DirectActionRequest, DirectActionResult, UseBlobbiDirectActionPara
|
||||
export { useAudioPlayback } from './hooks/useAudioPlayback';
|
||||
export type { PlaybackState, PlaybackError, UseAudioPlaybackOptions, UseAudioPlaybackReturn } from './hooks/useAudioPlayback';
|
||||
|
||||
// Built-in tracks
|
||||
// Track catalog
|
||||
export {
|
||||
BLOBBI_BUILTIN_TRACKS,
|
||||
getAllBuiltInTracks,
|
||||
getBuiltInTrackById,
|
||||
BLOBBI_TRACK_CATALOG,
|
||||
getAllTracks,
|
||||
getTrackById,
|
||||
formatTrackDuration,
|
||||
type BuiltInTrack,
|
||||
} from './lib/blobbi-builtin-tracks';
|
||||
type BlobbiTrack,
|
||||
} from './lib/blobbi-track-catalog';
|
||||
|
||||
// Activity state
|
||||
export {
|
||||
@@ -108,11 +106,11 @@ export {
|
||||
type SingActivityState,
|
||||
type NoActivityState,
|
||||
type BlobbiReactionState,
|
||||
type MusicTrackSource,
|
||||
type SelectedTrack,
|
||||
} from './lib/blobbi-activity-state';
|
||||
|
||||
// Re-export stat bounds from canonical source
|
||||
export { STAT_MIN, STAT_MAX } from '@/lib/blobbi';
|
||||
export { STAT_MIN, STAT_MAX } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// Utilities
|
||||
export {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/blobbi/actions/lib/blobbi-action-utils.ts
|
||||
|
||||
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/lib/blobbi';
|
||||
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/blobbi/core/lib/blobbi';
|
||||
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/blobbi/actions/lib/blobbi-activity-state.ts
|
||||
|
||||
import type { AudioSource } from '../components/PlayMusicModal';
|
||||
import type { SelectedTrack } from '../components/PlayMusicModal';
|
||||
|
||||
/**
|
||||
* Types of inline activities that can be displayed in BlobbiPage
|
||||
@@ -8,14 +8,14 @@ import type { AudioSource } from '../components/PlayMusicModal';
|
||||
export type InlineActivityType = 'none' | 'music' | 'sing';
|
||||
|
||||
// Re-export for convenience
|
||||
export type { AudioSource as MusicTrackSource } from '../components/PlayMusicModal';
|
||||
export type { SelectedTrack } from '../components/PlayMusicModal';
|
||||
|
||||
/**
|
||||
* State for the music inline activity
|
||||
*/
|
||||
export interface MusicActivityState {
|
||||
type: 'music';
|
||||
source: AudioSource;
|
||||
selection: SelectedTrack;
|
||||
isPublished: boolean;
|
||||
}
|
||||
|
||||
@@ -54,10 +54,10 @@ export type BlobbiReactionState =
|
||||
/**
|
||||
* Helper to create a music activity state
|
||||
*/
|
||||
export function createMusicActivity(source: AudioSource): MusicActivityState {
|
||||
export function createMusicActivity(selection: SelectedTrack): MusicActivityState {
|
||||
return {
|
||||
type: 'music',
|
||||
source,
|
||||
selection,
|
||||
isPublished: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
// src/blobbi/actions/lib/blobbi-builtin-tracks.ts
|
||||
|
||||
/**
|
||||
* Built-in music tracks for the Blobbi "Play Music" action.
|
||||
*
|
||||
* ## Asset Location
|
||||
*
|
||||
* Audio files live in: `public/blobbi/audio/`
|
||||
*
|
||||
* In Vite, files in `public/` are served at root paths, so:
|
||||
* - `public/blobbi/audio/foo.mp3` → accessible at `/blobbi/audio/foo.mp3`
|
||||
*
|
||||
* ## Adding New Tracks
|
||||
*
|
||||
* 1. Place the MP3 file in `public/blobbi/audio/`
|
||||
* 2. Add a new entry to `BLOBBI_BUILTIN_TRACKS` below
|
||||
* 3. Set `path` to `/blobbi/audio/<filename>.mp3`
|
||||
* 4. Get the duration: `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 <file>`
|
||||
*
|
||||
* ## Supported Formats
|
||||
*
|
||||
* MP3 is recommended for maximum browser compatibility.
|
||||
* WAV, OGG, and M4A may work but are browser-dependent.
|
||||
*/
|
||||
|
||||
export interface BuiltInTrack {
|
||||
/** Unique identifier for the track (used in state/events) */
|
||||
id: string;
|
||||
/** Display title shown in the UI */
|
||||
title: string;
|
||||
/** Artist or source attribution */
|
||||
artist: string;
|
||||
/** Path to audio file (relative to public directory root) */
|
||||
path: string;
|
||||
/** Duration in seconds (for display, get via ffprobe) */
|
||||
durationSeconds: number;
|
||||
/** Optional cover art path (relative to public directory root) */
|
||||
coverArt?: string;
|
||||
/** Optional tags for categorization/filtering */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in track catalog for Blobbi music player.
|
||||
*
|
||||
* All tracks are royalty-free/Creative Commons licensed.
|
||||
* Audio files located at: public/blobbi/audio/
|
||||
*/
|
||||
export const BLOBBI_BUILTIN_TRACKS: BuiltInTrack[] = [
|
||||
{
|
||||
id: 'nap_in_the_meadow',
|
||||
title: 'Nap in the Meadow',
|
||||
artist: 'Chilltape FM',
|
||||
path: '/blobbi/audio/chilltapefm-nap-in-the-meadow.mp3',
|
||||
durationSeconds: 240, // 4:00
|
||||
tags: ['relaxing', 'nature'],
|
||||
},
|
||||
{
|
||||
id: 'happy_kids',
|
||||
title: 'Happy Kids',
|
||||
artist: 'Dmitrii Kolesnikov',
|
||||
path: '/blobbi/audio/happy-kids.mp3',
|
||||
durationSeconds: 129, // 2:09
|
||||
tags: ['upbeat', 'fun'],
|
||||
},
|
||||
{
|
||||
id: 'soft_piano',
|
||||
title: 'Soft Piano',
|
||||
artist: 'Dmitrii Kolesnikov',
|
||||
path: '/blobbi/audio/soft-piano.mp3',
|
||||
durationSeconds: 124, // 2:04
|
||||
tags: ['calming', 'sleep'],
|
||||
},
|
||||
{
|
||||
id: 'epic_sacred_light',
|
||||
title: 'Epic Sacred Light',
|
||||
artist: 'Ura Megis',
|
||||
path: '/blobbi/audio/epic-sacred-light.mp3',
|
||||
durationSeconds: 223, // 3:43
|
||||
tags: ['energetic', 'adventure'],
|
||||
},
|
||||
{
|
||||
id: 'split_memmories',
|
||||
title: 'Split Memmories',
|
||||
artist: 'ido berg',
|
||||
path: '/blobbi/audio/split-memmories.mp3',
|
||||
durationSeconds: 153, // 2:33
|
||||
tags: ['ambient', 'relaxing'],
|
||||
},
|
||||
{
|
||||
id: 'minhas_mensagens',
|
||||
title: 'Minhas Mensagens',
|
||||
artist: 'PReis',
|
||||
path: '/blobbi/audio/minhas-mensagens-preis.mp3',
|
||||
durationSeconds: 248, // 4:08
|
||||
tags: ['ambient', 'relaxing'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a built-in track by ID
|
||||
*/
|
||||
export function getBuiltInTrackById(id: string): BuiltInTrack | undefined {
|
||||
return BLOBBI_BUILTIN_TRACKS.find(track => track.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all built-in tracks
|
||||
*/
|
||||
export function getAllBuiltInTracks(): BuiltInTrack[] {
|
||||
return BLOBBI_BUILTIN_TRACKS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to MM:SS string
|
||||
*/
|
||||
export function formatTrackDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
getLocalDayString,
|
||||
getDaysDifference,
|
||||
type BlobbiCompanion,
|
||||
} from '@/lib/blobbi';
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// src/blobbi/actions/lib/blobbi-track-catalog.ts
|
||||
|
||||
/**
|
||||
* Blobbi Track Catalog
|
||||
*
|
||||
* Music tracks for the Blobbi "Play Music" action.
|
||||
* All tracks are hosted on remote Blossom servers and streamed on-demand.
|
||||
*
|
||||
* ## Adding New Tracks
|
||||
*
|
||||
* 1. Convert the audio file to M4A (AAC-LC):
|
||||
* `ffmpeg -i input.m4a -c:a aac -b:a 64k -ar 48000 output.m4a`
|
||||
* 2. Upload the M4A file to a Blossom server
|
||||
* 3. Add a new entry to `BLOBBI_TRACK_CATALOG` below
|
||||
* 4. Set `url` to the full Blossom URL
|
||||
* 5. Get the duration: `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 <file>`
|
||||
*
|
||||
* ## Supported Formats
|
||||
*
|
||||
* M4A (AAC-LC) is required for iOS/Safari compatibility and small file size.
|
||||
*/
|
||||
|
||||
export interface BlobbiTrack {
|
||||
/** Unique identifier for the track (used in state/events) */
|
||||
id: string;
|
||||
/** Display title shown in the UI */
|
||||
title: string;
|
||||
/** Artist or source attribution */
|
||||
artist: string;
|
||||
/** Full URL to the remote audio file (Blossom server) */
|
||||
url: string;
|
||||
/** Duration in seconds (for display, get via ffprobe) */
|
||||
durationSeconds: number;
|
||||
/** Optional cover art URL */
|
||||
coverArt?: string;
|
||||
/** Optional tags for categorization/filtering */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Blobbi track catalog.
|
||||
*
|
||||
* All tracks are royalty-free/Creative Commons licensed.
|
||||
* Audio files hosted on remote Blossom servers.
|
||||
*/
|
||||
export const BLOBBI_TRACK_CATALOG: BlobbiTrack[] = [
|
||||
{
|
||||
id: 'nap_in_the_meadow',
|
||||
title: 'Nap in the Meadow',
|
||||
artist: 'Chilltape FM',
|
||||
url: 'https://blossom.ditto.pub/6be1c95e879187f83af2a661ccac2bd96196f7bc334af44529ede6270b2811fc.m4a',
|
||||
durationSeconds: 240, // 4:00
|
||||
tags: ['relaxing', 'nature'],
|
||||
},
|
||||
{
|
||||
id: 'happy_kids',
|
||||
title: 'Happy Kids',
|
||||
artist: 'Dmitrii Kolesnikov',
|
||||
url: 'https://blossom.ditto.pub/94d49abd178aa8afb14737a55e0a7143f6b337f618d74858d011232bb2db845d.m4a',
|
||||
durationSeconds: 129, // 2:09
|
||||
tags: ['upbeat', 'fun'],
|
||||
},
|
||||
{
|
||||
id: 'soft_piano',
|
||||
title: 'Soft Piano',
|
||||
artist: 'Dmitrii Kolesnikov',
|
||||
url: 'https://blossom.ditto.pub/5367242d3dc555c77f5c637fd153df1166708a24c5a4c222bb4dcaeabf740743.m4a',
|
||||
durationSeconds: 124, // 2:04
|
||||
tags: ['calming', 'sleep'],
|
||||
},
|
||||
{
|
||||
id: 'epic_sacred_light',
|
||||
title: 'Epic Sacred Light',
|
||||
artist: 'Ura Megis',
|
||||
url: 'https://blossom.dreamith.to/c22953791d686605958165fd44a84cd7d9fd3d4423ebf786e47891ed3a82c6db.m4a',
|
||||
durationSeconds: 223, // 3:43
|
||||
tags: ['energetic', 'adventure'],
|
||||
},
|
||||
{
|
||||
id: 'split_memories',
|
||||
title: 'Split Memories',
|
||||
artist: 'ido berg',
|
||||
url: 'https://blossom.ditto.pub/57ba2e2122a732449880ae531d4bfac9a580bc19693c7dda735afbfa336b35fe.m4a',
|
||||
durationSeconds: 153, // 2:33
|
||||
tags: ['ambient', 'relaxing'],
|
||||
},
|
||||
{
|
||||
id: 'minhas_mensagens',
|
||||
title: 'Minhas Mensagens',
|
||||
artist: 'PReis',
|
||||
url: 'https://blossom.ditto.pub/0945064dc8f946f3392be23629b166e72090cafca7cca865a20b5395dd83ff46.m4a',
|
||||
durationSeconds: 248, // 4:08
|
||||
tags: ['ambient', 'relaxing'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a track by ID from the catalog
|
||||
*/
|
||||
export function getTrackById(id: string): BlobbiTrack | undefined {
|
||||
return BLOBBI_TRACK_CATALOG.find(track => track.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tracks from the catalog
|
||||
*/
|
||||
export function getAllTracks(): BlobbiTrack[] {
|
||||
return BLOBBI_TRACK_CATALOG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to MM:SS string
|
||||
*/
|
||||
export function formatTrackDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
calculateActionXP,
|
||||
calculateInventoryActionXP,
|
||||
applyXPGain,
|
||||
getXPGainSummary,
|
||||
formatXPGain,
|
||||
getXPGainMessage,
|
||||
ACTION_XP,
|
||||
INVENTORY_ACTION_XP,
|
||||
DIRECT_ACTION_XP,
|
||||
} from './blobbi-xp';
|
||||
|
||||
describe('calculateActionXP', () => {
|
||||
it('returns the correct XP for each inventory action', () => {
|
||||
expect(calculateActionXP('feed')).toBe(5);
|
||||
expect(calculateActionXP('play')).toBe(8);
|
||||
expect(calculateActionXP('clean')).toBe(6);
|
||||
expect(calculateActionXP('medicine')).toBe(10);
|
||||
});
|
||||
|
||||
it('returns the correct XP for each direct action', () => {
|
||||
expect(calculateActionXP('play_music')).toBe(7);
|
||||
expect(calculateActionXP('sing')).toBe(9);
|
||||
});
|
||||
|
||||
it('returns 0 for an unknown action', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(calculateActionXP('unknown' as any)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateInventoryActionXP', () => {
|
||||
it('returns base XP for quantity 1', () => {
|
||||
expect(calculateInventoryActionXP('feed', 1)).toBe(5);
|
||||
expect(calculateInventoryActionXP('medicine', 1)).toBe(10);
|
||||
});
|
||||
|
||||
it('multiplies XP by quantity', () => {
|
||||
expect(calculateInventoryActionXP('feed', 3)).toBe(15);
|
||||
expect(calculateInventoryActionXP('play', 5)).toBe(40);
|
||||
});
|
||||
|
||||
it('defaults to quantity 1 when not specified', () => {
|
||||
expect(calculateInventoryActionXP('clean')).toBe(6);
|
||||
});
|
||||
|
||||
it('returns 0 for quantity less than 1', () => {
|
||||
expect(calculateInventoryActionXP('feed', 0)).toBe(0);
|
||||
expect(calculateInventoryActionXP('feed', -1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyXPGain', () => {
|
||||
it('adds XP to a current value', () => {
|
||||
expect(applyXPGain(100, 25)).toBe(125);
|
||||
});
|
||||
|
||||
it('treats undefined current XP as 0', () => {
|
||||
expect(applyXPGain(undefined, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it('never returns a negative value', () => {
|
||||
expect(applyXPGain(5, -20)).toBe(0);
|
||||
expect(applyXPGain(0, -1)).toBe(0);
|
||||
});
|
||||
|
||||
it('handles zero XP gain', () => {
|
||||
expect(applyXPGain(50, 0)).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getXPGainSummary', () => {
|
||||
it('returns the correct xpGained and quantity', () => {
|
||||
const result = getXPGainSummary('feed', 3);
|
||||
expect(result).toEqual({ xpGained: 15, quantity: 3 });
|
||||
});
|
||||
|
||||
it('defaults quantity to 1', () => {
|
||||
const result = getXPGainSummary('sing');
|
||||
expect(result).toEqual({ xpGained: 9, quantity: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatXPGain', () => {
|
||||
it('formats positive XP as "+N XP"', () => {
|
||||
expect(formatXPGain(15)).toBe('+15 XP');
|
||||
expect(formatXPGain(1)).toBe('+1 XP');
|
||||
});
|
||||
|
||||
it('returns empty string for zero or negative XP', () => {
|
||||
expect(formatXPGain(0)).toBe('');
|
||||
expect(formatXPGain(-5)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getXPGainMessage', () => {
|
||||
it('formats a message with action and XP earned', () => {
|
||||
expect(getXPGainMessage('feed', 5)).toBe('+5 XP earned!');
|
||||
});
|
||||
|
||||
it('includes total when provided', () => {
|
||||
expect(getXPGainMessage('feed', 5, 105)).toBe('+5 XP earned! Total: 105 XP');
|
||||
});
|
||||
|
||||
it('returns empty string for zero or negative XP', () => {
|
||||
expect(getXPGainMessage('feed', 0)).toBe('');
|
||||
expect(getXPGainMessage('feed', -1)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('XP constants', () => {
|
||||
it('ACTION_XP contains all inventory and direct actions', () => {
|
||||
for (const action of Object.keys(INVENTORY_ACTION_XP)) {
|
||||
expect(ACTION_XP).toHaveProperty(action);
|
||||
expect(ACTION_XP[action as keyof typeof ACTION_XP]).toBe(
|
||||
INVENTORY_ACTION_XP[action as keyof typeof INVENTORY_ACTION_XP],
|
||||
);
|
||||
}
|
||||
for (const action of Object.keys(DIRECT_ACTION_XP)) {
|
||||
expect(ACTION_XP).toHaveProperty(action);
|
||||
expect(ACTION_XP[action as keyof typeof ACTION_XP]).toBe(
|
||||
DIRECT_ACTION_XP[action as keyof typeof DIRECT_ACTION_XP],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('all XP values are positive integers', () => {
|
||||
for (const xp of Object.values(ACTION_XP)) {
|
||||
expect(xp).toBeGreaterThan(0);
|
||||
expect(Number.isInteger(xp)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Blobbi XP (Experience Points) System
|
||||
*
|
||||
* This module defines XP values for all Blobbi care actions and provides
|
||||
* utilities for calculating and applying XP gains.
|
||||
*
|
||||
* Design Philosophy:
|
||||
* - Different actions award different XP to reflect their complexity/value
|
||||
* - XP values are balanced to encourage variety in care activities
|
||||
* - Direct actions (sing, play_music) give moderate XP as they're free
|
||||
* - Inventory actions (feed, play, clean, medicine) give varied XP based on resource cost
|
||||
* - XP accumulates across all life stages and never resets
|
||||
*/
|
||||
|
||||
import type { BlobbiAction, InventoryAction, DirectAction } from './blobbi-action-utils';
|
||||
|
||||
// ─── XP Values by Action ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Base XP values for inventory actions (feed, play, clean, medicine).
|
||||
* These actions consume items from the player's storage.
|
||||
*/
|
||||
export const INVENTORY_ACTION_XP: Record<InventoryAction, number> = {
|
||||
feed: 5, // Feeding is common and essential - moderate XP
|
||||
play: 8, // Playing toys provides good interaction - higher XP
|
||||
clean: 6, // Hygiene maintenance is important - moderate-high XP
|
||||
medicine: 10, // Medicine is costly and critical - highest inventory XP
|
||||
};
|
||||
|
||||
/**
|
||||
* Base XP values for direct actions (play_music, sing).
|
||||
* These actions don't consume items - they're free activities.
|
||||
*/
|
||||
export const DIRECT_ACTION_XP: Record<DirectAction, number> = {
|
||||
play_music: 7, // Playing music is engaging - good XP
|
||||
sing: 9, // Singing requires more user effort - higher XP
|
||||
};
|
||||
|
||||
/**
|
||||
* Combined XP lookup for all action types.
|
||||
* Use this for a unified XP calculation interface.
|
||||
*/
|
||||
export const ACTION_XP: Record<BlobbiAction, number> = {
|
||||
...INVENTORY_ACTION_XP,
|
||||
...DIRECT_ACTION_XP,
|
||||
};
|
||||
|
||||
// ─── XP Calculation Utilities ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate XP gain for a single action.
|
||||
*
|
||||
* @param action - The action performed
|
||||
* @returns XP points earned
|
||||
*/
|
||||
export function calculateActionXP(action: BlobbiAction): number {
|
||||
return ACTION_XP[action] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total XP gain for using multiple items.
|
||||
* Each item use counts as a separate action for XP purposes.
|
||||
*
|
||||
* @param action - The action performed
|
||||
* @param quantity - Number of items used (defaults to 1)
|
||||
* @returns Total XP points earned
|
||||
*/
|
||||
export function calculateInventoryActionXP(action: InventoryAction, quantity: number = 1): number {
|
||||
if (quantity < 1) return 0;
|
||||
const baseXP = INVENTORY_ACTION_XP[action] ?? 0;
|
||||
return baseXP * quantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply XP gain to current experience value.
|
||||
*
|
||||
* @param currentXP - Current experience points (undefined = 0)
|
||||
* @param xpGain - XP points to add
|
||||
* @returns New total XP (never negative)
|
||||
*/
|
||||
export function applyXPGain(currentXP: number | undefined, xpGain: number): number {
|
||||
const current = currentXP ?? 0;
|
||||
const newXP = current + xpGain;
|
||||
return Math.max(0, newXP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get XP gain summary for displaying to the user.
|
||||
*
|
||||
* @param action - The action performed
|
||||
* @param quantity - Number of times the action was performed (for inventory actions)
|
||||
* @returns Object with xpGained and total quantity
|
||||
*/
|
||||
export function getXPGainSummary(
|
||||
action: BlobbiAction,
|
||||
quantity: number = 1
|
||||
): { xpGained: number; quantity: number } {
|
||||
const baseXP = ACTION_XP[action] ?? 0;
|
||||
const xpGained = baseXP * quantity;
|
||||
return { xpGained, quantity };
|
||||
}
|
||||
|
||||
// ─── XP Display Utilities ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format XP gain for display in toasts/notifications.
|
||||
*
|
||||
* @param xpGained - Amount of XP gained
|
||||
* @returns Formatted string like "+15 XP"
|
||||
*/
|
||||
export function formatXPGain(xpGained: number): string {
|
||||
if (xpGained <= 0) return '';
|
||||
return `+${xpGained} XP`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a descriptive message about XP gain.
|
||||
*
|
||||
* @param action - The action that earned XP
|
||||
* @param xpGained - Amount of XP gained
|
||||
* @param newTotal - New total XP (optional, for "You now have X XP" message)
|
||||
* @returns Formatted message for user feedback
|
||||
*/
|
||||
export function getXPGainMessage(
|
||||
action: BlobbiAction,
|
||||
xpGained: number,
|
||||
newTotal?: number
|
||||
): string {
|
||||
if (xpGained <= 0) return '';
|
||||
|
||||
const xpText = formatXPGain(xpGained);
|
||||
|
||||
if (newTotal !== undefined) {
|
||||
return `${xpText} earned! Total: ${newTotal} XP`;
|
||||
}
|
||||
|
||||
return `${xpText} earned!`;
|
||||
}
|
||||
@@ -1,58 +1,19 @@
|
||||
/**
|
||||
* Adult Blobbi SVG Customizer
|
||||
*
|
||||
*
|
||||
* Handles applying colors and customizations to adult SVG content.
|
||||
* Each adult form has different gradient IDs that need color mapping.
|
||||
*
|
||||
*
|
||||
* IMPORTANT: Gradients must be preserved for 3D shading effects.
|
||||
* We replace gradient colors, not the gradient structure.
|
||||
*
|
||||
* Uses shared utilities from blobbi/ui/lib/svg for common operations.
|
||||
*/
|
||||
|
||||
import type { Blobbi } from '@/types/blobbi';
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import { lightenColor, darkenColor, uniquifySvgIds, ensureSvgFillsContainer } from '@/blobbi/ui/lib/svg';
|
||||
import type { AdultForm, AdultSvgCustomization } from '../types/adult.types';
|
||||
|
||||
// ─── Color Utilities ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lighten a hex color by a percentage
|
||||
*/
|
||||
function lightenColor(color: string, percent: number): string {
|
||||
if (color.startsWith('#')) {
|
||||
const num = parseInt(color.slice(1), 16);
|
||||
const amt = Math.round(2.55 * percent);
|
||||
const R = (num >> 16) + amt;
|
||||
const G = (num >> 8 & 0x00FF) + amt;
|
||||
const B = (num & 0x0000FF) + amt;
|
||||
return '#' + (
|
||||
0x1000000 +
|
||||
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
|
||||
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
|
||||
(B < 255 ? (B < 1 ? 0 : B) : 255)
|
||||
).toString(16).slice(1).toUpperCase();
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Darken a hex color by a percentage
|
||||
*/
|
||||
function darkenColor(color: string, percent: number): string {
|
||||
if (color.startsWith('#')) {
|
||||
const num = parseInt(color.slice(1), 16);
|
||||
const amt = Math.round(2.55 * percent);
|
||||
const R = (num >> 16) - amt;
|
||||
const G = (num >> 8 & 0x00FF) - amt;
|
||||
const B = (num & 0x0000FF) - amt;
|
||||
return '#' + (
|
||||
0x1000000 +
|
||||
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
|
||||
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
|
||||
(B < 255 ? (B < 1 ? 0 : B) : 255)
|
||||
).toString(16).slice(1).toUpperCase();
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
// ─── Gradient Builders ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -610,77 +571,6 @@ export function customizeAdultSvg(
|
||||
return modifiedSvg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure SVG has width/height attributes so it fills its container
|
||||
*/
|
||||
function ensureSvgFillsContainer(svgText: string): string {
|
||||
if (/\swidth=/.test(svgText) && /\sheight=/.test(svgText)) {
|
||||
return svgText;
|
||||
}
|
||||
|
||||
return svgText.replace(
|
||||
/<svg([^>]*)>/,
|
||||
'<svg$1 width="100%" height="100%">'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make all SVG definition IDs unique by prefixing with an instance ID.
|
||||
* This prevents gradient ID collisions when multiple Blobbis are rendered on the same page.
|
||||
*
|
||||
* Updates both:
|
||||
* - Definition IDs: id="gradientName" → id="prefix_gradientName"
|
||||
* - References: url(#gradientName) → url(#prefix_gradientName)
|
||||
*/
|
||||
function uniquifySvgIds(svgText: string, instanceId: string): string {
|
||||
// Generate a unique prefix from the full instance ID
|
||||
// Sanitize to only allow valid SVG ID characters (letters, numbers, underscore, hyphen)
|
||||
// Note: instanceId format is "blobbi-{pubkeyPrefix12}-{petId10}" so we need the full ID
|
||||
// to distinguish between Blobbis owned by the same user
|
||||
const prefix = `b_${instanceId.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
|
||||
|
||||
// Find all IDs defined in the SVG (in defs, gradients, clipPaths, etc.)
|
||||
const idPattern = /\bid=["']([^"']+)["']/g;
|
||||
const ids = new Set<string>();
|
||||
let match;
|
||||
|
||||
while ((match = idPattern.exec(svgText)) !== null) {
|
||||
ids.add(match[1]);
|
||||
}
|
||||
|
||||
// Replace each ID and its references
|
||||
let modified = svgText;
|
||||
for (const id of ids) {
|
||||
const prefixedId = `${prefix}_${id}`;
|
||||
|
||||
// Replace the ID definition
|
||||
modified = modified.replace(
|
||||
new RegExp(`\\bid=["']${id}["']`, 'g'),
|
||||
`id="${prefixedId}"`
|
||||
);
|
||||
|
||||
// Replace url() references
|
||||
modified = modified.replace(
|
||||
new RegExp(`url\\(#${id}\\)`, 'g'),
|
||||
`url(#${prefixedId})`
|
||||
);
|
||||
|
||||
// Replace xlink:href references (older SVG format)
|
||||
modified = modified.replace(
|
||||
new RegExp(`xlink:href=["']#${id}["']`, 'g'),
|
||||
`xlink:href="#${prefixedId}"`
|
||||
);
|
||||
|
||||
// Replace href references (newer SVG format)
|
||||
modified = modified.replace(
|
||||
new RegExp(`\\bhref=["']#${id}["']`, 'g'),
|
||||
`href="#${prefixedId}"`
|
||||
);
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: Apply generic body gradient for forms without specific customizer
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Each adult form has its own folder with base and sleeping variants.
|
||||
*/
|
||||
|
||||
import type { Blobbi } from '@/types/blobbi';
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import {
|
||||
type AdultForm,
|
||||
type AdultSvgResolverOptions,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Type definitions for adult stage visuals and customization
|
||||
*/
|
||||
|
||||
import type { Blobbi } from '@/types/blobbi';
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
|
||||
/**
|
||||
* All available adult evolution forms.
|
||||
|
||||
@@ -1,35 +1,14 @@
|
||||
/**
|
||||
* Baby Blobbi SVG Customizer
|
||||
*
|
||||
* Handles applying colors and customizations to baby SVG content
|
||||
*
|
||||
* Handles applying colors and customizations to baby SVG content.
|
||||
* Uses shared utilities from blobbi/ui/lib/svg for common operations.
|
||||
*/
|
||||
|
||||
import { Blobbi } from '@/types/blobbi';
|
||||
import { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import { lightenColor, uniquifySvgIds, ensureSvgFillsContainer } from '@/blobbi/ui/lib/svg';
|
||||
import { BabySvgCustomization } from '../types/baby.types';
|
||||
|
||||
/**
|
||||
* Lighten a color by a percentage
|
||||
*/
|
||||
function lightenColor(color: string, percent: number): string {
|
||||
// Handle hex colors
|
||||
if (color.startsWith('#')) {
|
||||
const num = parseInt(color.slice(1), 16);
|
||||
const amt = Math.round(2.55 * percent);
|
||||
const R = (num >> 16) + amt;
|
||||
const G = (num >> 8 & 0x00FF) + amt;
|
||||
const B = (num & 0x0000FF) + amt;
|
||||
return '#' + (
|
||||
0x1000000 +
|
||||
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
|
||||
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
|
||||
(B < 255 ? (B < 1 ? 0 : B) : 255)
|
||||
).toString(16).slice(1).toUpperCase();
|
||||
}
|
||||
|
||||
// Return as-is for non-hex colors (rgb, etc.)
|
||||
return color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply color customizations to baby SVG
|
||||
*
|
||||
@@ -78,79 +57,6 @@ export function customizeBabySvg(
|
||||
return modifiedSvg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure SVG has width/height attributes so it fills its container
|
||||
*/
|
||||
function ensureSvgFillsContainer(svgText: string): string {
|
||||
// Check if width and height are already set
|
||||
if (/\swidth=/.test(svgText) && /\sheight=/.test(svgText)) {
|
||||
return svgText;
|
||||
}
|
||||
|
||||
// Add width="100%" height="100%" to the SVG tag
|
||||
return svgText.replace(
|
||||
/<svg([^>]*)>/,
|
||||
'<svg$1 width="100%" height="100%">'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make all SVG definition IDs unique by prefixing with an instance ID.
|
||||
* This prevents gradient ID collisions when multiple Blobbis are rendered on the same page.
|
||||
*
|
||||
* Updates both:
|
||||
* - Definition IDs: id="gradientName" → id="prefix_gradientName"
|
||||
* - References: url(#gradientName) → url(#prefix_gradientName)
|
||||
*/
|
||||
function uniquifySvgIds(svgText: string, instanceId: string): string {
|
||||
// Generate a unique prefix from the full instance ID
|
||||
// Sanitize to only allow valid SVG ID characters (letters, numbers, underscore, hyphen)
|
||||
// Note: instanceId format is "blobbi-{pubkeyPrefix12}-{petId10}" so we need the full ID
|
||||
// to distinguish between Blobbis owned by the same user
|
||||
const prefix = `b_${instanceId.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
|
||||
|
||||
// Find all IDs defined in the SVG (in defs, gradients, clipPaths, etc.)
|
||||
const idPattern = /\bid=["']([^"']+)["']/g;
|
||||
const ids = new Set<string>();
|
||||
let match;
|
||||
|
||||
while ((match = idPattern.exec(svgText)) !== null) {
|
||||
ids.add(match[1]);
|
||||
}
|
||||
|
||||
// Replace each ID and its references
|
||||
let modified = svgText;
|
||||
for (const id of ids) {
|
||||
const prefixedId = `${prefix}_${id}`;
|
||||
|
||||
// Replace the ID definition
|
||||
modified = modified.replace(
|
||||
new RegExp(`\\bid=["']${id}["']`, 'g'),
|
||||
`id="${prefixedId}"`
|
||||
);
|
||||
|
||||
// Replace url() references
|
||||
modified = modified.replace(
|
||||
new RegExp(`url\\(#${id}\\)`, 'g'),
|
||||
`url(#${prefixedId})`
|
||||
);
|
||||
|
||||
// Replace xlink:href references (older SVG format)
|
||||
modified = modified.replace(
|
||||
new RegExp(`xlink:href=["']#${id}["']`, 'g'),
|
||||
`xlink:href="#${prefixedId}"`
|
||||
);
|
||||
|
||||
// Replace href references (newer SVG format)
|
||||
modified = modified.replace(
|
||||
new RegExp(`\\bhref=["']#${id}["']`, 'g'),
|
||||
`href="#${prefixedId}"`
|
||||
);
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply body gradient customization
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Handles loading and resolving baby stage SVG assets
|
||||
*/
|
||||
|
||||
import { Blobbi } from '@/types/blobbi';
|
||||
import { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import { BabyVariant, BabySvgResolverOptions } from '../types/baby.types';
|
||||
import { BABY_BASE_SVG, BABY_SLEEPING_SVG } from './baby-svg-data';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Type definitions for baby stage visuals and customization
|
||||
*/
|
||||
|
||||
import { Blobbi } from '@/types/blobbi';
|
||||
import { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
|
||||
/**
|
||||
* Baby visual variant types
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
Position,
|
||||
EntryState,
|
||||
} from '../types/companion.types';
|
||||
import type { RefObject } from 'react';
|
||||
import { DEFAULT_COMPANION_CONFIG } from '../core/companionConfig';
|
||||
import {
|
||||
calculateFloatAnimation,
|
||||
@@ -28,6 +29,9 @@ import {
|
||||
} from '../utils/animation';
|
||||
import { BlobbiCompanionVisual } from './BlobbiCompanionVisual';
|
||||
import { useClickDetection } from '../interaction';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
|
||||
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects';
|
||||
|
||||
interface BlobbiCompanionProps {
|
||||
/** Companion data */
|
||||
@@ -36,8 +40,8 @@ interface BlobbiCompanionProps {
|
||||
state: CompanionState;
|
||||
/** Current motion state */
|
||||
motion: CompanionMotion;
|
||||
/** Eye offset for gaze */
|
||||
eyeOffset: EyeOffset;
|
||||
/** Ref-based eye offset for imperative gaze control (avoids per-frame rerenders) */
|
||||
eyeOffsetRef: RefObject<EyeOffset>;
|
||||
/** Whether entry animation is playing */
|
||||
isEntering: boolean;
|
||||
/** Entry animation progress (0-1) */
|
||||
@@ -58,6 +62,17 @@ interface BlobbiCompanionProps {
|
||||
onEndDrag: () => void;
|
||||
/** Click callback (when interaction is a click, not a drag) */
|
||||
onClick?: () => void;
|
||||
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
|
||||
recipe?: BlobbiVisualRecipe;
|
||||
/** Label for the recipe (CSS class names). */
|
||||
recipeLabel?: string;
|
||||
/** Named emotion preset (convenience). Ignored when `recipe` is provided. */
|
||||
emotion?: BlobbiEmotion;
|
||||
/**
|
||||
* Body-level visual effects — for manual/external use only.
|
||||
* Status-reaction body effects are already folded into the recipe.
|
||||
*/
|
||||
bodyEffects?: BodyEffectsSpec;
|
||||
/** Callback to report rendered position (including animations) */
|
||||
onPositionUpdate?: (position: Position) => void;
|
||||
/** Debug mode - disables animations and shows visual debug aids */
|
||||
@@ -68,7 +83,7 @@ export function BlobbiCompanion({
|
||||
companion,
|
||||
state,
|
||||
motion,
|
||||
eyeOffset,
|
||||
eyeOffsetRef,
|
||||
isEntering,
|
||||
entryProgress: _entryProgress,
|
||||
entryState,
|
||||
@@ -79,13 +94,17 @@ export function BlobbiCompanion({
|
||||
onUpdateDrag,
|
||||
onEndDrag,
|
||||
onClick,
|
||||
recipe,
|
||||
recipeLabel,
|
||||
emotion,
|
||||
bodyEffects,
|
||||
onPositionUpdate,
|
||||
debugMode = false,
|
||||
}: BlobbiCompanionProps) {
|
||||
const config = DEFAULT_COMPANION_CONFIG;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [animationTime, setAnimationTime] = useState(0);
|
||||
|
||||
|
||||
// Click detection - distinguishes click from drag
|
||||
const clickDetection = useClickDetection({
|
||||
onClick,
|
||||
@@ -174,8 +193,9 @@ export function BlobbiCompanion({
|
||||
}
|
||||
|
||||
// Calculate floating animation offset (gentle sway/float)
|
||||
// Skip during entry animation, dragging, or debug mode
|
||||
const floatOffset = (!useEntryPosition && !motion.isDragging && !debugMode)
|
||||
// Skip during entry animation, dragging, debug mode, or sleeping
|
||||
const isSleeping = companion.state === 'sleeping';
|
||||
const floatOffset = (!useEntryPosition && !motion.isDragging && !debugMode && !isSleeping)
|
||||
? calculateFloatAnimation(animationTime, state === 'walking')
|
||||
: { x: 0, y: 0, rotation: 0 };
|
||||
|
||||
@@ -209,12 +229,15 @@ export function BlobbiCompanion({
|
||||
: undefined;
|
||||
|
||||
// Drag handlers with click detection
|
||||
// Uses pointer events only (handles mouse, touch, and pen natively)
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Capture pointer for tracking outside element
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
// Capture pointer on the container (not e.target which may be a child)
|
||||
// for reliable tracking across element boundaries during drag
|
||||
if (containerRef.current) {
|
||||
containerRef.current.setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
// Start click detection tracking
|
||||
clickDetection.handlePointerDown({ x: e.clientX, y: e.clientY });
|
||||
@@ -235,7 +258,9 @@ export function BlobbiCompanion({
|
||||
}, [clickDetection, motion.isDragging, config.size, onUpdateDrag]);
|
||||
|
||||
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
if (containerRef.current) {
|
||||
containerRef.current.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
// Finalize click detection - will call onClick if it was a click
|
||||
clickDetection.handlePointerUp();
|
||||
@@ -246,42 +271,6 @@ export function BlobbiCompanion({
|
||||
}
|
||||
}, [clickDetection, motion.isDragging, onEndDrag]);
|
||||
|
||||
// Touch handlers for mobile (with click detection)
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.touches.length === 0) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
clickDetection.handlePointerDown({ x: touch.clientX, y: touch.clientY });
|
||||
}, [clickDetection]);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (e.touches.length === 0) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const position = { x: touch.clientX, y: touch.clientY };
|
||||
|
||||
// Check if movement exceeds click threshold (starts drag)
|
||||
const isDrag = clickDetection.handlePointerMove(position);
|
||||
|
||||
// If dragging, update position
|
||||
if (motion.isDragging || isDrag) {
|
||||
const newX = touch.clientX - config.size / 2;
|
||||
const newY = touch.clientY - config.size / 2;
|
||||
onUpdateDrag({ x: newX, y: newY });
|
||||
}
|
||||
}, [clickDetection, motion.isDragging, config.size, onUpdateDrag]);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
// Finalize click detection
|
||||
clickDetection.handlePointerUp();
|
||||
|
||||
// Always end drag state
|
||||
if (motion.isDragging) {
|
||||
onEndDrag();
|
||||
}
|
||||
}, [clickDetection, motion.isDragging, onEndDrag]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -302,20 +291,21 @@ export function BlobbiCompanion({
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<BlobbiCompanionVisual
|
||||
companion={companion}
|
||||
size={config.size}
|
||||
eyeOffset={eyeOffset}
|
||||
eyeOffsetRef={eyeOffsetRef}
|
||||
direction={isEntering ? 'right' : motion.direction}
|
||||
isDragging={motion.isDragging}
|
||||
isWalking={state === 'walking'}
|
||||
floatOffset={floatOffset}
|
||||
isOnGround={isOnGround}
|
||||
distanceFromGround={distanceFromGround}
|
||||
recipe={recipe}
|
||||
recipeLabel={recipeLabel}
|
||||
emotion={emotion}
|
||||
bodyEffects={bodyEffects}
|
||||
debugMode={debugMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
/**
|
||||
* BlobbiCompanionLayer
|
||||
*
|
||||
* Global layer component that renders the companion above all other content.
|
||||
* This should be placed at the root level of the app.
|
||||
*
|
||||
* Entry animations are VERTICAL based on sidebar navigation direction:
|
||||
* - Navigating DOWN the sidebar: Blobbi falls from the top of the screen
|
||||
* - Navigating UP the sidebar: Blobbi rises from the bottom with inspection
|
||||
*
|
||||
* Interaction features:
|
||||
* - Click/tap on Blobbi opens action menu
|
||||
* - Action menu shows available actions in a radial layout
|
||||
* - Selecting an action shows available items as floating bubbles
|
||||
* BlobbiCompanionLayer — Global orchestration layer for the companion.
|
||||
*
|
||||
* This component is the top-level coordinator. It is NOT a visual component.
|
||||
* It wires together:
|
||||
* - Companion runtime (position, motion, gaze, entry animations)
|
||||
* - Status reaction system (stats → visual recipe)
|
||||
* - Action menu and hanging items interaction
|
||||
* - Item use with temporary emotion overrides
|
||||
*
|
||||
* Visual rendering is delegated entirely to:
|
||||
* BlobbiCompanion → BlobbiCompanionVisual → MemoizedBlobbiVisual → Visual → SvgRenderer
|
||||
*
|
||||
* This file should be placed at the app root level (renders a fixed overlay).
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useState, useMemo } from 'react';
|
||||
|
||||
import { useBlobbiCompanion } from '../hooks/useBlobbiCompanion';
|
||||
import { useCompanionItemReaction } from '../hooks/useCompanionItemReaction';
|
||||
import { useActionEmotionOverride } from '../hooks/useActionEmotionOverride';
|
||||
import { BlobbiCompanion } from './BlobbiCompanion';
|
||||
import { DebugGroundOverlay } from './DebugGroundOverlay';
|
||||
import { DEFAULT_COMPANION_CONFIG } from '../core/companionConfig';
|
||||
import { calculateGroundY } from '../utils/movement';
|
||||
import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction';
|
||||
import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
import type { ActionType } from '@/blobbi/ui/lib/status-reactions';
|
||||
import {
|
||||
useCompanionActionMenu,
|
||||
useBlobbiActions,
|
||||
@@ -30,30 +35,19 @@ import {
|
||||
type CompanionItem,
|
||||
type ItemLandedData,
|
||||
} from '../interaction';
|
||||
import { useBlobbiSleepToggle } from '../interaction/useBlobbiSleepToggle';
|
||||
import type { Position } from '../types/companion.types';
|
||||
|
||||
// DEBUG MODE - Set to true to debug ground contact
|
||||
/** Set to true to show debug ground-contact lines. */
|
||||
const DEBUG_GROUND_CONTACT = false;
|
||||
|
||||
/**
|
||||
* Global companion layer.
|
||||
*
|
||||
* Renders the companion if:
|
||||
* - User is logged in
|
||||
* - User has set a current_companion in their profile
|
||||
* - The companion data is loaded
|
||||
*
|
||||
* Entry animations are vertical:
|
||||
* - Falls from top when navigating DOWN the sidebar
|
||||
* - Rises from bottom (with inspection) when navigating UP the sidebar
|
||||
*/
|
||||
export function BlobbiCompanionLayer() {
|
||||
const {
|
||||
companion,
|
||||
isVisible,
|
||||
state,
|
||||
motion,
|
||||
eyeOffset,
|
||||
eyeOffsetRef,
|
||||
isEntering,
|
||||
entryProgress,
|
||||
entryState,
|
||||
@@ -65,19 +59,20 @@ export function BlobbiCompanionLayer() {
|
||||
endDrag,
|
||||
triggerAttention,
|
||||
} = useBlobbiCompanion();
|
||||
|
||||
|
||||
const config = DEFAULT_COMPANION_CONFIG;
|
||||
|
||||
// Track the actual rendered position of the companion
|
||||
// This accounts for entry animations, float offset, etc.
|
||||
|
||||
// ── Rendered position tracking ─────────────────────────────────────────────
|
||||
// Tracks the actual visual position (including entry/float offsets) so
|
||||
// the action menu and hanging items can position relative to Blobbi.
|
||||
const [renderedPosition, setRenderedPosition] = useState<Position>(motion.position);
|
||||
|
||||
// Handle position updates from BlobbiCompanion
|
||||
|
||||
const handlePositionUpdate = useCallback((position: Position) => {
|
||||
setRenderedPosition(position);
|
||||
}, []);
|
||||
|
||||
// Callback for glancing at items (when Blobbi doesn't need them)
|
||||
|
||||
// ── Item reaction ──────────────────────────────────────────────────────────
|
||||
|
||||
const handleGlanceAtItem = useCallback((position: Position) => {
|
||||
triggerAttention(position, {
|
||||
duration: 800,
|
||||
@@ -86,39 +81,31 @@ export function BlobbiCompanionLayer() {
|
||||
isGlance: true,
|
||||
});
|
||||
}, [triggerAttention]);
|
||||
|
||||
// Callback for walking to items (when Blobbi needs them)
|
||||
// For now, we just glance more intensely - full walking behavior
|
||||
// would require deeper integration with the state machine
|
||||
|
||||
const handleWalkToItem = useCallback((position: Position) => {
|
||||
// TODO: Implement actual walking behavior via useBlobbiCompanionState
|
||||
// For now, trigger a longer attention to simulate interest
|
||||
triggerAttention(position, {
|
||||
duration: 1500,
|
||||
priority: 'normal',
|
||||
source: 'item-landed:need',
|
||||
isGlance: false, // Use longer cooldown for "interested" attention
|
||||
isGlance: false,
|
||||
});
|
||||
}, [triggerAttention]);
|
||||
|
||||
// Item reaction hook - determines if Blobbi needs items and how to react
|
||||
|
||||
const { reactToItemLanding } = useCompanionItemReaction({
|
||||
isActive: isVisible && !isEntering,
|
||||
onGlance: handleGlanceAtItem,
|
||||
onWalkTo: handleWalkToItem,
|
||||
});
|
||||
|
||||
// Handle when an item finishes falling and lands on the ground
|
||||
|
||||
const handleItemLanded = useCallback((data: ItemLandedData) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[CompanionLayer] Item landed:', data.item.name, 'at', { x: data.x, y: data.y });
|
||||
}
|
||||
|
||||
// React to the item landing based on Blobbi's needs
|
||||
reactToItemLanding(data.item.category, { x: data.x, y: data.y });
|
||||
}, [reactToItemLanding]);
|
||||
|
||||
// Action menu state
|
||||
|
||||
// ── Action menu ────────────────────────────────────────────────────────────
|
||||
|
||||
const {
|
||||
menuState,
|
||||
availableActions,
|
||||
@@ -130,57 +117,56 @@ export function BlobbiCompanionLayer() {
|
||||
isActive: isVisible,
|
||||
stage: companion?.stage,
|
||||
onItemClick: (item) => {
|
||||
// Item was clicked in the hanging menu - this releases it
|
||||
console.log('[CompanionLayer] Item released:', item);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[CompanionLayer] Item released:', item);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Get Blobbi actions from context
|
||||
// This now works even when BlobbiPage is not mounted (uses built-in fallback)
|
||||
const {
|
||||
useItem: contextUseItem,
|
||||
canUseItems,
|
||||
isItemOnCooldown
|
||||
|
||||
const {
|
||||
useItem: contextUseItem,
|
||||
canUseItems,
|
||||
isItemOnCooldown,
|
||||
} = useBlobbiActions();
|
||||
|
||||
/**
|
||||
* Handle item use - called when item contacts Blobbi or is clicked.
|
||||
* Uses the BlobbiActionsContext to perform the actual item use.
|
||||
* Returns success/failure to control whether item is removed from screen.
|
||||
*
|
||||
* Now works from any page (not just /blobbi) thanks to the built-in
|
||||
* fallback in BlobbiActionsContext.
|
||||
*/
|
||||
|
||||
// Standalone sleep/wake toggle — works without BlobbiPage mounted
|
||||
const { toggleSleep } = useBlobbiSleepToggle();
|
||||
|
||||
// ── Item use with emotion override ─────────────────────────────────────────
|
||||
|
||||
const { actionOverride, triggerOverride } = useActionEmotionOverride();
|
||||
|
||||
const handleItemUse = useCallback(async (item: CompanionItem): Promise<{ success: boolean; error?: string }> => {
|
||||
// Resolve the action from the item category
|
||||
const action = CATEGORY_TO_ACTION[item.category];
|
||||
|
||||
|
||||
if (!action) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[CompanionLayer] No action for item category:', item.category);
|
||||
}
|
||||
return { success: false, error: `Cannot use ${item.category} items` };
|
||||
}
|
||||
|
||||
|
||||
if (!canUseItems) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[CompanionLayer] Cannot use items - no companion selected');
|
||||
}
|
||||
return { success: false, error: 'No companion selected' };
|
||||
}
|
||||
|
||||
|
||||
// Trigger the temporary emotion override for visual feedback
|
||||
triggerOverride(action as ActionType);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[CompanionLayer] Using item:', item.name, 'with action:', action);
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const result = await contextUseItem(item.id, action, 1);
|
||||
|
||||
|
||||
if (result.success) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[CompanionLayer] Item used successfully:', item.name, result.statsChanged);
|
||||
}
|
||||
// Close the menu after successful use
|
||||
closeMenu();
|
||||
return { success: true };
|
||||
} else {
|
||||
@@ -196,154 +182,130 @@ export function BlobbiCompanionLayer() {
|
||||
}
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}, [canUseItems, contextUseItem, closeMenu]);
|
||||
|
||||
// Handle companion click
|
||||
}, [canUseItems, contextUseItem, closeMenu, triggerOverride]);
|
||||
|
||||
// ── Companion click ────────────────────────────────────────────────────────
|
||||
|
||||
// ── Sleep action (direct, not item-based) ───────────────────────────────────
|
||||
|
||||
const handleSleepAction = useCallback(async () => {
|
||||
closeMenu();
|
||||
try {
|
||||
await toggleSleep();
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('[CompanionLayer] Sleep toggle failed:', error);
|
||||
}
|
||||
}
|
||||
}, [toggleSleep, closeMenu]);
|
||||
|
||||
/** Intercept action selection: sleep is a direct action, others go through item flow. */
|
||||
const handleActionClick = useCallback((action: Parameters<typeof selectAction>[0]) => {
|
||||
if (action === 'sleep') {
|
||||
handleSleepAction();
|
||||
} else {
|
||||
selectAction(action);
|
||||
}
|
||||
}, [handleSleepAction, selectAction]);
|
||||
|
||||
const handleCompanionClick = useCallback(() => {
|
||||
// Don't open menu during entry animation
|
||||
if (isEntering) return;
|
||||
|
||||
toggleMenu();
|
||||
}, [isEntering, toggleMenu]);
|
||||
|
||||
// Handle click outside menu
|
||||
|
||||
const handleClickOutside = useCallback(() => {
|
||||
closeMenu();
|
||||
}, [closeMenu]);
|
||||
|
||||
// Don't render anything if not visible
|
||||
|
||||
// ── Status reaction ────────────────────────────────────────────────────────
|
||||
// Resolves companion stats into a visual recipe (sleepy, hungry, dirty, etc.).
|
||||
// The actionOverride from useActionEmotionOverride temporarily overrides
|
||||
// the recipe when an item is used (e.g., feeding → happy face for 1.5s).
|
||||
//
|
||||
// Status reaction stays ENABLED during sleep so body effects (dirty) and
|
||||
// extras (food icon) still resolve. The sleeping recipe overlay is applied
|
||||
// on top to override the face while preserving compatible body effects.
|
||||
|
||||
const isSleeping = companion?.state === 'sleeping';
|
||||
const companionStats = useMemo(() => companion?.stats ?? {
|
||||
hunger: 100, happiness: 100, health: 100, hygiene: 100, energy: 100,
|
||||
}, [companion?.stats]);
|
||||
|
||||
const { recipe: statusRecipe, recipeLabel: statusRecipeLabel } = useStatusReaction({
|
||||
stats: companionStats,
|
||||
enabled: isVisible && companion?.stage !== 'egg',
|
||||
actionOverride: isSleeping ? null : actionOverride,
|
||||
});
|
||||
|
||||
// When sleeping, overlay the sleeping face on top of the status recipe.
|
||||
// This keeps body effects (dirty, stink) and food icon while overriding
|
||||
// eyes, mouth, and eyebrows with sleeping visuals.
|
||||
const companionRecipe = isSleeping
|
||||
? buildSleepingRecipe(statusRecipe)
|
||||
: statusRecipe;
|
||||
const companionRecipeLabel = isSleeping ? 'sleeping' : statusRecipeLabel;
|
||||
|
||||
// ── Early return ───────────────────────────────────────────────────────────
|
||||
|
||||
if (!isVisible || !companion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Companion props
|
||||
const companionProps = {
|
||||
companion,
|
||||
state,
|
||||
motion,
|
||||
eyeOffset,
|
||||
isEntering,
|
||||
entryProgress,
|
||||
entryState,
|
||||
wasResolvedFromStuck,
|
||||
groundPosition,
|
||||
viewport,
|
||||
onStartDrag: startDrag,
|
||||
onUpdateDrag: updateDrag,
|
||||
onEndDrag: endDrag,
|
||||
onClick: handleCompanionClick,
|
||||
onPositionUpdate: handlePositionUpdate,
|
||||
};
|
||||
|
||||
// Calculate ground position for debug line
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const debugGroundY = calculateGroundY(viewport.height, config.size, config);
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none"
|
||||
style={{ zIndex: 9999 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* DEBUG: Visible ground line */}
|
||||
{DEBUG_GROUND_CONTACT && (
|
||||
<>
|
||||
{/* Ground line where Blobbi's CONTAINER bottom should be */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: debugGroundY + config.size, // Container bottom
|
||||
height: 2,
|
||||
backgroundColor: 'red',
|
||||
zIndex: 10002,
|
||||
}}
|
||||
/>
|
||||
{/* Label for the ground line */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 10,
|
||||
top: debugGroundY + config.size + 4,
|
||||
color: 'red',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
zIndex: 10002,
|
||||
backgroundColor: 'white',
|
||||
padding: '2px 4px',
|
||||
}}
|
||||
>
|
||||
Container bottom (groundY + size = {Math.round(debugGroundY + config.size)}px)
|
||||
</div>
|
||||
{/* Another line showing the actual viewport bottom minus padding */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: viewport.height - config.padding.bottom,
|
||||
height: 2,
|
||||
backgroundColor: 'blue',
|
||||
zIndex: 10002,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 10,
|
||||
top: viewport.height - config.padding.bottom + 4,
|
||||
color: 'blue',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
zIndex: 10002,
|
||||
backgroundColor: 'white',
|
||||
padding: '2px 4px',
|
||||
}}
|
||||
>
|
||||
Viewport - padding = {viewport.height - config.padding.bottom}px (Target ground)
|
||||
</div>
|
||||
{/* Entry type indicator */}
|
||||
{isEntering && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 10,
|
||||
top: 10,
|
||||
color: entryState.entryType === 'fall' ? 'orange' : 'green',
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
zIndex: 10002,
|
||||
backgroundColor: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
Entry: {entryState.entryType.toUpperCase()} | Phase: {entryState.phase}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<DebugGroundOverlay
|
||||
groundY={debugGroundY}
|
||||
size={config.size}
|
||||
viewportHeight={viewport.height}
|
||||
paddingBottom={config.padding.bottom}
|
||||
isEntering={isEntering}
|
||||
entryState={entryState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Companion */}
|
||||
|
||||
<div className="pointer-events-auto">
|
||||
<BlobbiCompanion
|
||||
{...companionProps}
|
||||
<BlobbiCompanion
|
||||
companion={companion}
|
||||
state={state}
|
||||
motion={motion}
|
||||
eyeOffsetRef={eyeOffsetRef}
|
||||
isEntering={isEntering}
|
||||
entryProgress={entryProgress}
|
||||
entryState={entryState}
|
||||
wasResolvedFromStuck={wasResolvedFromStuck}
|
||||
groundPosition={groundPosition}
|
||||
viewport={viewport}
|
||||
onStartDrag={startDrag}
|
||||
onUpdateDrag={updateDrag}
|
||||
onEndDrag={endDrag}
|
||||
onClick={handleCompanionClick}
|
||||
recipe={companionRecipe}
|
||||
recipeLabel={companionRecipeLabel}
|
||||
onPositionUpdate={handlePositionUpdate}
|
||||
debugMode={DEBUG_GROUND_CONTACT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Menu - radial buttons around Blobbi */}
|
||||
|
||||
<CompanionActionMenu
|
||||
isOpen={menuState.isOpen}
|
||||
companionPosition={renderedPosition}
|
||||
companionSize={config.size}
|
||||
actions={availableActions}
|
||||
selectedAction={menuState.selectedAction}
|
||||
onActionClick={selectAction}
|
||||
onActionClick={handleActionClick}
|
||||
onClickOutside={handleClickOutside}
|
||||
isSleeping={isSleeping}
|
||||
/>
|
||||
|
||||
{/* Hanging Items - items displayed as hanging elements from top */}
|
||||
|
||||
<HangingItems
|
||||
isVisible={menuState.isOpen && menuState.selectedAction !== null}
|
||||
selectedAction={menuState.selectedAction}
|
||||
|
||||
@@ -1,197 +1,210 @@
|
||||
/**
|
||||
* BlobbiCompanionVisual
|
||||
*
|
||||
*
|
||||
* Visual component for rendering the companion Blobbi.
|
||||
* Supports external eye offset control for custom gaze behavior.
|
||||
*
|
||||
* Architecture:
|
||||
* - Outer shell: handles per-frame updates (float, shadow, drag state) — rerenders freely
|
||||
* - Float wrapper: owns translateY alignment + JS float offset (inline transform)
|
||||
* - Sway wrapper: owns CSS rotation animation only (animate-blobbi-sway)
|
||||
* Kept separate from float wrapper so CSS @keyframes don't override the
|
||||
* inline translateY, which would make Blobbi float above the ground.
|
||||
* - Inner MemoizedBlobbiVisual: renders the actual SVG — only rerenders when visual inputs change
|
||||
* - Eye gaze is driven imperatively via ref (no React rerenders for gaze)
|
||||
*/
|
||||
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useMemo, memo, type RefObject } from 'react';
|
||||
|
||||
import { BlobbiBabyVisual } from '@/blobbi/ui/BlobbiBabyVisual';
|
||||
import { BlobbiAdultVisual } from '@/blobbi/ui/BlobbiAdultVisual';
|
||||
import { companionDataToBlobbi } from '@/blobbi/ui/lib/adapters';
|
||||
import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
|
||||
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects';
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CompanionData, EyeOffset, CompanionDirection } from '../types/companion.types';
|
||||
import type { Blobbi } from '@/types/blobbi';
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiCompanionVisualProps {
|
||||
/** Companion data */
|
||||
companion: CompanionData;
|
||||
/** Size in pixels */
|
||||
size: number;
|
||||
/** Eye offset for gaze direction */
|
||||
eyeOffset: EyeOffset;
|
||||
/** Facing direction (used for gaze, not for flipping) */
|
||||
eyeOffsetRef: RefObject<EyeOffset>;
|
||||
direction: CompanionDirection;
|
||||
/** Whether the companion is being dragged */
|
||||
isDragging: boolean;
|
||||
/** Whether the companion is walking */
|
||||
isWalking: boolean;
|
||||
/** Floating animation offset for gentle sway */
|
||||
floatOffset?: { x: number; y: number; rotation: number };
|
||||
/** Whether Blobbi is on or near the ground (affects shadow visibility) */
|
||||
isOnGround?: boolean;
|
||||
/** Distance from ground in pixels (for shadow fade, 0 = on ground) */
|
||||
distanceFromGround?: number;
|
||||
/** Additional class names */
|
||||
recipe?: BlobbiVisualRecipe;
|
||||
recipeLabel?: string;
|
||||
emotion?: BlobbiEmotion;
|
||||
bodyEffects?: BodyEffectsSpec;
|
||||
className?: string;
|
||||
/** Debug mode - shows visual boundaries */
|
||||
debugMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert CompanionData to the Blobbi type for rendering.
|
||||
*/
|
||||
function toBlobiForVisual(companion: CompanionData): Blobbi {
|
||||
return {
|
||||
id: companion.d,
|
||||
name: companion.name,
|
||||
lifeStage: companion.stage,
|
||||
state: 'active',
|
||||
isSleeping: false,
|
||||
stats: {
|
||||
hunger: 100,
|
||||
happiness: 100,
|
||||
health: 100,
|
||||
hygiene: 100,
|
||||
energy: companion.energy,
|
||||
},
|
||||
baseColor: companion.visualTraits.baseColor,
|
||||
secondaryColor: companion.visualTraits.secondaryColor,
|
||||
eyeColor: companion.visualTraits.eyeColor,
|
||||
pattern: companion.visualTraits.pattern,
|
||||
specialMark: companion.visualTraits.specialMark,
|
||||
size: companion.visualTraits.size,
|
||||
seed: companion.seed ?? '',
|
||||
tags: [],
|
||||
// Include adult form info for proper rendering
|
||||
adult: companion.adultType ? { evolutionForm: companion.adultType } : undefined,
|
||||
};
|
||||
// ─── Memoized Inner Visual ────────────────────────────────────────────────────
|
||||
//
|
||||
// STABILITY CONTRACT:
|
||||
// This component is the boundary that protects the SVG DOM subtree from the
|
||||
// companion rerender storm (~60 renders/s from motion/float RAF loops).
|
||||
// It renders BlobbiAdultVisual / BlobbiBabyVisual with renderMode="companion".
|
||||
//
|
||||
// It MUST only rerender when actual visual content changes:
|
||||
// blobbi, recipe, recipeLabel, emotion, bodyEffects, stage
|
||||
//
|
||||
// It MUST NOT receive or depend on per-frame values:
|
||||
// eyeOffset value, floatOffset, isDragging, isWalking, position, animationTime
|
||||
//
|
||||
// The eyeOffsetRef is a stable React ref — its identity never changes,
|
||||
// so it is safe to pass without triggering rerenders.
|
||||
|
||||
interface MemoizedBlobbiVisualProps {
|
||||
stage: 'baby' | 'adult';
|
||||
blobbi: Blobbi;
|
||||
eyeOffsetRef: RefObject<EyeOffset>;
|
||||
recipe?: BlobbiVisualRecipe;
|
||||
recipeLabel?: string;
|
||||
emotion: BlobbiEmotion;
|
||||
bodyEffects?: BodyEffectsSpec;
|
||||
}
|
||||
|
||||
const MemoizedBlobbiVisual = memo(function MemoizedBlobbiVisual({
|
||||
stage,
|
||||
blobbi,
|
||||
eyeOffsetRef,
|
||||
recipe,
|
||||
recipeLabel,
|
||||
emotion,
|
||||
bodyEffects,
|
||||
}: MemoizedBlobbiVisualProps) {
|
||||
if (stage === 'baby') {
|
||||
return (
|
||||
<BlobbiBabyVisual
|
||||
blobbi={blobbi}
|
||||
renderMode="companion"
|
||||
lookMode="forward"
|
||||
externalEyeOffsetRef={eyeOffsetRef}
|
||||
recipe={recipe}
|
||||
recipeLabel={recipeLabel}
|
||||
emotion={emotion}
|
||||
bodyEffects={bodyEffects}
|
||||
className="size-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlobbiAdultVisual
|
||||
blobbi={blobbi}
|
||||
renderMode="companion"
|
||||
lookMode="forward"
|
||||
externalEyeOffsetRef={eyeOffsetRef}
|
||||
recipe={recipe}
|
||||
recipeLabel={recipeLabel}
|
||||
emotion={emotion}
|
||||
bodyEffects={bodyEffects}
|
||||
className="size-full"
|
||||
/>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
return (
|
||||
prev.stage === next.stage &&
|
||||
prev.blobbi === next.blobbi &&
|
||||
prev.recipe === next.recipe &&
|
||||
prev.recipeLabel === next.recipeLabel &&
|
||||
prev.emotion === next.emotion &&
|
||||
prev.bodyEffects === next.bodyEffects
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiCompanionVisual({
|
||||
companion,
|
||||
size,
|
||||
eyeOffset,
|
||||
eyeOffsetRef,
|
||||
direction,
|
||||
isDragging,
|
||||
isWalking,
|
||||
floatOffset = { x: 0, y: 0, rotation: 0 },
|
||||
isOnGround = true,
|
||||
distanceFromGround = 0,
|
||||
recipe: recipeProp,
|
||||
recipeLabel: recipeLabelProp,
|
||||
emotion: emotionProp,
|
||||
bodyEffects: bodyEffectsProp,
|
||||
className,
|
||||
debugMode = false,
|
||||
}: BlobbiCompanionVisualProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const blobbi = useMemo(() => toBlobiForVisual(companion), [companion]);
|
||||
|
||||
// DEV ONLY: Get effective emotion from dev context
|
||||
const effectiveEmotion = useEffectiveEmotion();
|
||||
|
||||
// Eye offset is now passed directly to the visual components via externalEyeOffset prop
|
||||
// This is more reliable than DOM manipulation which can be overwritten by useBlobbiEyes
|
||||
|
||||
// Build transform for floating animation
|
||||
// No flipping based on direction - Blobbi always faces the same way
|
||||
const blobbi = useMemo(() => companionDataToBlobbi(companion), [companion]);
|
||||
|
||||
// DEV ONLY: Get effective emotion from dev context (overrides production emotions)
|
||||
const devEmotion = useEffectiveEmotion();
|
||||
const hasDevOverride = devEmotion !== 'neutral';
|
||||
|
||||
const effectiveRecipe = hasDevOverride ? undefined : recipeProp;
|
||||
const effectiveRecipeLabel = hasDevOverride ? undefined : recipeLabelProp;
|
||||
const effectiveEmotion = hasDevOverride ? devEmotion : (emotionProp ?? 'neutral');
|
||||
const effectiveBodyEffects = hasDevOverride ? undefined : bodyEffectsProp;
|
||||
|
||||
// Float transform
|
||||
const blobbiTransform = useMemo(() => {
|
||||
const transforms: string[] = [];
|
||||
|
||||
if (floatOffset.x !== 0 || floatOffset.y !== 0) {
|
||||
transforms.push(`translate(${floatOffset.x}px, ${floatOffset.y}px)`);
|
||||
}
|
||||
if (floatOffset.rotation !== 0) {
|
||||
transforms.push(`rotate(${floatOffset.rotation}deg)`);
|
||||
}
|
||||
|
||||
return transforms.length > 0 ? transforms.join(' ') : undefined;
|
||||
}, [floatOffset]);
|
||||
|
||||
// Determine reaction state
|
||||
const reaction = isDragging ? 'happy' : isWalking ? 'idle' : 'idle';
|
||||
|
||||
// Shadow visibility and appearance based on ground proximity
|
||||
// Shadow should only appear when Blobbi is on or very near the ground
|
||||
const SHADOW_FADE_DISTANCE = 30; // Shadow fully fades at this distance from ground
|
||||
|
||||
// Reaction state for CSS animations on the OUTER wrapper
|
||||
// When sleeping, always idle — no swaying/happy animation
|
||||
const isSleeping = companion.state === 'sleeping';
|
||||
const reaction = isSleeping ? 'idle' : isDragging ? 'happy' : isWalking ? 'swaying' : 'idle';
|
||||
|
||||
// ── Shadow ─────────────────────────────────────────────────────────────────
|
||||
const SHADOW_FADE_DISTANCE = 30;
|
||||
const SHADOW_MAX_OPACITY = 0.35;
|
||||
|
||||
// Calculate shadow visibility based on actual ground distance, not just float offset
|
||||
|
||||
const showShadow = isOnGround && !isDragging && distanceFromGround < SHADOW_FADE_DISTANCE;
|
||||
|
||||
// Shadow fades as Blobbi gets farther from ground
|
||||
// Also factor in the float animation offset for subtle breathing effect
|
||||
const floatHeight = Math.abs(floatOffset.y);
|
||||
const groundFadeRatio = Math.max(0, 1 - distanceFromGround / SHADOW_FADE_DISTANCE);
|
||||
const floatFadeRatio = Math.max(0.85, 1 - floatHeight * 0.02); // Subtle fade during float
|
||||
const floatFadeRatio = Math.max(0.85, 1 - floatHeight * 0.02);
|
||||
const shadowOpacity = SHADOW_MAX_OPACITY * groundFadeRatio * floatFadeRatio;
|
||||
const shadowScale = 0.9 + 0.1 * groundFadeRatio * floatFadeRatio; // Slightly smaller when lifting
|
||||
|
||||
// Suppress unused variable warning for direction (kept for API compatibility)
|
||||
const shadowScale = 0.9 + 0.1 * groundFadeRatio * floatFadeRatio;
|
||||
|
||||
// direction is accepted for API completeness but not currently used for rendering
|
||||
// (Blobbi does not flip based on facing direction). Suppress unused warning.
|
||||
void direction;
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
<div
|
||||
className={cn('relative', className)}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{/* DEBUG: Container and alignment markers */}
|
||||
{/* Debug alignment markers */}
|
||||
{debugMode && (
|
||||
<>
|
||||
{/* Container outline - lime */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
border: '2px solid lime',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{/* 88% line from top (where SVG body bottom should be before shift) - yellow */}
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
top: `${size * 0.88}px`,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
backgroundColor: 'yellow',
|
||||
}}
|
||||
/>
|
||||
{/* 100% line (container bottom where body should touch after shift) - cyan */}
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
backgroundColor: 'cyan',
|
||||
}}
|
||||
/>
|
||||
{/* Label showing the expected shift */}
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
top: 2,
|
||||
left: 2,
|
||||
fontSize: 8,
|
||||
color: 'white',
|
||||
backgroundColor: 'black',
|
||||
padding: '1px 2px',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 pointer-events-none" style={{ border: '2px solid lime', boxSizing: 'border-box' }} />
|
||||
<div className="absolute pointer-events-none" style={{ top: `${size * 0.88}px`, left: 0, right: 0, height: 2, backgroundColor: 'yellow' }} />
|
||||
<div className="absolute pointer-events-none" style={{ bottom: 0, left: 0, right: 0, height: 2, backgroundColor: 'cyan' }} />
|
||||
<div className="absolute pointer-events-none" style={{ top: 2, left: 2, fontSize: 8, color: 'white', backgroundColor: 'black', padding: '1px 2px' }}>
|
||||
shift: {size * 0.12}px
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Floor shadow - only visible when Blobbi is on/near the ground */}
|
||||
{/* Hidden during: dragging, entry animations, falling, or when far from ground */}
|
||||
|
||||
{/* Floor shadow */}
|
||||
{!debugMode && showShadow && shadowOpacity > 0.01 && (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
// Position shadow well below Blobbi to feel like it's on the floor
|
||||
bottom: -20,
|
||||
left: '50%',
|
||||
width: size * 0.5,
|
||||
@@ -200,53 +213,53 @@ export function BlobbiCompanionVisual({
|
||||
background: `radial-gradient(ellipse at center, rgba(0,0,0,${shadowOpacity}) 0%, rgba(0,0,0,${shadowOpacity * 0.5}) 40%, transparent 70%)`,
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(4px)',
|
||||
opacity: groundFadeRatio, // Additional opacity control for smooth fade
|
||||
opacity: groundFadeRatio,
|
||||
transition: 'opacity 0.15s ease-out, transform 0.1s ease-out',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Blobbi visual with floating transform */}
|
||||
{/*
|
||||
The Blobbi SVG has empty space: 15% at top (body starts at y=15), 12% at bottom (body ends at y=88).
|
||||
To align the visible body bottom with the container bottom, we shift down by 12% of container size.
|
||||
This is applied BEFORE the float transform so the ground position is correct.
|
||||
|
||||
{/*
|
||||
Float wrapper — owns translateY alignment + JS float offset.
|
||||
This is a separate element from the sway wrapper below so that
|
||||
the CSS animation on the sway wrapper does not override the
|
||||
inline transform here. (CSS @keyframes replace the entire
|
||||
`transform` property while active, which would drop the
|
||||
translateY alignment shift and cause Blobbi to float above
|
||||
the ground during walking.)
|
||||
*/}
|
||||
<div
|
||||
className="size-full"
|
||||
style={{
|
||||
// First apply the SVG alignment correction, then the float animation
|
||||
// The 12% shift pushes the SVG down so its visible body bottom aligns with container bottom
|
||||
transform: [
|
||||
`translateY(${size * 0.12}px)`, // SVG body alignment correction
|
||||
blobbiTransform, // Float animation (if any)
|
||||
`translateY(${size * 0.12}px)`,
|
||||
blobbiTransform,
|
||||
].filter(Boolean).join(' ') || undefined,
|
||||
transformOrigin: 'center bottom',
|
||||
transition: isDragging ? 'none' : 'transform 0.05s ease-out',
|
||||
// DEBUG: Show the shifted wrapper
|
||||
...(debugMode ? { outline: '2px dashed magenta' } : {}),
|
||||
}}
|
||||
>
|
||||
{companion.stage === 'baby' && (
|
||||
<BlobbiBabyVisual
|
||||
blobbi={blobbi}
|
||||
reaction={reaction}
|
||||
lookMode="forward"
|
||||
externalEyeOffset={eyeOffset}
|
||||
emotion={effectiveEmotion}
|
||||
className="size-full"
|
||||
/>
|
||||
)}
|
||||
{companion.stage === 'adult' && (
|
||||
<BlobbiAdultVisual
|
||||
blobbi={blobbi}
|
||||
reaction={reaction}
|
||||
lookMode="forward"
|
||||
externalEyeOffset={eyeOffset}
|
||||
emotion={effectiveEmotion}
|
||||
className="size-full"
|
||||
/>
|
||||
)}
|
||||
{/* Sway wrapper — CSS rotation only, no positioning transforms */}
|
||||
<div
|
||||
className={cn(
|
||||
'size-full',
|
||||
(reaction === 'swaying' || reaction === 'happy') && 'animate-blobbi-sway',
|
||||
)}
|
||||
style={{ transformOrigin: 'center bottom' }}
|
||||
>
|
||||
{(companion.stage === 'baby' || companion.stage === 'adult') && (
|
||||
<MemoizedBlobbiVisual
|
||||
stage={companion.stage}
|
||||
blobbi={blobbi}
|
||||
eyeOffsetRef={eyeOffsetRef}
|
||||
recipe={effectiveRecipe}
|
||||
recipeLabel={effectiveRecipeLabel}
|
||||
emotion={effectiveEmotion}
|
||||
bodyEffects={effectiveBodyEffects}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* DebugGroundOverlay — Debug-only visual overlay for ground contact debugging.
|
||||
*
|
||||
* Shows horizontal lines indicating:
|
||||
* - Container bottom (where Blobbi's container ends)
|
||||
* - Viewport bottom minus padding (target ground position)
|
||||
* - Entry animation type and phase (during entry)
|
||||
*
|
||||
* Enabled by setting DEBUG_GROUND_CONTACT = true in BlobbiCompanionLayer.
|
||||
*/
|
||||
|
||||
import type { EntryState } from '../types/companion.types';
|
||||
|
||||
interface DebugGroundOverlayProps {
|
||||
groundY: number;
|
||||
size: number;
|
||||
viewportHeight: number;
|
||||
paddingBottom: number;
|
||||
isEntering: boolean;
|
||||
entryState: EntryState;
|
||||
}
|
||||
|
||||
export function DebugGroundOverlay({
|
||||
groundY,
|
||||
size,
|
||||
viewportHeight,
|
||||
paddingBottom,
|
||||
isEntering,
|
||||
entryState,
|
||||
}: DebugGroundOverlayProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Ground line where Blobbi's CONTAINER bottom should be */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: groundY + size,
|
||||
height: 2,
|
||||
backgroundColor: 'red',
|
||||
zIndex: 10002,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 10,
|
||||
top: groundY + size + 4,
|
||||
color: 'red',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
zIndex: 10002,
|
||||
backgroundColor: 'white',
|
||||
padding: '2px 4px',
|
||||
}}
|
||||
>
|
||||
Container bottom (groundY + size = {Math.round(groundY + size)}px)
|
||||
</div>
|
||||
{/* Viewport bottom minus padding */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: viewportHeight - paddingBottom,
|
||||
height: 2,
|
||||
backgroundColor: 'blue',
|
||||
zIndex: 10002,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 10,
|
||||
top: viewportHeight - paddingBottom + 4,
|
||||
color: 'blue',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
zIndex: 10002,
|
||||
backgroundColor: 'white',
|
||||
padding: '2px 4px',
|
||||
}}
|
||||
>
|
||||
Viewport - padding = {viewportHeight - paddingBottom}px (Target ground)
|
||||
</div>
|
||||
{/* Entry type indicator */}
|
||||
{isEntering && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 10,
|
||||
top: 10,
|
||||
color: entryState.entryType === 'fall' ? 'orange' : 'green',
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
zIndex: 10002,
|
||||
backgroundColor: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
Entry: {entryState.entryType.toUpperCase()} | Phase: {entryState.phase}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* useActionEmotionOverride — Temporary emotion override when using items.
|
||||
*
|
||||
* When an item is used on the companion (e.g., feeding → happy), this hook
|
||||
* provides a short-lived emotion override that takes precedence over the
|
||||
* status reaction system. The override automatically clears after 1.5s.
|
||||
*
|
||||
* Used by BlobbiCompanionLayer to wrap item-use handlers with emotion feedback.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
import { getActionEmotion, type ActionType } from '@/blobbi/ui/lib/status-reactions';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
|
||||
|
||||
/** Duration of the action emotion override in milliseconds. */
|
||||
const ACTION_OVERRIDE_DURATION_MS = 1500;
|
||||
|
||||
interface UseActionEmotionOverrideResult {
|
||||
/** Current override emotion, or null if none active. Passed to useStatusReaction. */
|
||||
actionOverride: BlobbiEmotion | null;
|
||||
/** Trigger an override for the given action type. */
|
||||
triggerOverride: (action: ActionType) => void;
|
||||
}
|
||||
|
||||
export function useActionEmotionOverride(): UseActionEmotionOverrideResult {
|
||||
const [actionOverride, setActionOverride] = useState<BlobbiEmotion | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const triggerOverride = useCallback((action: ActionType) => {
|
||||
// Clear any existing timer
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
setActionOverride(getActionEmotion(action));
|
||||
timerRef.current = setTimeout(() => {
|
||||
setActionOverride(null);
|
||||
timerRef.current = null;
|
||||
}, ACTION_OVERRIDE_DURATION_MS);
|
||||
}, []);
|
||||
|
||||
return { actionOverride, triggerOverride };
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
CompanionData,
|
||||
CompanionState,
|
||||
CompanionMotion,
|
||||
GazeState,
|
||||
EyeOffset,
|
||||
Position,
|
||||
MovementBounds,
|
||||
@@ -20,8 +19,17 @@ import type {
|
||||
EntryType,
|
||||
InspectionDirection,
|
||||
} from '../types/companion.types';
|
||||
|
||||
/** Default motion state used before motion hook initializes */
|
||||
const DEFAULT_MOTION: CompanionMotion = {
|
||||
position: { x: 0, y: 0 },
|
||||
velocity: { x: 0, y: 0 },
|
||||
direction: 'right',
|
||||
isGrounded: true,
|
||||
isDragging: false,
|
||||
};
|
||||
import { DEFAULT_COMPANION_CONFIG } from '../core/companionConfig';
|
||||
import { calculateMovementBounds, calculateGroundY, calculateRestingPosition } from '../utils/movement';
|
||||
import { calculateMovementBounds, calculateGroundY } from '../utils/movement';
|
||||
import { useBlobbiCompanionData } from './useBlobbiCompanionData';
|
||||
import { useBlobbiCompanionState } from './useBlobbiCompanionState';
|
||||
import { useBlobbiCompanionMotion } from './useBlobbiCompanionMotion';
|
||||
@@ -50,10 +58,8 @@ interface UseBlobbiCompanionResult {
|
||||
state: CompanionState;
|
||||
/** Current motion state */
|
||||
motion: CompanionMotion;
|
||||
/** Current gaze state */
|
||||
gaze: GazeState;
|
||||
/** Smoothed eye offset for rendering */
|
||||
eyeOffset: EyeOffset;
|
||||
/** Ref-based eye offset for imperative gaze control (no rerenders) */
|
||||
eyeOffsetRef: React.RefObject<EyeOffset>;
|
||||
/** Whether entry animation is playing */
|
||||
isEntering: boolean;
|
||||
/** Entry animation progress (0-1) - legacy, use entryState for detailed control */
|
||||
@@ -128,10 +134,14 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
y: groundY,
|
||||
}), [viewport.width, config.size, groundY]);
|
||||
|
||||
const restingPosition = useMemo(() =>
|
||||
calculateRestingPosition(viewport.width, viewport.height, config.size, config),
|
||||
[viewport.width, viewport.height, config]
|
||||
);
|
||||
// Shared motion ref - motion hook writes, state hook reads
|
||||
// This solves the bidirectional dependency: state needs motion position,
|
||||
// motion needs state/targetX. By using a ref, state can read current motion
|
||||
// without creating a circular hook dependency.
|
||||
const motionRef = useRef<CompanionMotion>({
|
||||
...DEFAULT_MOTION,
|
||||
position: groundPosition,
|
||||
});
|
||||
|
||||
// Fetch companion data
|
||||
const { companion, isLoading } = useBlobbiCompanionData();
|
||||
@@ -200,7 +210,11 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
}, config.attention.postRouteDelay);
|
||||
}, [findMainContentPosition, triggerAttention, config.attention.postRouteDuration, config.attention.postRouteDelay]);
|
||||
|
||||
// Determine if companion is sleeping
|
||||
const companionSleeping = companion?.state === 'sleeping';
|
||||
|
||||
// State management
|
||||
// Pass the shared motionRef so state can read live motion values
|
||||
const {
|
||||
state,
|
||||
direction,
|
||||
@@ -210,19 +224,15 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
onReachedTarget,
|
||||
} = useBlobbiCompanionState({
|
||||
isActive: isVisible,
|
||||
motion: {
|
||||
position: restingPosition,
|
||||
velocity: { x: 0, y: 0 },
|
||||
direction: 'right',
|
||||
isGrounded: true,
|
||||
isDragging: false
|
||||
},
|
||||
motionRef,
|
||||
bounds,
|
||||
attentionTarget: currentAttention,
|
||||
isSleeping: companionSleeping,
|
||||
});
|
||||
|
||||
// Motion management
|
||||
// After entry completes, motion continues from groundPosition (where entry ended)
|
||||
// Pass sharedMotionRef so state hook can read live motion values
|
||||
const {
|
||||
motion,
|
||||
startDrag,
|
||||
@@ -237,6 +247,7 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
targetX,
|
||||
energy: companion?.energy ?? 50,
|
||||
onReachedTarget,
|
||||
sharedMotionRef: motionRef,
|
||||
});
|
||||
|
||||
// Entry animation management (handles route changes and companion changes)
|
||||
@@ -292,7 +303,7 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
}, [entryJustCompleted, wasResolvedFromStuck, setPosition, groundPosition, acknowledgeCompletion]);
|
||||
|
||||
// Gaze management - passes entry inspection direction for eye control during entry
|
||||
const { gaze, eyeOffset } = useBlobbiCompanionGaze({
|
||||
const { eyeOffsetRef } = useBlobbiCompanionGaze({
|
||||
state: isEntering ? 'idle' : state,
|
||||
direction: isEntering ? 'right' : direction,
|
||||
companionPosition: motion.position,
|
||||
@@ -310,8 +321,7 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
isVisible: shouldBeVisible,
|
||||
state: isEntering ? 'idle' : state,
|
||||
motion,
|
||||
gaze,
|
||||
eyeOffset,
|
||||
eyeOffsetRef,
|
||||
isEntering,
|
||||
entryProgress: entryState.progress,
|
||||
entryState,
|
||||
|
||||
@@ -4,22 +4,18 @@
|
||||
* Fetches the current companion data from the user's Blobbonaut profile.
|
||||
* This is the data layer - it handles fetching and provides companion data.
|
||||
*
|
||||
* IMPORTANT: This hook uses useBlobbonautProfile to ensure reactivity.
|
||||
* When the profile is updated (e.g., companion selected/removed), this hook
|
||||
* automatically receives the update via the shared query cache.
|
||||
* IMPORTANT: This hook shares the same query cache as BlobbiPage via
|
||||
* useBlobbisCollection. This ensures:
|
||||
* - Immediate reactivity when stats change (optimistic updates)
|
||||
* - Projected decay is applied for accurate visual reactions
|
||||
* - No duplicate queries or stale cache issues
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
isValidBlobbiEvent,
|
||||
parseBlobbiEvent,
|
||||
} from '@/lib/blobbi';
|
||||
import { useBlobbisCollection } from '@/blobbi/core/hooks/useBlobbisCollection';
|
||||
import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState';
|
||||
import type { CompanionData } from '../types/companion.types';
|
||||
|
||||
interface UseBlobbiCompanionDataResult {
|
||||
@@ -36,79 +32,83 @@ interface UseBlobbiCompanionDataResult {
|
||||
*
|
||||
* Flow:
|
||||
* 1. Use useBlobbonautProfile to get the profile (shared query, reactive)
|
||||
* 2. Read the currentCompanion from the profile
|
||||
* 3. If it exists, fetch the corresponding kind 31124 (Blobbi State) event
|
||||
* 4. Return the minimal data needed for rendering
|
||||
* 2. Build a dList containing just the currentCompanion
|
||||
* 3. Use useBlobbisCollection (shared with BlobbiPage) to get the companion
|
||||
* 4. Apply projected decay for accurate UI reactions
|
||||
* 5. Return the companion data with projected stats
|
||||
*
|
||||
* Reactivity:
|
||||
* - Uses the same query cache as useBlobbonautProfile
|
||||
* - When profile is updated via updateProfileEvent(), this hook reacts immediately
|
||||
* - No duplicate queries or stale cache issues
|
||||
* - Uses the same query cache as BlobbiPage (blobbi-collection)
|
||||
* - When Blobbi state is updated, optimistic updates flow through immediately
|
||||
* - Projected decay recalculates every 60 seconds
|
||||
* - No separate query or stale cache issues
|
||||
*/
|
||||
export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
// Use the shared profile hook - this ensures reactivity when profile changes
|
||||
const { profile, isLoading: profileLoading } = useBlobbonautProfile();
|
||||
|
||||
// Extract current companion d-tag from the reactive profile
|
||||
const currentCompanionD = profile?.currentCompanion;
|
||||
|
||||
// Fetch the Blobbi state if we have a current companion
|
||||
const blobbiQuery = useQuery({
|
||||
queryKey: ['companion-blobbi', user?.pubkey, currentCompanionD],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!user?.pubkey || !currentCompanionD) return null;
|
||||
|
||||
const events = await nostr.query([{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': [currentCompanionD],
|
||||
}], { signal });
|
||||
|
||||
// Get the latest valid event
|
||||
const validEvents = events
|
||||
.filter(isValidBlobbiEvent)
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
if (validEvents.length === 0) return null;
|
||||
|
||||
return parseBlobbiEvent(validEvents[0]);
|
||||
},
|
||||
enabled: !!user?.pubkey && !!currentCompanionD,
|
||||
staleTime: 60_000, // 1 minute
|
||||
gcTime: 5 * 60_000, // 5 minutes
|
||||
});
|
||||
// Build dList containing just the current companion (if set)
|
||||
// This allows us to use the shared collection query cache
|
||||
const dList = useMemo(() => {
|
||||
if (!currentCompanionD) return undefined;
|
||||
return [currentCompanionD];
|
||||
}, [currentCompanionD]);
|
||||
|
||||
// Transform to CompanionData
|
||||
// Use the shared collection query - same cache as BlobbiPage
|
||||
// This ensures we get optimistic updates immediately
|
||||
const {
|
||||
companionsByD,
|
||||
isLoading: collectionLoading,
|
||||
} = useBlobbisCollection(dList);
|
||||
|
||||
// Get the BlobbiCompanion from the collection
|
||||
const blobbi = currentCompanionD ? companionsByD[currentCompanionD] ?? null : null;
|
||||
|
||||
// Apply projected decay for accurate visual reactions
|
||||
// This recalculates every 60 seconds while mounted
|
||||
const projectedState = useProjectedBlobbiState(blobbi);
|
||||
|
||||
// Transform to CompanionData with projected stats
|
||||
// When currentCompanionD becomes null/undefined, companion becomes null
|
||||
const companion = useMemo((): CompanionData | null => {
|
||||
// If no current companion is set in profile, return null immediately
|
||||
// This ensures removal is reactive
|
||||
if (!currentCompanionD) return null;
|
||||
|
||||
const blobbi = blobbiQuery.data;
|
||||
if (!blobbi) return null;
|
||||
|
||||
// Only baby and adult can be companions
|
||||
if (blobbi.stage === 'egg') return null;
|
||||
|
||||
// Use projected stats if available, otherwise fall back to base stats
|
||||
const stats = projectedState?.stats ?? blobbi.stats;
|
||||
|
||||
return {
|
||||
d: blobbi.d,
|
||||
name: blobbi.name,
|
||||
stage: blobbi.stage,
|
||||
visualTraits: blobbi.visualTraits,
|
||||
energy: blobbi.stats.energy ?? 100,
|
||||
energy: stats.energy ?? 100,
|
||||
stats: {
|
||||
hunger: stats.hunger ?? 100,
|
||||
happiness: stats.happiness ?? 100,
|
||||
health: stats.health ?? 100,
|
||||
hygiene: stats.hygiene ?? 100,
|
||||
energy: stats.energy ?? 100,
|
||||
},
|
||||
state: blobbi.state,
|
||||
// Include adult form info for proper rendering
|
||||
adultType: blobbi.adultType,
|
||||
seed: blobbi.seed,
|
||||
};
|
||||
}, [currentCompanionD, blobbiQuery.data]);
|
||||
}, [currentCompanionD, blobbi, projectedState?.stats]);
|
||||
|
||||
return {
|
||||
companion,
|
||||
isLoading: profileLoading || (!!currentCompanionD && blobbiQuery.isLoading),
|
||||
error: blobbiQuery.error ?? null,
|
||||
isLoading: profileLoading || (!!currentCompanionD && collectionLoading),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,10 +49,8 @@ interface UseBlobbiCompanionGazeOptions {
|
||||
}
|
||||
|
||||
interface UseBlobbiCompanionGazeResult {
|
||||
/** Current gaze state */
|
||||
gaze: GazeState;
|
||||
/** Smoothed eye offset for rendering */
|
||||
eyeOffset: EyeOffset;
|
||||
/** Ref-based eye offset for imperative gaze control (no rerenders) */
|
||||
eyeOffsetRef: React.RefObject<EyeOffset>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,8 +92,11 @@ export function useBlobbiCompanionGaze({
|
||||
attentionPosition,
|
||||
entryInspectionDirection,
|
||||
}: UseBlobbiCompanionGazeOptions): UseBlobbiCompanionGazeResult {
|
||||
const [gaze, setGaze] = useState<GazeState>(createInitialGaze);
|
||||
const [eyeOffset, setEyeOffset] = useState<EyeOffset>({ x: 0, y: 0 });
|
||||
const [, setGaze] = useState<GazeState>(createInitialGaze);
|
||||
// Eye offset is driven imperatively via ref — no React state needed.
|
||||
// The RAF loop writes to eyeOffsetRef; useExternalEyeOffset reads from it.
|
||||
/** Ref-based eye offset for imperative consumers (avoids per-frame React rerenders) */
|
||||
const eyeOffsetRef = useRef<EyeOffset>({ x: 0, y: 0 });
|
||||
const [mousePosition, setMousePosition] = useState<Position | null>(null);
|
||||
|
||||
// Use refs for values that shouldn't trigger re-renders
|
||||
@@ -109,8 +110,25 @@ export function useBlobbiCompanionGaze({
|
||||
const mouseFollowTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mouseFollowCheckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Refs for frequently changing values used in animation loop
|
||||
// This prevents RAF effect from being torn down on every position change
|
||||
const directionRef = useRef(direction);
|
||||
const companionPositionRef = useRef(companionPosition);
|
||||
const companionSizeRef = useRef(companionSize);
|
||||
const mousePositionRef = useRef(mousePosition);
|
||||
const observationTargetRef = useRef(observationTarget);
|
||||
const attentionPositionRef = useRef(attentionPosition);
|
||||
|
||||
const config = DEFAULT_COMPANION_CONFIG;
|
||||
|
||||
// Keep refs updated with latest values
|
||||
useEffect(() => { directionRef.current = direction; }, [direction]);
|
||||
useEffect(() => { companionPositionRef.current = companionPosition; }, [companionPosition]);
|
||||
useEffect(() => { companionSizeRef.current = companionSize; }, [companionSize]);
|
||||
useEffect(() => { mousePositionRef.current = mousePosition; }, [mousePosition]);
|
||||
useEffect(() => { observationTargetRef.current = observationTarget; }, [observationTarget]);
|
||||
useEffect(() => { attentionPositionRef.current = attentionPosition; }, [attentionPosition]);
|
||||
|
||||
// Clear all timers helper
|
||||
const clearAllTimers = useCallback(() => {
|
||||
if (randomGazeTimerRef.current) {
|
||||
@@ -275,6 +293,9 @@ export function useBlobbiCompanionGaze({
|
||||
}, [isActive, state, observationTarget, attentionPosition, entryInspectionDirection, config.gaze.randomInterval, config.gaze.mouseFollowCooldown, config.gaze.mouseFollowChance, config.gaze.mouseFollowDuration, clearAllTimers]);
|
||||
|
||||
// Animation loop for smooth eye movement
|
||||
// IMPORTANT: This effect only depends on isActive to start/stop the loop.
|
||||
// All other values are read from refs to prevent loop recreation on every
|
||||
// position change (which caused jitter and stuck eyes after entry).
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
if (animationRef.current) {
|
||||
@@ -285,6 +306,14 @@ export function useBlobbiCompanionGaze({
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
// Read current values from refs (not closure captures)
|
||||
const currentPosition = companionPositionRef.current;
|
||||
const currentSize = companionSizeRef.current;
|
||||
const currentDirection = directionRef.current;
|
||||
const currentMouse = mousePositionRef.current;
|
||||
const currentObservation = observationTargetRef.current;
|
||||
const currentAttention = attentionPositionRef.current;
|
||||
|
||||
// Calculate target offset based on current gaze mode
|
||||
let targetOffset: EyeOffset;
|
||||
|
||||
@@ -294,19 +323,19 @@ export function useBlobbiCompanionGaze({
|
||||
// During entry inspection - use the pre-set target offset from inspection direction
|
||||
// This is set by the main effect when entryInspectionDirection changes
|
||||
targetOffset = targetOffsetRef.current;
|
||||
} else if (currentMode === 'attend-ui' && attentionPosition) {
|
||||
} else if (currentMode === 'attend-ui' && currentAttention) {
|
||||
// Look at UI element that appeared - calculate offset to that position
|
||||
targetOffset = calculateEyeOffset(companionPosition, attentionPosition, companionSize);
|
||||
} else if (currentMode === 'observe-target' && observationTarget) {
|
||||
targetOffset = calculateEyeOffset(currentPosition, currentAttention, currentSize);
|
||||
} else if (currentMode === 'observe-target' && currentObservation) {
|
||||
// Look at observation target - calculate offset to that position
|
||||
targetOffset = calculateEyeOffset(companionPosition, observationTarget, companionSize);
|
||||
} else if (currentMode === 'follow-mouse' && mousePosition) {
|
||||
targetOffset = calculateEyeOffset(currentPosition, currentObservation, currentSize);
|
||||
} else if (currentMode === 'follow-mouse' && currentMouse) {
|
||||
// Follow mouse cursor
|
||||
targetOffset = calculateEyeOffset(companionPosition, mousePosition, companionSize);
|
||||
targetOffset = calculateEyeOffset(currentPosition, currentMouse, currentSize);
|
||||
} else if (currentMode === 'forward') {
|
||||
// Look in movement direction - STRONGER offset for clear visual feedback
|
||||
targetOffset = {
|
||||
x: direction === 'right' ? 0.85 : -0.85,
|
||||
x: currentDirection === 'right' ? 0.85 : -0.85,
|
||||
y: 0.15, // Slightly down, looking at path ahead
|
||||
};
|
||||
} else {
|
||||
@@ -329,10 +358,13 @@ export function useBlobbiCompanionGaze({
|
||||
: currentMode === 'forward' ? 0.12
|
||||
: 0.06;
|
||||
|
||||
setEyeOffset(prev => ({
|
||||
x: smoothLerp(prev.x, targetOffset.x, smoothFactor),
|
||||
y: smoothLerp(prev.y, targetOffset.y, smoothFactor),
|
||||
}));
|
||||
// Update the ref imperatively (no React rerender) — companion visual reads from this
|
||||
const prevOffset = eyeOffsetRef.current;
|
||||
const newOffset = {
|
||||
x: smoothLerp(prevOffset.x, targetOffset.x, smoothFactor),
|
||||
y: smoothLerp(prevOffset.y, targetOffset.y, smoothFactor),
|
||||
};
|
||||
eyeOffsetRef.current = newOffset;
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
@@ -345,10 +377,9 @@ export function useBlobbiCompanionGaze({
|
||||
animationRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isActive, direction, companionPosition, mousePosition, companionSize, observationTarget, attentionPosition, entryInspectionDirection]);
|
||||
}, [isActive]); // ONLY depend on isActive - all other values read from refs
|
||||
|
||||
return {
|
||||
gaze,
|
||||
eyeOffset,
|
||||
eyeOffsetRef,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* This includes walking, gravity, and drag behavior.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect, type MutableRefObject } from 'react';
|
||||
|
||||
import type {
|
||||
CompanionState,
|
||||
@@ -37,6 +37,12 @@ interface UseBlobbiCompanionMotionOptions {
|
||||
energy: number;
|
||||
/** Callback when target is reached */
|
||||
onReachedTarget: () => void;
|
||||
/**
|
||||
* Shared ref to sync motion state with state hook.
|
||||
* This allows the state hook to read live motion values without
|
||||
* creating a circular dependency.
|
||||
*/
|
||||
sharedMotionRef?: MutableRefObject<CompanionMotion>;
|
||||
}
|
||||
|
||||
interface UseBlobbiCompanionMotionResult {
|
||||
@@ -63,6 +69,7 @@ export function useBlobbiCompanionMotion({
|
||||
targetX,
|
||||
energy,
|
||||
onReachedTarget,
|
||||
sharedMotionRef,
|
||||
}: UseBlobbiCompanionMotionOptions): UseBlobbiCompanionMotionResult {
|
||||
const [motion, setMotion] = useState<CompanionMotion>(() =>
|
||||
createInitialMotion(initialX, groundY)
|
||||
@@ -72,6 +79,13 @@ export function useBlobbiCompanionMotion({
|
||||
const lastTimeRef = useRef<number>(0);
|
||||
const config = DEFAULT_COMPANION_CONFIG;
|
||||
|
||||
// Sync motion to shared ref so state hook can read it
|
||||
useEffect(() => {
|
||||
if (sharedMotionRef) {
|
||||
sharedMotionRef.current = motion;
|
||||
}
|
||||
}, [motion, sharedMotionRef]);
|
||||
|
||||
// Animation loop
|
||||
useEffect(() => {
|
||||
const animate = (time: number) => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* This is the state layer - it handles state transitions and timing.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, type MutableRefObject } from 'react';
|
||||
|
||||
import type {
|
||||
CompanionState,
|
||||
@@ -21,14 +21,20 @@ import { DEFAULT_COMPANION_CONFIG, randomDuration } from '../core/companionConfi
|
||||
interface UseBlobbiCompanionStateOptions {
|
||||
/** Whether the companion is active and should be making decisions */
|
||||
isActive: boolean;
|
||||
/** Current motion state (used for position/dragging checks) */
|
||||
motion: CompanionMotion;
|
||||
/**
|
||||
* Ref to current motion state (shared with motion hook).
|
||||
* Using a ref allows state to read live motion values without
|
||||
* creating a circular dependency between state and motion hooks.
|
||||
*/
|
||||
motionRef: MutableRefObject<CompanionMotion>;
|
||||
/** Movement bounds */
|
||||
bounds: MovementBounds;
|
||||
/** Whether to force walking on first activation (after entry) */
|
||||
forceInitialWalk?: boolean;
|
||||
/** Current attention target (from UI attention system) */
|
||||
attentionTarget?: AttentionTarget | null;
|
||||
/** Whether the companion is sleeping (freezes all decisions/movement) */
|
||||
isSleeping?: boolean;
|
||||
}
|
||||
|
||||
interface UseBlobbiCompanionStateResult {
|
||||
@@ -51,10 +57,11 @@ interface UseBlobbiCompanionStateResult {
|
||||
*/
|
||||
export function useBlobbiCompanionState({
|
||||
isActive,
|
||||
motion,
|
||||
motionRef,
|
||||
bounds,
|
||||
forceInitialWalk = true,
|
||||
attentionTarget,
|
||||
isSleeping = false,
|
||||
}: UseBlobbiCompanionStateOptions): UseBlobbiCompanionStateResult {
|
||||
const [state, setState] = useState<CompanionState>('idle');
|
||||
const [direction, setDirection] = useState<CompanionDirection>('right');
|
||||
@@ -67,14 +74,11 @@ export function useBlobbiCompanionState({
|
||||
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const hasHadInitialWalk = useRef(false);
|
||||
const motionRef = useRef(motion);
|
||||
const lastObservationTimeRef = useRef<number>(0);
|
||||
const config = DEFAULT_COMPANION_CONFIG;
|
||||
|
||||
// Keep motion ref updated
|
||||
useEffect(() => {
|
||||
motionRef.current = motion;
|
||||
}, [motion]);
|
||||
// motionRef is now passed in from the orchestrator and shared with motion hook
|
||||
// No need for local ref or sync effect - just read directly from motionRef.current
|
||||
|
||||
// Clear timer on cleanup
|
||||
useEffect(() => {
|
||||
@@ -136,7 +140,7 @@ export function useBlobbiCompanionState({
|
||||
|
||||
// Make a decision about what to do next
|
||||
const makeDecision = useCallback(() => {
|
||||
if (!isActive || motionRef.current.isDragging) {
|
||||
if (!isActive || isSleeping || motionRef.current.isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -172,7 +176,7 @@ export function useBlobbiCompanionState({
|
||||
// Schedule next decision
|
||||
const duration = transition.duration ?? randomDuration(config.idleTime);
|
||||
timerRef.current = window.setTimeout(makeDecision, duration);
|
||||
}, [isActive, bounds, state, config, startObservation]);
|
||||
}, [isActive, isSleeping, bounds, state, config, startObservation]);
|
||||
|
||||
// Handle reaching target
|
||||
const onReachedTarget = useCallback(() => {
|
||||
@@ -207,9 +211,22 @@ export function useBlobbiCompanionState({
|
||||
}
|
||||
}, [makeDecision, observationTarget, config.observation.lookDuration]);
|
||||
|
||||
// Start decision loop when active
|
||||
// Force idle when sleeping - stop all movement/decisions immediately
|
||||
useEffect(() => {
|
||||
if (isActive && !motionRef.current.isDragging) {
|
||||
if (isSleeping) {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setState('idle');
|
||||
setTargetX(null);
|
||||
setObservationTarget(null);
|
||||
}
|
||||
}, [isSleeping]);
|
||||
|
||||
// Start decision loop when active (and not sleeping)
|
||||
useEffect(() => {
|
||||
if (isActive && !isSleeping && !motionRef.current.isDragging) {
|
||||
// Clear any existing timer
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
@@ -238,19 +255,33 @@ export function useBlobbiCompanionState({
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [isActive, forceInitialWalk, startInitialWalk, makeDecision]);
|
||||
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision]);
|
||||
|
||||
// Pause decisions while dragging
|
||||
// We poll isDragging via interval since motionRef changes don't trigger re-renders
|
||||
useEffect(() => {
|
||||
if (motion.isDragging) {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
if (!isActive) return;
|
||||
|
||||
let wasDragging = false;
|
||||
|
||||
const checkDragging = () => {
|
||||
const isDragging = motionRef.current.isDragging;
|
||||
if (isDragging && !wasDragging) {
|
||||
// Started dragging
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setState('idle');
|
||||
setTargetX(null);
|
||||
}
|
||||
setState('idle');
|
||||
setTargetX(null);
|
||||
}
|
||||
}, [motion.isDragging]);
|
||||
wasDragging = isDragging;
|
||||
};
|
||||
|
||||
// Check frequently for drag state changes
|
||||
const interval = setInterval(checkDragging, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, [isActive, motionRef]);
|
||||
|
||||
// Handle attention targets - interrupt current behavior when UI elements appear
|
||||
useEffect(() => {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
isValidBlobbiEvent,
|
||||
parseBlobbiEvent,
|
||||
type BlobbiStats,
|
||||
} from '@/lib/blobbi';
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { checkItemCategoryNeed, type NeedCheckResult } from '../interaction/needDetection';
|
||||
import type { ShopItemCategory } from '@/blobbi/shop/types/shop.types';
|
||||
import type { Position } from '../types/companion.types';
|
||||
|
||||
@@ -100,7 +100,6 @@ export function BlobbiActionsProvider({ children }: BlobbiActionsProviderProps)
|
||||
const registerRef = useRef<UseItemFunction | null>(null);
|
||||
const canUseItemsRegisteredRef = useRef<boolean>(false);
|
||||
const isUsingItemRegisteredRef = useRef<boolean>(false);
|
||||
|
||||
// Subscribers for manual notification
|
||||
const subscribersRef = useRef<Set<() => void>>(new Set());
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ interface CompanionActionMenuProps {
|
||||
onActionClick: (action: CompanionMenuAction) => void;
|
||||
/** Callback for clicking outside the menu */
|
||||
onClickOutside?: () => void;
|
||||
/** Whether Blobbi is currently sleeping (affects sleep button label) */
|
||||
isSleeping?: boolean;
|
||||
}
|
||||
|
||||
// Layout configuration
|
||||
@@ -90,6 +92,7 @@ export function CompanionActionMenu({
|
||||
selectedAction,
|
||||
onActionClick,
|
||||
onClickOutside,
|
||||
isSleeping = false,
|
||||
}: CompanionActionMenuProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -122,6 +125,11 @@ export function CompanionActionMenu({
|
||||
const isSelected = selectedAction === action.id;
|
||||
const delay = index * MENU_CONFIG.staggerDelay;
|
||||
|
||||
// Sleep action toggles label/emoji based on sleeping state
|
||||
const isSleepAction = action.id === 'sleep';
|
||||
const displayEmoji = isSleepAction && isSleeping ? '\u2600\uFE0F' : action.emoji;
|
||||
const displayLabel = isSleepAction && isSleeping ? 'Wake up' : action.label;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
@@ -155,15 +163,15 @@ export function CompanionActionMenu({
|
||||
e.stopPropagation();
|
||||
onActionClick(action.id);
|
||||
}}
|
||||
title={action.label}
|
||||
aria-label={action.label}
|
||||
title={displayLabel}
|
||||
aria-label={displayLabel}
|
||||
>
|
||||
<span
|
||||
className="text-xl select-none"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{action.emoji}
|
||||
{displayEmoji}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* - Returns both boolean need and priority level for potential future use
|
||||
*/
|
||||
|
||||
import type { BlobbiStats } from '@/lib/blobbi';
|
||||
import type { BlobbiStats } from '@/blobbi/core/lib/blobbi';
|
||||
import type { ShopItemCategory } from '@/blobbi/shop/types/shop.types';
|
||||
|
||||
// ─── Need Thresholds ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
@@ -36,8 +36,8 @@ import {
|
||||
createStorageTags,
|
||||
parseBlobbiEvent,
|
||||
isValidBlobbiEvent,
|
||||
} from '@/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import {
|
||||
applyItemEffects,
|
||||
@@ -188,14 +188,45 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
return parseBlobbiEvent(validEvents[0]) ?? null;
|
||||
}, [nostr, user?.pubkey, profile?.currentCompanion, options.companion]);
|
||||
|
||||
// Update companion in query cache
|
||||
const updateCompanionInCache = useCallback((_event: NostrEvent) => {
|
||||
// Update companion in query cache - optimistic update for immediate UI refresh
|
||||
const updateCompanionInCache = useCallback((event: NostrEvent) => {
|
||||
if (!user?.pubkey || !profile?.currentCompanion) return;
|
||||
|
||||
// Invalidate and update the companion query
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['companion-blobbi', user.pubkey, profile.currentCompanion]
|
||||
});
|
||||
// Parse the new event to get the updated companion
|
||||
const parsed = parseBlobbiEvent(event);
|
||||
if (!parsed) {
|
||||
// Fallback to invalidation if parsing fails
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['blobbi-collection', user.pubkey]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistically update the blobbi-collection cache
|
||||
// This ensures the companion layer sees the update immediately
|
||||
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] } | undefined>(
|
||||
// Use partial key match - React Query will find any matching query
|
||||
['blobbi-collection', user.pubkey],
|
||||
(prev) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
// Update the specific companion in the record
|
||||
const newCompanionsByD = {
|
||||
...prev.companionsByD,
|
||||
[parsed.d]: parsed,
|
||||
};
|
||||
|
||||
// Rebuild companions array from the record
|
||||
const newCompanions = Object.values(newCompanionsByD);
|
||||
|
||||
return {
|
||||
companionsByD: newCompanionsByD,
|
||||
companions: newCompanions,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Also invalidate to trigger background refetch (ensures consistency)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['blobbi-collection', user.pubkey]
|
||||
});
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* useBlobbiSleepToggle — Standalone sleep/wake toggle for the companion.
|
||||
*
|
||||
* This hook mirrors the essential logic of BlobbiPage's `handleRest` but
|
||||
* works independently — it fetches fresh event data from relays, publishes
|
||||
* the state change, and updates the TanStack Query cache directly.
|
||||
*
|
||||
* This eliminates the dependency on BlobbiPage being mounted. The companion
|
||||
* sleep button works on any page.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
updateBlobbiTags,
|
||||
parseBlobbiEvent,
|
||||
isValidBlobbiEvent,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
|
||||
import { trackDailyMissionProgress } from '@/blobbi/actions/lib/daily-mission-tracker';
|
||||
|
||||
export interface UseBlobbiSleepToggleResult {
|
||||
/** Toggle sleep/wake state. Resolves when published. */
|
||||
toggleSleep: () => Promise<void>;
|
||||
/** Whether a toggle is currently in progress. */
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
const { profile } = useBlobbonautProfile();
|
||||
|
||||
// Track pending state via ref to avoid re-renders.
|
||||
// We only use this for the guard (no duplicate calls), not for rendering.
|
||||
const pendingRef = useRef(false);
|
||||
|
||||
/** Fetch the latest companion event directly from relays. */
|
||||
const fetchFreshCompanion = useCallback(async (
|
||||
pubkey: string,
|
||||
dTag: string,
|
||||
): Promise<BlobbiCompanion | null> => {
|
||||
const events = await nostr.query([{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag],
|
||||
}]);
|
||||
|
||||
const validEvents = events
|
||||
.filter(isValidBlobbiEvent)
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
if (validEvents.length === 0) return null;
|
||||
return parseBlobbiEvent(validEvents[0]) ?? null;
|
||||
}, [nostr]);
|
||||
|
||||
/** Optimistically update the TanStack cache so the companion reacts immediately. */
|
||||
const updateCache = useCallback((event: import('@nostrify/nostrify').NostrEvent, pubkey: string) => {
|
||||
const parsed = parseBlobbiEvent(event);
|
||||
if (!parsed) {
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistically update ALL blobbi-collection queries for this user.
|
||||
// The cache key is ['blobbi-collection', pubkey, dListArray], so we use
|
||||
// partial matching to find all entries regardless of dList shape.
|
||||
type CollectionData = { companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] };
|
||||
const matchingQueries = queryClient.getQueriesData<CollectionData>({
|
||||
queryKey: ['blobbi-collection', pubkey],
|
||||
});
|
||||
|
||||
for (const [queryKey, data] of matchingQueries) {
|
||||
if (!data) continue;
|
||||
const newCompanionsByD = { ...data.companionsByD, [parsed.d]: parsed };
|
||||
queryClient.setQueryData<CollectionData>(queryKey, {
|
||||
companionsByD: newCompanionsByD,
|
||||
companions: Object.values(newCompanionsByD),
|
||||
});
|
||||
}
|
||||
|
||||
// Also invalidate for background refetch to ensure eventual consistency
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
|
||||
}, [queryClient]);
|
||||
|
||||
const toggleSleep = useCallback(async () => {
|
||||
if (pendingRef.current) return;
|
||||
if (!user?.pubkey || !profile?.currentCompanion) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[SleepToggle] No user or no current companion');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRef.current = true;
|
||||
|
||||
try {
|
||||
// Fetch the freshest event from relays (read-modify-write)
|
||||
const companion = await fetchFreshCompanion(user.pubkey, profile.currentCompanion);
|
||||
if (!companion) {
|
||||
toast({
|
||||
title: 'Cannot change state',
|
||||
description: 'Companion not found. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isCurrentlySleeping = companion.state === 'sleeping';
|
||||
const newState = isCurrentlySleeping ? 'active' : 'sleeping';
|
||||
|
||||
// Apply accumulated decay before the state change
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: companion.stage,
|
||||
state: companion.state,
|
||||
stats: companion.stats,
|
||||
lastDecayAt: companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
const nowStr = now.toString();
|
||||
|
||||
// Streak updates (putting to sleep/waking counts as care activity)
|
||||
const streakUpdates = getStreakTagUpdates(companion) ?? {};
|
||||
|
||||
const newTags = updateBlobbiTags(companion.allTags, {
|
||||
state: newState,
|
||||
hunger: decayResult.stats.hunger.toString(),
|
||||
happiness: decayResult.stats.happiness.toString(),
|
||||
health: decayResult.stats.health.toString(),
|
||||
hygiene: decayResult.stats.hygiene.toString(),
|
||||
energy: decayResult.stats.energy.toString(),
|
||||
...streakUpdates,
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: companion.event.content,
|
||||
tags: newTags,
|
||||
});
|
||||
|
||||
// Optimistic cache update + background invalidation
|
||||
updateCache(event, user.pubkey);
|
||||
|
||||
toast({
|
||||
title: isCurrentlySleeping ? 'Woke up!' : 'Resting...',
|
||||
description: isCurrentlySleeping
|
||||
? 'Your Blobbi is now awake and active!'
|
||||
: 'Your Blobbi is taking a rest.',
|
||||
});
|
||||
|
||||
// Track daily mission progress (only when putting to sleep)
|
||||
if (!isCurrentlySleeping) {
|
||||
trackDailyMissionProgress('sleep', 1, user.pubkey);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SleepToggle] Failed:', error);
|
||||
toast({
|
||||
title: 'Failed to update',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
pendingRef.current = false;
|
||||
}
|
||||
}, [user?.pubkey, profile?.currentCompanion, fetchFreshCompanion, publishEvent, updateCache]);
|
||||
|
||||
return {
|
||||
toggleSleep,
|
||||
isPending: false, // ref-based, so always false for render — prevents unnecessary re-renders
|
||||
};
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import type { StorageItem } from '@/lib/blobbi';
|
||||
import type { StorageItem } from '@/blobbi/core/lib/blobbi';
|
||||
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
|
||||
import type {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* decoupled from app-specific concerns.
|
||||
*/
|
||||
|
||||
import type { BlobbiVisualTraits } from '@/lib/blobbi';
|
||||
import type { BlobbiVisualTraits, BlobbiStats } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbiState } from '@/blobbi/core/types/blobbi';
|
||||
|
||||
// ─── Companion State Machine ──────────────────────────────────────────────────
|
||||
|
||||
@@ -176,6 +177,10 @@ export interface CompanionData {
|
||||
visualTraits: BlobbiVisualTraits;
|
||||
/** Current energy level (0-100) - affects walking speed */
|
||||
energy: number;
|
||||
/** Current stats for status-based visual reactions */
|
||||
stats: BlobbiStats;
|
||||
/** Current companion state (e.g., 'sleeping') */
|
||||
state?: BlobbiState;
|
||||
/** Adult evolution form type (e.g., 'catti', 'pupp', 'buni') - only for adults */
|
||||
adultType?: string;
|
||||
/** Deterministic seed for deriving traits */
|
||||
@@ -318,8 +323,6 @@ export interface CompanionContextValue {
|
||||
state: CompanionState;
|
||||
/** Current motion state */
|
||||
motion: CompanionMotion;
|
||||
/** Current gaze state */
|
||||
gaze: GazeState;
|
||||
/** Start dragging the companion */
|
||||
startDrag: () => void;
|
||||
/** Update drag position */
|
||||
|
||||
@@ -44,13 +44,12 @@ export function calculateFloatAnimation(time: number, isMoving: boolean): FloatO
|
||||
// Multiple frequencies create a bouncy, charming walk
|
||||
const t = time / 1000; // Convert to seconds for easier frequency tuning
|
||||
|
||||
// Primary bob - quick rhythmic bounce (about 2 bounces per second)
|
||||
const primaryBob = Math.sin(t * 12) * 3;
|
||||
// Secondary bob - slower wave that adds variation
|
||||
const secondaryBob = Math.sin(t * 5 + 0.5) * 1.5;
|
||||
// Slight lift during walk - don't stay on ground
|
||||
const baseLift = -2;
|
||||
const yOffset = baseLift + primaryBob * 0.5 + secondaryBob * 0.3;
|
||||
// Vertical bob oscillates symmetrically around zero so Blobbi's base
|
||||
// stays anchored to the ground line. The original baseLift = -2 was
|
||||
// removed because it biased the offset permanently upward.
|
||||
const primaryBob = Math.sin(t * 12) * 2; // Reduced from *3: less vertical energy
|
||||
const secondaryBob = Math.sin(t * 5 + 0.5) * 1;
|
||||
const yOffset = primaryBob * 0.4 + secondaryBob * 0.25;
|
||||
|
||||
// Horizontal sway - playful side-to-side motion
|
||||
const primarySway = Math.sin(t * 6) * 2;
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
buildMigrationTags,
|
||||
generatePetId10,
|
||||
getCanonicalBlobbiD,
|
||||
migratePetInHas,
|
||||
updateBlobbonautTags,
|
||||
parseBlobbiEvent,
|
||||
parseStorageTags,
|
||||
type BlobbiCompanion,
|
||||
type BlobbonautProfile,
|
||||
type StorageItem,
|
||||
} from '../lib/blobbi';
|
||||
|
||||
/**
|
||||
* Result of a successful migration.
|
||||
*/
|
||||
export interface MigrationResult {
|
||||
/** The new canonical d-tag */
|
||||
canonicalD: string;
|
||||
/** The published canonical Blobbi event */
|
||||
event: NostrEvent;
|
||||
/** The parsed canonical BlobbiCompanion */
|
||||
companion: BlobbiCompanion;
|
||||
/** The updated profile event */
|
||||
profileEvent: NostrEvent;
|
||||
/** The updated profile tags (canonical has, current_companion, etc.) */
|
||||
profileTags: string[][];
|
||||
/** The profile storage (unchanged during migration, but fresh from migrated profile) */
|
||||
profileStorage: StorageItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the migration helper.
|
||||
*/
|
||||
export interface EnsureCanonicalOptions {
|
||||
/** The companion to check/migrate */
|
||||
companion: BlobbiCompanion;
|
||||
/** The user's profile */
|
||||
profile: BlobbonautProfile;
|
||||
/** Callback to update the profile event in query cache */
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
/** Callback to update the companion event in query cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Callback to update localStorage selection if it was pointing to legacy d */
|
||||
updateStoredSelectedD?: (newD: string) => void;
|
||||
/** Callback to invalidate companion query */
|
||||
invalidateCompanion?: () => void;
|
||||
/** Callback to invalidate profile query */
|
||||
invalidateProfile?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of ensureCanonicalBlobbiBeforeAction.
|
||||
*/
|
||||
export interface EnsureCanonicalResult {
|
||||
/** Whether the companion was migrated */
|
||||
wasMigrated: boolean;
|
||||
/** The canonical companion (either the original or the migrated one) */
|
||||
companion: BlobbiCompanion;
|
||||
/** The canonical event tags to use for the action */
|
||||
allTags: string[][];
|
||||
/** The event content to use */
|
||||
content: string;
|
||||
/**
|
||||
* The latest profile tags to use for profile updates.
|
||||
* IMPORTANT: Always use these instead of profile.allTags from hook closure
|
||||
* to avoid restoring stale/legacy values after migration.
|
||||
*/
|
||||
profileAllTags: string[][];
|
||||
/**
|
||||
* The latest profile storage to use.
|
||||
* Use this as the base for storage modifications.
|
||||
*/
|
||||
profileStorage: StorageItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook providing centralized migration logic for Blobbi companions.
|
||||
*
|
||||
* This hook should be used by all action handlers to ensure legacy Blobbis
|
||||
* are automatically migrated before any interaction.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const { ensureCanonicalBlobbiBeforeAction } = useBlobbiMigration();
|
||||
*
|
||||
* const handleFeed = async () => {
|
||||
* const result = await ensureCanonicalBlobbiBeforeAction({
|
||||
* companion,
|
||||
* profile,
|
||||
* updateProfileEvent,
|
||||
* updateCompanionEvent,
|
||||
* updateStoredSelectedD: setStoredSelectedD,
|
||||
* });
|
||||
*
|
||||
* if (!result) return; // Migration failed
|
||||
*
|
||||
* // Continue with the action using result.companion and result.allTags
|
||||
* const newTags = updateBlobbiTags(result.allTags, { ... });
|
||||
* // ... publish event
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export function useBlobbiMigration() {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
/**
|
||||
* Migrate a legacy Blobbi to canonical format.
|
||||
*
|
||||
* This function:
|
||||
* 1. Generates a canonical d-tag
|
||||
* 2. Ensures a seed exists (generates one if missing)
|
||||
* 3. Preserves name, stage, stats, state, timestamps
|
||||
* 4. Publishes a canonical 31124 event
|
||||
* 5. Updates the Blobbonaut profile (kind 11125)
|
||||
* 6. Updates local state (query cache, localStorage)
|
||||
*/
|
||||
const migrateLegacyBlobbi = useCallback(async (
|
||||
options: EnsureCanonicalOptions
|
||||
): Promise<MigrationResult | null> => {
|
||||
const {
|
||||
companion,
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
updateStoredSelectedD,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
} = options;
|
||||
|
||||
if (!user?.pubkey) {
|
||||
console.error('[Blobbi Migration] No user pubkey');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[Blobbi Migration] Starting migration for:', companion.d);
|
||||
|
||||
try {
|
||||
// Generate new canonical d-tag
|
||||
const newPetId = generatePetId10();
|
||||
const canonicalD = getCanonicalBlobbiD(user.pubkey, newPetId);
|
||||
|
||||
// Build migration tags (preserves name, stage, stats, generates seed if missing)
|
||||
const migrationTags = buildMigrationTags(companion.event, newPetId, user.pubkey);
|
||||
|
||||
console.log('[Blobbi Migration] Publishing canonical event with d:', canonicalD);
|
||||
|
||||
// Publish the canonical Blobbi state
|
||||
const canonicalEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: companion.event.content || `${companion.name} is a ${companion.stage} Blobbi.`,
|
||||
tags: migrationTags,
|
||||
});
|
||||
|
||||
// Parse the new event to get the canonical companion
|
||||
const canonicalCompanion = parseBlobbiEvent(canonicalEvent);
|
||||
if (!canonicalCompanion) {
|
||||
throw new Error('Failed to parse migrated event');
|
||||
}
|
||||
|
||||
// Update profile: replace legacy d with canonical d in has[], update current_companion
|
||||
const updatedHas = migratePetInHas(profile.has, companion.d, canonicalD);
|
||||
const shouldUpdateCurrentCompanion = profile.currentCompanion === companion.d;
|
||||
|
||||
const profileUpdates: Record<string, string | string[]> = {
|
||||
has: updatedHas,
|
||||
};
|
||||
|
||||
if (shouldUpdateCurrentCompanion) {
|
||||
profileUpdates.current_companion = canonicalD;
|
||||
}
|
||||
|
||||
const profileTags = updateBlobbonautTags(profile.allTags, profileUpdates);
|
||||
|
||||
console.log('[Blobbi Migration] Publishing updated profile');
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
// Update query caches
|
||||
updateProfileEvent(profileEvent);
|
||||
updateCompanionEvent(canonicalEvent);
|
||||
|
||||
// Update localStorage selection if it was pointing to legacy d
|
||||
if (updateStoredSelectedD) {
|
||||
console.log('[Blobbi Migration] Updating localStorage selection:', canonicalD);
|
||||
updateStoredSelectedD(canonicalD);
|
||||
}
|
||||
|
||||
// Invalidate queries to refetch fresh data
|
||||
invalidateCompanion?.();
|
||||
invalidateProfile?.();
|
||||
|
||||
toast({
|
||||
title: 'Pet upgraded!',
|
||||
description: `${companion.name} has been migrated to the new format.`,
|
||||
});
|
||||
|
||||
console.log('[Blobbi Migration] Migration complete:', {
|
||||
legacyD: companion.d,
|
||||
canonicalD,
|
||||
});
|
||||
|
||||
// Parse storage from the migrated profile tags
|
||||
// Storage itself doesn't change during migration, but we need fresh tags
|
||||
const migratedStorage = parseStorageTags(profileTags);
|
||||
|
||||
return {
|
||||
canonicalD,
|
||||
event: canonicalEvent,
|
||||
companion: canonicalCompanion,
|
||||
profileEvent,
|
||||
profileTags,
|
||||
profileStorage: migratedStorage,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Blobbi Migration] Migration failed:', error);
|
||||
toast({
|
||||
title: 'Migration failed',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}, [user?.pubkey, publishEvent]);
|
||||
|
||||
/**
|
||||
* Ensure a Blobbi is in canonical format before performing an action.
|
||||
*
|
||||
* If the companion is legacy, it will be migrated first.
|
||||
* Returns the canonical companion to use for the action.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Check if Blobbi is legacy
|
||||
* 2. If legacy: migrate Blobbi
|
||||
* 3. Return the resolved canonical Blobbi
|
||||
*
|
||||
* All interaction handlers should call this before publishing events.
|
||||
*/
|
||||
const ensureCanonicalBlobbiBeforeAction = useCallback(async (
|
||||
options: EnsureCanonicalOptions
|
||||
): Promise<EnsureCanonicalResult | null> => {
|
||||
const { companion, profile } = options;
|
||||
|
||||
// Check if the companion needs migration
|
||||
if (companion.isLegacy) {
|
||||
console.log('[Blobbi Migration] Legacy companion detected, migrating before action');
|
||||
|
||||
const migrationResult = await migrateLegacyBlobbi(options);
|
||||
|
||||
if (!migrationResult) {
|
||||
// Migration failed, cannot proceed with action
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return the canonical companion AND migrated profile context
|
||||
// CRITICAL: Consumers must use profileAllTags instead of profile.allTags
|
||||
// to avoid restoring stale/legacy values
|
||||
return {
|
||||
wasMigrated: true,
|
||||
companion: migrationResult.companion,
|
||||
allTags: migrationResult.event.tags,
|
||||
content: migrationResult.event.content,
|
||||
profileAllTags: migrationResult.profileTags,
|
||||
profileStorage: migrationResult.profileStorage,
|
||||
};
|
||||
}
|
||||
|
||||
// Companion is already canonical, return profile as-is
|
||||
return {
|
||||
wasMigrated: false,
|
||||
companion,
|
||||
allTags: companion.allTags,
|
||||
content: companion.event.content,
|
||||
profileAllTags: profile.allTags,
|
||||
profileStorage: profile.storage,
|
||||
};
|
||||
}, [migrateLegacyBlobbi]);
|
||||
|
||||
return {
|
||||
/** Migrate a legacy Blobbi to canonical format */
|
||||
migrateLegacyBlobbi,
|
||||
/** Ensure a Blobbi is canonical before an action, migrating if necessary */
|
||||
ensureCanonicalBlobbiBeforeAction,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
isValidBlobbiEvent,
|
||||
parseBlobbiEvent,
|
||||
type BlobbiCompanion,
|
||||
} from '../lib/blobbi';
|
||||
|
||||
/** Maximum number of d-tags per query chunk to avoid relay issues */
|
||||
const CHUNK_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Split an array into chunks of a given size.
|
||||
*/
|
||||
function chunkArray<T>(array: T[], size: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
chunks.push(array.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch ALL Blobbi companions (Kind 31124) owned by the logged-in user.
|
||||
*
|
||||
* Features:
|
||||
* - Fetches ALL pets by d-tag list (no limit: 1)
|
||||
* - Chunks large d-lists into multiple queries for relay compatibility
|
||||
* - Keeps only the newest event per d-tag
|
||||
* - Returns both a lookup record and array of companions
|
||||
* - Provides invalidation and optimistic update helpers
|
||||
*/
|
||||
export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Create a stable query key based on sorted d-tags
|
||||
const sortedDList = useMemo(() => {
|
||||
if (!dList || dList.length === 0) return null;
|
||||
return [...dList].sort();
|
||||
}, [dList]);
|
||||
|
||||
const queryKeyDTags = sortedDList?.join(',') ?? '';
|
||||
|
||||
// Main query to fetch all companions from relays
|
||||
const query = useQuery({
|
||||
queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!user?.pubkey || !sortedDList || sortedDList.length === 0) {
|
||||
console.log('[useBlobbisCollection] No pubkey or empty dList, returning empty');
|
||||
return { companionsByD: {}, companions: [] };
|
||||
}
|
||||
|
||||
// Log the dList we're about to query
|
||||
console.log('[Blobbi] dList:', sortedDList);
|
||||
|
||||
// Chunk the d-list for relay compatibility
|
||||
const chunks = chunkArray(sortedDList, CHUNK_SIZE);
|
||||
console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)');
|
||||
|
||||
// Query all chunks in parallel
|
||||
const allEvents: NostrEvent[] = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const filter = {
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': chunk,
|
||||
// IMPORTANT: No limit - fetch ALL pets matching the d-tags
|
||||
};
|
||||
|
||||
// Log the filter immediately before query
|
||||
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
|
||||
|
||||
const events = await nostr.query([filter], { signal });
|
||||
allEvents.push(...events);
|
||||
|
||||
console.log('[useBlobbisCollection] Chunk returned', events.length, 'events');
|
||||
}
|
||||
|
||||
console.log('[useBlobbisCollection] Total events received:', allEvents.length);
|
||||
|
||||
// Filter to valid events
|
||||
const validEvents = allEvents.filter(isValidBlobbiEvent);
|
||||
|
||||
console.log('[useBlobbisCollection] Valid events:', validEvents.length);
|
||||
|
||||
// Group events by d-tag and keep only the newest per d
|
||||
const eventsByD = new Map<string, NostrEvent>();
|
||||
|
||||
for (const event of validEvents) {
|
||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
if (!dTag) continue;
|
||||
|
||||
const existing = eventsByD.get(dTag);
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
eventsByD.set(dTag, event);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse all events into BlobbiCompanion objects
|
||||
const companionsByD: Record<string, BlobbiCompanion> = {};
|
||||
const companions: BlobbiCompanion[] = [];
|
||||
|
||||
for (const [dTag, event] of eventsByD) {
|
||||
const parsed = parseBlobbiEvent(event);
|
||||
if (parsed) {
|
||||
companionsByD[dTag] = parsed;
|
||||
companions.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[useBlobbisCollection] Parsed companions:', {
|
||||
count: companions.length,
|
||||
dTags: Object.keys(companionsByD),
|
||||
});
|
||||
|
||||
return { companionsByD, companions };
|
||||
},
|
||||
enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0,
|
||||
staleTime: 30_000, // 30 seconds
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
});
|
||||
|
||||
// Helper to invalidate and refetch after publishing
|
||||
const invalidate = useCallback(() => {
|
||||
if (user?.pubkey && queryKeyDTags) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['blobbi-collection', user.pubkey, queryKeyDTags],
|
||||
});
|
||||
}
|
||||
}, [queryClient, user?.pubkey, queryKeyDTags]);
|
||||
|
||||
// Update a single companion event in the query cache (optimistic update)
|
||||
const updateCompanionEvent = useCallback((event: NostrEvent) => {
|
||||
const parsed = parseBlobbiEvent(event);
|
||||
if (!parsed || !user?.pubkey) return;
|
||||
|
||||
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] }>(
|
||||
['blobbi-collection', user.pubkey, queryKeyDTags],
|
||||
(prev) => {
|
||||
if (!prev) {
|
||||
return {
|
||||
companionsByD: { [parsed.d]: parsed },
|
||||
companions: [parsed],
|
||||
};
|
||||
}
|
||||
|
||||
// Update the specific companion in the record
|
||||
const newCompanionsByD = {
|
||||
...prev.companionsByD,
|
||||
[parsed.d]: parsed,
|
||||
};
|
||||
|
||||
// Rebuild companions array from the record
|
||||
const newCompanions = Object.values(newCompanionsByD);
|
||||
|
||||
return {
|
||||
companionsByD: newCompanionsByD,
|
||||
companions: newCompanions,
|
||||
};
|
||||
}
|
||||
);
|
||||
}, [queryClient, user?.pubkey, queryKeyDTags]);
|
||||
|
||||
// Memoize return values for stability
|
||||
const companionsByD = query.data?.companionsByD ?? {};
|
||||
const companions = query.data?.companions ?? [];
|
||||
|
||||
return {
|
||||
/** Record of companions keyed by d-tag */
|
||||
companionsByD,
|
||||
/** Array of all companions (newest per d-tag) */
|
||||
companions,
|
||||
/** True only when query is loading and no data available */
|
||||
isLoading: query.isLoading,
|
||||
/** True when actively fetching */
|
||||
isFetching: query.isFetching,
|
||||
/** True when data is stale */
|
||||
isStale: query.isStale,
|
||||
/** Query error if any */
|
||||
error: query.error,
|
||||
/** Invalidate and refetch the collection */
|
||||
invalidate,
|
||||
/** Optimistically update a single companion in the cache */
|
||||
updateCompanionEvent,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Hook for projecting Blobbi decay state in the UI.
|
||||
*
|
||||
* This hook provides a local projection of decay without publishing events.
|
||||
* It recalculates every 60 seconds while the component is mounted.
|
||||
*
|
||||
* The projected state is for UI display only. Actual mutations must
|
||||
* recalculate from the persisted state before publishing.
|
||||
*
|
||||
* @see docs/blobbi/decay-system.md
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
|
||||
import type { BlobbiCompanion, BlobbiStats } from '../lib/blobbi';
|
||||
import { applyBlobbiDecay, getVisibleStatsWithValues, type DecayResult } from '@/blobbi/core/lib/blobbi-decay';
|
||||
|
||||
/** UI refresh interval in milliseconds (60 seconds) */
|
||||
const UI_REFRESH_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Projected Blobbi state for UI display.
|
||||
*/
|
||||
export interface ProjectedBlobbiState {
|
||||
/** Stats after applying projected decay */
|
||||
stats: BlobbiStats;
|
||||
/** Visible stats for the current stage with status indicators */
|
||||
visibleStats: Array<{
|
||||
stat: keyof BlobbiStats;
|
||||
value: number;
|
||||
status: 'critical' | 'warning' | 'normal';
|
||||
}>;
|
||||
/** Time elapsed since last decay (seconds) */
|
||||
elapsedSeconds: number;
|
||||
/** Timestamp of the projection calculation */
|
||||
projectedAt: number;
|
||||
/** Whether this is a fresh projection (recalculated this render) */
|
||||
isFresh: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get a projected Blobbi state with decay applied.
|
||||
*
|
||||
* Features:
|
||||
* - Immediately calculates projected state on mount/companion change
|
||||
* - Recalculates every 60 seconds while mounted
|
||||
* - Pure calculation - does not publish any events
|
||||
* - Returns both full stats and stage-appropriate visible stats
|
||||
*
|
||||
* @param companion - The persisted Blobbi companion (source of truth)
|
||||
* @returns Projected state with decay applied, or null if no companion
|
||||
*/
|
||||
export function useProjectedBlobbiState(
|
||||
companion: BlobbiCompanion | null
|
||||
): ProjectedBlobbiState | null {
|
||||
// Track when we last recalculated
|
||||
const [refreshTick, setRefreshTick] = useState(0);
|
||||
|
||||
// Set up 60-second refresh interval
|
||||
useEffect(() => {
|
||||
if (!companion) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setRefreshTick(t => t + 1);
|
||||
}, UI_REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [companion]);
|
||||
|
||||
// Calculate projected state
|
||||
const projectedState = useMemo((): ProjectedBlobbiState | null => {
|
||||
if (!companion) return null;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Apply decay from persisted state
|
||||
const decayResult: DecayResult = applyBlobbiDecay({
|
||||
stage: companion.stage,
|
||||
state: companion.state,
|
||||
stats: companion.stats,
|
||||
lastDecayAt: companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// Get visible stats for the stage
|
||||
const visibleStats = getVisibleStatsWithValues(companion.stage, decayResult.stats);
|
||||
|
||||
return {
|
||||
stats: decayResult.stats,
|
||||
visibleStats,
|
||||
elapsedSeconds: decayResult.elapsedSeconds,
|
||||
projectedAt: now,
|
||||
isFresh: true,
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshTick triggers recalculation
|
||||
}, [companion, refreshTick]);
|
||||
|
||||
return projectedState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate projected decay for a companion at a specific timestamp.
|
||||
*
|
||||
* This is a utility function for use outside of React components,
|
||||
* such as in mutation handlers before publishing.
|
||||
*
|
||||
* @param companion - The persisted Blobbi companion
|
||||
* @param now - Unix timestamp to calculate decay to (defaults to current time)
|
||||
* @returns Decay result with updated stats
|
||||
*/
|
||||
export function calculateProjectedDecay(
|
||||
companion: BlobbiCompanion,
|
||||
now?: number
|
||||
): DecayResult {
|
||||
return applyBlobbiDecay({
|
||||
stage: companion.stage,
|
||||
state: companion.state,
|
||||
stats: companion.stats,
|
||||
lastDecayAt: companion.lastDecayAt,
|
||||
now: now ?? Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* Blobbi Decay System
|
||||
*
|
||||
* This module implements the continuous proportional decay system for Blobbi stats.
|
||||
*
|
||||
* Key principles:
|
||||
* - Pure, deterministic calculation based on elapsed time
|
||||
* - Floored stat changes before application
|
||||
* - Stats clamped to 0-100 range
|
||||
* - Stage-specific decay rates and health modifiers
|
||||
* - Persisted state is the source of truth
|
||||
*
|
||||
* @see docs/blobbi/decay-system.md for full documentation
|
||||
*/
|
||||
|
||||
import type { BlobbiStage, BlobbiState, BlobbiStats } from './blobbi';
|
||||
import { STAT_MIN, STAT_MAX } from './blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of applying decay to a Blobbi.
|
||||
* Contains updated stats and metadata about the calculation.
|
||||
*/
|
||||
export interface DecayResult {
|
||||
/** Updated stats after decay (clamped to 0-100) */
|
||||
stats: BlobbiStats;
|
||||
/** Elapsed time in seconds that was used for decay calculation */
|
||||
elapsedSeconds: number;
|
||||
/** The timestamp that should be set as the new last_decay_at */
|
||||
newDecayTimestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input parameters for decay calculation.
|
||||
* Uses the persisted Blobbi state as source of truth.
|
||||
*/
|
||||
export interface DecayInput {
|
||||
/** Current life stage */
|
||||
stage: BlobbiStage;
|
||||
/** Current activity state (awake/sleeping) */
|
||||
state: BlobbiState;
|
||||
/** Current stats from persisted state */
|
||||
stats: Partial<BlobbiStats>;
|
||||
/** Unix timestamp of last decay application */
|
||||
lastDecayAt: number | undefined;
|
||||
/** Current unix timestamp (defaults to now) */
|
||||
now?: number;
|
||||
}
|
||||
|
||||
// ─── Constants: Decay Rates ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Baby stage decay rates (per hour).
|
||||
*
|
||||
* Design goal: Needs attention every 3-5 hours.
|
||||
*/
|
||||
const BABY_DECAY = {
|
||||
hunger: -7.0,
|
||||
happiness: -4.0,
|
||||
hygiene: -5.0,
|
||||
energy: {
|
||||
awake: -8.0,
|
||||
sleeping: 6.0, // Regeneration
|
||||
},
|
||||
health: {
|
||||
base: -0.75,
|
||||
hungerBelow70: -0.75,
|
||||
hungerBelow40: -1.25,
|
||||
hygieneBelow70: -0.75,
|
||||
hygieneBelow40: -1.25,
|
||||
energyBelow50: -0.5,
|
||||
energyBelow25: -1.0,
|
||||
happinessBelow50: -0.5,
|
||||
happinessBelow25: -1.0,
|
||||
// Regeneration when all stats are >= 80
|
||||
regenThreshold: 80,
|
||||
regenRate: 1.5,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Adult stage decay rates (per hour).
|
||||
*
|
||||
* Design goal: Needs attention every 5-7 hours.
|
||||
*/
|
||||
const ADULT_DECAY = {
|
||||
hunger: -4.5,
|
||||
happiness: -2.5,
|
||||
hygiene: -3.5,
|
||||
energy: {
|
||||
awake: -5.0,
|
||||
sleeping: 5.0, // Regeneration
|
||||
},
|
||||
health: {
|
||||
base: -0.4,
|
||||
hungerBelow60: -0.5,
|
||||
hungerBelow30: -1.0,
|
||||
hygieneBelow60: -0.5,
|
||||
hygieneBelow30: -1.0,
|
||||
energyBelow40: -0.4,
|
||||
energyBelow20: -0.8,
|
||||
happinessBelow40: -0.4,
|
||||
happinessBelow20: -0.8,
|
||||
// Regeneration when all stats are >= 80
|
||||
regenThreshold: 80,
|
||||
regenRate: 1.0,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ─── Constants: Warning Thresholds ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Warning thresholds by stage.
|
||||
* Warning = stat below this value indicates the Blobbi needs attention.
|
||||
*/
|
||||
export const WARNING_THRESHOLDS = {
|
||||
egg: {
|
||||
hygiene: 75,
|
||||
health: 75,
|
||||
happiness: 75,
|
||||
},
|
||||
baby: {
|
||||
hunger: 65,
|
||||
happiness: 65,
|
||||
hygiene: 65,
|
||||
energy: 65,
|
||||
health: 65,
|
||||
},
|
||||
adult: {
|
||||
hunger: 60,
|
||||
happiness: 60,
|
||||
hygiene: 60,
|
||||
energy: 60,
|
||||
health: 60,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Critical thresholds by stage.
|
||||
* Critical = stat below this value indicates urgent attention needed.
|
||||
*/
|
||||
export const CRITICAL_THRESHOLDS = {
|
||||
egg: {
|
||||
hygiene: 45,
|
||||
health: 45,
|
||||
happiness: 45,
|
||||
},
|
||||
baby: {
|
||||
hunger: 35,
|
||||
happiness: 35,
|
||||
hygiene: 35,
|
||||
energy: 25,
|
||||
health: 35,
|
||||
},
|
||||
adult: {
|
||||
hunger: 30,
|
||||
happiness: 30,
|
||||
hygiene: 30,
|
||||
energy: 20,
|
||||
health: 30,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clamp a value to the STAT_MIN-STAT_MAX range (1-100).
|
||||
* Stats can never reach true zero - minimum is always 1.
|
||||
*/
|
||||
function clamp(value: number): number {
|
||||
return Math.max(STAT_MIN, Math.min(STAT_MAX, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stat value with fallback to 100 (full).
|
||||
*/
|
||||
function getStat(stats: Partial<BlobbiStats>, key: keyof BlobbiStats): number {
|
||||
return stats[key] ?? 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hours to the elapsed time unit for calculation.
|
||||
* @param hours - Elapsed hours
|
||||
* @returns Rate multiplier for the elapsed time
|
||||
*/
|
||||
function hoursFromSeconds(seconds: number): number {
|
||||
return seconds / 3600;
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a stat delta toward zero (truncate fractional part).
|
||||
*
|
||||
* CRITICAL: We use Math.trunc() instead of Math.floor() because:
|
||||
* - Math.floor(-0.5) = -1 (rounds down, applying decay even with tiny elapsed time)
|
||||
* - Math.trunc(-0.5) = 0 (rounds toward zero, no decay applied)
|
||||
*
|
||||
* This prevents the bug where any action within seconds of the last action
|
||||
* would still apply -1 decay even though insufficient time passed.
|
||||
*
|
||||
* @param delta - Calculated stat change (can be positive or negative)
|
||||
* @returns Integer delta to apply
|
||||
*/
|
||||
function roundDelta(delta: number): number {
|
||||
return Math.trunc(delta);
|
||||
}
|
||||
|
||||
// ─── Stage-Specific Decay Calculators ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate egg stage decay.
|
||||
*
|
||||
* Eggs only decay hygiene, health, and happiness.
|
||||
* Hunger and energy are fixed at 100.
|
||||
*/
|
||||
function calculateEggDecay(
|
||||
stats: Partial<BlobbiStats>,
|
||||
_elapsedHours: number
|
||||
): BlobbiStats {
|
||||
// Eggs do not decay — all stats remain fixed until hatching.
|
||||
return {
|
||||
hunger: 100,
|
||||
energy: 100,
|
||||
hygiene: getStat(stats, 'hygiene'),
|
||||
health: getStat(stats, 'health'),
|
||||
happiness: getStat(stats, 'happiness'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate baby stage decay.
|
||||
*/
|
||||
function calculateBabyDecay(
|
||||
stats: Partial<BlobbiStats>,
|
||||
state: BlobbiState,
|
||||
elapsedHours: number
|
||||
): BlobbiStats {
|
||||
const isSleeping = state === 'sleeping';
|
||||
|
||||
// Get current values
|
||||
let hunger = getStat(stats, 'hunger');
|
||||
let happiness = getStat(stats, 'happiness');
|
||||
let hygiene = getStat(stats, 'hygiene');
|
||||
let energy = getStat(stats, 'energy');
|
||||
let health = getStat(stats, 'health');
|
||||
|
||||
// Calculate basic stat decay/regen
|
||||
const hungerDelta = BABY_DECAY.hunger * elapsedHours;
|
||||
const happinessDelta = BABY_DECAY.happiness * elapsedHours;
|
||||
const hygieneDelta = BABY_DECAY.hygiene * elapsedHours;
|
||||
const energyDelta = (isSleeping ? BABY_DECAY.energy.sleeping : BABY_DECAY.energy.awake) * elapsedHours;
|
||||
|
||||
// Apply basic deltas
|
||||
hunger = clamp(hunger + roundDelta(hungerDelta));
|
||||
happiness = clamp(happiness + roundDelta(happinessDelta));
|
||||
hygiene = clamp(hygiene + roundDelta(hygieneDelta));
|
||||
energy = clamp(energy + roundDelta(energyDelta));
|
||||
|
||||
// Calculate health (complex conditional decay + possible regen)
|
||||
let healthDelta = BABY_DECAY.health.base * elapsedHours;
|
||||
|
||||
// Hunger penalties
|
||||
if (hunger < 70) healthDelta += BABY_DECAY.health.hungerBelow70 * elapsedHours;
|
||||
if (hunger < 40) healthDelta += BABY_DECAY.health.hungerBelow40 * elapsedHours;
|
||||
|
||||
// Hygiene penalties
|
||||
if (hygiene < 70) healthDelta += BABY_DECAY.health.hygieneBelow70 * elapsedHours;
|
||||
if (hygiene < 40) healthDelta += BABY_DECAY.health.hygieneBelow40 * elapsedHours;
|
||||
|
||||
// Energy penalties
|
||||
if (energy < 50) healthDelta += BABY_DECAY.health.energyBelow50 * elapsedHours;
|
||||
if (energy < 25) healthDelta += BABY_DECAY.health.energyBelow25 * elapsedHours;
|
||||
|
||||
// Happiness penalties
|
||||
if (happiness < 50) healthDelta += BABY_DECAY.health.happinessBelow50 * elapsedHours;
|
||||
if (happiness < 25) healthDelta += BABY_DECAY.health.happinessBelow25 * elapsedHours;
|
||||
|
||||
// Health regeneration (all stats >= 80)
|
||||
const threshold = BABY_DECAY.health.regenThreshold;
|
||||
if (hunger >= threshold && happiness >= threshold && hygiene >= threshold && energy >= threshold) {
|
||||
healthDelta += BABY_DECAY.health.regenRate * elapsedHours;
|
||||
}
|
||||
|
||||
health = clamp(health + roundDelta(healthDelta));
|
||||
|
||||
return { hunger, happiness, hygiene, energy, health };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate adult stage decay.
|
||||
*/
|
||||
function calculateAdultDecay(
|
||||
stats: Partial<BlobbiStats>,
|
||||
state: BlobbiState,
|
||||
elapsedHours: number
|
||||
): BlobbiStats {
|
||||
const isSleeping = state === 'sleeping';
|
||||
|
||||
// Get current values
|
||||
let hunger = getStat(stats, 'hunger');
|
||||
let happiness = getStat(stats, 'happiness');
|
||||
let hygiene = getStat(stats, 'hygiene');
|
||||
let energy = getStat(stats, 'energy');
|
||||
let health = getStat(stats, 'health');
|
||||
|
||||
// Calculate basic stat decay/regen
|
||||
const hungerDelta = ADULT_DECAY.hunger * elapsedHours;
|
||||
const happinessDelta = ADULT_DECAY.happiness * elapsedHours;
|
||||
const hygieneDelta = ADULT_DECAY.hygiene * elapsedHours;
|
||||
const energyDelta = (isSleeping ? ADULT_DECAY.energy.sleeping : ADULT_DECAY.energy.awake) * elapsedHours;
|
||||
|
||||
// Apply basic deltas
|
||||
hunger = clamp(hunger + roundDelta(hungerDelta));
|
||||
happiness = clamp(happiness + roundDelta(happinessDelta));
|
||||
hygiene = clamp(hygiene + roundDelta(hygieneDelta));
|
||||
energy = clamp(energy + roundDelta(energyDelta));
|
||||
|
||||
// Calculate health (complex conditional decay + possible regen)
|
||||
let healthDelta = ADULT_DECAY.health.base * elapsedHours;
|
||||
|
||||
// Hunger penalties
|
||||
if (hunger < 60) healthDelta += ADULT_DECAY.health.hungerBelow60 * elapsedHours;
|
||||
if (hunger < 30) healthDelta += ADULT_DECAY.health.hungerBelow30 * elapsedHours;
|
||||
|
||||
// Hygiene penalties
|
||||
if (hygiene < 60) healthDelta += ADULT_DECAY.health.hygieneBelow60 * elapsedHours;
|
||||
if (hygiene < 30) healthDelta += ADULT_DECAY.health.hygieneBelow30 * elapsedHours;
|
||||
|
||||
// Energy penalties
|
||||
if (energy < 40) healthDelta += ADULT_DECAY.health.energyBelow40 * elapsedHours;
|
||||
if (energy < 20) healthDelta += ADULT_DECAY.health.energyBelow20 * elapsedHours;
|
||||
|
||||
// Happiness penalties
|
||||
if (happiness < 40) healthDelta += ADULT_DECAY.health.happinessBelow40 * elapsedHours;
|
||||
if (happiness < 20) healthDelta += ADULT_DECAY.health.happinessBelow20 * elapsedHours;
|
||||
|
||||
// Health regeneration (all stats >= 80)
|
||||
const threshold = ADULT_DECAY.health.regenThreshold;
|
||||
if (hunger >= threshold && happiness >= threshold && hygiene >= threshold && energy >= threshold) {
|
||||
healthDelta += ADULT_DECAY.health.regenRate * elapsedHours;
|
||||
}
|
||||
|
||||
health = clamp(health + roundDelta(healthDelta));
|
||||
|
||||
return { hunger, happiness, hygiene, energy, health };
|
||||
}
|
||||
|
||||
// ─── Main Decay Function ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply decay to a Blobbi based on elapsed time since last decay.
|
||||
*
|
||||
* This is a pure, deterministic function that:
|
||||
* 1. Calculates elapsed time from lastDecayAt to now
|
||||
* 2. Applies stage-specific decay rates
|
||||
* 3. Truncates all stat deltas toward zero before application (prevents micro-decay from tiny elapsed times)
|
||||
* 4. Clamps final stats to 1-100 range
|
||||
* 5. Returns updated stats without side effects
|
||||
*
|
||||
* @param input - Decay input parameters from persisted state
|
||||
* @returns DecayResult with updated stats and new decay timestamp
|
||||
*/
|
||||
export function applyBlobbiDecay(input: DecayInput): DecayResult {
|
||||
const now = input.now ?? Math.floor(Date.now() / 1000);
|
||||
const lastDecayAt = input.lastDecayAt ?? now;
|
||||
|
||||
// Calculate elapsed time
|
||||
const elapsedSeconds = Math.max(0, now - lastDecayAt);
|
||||
const elapsedHours = hoursFromSeconds(elapsedSeconds);
|
||||
|
||||
// If no time has passed, return current stats unchanged
|
||||
if (elapsedSeconds === 0) {
|
||||
return {
|
||||
stats: {
|
||||
hunger: getStat(input.stats, 'hunger'),
|
||||
happiness: getStat(input.stats, 'happiness'),
|
||||
health: getStat(input.stats, 'health'),
|
||||
hygiene: getStat(input.stats, 'hygiene'),
|
||||
energy: getStat(input.stats, 'energy'),
|
||||
},
|
||||
elapsedSeconds: 0,
|
||||
newDecayTimestamp: now,
|
||||
};
|
||||
}
|
||||
|
||||
// Apply stage-specific decay
|
||||
let newStats: BlobbiStats;
|
||||
switch (input.stage) {
|
||||
case 'egg':
|
||||
newStats = calculateEggDecay(input.stats, elapsedHours);
|
||||
break;
|
||||
case 'baby':
|
||||
newStats = calculateBabyDecay(input.stats, input.state, elapsedHours);
|
||||
break;
|
||||
case 'adult':
|
||||
newStats = calculateAdultDecay(input.stats, input.state, elapsedHours);
|
||||
break;
|
||||
default:
|
||||
// Fallback to adult decay for unknown stages
|
||||
newStats = calculateAdultDecay(input.stats, input.state, elapsedHours);
|
||||
}
|
||||
|
||||
return {
|
||||
stats: newStats,
|
||||
elapsedSeconds,
|
||||
newDecayTimestamp: now,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Threshold Checkers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a stat is at warning level for the given stage.
|
||||
*/
|
||||
export function isStatAtWarning(
|
||||
stage: BlobbiStage,
|
||||
stat: keyof BlobbiStats,
|
||||
value: number
|
||||
): boolean {
|
||||
const thresholds = WARNING_THRESHOLDS[stage];
|
||||
const threshold = (thresholds as Record<string, number>)[stat];
|
||||
if (threshold === undefined) return false;
|
||||
return value < threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a stat is at critical level for the given stage.
|
||||
*/
|
||||
export function isStatAtCritical(
|
||||
stage: BlobbiStage,
|
||||
stat: keyof BlobbiStats,
|
||||
value: number
|
||||
): boolean {
|
||||
const thresholds = CRITICAL_THRESHOLDS[stage];
|
||||
const threshold = (thresholds as Record<string, number>)[stat];
|
||||
if (threshold === undefined) return false;
|
||||
return value < threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status level for a stat.
|
||||
* @returns 'critical' | 'warning' | 'normal'
|
||||
*/
|
||||
export function getStatStatus(
|
||||
stage: BlobbiStage,
|
||||
stat: keyof BlobbiStats,
|
||||
value: number
|
||||
): 'critical' | 'warning' | 'normal' {
|
||||
if (isStatAtCritical(stage, stat, value)) return 'critical';
|
||||
if (isStatAtWarning(stage, stat, value)) return 'warning';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stats that are at warning or critical level.
|
||||
*/
|
||||
export function getStatsNeedingAttention(
|
||||
stage: BlobbiStage,
|
||||
stats: Partial<BlobbiStats>
|
||||
): Array<{ stat: keyof BlobbiStats; value: number; status: 'warning' | 'critical' }> {
|
||||
const results: Array<{ stat: keyof BlobbiStats; value: number; status: 'warning' | 'critical' }> = [];
|
||||
|
||||
const statKeys: (keyof BlobbiStats)[] = ['hunger', 'happiness', 'health', 'hygiene', 'energy'];
|
||||
|
||||
// For eggs, only check relevant stats
|
||||
const relevantStats = stage === 'egg'
|
||||
? ['health', 'hygiene', 'happiness'] as (keyof BlobbiStats)[]
|
||||
: statKeys;
|
||||
|
||||
for (const stat of relevantStats) {
|
||||
const value = stats[stat] ?? 100;
|
||||
const status = getStatStatus(stage, stat, value);
|
||||
if (status !== 'normal') {
|
||||
results.push({ stat, value, status });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Visible Stats Helper ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Visibility threshold: stats at or above this value are hidden in the UI.
|
||||
* Only stats below this threshold are displayed.
|
||||
*/
|
||||
export const STAT_VISIBILITY_THRESHOLD = 70;
|
||||
|
||||
/**
|
||||
* Get the stats that should be visible for a given stage.
|
||||
* Eggs only show health, hygiene, happiness.
|
||||
* Baby/adult show all stats.
|
||||
*/
|
||||
export function getVisibleStats(stage: BlobbiStage): (keyof BlobbiStats)[] {
|
||||
if (stage === 'egg') {
|
||||
return ['health', 'hygiene', 'happiness'];
|
||||
}
|
||||
return ['hunger', 'happiness', 'health', 'hygiene', 'energy'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get visible stats with their values for display.
|
||||
* Stats at or above STAT_VISIBILITY_THRESHOLD are filtered out.
|
||||
*/
|
||||
export function getVisibleStatsWithValues(
|
||||
stage: BlobbiStage,
|
||||
stats: Partial<BlobbiStats>
|
||||
): Array<{ stat: keyof BlobbiStats; value: number; status: 'critical' | 'warning' | 'normal' }> {
|
||||
const visibleStats = getVisibleStats(stage);
|
||||
return visibleStats
|
||||
.map(stat => ({
|
||||
stat,
|
||||
value: stats[stat] ?? 100,
|
||||
status: getStatStatus(stage, stat, stats[stat] ?? 100),
|
||||
}))
|
||||
.filter(entry => entry.value < STAT_VISIBILITY_THRESHOLD);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Blobbi → EggGraphic Adapter
|
||||
*
|
||||
* This module provides a translation layer between the Blobbi domain model
|
||||
* and the portable EggGraphic visual module.
|
||||
*
|
||||
* PURPOSE:
|
||||
* - Keep the game/domain visual model decoupled from EggGraphic internals
|
||||
* - Provide explicit mappings between vocabularies
|
||||
* - Act as the single translation boundary for visual rendering
|
||||
*
|
||||
* USAGE:
|
||||
* ```ts
|
||||
* const eggVisual = toEggGraphicVisualBlobbi(companion);
|
||||
* // Pass eggVisual to EggGraphic component
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { EggVisualBlobbi } from '@/blobbi/egg';
|
||||
import {
|
||||
type BlobbiCompanion,
|
||||
type BlobbiPattern,
|
||||
type BlobbiSpecialMark,
|
||||
type BlobbiStage,
|
||||
getTagValue,
|
||||
} from './blobbi';
|
||||
|
||||
// ─── Egg Module Types (derived from EggVisualBlobbi) ──────────────────────────
|
||||
|
||||
/** Life stage values accepted by EggGraphic */
|
||||
type EggLifeStage = NonNullable<EggVisualBlobbi['lifeStage']>;
|
||||
|
||||
/** Pattern values accepted by EggGraphic */
|
||||
type EggPattern = NonNullable<EggVisualBlobbi['pattern']>;
|
||||
|
||||
/** Special mark values accepted by EggGraphic */
|
||||
type EggSpecialMark = NonNullable<EggVisualBlobbi['specialMark']>;
|
||||
|
||||
/** Theme variant values accepted by EggGraphic */
|
||||
type EggThemeVariant = NonNullable<EggVisualBlobbi['themeVariant']>;
|
||||
|
||||
// ─── Mapping Tables ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps Blobbi pattern values to EggGraphic pattern values.
|
||||
* Explicit mapping allows vocabularies to diverge in the future.
|
||||
*/
|
||||
const PATTERN_MAP: Record<BlobbiPattern, EggPattern> = {
|
||||
'solid': 'solid',
|
||||
'spotted': 'spotted',
|
||||
'striped': 'striped',
|
||||
'gradient': 'gradient',
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps Blobbi special mark values to EggGraphic special mark values.
|
||||
*/
|
||||
const SPECIAL_MARK_MAP: Record<BlobbiSpecialMark, EggSpecialMark> = {
|
||||
'none': 'none',
|
||||
'star': 'star',
|
||||
'heart': 'heart',
|
||||
'sparkle': 'sparkle',
|
||||
'blush': 'blush',
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps Blobbi stage values to EggGraphic life stage values.
|
||||
*/
|
||||
const LIFE_STAGE_MAP: Record<BlobbiStage, EggLifeStage> = {
|
||||
'egg': 'egg',
|
||||
'baby': 'baby',
|
||||
'adult': 'adult',
|
||||
};
|
||||
|
||||
// ─── Fallback Values ──────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_PATTERN: EggPattern = 'solid';
|
||||
const DEFAULT_SPECIAL_MARK: EggSpecialMark = 'none';
|
||||
const DEFAULT_LIFE_STAGE: EggLifeStage = 'egg';
|
||||
const DEFAULT_THEME_VARIANT: EggThemeVariant = 'default';
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract crossover app identifier from companion tags.
|
||||
*/
|
||||
function extractCrossoverApp(allTags: string[][]): string | undefined {
|
||||
return getTagValue(allTags, 'crossover_app');
|
||||
}
|
||||
|
||||
// ─── Main Adapter Function ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a BlobbiCompanion to EggVisualBlobbi for rendering.
|
||||
*
|
||||
* This is the TRANSLATION BOUNDARY between the Blobbi domain model
|
||||
* and the EggGraphic visual module.
|
||||
*
|
||||
* The adapter:
|
||||
* - Maps vocabulary values through explicit mapping tables
|
||||
* - Passes through full tags for EggGraphic metadata lookups
|
||||
* - Provides safe fallbacks for any missing/invalid data
|
||||
* - Does NOT leak app-specific assumptions into EggGraphic
|
||||
*
|
||||
* @param companion - The parsed BlobbiCompanion from parseBlobbiEvent
|
||||
* @param themeVariant - Optional theme variant override
|
||||
* @returns Visual data compatible with EggVisualBlobbi
|
||||
*/
|
||||
export function toEggGraphicVisualBlobbi(
|
||||
companion: BlobbiCompanion,
|
||||
themeVariant: EggThemeVariant = DEFAULT_THEME_VARIANT
|
||||
): EggVisualBlobbi {
|
||||
const { visualTraits, stage, allTags } = companion;
|
||||
|
||||
return {
|
||||
// Colors pass through directly (already CSS hex values)
|
||||
baseColor: visualTraits.baseColor,
|
||||
secondaryColor: visualTraits.secondaryColor,
|
||||
|
||||
// Mapped through explicit tables with fallbacks
|
||||
pattern: PATTERN_MAP[visualTraits.pattern] ?? DEFAULT_PATTERN,
|
||||
specialMark: SPECIAL_MARK_MAP[visualTraits.specialMark] ?? DEFAULT_SPECIAL_MARK,
|
||||
lifeStage: LIFE_STAGE_MAP[stage] ?? DEFAULT_LIFE_STAGE,
|
||||
|
||||
// Theme variant
|
||||
themeVariant,
|
||||
|
||||
// Pass through full tags for EggGraphic metadata lookups
|
||||
tags: allTags,
|
||||
|
||||
// Extracted convenience values
|
||||
crossoverApp: extractCrossoverApp(allTags),
|
||||
|
||||
// NOTE: We intentionally do NOT pass companion.name as title here.
|
||||
// The EggGraphic 'title' field is for special designations (e.g., "Divine"),
|
||||
// not the pet's name. The pet name is displayed separately by the parent component.
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two EggVisualBlobbi configurations are visually equivalent.
|
||||
* Useful for memoization and avoiding unnecessary re-renders.
|
||||
*/
|
||||
export function areEggGraphicVisualsEqual(
|
||||
a: EggVisualBlobbi,
|
||||
b: EggVisualBlobbi
|
||||
): boolean {
|
||||
return (
|
||||
a.baseColor === b.baseColor &&
|
||||
a.secondaryColor === b.secondaryColor &&
|
||||
a.pattern === b.pattern &&
|
||||
a.specialMark === b.specialMark &&
|
||||
a.lifeStage === b.lifeStage &&
|
||||
a.themeVariant === b.themeVariant
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,247 @@
|
||||
// src/types/blobbi.ts
|
||||
|
||||
/**
|
||||
* Minimal, clean Blobbi domain types for the new project.
|
||||
*
|
||||
* Goal:
|
||||
* - keep the model small and portable
|
||||
* - support egg / baby / adult rendering
|
||||
* - support sleep state
|
||||
* - support visual customization
|
||||
* - avoid dragging old project complexity into the new app
|
||||
*/
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── *
|
||||
* Core lifecycle / state
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type BlobbiLifeStage = 'egg' | 'baby' | 'adult';
|
||||
export type BlobbiState = 'active' | 'sleeping' | 'hibernating' | 'incubating' | 'evolving';
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── *
|
||||
* Visual traits
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type BlobbiPattern = 'solid' | 'spotted' | 'striped' | 'gradient';
|
||||
export type BlobbiSpecialMark = 'none' | 'star' | 'heart' | 'sparkle' | 'blush';
|
||||
export type BlobbiSize = 'small' | 'medium' | 'large';
|
||||
|
||||
export interface BlobbiVisualTraits {
|
||||
/**
|
||||
* Main body/base color.
|
||||
* Example: "#8B5CF6"
|
||||
*/
|
||||
baseColor?: string;
|
||||
|
||||
/**
|
||||
* Secondary/accent color, usually used in gradients or details.
|
||||
*/
|
||||
secondaryColor?: string;
|
||||
|
||||
/**
|
||||
* Eye / pupil color.
|
||||
*/
|
||||
eyeColor?: string;
|
||||
|
||||
/**
|
||||
* Optional pattern used by egg or future visual systems.
|
||||
*/
|
||||
pattern?: BlobbiPattern;
|
||||
|
||||
/**
|
||||
* Optional visual mark.
|
||||
*/
|
||||
specialMark?: BlobbiSpecialMark;
|
||||
|
||||
/**
|
||||
* Optional size hint for rendering.
|
||||
*/
|
||||
size?: BlobbiSize;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── *
|
||||
* Basic stats
|
||||
* Keep only what is useful right now for UI and simple interactions.
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface BlobbiStats {
|
||||
hunger: number;
|
||||
happiness: number;
|
||||
health: number;
|
||||
hygiene: number;
|
||||
energy: number;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── *
|
||||
* Stage-specific fields
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface BlobbiEggData {
|
||||
incubationTime?: number;
|
||||
incubationProgress?: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface BlobbiBabyData {
|
||||
// Reserved for future baby-specific fields
|
||||
}
|
||||
|
||||
export interface BlobbiAdultData {
|
||||
evolutionForm?: string;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── *
|
||||
* Main Blobbi entity
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface Blobbi extends BlobbiVisualTraits {
|
||||
/**
|
||||
* Stable unique identifier.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Display name.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Current lifecycle stage.
|
||||
*/
|
||||
lifeStage: BlobbiLifeStage;
|
||||
|
||||
/**
|
||||
* Current activity state.
|
||||
*/
|
||||
state: BlobbiState;
|
||||
|
||||
/**
|
||||
* Optional convenience boolean for UI code that still expects this.
|
||||
* Prefer using `state === "sleeping"` in new code.
|
||||
*/
|
||||
isSleeping?: boolean;
|
||||
|
||||
/**
|
||||
* Basic gameplay / care stats.
|
||||
*/
|
||||
stats: BlobbiStats;
|
||||
|
||||
/**
|
||||
* Ownership / identity metadata.
|
||||
*/
|
||||
ownerPubkey?: string;
|
||||
seed?: string;
|
||||
|
||||
/**
|
||||
* Timestamps.
|
||||
* Keep them simple for now; decide later whether the project will
|
||||
* standardize on seconds or milliseconds everywhere.
|
||||
*/
|
||||
createdAt?: number;
|
||||
birthTime?: number;
|
||||
hatchTime?: number;
|
||||
lastInteraction?: number;
|
||||
|
||||
/**
|
||||
* Progression.
|
||||
*/
|
||||
experience?: number;
|
||||
generation?: number;
|
||||
careStreak?: number;
|
||||
|
||||
/**
|
||||
* Visibility / social.
|
||||
*/
|
||||
visibleToOthers?: boolean;
|
||||
crossoverApp?: string | null;
|
||||
themeVariant?: string;
|
||||
|
||||
/**
|
||||
* Optional raw tags for Nostr-backed or metadata-driven rendering.
|
||||
*/
|
||||
tags?: string[][];
|
||||
|
||||
/**
|
||||
* Optional stage-specific buckets.
|
||||
* This keeps the root model clean while leaving room to grow.
|
||||
*/
|
||||
egg?: BlobbiEggData;
|
||||
baby?: BlobbiBabyData;
|
||||
adult?: BlobbiAdultData;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── *
|
||||
* Defaults / helpers
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const DEFAULT_BLOBBI_STATS: BlobbiStats = {
|
||||
hunger: 100,
|
||||
happiness: 100,
|
||||
health: 100,
|
||||
hygiene: 100,
|
||||
energy: 100,
|
||||
};
|
||||
|
||||
export const DEFAULT_BLOBBI_STATE: BlobbiState = 'active';
|
||||
export const DEFAULT_BLOBBI_LIFE_STAGE: BlobbiLifeStage = 'egg';
|
||||
|
||||
export function createDefaultBlobbi(overrides: Partial<Blobbi> = {}): Blobbi {
|
||||
const state = overrides.state ?? DEFAULT_BLOBBI_STATE;
|
||||
|
||||
return {
|
||||
id: overrides.id ?? 'blobbi-1',
|
||||
name: overrides.name ?? 'Blobbi',
|
||||
lifeStage: overrides.lifeStage ?? DEFAULT_BLOBBI_LIFE_STAGE,
|
||||
state,
|
||||
isSleeping: overrides.isSleeping ?? state === 'sleeping',
|
||||
stats: overrides.stats ?? { ...DEFAULT_BLOBBI_STATS },
|
||||
|
||||
baseColor: overrides.baseColor,
|
||||
secondaryColor: overrides.secondaryColor,
|
||||
eyeColor: overrides.eyeColor,
|
||||
pattern: overrides.pattern,
|
||||
specialMark: overrides.specialMark,
|
||||
size: overrides.size,
|
||||
|
||||
ownerPubkey: overrides.ownerPubkey,
|
||||
seed: overrides.seed,
|
||||
|
||||
createdAt: overrides.createdAt,
|
||||
birthTime: overrides.birthTime,
|
||||
hatchTime: overrides.hatchTime,
|
||||
lastInteraction: overrides.lastInteraction,
|
||||
|
||||
experience: overrides.experience ?? 0,
|
||||
generation: overrides.generation ?? 1,
|
||||
careStreak: overrides.careStreak ?? 0,
|
||||
|
||||
visibleToOthers: overrides.visibleToOthers ?? true,
|
||||
crossoverApp: overrides.crossoverApp ?? null,
|
||||
themeVariant: overrides.themeVariant,
|
||||
tags: overrides.tags ?? [],
|
||||
|
||||
egg: overrides.egg,
|
||||
baby: overrides.baby,
|
||||
adult: overrides.adult,
|
||||
};
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── *
|
||||
* Type guards
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function isEggBlobbi(blobbi: Blobbi): boolean {
|
||||
return blobbi.lifeStage === 'egg';
|
||||
}
|
||||
|
||||
export function isBabyBlobbi(blobbi: Blobbi): boolean {
|
||||
return blobbi.lifeStage === 'baby';
|
||||
}
|
||||
|
||||
export function isAdultBlobbi(blobbi: Blobbi): boolean {
|
||||
return blobbi.lifeStage === 'adult';
|
||||
}
|
||||
|
||||
export function isBlobbiSleeping(blobbi: Blobbi): boolean {
|
||||
return blobbi.state === 'sleeping' || blobbi.isSleeping === true;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { BlobbiCompanion, BlobbiStage, BlobbiState, BlobbiStats } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion, BlobbiStage, BlobbiState, BlobbiStats } from '@/blobbi/core/lib/blobbi';
|
||||
import { ADULT_FORMS } from '@/blobbi/adult-blobbi/types/adult.types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -32,6 +32,8 @@ interface BlobbiEmotionPanelProps {
|
||||
const EMOTIONS: Array<{ value: BlobbiEmotion; label: string; emoji: string }> = [
|
||||
{ value: 'neutral', label: 'Default', emoji: '😊' },
|
||||
{ value: 'sad', label: 'Sad', emoji: '😢' },
|
||||
{ value: 'boring', label: 'Boring', emoji: '😑' },
|
||||
{ value: 'dirty', label: 'Dirty', emoji: '💩' },
|
||||
{ value: 'happy', label: 'Extra Happy', emoji: '😄' },
|
||||
{ value: 'angry', label: 'Angry', emoji: '😠' },
|
||||
{ value: 'surprised', label: 'Surprised', emoji: '😲' },
|
||||
|
||||
@@ -14,8 +14,8 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion, BlobbiStage } from '@/lib/blobbi';
|
||||
import { KIND_BLOBBI_STATE, updateBlobbiTags, getLocalDayString } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion, BlobbiStage } from '@/blobbi/core/lib/blobbi';
|
||||
import { KIND_BLOBBI_STATE, updateBlobbiTags, getLocalDayString } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbiDevUpdates } from './BlobbiDevEditor';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import type { EggVisualBlobbi } from '../types/egg.types';
|
||||
import { isValidBaseColor, isValidSecondaryColor } from '../lib/blobbi-egg-validation';
|
||||
import { SpecialMarkRenderer, SpecialMarkFallback } from './SpecialMarkRenderer';
|
||||
@@ -12,6 +12,19 @@ import { cn } from '../lib/cn';
|
||||
*/
|
||||
export type EggReactionState = 'idle' | 'listening' | 'swaying' | 'singing' | 'happy';
|
||||
|
||||
/**
|
||||
* Status effects for egg-stage visual feedback.
|
||||
* These are simpler than adult/baby expressions since eggs don't have faces.
|
||||
*/
|
||||
export interface EggStatusEffects {
|
||||
/** Dirty state: shows sweat droplet and dust underneath */
|
||||
dirty?: boolean;
|
||||
/** Health state: shows floating dizzy spirals around egg */
|
||||
sick?: boolean;
|
||||
/** Happy state: shows subtle sparkles around egg */
|
||||
happy?: boolean;
|
||||
}
|
||||
|
||||
interface EggGraphicProps {
|
||||
blobbi?: EggVisualBlobbi; // Visual blobbi object for visual properties
|
||||
sizeVariant?: 'tiny' | 'small' | 'medium' | 'large'; // Internal scaling only, NOT layout size
|
||||
@@ -21,6 +34,42 @@ interface EggGraphicProps {
|
||||
cracking?: boolean;
|
||||
warmth?: number; // 0-100, affects the glow (fallback if no blobbi)
|
||||
forceInlineSvg?: boolean; // New prop to guarantee inline SVG
|
||||
/** Status effects for egg-stage visual feedback */
|
||||
statusEffects?: EggStatusEffects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a spiral path for sick/dizzy effects.
|
||||
* Generates a true Archimedean spiral that starts from center and winds outward.
|
||||
* Based on the spiral algorithm from eyes/effects.ts.
|
||||
*
|
||||
* @param cx - Center X coordinate
|
||||
* @param cy - Center Y coordinate
|
||||
* @param radius - Outer radius of the spiral
|
||||
* @param clockwise - If true, winds clockwise; if false, winds counter-clockwise (default: true)
|
||||
*/
|
||||
function createEggSpiralPath(cx: number, cy: number, radius: number, clockwise: boolean = true): string {
|
||||
const points: string[] = [];
|
||||
const turns = 2; // Number of complete rotations
|
||||
const steps = 40; // Smoothness of the spiral
|
||||
|
||||
// Direction multiplier: 1 for clockwise, -1 for counter-clockwise
|
||||
const direction = clockwise ? 1 : -1;
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const angle = direction * (i / steps) * turns * 2 * Math.PI;
|
||||
const r = (i / steps) * radius;
|
||||
const x = cx + r * Math.cos(angle);
|
||||
const y = cy + r * Math.sin(angle);
|
||||
|
||||
if (i === 0) {
|
||||
points.push(`M ${x.toFixed(2)} ${y.toFixed(2)}`);
|
||||
} else {
|
||||
points.push(`L ${x.toFixed(2)} ${y.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return points.join(' ');
|
||||
}
|
||||
|
||||
// Legacy fallback function for special marks (kept for compatibility)
|
||||
@@ -64,6 +113,7 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
cracking = false,
|
||||
warmth = 50,
|
||||
forceInlineSvg: _forceInlineSvg = false,
|
||||
statusEffects,
|
||||
}) => {
|
||||
// sizeVariant controls ONLY internal scaling/details, NOT layout dimensions
|
||||
// Parent container controls actual rendered width/height via slot
|
||||
@@ -98,6 +148,18 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
|
||||
const scale = fillScale[sizeVariant] || fillScale.medium;
|
||||
|
||||
// Tap-to-wiggle interaction state
|
||||
const [isTapWiggling, setIsTapWiggling] = useState(false);
|
||||
|
||||
const handleEggClick = useCallback(() => {
|
||||
if (isTapWiggling || cracking) return; // Don't re-trigger during animation or cracking
|
||||
setIsTapWiggling(true);
|
||||
}, [isTapWiggling, cracking]);
|
||||
|
||||
const handleWiggleEnd = useCallback(() => {
|
||||
setIsTapWiggling(false);
|
||||
}, []);
|
||||
|
||||
// Divine color constants
|
||||
const DIVINE_PRIMARY_GREEN = '#55C4A2';
|
||||
const _DIVINE_HIGHLIGHT_GREEN = '#7AD9B9';
|
||||
@@ -393,11 +455,17 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
|
||||
{/* Main egg shape - uses percentage-based sizing */}
|
||||
<div
|
||||
onClick={handleEggClick}
|
||||
onAnimationEnd={(e) => {
|
||||
if (e.animationName === 'egg-tap-wiggle') handleWiggleEnd();
|
||||
}}
|
||||
className={cn(
|
||||
'relative transition-all duration-500 z-10',
|
||||
// Reaction-based animations (music/sing)
|
||||
(reaction === 'listening' || reaction === 'swaying' || reaction === 'happy') && 'animate-egg-sway',
|
||||
reaction === 'singing' && 'animate-egg-bounce',
|
||||
'relative transition-all duration-500 z-10 cursor-pointer',
|
||||
// Tap wiggle (highest priority after cracking)
|
||||
isTapWiggling && !cracking && 'animate-egg-tap-wiggle',
|
||||
// Reaction-based animations (music/sing) - only when not tap-wiggling
|
||||
!isTapWiggling && (reaction === 'listening' || reaction === 'swaying' || reaction === 'happy') && 'animate-egg-sway',
|
||||
!isTapWiggling && reaction === 'singing' && 'animate-egg-bounce',
|
||||
// Warmth effect only when animated AND warm
|
||||
animated && actualWarmth > 60 && 'animate-egg-warmth',
|
||||
// Cracking overrides other animations
|
||||
@@ -657,6 +725,440 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Status Effects ─────────────────────────────────────────────── */}
|
||||
|
||||
{/* Dirty effect: sweat droplet + dust at lower shell edges
|
||||
Placement rules for egg:
|
||||
- Sweat droplet stays at upper-left (outside egg, not on face)
|
||||
- Dust particles only at lower outer shell edges
|
||||
- Avoid center-front placement entirely
|
||||
*/}
|
||||
{statusEffects?.dirty && (
|
||||
<>
|
||||
{/* Sweat droplet - upper left outside egg shell */}
|
||||
<div
|
||||
className="absolute animate-egg-sweat-drop"
|
||||
style={{
|
||||
top: '15%',
|
||||
left: '5%',
|
||||
width: '0.6em',
|
||||
height: '0.9em',
|
||||
background: 'linear-gradient(180deg, rgba(147, 197, 253, 0.9) 0%, rgba(59, 130, 246, 0.7) 100%)',
|
||||
borderRadius: '50% 50% 50% 50% / 30% 30% 70% 70%',
|
||||
zIndex: 20,
|
||||
}}
|
||||
/>
|
||||
{/* Dust particles underneath (back layer) - at lower edges */}
|
||||
<div
|
||||
className="absolute animate-egg-dust"
|
||||
style={{
|
||||
bottom: '-5%',
|
||||
left: '20%',
|
||||
width: '60%',
|
||||
height: '0.4em',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
{/* Left edge particle */}
|
||||
<div
|
||||
style={{
|
||||
width: '0.3em',
|
||||
height: '0.3em',
|
||||
background: 'rgba(87, 83, 78, 0.7)',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
className="animate-egg-dust-particle"
|
||||
/>
|
||||
{/* Right edge particle */}
|
||||
<div
|
||||
style={{
|
||||
width: '0.28em',
|
||||
height: '0.28em',
|
||||
background: 'rgba(87, 83, 78, 0.65)',
|
||||
borderRadius: '50%',
|
||||
animationDelay: '0.4s',
|
||||
}}
|
||||
className="animate-egg-dust-particle"
|
||||
/>
|
||||
</div>
|
||||
{/* Front-layer dust - at lower-left and lower-right edges
|
||||
More visible than back layer, stronger colors */}
|
||||
{/* Lower-left edge particle - larger, more visible */}
|
||||
<div
|
||||
className="absolute animate-egg-dust-particle"
|
||||
style={{
|
||||
bottom: '12%',
|
||||
left: '6%',
|
||||
width: '0.28em',
|
||||
height: '0.28em',
|
||||
background: 'rgba(63, 63, 70, 0.8)',
|
||||
borderRadius: '50%',
|
||||
zIndex: 25,
|
||||
animationDelay: '0.1s',
|
||||
}}
|
||||
/>
|
||||
{/* Lower-right edge particle */}
|
||||
<div
|
||||
className="absolute animate-egg-dust-particle"
|
||||
style={{
|
||||
bottom: '15%',
|
||||
right: '5%',
|
||||
width: '0.25em',
|
||||
height: '0.25em',
|
||||
background: 'rgba(63, 63, 70, 0.75)',
|
||||
borderRadius: '50%',
|
||||
zIndex: 25,
|
||||
animationDelay: '0.5s',
|
||||
}}
|
||||
/>
|
||||
{/* Very bottom left particle */}
|
||||
<div
|
||||
className="absolute animate-egg-dust-particle"
|
||||
style={{
|
||||
bottom: '5%',
|
||||
left: '18%',
|
||||
width: '0.22em',
|
||||
height: '0.22em',
|
||||
background: 'rgba(68, 64, 60, 0.7)',
|
||||
borderRadius: '50%',
|
||||
zIndex: 25,
|
||||
animationDelay: '0.8s',
|
||||
}}
|
||||
/>
|
||||
{/* Very bottom right particle */}
|
||||
<div
|
||||
className="absolute animate-egg-dust-particle"
|
||||
style={{
|
||||
bottom: '3%',
|
||||
right: '20%',
|
||||
width: '0.2em',
|
||||
height: '0.2em',
|
||||
background: 'rgba(68, 64, 60, 0.65)',
|
||||
borderRadius: '50%',
|
||||
zIndex: 25,
|
||||
animationDelay: '1.1s',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sick effect: layered dizzy spirals around and across egg
|
||||
Creates a magical/dizzy atmosphere with multiple spiral layers:
|
||||
- Outer spirals: floating around the egg shell
|
||||
- Inner spirals: across the egg body itself
|
||||
- Mixed colors: gray (primary) + white (accents)
|
||||
- Varying sizes, speeds, and rotation directions
|
||||
All use true Archimedean spiral paths matching Blobbi dizzy eyes
|
||||
*/}
|
||||
{statusEffects?.sick && (
|
||||
<>
|
||||
{/* ═══ OUTER SPIRALS (floating around egg) ═══ */}
|
||||
|
||||
{/* Outer 1 - top left, large, gray, COUNTER-CLOCKWISE path */}
|
||||
<svg
|
||||
className="absolute"
|
||||
style={{
|
||||
top: '0%',
|
||||
left: '-5%',
|
||||
width: '1.1em',
|
||||
height: '1.1em',
|
||||
zIndex: 20,
|
||||
overflow: 'visible',
|
||||
}}
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d={createEggSpiralPath(10, 10, 8, false)}
|
||||
stroke="#4b5563"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
opacity="0.65"
|
||||
/>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 10 10"
|
||||
to="360 10 10"
|
||||
dur="2.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Outer 2 - right side, medium, gray, CLOCKWISE path */}
|
||||
<svg
|
||||
className="absolute"
|
||||
style={{
|
||||
top: '25%',
|
||||
right: '-6%',
|
||||
width: '0.95em',
|
||||
height: '0.95em',
|
||||
zIndex: 20,
|
||||
overflow: 'visible',
|
||||
}}
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d={createEggSpiralPath(10, 10, 8, true)}
|
||||
stroke="#6b7280"
|
||||
strokeWidth="1.4"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="360 10 10"
|
||||
to="0 10 10"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Outer 3 - bottom left, small, white accent, COUNTER-CLOCKWISE path */}
|
||||
<svg
|
||||
className="absolute"
|
||||
style={{
|
||||
bottom: '15%',
|
||||
left: '-4%',
|
||||
width: '0.7em',
|
||||
height: '0.7em',
|
||||
zIndex: 20,
|
||||
overflow: 'visible',
|
||||
}}
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d={createEggSpiralPath(10, 10, 8, false)}
|
||||
stroke="white"
|
||||
strokeWidth="1.3"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
opacity="0.5"
|
||||
/>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="360 10 10"
|
||||
to="0 10 10"
|
||||
dur="3s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Outer 4 - bottom right, tiny, gray, CLOCKWISE path */}
|
||||
<svg
|
||||
className="absolute"
|
||||
style={{
|
||||
bottom: '8%',
|
||||
right: '-3%',
|
||||
width: '0.6em',
|
||||
height: '0.6em',
|
||||
zIndex: 20,
|
||||
overflow: 'visible',
|
||||
}}
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d={createEggSpiralPath(10, 10, 8, true)}
|
||||
stroke="#9ca3af"
|
||||
strokeWidth="1.2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
opacity="0.5"
|
||||
/>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 10 10"
|
||||
to="360 10 10"
|
||||
dur="3.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* ═══ INNER SPIRALS (across egg body) ═══ */}
|
||||
|
||||
{/* Inner 1 - upper egg, small, white, COUNTER-CLOCKWISE path */}
|
||||
<svg
|
||||
className="absolute"
|
||||
style={{
|
||||
top: '18%',
|
||||
left: '22%',
|
||||
width: '0.55em',
|
||||
height: '0.55em',
|
||||
zIndex: 15,
|
||||
overflow: 'visible',
|
||||
}}
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d={createEggSpiralPath(10, 10, 7, false)}
|
||||
stroke="white"
|
||||
strokeWidth="1.2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 10 10"
|
||||
to="360 10 10"
|
||||
dur="4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Inner 2 - mid-right egg, tiny, gray, CLOCKWISE path */}
|
||||
<svg
|
||||
className="absolute"
|
||||
style={{
|
||||
top: '40%',
|
||||
right: '18%',
|
||||
width: '0.5em',
|
||||
height: '0.5em',
|
||||
zIndex: 15,
|
||||
overflow: 'visible',
|
||||
}}
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d={createEggSpiralPath(10, 10, 7, true)}
|
||||
stroke="#6b7280"
|
||||
strokeWidth="1.1"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="360 10 10"
|
||||
to="0 10 10"
|
||||
dur="3s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Inner 3 - lower-center egg, small, white accent */}
|
||||
<svg
|
||||
className="absolute"
|
||||
style={{
|
||||
bottom: '28%',
|
||||
left: '35%',
|
||||
width: '0.45em',
|
||||
height: '0.45em',
|
||||
zIndex: 15,
|
||||
overflow: 'visible',
|
||||
}}
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d={createEggSpiralPath(10, 10, 7, false)}
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 10 10"
|
||||
to="360 10 10"
|
||||
dur="3.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Happy effect: subtle sparkles around egg */}
|
||||
{statusEffects?.happy && (
|
||||
<>
|
||||
{/* Sparkle 1 - top */}
|
||||
<div
|
||||
className="absolute animate-egg-sparkle"
|
||||
style={{
|
||||
top: '5%',
|
||||
left: '45%',
|
||||
width: '0.5em',
|
||||
height: '0.5em',
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 20 20" className="w-full h-full">
|
||||
<path
|
||||
d="M10 0 L10 20 M0 10 L20 10 M3 3 L17 17 M17 3 L3 17"
|
||||
stroke="rgba(251, 191, 36, 0.8)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/* Sparkle 2 - right */}
|
||||
<div
|
||||
className="absolute animate-egg-sparkle"
|
||||
style={{
|
||||
top: '25%',
|
||||
right: '0%',
|
||||
width: '0.4em',
|
||||
height: '0.4em',
|
||||
zIndex: 20,
|
||||
animationDelay: '0.4s',
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 20 20" className="w-full h-full">
|
||||
<path
|
||||
d="M10 0 L10 20 M0 10 L20 10"
|
||||
stroke="rgba(251, 191, 36, 0.7)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/* Sparkle 3 - left */}
|
||||
<div
|
||||
className="absolute animate-egg-sparkle"
|
||||
style={{
|
||||
top: '40%',
|
||||
left: '0%',
|
||||
width: '0.35em',
|
||||
height: '0.35em',
|
||||
zIndex: 20,
|
||||
animationDelay: '0.8s',
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 20 20" className="w-full h-full">
|
||||
<path
|
||||
d="M10 0 L10 20 M0 10 L20 10"
|
||||
stroke="rgba(251, 191, 36, 0.6)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import './styles/egg-animations.css';
|
||||
|
||||
// Components
|
||||
export { EggGraphic, type EggReactionState } from './components/EggGraphic';
|
||||
export { EggGraphic, type EggReactionState, type EggStatusEffects } from './components/EggGraphic';
|
||||
export { SpecialMarkRenderer, SpecialMarkFallback } from './components/SpecialMarkRenderer';
|
||||
|
||||
// Hooks
|
||||
|
||||
@@ -82,6 +82,118 @@
|
||||
animation: egg-crack-shake 0.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Tap interaction: rock side-to-side and hop */
|
||||
@keyframes egg-tap-wiggle {
|
||||
0% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
}
|
||||
15% {
|
||||
transform: translateY(-2px) rotate(-6deg);
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-6px) rotate(5deg);
|
||||
}
|
||||
45% {
|
||||
transform: translateY(-3px) rotate(-4deg);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-1px) rotate(3deg);
|
||||
}
|
||||
75% {
|
||||
transform: translateY(0) rotate(-1.5deg);
|
||||
}
|
||||
90% {
|
||||
transform: translateY(0) rotate(0.5deg);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-tap-wiggle {
|
||||
animation: egg-tap-wiggle 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Egg Status Effect Animations
|
||||
========================================== */
|
||||
|
||||
/* Sweat droplet - slides down and fades */
|
||||
@keyframes egg-sweat-drop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
80% {
|
||||
opacity: 1;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-sweat-drop {
|
||||
animation: egg-sweat-drop 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Dust particles - gentle float up */
|
||||
@keyframes egg-dust-particle {
|
||||
0%, 100% {
|
||||
opacity: 0.3;
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-dust-particle {
|
||||
animation: egg-dust-particle 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Dizzy spirals - rotate and float */
|
||||
@keyframes egg-spiral {
|
||||
0% {
|
||||
opacity: 0.4;
|
||||
transform: rotate(0deg) translateY(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: rotate(180deg) translateY(-4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
transform: rotate(360deg) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-spiral {
|
||||
animation: egg-spiral 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Happy sparkles - twinkle effect */
|
||||
@keyframes egg-sparkle {
|
||||
0%, 100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-sparkle {
|
||||
animation: egg-sparkle 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Special Mark Animations
|
||||
========================================== */
|
||||
@@ -234,7 +346,12 @@
|
||||
.animate-egg-sway,
|
||||
.animate-egg-bounce,
|
||||
.animate-egg-warmth,
|
||||
.animate-egg-crack {
|
||||
.animate-egg-crack,
|
||||
.animate-egg-tap-wiggle,
|
||||
.animate-egg-sweat-drop,
|
||||
.animate-egg-dust-particle,
|
||||
.animate-egg-spiral,
|
||||
.animate-egg-sparkle {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { BLOBBI_ADOPTION_COST } from '@/lib/blobbi';
|
||||
import { BLOBBI_ADOPTION_COST } from '@/blobbi/core/lib/blobbi';
|
||||
import { formatCompactNumber } from '@/lib/utils';
|
||||
|
||||
import type { BlobbiEggPreview } from '../lib/blobbi-preview';
|
||||
|
||||
@@ -19,7 +19,7 @@ import { cn, formatCompactNumber } from '@/lib/utils';
|
||||
import {
|
||||
BLOBBI_PREVIEW_REROLL_COST,
|
||||
BLOBBI_ADOPTION_COST,
|
||||
} from '@/lib/blobbi';
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import type { BlobbiEggPreview } from '../lib/blobbi-preview';
|
||||
import { previewToBlobbiCompanion } from '../lib/blobbi-preview';
|
||||
|
||||
@@ -26,7 +26,7 @@ import { BlobbiEggPreviewCard } from './BlobbiEggPreviewCard';
|
||||
import { BlobbiAdoptionConfirmDialog } from './BlobbiAdoptionConfirmDialog';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import type { BlobbonautProfile } from '@/lib/blobbi';
|
||||
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
interface BlobbiOnboardingFlowProps {
|
||||
/** Current profile (null if doesn't exist) */
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
buildBlobbonautTags,
|
||||
updateBlobbonautTags,
|
||||
type BlobbonautProfile,
|
||||
} from '@/lib/blobbi';
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import {
|
||||
generateEggPreview,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
getLocalDayString,
|
||||
type BlobbiVisualTraits,
|
||||
type BlobbiStats,
|
||||
} from '@/lib/blobbi';
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import type { ShopItem } from '../types/shop.types';
|
||||
import { getShopItemById } from '../lib/blobbi-shop-items';
|
||||
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
|
||||
@@ -14,7 +14,7 @@ import { BlobbiShopItemRow } from './BlobbiShopItemRow';
|
||||
import { BlobbiPurchaseDialog } from './BlobbiPurchaseDialog';
|
||||
|
||||
import type { ShopItem, ShopItemCategory } from '../types/shop.types';
|
||||
import type { BlobbonautProfile } from '@/lib/blobbi';
|
||||
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import { getShopItemsByType } from '../lib/blobbi-shop-items';
|
||||
import { useBlobbiPurchaseItem } from '../hooks/useBlobbiPurchaseItem';
|
||||
import { cn, formatCompactNumber } from '@/lib/utils';
|
||||
|
||||
@@ -5,12 +5,12 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { PurchaseRequest } from '../types/shop.types';
|
||||
import type { BlobbonautProfile, StorageItem } from '@/lib/blobbi';
|
||||
import type { BlobbonautProfile, StorageItem } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbonautTags,
|
||||
createStorageTags,
|
||||
} from '@/lib/blobbi';
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { getShopItemById } from '../lib/blobbi-shop-items';
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* BlobbiAdultSvgRenderer — Pure SVG rendering component for adult Blobbi.
|
||||
*
|
||||
* This component is the leaf node of the visual pipeline. It:
|
||||
* 1. Resolves the base SVG for the adult form
|
||||
* 2. Customizes colors and unique IDs
|
||||
* 3. Adds eye animation infrastructure (blink clip-paths, gaze groups)
|
||||
* 4. Applies visual recipe or emotion preset
|
||||
* 5. Applies manual body effects (when no recipe is provided)
|
||||
* 6. Sanitizes the SVG
|
||||
* 7. Renders via dangerouslySetInnerHTML
|
||||
*
|
||||
* It does NOT know about:
|
||||
* - Eye tracking hooks (useBlobbiEyes / useExternalEyeOffset)
|
||||
* - Render mode (page vs companion)
|
||||
* - Reaction CSS classes (sway / bounce)
|
||||
* - Companion runtime (drag, float, position)
|
||||
*
|
||||
* This separation ensures that the SVG DOM node stays mounted and stable
|
||||
* as long as the visual inputs don't change. SMIL and CSS animations
|
||||
* inside the SVG continue running across parent rerenders.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { resolveAdultSvgWithForm, customizeAdultSvgFromBlobbi } from '@/blobbi/adult-blobbi';
|
||||
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
|
||||
|
||||
import { addEyeAnimation } from './lib/eye-animation';
|
||||
import { resolveVisualRecipe, applyVisualRecipe, type BlobbiVisualRecipe } from './lib/recipe';
|
||||
import type { BlobbiEmotion } from './lib/emotion-types';
|
||||
import { applyBodyEffects, type BodyEffectsSpec } from './lib/bodyEffects';
|
||||
import { debugBlobbi } from './lib/debug';
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
|
||||
export interface BlobbiAdultSvgRendererProps {
|
||||
/** The Blobbi data */
|
||||
blobbi: Blobbi;
|
||||
/** Whether the Blobbi is sleeping */
|
||||
isSleeping: boolean;
|
||||
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
|
||||
recipe?: BlobbiVisualRecipe;
|
||||
/** Label for the recipe (used in CSS class names). */
|
||||
recipeLabel?: string;
|
||||
/** Named emotion preset. Ignored when `recipe` is provided. Default: 'neutral' */
|
||||
emotion?: BlobbiEmotion;
|
||||
/** Body-level visual effects (manual/external use only — not from status reaction). */
|
||||
bodyEffects?: BodyEffectsSpec;
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure SVG renderer for adult Blobbi.
|
||||
*
|
||||
* IMPORTANT: This component must remain a pure rendering leaf. It must NOT:
|
||||
* - Run eye-tracking hooks (those belong in the Visual wrapper)
|
||||
* - Know about render modes or companion runtime
|
||||
* - Apply reaction CSS classes (those belong on an outer wrapper)
|
||||
*
|
||||
* The parent Visual wrapper owns the DOM query boundary (containerRef)
|
||||
* that eye hooks use to find SVG elements via querySelector.
|
||||
*/
|
||||
export function BlobbiAdultSvgRenderer({
|
||||
blobbi,
|
||||
isSleeping: _isSleeping,
|
||||
recipe: recipeProp,
|
||||
recipeLabel,
|
||||
emotion = 'neutral',
|
||||
bodyEffects,
|
||||
className,
|
||||
}: BlobbiAdultSvgRendererProps) {
|
||||
const customizedSvg = useMemo(() => {
|
||||
debugBlobbi('svg-rebuild', 'adult customizedSvg rebuild');
|
||||
|
||||
// Always use the base (awake) SVG — sleeping is a recipe overlay, not an asset swap
|
||||
const { form, svg } = resolveAdultSvgWithForm(blobbi, { isSleeping: false });
|
||||
const colorizedSvg = customizeAdultSvgFromBlobbi(svg, form, blobbi, false);
|
||||
|
||||
let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id });
|
||||
|
||||
if (recipeProp) {
|
||||
animatedSvg = applyVisualRecipe(animatedSvg, recipeProp, recipeLabel ?? 'status', 'adult', form, blobbi.id);
|
||||
} else if (emotion !== 'neutral') {
|
||||
const resolved = resolveVisualRecipe(emotion);
|
||||
animatedSvg = applyVisualRecipe(animatedSvg, resolved, emotion, 'adult', form, blobbi.id);
|
||||
}
|
||||
|
||||
if (bodyEffects && !recipeProp) {
|
||||
animatedSvg = applyBodyEffects(animatedSvg, { ...bodyEffects, idPrefix: bodyEffects.idPrefix ?? blobbi.id });
|
||||
}
|
||||
|
||||
return animatedSvg;
|
||||
}, [blobbi, recipeProp, recipeLabel, emotion, bodyEffects]);
|
||||
|
||||
const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: safeSvg }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,162 +1,134 @@
|
||||
/**
|
||||
* BlobbiAdultVisual - Reusable component for rendering Blobbi adults
|
||||
* BlobbiAdultVisual — Visual wrapper for rendering Blobbi adults.
|
||||
*
|
||||
* Uses the adult-blobbi module for SVG resolution and customization.
|
||||
* Handles awake vs sleeping states automatically.
|
||||
* Supports multiple adult evolution forms.
|
||||
* Eyes always track the mouse cursor in real-time.
|
||||
* Responsibilities:
|
||||
* - Owns the container ref for eye hooks to query SVG DOM
|
||||
* - Runs useBlobbiEyes (blink RAF loop, optional mouse tracking)
|
||||
* - Runs useExternalEyeOffset (companion gaze RAF loop)
|
||||
* - Applies reaction CSS classes (sway/bounce) in page mode
|
||||
* - Delegates SVG rendering to BlobbiAdultSvgRenderer
|
||||
*
|
||||
* The SVG renderer is a separate component so the dangerouslySetInnerHTML
|
||||
* node stays mounted even when wrapper-level props change (reaction,
|
||||
* className toggles, etc.).
|
||||
*
|
||||
* Render modes:
|
||||
* - 'page' (default): Mouse tracking enabled, reaction classes applied here.
|
||||
* - 'companion': Mouse tracking disabled (gaze via ref), reaction classes
|
||||
* suppressed (applied by outer companion wrapper instead).
|
||||
*/
|
||||
|
||||
import { useMemo, useRef, useEffect } from 'react';
|
||||
import { useRef, type RefObject } from 'react';
|
||||
|
||||
import { resolveAdultSvgWithForm, customizeAdultSvgFromBlobbi } from '@/blobbi/adult-blobbi';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { addEyeAnimation } from './lib/eye-animation';
|
||||
import { applyEmotion, type BlobbiEmotion } from './lib/emotions';
|
||||
import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes';
|
||||
import type { Blobbi } from '@/types/blobbi';
|
||||
import { isBlobbiSleeping } from '@/types/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reaction states for adult Blobbi animations
|
||||
*/
|
||||
export type AdultReactionState = 'idle' | 'listening' | 'swaying' | 'singing' | 'happy';
|
||||
|
||||
/**
|
||||
* External eye offset for companion control
|
||||
* Values range from -1 to 1, converted to pixel movement internally
|
||||
*/
|
||||
export interface ExternalEyeOffset {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
import { useExternalEyeOffset } from './lib/useExternalEyeOffset';
|
||||
import type { ExternalEyeOffset, BlobbiReactionState, BlobbiRenderMode } from './lib/types';
|
||||
import type { BlobbiVisualRecipe } from './lib/recipe';
|
||||
import type { BlobbiEmotion } from './lib/emotion-types';
|
||||
import type { BodyEffectsSpec } from './lib/bodyEffects';
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import { isBlobbiSleeping } from '@/blobbi/core/types/blobbi';
|
||||
import { BlobbiAdultSvgRenderer } from './BlobbiAdultSvgRenderer';
|
||||
|
||||
export interface BlobbiAdultVisualProps {
|
||||
/** The Blobbi data */
|
||||
blobbi: Blobbi;
|
||||
/** Reaction state for music/sing animations */
|
||||
reaction?: AdultReactionState;
|
||||
reaction?: BlobbiReactionState;
|
||||
/** Controls eye tracking behavior (default: 'follow-pointer') */
|
||||
lookMode?: BlobbiLookMode;
|
||||
/** Disable blinking animation (for photo/export mode) */
|
||||
disableBlink?: boolean;
|
||||
/**
|
||||
* External eye offset from companion system.
|
||||
* When provided, bypasses internal mouse tracking and uses this offset directly.
|
||||
* Values should be -1 to 1, will be converted to pixel movement.
|
||||
*/
|
||||
/** External eye offset (value-based — causes rerenders). */
|
||||
externalEyeOffset?: ExternalEyeOffset;
|
||||
/**
|
||||
* Emotional state to display.
|
||||
* Adds visual overlays like eyebrows, modified mouth, and tears.
|
||||
* Default: 'neutral' (no modifications)
|
||||
*/
|
||||
/** Ref-based external eye offset (imperative — no rerenders). Preferred for companion mode. */
|
||||
externalEyeOffsetRef?: RefObject<ExternalEyeOffset>;
|
||||
/** Render mode. Default: 'page'. */
|
||||
renderMode?: BlobbiRenderMode;
|
||||
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
|
||||
recipe?: BlobbiVisualRecipe;
|
||||
/** Label for the recipe (used in CSS class names). */
|
||||
recipeLabel?: string;
|
||||
/** Named emotion preset. Ignored when `recipe` is provided. Default: 'neutral' */
|
||||
emotion?: BlobbiEmotion;
|
||||
/** Body-level visual effects (manual/external use only). */
|
||||
bodyEffects?: BodyEffectsSpec;
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renders an adult Blobbi using inline SVG.
|
||||
*
|
||||
* - Resolves the correct form from blobbi data (evolutionForm or seed-derived)
|
||||
* - Selects the correct SVG variant (awake or sleeping) based on state
|
||||
* - Applies color customization from Blobbi traits
|
||||
* - Eyes always track the mouse cursor (instant, real-time)
|
||||
* - Renders safely using dangerouslySetInnerHTML
|
||||
*/
|
||||
export function BlobbiAdultVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', disableBlink = false, externalEyeOffset, emotion = 'neutral', className }: BlobbiAdultVisualProps) {
|
||||
export function BlobbiAdultVisual({
|
||||
blobbi,
|
||||
reaction = 'idle',
|
||||
lookMode = 'follow-pointer',
|
||||
disableBlink = false,
|
||||
externalEyeOffset,
|
||||
externalEyeOffsetRef,
|
||||
renderMode = 'page',
|
||||
recipe,
|
||||
recipeLabel,
|
||||
emotion = 'neutral',
|
||||
bodyEffects,
|
||||
className,
|
||||
}: BlobbiAdultVisualProps) {
|
||||
const isSleeping = isBlobbiSleeping(blobbi);
|
||||
|
||||
// This ref is the DOM query boundary for eye hooks. useBlobbiEyes and
|
||||
// useExternalEyeOffset use querySelector on this element to find SVG
|
||||
// eye elements rendered by the child SvgRenderer.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Disable reactions when sleeping
|
||||
const isCompanion = renderMode === 'companion';
|
||||
|
||||
const effectiveReaction = isSleeping ? 'idle' : reaction;
|
||||
|
||||
// Eye animation hook - handles DOM manipulation internally
|
||||
// When externalEyeOffset is provided, we disable tracking but keep blinking
|
||||
// ── Eye hooks ──────────────────────────────────────────────────────────────
|
||||
|
||||
useBlobbiEyes(containerRef, {
|
||||
isSleeping,
|
||||
maxMovement: 2.5, // Slightly more movement for larger adult form
|
||||
maxMovement: 2.5,
|
||||
lookMode,
|
||||
disableBlink,
|
||||
disableTracking: !!externalEyeOffset, // External system controls eye position
|
||||
disableTracking: isCompanion,
|
||||
});
|
||||
|
||||
// External eye offset control - applies offset directly when provided
|
||||
// This bypasses useBlobbiEyes and gives companion full control
|
||||
useEffect(() => {
|
||||
if (!externalEyeOffset || !containerRef.current || isSleeping) return;
|
||||
useExternalEyeOffset({
|
||||
containerRef,
|
||||
externalEyeOffset,
|
||||
externalEyeOffsetRef,
|
||||
isSleeping,
|
||||
variant: 'adult',
|
||||
});
|
||||
|
||||
const eyeElements = containerRef.current.querySelectorAll<SVGGElement>('.blobbi-eye-left, .blobbi-eye-right');
|
||||
if (eyeElements.length === 0) return;
|
||||
|
||||
// Convert -1 to 1 offset to pixel movement
|
||||
// Increased max movement for more visible eye tracking (4.5px horizontal for adults)
|
||||
const maxMovementX = 4.5;
|
||||
const x = externalEyeOffset.x * maxMovementX;
|
||||
|
||||
// Asymmetric vertical movement:
|
||||
// - Upward (negative y): stronger movement (1.0x) for clear "looking up" effect
|
||||
// - Downward (positive y): reduced movement (0.6x) to avoid looking too droopy
|
||||
// Y offset: -1 = looking up, +1 = looking down
|
||||
const maxMovementYUp = 4.5; // Full range for looking up
|
||||
const maxMovementYDown = 2.7; // Reduced range for looking down (0.6x)
|
||||
const y = externalEyeOffset.y < 0
|
||||
? externalEyeOffset.y * maxMovementYUp // Looking up: full range
|
||||
: externalEyeOffset.y * maxMovementYDown; // Looking down: reduced range
|
||||
|
||||
eyeElements.forEach(el => {
|
||||
el.setAttribute('transform', `translate(${x} ${y})`);
|
||||
});
|
||||
}, [externalEyeOffset, isSleeping]);
|
||||
|
||||
// Memoize the customized SVG to avoid unnecessary processing
|
||||
const customizedSvg = useMemo(() => {
|
||||
// Get form and base SVG
|
||||
const { form, svg } = resolveAdultSvgWithForm(blobbi, { isSleeping });
|
||||
|
||||
// Apply color customization
|
||||
const colorizedSvg = customizeAdultSvgFromBlobbi(svg, form, blobbi, isSleeping);
|
||||
|
||||
// Add eye animation wrappers when awake (eyes are closed when sleeping)
|
||||
if (!isSleeping) {
|
||||
// Pass base color for eyelid generation
|
||||
let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id });
|
||||
|
||||
// Apply emotion overlays (eyebrows, sad mouth, tears, etc.)
|
||||
// Pass form for form-specific adjustments (e.g., owli/froggi eyebrow positioning)
|
||||
if (emotion !== 'neutral') {
|
||||
animatedSvg = applyEmotion(animatedSvg, emotion, 'adult', form);
|
||||
}
|
||||
|
||||
return animatedSvg;
|
||||
}
|
||||
|
||||
return colorizedSvg;
|
||||
}, [blobbi, isSleeping, emotion]);
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
// In companion mode, reaction classes are applied by an outer wrapper to
|
||||
// keep the dangerouslySetInnerHTML div className-stable.
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative flex items-center justify-center',
|
||||
// Reduced opacity when sleeping for visual feedback
|
||||
isSleeping && 'opacity-70',
|
||||
// Reaction animations for adult
|
||||
(effectiveReaction === 'listening' ||
|
||||
// No opacity change for sleeping — sleeping is a recipe overlay, not a visual dim
|
||||
!isCompanion && (effectiveReaction === 'listening' ||
|
||||
effectiveReaction === 'swaying' ||
|
||||
effectiveReaction === 'happy') &&
|
||||
'animate-blobbi-sway',
|
||||
effectiveReaction === 'singing' && 'animate-blobbi-bounce',
|
||||
className
|
||||
!isCompanion && effectiveReaction === 'singing' && 'animate-blobbi-bounce',
|
||||
className,
|
||||
)}
|
||||
// Safe: SVG content comes from our own trusted module
|
||||
dangerouslySetInnerHTML={{ __html: customizedSvg }}
|
||||
/>
|
||||
>
|
||||
<BlobbiAdultSvgRenderer
|
||||
blobbi={blobbi}
|
||||
isSleeping={isSleeping}
|
||||
recipe={recipe}
|
||||
recipeLabel={recipeLabel}
|
||||
emotion={emotion}
|
||||
bodyEffects={bodyEffects}
|
||||
className="size-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* BlobbiBabySvgRenderer — Pure SVG rendering component for baby Blobbi.
|
||||
*
|
||||
* This component is the leaf node of the visual pipeline. It:
|
||||
* 1. Resolves the base SVG for the baby
|
||||
* 2. Customizes colors and unique IDs
|
||||
* 3. Adds eye animation infrastructure (blink clip-paths, gaze groups)
|
||||
* 4. Applies visual recipe or emotion preset
|
||||
* 5. Applies manual body effects (when no recipe is provided)
|
||||
* 6. Sanitizes the SVG
|
||||
* 7. Renders via dangerouslySetInnerHTML
|
||||
*
|
||||
* It does NOT know about:
|
||||
* - Eye tracking hooks (useBlobbiEyes / useExternalEyeOffset)
|
||||
* - Render mode (page vs companion)
|
||||
* - Reaction CSS classes (sway / bounce)
|
||||
* - Companion runtime (drag, float, position)
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { resolveBabySvg, customizeBabySvgFromBlobbi } from '@/blobbi/baby-blobbi';
|
||||
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
|
||||
|
||||
import { addEyeAnimation } from './lib/eye-animation';
|
||||
import { resolveVisualRecipe, applyVisualRecipe, type BlobbiVisualRecipe } from './lib/recipe';
|
||||
import type { BlobbiEmotion } from './lib/emotion-types';
|
||||
import { applyBodyEffects, type BodyEffectsSpec } from './lib/bodyEffects';
|
||||
import { debugBlobbi } from './lib/debug';
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
|
||||
export interface BlobbiBabySvgRendererProps {
|
||||
/** The Blobbi data */
|
||||
blobbi: Blobbi;
|
||||
/** Whether the Blobbi is sleeping */
|
||||
isSleeping: boolean;
|
||||
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
|
||||
recipe?: BlobbiVisualRecipe;
|
||||
/** Label for the recipe (used in CSS class names). */
|
||||
recipeLabel?: string;
|
||||
/** Named emotion preset. Ignored when `recipe` is provided. Default: 'neutral' */
|
||||
emotion?: BlobbiEmotion;
|
||||
/** Body-level visual effects (manual/external use only — not from status reaction). */
|
||||
bodyEffects?: BodyEffectsSpec;
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure SVG renderer for baby Blobbi.
|
||||
*
|
||||
* IMPORTANT: This component must remain a pure rendering leaf. It must NOT:
|
||||
* - Run eye-tracking hooks (those belong in the Visual wrapper)
|
||||
* - Know about render modes or companion runtime
|
||||
* - Apply reaction CSS classes (those belong on an outer wrapper)
|
||||
*
|
||||
* The parent Visual wrapper owns the DOM query boundary (containerRef)
|
||||
* that eye hooks use to find SVG elements via querySelector.
|
||||
*/
|
||||
export function BlobbiBabySvgRenderer({
|
||||
blobbi,
|
||||
isSleeping: _isSleeping,
|
||||
recipe: recipeProp,
|
||||
recipeLabel,
|
||||
emotion = 'neutral',
|
||||
bodyEffects,
|
||||
className,
|
||||
}: BlobbiBabySvgRendererProps) {
|
||||
const customizedSvg = useMemo(() => {
|
||||
debugBlobbi('svg-rebuild', 'baby customizedSvg rebuild');
|
||||
|
||||
// Always use the base (awake) SVG — sleeping is a recipe overlay, not an asset swap
|
||||
const baseSvg = resolveBabySvg(blobbi, { isSleeping: false });
|
||||
const colorizedSvg = customizeBabySvgFromBlobbi(baseSvg, blobbi, false);
|
||||
|
||||
let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id });
|
||||
|
||||
if (recipeProp) {
|
||||
animatedSvg = applyVisualRecipe(animatedSvg, recipeProp, recipeLabel ?? 'status', 'baby', undefined, blobbi.id);
|
||||
} else if (emotion !== 'neutral') {
|
||||
const resolved = resolveVisualRecipe(emotion);
|
||||
animatedSvg = applyVisualRecipe(animatedSvg, resolved, emotion, 'baby', undefined, blobbi.id);
|
||||
}
|
||||
|
||||
if (bodyEffects && !recipeProp) {
|
||||
animatedSvg = applyBodyEffects(animatedSvg, { ...bodyEffects, idPrefix: bodyEffects.idPrefix ?? blobbi.id });
|
||||
}
|
||||
|
||||
return animatedSvg;
|
||||
}, [blobbi, recipeProp, recipeLabel, emotion, bodyEffects]);
|
||||
|
||||
const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: safeSvg }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +1,120 @@
|
||||
/**
|
||||
* BlobbiBabyVisual - Reusable component for rendering Blobbi babies
|
||||
* BlobbiBabyVisual — Visual wrapper for rendering Blobbi babies.
|
||||
*
|
||||
* Uses the baby-blobbi module for SVG resolution and customization.
|
||||
* Handles awake vs sleeping states automatically.
|
||||
* Eyes always track the mouse cursor in real-time.
|
||||
* Responsibilities:
|
||||
* - Owns the container ref for eye hooks to query SVG DOM
|
||||
* - Runs useBlobbiEyes (blink RAF loop, optional mouse tracking)
|
||||
* - Runs useExternalEyeOffset (companion gaze RAF loop)
|
||||
* - Applies reaction CSS classes (sway/bounce) in page mode
|
||||
* - Delegates SVG rendering to BlobbiBabySvgRenderer
|
||||
*
|
||||
* Render modes:
|
||||
* - 'page' (default): Mouse tracking enabled, reaction classes applied here.
|
||||
* - 'companion': Mouse tracking disabled (gaze via ref), reaction classes
|
||||
* suppressed (applied by outer companion wrapper instead).
|
||||
*/
|
||||
|
||||
import { useMemo, useRef, useEffect } from 'react';
|
||||
import { useRef, type RefObject } from 'react';
|
||||
|
||||
import { resolveBabySvg, customizeBabySvgFromBlobbi } from '@/blobbi/baby-blobbi';
|
||||
import { addEyeAnimation } from './lib/eye-animation';
|
||||
import { applyEmotion, type BlobbiEmotion } from './lib/emotions';
|
||||
import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Blobbi } from '@/types/blobbi';
|
||||
import { isBlobbiSleeping } from '@/types/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reaction states for baby Blobbi animations
|
||||
*/
|
||||
export type BabyReactionState = 'idle' | 'listening' | 'swaying' | 'singing' | 'happy';
|
||||
|
||||
/**
|
||||
* External eye offset for companion control
|
||||
* Values range from -1 to 1, converted to pixel movement internally
|
||||
*/
|
||||
export interface ExternalEyeOffset {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes';
|
||||
import { useExternalEyeOffset } from './lib/useExternalEyeOffset';
|
||||
import type { ExternalEyeOffset, BlobbiReactionState, BlobbiRenderMode } from './lib/types';
|
||||
import type { BlobbiVisualRecipe } from './lib/recipe';
|
||||
import type { BlobbiEmotion } from './lib/emotion-types';
|
||||
import type { BodyEffectsSpec } from './lib/bodyEffects';
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import { isBlobbiSleeping } from '@/blobbi/core/types/blobbi';
|
||||
import { BlobbiBabySvgRenderer } from './BlobbiBabySvgRenderer';
|
||||
|
||||
export interface BlobbiBabyVisualProps {
|
||||
/** The Blobbi data */
|
||||
blobbi: Blobbi;
|
||||
/** Reaction state for music/sing animations */
|
||||
reaction?: BabyReactionState;
|
||||
/** Controls eye tracking behavior (default: 'follow-pointer') */
|
||||
reaction?: BlobbiReactionState;
|
||||
lookMode?: BlobbiLookMode;
|
||||
/** Disable blinking animation (for photo/export mode) */
|
||||
disableBlink?: boolean;
|
||||
/**
|
||||
* External eye offset from companion system.
|
||||
* When provided, bypasses internal mouse tracking and uses this offset directly.
|
||||
* Values should be -1 to 1, will be converted to pixel movement.
|
||||
*/
|
||||
externalEyeOffset?: ExternalEyeOffset;
|
||||
/**
|
||||
* Emotional state to display.
|
||||
* Adds visual overlays like eyebrows, modified mouth, and tears.
|
||||
* Default: 'neutral' (no modifications)
|
||||
*/
|
||||
/** Ref-based external eye offset (imperative — no rerenders). Preferred for companion mode. */
|
||||
externalEyeOffsetRef?: RefObject<ExternalEyeOffset>;
|
||||
/** Render mode. Default: 'page'. */
|
||||
renderMode?: BlobbiRenderMode;
|
||||
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
|
||||
recipe?: BlobbiVisualRecipe;
|
||||
/** Label for the recipe (CSS class names). */
|
||||
recipeLabel?: string;
|
||||
/** Named emotion preset. Ignored when `recipe` is provided. */
|
||||
emotion?: BlobbiEmotion;
|
||||
/** Additional CSS classes for the container */
|
||||
/** Body-level visual effects — for manual/external use only. */
|
||||
bodyEffects?: BodyEffectsSpec;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renders a baby Blobbi using inline SVG.
|
||||
*
|
||||
* - Resolves the correct SVG (awake or sleeping) based on state
|
||||
* - Applies color customization from Blobbi traits
|
||||
* - Eyes always track the mouse cursor (instant, real-time)
|
||||
* - Renders safely using dangerouslySetInnerHTML
|
||||
*/
|
||||
export function BlobbiBabyVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', disableBlink = false, externalEyeOffset, emotion = 'neutral', className }: BlobbiBabyVisualProps) {
|
||||
export function BlobbiBabyVisual({
|
||||
blobbi,
|
||||
reaction = 'idle',
|
||||
lookMode = 'follow-pointer',
|
||||
disableBlink = false,
|
||||
externalEyeOffset,
|
||||
externalEyeOffsetRef,
|
||||
renderMode = 'page',
|
||||
recipe,
|
||||
recipeLabel,
|
||||
emotion = 'neutral',
|
||||
bodyEffects,
|
||||
className,
|
||||
}: BlobbiBabyVisualProps) {
|
||||
const isSleeping = isBlobbiSleeping(blobbi);
|
||||
|
||||
// DOM query boundary for eye hooks. See BlobbiAdultVisual for details.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Disable reactions when sleeping
|
||||
const isCompanion = renderMode === 'companion';
|
||||
|
||||
const effectiveReaction = isSleeping ? 'idle' : reaction;
|
||||
|
||||
// Eye animation hook - handles DOM manipulation internally
|
||||
// When externalEyeOffset is provided, we disable tracking but keep blinking
|
||||
// ── Eye hooks ──────────────────────────────────────────────────────────────
|
||||
|
||||
useBlobbiEyes(containerRef, {
|
||||
isSleeping,
|
||||
maxMovement: 2,
|
||||
lookMode,
|
||||
disableBlink,
|
||||
disableTracking: !!externalEyeOffset, // External system controls eye position
|
||||
disableTracking: isCompanion,
|
||||
});
|
||||
|
||||
// External eye offset control - applies offset directly when provided
|
||||
// This bypasses useBlobbiEyes and gives companion full control
|
||||
useEffect(() => {
|
||||
if (!externalEyeOffset || !containerRef.current || isSleeping) return;
|
||||
useExternalEyeOffset({
|
||||
containerRef,
|
||||
externalEyeOffset,
|
||||
externalEyeOffsetRef,
|
||||
isSleeping,
|
||||
variant: 'baby',
|
||||
});
|
||||
|
||||
const eyeElements = containerRef.current.querySelectorAll<SVGGElement>('.blobbi-eye-left, .blobbi-eye-right');
|
||||
if (eyeElements.length === 0) return;
|
||||
|
||||
// Convert -1 to 1 offset to pixel movement
|
||||
// Increased max movement for more visible eye tracking (4px horizontal)
|
||||
const maxMovementX = 4;
|
||||
const x = externalEyeOffset.x * maxMovementX;
|
||||
|
||||
// Asymmetric vertical movement:
|
||||
// - Upward (negative y): stronger movement (1.0x) for clear "looking up" effect
|
||||
// - Downward (positive y): reduced movement (0.6x) to avoid looking too droopy
|
||||
// Y offset: -1 = looking up, +1 = looking down
|
||||
const maxMovementYUp = 4; // Full range for looking up
|
||||
const maxMovementYDown = 2.4; // Reduced range for looking down (0.6x)
|
||||
const y = externalEyeOffset.y < 0
|
||||
? externalEyeOffset.y * maxMovementYUp // Looking up: full range
|
||||
: externalEyeOffset.y * maxMovementYDown; // Looking down: reduced range
|
||||
|
||||
eyeElements.forEach(el => {
|
||||
el.setAttribute('transform', `translate(${x} ${y})`);
|
||||
});
|
||||
}, [externalEyeOffset, isSleeping]);
|
||||
|
||||
// Memoize the customized SVG to avoid unnecessary processing
|
||||
const customizedSvg = useMemo(() => {
|
||||
const baseSvg = resolveBabySvg(blobbi, { isSleeping });
|
||||
const colorizedSvg = customizeBabySvgFromBlobbi(baseSvg, blobbi, isSleeping);
|
||||
|
||||
// Add eye animation wrappers (only when not sleeping)
|
||||
if (!isSleeping) {
|
||||
// Pass base color for eyelid generation
|
||||
let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id });
|
||||
|
||||
// Apply emotion overlays (eyebrows, sad mouth, tears, etc.)
|
||||
// Pass 'baby' variant for baby-specific adjustments (e.g., eyebrow positioning)
|
||||
if (emotion !== 'neutral') {
|
||||
animatedSvg = applyEmotion(animatedSvg, emotion, 'baby');
|
||||
}
|
||||
|
||||
return animatedSvg;
|
||||
}
|
||||
|
||||
return colorizedSvg;
|
||||
}, [blobbi, isSleeping, emotion]);
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative flex items-center justify-center',
|
||||
// Reduced opacity when sleeping for visual feedback
|
||||
isSleeping && 'opacity-70',
|
||||
// Reaction animations for baby
|
||||
(effectiveReaction === 'listening' ||
|
||||
// No opacity change for sleeping — sleeping is a recipe overlay, not a visual dim
|
||||
!isCompanion && (effectiveReaction === 'listening' ||
|
||||
effectiveReaction === 'swaying' ||
|
||||
effectiveReaction === 'happy') &&
|
||||
'animate-blobbi-sway',
|
||||
effectiveReaction === 'singing' && 'animate-blobbi-bounce',
|
||||
className
|
||||
!isCompanion && effectiveReaction === 'singing' && 'animate-blobbi-bounce',
|
||||
className,
|
||||
)}
|
||||
// Safe: SVG content comes from our own trusted module
|
||||
dangerouslySetInnerHTML={{ __html: customizedSvg }}
|
||||
/>
|
||||
>
|
||||
<BlobbiBabySvgRenderer
|
||||
blobbi={blobbi}
|
||||
isSleeping={isSleeping}
|
||||
recipe={recipe}
|
||||
recipeLabel={recipeLabel}
|
||||
emotion={emotion}
|
||||
bodyEffects={bodyEffects}
|
||||
className="size-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,17 +13,17 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { EggGraphic, type EggReactionState } from '@/blobbi/egg';
|
||||
import { toEggGraphicVisualBlobbi } from '@/lib/blobbi-egg-adapter';
|
||||
import { EggGraphic, type EggReactionState, type EggStatusEffects } from '@/blobbi/egg';
|
||||
import { toEggGraphicVisualBlobbi } from '@/blobbi/core/lib/blobbi-egg-adapter';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type BlobbiEggSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
// Re-export for convenience
|
||||
export type { EggReactionState } from '@/blobbi/egg';
|
||||
export type { EggReactionState, EggStatusEffects } from '@/blobbi/egg';
|
||||
|
||||
export interface BlobbiEggVisualProps {
|
||||
/** The Blobbi companion data from parseBlobbiEvent */
|
||||
@@ -34,6 +34,8 @@ export interface BlobbiEggVisualProps {
|
||||
animated?: boolean;
|
||||
/** Reaction state for music/sing animations */
|
||||
reaction?: EggReactionState;
|
||||
/** Status effects for egg visual feedback (dirty, sick, happy) */
|
||||
statusEffects?: EggStatusEffects;
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string;
|
||||
}
|
||||
@@ -67,6 +69,7 @@ export function BlobbiEggVisual({
|
||||
size = 'md',
|
||||
animated = false,
|
||||
reaction = 'idle',
|
||||
statusEffects,
|
||||
className,
|
||||
}: BlobbiEggVisualProps) {
|
||||
// Memoize adapter output to avoid unnecessary re-renders
|
||||
@@ -99,6 +102,7 @@ export function BlobbiEggVisual({
|
||||
sizeVariant={config.sizeVariant}
|
||||
animated={animated && !isSleeping}
|
||||
reaction={effectiveReaction}
|
||||
statusEffects={isSleeping ? undefined : statusEffects}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { trackDailyMissionProgress } from '@/blobbi/actions';
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { forwardRef } from 'react';
|
||||
|
||||
import { BlobbiStageVisual } from './BlobbiStageVisual';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2,24 +2,27 @@
|
||||
* BlobbiStageVisual - Stage-aware visual component for Blobbi
|
||||
*
|
||||
* Routes to the appropriate visual component based on the Blobbi's life stage:
|
||||
* - egg → BlobbiEggVisual
|
||||
* - baby → BlobbiBabyVisual
|
||||
* - adult → BlobbiAdultVisual
|
||||
* - egg → BlobbiEggVisual
|
||||
* - baby → BlobbiBabyVisual
|
||||
* - adult → BlobbiAdultVisual
|
||||
*
|
||||
* This component is the single entry point for rendering any Blobbi visually.
|
||||
* It passes through visual recipe props to the stage-specific components.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { BlobbiEggVisual, type BlobbiEggSize } from './BlobbiEggVisual';
|
||||
import { BlobbiEggVisual, type BlobbiEggSize, type EggStatusEffects } from './BlobbiEggVisual';
|
||||
import { BlobbiBabyVisual } from './BlobbiBabyVisual';
|
||||
import { BlobbiAdultVisual } from './BlobbiAdultVisual';
|
||||
import { FloatingMusicNotes } from './FloatingMusicNotes';
|
||||
import { blobbiCompanionToBlobbi } from './lib/adapters';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BlobbiCompanion } from '@/lib/blobbi';
|
||||
import type { Blobbi } from '@/types/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbiLookMode } from './lib/useBlobbiEyes';
|
||||
import type { BlobbiEmotion } from './lib/emotions';
|
||||
import type { BlobbiEmotion } from './lib/emotion-types';
|
||||
import type { BlobbiVisualRecipe } from './lib/recipe';
|
||||
import type { BodyEffectsSpec } from './lib/bodyEffects';
|
||||
|
||||
export type { BlobbiLookMode };
|
||||
|
||||
@@ -27,96 +30,39 @@ export type { BlobbiLookMode };
|
||||
|
||||
export type BlobbiVisualSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* Reaction states for all Blobbi stages.
|
||||
* Controls music/sing dance animations.
|
||||
*/
|
||||
export type BlobbiReaction = 'idle' | 'listening' | 'swaying' | 'singing' | 'happy';
|
||||
|
||||
export interface BlobbiStageVisualProps {
|
||||
/** The Blobbi companion data from parseBlobbiEvent */
|
||||
companion: BlobbiCompanion;
|
||||
/** Size variant: sm (48px), md (96px), lg (160px) */
|
||||
size?: BlobbiVisualSize;
|
||||
/** Enable ambient animations (glow, particles) */
|
||||
animated?: boolean;
|
||||
/** Reaction state for music/sing animations */
|
||||
reaction?: BlobbiReaction;
|
||||
/** Controls eye tracking behavior (default: 'follow-pointer') */
|
||||
lookMode?: BlobbiLookMode;
|
||||
/** Disable blinking animation (for photo/export mode) */
|
||||
disableBlink?: boolean;
|
||||
/**
|
||||
* Emotional state to display.
|
||||
* Adds visual overlays like eyebrows, modified mouth, and tears.
|
||||
* Default: 'neutral' (no modifications)
|
||||
*/
|
||||
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
|
||||
recipe?: BlobbiVisualRecipe;
|
||||
/** Label for the recipe (CSS class names). Required when `recipe` is provided. */
|
||||
recipeLabel?: string;
|
||||
/** Named emotion preset (convenience path). Ignored when `recipe` is provided. */
|
||||
emotion?: BlobbiEmotion;
|
||||
/** Additional CSS classes for the container */
|
||||
/**
|
||||
* Body-level visual effects — for manual/external use only.
|
||||
* Status-reaction body effects are already in the recipe.
|
||||
*/
|
||||
bodyEffects?: BodyEffectsSpec;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Size Configuration ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Container sizes for baby/adult stages.
|
||||
* Matches the egg visual sizing for consistency.
|
||||
*/
|
||||
const SIZE_CONFIG: Record<BlobbiVisualSize, string> = {
|
||||
sm: 'size-14',
|
||||
md: 'size-24',
|
||||
lg: 'size-40',
|
||||
};
|
||||
|
||||
// ─── Adapter ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Converts BlobbiCompanion to the Blobbi type for baby/adult rendering.
|
||||
*
|
||||
* This is a minimal adapter that extracts only the fields needed
|
||||
* by BlobbiBabyVisual and BlobbiAdultVisual. It does not perform a full conversion.
|
||||
*/
|
||||
function toBlobbiForVisual(companion: BlobbiCompanion): Blobbi {
|
||||
return {
|
||||
id: companion.d,
|
||||
name: companion.name,
|
||||
lifeStage: companion.stage,
|
||||
state: companion.state,
|
||||
isSleeping: companion.state === 'sleeping',
|
||||
stats: {
|
||||
hunger: companion.stats.hunger ?? 100,
|
||||
happiness: companion.stats.happiness ?? 100,
|
||||
health: companion.stats.health ?? 100,
|
||||
hygiene: companion.stats.hygiene ?? 100,
|
||||
energy: companion.stats.energy ?? 100,
|
||||
},
|
||||
// Visual traits
|
||||
baseColor: companion.visualTraits.baseColor,
|
||||
secondaryColor: companion.visualTraits.secondaryColor,
|
||||
eyeColor: companion.visualTraits.eyeColor,
|
||||
pattern: companion.visualTraits.pattern,
|
||||
specialMark: companion.visualTraits.specialMark,
|
||||
size: companion.visualTraits.size,
|
||||
// Metadata
|
||||
seed: companion.seed,
|
||||
tags: companion.allTags,
|
||||
// Adult-specific data (for adult form resolution)
|
||||
adult: companion.adultType ? { evolutionForm: companion.adultType } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renders a Blobbi visual based on its current life stage.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Stage routing (egg/baby/adult)
|
||||
* - Size and container management
|
||||
*
|
||||
* Does NOT handle:
|
||||
* - Individual stage rendering logic (delegated to stage-specific components)
|
||||
*/
|
||||
export function BlobbiStageVisual({
|
||||
companion,
|
||||
size = 'md',
|
||||
@@ -124,29 +70,37 @@ export function BlobbiStageVisual({
|
||||
reaction = 'idle',
|
||||
lookMode = 'follow-pointer',
|
||||
disableBlink = false,
|
||||
recipe,
|
||||
recipeLabel,
|
||||
emotion = 'neutral',
|
||||
bodyEffects,
|
||||
className,
|
||||
}: BlobbiStageVisualProps) {
|
||||
const { stage } = companion;
|
||||
const isSleeping = companion.state === 'sleeping';
|
||||
|
||||
// Disable reactions when sleeping
|
||||
|
||||
const effectiveReaction = isSleeping ? 'idle' : reaction;
|
||||
|
||||
// Convert to Blobbi for baby/adult rendering (memoized)
|
||||
const blobbiForVisual = useMemo(
|
||||
() => (stage === 'baby' || stage === 'adult' ? toBlobbiForVisual(companion) : null),
|
||||
() => (stage === 'baby' || stage === 'adult' ? blobbiCompanionToBlobbi(companion) : null),
|
||||
[companion, stage]
|
||||
);
|
||||
|
||||
// Show music notes when listening to music
|
||||
const showMusicNotes = effectiveReaction === 'listening';
|
||||
|
||||
// Container size class (shared across all stages)
|
||||
const containerClass = SIZE_CONFIG[size];
|
||||
|
||||
// Egg stage
|
||||
if (stage === 'egg') {
|
||||
// Derive egg status effects from the recipe
|
||||
// Eggs don't have faces, so we translate recipe parts to egg-specific effects
|
||||
const eggStatusEffects: EggStatusEffects | undefined = recipe ? {
|
||||
// Dirty: hygiene-related body effects
|
||||
dirty: Boolean(recipe.bodyEffects?.dirtMarks?.enabled || recipe.bodyEffects?.stinkClouds?.enabled),
|
||||
// Sick: health-critical dizzy eyes → floating spirals for egg
|
||||
sick: Boolean(recipe.eyes?.dizzySpirals),
|
||||
// Happy: positive reaction or explicit happy state (not sad/crying)
|
||||
happy: effectiveReaction === 'happy' && !recipe.extras?.tears?.enabled,
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<div className={cn('relative', containerClass, className)}>
|
||||
<BlobbiEggVisual
|
||||
@@ -154,6 +108,7 @@ export function BlobbiStageVisual({
|
||||
size={size as BlobbiEggSize}
|
||||
animated={animated}
|
||||
reaction={effectiveReaction}
|
||||
statusEffects={eggStatusEffects}
|
||||
className="size-full"
|
||||
/>
|
||||
<FloatingMusicNotes active={showMusicNotes} />
|
||||
@@ -161,7 +116,6 @@ export function BlobbiStageVisual({
|
||||
);
|
||||
}
|
||||
|
||||
// Baby stage
|
||||
if (stage === 'baby' && blobbiForVisual) {
|
||||
return (
|
||||
<div className={cn('relative', containerClass, className)}>
|
||||
@@ -170,7 +124,10 @@ export function BlobbiStageVisual({
|
||||
reaction={effectiveReaction}
|
||||
lookMode={lookMode}
|
||||
disableBlink={disableBlink}
|
||||
recipe={recipe}
|
||||
recipeLabel={recipeLabel}
|
||||
emotion={emotion}
|
||||
bodyEffects={bodyEffects}
|
||||
className="size-full"
|
||||
/>
|
||||
<FloatingMusicNotes active={showMusicNotes} />
|
||||
@@ -178,7 +135,6 @@ export function BlobbiStageVisual({
|
||||
);
|
||||
}
|
||||
|
||||
// Adult stage
|
||||
if (stage === 'adult' && blobbiForVisual) {
|
||||
return (
|
||||
<div className={cn('relative', containerClass, className)}>
|
||||
@@ -187,7 +143,10 @@ export function BlobbiStageVisual({
|
||||
reaction={effectiveReaction}
|
||||
lookMode={lookMode}
|
||||
disableBlink={disableBlink}
|
||||
recipe={recipe}
|
||||
recipeLabel={recipeLabel}
|
||||
emotion={emotion}
|
||||
bodyEffects={bodyEffects}
|
||||
className="size-full"
|
||||
/>
|
||||
<FloatingMusicNotes active={showMusicNotes} />
|
||||
@@ -195,6 +154,5 @@ export function BlobbiStageVisual({
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for unknown stage (should not happen)
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* useStatusReaction Hook
|
||||
*
|
||||
* Manages automatic status-based visual reactions for Blobbi.
|
||||
* Resolves stats into a final BlobbiVisualRecipe that can be passed
|
||||
* straight to applyVisualRecipe() for rendering.
|
||||
*
|
||||
* The hook's output is recipe-first:
|
||||
* - `recipe`: the fully resolved visual recipe (includes body effects)
|
||||
* - `recipeLabel`: human-readable label for debugging / CSS class naming
|
||||
* - metadata: triggeringStat, severity, isOverrideActive
|
||||
*
|
||||
* Body effects (dirt marks, stink clouds) are folded into the recipe by
|
||||
* resolveStatusRecipe(). Consumers should NOT apply body effects separately
|
||||
* from the status reaction path — applyVisualRecipe() handles everything.
|
||||
*
|
||||
* Features:
|
||||
* - Periodic stat checks with configurable intervals
|
||||
* - Animation-aware state transitions (won't interrupt mid-animation)
|
||||
* - Override support for temporary action reactions
|
||||
* - Re-resolution on stat recovery (switches to next-priority reaction
|
||||
* rather than forcing neutral when only one stat recovers)
|
||||
* - Clean state management with proper cleanup
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import type { BlobbiEmotion } from '../lib/emotion-types';
|
||||
import type { BlobbiStats } from '@/blobbi/core/types/blobbi';
|
||||
import {
|
||||
resolveStatusRecipe,
|
||||
EMPTY_RECIPE,
|
||||
DEFAULT_TIMING,
|
||||
type StatusReactionTiming,
|
||||
type ReactiveStat,
|
||||
type StatSeverity,
|
||||
type StatusRecipeResult,
|
||||
} from '../lib/status-reactions';
|
||||
import { resolveVisualRecipe, type BlobbiVisualRecipe } from '../lib/recipe';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseStatusReactionOptions {
|
||||
/** Current Blobbi stats */
|
||||
stats: BlobbiStats;
|
||||
/** Whether the system is enabled (disable during sleep, etc.) */
|
||||
enabled?: boolean;
|
||||
/** Timing configuration override */
|
||||
timing?: Partial<StatusReactionTiming>;
|
||||
/** Temporary override emotion (from actions like eating, playing, etc.) */
|
||||
actionOverride?: BlobbiEmotion | null;
|
||||
}
|
||||
|
||||
export interface StatusReactionState {
|
||||
/** The fully resolved visual recipe to render (includes body effects) */
|
||||
recipe: BlobbiVisualRecipe;
|
||||
/** Human-readable label for the recipe (for CSS classes, debugging) */
|
||||
recipeLabel: string;
|
||||
/** Whether any status reaction is actively showing */
|
||||
isStatusReactionActive: boolean;
|
||||
/** The stat that triggered the current recipe (if any) */
|
||||
triggeringStat: ReactiveStat | null;
|
||||
/** Severity of the highest-priority active reaction */
|
||||
currentSeverity: StatSeverity | null;
|
||||
/** Whether an action override is active */
|
||||
isOverrideActive: boolean;
|
||||
}
|
||||
|
||||
// ─── Animation Cycle Durations ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Minimum animation cycle durations keyed by recipe label.
|
||||
* Used to determine when it's safe to switch reactions without cutting animations.
|
||||
*/
|
||||
const LABEL_CYCLE_DURATIONS: Record<string, number> = {
|
||||
// Status reaction labels (from STAT_LABEL_MAP)
|
||||
sleepy: 8000,
|
||||
sick: 4000,
|
||||
hungry: 4000,
|
||||
dirty: 3000,
|
||||
sad: 6000,
|
||||
// Action override / emotion preset labels
|
||||
dizzy: 2000,
|
||||
boring: 3000,
|
||||
angry: 2000,
|
||||
surprised: 1000,
|
||||
curious: 1000,
|
||||
excited: 1500,
|
||||
excitedB: 1500,
|
||||
mischievous: 1500,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the minimum animation cycle duration for a recipe label.
|
||||
*
|
||||
* For merged labels like "boring-sleepy" or "hungry-sleepy", computes the
|
||||
* maximum matching duration among all matching parts. This ensures merged
|
||||
* recipes respect the longest animation cycle among their components.
|
||||
*/
|
||||
function getRecipeCycleDuration(label: string): number {
|
||||
const matches = Object.entries(LABEL_CYCLE_DURATIONS)
|
||||
.filter(([key]) => label.includes(key))
|
||||
.map(([, duration]) => duration);
|
||||
|
||||
return matches.length > 0 ? Math.max(...matches) : 2000;
|
||||
}
|
||||
|
||||
// ─── Internal State ───────────────────────────────────────────────────────────
|
||||
|
||||
interface InternalState {
|
||||
checkTimer: ReturnType<typeof setTimeout> | null;
|
||||
recipeStartTime: number;
|
||||
current: StatusRecipeResult;
|
||||
}
|
||||
|
||||
const NEUTRAL_RESULT: StatusRecipeResult = {
|
||||
recipe: EMPTY_RECIPE,
|
||||
label: 'neutral',
|
||||
triggeringStat: null,
|
||||
severity: null,
|
||||
};
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useStatusReaction({
|
||||
stats,
|
||||
enabled = true,
|
||||
timing: timingOverride,
|
||||
actionOverride,
|
||||
}: UseStatusReactionOptions): StatusReactionState {
|
||||
const timing: StatusReactionTiming = useMemo(() => ({
|
||||
...DEFAULT_TIMING,
|
||||
...timingOverride,
|
||||
cooldownMultipliers: {
|
||||
...DEFAULT_TIMING.cooldownMultipliers,
|
||||
...timingOverride?.cooldownMultipliers,
|
||||
},
|
||||
}), [timingOverride]);
|
||||
|
||||
const [currentResult, setCurrentResult] = useState<StatusRecipeResult>(NEUTRAL_RESULT);
|
||||
|
||||
const internalRef = useRef<InternalState>({
|
||||
checkTimer: null,
|
||||
recipeStartTime: 0,
|
||||
current: NEUTRAL_RESULT,
|
||||
});
|
||||
|
||||
const statsRef = useRef(stats);
|
||||
statsRef.current = stats;
|
||||
|
||||
const timingRef = useRef(timing);
|
||||
timingRef.current = timing;
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
const internal = internalRef.current;
|
||||
if (internal.checkTimer) {
|
||||
clearTimeout(internal.checkTimer);
|
||||
internal.checkTimer = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearState = useCallback(() => {
|
||||
internalRef.current.current = NEUTRAL_RESULT;
|
||||
setCurrentResult(NEUTRAL_RESULT);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Apply a resolved recipe, respecting animation safety.
|
||||
*
|
||||
* Transition rules:
|
||||
* - Same label → no change (recipe content may still update)
|
||||
* - To neutral → apply immediately
|
||||
* - From neutral → apply immediately
|
||||
* - Between non-neutral recipes → wait for current animation cycle to finish
|
||||
*/
|
||||
const applyResult = useCallback((resolved: StatusRecipeResult) => {
|
||||
const internal = internalRef.current;
|
||||
const now = Date.now();
|
||||
const prev = internal.current;
|
||||
|
||||
// Same label → no visual change needed
|
||||
if (resolved.label === prev.label) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Transitioning to neutral → apply immediately
|
||||
if (resolved.label === 'neutral') {
|
||||
internal.current = resolved;
|
||||
internal.recipeStartTime = now;
|
||||
setCurrentResult(resolved);
|
||||
return;
|
||||
}
|
||||
|
||||
// Transitioning from neutral → activate immediately
|
||||
if (prev.label === 'neutral') {
|
||||
internal.current = resolved;
|
||||
internal.recipeStartTime = now;
|
||||
setCurrentResult(resolved);
|
||||
return;
|
||||
}
|
||||
|
||||
// Switching between non-neutral recipes — check animation safety
|
||||
const elapsed = now - internal.recipeStartTime;
|
||||
const cycleDuration = getRecipeCycleDuration(prev.label);
|
||||
if (elapsed >= cycleDuration) {
|
||||
internal.current = resolved;
|
||||
internal.recipeStartTime = now;
|
||||
setCurrentResult(resolved);
|
||||
}
|
||||
// else: mid-animation, wait for next check
|
||||
}, []);
|
||||
|
||||
const checkStats = useCallback(() => {
|
||||
const currentStats = statsRef.current;
|
||||
const currentTiming = timingRef.current;
|
||||
const internal = internalRef.current;
|
||||
|
||||
const resolved = resolveStatusRecipe(currentStats);
|
||||
applyResult(resolved);
|
||||
|
||||
internal.checkTimer = setTimeout(checkStats, currentTiming.checkInterval);
|
||||
}, [applyResult]);
|
||||
|
||||
// Start/stop the check loop based on enabled state
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
clearTimers();
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial check — apply immediately
|
||||
const initial = resolveStatusRecipe(statsRef.current);
|
||||
const internal = internalRef.current;
|
||||
internal.current = initial;
|
||||
internal.recipeStartTime = Date.now();
|
||||
setCurrentResult(initial);
|
||||
|
||||
internal.checkTimer = setTimeout(checkStats, timingRef.current.checkInterval);
|
||||
|
||||
return () => {
|
||||
clearTimers();
|
||||
};
|
||||
}, [enabled, checkStats, clearTimers, clearState]);
|
||||
|
||||
// Re-resolve on stat changes.
|
||||
//
|
||||
// When stats change, always re-run resolveStatusRecipe() to get the
|
||||
// freshly resolved recipe. This handles the case where one stat recovers
|
||||
// but another is still low — instead of forcing neutral, we transition
|
||||
// to the recipe for the remaining low stat(s).
|
||||
//
|
||||
// Example: energy low + hunger low → merged sleepy/hungry recipe.
|
||||
// Energy recovers → re-resolve → hunger still low → hungry recipe.
|
||||
// Only goes neutral if resolveStatusRecipe() itself returns neutral.
|
||||
useEffect(() => {
|
||||
const fresh = resolveStatusRecipe(stats);
|
||||
applyResult(fresh);
|
||||
}, [stats, applyResult]);
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimers();
|
||||
};
|
||||
}, [clearTimers]);
|
||||
|
||||
// ── Determine final output ──
|
||||
const isOverrideActive = actionOverride !== null && actionOverride !== undefined;
|
||||
|
||||
let finalRecipe: BlobbiVisualRecipe;
|
||||
let finalLabel: string;
|
||||
|
||||
if (isOverrideActive) {
|
||||
finalRecipe = resolveVisualRecipe(actionOverride);
|
||||
finalLabel = actionOverride;
|
||||
} else {
|
||||
finalRecipe = currentResult.recipe;
|
||||
finalLabel = currentResult.label;
|
||||
}
|
||||
|
||||
const isStatusReactionActive = currentResult.label !== 'neutral' && !isOverrideActive;
|
||||
|
||||
return {
|
||||
recipe: finalRecipe,
|
||||
recipeLabel: finalLabel,
|
||||
isStatusReactionActive,
|
||||
triggeringStat: currentResult.triggeringStat,
|
||||
currentSeverity: currentResult.severity,
|
||||
isOverrideActive,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Blobbi Data Adapters
|
||||
*
|
||||
* Adapter functions for converting various Blobbi data types
|
||||
* to the format expected by visual components.
|
||||
*
|
||||
* Previously duplicated in:
|
||||
* - BlobbiStageVisual.tsx (toBlobbiForVisual)
|
||||
* - BlobbiCompanionVisual.tsx (toBlobiForVisual - note typo)
|
||||
*/
|
||||
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { CompanionData } from '@/blobbi/companion/types/companion.types';
|
||||
|
||||
/**
|
||||
* Convert BlobbiCompanion to Blobbi type for visual rendering.
|
||||
*
|
||||
* This is a minimal adapter that extracts only the fields needed
|
||||
* by BlobbiBabyVisual and BlobbiAdultVisual.
|
||||
*
|
||||
* @param companion - BlobbiCompanion from parseBlobbiEvent
|
||||
* @returns Blobbi type for visual components
|
||||
*/
|
||||
export function blobbiCompanionToBlobbi(companion: BlobbiCompanion): Blobbi {
|
||||
return {
|
||||
id: companion.d,
|
||||
name: companion.name,
|
||||
lifeStage: companion.stage,
|
||||
state: companion.state,
|
||||
isSleeping: companion.state === 'sleeping',
|
||||
stats: {
|
||||
hunger: companion.stats.hunger ?? 100,
|
||||
happiness: companion.stats.happiness ?? 100,
|
||||
health: companion.stats.health ?? 100,
|
||||
hygiene: companion.stats.hygiene ?? 100,
|
||||
energy: companion.stats.energy ?? 100,
|
||||
},
|
||||
// Visual traits
|
||||
baseColor: companion.visualTraits.baseColor,
|
||||
secondaryColor: companion.visualTraits.secondaryColor,
|
||||
eyeColor: companion.visualTraits.eyeColor,
|
||||
pattern: companion.visualTraits.pattern,
|
||||
specialMark: companion.visualTraits.specialMark,
|
||||
size: companion.visualTraits.size,
|
||||
// Metadata
|
||||
seed: companion.seed,
|
||||
tags: companion.allTags,
|
||||
// Adult-specific data (for adult form resolution)
|
||||
adult: companion.adultType ? { evolutionForm: companion.adultType } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert CompanionData to Blobbi type for visual rendering.
|
||||
*
|
||||
* CompanionData is the companion system's internal data type,
|
||||
* different from BlobbiCompanion used in the main app.
|
||||
*
|
||||
* @param companion - CompanionData from companion system
|
||||
* @returns Blobbi type for visual components
|
||||
*/
|
||||
export function companionDataToBlobbi(companion: CompanionData): Blobbi {
|
||||
const isSleeping = companion.state === 'sleeping';
|
||||
return {
|
||||
id: companion.d,
|
||||
name: companion.name,
|
||||
lifeStage: companion.stage,
|
||||
state: companion.state ?? 'active',
|
||||
isSleeping,
|
||||
stats: {
|
||||
hunger: 100,
|
||||
happiness: 100,
|
||||
health: 100,
|
||||
hygiene: 100,
|
||||
energy: companion.energy,
|
||||
},
|
||||
baseColor: companion.visualTraits.baseColor,
|
||||
secondaryColor: companion.visualTraits.secondaryColor,
|
||||
eyeColor: companion.visualTraits.eyeColor,
|
||||
pattern: companion.visualTraits.pattern,
|
||||
specialMark: companion.visualTraits.specialMark,
|
||||
size: companion.visualTraits.size,
|
||||
seed: companion.seed ?? '',
|
||||
tags: [],
|
||||
// Include adult form info for proper rendering
|
||||
adult: companion.adultType ? { evolutionForm: companion.adultType } : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Body Effects Application
|
||||
*
|
||||
* Applies body-level visual effects to Blobbi SVG.
|
||||
* Body effects are independent of face emotions.
|
||||
*/
|
||||
|
||||
import type { BodyEffectsSpec } from './types';
|
||||
import {
|
||||
generateDirtMarks,
|
||||
generateDustParticles,
|
||||
generateStinkClouds,
|
||||
detectBodyPath,
|
||||
generateAngerRiseEffect,
|
||||
} from './generators';
|
||||
|
||||
/**
|
||||
* Apply body effects to a Blobbi SVG.
|
||||
*
|
||||
* This is the single entry point for all body-level visual effects.
|
||||
* emotions.ts should delegate to this function rather than calling
|
||||
* individual generators directly.
|
||||
*
|
||||
* For adult variants, detects body path bounds to enable shape-aware
|
||||
* placement of dirt marks and dust particles.
|
||||
*
|
||||
* @param svgText - The base SVG content (may already have face emotions applied)
|
||||
* @param spec - Which body effects to apply
|
||||
* @returns Modified SVG with body effects applied
|
||||
*/
|
||||
export function applyBodyEffects(svgText: string, spec: BodyEffectsSpec): string {
|
||||
const overlays: string[] = [];
|
||||
const defs: string[] = [];
|
||||
|
||||
// Generate a unique ID suffix for this application (used by anger-rise)
|
||||
// This prevents ID collisions when multiple Blobbis render on the same page
|
||||
const idSuffix = spec.idPrefix ?? Math.random().toString(36).slice(2, 8);
|
||||
const variant = spec.variant ?? 'adult';
|
||||
|
||||
// Detect body path for adult shape-aware placement
|
||||
// This enables dirt marks and dust to follow the actual body silhouette
|
||||
const bodyPath = variant === 'adult' ? detectBodyPath(svgText) : null;
|
||||
|
||||
// Dirt marks + dust particles
|
||||
if (spec.dirtyMarks?.enabled) {
|
||||
const dirtMarkup = generateDirtMarks({
|
||||
...spec.dirtyMarks,
|
||||
variant,
|
||||
bodyPath: bodyPath ?? undefined,
|
||||
});
|
||||
if (dirtMarkup) overlays.push(dirtMarkup);
|
||||
|
||||
// Add dust particles (includes both back and front layers)
|
||||
const dustMarkup = generateDustParticles({
|
||||
...spec.dirtyMarks,
|
||||
variant,
|
||||
bodyPath: bodyPath ?? undefined,
|
||||
});
|
||||
if (dustMarkup) overlays.push(dustMarkup);
|
||||
}
|
||||
|
||||
// Stink clouds
|
||||
if (spec.stinkClouds?.enabled) {
|
||||
const markup = generateStinkClouds({ ...spec.stinkClouds, variant });
|
||||
if (markup) overlays.push(markup);
|
||||
}
|
||||
|
||||
// Anger rise (needs body path detection + special insertion)
|
||||
// Anger-rise is inserted directly after the body path for correct z-ordering
|
||||
if (spec.angerRise) {
|
||||
const bodyPathInfo = detectBodyPath(svgText);
|
||||
if (bodyPathInfo) {
|
||||
const result = generateAngerRiseEffect(
|
||||
bodyPathInfo,
|
||||
{
|
||||
type: 'anger-rise',
|
||||
color: spec.angerRise.color,
|
||||
duration: spec.angerRise.duration,
|
||||
},
|
||||
idSuffix,
|
||||
);
|
||||
defs.push(result.defs);
|
||||
|
||||
// Insert anger-rise overlay right after the body path element
|
||||
// This ensures correct z-ordering (anger fill appears on top of body but under face)
|
||||
const bodyPathRegex = /<path[^>]*d="[^"]*"[^>]*fill="url\(#[^"]*[Bb]ody[^"]*\)"[^>]*\/>/;
|
||||
const bodyPathMatch = svgText.match(bodyPathRegex);
|
||||
if (bodyPathMatch && bodyPathMatch.index !== undefined) {
|
||||
const insertPos = bodyPathMatch.index + bodyPathMatch[0].length;
|
||||
svgText = svgText.slice(0, insertPos) + result.overlay + svgText.slice(insertPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add defs
|
||||
if (defs.length > 0) {
|
||||
const defsContent = defs.join('\n');
|
||||
if (svgText.includes('<defs>')) {
|
||||
svgText = svgText.replace('<defs>', '<defs>' + defsContent);
|
||||
} else {
|
||||
svgText = svgText.replace(/(<svg[^>]*>)/, `$1\n <defs>${defsContent}\n </defs>`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add overlays
|
||||
if (overlays.length > 0) {
|
||||
const overlayGroup = `
|
||||
<!-- Body effects -->
|
||||
<g class="blobbi-body-effects">
|
||||
${overlays.join('\n ')}
|
||||
</g>`;
|
||||
svgText = svgText.replace('</svg>', overlayGroup + '\n</svg>');
|
||||
}
|
||||
|
||||
return svgText;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user