Compare commits
257 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e67f90582b | |||
| 7fa6e574f8 | |||
| 9b36bf3325 | |||
| bc1c4cb7cf | |||
| 119f684fb3 | |||
| 45134ef9cc | |||
| db502b462c | |||
| ed083bfdad | |||
| 47811f9190 | |||
| ba99cdc51c | |||
| 7092f7306f | |||
| 357dd56de0 | |||
| fadec0574a | |||
| 469806886a | |||
| f7ab980ecd | |||
| c6b5ab2284 | |||
| 2231673ee6 | |||
| f8907475f9 | |||
| 4252841125 | |||
| ee8220c1f0 | |||
| 11e29646a7 | |||
| a9bab7f8e8 | |||
| 0b69ab51f4 | |||
| 2a32e79b13 | |||
| 39fc7549ac | |||
| 414f42e339 | |||
| 8e3f778f5b | |||
| bc83d08961 | |||
| 7d83273410 | |||
| fabcb4170d | |||
| 8b824f8cc9 | |||
| 3e429fe0b0 | |||
| a261934ab0 | |||
| 822ff13ac3 | |||
| afa475ecef | |||
| 853b5ead9c | |||
| a5746ee915 | |||
| fa3376ac4f | |||
| 6f0c10fe9b | |||
| 2f1bf0bca5 | |||
| 9be98d9a8d | |||
| c4dd8e7c3d | |||
| 42832b72e3 | |||
| e77436d02a | |||
| 302d7732ef | |||
| b09b4938d2 | |||
| 0a0d6de111 | |||
| 4e9b893822 | |||
| c60e87ad65 | |||
| 8e07ad515a | |||
| b4c4b8eb21 | |||
| 23ee6f1196 | |||
| 4b97baa428 | |||
| c8e844a19a | |||
| 205a252cac | |||
| ad604eae68 | |||
| 57064b4f40 | |||
| bb7b8da581 | |||
| 5683f6ea1e | |||
| 61c606822a | |||
| bc12331cd4 | |||
| 2478bf1c66 | |||
| ade9eb4999 | |||
| 213bbb21c1 | |||
| dd3ae4da4e | |||
| 681d2ab90b | |||
| 24a645277e | |||
| fa34922cce | |||
| 89c71ed073 | |||
| 0f02563d3a | |||
| f49909dedf | |||
| ab43225f0c | |||
| 2bb1b07dd6 | |||
| f93c759bf2 | |||
| 38630be23d | |||
| ef4ac2e3f4 | |||
| 32b36b2f54 | |||
| dee5c82fa8 | |||
| 22d66a28d7 | |||
| 984a56c412 | |||
| 207e7a13a2 | |||
| cc7feebbb0 | |||
| 9b8cff63da | |||
| 925619b13c | |||
| ceb7bbc718 | |||
| 53a607fa53 | |||
| e13473809d | |||
| e9eeebc4b1 | |||
| b42d241882 | |||
| 68da609a9e | |||
| 1afa78ae39 | |||
| 00a9ad20de | |||
| e0ff462f12 | |||
| f4e38123e4 | |||
| eb1c873b9a | |||
| 22f13c1505 | |||
| cbfc8f149f | |||
| 2e41859747 | |||
| 3b176a3e8f | |||
| a1e1e1d57f | |||
| eb973cc20b | |||
| f66ab92e51 | |||
| 4d573ffaa8 | |||
| 081189886a | |||
| 1efc8de880 | |||
| 8bf9db382e | |||
| 103b9c71bf | |||
| e27057788b | |||
| 4983b3c1ef | |||
| 197ab6c28a | |||
| fd0d47160d | |||
| 4697d269bc | |||
| 73bf03cfab | |||
| c3d4d5f06e | |||
| 4c201cc2d3 | |||
| d28364531b | |||
| c30a6a7bcd | |||
| c4354774ad | |||
| 8a44f77fb1 | |||
| f3eb4adba5 | |||
| 9ebd9a304f | |||
| 0487586af9 | |||
| 2c737ca322 | |||
| c9823055fd | |||
| d2cd5f22bf | |||
| b223a9c1f2 | |||
| 2d1a3ff6f5 | |||
| 90bd10d87a | |||
| 280bcbd5ab | |||
| 65ecfca05e | |||
| 91f5afc110 | |||
| 1c980fb039 | |||
| e93c665123 | |||
| 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 | |||
| c77b68eed2 | |||
| fd9a963b27 | |||
| 672d252492 | |||
| bc4e00520e | |||
| d777d1bc98 | |||
| 4cd97124da | |||
| 7e7abdee3d | |||
| 9ed2127494 | |||
| 30608ae8ed | |||
| ae43014cf2 | |||
| ea8d3dd0f3 | |||
| cf0524a211 | |||
| 8d04bbbdbe | |||
| a3e6ff34db | |||
| 82b2aeb294 | |||
| fd20081ce8 | |||
| 5ffab157d7 | |||
| c6e791d18f | |||
| a80b306248 | |||
| c8c294a8ad |
@@ -108,6 +108,7 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
|
||||
- Use present tense ("Add dark mode toggle", not "Added dark mode toggle")
|
||||
- Focus on what the user sees/experiences, not internal implementation details
|
||||
- Use the current date in YYYY-MM-DD format
|
||||
- **Never use Nostr protocol jargon.** NIP numbers (e.g., "NIP-89", "NIP-17"), kind numbers (e.g., "kind 30078"), and other protocol-level references must not appear in the changelog. Describe the feature in plain language from the user's perspective. For example, write "App cards for Nostr apps" instead of "App cards for Nostr apps (NIP-89)". The changelog audience is end users, not protocol developers.
|
||||
- **Collapse related work into one entry.** If a feature was added and then fixed/tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
|
||||
- **Omit purely internal changes.** CI fixes, build pipeline tweaks, developer tooling, and infrastructure changes should be omitted from the changelog entirely unless they have a direct, visible impact on the user experience. The changelog is for users, not developers.
|
||||
- **Compare the actual code between versions** to understand what really changed, rather than just reading commit messages. Commit messages may over- or under-represent the significance of changes.
|
||||
|
||||
+30
-11
@@ -26,19 +26,36 @@ 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"
|
||||
--use-fallback-relays
|
||||
--use-fallback-servers
|
||||
|
||||
build-apk:
|
||||
stage: build
|
||||
@@ -185,6 +202,8 @@ publish-zapstore:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
variables:
|
||||
SIGN_WITH: $ZAPSTORE_BUNKER_URL
|
||||
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub"
|
||||
BLOSSOM_URL: "https://blossom.ditto.pub"
|
||||
script:
|
||||
- go install github.com/zapstore/zsp@latest
|
||||
|
||||
@@ -198,4 +217,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
|
||||
|
||||
@@ -716,6 +716,43 @@ await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newT
|
||||
|
||||
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
|
||||
|
||||
### D-Tag Collision Prevention for Addressable Events
|
||||
|
||||
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
|
||||
|
||||
#### When to Check for Collisions
|
||||
|
||||
**Must check before publishing** when the d-tag is derived from user input (slugified titles, user-entered identifiers, etc.). **No check needed** when the d-tag is a `crypto.randomUUID()`, a canonical format with embedded pubkey prefix, or intentionally the same as an existing event (edit/update flows).
|
||||
|
||||
#### Implementation Pattern
|
||||
|
||||
Before publishing a **new** addressable event with a user-derived d-tag, query for an existing event with that d-tag. If one exists, block the publish and tell the user to change the identifier.
|
||||
|
||||
```typescript
|
||||
// Before publishing a new addressable event:
|
||||
const slug = slugify(title, { lower: true, strict: true });
|
||||
|
||||
const existing = await nostr.query([
|
||||
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
|
||||
]);
|
||||
|
||||
if (existing.length > 0) {
|
||||
toast({
|
||||
title: 'Slug already in use',
|
||||
description: 'Change the slug or edit the existing item.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Safe to publish
|
||||
publishEvent({ kind: 30023, content, tags: [['d', slug], ...otherTags] });
|
||||
```
|
||||
|
||||
**Skip the check in edit mode** -- when the user explicitly loaded an existing event to update, overwriting is the intended behavior.
|
||||
|
||||
Prefer UUID or canonical formats when the d-tag doesn't need to be human-readable. Only use slugified input when the d-tag will appear in URLs or needs to be meaningful to users, and always add a collision check.
|
||||
|
||||
### Nostr Login
|
||||
|
||||
To enable login with Nostr, simply use the `LoginArea` component already included in this project.
|
||||
@@ -977,6 +1014,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:
|
||||
@@ -1338,7 +1385,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)
|
||||
@@ -1394,4 +1441,53 @@ The script accepts options:
|
||||
|
||||
**Key points:**
|
||||
- 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, run the script again and update the GitLab CI/CD variables
|
||||
- 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
|
||||
+157
-5
@@ -1,5 +1,157 @@
|
||||
# Changelog
|
||||
|
||||
## [2.5.1] - 2026-04-03
|
||||
|
||||
### Fixed
|
||||
- Lightbox now reliably appears above all content, not just when opened from photo galleries
|
||||
|
||||
## [2.5.0] - 2026-04-03
|
||||
|
||||
### Added
|
||||
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
|
||||
- File uploads in the poll composer -- attach images and media to your polls
|
||||
- Blobbi posts now appear in the homepage feed
|
||||
|
||||
### Changed
|
||||
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
|
||||
- App cards now show banner images and improved layout
|
||||
|
||||
### Fixed
|
||||
- Lightbox no longer appears behind the right sidebar
|
||||
- Compose box corners are properly rounded
|
||||
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
|
||||
|
||||
## [2.4.1] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
|
||||
|
||||
### Fixed
|
||||
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
|
||||
|
||||
## [2.4.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
|
||||
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
|
||||
- Mission surface card in the feed that surfaces your active quests at a glance
|
||||
|
||||
### Changed
|
||||
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
|
||||
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
|
||||
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
|
||||
- Blobbi onboarding state now syncs to your profile so it follows you across devices
|
||||
|
||||
### Fixed
|
||||
- Notification dot no longer reappears after you've already marked notifications as read
|
||||
- Dialogs no longer fly up when the mobile keyboard opens
|
||||
|
||||
## [2.3.1] - 2026-04-02
|
||||
|
||||
### Changed
|
||||
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
|
||||
|
||||
### Fixed
|
||||
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
|
||||
- Editing an existing article no longer incorrectly warns about a duplicate slug
|
||||
- Switching between rich text and markdown source mode no longer clears your content
|
||||
- Fix crash when editing in markdown source mode
|
||||
|
||||
## [2.3.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
|
||||
|
||||
### Fixed
|
||||
- Custom emoji no longer stretch to fill their container
|
||||
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
|
||||
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
|
||||
|
||||
## [2.2.11] - 2026-04-02
|
||||
|
||||
### Fixed
|
||||
- Fix crash caused by the "What's new" toast firing outside the router
|
||||
|
||||
## [2.2.10] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
|
||||
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
|
||||
|
||||
### Changed
|
||||
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
|
||||
|
||||
### Fixed
|
||||
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
|
||||
|
||||
## [2.2.9] - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
|
||||
- Blobbi companions now appear in feeds and post detail pages
|
||||
|
||||
### Changed
|
||||
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
|
||||
- Emoji packs without any valid emojis are now hidden from feeds
|
||||
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
|
||||
|
||||
## [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 login flows with Amber, Primal, and other signing apps
|
||||
|
||||
### 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
|
||||
@@ -23,11 +175,11 @@
|
||||
## [2.2.2] - 2026-03-29
|
||||
|
||||
### Added
|
||||
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
|
||||
- Dedicated photo upload flow for sharing photos
|
||||
- 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
|
||||
- Badge list recovery dialog for restoring 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
|
||||
@@ -46,7 +198,7 @@
|
||||
- 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)
|
||||
- Event links now route correctly for all event types
|
||||
- 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
|
||||
@@ -73,10 +225,10 @@
|
||||
- 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 divines experience on both mobile and desktop with floating controls
|
||||
- NIP-11 relay information panel on the network settings page
|
||||
- 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
|
||||
- Remote signer UX improvements for Amber and NIP-46 users on Android
|
||||
- Remote signer UX improvements for Amber users on Android
|
||||
- Badge awards now trigger push notifications
|
||||
- Badges display in profile bio section with a "Give badge" option in the profile menu
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.2.3"
|
||||
versionName "2.5.1"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -60,7 +60,7 @@ const builtinThemes = {
|
||||
};
|
||||
```
|
||||
|
||||
Self-hosters can override these at build time via `ditto.json` (injected through `__DITTO_CONFIG__` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
|
||||
Self-hosters can override these at build time via `ditto.json` (injected through `import.meta.env.DITTO_CONFIG` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
|
||||
|
||||
### ThemeConfig
|
||||
|
||||
|
||||
@@ -127,7 +127,6 @@ User preferences and computed flags.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|
||||
|-----|----------|--------|------------|--------|--------|---------|-------------|
|
||||
| `visible_to_others` | No | egg, baby, adult | Yes | user | `true\|false` | true | Public visibility |
|
||||
| `breeding_ready` | No | egg, baby, adult | Yes | computed | `true\|false` | false | Breeding eligibility |
|
||||
|
||||
### 10. Evolution Tags
|
||||
@@ -192,7 +191,7 @@ These tags are from legacy versions and MUST be removed when republishing events
|
||||
- All visual tags (colors, pattern, size)
|
||||
- All personality tags (if present)
|
||||
- All progression tags (`experience`, `care_streak`)
|
||||
- All social tags (`visible_to_others`, `breeding_ready`)
|
||||
- All social tags (`breeding_ready`)
|
||||
- All extension tags (`theme`, `crossover_app`)
|
||||
|
||||
### Evolve (baby → adult)
|
||||
|
||||
@@ -303,7 +303,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.3;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
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.3;
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
Generated
+1900
-57
File diff suppressed because it is too large
Load Diff
+16
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.2.3",
|
||||
"version": "2.5.1",
|
||||
"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",
|
||||
@@ -52,8 +53,19 @@
|
||||
"@fontsource/special-elite": "^5.2.8",
|
||||
"@getalby/sdk": "^5.1.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@nostrify/nostrify": "^0.51.0",
|
||||
"@nostrify/react": "^0.4.0",
|
||||
"@milkdown/core": "^7.20.0",
|
||||
"@milkdown/ctx": "^7.20.0",
|
||||
"@milkdown/plugin-clipboard": "^7.20.0",
|
||||
"@milkdown/plugin-history": "^7.20.0",
|
||||
"@milkdown/plugin-listener": "^7.20.0",
|
||||
"@milkdown/plugin-upload": "^7.20.0",
|
||||
"@milkdown/preset-commonmark": "^7.20.0",
|
||||
"@milkdown/preset-gfm": "^7.20.0",
|
||||
"@milkdown/prose": "^7.20.0",
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.4.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -116,6 +128,7 @@
|
||||
"react-router-dom": "^6.26.2",
|
||||
"recharts": "^2.12.7",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"slugify": "^1.6.8",
|
||||
"smol-toml": "^1.6.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
+157
-5
@@ -1,5 +1,157 @@
|
||||
# Changelog
|
||||
|
||||
## [2.5.1] - 2026-04-03
|
||||
|
||||
### Fixed
|
||||
- Lightbox now reliably appears above all content, not just when opened from photo galleries
|
||||
|
||||
## [2.5.0] - 2026-04-03
|
||||
|
||||
### Added
|
||||
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
|
||||
- File uploads in the poll composer -- attach images and media to your polls
|
||||
- Blobbi posts now appear in the homepage feed
|
||||
|
||||
### Changed
|
||||
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
|
||||
- App cards now show banner images and improved layout
|
||||
|
||||
### Fixed
|
||||
- Lightbox no longer appears behind the right sidebar
|
||||
- Compose box corners are properly rounded
|
||||
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
|
||||
|
||||
## [2.4.1] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
|
||||
|
||||
### Fixed
|
||||
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
|
||||
|
||||
## [2.4.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
|
||||
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
|
||||
- Mission surface card in the feed that surfaces your active quests at a glance
|
||||
|
||||
### Changed
|
||||
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
|
||||
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
|
||||
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
|
||||
- Blobbi onboarding state now syncs to your profile so it follows you across devices
|
||||
|
||||
### Fixed
|
||||
- Notification dot no longer reappears after you've already marked notifications as read
|
||||
- Dialogs no longer fly up when the mobile keyboard opens
|
||||
|
||||
## [2.3.1] - 2026-04-02
|
||||
|
||||
### Changed
|
||||
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
|
||||
|
||||
### Fixed
|
||||
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
|
||||
- Editing an existing article no longer incorrectly warns about a duplicate slug
|
||||
- Switching between rich text and markdown source mode no longer clears your content
|
||||
- Fix crash when editing in markdown source mode
|
||||
|
||||
## [2.3.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
|
||||
|
||||
### Fixed
|
||||
- Custom emoji no longer stretch to fill their container
|
||||
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
|
||||
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
|
||||
|
||||
## [2.2.11] - 2026-04-02
|
||||
|
||||
### Fixed
|
||||
- Fix crash caused by the "What's new" toast firing outside the router
|
||||
|
||||
## [2.2.10] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
|
||||
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
|
||||
|
||||
### Changed
|
||||
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
|
||||
|
||||
### Fixed
|
||||
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
|
||||
|
||||
## [2.2.9] - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
|
||||
- Blobbi companions now appear in feeds and post detail pages
|
||||
|
||||
### Changed
|
||||
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
|
||||
- Emoji packs without any valid emojis are now hidden from feeds
|
||||
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
|
||||
|
||||
## [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 login flows with Amber, Primal, and other signing apps
|
||||
|
||||
### 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
|
||||
@@ -23,11 +175,11 @@
|
||||
## [2.2.2] - 2026-03-29
|
||||
|
||||
### Added
|
||||
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
|
||||
- Dedicated photo upload flow for sharing photos
|
||||
- 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
|
||||
- Badge list recovery dialog for restoring 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
|
||||
@@ -46,7 +198,7 @@
|
||||
- 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)
|
||||
- Event links now route correctly for all event types
|
||||
- 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
|
||||
@@ -73,10 +225,10 @@
|
||||
- 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 divines experience on both mobile and desktop with floating controls
|
||||
- NIP-11 relay information panel on the network settings page
|
||||
- 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
|
||||
- Remote signer UX improvements for Amber and NIP-46 users on Android
|
||||
- Remote signer UX improvements for Amber users on Android
|
||||
- Badge awards now trigger push notifications
|
||||
- Badges display in profile bio section with a "Give badge" option in the profile menu
|
||||
|
||||
|
||||
+23
-5
@@ -16,12 +16,14 @@ import NostrProvider from "@/components/NostrProvider";
|
||||
import { NostrSync } from "@/components/NostrSync";
|
||||
import { PlausibleProvider } from "@/components/PlausibleProvider";
|
||||
import { SentryProvider } from "@/components/SentryProvider";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
|
||||
import type { AppConfig } from "@/contexts/AppContext";
|
||||
import { NWCProvider } from "@/contexts/NWCContext";
|
||||
import { PROTOCOL_MODE } from "@/lib/dmConstants";
|
||||
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
|
||||
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
|
||||
import AppRouter from "./AppRouter";
|
||||
|
||||
@@ -114,6 +116,7 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeBadgeDefinitions: true,
|
||||
feedIncludeProfileBadges: true,
|
||||
feedIncludeVanish: true,
|
||||
feedIncludeBlobbi: true,
|
||||
followsFeedShowReplies: true,
|
||||
},
|
||||
sidebarOrder: [
|
||||
@@ -147,15 +150,30 @@ const hardcodedConfig: AppConfig = {
|
||||
imageQuality: 'compressed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse and validate build-time ditto.json overrides from the env string.
|
||||
* Returns an empty object when no config file was provided or validation fails.
|
||||
*/
|
||||
function parseDittoConfig(): DittoConfig {
|
||||
try {
|
||||
const json = JSON.parse(import.meta.env.DITTO_CONFIG);
|
||||
if (!json) return {};
|
||||
return DittoConfigSchema.parse(json);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge hardcoded defaults with build-time ditto.json overrides.
|
||||
* Deep-merges feedSettings so a partial override doesn't erase defaults.
|
||||
* Precedence (handled by AppProvider): user localStorage > build-time > hardcoded.
|
||||
*/
|
||||
const dittoConfig = parseDittoConfig();
|
||||
const defaultConfig: AppConfig = {
|
||||
...hardcodedConfig,
|
||||
...(typeof __DITTO_CONFIG__ !== "undefined" && __DITTO_CONFIG__
|
||||
? __DITTO_CONFIG__
|
||||
: {}),
|
||||
...dittoConfig,
|
||||
feedSettings: { ...hardcodedConfig.feedSettings, ...dittoConfig.feedSettings },
|
||||
};
|
||||
|
||||
export function App() {
|
||||
@@ -183,11 +201,11 @@ export function App() {
|
||||
<NostrProvider>
|
||||
<NostrSync />
|
||||
<NativeNotifications />
|
||||
|
||||
<NWCProvider>
|
||||
<DMProvider config={dmConfig}>
|
||||
<EmotionDevProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<InitialSyncGate>
|
||||
<AppRouter />
|
||||
</InitialSyncGate>
|
||||
|
||||
+37
-10
@@ -6,8 +6,10 @@ import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
|
||||
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
|
||||
import { BlobbiActionsProvider } from "@/blobbi/companion/interaction/BlobbiActionsProvider";
|
||||
import { sidebarItemIcon } from "@/lib/sidebarItems";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { MainLayout } from "./components/MainLayout";
|
||||
import { ScrollToTop } from "./components/ScrollToTop";
|
||||
import { VersionCheck } from "./components/VersionCheck";
|
||||
import { useCurrentUser } from "./hooks/useCurrentUser";
|
||||
import { useProfileUrl } from "./hooks/useProfileUrl";
|
||||
import { getExtraKindDef } from "./lib/extraKinds";
|
||||
@@ -22,6 +24,9 @@ const BlobbiCompanionLayer = lazy(() => import("@/blobbi/companion").then(m => (
|
||||
// Lazy-loaded compose modal (pulls in emoji-mart ~620K)
|
||||
const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").then(m => ({ default: m.ReplyComposeModal })));
|
||||
|
||||
// Lazy-loaded emoji pack dialog
|
||||
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
|
||||
|
||||
// HomePage eagerly imported all page components; now lazy-loaded
|
||||
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
|
||||
|
||||
@@ -29,6 +34,7 @@ const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.H
|
||||
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
|
||||
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
|
||||
const ArchivePage = lazy(() => import("./pages/ArchivePage").then(m => ({ default: m.ArchivePage })));
|
||||
const ArticleEditorPage = lazy(() => import("./pages/ArticleEditorPage").then(m => ({ default: m.ArticleEditorPage })));
|
||||
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
|
||||
const BlobbiPage = lazy(() => import("./pages/BlobbiPage").then(m => ({ default: m.BlobbiPage })));
|
||||
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
|
||||
@@ -45,6 +51,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 +77,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")!;
|
||||
@@ -99,6 +107,26 @@ function PollsFeedPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Emoji feed page with a FAB that opens the emoji pack creation dialog. */
|
||||
function EmojiFeedPage() {
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<KindFeedPage
|
||||
kind={emojisDef.kind}
|
||||
title={emojisDef.label}
|
||||
icon={sidebarItemIcon("emojis", "size-5")}
|
||||
onFabClick={() => setComposeOpen(true)}
|
||||
/>
|
||||
{composeOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<EmojiPackDialog open={composeOpen} onOpenChange={setComposeOpen} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Redirects /profile to the user's canonical profile URL (nip05 or npub). */
|
||||
function ProfileRedirect() {
|
||||
const { user, metadata } = useCurrentUser();
|
||||
@@ -111,6 +139,8 @@ export function AppRouter() {
|
||||
return (
|
||||
<AudioPlayerProvider>
|
||||
<BrowserRouter>
|
||||
<Toaster />
|
||||
<VersionCheck />
|
||||
<MinimizedAudioBar />
|
||||
<AudioNavigationGuard />
|
||||
<DeepLinkHandler />
|
||||
@@ -182,6 +212,8 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
<Route path="/webxdc" element={<WebxdcFeedPage />} />
|
||||
<Route path="/articles/new" element={<ArticleEditorPage />} />
|
||||
<Route path="/articles/edit/:naddr" element={<ArticleEditorPage />} />
|
||||
<Route
|
||||
path="/articles"
|
||||
element={
|
||||
@@ -189,6 +221,7 @@ export function AppRouter() {
|
||||
kind={articlesDef.kind}
|
||||
title={articlesDef.label}
|
||||
icon={sidebarItemIcon("articles", "size-5")}
|
||||
fabHref="/articles/new"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -202,16 +235,7 @@ export function AppRouter() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/emojis"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={emojisDef.kind}
|
||||
title={emojisDef.label}
|
||||
icon={sidebarItemIcon("emojis", "size-5")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/emojis" element={<EmojiFeedPage />} />
|
||||
<Route
|
||||
path="/development"
|
||||
element={
|
||||
@@ -237,6 +261,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 +274,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 {
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
// src/blobbi/actions/components/BlobbiMissionsModal.tsx
|
||||
|
||||
/**
|
||||
* Missions modal for Blobbi.
|
||||
*
|
||||
* Shows:
|
||||
* - Daily missions (always visible, separate reward system)
|
||||
* - Incubation tasks when the current Blobbi is incubating (egg stage)
|
||||
* - Evolve tasks when evolving (baby stage)
|
||||
* Missions modal for Blobbi — card-grid quest board.
|
||||
*
|
||||
* Layout:
|
||||
* 1. Sticky header with title, subtitle, legend help button, close
|
||||
* 2. Current Focus section (hatch / evolve) — collapsible, default open
|
||||
* 3. Daily Bounties section — collapsible, default open
|
||||
* 4. Settings row — low emphasis toggle (not collapsible)
|
||||
*
|
||||
* Both main sections use lightweight Radix Collapsible wrappers.
|
||||
* Collapsed headers still show summary info (progress / coins).
|
||||
*/
|
||||
|
||||
import { Target, Loader2, XCircle, AlertTriangle, Calendar, Coins, X, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
Loader2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Coins,
|
||||
X,
|
||||
Eye,
|
||||
Scroll,
|
||||
Compass,
|
||||
HelpCircle,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { formatCompactNumber, cn } from '@/lib/utils';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogClose } from '@/components/ui/dialog';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogClose } from '@/components/ui/dialog';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -24,10 +44,9 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
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';
|
||||
@@ -42,36 +61,86 @@ import { useRerollMission } from '../hooks/useRerollMission';
|
||||
interface BlobbiMissionsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Current companion being viewed */
|
||||
companion: BlobbiCompanion;
|
||||
/** Current Blobbonaut profile (required for coin updates) */
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Callback to update profile in query cache after claiming */
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
/** Hatch tasks result from useHatchTasks */
|
||||
hatchTasks: HatchTasksResult;
|
||||
/** Evolve tasks result from useEvolveTasks */
|
||||
evolveTasks: EvolveTasksResult;
|
||||
/** Called when user clicks "Create Post" action in tasks */
|
||||
onOpenPostModal: () => void;
|
||||
/** Called when all hatch tasks are complete and user clicks "Hatch" */
|
||||
onHatch: () => void;
|
||||
/** Whether hatching is in progress */
|
||||
isHatching: boolean;
|
||||
/** Called when all evolve tasks are complete and user clicks "Evolve" */
|
||||
onEvolve: () => void;
|
||||
/** Whether evolving is in progress */
|
||||
isEvolving: boolean;
|
||||
/** Called when user confirms stopping incubation */
|
||||
onStopIncubation: () => Promise<void>;
|
||||
/** Whether stop incubation is in progress */
|
||||
isStoppingIncubation: boolean;
|
||||
/** Called when user confirms stopping evolution */
|
||||
onStopEvolution: () => Promise<void>;
|
||||
/** Whether stop evolution is in progress */
|
||||
isStoppingEvolution: boolean;
|
||||
/** Available Blobbi stages across all user's companions (for mission filtering) */
|
||||
availableStages?: ('egg' | 'baby' | 'adult')[];
|
||||
showMissionCard?: boolean;
|
||||
onToggleMissionCard?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
// ─── Section Chevron ─────────────────────────────────────────────────────────
|
||||
|
||||
function SectionChevron({ open }: { open: boolean }) {
|
||||
return (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 text-muted-foreground/60 transition-transform duration-200',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Mission Type Legend ──────────────────────────────────────────────────────
|
||||
|
||||
function MissionTypeLegend() {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 opacity-50 hover:opacity-100 hover:bg-muted transition-all"
|
||||
aria-label="Mission types legend"
|
||||
>
|
||||
<HelpCircle className="size-4" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="end" className="w-56 p-3">
|
||||
<p className="text-xs font-semibold mb-2">Mission Types</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-5 rounded-full bg-amber-500/15 flex items-center justify-center shrink-0">
|
||||
<Scroll className="size-3 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium">Daily Bounty</p>
|
||||
<p className="text-[10px] text-muted-foreground">Resets every day</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-5 rounded-full bg-sky-500/15 flex items-center justify-center shrink-0">
|
||||
<span className="text-xs">🥚</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium">Hatch Task</p>
|
||||
<p className="text-[10px] text-muted-foreground">Egg progression</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-5 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
|
||||
<span className="text-xs">🐣</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium">Evolve Task</p>
|
||||
<p className="text-[10px] text-muted-foreground">Baby progression</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Daily Missions Section ───────────────────────────────────────────────────
|
||||
@@ -79,14 +148,20 @@ interface BlobbiMissionsModalProps {
|
||||
interface DailyMissionsSectionProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
/** Available Blobbi stages the user has */
|
||||
availableStages?: ('egg' | 'baby' | 'adult')[];
|
||||
disabled?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function DailyMissionsSection({ profile, updateProfileEvent, availableStages, disabled, defaultOpen = true }: DailyMissionsSectionProps) {
|
||||
function DailyMissionsSection({
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
availableStages,
|
||||
disabled,
|
||||
defaultOpen = true,
|
||||
}: DailyMissionsSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
const {
|
||||
missions,
|
||||
todayClaimedReward,
|
||||
@@ -100,58 +175,56 @@ function DailyMissionsSection({ profile, updateProfileEvent, availableStages, di
|
||||
|
||||
const { mutate: claimReward, isPending: isClaiming } = useClaimMissionReward(
|
||||
profile,
|
||||
updateProfileEvent
|
||||
updateProfileEvent,
|
||||
);
|
||||
|
||||
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
|
||||
|
||||
const handleClaimReward = (missionId: string) => {
|
||||
claimReward({ missionId });
|
||||
};
|
||||
|
||||
const handleRerollMission = (missionId: string) => {
|
||||
rerollMission({ missionId, availableStages });
|
||||
};
|
||||
const claimableCount = missions.filter((m) => m.completed && !m.claimed).length;
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
|
||||
{/* Section header - Clickable */}
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
{/* Section header — tappable */}
|
||||
<CollapsibleTrigger className="w-full">
|
||||
<div className="flex items-center justify-between gap-2 p-3 rounded-lg bg-muted/50 hover:bg-muted/70 transition-colors">
|
||||
<div className="flex items-center justify-between py-1 group">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="size-4 text-primary shrink-0" />
|
||||
<h3 className="font-semibold text-sm">Daily Missions</h3>
|
||||
<Scroll className="size-4 text-amber-500 dark:text-amber-400 shrink-0" />
|
||||
<h3 className="font-semibold text-sm">Daily Bounties</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Coins className="size-3 shrink-0" />
|
||||
<span className="whitespace-nowrap">
|
||||
{/* Summary pill — always visible */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Coins className="size-3 shrink-0 text-amber-500 dark:text-amber-400" />
|
||||
<span className="tabular-nums">
|
||||
{formatCompactNumber(todayClaimedReward)} / {formatCompactNumber(totalPotentialReward)}
|
||||
</span>
|
||||
{claimableCount > 0 && (
|
||||
<span className="size-4 rounded-full bg-emerald-500 text-white text-[10px] font-bold flex items-center justify-center shrink-0">
|
||||
{claimableCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className={cn(
|
||||
"size-4 text-muted-foreground transition-transform duration-200",
|
||||
isOpen && "rotate-180"
|
||||
)} />
|
||||
<SectionChevron open={isOpen} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* Mission list */}
|
||||
<CollapsibleContent className="pt-3">
|
||||
<DailyMissionsPanel
|
||||
missions={missions}
|
||||
onClaimReward={handleClaimReward}
|
||||
onRerollMission={handleRerollMission}
|
||||
todayCoins={todayClaimedReward}
|
||||
disabled={disabled || isClaiming || isRerolling}
|
||||
bonusAvailable={bonusAvailable}
|
||||
bonusClaimed={bonusClaimed}
|
||||
bonusReward={bonusReward}
|
||||
noMissionsAvailable={noMissionsAvailable}
|
||||
rerollsRemaining={rerollsRemaining}
|
||||
isRerolling={isRerolling}
|
||||
/>
|
||||
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
|
||||
<div className="pt-3">
|
||||
<DailyMissionsPanel
|
||||
missions={missions}
|
||||
onClaimReward={(id) => claimReward({ missionId: id })}
|
||||
onRerollMission={(id) => rerollMission({ missionId: id, availableStages })}
|
||||
todayCoins={todayClaimedReward}
|
||||
disabled={disabled || isClaiming || isRerolling}
|
||||
bonusAvailable={bonusAvailable}
|
||||
bonusClaimed={bonusClaimed}
|
||||
bonusReward={bonusReward}
|
||||
noMissionsAvailable={noMissionsAvailable}
|
||||
rerollsRemaining={rerollsRemaining}
|
||||
isRerolling={isRerolling}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
@@ -224,9 +297,9 @@ function StopConfirmationDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Process Content (Incubation or Evolution) ────────────────────────────────
|
||||
// ─── Current Focus Section (Hatch / Evolve) ──────────────────────────────────
|
||||
|
||||
interface ProcessContentProps {
|
||||
interface CurrentFocusSectionProps {
|
||||
companion: BlobbiCompanion;
|
||||
tasks: HatchTasksResult | EvolveTasksResult;
|
||||
processType: 'incubation' | 'evolution';
|
||||
@@ -238,7 +311,7 @@ interface ProcessContentProps {
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function ProcessContent({
|
||||
function CurrentFocusSection({
|
||||
companion,
|
||||
tasks,
|
||||
processType,
|
||||
@@ -248,93 +321,98 @@ function ProcessContent({
|
||||
onStop,
|
||||
isStopping,
|
||||
defaultOpen = true,
|
||||
}: ProcessContentProps) {
|
||||
}: CurrentFocusSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const [showStopConfirmation, setShowStopConfirmation] = useState(false);
|
||||
|
||||
const isIncubation = processType === 'incubation';
|
||||
const emoji = isIncubation ? '🥚' : '🐣';
|
||||
const title = isIncubation ? 'Hatch Tasks' : 'Evolve Tasks';
|
||||
const description = isIncubation
|
||||
? 'Complete these tasks to hatch your Blobbi'
|
||||
: 'Complete these tasks to evolve your Blobbi';
|
||||
const completeLabel = isIncubation ? 'Hatch Your Blobbi!' : 'Evolve Your Blobbi!';
|
||||
const completingLabel = isIncubation ? 'Hatching...' : 'Evolving...';
|
||||
const completeEmoji = isIncubation ? '🐣' : '✨';
|
||||
const stopLabel = isIncubation ? 'Stop Incubation' : 'Stop Evolution';
|
||||
const badgeLabel = isIncubation ? 'Hatch' : 'Evolve';
|
||||
const category = isIncubation ? ('hatch' as const) : ('evolve' as const);
|
||||
|
||||
const completedCount = tasks.tasks.filter(t => t.completed).length;
|
||||
const completedCount = tasks.tasks.filter((t) => t.completed).length;
|
||||
const totalTasks = tasks.tasks.length;
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
|
||||
{/* Section header - Clickable */}
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
{/* Section header — tappable */}
|
||||
<CollapsibleTrigger className="w-full">
|
||||
<div className="flex items-center justify-between gap-2 p-3 rounded-lg bg-muted/50 hover:bg-muted/70 transition-colors">
|
||||
<div className="flex items-center justify-between py-1 group">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{emoji}</span>
|
||||
<h3 className="font-semibold text-sm">{title}</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs font-semibold px-2 py-0.5',
|
||||
isIncubation
|
||||
? 'bg-sky-500/15 text-sky-600 dark:text-sky-400'
|
||||
: 'bg-violet-500/15 text-violet-600 dark:text-violet-400',
|
||||
)}
|
||||
>
|
||||
{badgeLabel}
|
||||
</Badge>
|
||||
<span className="text-sm font-semibold">{title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"text-xs font-medium px-2 py-0.5 rounded-full",
|
||||
tasks.allCompleted
|
||||
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{completedCount}/{totalTasks}
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-medium tabular-nums',
|
||||
tasks.allCompleted
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{completedCount} / {totalTasks}
|
||||
</span>
|
||||
<ChevronDown className={cn(
|
||||
"size-4 text-muted-foreground transition-transform duration-200",
|
||||
isOpen && "rotate-180"
|
||||
)} />
|
||||
<SectionChevron open={isOpen} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* Tasks content */}
|
||||
<CollapsibleContent className="pt-3">
|
||||
{/* Tasks Panel */}
|
||||
<TasksPanel
|
||||
tasks={tasks.tasks}
|
||||
allCompleted={tasks.allCompleted}
|
||||
isLoading={tasks.isLoading}
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
onComplete={onComplete}
|
||||
isCompleting={isCompleting}
|
||||
emoji={emoji}
|
||||
title={title}
|
||||
description={description}
|
||||
completeLabel={completeLabel}
|
||||
completingLabel={completingLabel}
|
||||
completeEmoji={completeEmoji}
|
||||
/>
|
||||
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
|
||||
<div className="pt-3">
|
||||
{/* Task card grid */}
|
||||
<TasksPanel
|
||||
tasks={tasks.tasks}
|
||||
allCompleted={tasks.allCompleted}
|
||||
isLoading={tasks.isLoading}
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
onComplete={onComplete}
|
||||
isCompleting={isCompleting}
|
||||
completeLabel={completeLabel}
|
||||
completingLabel={completingLabel}
|
||||
completeEmoji={completeEmoji}
|
||||
category={category}
|
||||
/>
|
||||
|
||||
{/* Stop Process Button */}
|
||||
<div className="mt-6 pt-4 border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowStopConfirmation(true)}
|
||||
disabled={isStopping || isCompleting}
|
||||
className="w-full text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{isStopping ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Stopping...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="size-4 mr-2" />
|
||||
{stopLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{/* Stop process — low emphasis */}
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowStopConfirmation(true)}
|
||||
disabled={isStopping || isCompleting}
|
||||
className="text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 h-8 px-3"
|
||||
>
|
||||
{isStopping ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
|
||||
Stopping...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="size-3.5 mr-1.5" />
|
||||
{stopLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
||||
{/* Stop Confirmation Dialog */}
|
||||
<StopConfirmationDialog
|
||||
open={showStopConfirmation}
|
||||
onOpenChange={setShowStopConfirmation}
|
||||
@@ -347,6 +425,17 @@ function ProcessContent({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Empty Focus State ────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyFocusState() {
|
||||
return (
|
||||
<div className="py-6 text-center">
|
||||
<Compass className="size-5 text-muted-foreground/50 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No active progression right now</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Modal ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiMissionsModal({
|
||||
@@ -367,54 +456,46 @@ export function BlobbiMissionsModal({
|
||||
onStopEvolution,
|
||||
isStoppingEvolution,
|
||||
availableStages,
|
||||
showMissionCard,
|
||||
onToggleMissionCard,
|
||||
}: BlobbiMissionsModalProps) {
|
||||
const isIncubating = companion.state === 'incubating';
|
||||
const isEvolvingState = companion.state === 'evolving';
|
||||
const isEgg = companion.stage === 'egg';
|
||||
const isBaby = companion.stage === 'baby';
|
||||
|
||||
// Check if there's an active hatch/evolve process
|
||||
const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby);
|
||||
const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 overflow-hidden [&>button:last-child]:hidden">
|
||||
{/* Header - Sticky */}
|
||||
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Target className="size-5 shrink-0" />
|
||||
Missions
|
||||
</DialogTitle>
|
||||
<DialogDescription className="break-words">
|
||||
Complete missions to earn rewards for {companion.name}
|
||||
</DialogDescription>
|
||||
{/* ── Sticky Header ── */}
|
||||
<div className="sticky top-0 z-10 bg-background px-4 sm:px-5 pt-4 pb-3 border-b border-border/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-bold tracking-tight">Missions</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Quests & bounties for {companion.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<MissionTypeLegend />
|
||||
<DialogClose className="rounded-full p-1.5 opacity-60 hover:opacity-100 hover:bg-muted transition-all">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
|
||||
<X className="size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 sm:px-6 py-3 sm:py-4 space-y-4">
|
||||
{/* Daily Missions Section - Always visible, expanded by default */}
|
||||
<DailyMissionsSection
|
||||
profile={profile}
|
||||
updateProfileEvent={updateProfileEvent}
|
||||
availableStages={availableStages}
|
||||
disabled={isProcessBusy}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
|
||||
{/* Hatch/Evolve Process Section - Only when active, expanded by default */}
|
||||
{hasActiveProcess && (
|
||||
{/* ── Scrollable Content ── */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 sm:px-5 py-4 space-y-5">
|
||||
{/* 1. Current Focus */}
|
||||
{hasActiveProcess ? (
|
||||
<>
|
||||
{isIncubating && isEgg ? (
|
||||
<ProcessContent
|
||||
<CurrentFocusSection
|
||||
companion={companion}
|
||||
tasks={hatchTasks}
|
||||
processType="incubation"
|
||||
@@ -423,10 +504,9 @@ export function BlobbiMissionsModal({
|
||||
isCompleting={isHatching}
|
||||
onStop={onStopIncubation}
|
||||
isStopping={isStoppingIncubation}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
) : isEvolvingState && isBaby ? (
|
||||
<ProcessContent
|
||||
<CurrentFocusSection
|
||||
companion={companion}
|
||||
tasks={evolveTasks}
|
||||
processType="evolution"
|
||||
@@ -435,10 +515,43 @@ export function BlobbiMissionsModal({
|
||||
isCompleting={isEvolving}
|
||||
onStop={onStopEvolution}
|
||||
isStopping={isStoppingEvolution}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<EmptyFocusState />
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
{/* 2. Daily Bounties */}
|
||||
<DailyMissionsSection
|
||||
profile={profile}
|
||||
updateProfileEvent={updateProfileEvent}
|
||||
availableStages={availableStages}
|
||||
disabled={isProcessBusy}
|
||||
/>
|
||||
|
||||
{/* 3. Settings */}
|
||||
{onToggleMissionCard !== undefined && showMissionCard !== undefined && (
|
||||
<>
|
||||
<div className="h-px bg-border/40" />
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<Label
|
||||
htmlFor="mission-card-toggle"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
Show mission card on main page
|
||||
</Label>
|
||||
<Switch
|
||||
id="mission-card-toggle"
|
||||
checked={showMissionCard}
|
||||
onCheckedChange={onToggleMissionCard}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { toast } from '@/hooks/useToast';
|
||||
|
||||
import {
|
||||
BLOBBI_POST_REQUIRED_HASHTAGS,
|
||||
buildHatchPhrase,
|
||||
} from '../hooks/useHatchTasks';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -49,33 +50,13 @@ interface BlobbiPostModalProps {
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sanitize a name into a valid hashtag format.
|
||||
* - Removes special characters
|
||||
* - Replaces spaces with nothing (camelCase-like)
|
||||
* - Ensures lowercase
|
||||
* - Handles edge cases
|
||||
*/
|
||||
function sanitizeToHashtag(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
// Remove emojis and special characters, keep letters, numbers, underscores
|
||||
.replace(/[^\p{L}\p{N}_]/gu, '')
|
||||
// Ensure it starts with a letter (prepend 'blobbi' if it starts with number)
|
||||
.replace(/^(\d)/, 'blobbi$1')
|
||||
// Limit length
|
||||
.slice(0, 30)
|
||||
// Fallback if empty
|
||||
|| 'myblobbi';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the required prefix text based on process type.
|
||||
*/
|
||||
function buildPrefix(process: BlobbiPostProcess): string {
|
||||
return process === 'evolve'
|
||||
? 'Hello Nostr! Posting to evolve'
|
||||
: 'Hello Nostr! Posting to hatch';
|
||||
? 'Posting to evolve'
|
||||
: 'Posting to hatch';
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
@@ -91,20 +72,19 @@ export function BlobbiPostModal({
|
||||
const { mutateAsync: createEvent, isPending } = useNostrPublish();
|
||||
|
||||
// Compute the required elements based on props
|
||||
const blobbiHashtag = useMemo(() => sanitizeToHashtag(blobbiName), [blobbiName]);
|
||||
const prefix = useMemo(() => buildPrefix(process), [process]);
|
||||
const capitalizedName = useMemo(() => blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1), [blobbiName]);
|
||||
|
||||
// All required hashtags including the Blobbi name (first)
|
||||
const allRequiredHashtags = useMemo(() =>
|
||||
[blobbiHashtag, ...BLOBBI_POST_REQUIRED_HASHTAGS],
|
||||
[blobbiHashtag]
|
||||
// The required phrase that must appear in the post
|
||||
const requiredPhrase = useMemo(() =>
|
||||
process === 'hatch'
|
||||
? buildHatchPhrase(blobbiName)
|
||||
: `${prefix} ${capitalizedName} #blobbi`,
|
||||
[process, blobbiName, prefix, capitalizedName]
|
||||
);
|
||||
|
||||
// Build default content
|
||||
const defaultContent = useMemo(() =>
|
||||
`${prefix} #${allRequiredHashtags.join(' #')}`,
|
||||
[prefix, allRequiredHashtags]
|
||||
);
|
||||
// Build default content (the phrase itself is enough)
|
||||
const defaultContent = useMemo(() => requiredPhrase, [requiredPhrase]);
|
||||
|
||||
const [content, setContent] = useState(defaultContent);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
@@ -118,24 +98,14 @@ export function BlobbiPostModal({
|
||||
}, [open, defaultContent]);
|
||||
|
||||
/**
|
||||
* Validate that the content still contains the required prefix and hashtags.
|
||||
* Validate that the content contains the required phrase.
|
||||
*/
|
||||
const validateContent = useCallback((text: string): string | null => {
|
||||
// Check prefix
|
||||
if (!text.startsWith(prefix)) {
|
||||
return 'The post must start with the required text';
|
||||
if (!text.includes(requiredPhrase)) {
|
||||
return `The post must contain: "${requiredPhrase}"`;
|
||||
}
|
||||
|
||||
// Check all required hashtags are present (including Blobbi name)
|
||||
const lowerText = text.toLowerCase();
|
||||
for (const tag of allRequiredHashtags) {
|
||||
if (!lowerText.includes(`#${tag.toLowerCase()}`)) {
|
||||
return `Missing required hashtag: #${tag}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [prefix, allRequiredHashtags]);
|
||||
}, [requiredPhrase]);
|
||||
|
||||
/**
|
||||
* Handle content change with validation.
|
||||
@@ -180,21 +150,26 @@ export function BlobbiPostModal({
|
||||
}
|
||||
|
||||
try {
|
||||
// Build tags for the post
|
||||
// Build tags for the post: extract all hashtags from content
|
||||
const tags: string[][] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Add all required hashtags as 't' tags
|
||||
for (const hashtag of allRequiredHashtags) {
|
||||
tags.push(['t', hashtag.toLowerCase()]);
|
||||
// Always include BLOBBI_POST_REQUIRED_HASHTAGS as t tags
|
||||
for (const hashtag of BLOBBI_POST_REQUIRED_HASHTAGS) {
|
||||
const lower = hashtag.toLowerCase();
|
||||
if (!seen.has(lower)) {
|
||||
tags.push(['t', lower]);
|
||||
seen.add(lower);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract any additional hashtags the user added
|
||||
const additionalHashtags = content.match(/#(\w+)/g) || [];
|
||||
const requiredLower = allRequiredHashtags.map(t => t.toLowerCase());
|
||||
for (const tag of additionalHashtags) {
|
||||
// Extract any additional hashtags from the content
|
||||
const contentHashtags = content.match(/#(\w+)/g) || [];
|
||||
for (const tag of contentHashtags) {
|
||||
const tagValue = tag.slice(1).toLowerCase();
|
||||
if (!requiredLower.includes(tagValue)) {
|
||||
if (!seen.has(tagValue)) {
|
||||
tags.push(['t', tagValue]);
|
||||
seen.add(tagValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +195,7 @@ export function BlobbiPostModal({
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, allRequiredHashtags, process]);
|
||||
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, process]);
|
||||
|
||||
const canPost = !validationError && content.trim().length > 0;
|
||||
|
||||
@@ -282,13 +257,9 @@ export function BlobbiPostModal({
|
||||
|
||||
{/* Preview of required content */}
|
||||
<div className="p-3 rounded-lg bg-muted/50 border border-dashed">
|
||||
<p className="text-xs text-muted-foreground mb-1">Required content:</p>
|
||||
<p className="text-sm font-medium">
|
||||
<span className="text-primary">{prefix}</span>
|
||||
{' '}
|
||||
{allRequiredHashtags.map(tag => (
|
||||
<span key={tag} className="text-blue-500">#{tag} </span>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground mb-1">Required phrase:</p>
|
||||
<p className="text-sm font-medium text-primary">
|
||||
{requiredPhrase}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,285 +1,164 @@
|
||||
/**
|
||||
* DailyMissionsPanel - UI component for displaying daily missions
|
||||
*
|
||||
* Shows:
|
||||
* - Daily mission list with progress bars
|
||||
* - Completion state
|
||||
* - Claim buttons for completed missions
|
||||
* - Coin rewards
|
||||
* - Bonus mission after completing all regular missions
|
||||
* - Empty state when no missions available (egg-only users)
|
||||
* - Reroll button to replace missions (max 3/day)
|
||||
* DailyMissionsPanel — card-grid layout for daily bounties.
|
||||
*
|
||||
* Each mission is a compact card in a 2-col grid.
|
||||
* Tapping a card expands it to show progress, claim button, and reroll.
|
||||
* Only one card expanded at a time.
|
||||
*/
|
||||
|
||||
import { Check, Coins, Gift, Sparkles, Egg, Trophy, RefreshCw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Coins,
|
||||
Gift,
|
||||
Sparkles,
|
||||
Egg,
|
||||
Trophy,
|
||||
RefreshCw,
|
||||
Heart,
|
||||
Utensils,
|
||||
Droplets,
|
||||
Moon,
|
||||
Camera,
|
||||
Mic,
|
||||
Music,
|
||||
Pill,
|
||||
CircleDot,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn, formatCompactNumber } from '@/lib/utils';
|
||||
import type { DailyMission } from '../lib/daily-missions';
|
||||
import type { DailyMission, DailyMissionAction } from '../lib/daily-missions';
|
||||
import { BONUS_MISSION_ID } from '../hooks/useClaimMissionReward';
|
||||
import {
|
||||
ExpandableMissionCard,
|
||||
MissionDescription,
|
||||
MissionProgress,
|
||||
} from './ExpandableMissionCard';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DailyMissionsPanelProps {
|
||||
/** The daily missions to display */
|
||||
missions: DailyMission[];
|
||||
/** Callback when claiming a mission reward */
|
||||
onClaimReward: (missionId: string) => void;
|
||||
/** Callback when rerolling a mission */
|
||||
onRerollMission?: (missionId: string) => void;
|
||||
/** Total coins earned today */
|
||||
todayCoins: number;
|
||||
/** Whether claiming is disabled (e.g., during another operation) */
|
||||
disabled?: boolean;
|
||||
/** Whether the bonus mission is available */
|
||||
bonusAvailable?: boolean;
|
||||
/** Whether the bonus mission has been claimed */
|
||||
bonusClaimed?: boolean;
|
||||
/** Bonus mission reward amount */
|
||||
bonusReward?: number;
|
||||
/** Whether user has no eligible missions (e.g., only eggs) */
|
||||
noMissionsAvailable?: boolean;
|
||||
/** Number of rerolls remaining today */
|
||||
rerollsRemaining?: number;
|
||||
/** Whether a reroll is currently in progress */
|
||||
isRerolling?: boolean;
|
||||
}
|
||||
|
||||
// ─── Mission Item ─────────────────────────────────────────────────────────────
|
||||
// ─── Daily Mission Icon Mapping ───────────────────────────────────────────────
|
||||
|
||||
interface MissionItemProps {
|
||||
mission: DailyMission;
|
||||
onClaim: () => void;
|
||||
onReroll?: () => void;
|
||||
disabled?: boolean;
|
||||
canReroll?: boolean;
|
||||
isRerolling?: boolean;
|
||||
function DailyMissionIcon({ action }: { action: DailyMissionAction }) {
|
||||
const cls = 'size-5';
|
||||
switch (action) {
|
||||
case 'interact':
|
||||
return <Heart className={cls} />;
|
||||
case 'feed':
|
||||
return <Utensils className={cls} />;
|
||||
case 'clean':
|
||||
return <Droplets className={cls} />;
|
||||
case 'sleep':
|
||||
return <Moon className={cls} />;
|
||||
case 'take_photo':
|
||||
return <Camera className={cls} />;
|
||||
case 'sing':
|
||||
return <Mic className={cls} />;
|
||||
case 'play_music':
|
||||
return <Music className={cls} />;
|
||||
case 'medicine':
|
||||
return <Pill className={cls} />;
|
||||
default:
|
||||
return <CircleDot className={cls} />;
|
||||
}
|
||||
}
|
||||
|
||||
function MissionItem({ mission, onClaim, onReroll, disabled, canReroll = false, isRerolling = false }: MissionItemProps) {
|
||||
const progressPercent = (mission.currentCount / mission.requiredCount) * 100;
|
||||
const canClaim = mission.completed && !mission.claimed;
|
||||
|
||||
// Can only reroll if: not completed, not claimed, has reroll callback, and has rerolls remaining
|
||||
const showRerollButton = onReroll && !mission.completed && !mission.claimed && canReroll;
|
||||
// ─── Bonus Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative p-3 sm:p-4 rounded-lg border transition-colors overflow-hidden',
|
||||
mission.claimed
|
||||
? 'bg-primary/5 border-primary/20'
|
||||
: mission.completed
|
||||
? 'bg-green-500/5 border-green-500/30'
|
||||
: 'bg-card border-border'
|
||||
)}
|
||||
>
|
||||
{/* Top right area: Claimed badge OR Reroll button */}
|
||||
<div className="absolute top-2 right-2">
|
||||
{mission.claimed ? (
|
||||
<div className="flex items-center gap-1 text-xs text-primary font-medium">
|
||||
<Check className="size-3" />
|
||||
Claimed
|
||||
</div>
|
||||
) : showRerollButton ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={onReroll}
|
||||
disabled={disabled || isRerolling}
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", isRerolling && "animate-spin")} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Replace this mission</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Mission content */}
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{/* Title and description */}
|
||||
<div className="pr-14 sm:pr-16">
|
||||
<h4 className="font-medium text-sm break-words">{mission.title}</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 break-words">
|
||||
{mission.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs gap-2">
|
||||
<span className="text-muted-foreground whitespace-nowrap">
|
||||
{mission.currentCount} / {mission.requiredCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 font-medium text-amber-600 dark:text-amber-400 whitespace-nowrap">
|
||||
<Coins className="size-3 shrink-0" />
|
||||
{formatCompactNumber(mission.reward)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={progressPercent}
|
||||
className={cn(
|
||||
'h-2',
|
||||
mission.completed && '[&>div]:bg-green-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Claim button */}
|
||||
{canClaim && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onClaim}
|
||||
disabled={disabled}
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<Gift className="size-4 mr-2 shrink-0" />
|
||||
<span className="truncate">Claim {formatCompactNumber(mission.reward)} Coins</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Bonus Mission Item ───────────────────────────────────────────────────────
|
||||
|
||||
interface BonusMissionItemProps {
|
||||
interface BonusCardProps {
|
||||
isAvailable: boolean;
|
||||
isClaimed: boolean;
|
||||
reward: number;
|
||||
onClaim: () => void;
|
||||
disabled?: boolean;
|
||||
isExpanded: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
function BonusMissionItem({ isAvailable, isClaimed, reward, onClaim, disabled }: BonusMissionItemProps) {
|
||||
function BonusCard({ isAvailable, isClaimed, reward, onClaim, disabled, isExpanded, onToggle }: BonusCardProps) {
|
||||
const progress = isClaimed ? 1 : isAvailable ? 1 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative p-3 sm:p-4 rounded-lg border-2 transition-colors overflow-hidden',
|
||||
isClaimed
|
||||
? 'bg-amber-500/10 border-amber-500/30'
|
||||
: isAvailable
|
||||
? 'bg-gradient-to-br from-amber-500/10 to-orange-500/10 border-amber-500/40 animate-pulse'
|
||||
: 'bg-muted/30 border-dashed border-muted-foreground/20'
|
||||
)}
|
||||
<ExpandableMissionCard
|
||||
id="bonus"
|
||||
category="daily"
|
||||
icon={<Trophy className="size-5" />}
|
||||
title="Daily Champion"
|
||||
completed={isClaimed}
|
||||
progress={progress}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{/* Claimed badge */}
|
||||
{isClaimed && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 font-medium">
|
||||
<Check className="size-3" />
|
||||
Claimed
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MissionDescription>
|
||||
{isAvailable || isClaimed
|
||||
? 'Bonus reward for completing all daily missions!'
|
||||
: 'Complete all missions to unlock this bonus'}
|
||||
</MissionDescription>
|
||||
|
||||
{/* Mission content */}
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{/* Title and description */}
|
||||
<div className={cn("pr-14 sm:pr-16", !isAvailable && !isClaimed && "opacity-50")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className={cn(
|
||||
"size-4 shrink-0",
|
||||
isClaimed
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: isAvailable
|
||||
? "text-amber-500"
|
||||
: "text-muted-foreground"
|
||||
)} />
|
||||
<h4 className="font-medium text-sm">Daily Champion</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{isAvailable || isClaimed
|
||||
? 'Bonus reward for completing all daily missions!'
|
||||
: 'Complete all missions above to unlock this bonus'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reward display */}
|
||||
<div className="flex items-center justify-between text-xs gap-2">
|
||||
<span className={cn(
|
||||
"text-muted-foreground",
|
||||
!isAvailable && !isClaimed && "opacity-50"
|
||||
)}>
|
||||
Bonus Reward
|
||||
</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 font-medium",
|
||||
isClaimed || isAvailable
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
<Coins className="size-3 shrink-0" />
|
||||
+{formatCompactNumber(reward)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Claim button */}
|
||||
{isAvailable && !isClaimed && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onClaim}
|
||||
disabled={disabled}
|
||||
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
|
||||
>
|
||||
<Trophy className="size-4 mr-2 shrink-0" />
|
||||
<span className="truncate">Claim Bonus {formatCompactNumber(reward)} Coins!</span>
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
<Coins className="size-3" />
|
||||
+{formatCompactNumber(reward)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAvailable && !isClaimed && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onClaim}
|
||||
disabled={disabled}
|
||||
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white h-8 text-xs"
|
||||
>
|
||||
<Trophy className="size-3.5 mr-1.5" />
|
||||
Claim Bonus {formatCompactNumber(reward)} Coins
|
||||
</Button>
|
||||
)}
|
||||
</ExpandableMissionCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── No Missions Available State ──────────────────────────────────────────────
|
||||
// ─── Empty / Done States ──────────────────────────────────────────────────────
|
||||
|
||||
function NoMissionsState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
|
||||
<Egg className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold text-sm">Hatch Your Blobbi First</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Daily missions will be available once you have
|
||||
<br />
|
||||
a hatched Blobbi to interact with!
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<Egg className="size-5 text-muted-foreground/50" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Hatch your Blobbi first</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Daily missions unlock after hatching
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── All Claimed State ────────────────────────────────────────────────────────
|
||||
|
||||
interface AllClaimedStateProps {
|
||||
todayCoins: number;
|
||||
}
|
||||
|
||||
function AllClaimedState({ todayCoins }: AllClaimedStateProps) {
|
||||
function AllClaimedState({ todayCoins }: { todayCoins: number }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<div className="size-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Sparkles className="size-6 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold text-sm">All Done for Today!</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You earned <span className="font-medium text-amber-600 dark:text-amber-400">{formatCompactNumber(todayCoins)} coins</span> today.
|
||||
<br />
|
||||
Come back tomorrow for new missions!
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<Sparkles className="size-5 text-primary/60" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">All done for today</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Earned{' '}
|
||||
<span className="font-medium text-amber-600 dark:text-amber-400">
|
||||
{formatCompactNumber(todayCoins)} coins
|
||||
</span>{' '}
|
||||
— come back tomorrow!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -288,20 +167,17 @@ function AllClaimedState({ todayCoins }: AllClaimedStateProps) {
|
||||
|
||||
// ─── Reroll Counter ───────────────────────────────────────────────────────────
|
||||
|
||||
interface RerollCounterProps {
|
||||
remaining: number;
|
||||
}
|
||||
function RerollCounter({ remaining }: { remaining: number }) {
|
||||
const text =
|
||||
remaining === 0
|
||||
? 'No rerolls left'
|
||||
: remaining === 1
|
||||
? '1 reroll left'
|
||||
: `${remaining} rerolls left`;
|
||||
|
||||
function RerollCounter({ remaining }: RerollCounterProps) {
|
||||
const text = remaining === 0
|
||||
? 'No rerolls left'
|
||||
: remaining === 1
|
||||
? '1 reroll left'
|
||||
: `${remaining} rerolls left`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
||||
<RefreshCw className="size-3" />
|
||||
<div className="flex items-center justify-end gap-1 text-[11px] text-muted-foreground col-span-full">
|
||||
<RefreshCw className="size-2.5" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -322,48 +198,121 @@ export function DailyMissionsPanel({
|
||||
rerollsRemaining = 0,
|
||||
isRerolling = false,
|
||||
}: DailyMissionsPanelProps) {
|
||||
// Show empty state if user has no eligible missions (e.g., only eggs)
|
||||
if (noMissionsAvailable) {
|
||||
return <NoMissionsState />;
|
||||
}
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
if (noMissionsAvailable) return <NoMissionsState />;
|
||||
|
||||
const allRegularClaimed = missions.every((m) => m.claimed);
|
||||
const allDone = allRegularClaimed && bonusClaimed;
|
||||
|
||||
// Show "all done" state only when everything including bonus is claimed
|
||||
if (allDone) {
|
||||
return <AllClaimedState todayCoins={todayCoins} />;
|
||||
}
|
||||
if (allDone) return <AllClaimedState todayCoins={todayCoins} />;
|
||||
|
||||
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Reroll counter - only show if reroll functionality is available */}
|
||||
{onRerollMission && (
|
||||
<RerollCounter remaining={rerollsRemaining} />
|
||||
)}
|
||||
|
||||
{/* Regular missions */}
|
||||
{missions.map((mission) => (
|
||||
<MissionItem
|
||||
key={mission.id}
|
||||
mission={mission}
|
||||
onClaim={() => onClaimReward(mission.id)}
|
||||
onReroll={onRerollMission ? () => onRerollMission(mission.id) : undefined}
|
||||
disabled={disabled}
|
||||
canReroll={canReroll}
|
||||
isRerolling={isRerolling}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Bonus mission - always visible */}
|
||||
<BonusMissionItem
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{/* Reroll counter */}
|
||||
{onRerollMission && <RerollCounter remaining={rerollsRemaining} />}
|
||||
|
||||
{/* Regular mission cards */}
|
||||
{missions.map((mission) => {
|
||||
const progress = mission.requiredCount > 0 ? mission.currentCount / mission.requiredCount : 0;
|
||||
const canClaim = mission.completed && !mission.claimed;
|
||||
const showReroll = onRerollMission && !mission.completed && !mission.claimed && canReroll;
|
||||
|
||||
return (
|
||||
<ExpandableMissionCard
|
||||
key={mission.id}
|
||||
id={mission.id}
|
||||
category="daily"
|
||||
icon={<DailyMissionIcon action={mission.action} />}
|
||||
title={mission.title}
|
||||
completed={mission.claimed}
|
||||
progress={Math.min(progress, 1)}
|
||||
isExpanded={expandedId === mission.id}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
{/* Description */}
|
||||
<MissionDescription>{mission.description}</MissionDescription>
|
||||
|
||||
{/* Progress */}
|
||||
{!mission.claimed && (
|
||||
<MissionProgress
|
||||
current={mission.currentCount}
|
||||
required={mission.requiredCount}
|
||||
completed={mission.completed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reward + reroll row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
<Coins className="size-3" />
|
||||
{formatCompactNumber(mission.reward)}
|
||||
</span>
|
||||
|
||||
{showReroll && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRerollMission(mission.id);
|
||||
}}
|
||||
disabled={disabled || isRerolling}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw className={cn('size-3', isRerolling && 'animate-spin')} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Replace mission</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{mission.claimed && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary">
|
||||
<Check className="size-2.5" />
|
||||
Done
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Claim button */}
|
||||
{canClaim && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClaimReward(mission.id);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white h-8 text-xs"
|
||||
>
|
||||
<Gift className="size-3.5 mr-1.5" />
|
||||
Claim {formatCompactNumber(mission.reward)} Coins
|
||||
</Button>
|
||||
)}
|
||||
</ExpandableMissionCard>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Bonus card */}
|
||||
<BonusCard
|
||||
isAvailable={bonusAvailable}
|
||||
isClaimed={bonusClaimed}
|
||||
reward={bonusReward}
|
||||
onClaim={() => onClaimReward(BONUS_MISSION_ID)}
|
||||
disabled={disabled}
|
||||
isExpanded={expandedId === 'bonus'}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// src/blobbi/actions/components/ExpandableMissionCard.tsx
|
||||
|
||||
/**
|
||||
* Expandable mission card for the quest-board grid.
|
||||
*
|
||||
* Collapsed: compact square-ish card showing icon, title, and a tiny
|
||||
* progress ring / checkmark.
|
||||
* Expanded: full-width row that reveals description, progress bar,
|
||||
* action link, claim button, dynamic hints, etc.
|
||||
*
|
||||
* Only one card is expanded at a time per section (controlled by parent).
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Check, ChevronRight, ExternalLink, AlertCircle } from 'lucide-react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type MissionCategory = 'daily' | 'hatch' | 'evolve';
|
||||
|
||||
export interface ExpandableMissionCardProps {
|
||||
/** Unique id used to track which card is expanded */
|
||||
id: string;
|
||||
/** Mission category for visual styling */
|
||||
category: MissionCategory;
|
||||
/** Icon rendered in the compact card (ReactNode — usually a lucide icon or emoji span) */
|
||||
icon: ReactNode;
|
||||
/** Short title */
|
||||
title: string;
|
||||
/** Whether the mission is complete */
|
||||
completed: boolean;
|
||||
/** Progress fraction 0-1 (used for the tiny ring in compact mode) */
|
||||
progress: number;
|
||||
/** Whether this card is currently expanded */
|
||||
isExpanded: boolean;
|
||||
/** Parent calls this to toggle expansion */
|
||||
onToggle: (id: string) => void;
|
||||
/** Content rendered only when expanded */
|
||||
children: ReactNode;
|
||||
/** Optional extra className on the outer wrapper */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Tiny Progress Ring ───────────────────────────────────────────────────────
|
||||
|
||||
function ProgressRing({ progress, completed, category }: { progress: number; completed: boolean; category: MissionCategory }) {
|
||||
const size = 28;
|
||||
const stroke = 2.5;
|
||||
const radius = (size - stroke) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - progress * circumference;
|
||||
|
||||
if (completed) {
|
||||
return (
|
||||
<div className="size-7 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||||
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ringColor =
|
||||
category === 'hatch'
|
||||
? 'text-sky-500'
|
||||
: category === 'evolve'
|
||||
? 'text-violet-500'
|
||||
: 'text-amber-500';
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className={cn('shrink-0 -rotate-90', ringColor)}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
opacity={0.15}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Accent colors per category ───────────────────────────────────────────────
|
||||
|
||||
const CATEGORY_STYLES: Record<MissionCategory, { bg: string; expandedBg: string; border: string }> = {
|
||||
daily: {
|
||||
bg: 'bg-amber-500/[0.06] hover:bg-amber-500/10',
|
||||
expandedBg: 'bg-amber-500/[0.06]',
|
||||
border: 'ring-amber-500/20',
|
||||
},
|
||||
hatch: {
|
||||
bg: 'bg-sky-500/[0.06] hover:bg-sky-500/10',
|
||||
expandedBg: 'bg-sky-500/[0.06]',
|
||||
border: 'ring-sky-500/20',
|
||||
},
|
||||
evolve: {
|
||||
bg: 'bg-violet-500/[0.06] hover:bg-violet-500/10',
|
||||
expandedBg: 'bg-violet-500/[0.06]',
|
||||
border: 'ring-violet-500/20',
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ExpandableMissionCard({
|
||||
id,
|
||||
category,
|
||||
icon,
|
||||
title,
|
||||
completed,
|
||||
progress,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
children,
|
||||
className,
|
||||
}: ExpandableMissionCardProps) {
|
||||
const styles = CATEGORY_STYLES[category];
|
||||
|
||||
// ── Collapsed card ──
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(id)}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 rounded-xl p-3 transition-all text-center cursor-pointer select-none',
|
||||
'ring-1 ring-transparent',
|
||||
completed ? 'bg-emerald-500/[0.06] hover:bg-emerald-500/10' : styles.bg,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="text-lg leading-none">{icon}</div>
|
||||
|
||||
{/* Title — 2 lines max */}
|
||||
<span className={cn(
|
||||
'text-[11px] font-medium leading-tight line-clamp-2 min-h-[2lh]',
|
||||
completed && 'text-emerald-600 dark:text-emerald-400',
|
||||
)}>
|
||||
{title}
|
||||
</span>
|
||||
|
||||
{/* Progress ring / check */}
|
||||
<ProgressRing progress={progress} completed={completed} category={category} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Expanded card (spans full row) ──
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'col-span-full rounded-xl ring-1 transition-all overflow-hidden',
|
||||
completed ? 'bg-emerald-500/[0.06] ring-emerald-500/20' : cn(styles.expandedBg, styles.border),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Compact header — click to collapse */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(id)}
|
||||
className="w-full flex items-center gap-3 p-3 text-left cursor-pointer select-none"
|
||||
>
|
||||
<div className="text-lg leading-none shrink-0">{icon}</div>
|
||||
<span className={cn(
|
||||
'text-sm font-medium flex-1 min-w-0',
|
||||
completed && 'text-emerald-600 dark:text-emerald-400',
|
||||
)}>
|
||||
{title}
|
||||
</span>
|
||||
<ProgressRing progress={progress} completed={completed} category={category} />
|
||||
</button>
|
||||
|
||||
{/* Expanded details */}
|
||||
<div className="px-3 pb-3 pt-0 space-y-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared detail sub-components ─────────────────────────────────────────────
|
||||
|
||||
/** Description text */
|
||||
export function MissionDescription({ children }: { children: ReactNode }) {
|
||||
return <p className="text-xs text-muted-foreground leading-snug">{children}</p>;
|
||||
}
|
||||
|
||||
/** Progress bar with fraction label */
|
||||
export function MissionProgress({ current, required, completed }: { current: number; required: number; completed: boolean }) {
|
||||
const pct = required > 0 ? Math.round((current / required) * 100) : 0;
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground mb-1">
|
||||
<span className="tabular-nums">{current} / {required}</span>
|
||||
<span className="tabular-nums">{pct}%</span>
|
||||
</div>
|
||||
<Progress value={pct} className={cn('h-1.5', completed && '[&>div]:bg-emerald-500')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline action link (navigate, external, modal) */
|
||||
export function MissionAction({
|
||||
label,
|
||||
type,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
type: 'navigate' | 'external_link' | 'open_modal';
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{label}
|
||||
{type === 'external_link' ? (
|
||||
<ExternalLink className="size-3" />
|
||||
) : (
|
||||
<ChevronRight className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dynamic / live task hint */
|
||||
export function DynamicHint({ current, required }: { current: number; required: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-amber-600/80 dark:text-amber-400/80">
|
||||
<AlertCircle className="size-3 shrink-0" />
|
||||
<span>Lowest stat: {current}% (need {required}%+)</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
// src/blobbi/actions/components/TasksPanel.tsx
|
||||
|
||||
/**
|
||||
* Generic UI component for displaying task progress.
|
||||
* Shows a list of tasks with progress indicators and action buttons.
|
||||
* Used for both hatch and evolve tasks.
|
||||
* Card-grid presentation for hatch / evolve tasks.
|
||||
*
|
||||
* Each task is a compact card in a 2-column grid.
|
||||
* Tapping a card expands it inline (full row) to reveal details.
|
||||
* Only one card is expanded at a time.
|
||||
*/
|
||||
|
||||
import { ExternalLink, Check, Loader2, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Palette,
|
||||
Droplets,
|
||||
MessageSquare,
|
||||
Heart,
|
||||
UserPen,
|
||||
Activity,
|
||||
Loader2,
|
||||
HelpCircle,
|
||||
} from 'lucide-react';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { HatchTask } from '../hooks/useHatchTasks';
|
||||
import type { MissionCategory } from './ExpandableMissionCard';
|
||||
import {
|
||||
ExpandableMissionCard,
|
||||
MissionDescription,
|
||||
MissionProgress,
|
||||
MissionAction,
|
||||
DynamicHint,
|
||||
} from './ExpandableMissionCard';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -24,149 +40,38 @@ interface TasksPanelProps {
|
||||
tasks: HatchTask[];
|
||||
allCompleted: boolean;
|
||||
isLoading: boolean;
|
||||
/** Called when user clicks "Create Post" action */
|
||||
onOpenPostModal: () => void;
|
||||
/** Called when all tasks are complete and user clicks the complete button */
|
||||
onComplete: () => void;
|
||||
/** Whether completion is in progress */
|
||||
isCompleting?: boolean;
|
||||
/** Emoji to show in header */
|
||||
emoji: string;
|
||||
/** Title for the tasks panel */
|
||||
title: string;
|
||||
/** Description for the tasks panel */
|
||||
description: string;
|
||||
/** Label for the complete button */
|
||||
completeLabel: string;
|
||||
/** Label while completing */
|
||||
completingLabel: string;
|
||||
/** Emoji for complete button */
|
||||
completeEmoji: string;
|
||||
/** Mission category for styling the cards */
|
||||
category?: MissionCategory;
|
||||
}
|
||||
|
||||
// ─── Task Row Component ───────────────────────────────────────────────────────
|
||||
// ─── Task Icon Mapping ────────────────────────────────────────────────────────
|
||||
|
||||
interface TaskRowProps {
|
||||
task: HatchTask;
|
||||
onOpenPostModal: () => void;
|
||||
}
|
||||
/** Map task ids to lucide icons. Falls back to a generic icon. */
|
||||
function TaskIcon({ taskId }: { taskId: string }) {
|
||||
const iconClass = 'size-5';
|
||||
|
||||
function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
|
||||
const navigate = useNavigate();
|
||||
const isDynamic = task.type === 'dynamic';
|
||||
|
||||
const handleAction = () => {
|
||||
if (!task.action || !task.actionTarget) return;
|
||||
|
||||
switch (task.action) {
|
||||
case 'navigate':
|
||||
navigate(task.actionTarget);
|
||||
break;
|
||||
case 'external_link':
|
||||
openUrl(task.actionTarget);
|
||||
break;
|
||||
case 'open_modal':
|
||||
if (task.actionTarget === 'blobbi_post') {
|
||||
onOpenPostModal();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const progress = task.required > 1
|
||||
? Math.round((task.current / task.required) * 100)
|
||||
: task.completed ? 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border transition-all overflow-hidden",
|
||||
task.completed
|
||||
? "bg-emerald-500/5 border-emerald-500/20"
|
||||
: isDynamic
|
||||
? "bg-amber-500/5 border-amber-500/20"
|
||||
: "bg-card/60 border-border hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
{/* Top row on mobile: Status + Task info */}
|
||||
<div className="flex items-start sm:items-center gap-3 sm:contents">
|
||||
{/* Status indicator */}
|
||||
<div className={cn(
|
||||
"size-8 sm:size-10 rounded-full flex items-center justify-center shrink-0",
|
||||
task.completed
|
||||
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
|
||||
: isDynamic
|
||||
? "bg-amber-500/20 text-amber-600 dark:text-amber-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{task.completed ? (
|
||||
<Check className="size-4 sm:size-5" />
|
||||
) : isDynamic ? (
|
||||
<AlertCircle className="size-4 sm:size-5" />
|
||||
) : task.required > 1 ? (
|
||||
<span className="text-xs sm:text-sm font-medium">{task.current}/{task.required}</span>
|
||||
) : (
|
||||
<span className="text-base sm:text-lg">○</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1 sm:gap-2 mb-0.5 sm:mb-1">
|
||||
<h4 className={cn(
|
||||
"font-medium text-sm sm:text-base break-words",
|
||||
task.completed && "text-emerald-600 dark:text-emerald-400",
|
||||
isDynamic && !task.completed && "text-amber-600 dark:text-amber-400"
|
||||
)}>
|
||||
{task.name}
|
||||
</h4>
|
||||
{task.completed && (
|
||||
<Badge variant="secondary" className="bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs shrink-0">
|
||||
Complete
|
||||
</Badge>
|
||||
)}
|
||||
{isDynamic && !task.completed && (
|
||||
<Badge variant="secondary" className="bg-amber-500/20 text-amber-700 dark:text-amber-300 text-xs shrink-0">
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground break-words">
|
||||
{task.description}
|
||||
</p>
|
||||
|
||||
{/* Progress bar for multi-step tasks (not for dynamic stat tasks) */}
|
||||
{task.required > 1 && !task.completed && !isDynamic && (
|
||||
<Progress value={progress} className="h-1.5 mt-2" />
|
||||
)}
|
||||
|
||||
{/* Dynamic task hint */}
|
||||
{isDynamic && !task.completed && (
|
||||
<p className="text-xs text-amber-600/70 dark:text-amber-400/70 mt-1">
|
||||
Lowest stat: {task.current}% (need {task.required}%+)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action button - full width on mobile when present */}
|
||||
{task.action && task.actionLabel && !task.completed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAction}
|
||||
className="shrink-0 gap-2 w-full sm:w-auto mt-1 sm:mt-0"
|
||||
>
|
||||
<span className="truncate">{task.actionLabel}</span>
|
||||
{task.action === 'external_link' ? (
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
switch (taskId) {
|
||||
case 'create_themes':
|
||||
return <Palette className={iconClass} />;
|
||||
case 'color_moments':
|
||||
return <Droplets className={iconClass} />;
|
||||
case 'create_posts':
|
||||
return <MessageSquare className={iconClass} />;
|
||||
case 'interactions':
|
||||
return <Heart className={iconClass} />;
|
||||
case 'edit_profile':
|
||||
return <UserPen className={iconClass} />;
|
||||
case 'maintain_stats':
|
||||
return <Activity className={iconClass} />;
|
||||
default:
|
||||
return <HelpCircle className={iconClass} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
@@ -178,86 +83,113 @@ export function TasksPanel({
|
||||
onOpenPostModal,
|
||||
onComplete,
|
||||
isCompleting = false,
|
||||
emoji,
|
||||
title,
|
||||
description,
|
||||
completeLabel,
|
||||
completingLabel,
|
||||
completeEmoji,
|
||||
category = 'hatch',
|
||||
}: TasksPanelProps) {
|
||||
const completedCount = tasks.filter(t => t.completed).length;
|
||||
const totalTasks = tasks.length;
|
||||
const overallProgress = totalTasks > 0 ? Math.round((completedCount / totalTasks) * 100) : 0;
|
||||
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent overflow-hidden">
|
||||
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
|
||||
<div className="flex items-start sm:items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<span className="text-xl sm:text-2xl shrink-0">{emoji}</span>
|
||||
<span className="break-words">{title}</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs sm:text-sm break-words">
|
||||
{description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-sm sm:text-base px-2 sm:px-3 py-0.5 sm:py-1 shrink-0">
|
||||
{completedCount}/{totalTasks}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Overall progress */}
|
||||
<div className="mt-3 sm:mt-4">
|
||||
<div className="flex items-center justify-between text-xs sm:text-sm mb-2">
|
||||
<span className="text-muted-foreground">Overall progress</span>
|
||||
<span className="font-medium">{overallProgress}%</span>
|
||||
</div>
|
||||
<Progress value={overallProgress} className="h-2" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2 sm:space-y-3 px-3 sm:px-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{tasks.map(task => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Complete button - only visible when all tasks complete */}
|
||||
{allCompleted && (
|
||||
<div className="pt-4 border-t border-border mt-4">
|
||||
<Button
|
||||
onClick={onComplete}
|
||||
disabled={isCompleting}
|
||||
size="lg"
|
||||
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
|
||||
>
|
||||
{isCompleting ? (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
{completingLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xl">{completeEmoji}</span>
|
||||
{completeLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-3">
|
||||
{/* Card grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{tasks.map((task) => {
|
||||
const isDynamic = task.type === 'dynamic';
|
||||
const progress =
|
||||
task.required > 0 ? task.current / task.required : task.completed ? 1 : 0;
|
||||
|
||||
const handleAction = () => {
|
||||
if (!task.action || !task.actionTarget) return;
|
||||
switch (task.action) {
|
||||
case 'navigate':
|
||||
navigate(task.actionTarget);
|
||||
break;
|
||||
case 'external_link':
|
||||
openUrl(task.actionTarget);
|
||||
break;
|
||||
case 'open_modal':
|
||||
if (task.actionTarget === 'blobbi_post') onOpenPostModal();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ExpandableMissionCard
|
||||
key={task.id}
|
||||
id={task.id}
|
||||
category={category}
|
||||
icon={<TaskIcon taskId={task.id} />}
|
||||
title={task.name}
|
||||
completed={task.completed}
|
||||
progress={Math.min(progress, 1)}
|
||||
isExpanded={expandedId === task.id}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
{/* Expanded content */}
|
||||
<MissionDescription>{task.description}</MissionDescription>
|
||||
|
||||
{/* Progress bar for multi-step tasks */}
|
||||
{task.required > 1 && !isDynamic && (
|
||||
<MissionProgress
|
||||
current={task.current}
|
||||
required={task.required}
|
||||
completed={task.completed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dynamic stat hint */}
|
||||
{isDynamic && !task.completed && (
|
||||
<DynamicHint current={task.current} required={task.required} />
|
||||
)}
|
||||
|
||||
{/* Action link */}
|
||||
{task.action && task.actionLabel && !task.completed && (
|
||||
<MissionAction
|
||||
label={task.actionLabel}
|
||||
type={task.action}
|
||||
onClick={handleAction}
|
||||
/>
|
||||
)}
|
||||
</ExpandableMissionCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* CTA button when all tasks are done */}
|
||||
{allCompleted && (
|
||||
<Button
|
||||
onClick={onComplete}
|
||||
disabled={isCompleting}
|
||||
size="lg"
|
||||
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white shadow-sm"
|
||||
>
|
||||
{isCompleting ? (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
{completingLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-lg">{completeEmoji}</span>
|
||||
{completeLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -30,8 +30,8 @@ import {
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Kind for wall edit events */
|
||||
export const KIND_WALL_EDIT = 16769;
|
||||
/** Kind for custom profile tabs event */
|
||||
export const KIND_PROFILE_TABS = 16769;
|
||||
|
||||
/** Required themes for evolve task */
|
||||
export const EVOLVE_REQUIRED_THEMES = 3;
|
||||
@@ -117,7 +117,7 @@ export function isValidEvolvePost(event: NostrEvent, blobbiName: string): boolea
|
||||
* 2. Create 3 Color Moments (kind 3367)
|
||||
* 3. Create 1 Evolve Post (kind 1) - lighter than hatch, evolve-specific
|
||||
* 4. Interact 21 times (tracked via companion.tasks cache)
|
||||
* 5. Edit Wall once (kind 16769)
|
||||
* 5. Edit Profile once (kind 0 profile metadata OR kind 16769 custom tabs)
|
||||
*
|
||||
* DYNAMIC TASK (stat-based, NEVER cached):
|
||||
* 6. Maintain All Stats >= 80
|
||||
@@ -165,14 +165,14 @@ export function useEvolveTasks(
|
||||
since: stateStartedAt,
|
||||
limit: 50, // Only need 1 valid evolve post
|
||||
},
|
||||
// Wall edits after start
|
||||
// Custom profile tabs after start
|
||||
{
|
||||
kinds: [KIND_WALL_EDIT],
|
||||
kinds: [KIND_PROFILE_TABS],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 1, // Only need 1
|
||||
},
|
||||
// Profile metadata after start (for Blobbi shape check)
|
||||
// Profile metadata after start (for Blobbi shape check + profile edit mission)
|
||||
{
|
||||
kinds: [KIND_PROFILE_METADATA],
|
||||
authors: [pubkey],
|
||||
@@ -197,8 +197,8 @@ export function useEvolveTasks(
|
||||
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const wallEditEvents = events.filter(e =>
|
||||
e.kind === KIND_WALL_EDIT && e.created_at >= stateStartedAt
|
||||
const profileTabsEvents = events.filter(e =>
|
||||
e.kind === KIND_PROFILE_TABS && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
// Get latest profile after start
|
||||
@@ -211,7 +211,7 @@ export function useEvolveTasks(
|
||||
themeEvents,
|
||||
colorMomentEvents,
|
||||
postEvents,
|
||||
wallEditEvents,
|
||||
profileTabsEvents,
|
||||
profileAfter,
|
||||
};
|
||||
},
|
||||
@@ -287,20 +287,21 @@ export function useEvolveTasks(
|
||||
// No action - just interact with Blobbi
|
||||
});
|
||||
|
||||
// 5. Edit Wall once (PERSISTENT)
|
||||
const wallEditCount = data?.wallEditEvents?.length ?? 0;
|
||||
const hasWallEdit = wallEditCount >= 1;
|
||||
// 5. Edit Profile once (PERSISTENT) — kind 0 profile metadata OR kind 16769 custom tabs
|
||||
const hasTabsEdit = (data?.profileTabsEvents?.length ?? 0) >= 1;
|
||||
const hasMetadataEdit = !!data?.profileAfter;
|
||||
const hasProfileEdit = hasTabsEdit || hasMetadataEdit;
|
||||
tasks.push({
|
||||
id: 'edit_wall',
|
||||
name: 'Edit Your Wall',
|
||||
description: 'Customize your profile wall',
|
||||
current: hasWallEdit ? 1 : 0,
|
||||
id: 'edit_profile',
|
||||
name: 'Edit Your Profile',
|
||||
description: 'Update your profile info or customize your profile tabs',
|
||||
current: hasProfileEdit ? 1 : 0,
|
||||
required: 1,
|
||||
completed: hasWallEdit,
|
||||
completed: hasProfileEdit,
|
||||
type: 'persistent',
|
||||
action: 'navigate',
|
||||
actionTarget: '/settings/profile',
|
||||
actionLabel: 'Edit Wall',
|
||||
actionLabel: 'Edit Profile',
|
||||
});
|
||||
|
||||
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -33,13 +34,10 @@ export const KIND_SHORT_TEXT_NOTE = 1;
|
||||
export const HATCH_REQUIRED_INTERACTIONS = 7;
|
||||
|
||||
/** Required hashtags for the Blobbi post (excludes Blobbi name, which is dynamic) */
|
||||
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi', 'ditto', 'nostr'];
|
||||
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi'];
|
||||
|
||||
/** 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;
|
||||
/** Prefix text for Blobbi hatch post (the Blobbi name is appended after this) */
|
||||
export const BLOBBI_POST_PREFIX = 'Posting to hatch';
|
||||
|
||||
// Legacy export for backwards compatibility
|
||||
export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS;
|
||||
@@ -112,16 +110,28 @@ export interface HatchTasksResult {
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the required phrase for a hatch post.
|
||||
* Format: "Posting to hatch {CapitalizedName} #blobbi"
|
||||
*/
|
||||
export function buildHatchPhrase(blobbiName: string): string {
|
||||
const capitalized = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
|
||||
return `${BLOBBI_POST_PREFIX} ${capitalized} #blobbi`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a post is a valid Blobbi hatch post.
|
||||
* Must contain the required prefix and all required hashtags including the Blobbi name.
|
||||
* The post must contain the required phrase: "Posting to hatch {Name} #blobbi"
|
||||
* The user may add extra text before or after it.
|
||||
*
|
||||
* @param event - The Nostr event to validate
|
||||
* @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag)
|
||||
* @param blobbiName - The Blobbi's name
|
||||
*/
|
||||
export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean {
|
||||
// Check content starts with prefix
|
||||
if (!event.content.startsWith(BLOBBI_POST_PREFIX)) {
|
||||
const phrase = buildHatchPhrase(blobbiName);
|
||||
|
||||
// The phrase must appear somewhere in the content
|
||||
if (!event.content.includes(phrase)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -130,18 +140,12 @@ export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean
|
||||
.filter(tag => tag[0] === 't')
|
||||
.map(tag => tag[1]?.toLowerCase());
|
||||
|
||||
// All required hashtags must be present
|
||||
// All required hashtags must be present as t tags
|
||||
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
|
||||
hashtags.includes(required.toLowerCase())
|
||||
);
|
||||
|
||||
if (!hasRequiredHashtags) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Blobbi name hashtag must also be present
|
||||
const blobbiHashtag = sanitizeToHashtag(blobbiName);
|
||||
return hashtags.includes(blobbiHashtag);
|
||||
return hasRequiredHashtags;
|
||||
}
|
||||
|
||||
// Legacy function name for backwards compatibility
|
||||
@@ -158,8 +162,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 +327,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');
|
||||
|
||||
@@ -57,10 +57,10 @@ export {
|
||||
sanitizeToHashtag,
|
||||
isValidHatchPost,
|
||||
isValidBlobbiPost, // Legacy export
|
||||
buildHatchPhrase,
|
||||
KIND_THEME_DEFINITION,
|
||||
KIND_COLOR_MOMENT,
|
||||
HATCH_REQUIRED_INTERACTIONS,
|
||||
HATCH_STAT_THRESHOLD,
|
||||
REQUIRED_INTERACTIONS, // Legacy export
|
||||
BLOBBI_POST_PREFIX,
|
||||
BLOBBI_POST_REQUIRED_HASHTAGS,
|
||||
@@ -71,7 +71,7 @@ export {
|
||||
useEvolveTasks,
|
||||
getEvolveInteractionCount,
|
||||
isValidEvolvePost,
|
||||
KIND_WALL_EDIT,
|
||||
KIND_PROFILE_TABS,
|
||||
EVOLVE_REQUIRED_THEMES,
|
||||
EVOLVE_REQUIRED_COLOR_MOMENTS,
|
||||
EVOLVE_REQUIRED_POSTS,
|
||||
@@ -111,7 +111,7 @@ export {
|
||||
} 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';
|
||||
|
||||
@@ -217,11 +217,6 @@ export function canUseItemForStage(
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
// Accessories are disabled
|
||||
if (shopItem.type === 'accessory') {
|
||||
return { canUse: false, reason: 'Accessories are not usable yet' };
|
||||
}
|
||||
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
getLocalDayString,
|
||||
getDaysDifference,
|
||||
type BlobbiCompanion,
|
||||
} from '@/lib/blobbi';
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
@@ -66,7 +66,6 @@ const CATEGORY_TO_PRIMARY_STAT: Record<ShopItemCategory, (keyof BlobbiStats)[]>
|
||||
toy: ['happiness'],
|
||||
hygiene: ['hygiene'],
|
||||
medicine: ['health'],
|
||||
accessory: [], // Accessories don't address needs
|
||||
};
|
||||
|
||||
// ─── Need Detection Functions ─────────────────────────────────────────────────
|
||||
|
||||
@@ -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,184 @@
|
||||
/**
|
||||
* 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 { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
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 event = await fetchFreshEvent(
|
||||
nostr,
|
||||
{ kinds: [KIND_BLOBBI_STATE], authors: [pubkey], '#d': [dTag] },
|
||||
{ eoseTimeout: 1000 },
|
||||
);
|
||||
|
||||
if (!event || !isValidBlobbiEvent(event)) return null;
|
||||
return parseBlobbiEvent(event) ?? 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 {
|
||||
|
||||
@@ -82,7 +82,6 @@ export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null
|
||||
toy: 'play',
|
||||
medicine: 'medicine',
|
||||
hygiene: 'clean',
|
||||
accessory: null, // Accessories are cosmetic, not usable
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { toast } from './useToast';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type BlobbiCompanion,
|
||||
type BlobbonautProfile,
|
||||
type StorageItem,
|
||||
} from '@/lib/blobbi';
|
||||
} from '../lib/blobbi';
|
||||
|
||||
/**
|
||||
* Result of a successful migration.
|
||||
@@ -3,13 +3,14 @@ import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { fetchFreshEvents } from '@/lib/fetchFreshEvent';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
isValidBlobbiEvent,
|
||||
parseBlobbiEvent,
|
||||
type BlobbiCompanion,
|
||||
} from '@/lib/blobbi';
|
||||
} from '../lib/blobbi';
|
||||
|
||||
/** Maximum number of d-tags per query chunk to avoid relay issues */
|
||||
const CHUNK_SIZE = 20;
|
||||
@@ -51,46 +52,34 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
// Main query to fetch all companions from relays
|
||||
const query = useQuery({
|
||||
queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags],
|
||||
queryFn: async ({ signal }) => {
|
||||
queryFn: async () => {
|
||||
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
|
||||
// Fetch all chunks, using a relaxed eoseTimeout (1000ms) so slower
|
||||
// relays have time to respond and we get the freshest events.
|
||||
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 });
|
||||
const events = await fetchFreshEvents(
|
||||
nostr,
|
||||
[{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': chunk,
|
||||
}],
|
||||
{ eoseTimeout: 1000 },
|
||||
);
|
||||
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>();
|
||||
|
||||
@@ -116,11 +105,6 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[useBlobbisCollection] Parsed companions:', {
|
||||
count: companions.length,
|
||||
dTags: Object.keys(companionsByD),
|
||||
});
|
||||
|
||||
return { companionsByD, companions };
|
||||
},
|
||||
enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0,
|
||||
+2
-2
@@ -12,8 +12,8 @@
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
|
||||
import type { BlobbiCompanion, BlobbiStats } from '@/lib/blobbi';
|
||||
import { applyBlobbiDecay, getVisibleStatsWithValues, type DecayResult } from '@/lib/blobbi-decay';
|
||||
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;
|
||||
@@ -50,32 +50,6 @@ export interface DecayInput {
|
||||
|
||||
// ─── Constants: Decay Rates ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Egg stage decay rates (per hour).
|
||||
*
|
||||
* Design goal: Needs attention every 2-3 hours.
|
||||
*
|
||||
* Notes:
|
||||
* - hunger and energy are fixed at 100 for eggs
|
||||
* - hygiene decays at 8/hr → reaches warning (75) in ~3.1 hours
|
||||
* - health has conditional decay based on hygiene
|
||||
* - happiness depends on health and hygiene state
|
||||
*/
|
||||
const EGG_DECAY = {
|
||||
hygiene: -8.0, // Base hygiene decay
|
||||
health: {
|
||||
base: -1.0, // Base health decay
|
||||
hygieneBelow70: -2.0, // Extra if hygiene < 70
|
||||
hygieneBelow40: -3.0, // Extra if hygiene < 40
|
||||
},
|
||||
happiness: {
|
||||
// Happiness is calculated after health/hygiene are updated
|
||||
healthyAndClean: 2.0, // health >= 70 AND hygiene >= 70
|
||||
moderate: -2.0, // health >= 40 AND hygiene >= 40
|
||||
poor: -4.0, // otherwise
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Baby stage decay rates (per hour).
|
||||
*
|
||||
@@ -214,6 +188,23 @@ 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 ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -224,44 +215,15 @@ function hoursFromSeconds(seconds: number): number {
|
||||
*/
|
||||
function calculateEggDecay(
|
||||
stats: Partial<BlobbiStats>,
|
||||
elapsedHours: number
|
||||
_elapsedHours: number
|
||||
): BlobbiStats {
|
||||
// Get current values
|
||||
let hygiene = getStat(stats, 'hygiene');
|
||||
let health = getStat(stats, 'health');
|
||||
let happiness = getStat(stats, 'happiness');
|
||||
|
||||
// Calculate hygiene decay first
|
||||
const hygieneDelta = EGG_DECAY.hygiene * elapsedHours;
|
||||
hygiene = clamp(hygiene + Math.floor(hygieneDelta));
|
||||
|
||||
// Calculate health decay (depends on current hygiene)
|
||||
let healthDelta = EGG_DECAY.health.base * elapsedHours;
|
||||
if (hygiene < 70) {
|
||||
healthDelta += EGG_DECAY.health.hygieneBelow70 * elapsedHours;
|
||||
}
|
||||
if (hygiene < 40) {
|
||||
healthDelta += EGG_DECAY.health.hygieneBelow40 * elapsedHours;
|
||||
}
|
||||
health = clamp(health + Math.floor(healthDelta));
|
||||
|
||||
// Calculate happiness (depends on updated health and hygiene)
|
||||
let happinessDelta: number;
|
||||
if (health >= 70 && hygiene >= 70) {
|
||||
happinessDelta = EGG_DECAY.happiness.healthyAndClean * elapsedHours;
|
||||
} else if (health >= 40 && hygiene >= 40) {
|
||||
happinessDelta = EGG_DECAY.happiness.moderate * elapsedHours;
|
||||
} else {
|
||||
happinessDelta = EGG_DECAY.happiness.poor * elapsedHours;
|
||||
}
|
||||
happiness = clamp(happiness + Math.floor(happinessDelta));
|
||||
|
||||
// Eggs do not decay — all stats remain fixed until hatching.
|
||||
return {
|
||||
hunger: 100, // Fixed for eggs
|
||||
energy: 100, // Fixed for eggs
|
||||
hygiene,
|
||||
health,
|
||||
happiness,
|
||||
hunger: 100,
|
||||
energy: 100,
|
||||
hygiene: getStat(stats, 'hygiene'),
|
||||
health: getStat(stats, 'health'),
|
||||
happiness: getStat(stats, 'happiness'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -289,10 +251,10 @@ function calculateBabyDecay(
|
||||
const energyDelta = (isSleeping ? BABY_DECAY.energy.sleeping : BABY_DECAY.energy.awake) * elapsedHours;
|
||||
|
||||
// Apply basic deltas
|
||||
hunger = clamp(hunger + Math.floor(hungerDelta));
|
||||
happiness = clamp(happiness + Math.floor(happinessDelta));
|
||||
hygiene = clamp(hygiene + Math.floor(hygieneDelta));
|
||||
energy = clamp(energy + Math.floor(energyDelta));
|
||||
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;
|
||||
@@ -319,7 +281,7 @@ function calculateBabyDecay(
|
||||
healthDelta += BABY_DECAY.health.regenRate * elapsedHours;
|
||||
}
|
||||
|
||||
health = clamp(health + Math.floor(healthDelta));
|
||||
health = clamp(health + roundDelta(healthDelta));
|
||||
|
||||
return { hunger, happiness, hygiene, energy, health };
|
||||
}
|
||||
@@ -348,10 +310,10 @@ function calculateAdultDecay(
|
||||
const energyDelta = (isSleeping ? ADULT_DECAY.energy.sleeping : ADULT_DECAY.energy.awake) * elapsedHours;
|
||||
|
||||
// Apply basic deltas
|
||||
hunger = clamp(hunger + Math.floor(hungerDelta));
|
||||
happiness = clamp(happiness + Math.floor(happinessDelta));
|
||||
hygiene = clamp(hygiene + Math.floor(hygieneDelta));
|
||||
energy = clamp(energy + Math.floor(energyDelta));
|
||||
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;
|
||||
@@ -378,7 +340,7 @@ function calculateAdultDecay(
|
||||
healthDelta += ADULT_DECAY.health.regenRate * elapsedHours;
|
||||
}
|
||||
|
||||
health = clamp(health + Math.floor(healthDelta));
|
||||
health = clamp(health + roundDelta(healthDelta));
|
||||
|
||||
return { hunger, happiness, hygiene, energy, health };
|
||||
}
|
||||
@@ -391,8 +353,8 @@ function calculateAdultDecay(
|
||||
* This is a pure, deterministic function that:
|
||||
* 1. Calculates elapsed time from lastDecayAt to now
|
||||
* 2. Applies stage-specific decay rates
|
||||
* 3. Floors all stat deltas before application
|
||||
* 4. Clamps final stats to 0-100 range
|
||||
* 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
|
||||
@@ -518,6 +480,12 @@ export function getStatsNeedingAttention(
|
||||
|
||||
// ─── 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.
|
||||
@@ -532,15 +500,18 @@ export function getVisibleStats(stage: BlobbiStage): (keyof BlobbiStats)[] {
|
||||
|
||||
/**
|
||||
* 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),
|
||||
}));
|
||||
return visibleStats
|
||||
.map(stat => ({
|
||||
stat,
|
||||
value: stats[stat] ?? 100,
|
||||
status: getStatStatus(stage, stat, stats[stat] ?? 100),
|
||||
}))
|
||||
.filter(entry => entry.value < STAT_VISIBILITY_THRESHOLD);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export type TagCategory =
|
||||
| 'state' // Lifecycle state (stage, state, timestamps)
|
||||
| 'progression' // Progress tracking (experience, care_streak)
|
||||
| 'task' // Task system (task, task_completed, state_started_at)
|
||||
| 'social' // Social flags (visible_to_others, breeding_ready)
|
||||
| 'social' // Social flags (breeding_ready)
|
||||
| 'evolution' // Evolution-specific (adult_type)
|
||||
| 'extension'; // Extension tags (theme, crossover_app)
|
||||
|
||||
@@ -509,19 +509,6 @@ export const BLOBBI_TAG_SCHEMA: readonly BlobbiTagSchema[] = [
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// SOCIAL / FLAG TAGS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
{
|
||||
tag: 'visible_to_others',
|
||||
description: 'Whether the Blobbi is publicly visible to other users',
|
||||
category: 'social',
|
||||
required: false,
|
||||
stages: ['egg', 'baby', 'adult'],
|
||||
persistent: true,
|
||||
source: 'user',
|
||||
regenerable: false,
|
||||
format: 'true | false',
|
||||
defaultValue: 'true',
|
||||
notes: 'User preference. Persists across stages.',
|
||||
},
|
||||
{
|
||||
tag: 'breeding_ready',
|
||||
description: 'Whether the Blobbi is eligible for breeding',
|
||||
@@ -253,8 +253,6 @@ export interface BlobbiCompanion {
|
||||
lastDecayAt: number | undefined;
|
||||
/** Stats (0-100) */
|
||||
stats: Partial<BlobbiStats>;
|
||||
/** Whether the Blobbi is publicly visible */
|
||||
visibleToOthers: boolean;
|
||||
/** Generation number */
|
||||
generation: number | undefined;
|
||||
/** Breeding eligibility */
|
||||
@@ -894,13 +892,15 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined
|
||||
const isLegacy = isLegacyBlobbiEvent(event);
|
||||
|
||||
// Concise, structured debug log
|
||||
console.log('[Blobbi]', {
|
||||
d: d.length > 30 ? `${d.slice(0, 20)}...` : d,
|
||||
name,
|
||||
isLegacy,
|
||||
hasSeed: !!seed,
|
||||
traits: `${visualTraits.baseColor} ${visualTraits.pattern} ${visualTraits.size}`,
|
||||
});
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[Blobbi]', {
|
||||
d: d.length > 30 ? `${d.slice(0, 20)}...` : d,
|
||||
name,
|
||||
isLegacy,
|
||||
hasSeed: !!seed,
|
||||
traits: `${visualTraits.baseColor} ${visualTraits.pattern} ${visualTraits.size}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Parse task progress tags: ["task", "name:value"]
|
||||
const tasks: BlobbiTaskProgress[] = [];
|
||||
@@ -939,7 +939,6 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined
|
||||
hygiene: parseNumericTag(tags, 'hygiene'),
|
||||
energy: parseNumericTag(tags, 'energy'),
|
||||
},
|
||||
visibleToOthers: parseBooleanTag(tags, 'visible_to_others', true),
|
||||
generation: parseNumericTag(tags, 'generation'),
|
||||
breedingReady: parseBooleanTag(tags, 'breeding_ready', false),
|
||||
experience: parseNumericTag(tags, 'experience'),
|
||||
@@ -979,7 +978,8 @@ export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | und
|
||||
event,
|
||||
d,
|
||||
currentCompanion: getTagValue(tags, 'current_companion'),
|
||||
onboardingDone: parseBooleanTag(tags, 'onboarding_done', false),
|
||||
onboardingDone: parseBooleanTag(tags, 'blobbi_onboarding_done', false)
|
||||
|| parseBooleanTag(tags, 'onboarding_done', false),
|
||||
name: getTagValue(tags, 'name'),
|
||||
has: getTagValues(tags, 'has'),
|
||||
coins: parseNumericTag(tags, 'coins') ?? 0,
|
||||
@@ -999,7 +999,7 @@ export function buildBlobbonautTags(pubkey: string): string[][] {
|
||||
return [
|
||||
['d', getCanonicalBlobbonautD(pubkey)],
|
||||
['b', BLOBBI_ECOSYSTEM_NAMESPACE],
|
||||
['onboarding_done', 'false'],
|
||||
['blobbi_onboarding_done', 'false'],
|
||||
['pettingLevel', '0'],
|
||||
];
|
||||
}
|
||||
@@ -1036,7 +1036,6 @@ export function buildEggTags(
|
||||
['stage', 'egg'],
|
||||
['state', 'active'],
|
||||
['seed', seed],
|
||||
['visible_to_others', 'true'],
|
||||
['generation', '1'],
|
||||
['breeding_ready', 'false'],
|
||||
['experience', '0'],
|
||||
@@ -1084,7 +1083,7 @@ export const MANAGED_BLOBBI_STATE_TAG_NAMES = new Set([
|
||||
// Progression tags
|
||||
'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day',
|
||||
// Social/flag tags
|
||||
'visible_to_others', 'breeding_ready',
|
||||
'breeding_ready',
|
||||
// Task system tags (removed after stage transitions)
|
||||
'state_started_at', 'task', 'task_completed',
|
||||
// Evolution tags (adult only)
|
||||
@@ -1142,7 +1141,7 @@ export const DEPRECATED_BLOBBI_TAG_NAMES = new Set([
|
||||
* These tags are controlled by the application and may be overwritten.
|
||||
*/
|
||||
export const MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES = new Set([
|
||||
'd', 'b', 'name', 'current_companion', 'onboarding_done', 'has', 'storage',
|
||||
'd', 'b', 'name', 'current_companion', 'blobbi_onboarding_done', 'onboarding_done', 'has', 'storage',
|
||||
// Legacy player progress tags (preserved for compatibility)
|
||||
'coins', 'petting_level', 'pettingLevel', 'lifetime_blobbis', 'lifetimeBlobbis',
|
||||
'starter_blobbi', 'starterBlobbi', 'favorite_blobbi', 'favoriteBlobbi',
|
||||
@@ -1369,17 +1368,44 @@ export function profileNeedsPettingLevelNormalization(profile: BlobbonautProfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Build updated tags for normalizing a profile to include pettingLevel.
|
||||
* Preserves all existing tags and adds pettingLevel: 0 if missing.
|
||||
* Check if a profile uses the legacy `onboarding_done` tag instead of the
|
||||
* new `blobbi_onboarding_done` tag. Returns true if migration is needed.
|
||||
*/
|
||||
export function profileNeedsOnboardingTagMigration(profile: BlobbonautProfile): boolean {
|
||||
const hasNewTag = profile.allTags.some(([name]) => name === 'blobbi_onboarding_done');
|
||||
const hasOldTag = profile.allTags.some(([name]) => name === 'onboarding_done');
|
||||
// Needs migration if: has old tag but not the new one
|
||||
return !hasNewTag && hasOldTag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build updated tags for normalizing a profile.
|
||||
* Handles:
|
||||
* - Adding pettingLevel: 0 if missing
|
||||
* - Migrating onboarding_done → blobbi_onboarding_done
|
||||
*
|
||||
* Preserves all existing tags except the ones being migrated.
|
||||
*/
|
||||
export function buildNormalizedProfileTags(profile: BlobbonautProfile): string[][] {
|
||||
if (!profileNeedsPettingLevelNormalization(profile)) {
|
||||
return profile.allTags;
|
||||
let tags = profile.allTags;
|
||||
let changed = false;
|
||||
|
||||
// Normalize pettingLevel
|
||||
if (profileNeedsPettingLevelNormalization(profile)) {
|
||||
tags = updateBlobbonautTags(tags, { pettingLevel: '0' });
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return updateBlobbonautTags(profile.allTags, {
|
||||
pettingLevel: '0',
|
||||
});
|
||||
|
||||
// Migrate onboarding_done → blobbi_onboarding_done
|
||||
if (profileNeedsOnboardingTagMigration(profile)) {
|
||||
const oldValue = tags.find(([name]) => name === 'onboarding_done')?.[1] ?? 'false';
|
||||
// Remove old tag, add new tag
|
||||
tags = tags.filter(([name]) => name !== 'onboarding_done');
|
||||
tags = updateBlobbonautTags(tags, { blobbi_onboarding_done: oldValue });
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? tags : profile.allTags;
|
||||
}
|
||||
|
||||
// ─── Query Helpers ────────────────────────────────────────────────────────────
|
||||
@@ -1463,7 +1489,7 @@ export function buildMigrationTags(
|
||||
// Progression tags
|
||||
'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day',
|
||||
// Social/flag tags
|
||||
'visible_to_others', 'generation', 'breeding_ready',
|
||||
'generation', 'breeding_ready',
|
||||
// Personality tags (preserve if they exist, do NOT generate)
|
||||
'personality', 'trait', 'favorite_food', 'voice_type', 'mood',
|
||||
// Evolution tags
|
||||
@@ -0,0 +1,242 @@
|
||||
// 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;
|
||||
|
||||
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,
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun } from 'lucide-react';
|
||||
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun, RefreshCw, SkipForward } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
@@ -22,11 +22,23 @@ 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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Tour dev actions for the first-hatch tour */
|
||||
interface FirstHatchTourDevActions {
|
||||
/** Skip the post requirement: advance from show_hatch_card to egg_glowing_waiting_click */
|
||||
skipPostRequirement: () => void;
|
||||
/** Reset the entire first-hatch tour so it can be tested again from scratch */
|
||||
resetTour: () => void;
|
||||
/** Current tour step id, or null if not active */
|
||||
currentStepId: string | null;
|
||||
/** Whether the tour has been completed */
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
interface BlobbiDevEditorProps {
|
||||
/** Whether the editor modal is open */
|
||||
isOpen: boolean;
|
||||
@@ -38,6 +50,8 @@ interface BlobbiDevEditorProps {
|
||||
onApply: (updates: BlobbiDevUpdates) => Promise<void>;
|
||||
/** Whether an update is in progress */
|
||||
isUpdating?: boolean;
|
||||
/** Optional: first-hatch tour dev actions (only passed when tour system is available) */
|
||||
tourDevActions?: FirstHatchTourDevActions;
|
||||
}
|
||||
|
||||
/** Updates that can be applied to a Blobbi */
|
||||
@@ -58,8 +72,6 @@ export interface BlobbiDevUpdates {
|
||||
breedingReady?: boolean;
|
||||
/** Generation number */
|
||||
generation?: number;
|
||||
/** Visibility to others */
|
||||
visibleToOthers?: boolean;
|
||||
}
|
||||
|
||||
// ─── Stat Presets ─────────────────────────────────────────────────────────────
|
||||
@@ -172,6 +184,7 @@ export function BlobbiDevEditor({
|
||||
companion,
|
||||
onApply,
|
||||
isUpdating = false,
|
||||
tourDevActions,
|
||||
}: BlobbiDevEditorProps) {
|
||||
// ─── Local State ───
|
||||
// Initialize from companion values
|
||||
@@ -189,7 +202,6 @@ export function BlobbiDevEditor({
|
||||
const [careStreak, setCareStreak] = useState(companion.careStreak ?? 0);
|
||||
const [breedingReady, setBreedingReady] = useState(companion.breedingReady);
|
||||
const [generation, setGeneration] = useState(companion.generation ?? 1);
|
||||
const [visibleToOthers, setVisibleToOthers] = useState(companion.visibleToOthers);
|
||||
|
||||
// Reset state when companion changes or modal opens
|
||||
const resetToCompanion = useCallback(() => {
|
||||
@@ -207,7 +219,6 @@ export function BlobbiDevEditor({
|
||||
setCareStreak(companion.careStreak ?? 0);
|
||||
setBreedingReady(companion.breedingReady);
|
||||
setGeneration(companion.generation ?? 1);
|
||||
setVisibleToOthers(companion.visibleToOthers);
|
||||
}, [companion]);
|
||||
|
||||
// Check if there are any changes
|
||||
@@ -224,10 +235,9 @@ export function BlobbiDevEditor({
|
||||
experience !== (companion.experience ?? 0) ||
|
||||
careStreak !== (companion.careStreak ?? 0) ||
|
||||
breedingReady !== companion.breedingReady ||
|
||||
generation !== (companion.generation ?? 1) ||
|
||||
visibleToOthers !== companion.visibleToOthers
|
||||
generation !== (companion.generation ?? 1)
|
||||
);
|
||||
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, visibleToOthers, companion]);
|
||||
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, companion]);
|
||||
|
||||
// Apply preset
|
||||
const applyPreset = useCallback((preset: StatPreset) => {
|
||||
@@ -270,11 +280,10 @@ export function BlobbiDevEditor({
|
||||
if (careStreak !== (companion.careStreak ?? 0)) updates.careStreak = careStreak;
|
||||
if (breedingReady !== companion.breedingReady) updates.breedingReady = breedingReady;
|
||||
if (generation !== (companion.generation ?? 1)) updates.generation = generation;
|
||||
if (visibleToOthers !== companion.visibleToOthers) updates.visibleToOthers = visibleToOthers;
|
||||
|
||||
await onApply(updates);
|
||||
onClose();
|
||||
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, visibleToOthers, companion, onApply, onClose]);
|
||||
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, companion, onApply, onClose]);
|
||||
|
||||
// Handle close
|
||||
const handleClose = useCallback(() => {
|
||||
@@ -533,15 +542,82 @@ export function BlobbiDevEditor({
|
||||
onCheckedChange={setBreedingReady}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">Visible to Others</Label>
|
||||
<Switch
|
||||
checked={visibleToOthers}
|
||||
onCheckedChange={setVisibleToOthers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── First-Hatch Tour Controls ─── */}
|
||||
{tourDevActions && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-semibold">First-Hatch Tour</Label>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{tourDevActions.isCompleted
|
||||
? 'Completed'
|
||||
: tourDevActions.currentStepId
|
||||
? tourDevActions.currentStepId
|
||||
: 'Not started'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Test the first-hatch tour flow without needing to create a real post.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* A. Skip Post Requirement */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
tourDevActions.skipPostRequirement();
|
||||
}}
|
||||
disabled={tourDevActions.currentStepId !== 'show_hatch_card'}
|
||||
className="gap-2 text-xs"
|
||||
title="Advance from show_hatch_card to egg_glowing_waiting_click (skips post check)"
|
||||
>
|
||||
<SkipForward className="size-3.5" />
|
||||
Skip Post
|
||||
</Button>
|
||||
|
||||
{/* B. Restart First-Hatch Tour */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
tourDevActions.resetTour();
|
||||
}}
|
||||
className="gap-2 text-xs"
|
||||
title="Reset the entire first-hatch tour state so it can be tested again"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Restart Tour
|
||||
</Button>
|
||||
|
||||
{/* C. Reset Blobbi to Egg */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStage('egg');
|
||||
setState('active');
|
||||
tourDevActions.resetTour();
|
||||
}}
|
||||
disabled={companion.stage === 'egg'}
|
||||
className="gap-2 text-xs"
|
||||
title="Set stage to egg AND reset the tour — apply changes to test from scratch"
|
||||
>
|
||||
<Egg className="size-3.5" />
|
||||
Reset to Egg + Tour
|
||||
</Button>
|
||||
</div>
|
||||
{companion.stage !== 'egg' && stage === 'egg' && (
|
||||
<p className="text-xs text-amber-500">
|
||||
Stage will change to egg. Click "Apply Changes" to publish, then the tour will auto-start.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
@@ -140,10 +140,6 @@ export function useBlobbiDevUpdate({
|
||||
tagUpdates.generation = updates.generation.toString();
|
||||
changedFields.push('generation');
|
||||
}
|
||||
if (updates.visibleToOthers !== undefined) {
|
||||
tagUpdates.visible = updates.visibleToOthers ? 'true' : 'false';
|
||||
changedFields.push('visible');
|
||||
}
|
||||
|
||||
// Always update last_interaction and last_decay_at
|
||||
tagUpdates.last_interaction = now.toString();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef } 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,42 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tour visual states that the egg can display.
|
||||
* Driven by the tour orchestration layer, not by EggGraphic itself.
|
||||
*
|
||||
* - idle: no tour effects
|
||||
* - show_hatch_card: initial crack visible + auto-wiggle every 2.5s
|
||||
* - glowing_waiting_click: enhanced glow + auto-wiggle, waiting for tap
|
||||
* - crack_stage_1: crack expands (click 1)
|
||||
* - crack_stage_2: crack expands more (click 2)
|
||||
* - crack_stage_3: final crack (click 3)
|
||||
* - opening: shell splits open
|
||||
* - hatching: bright light + reveal
|
||||
*/
|
||||
export type EggTourVisualState =
|
||||
| 'idle'
|
||||
| 'show_hatch_card'
|
||||
| 'glowing_waiting_click'
|
||||
| 'crack_stage_1'
|
||||
| 'crack_stage_2'
|
||||
| 'crack_stage_3'
|
||||
| 'opening'
|
||||
| 'hatching';
|
||||
|
||||
interface EggGraphicProps {
|
||||
blobbi?: EggVisualBlobbi; // Visual blobbi object for visual properties
|
||||
sizeVariant?: 'tiny' | 'small' | 'medium' | 'large'; // Internal scaling only, NOT layout size
|
||||
@@ -21,6 +57,46 @@ 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;
|
||||
/** Tour visual state - driven externally by the tour orchestration layer */
|
||||
tourVisualState?: EggTourVisualState;
|
||||
/** Callback when the egg is clicked during an interactive tour step */
|
||||
onTourEggClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +140,9 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
cracking = false,
|
||||
warmth = 50,
|
||||
forceInlineSvg: _forceInlineSvg = false,
|
||||
statusEffects,
|
||||
tourVisualState = 'idle',
|
||||
onTourEggClick,
|
||||
}) => {
|
||||
// sizeVariant controls ONLY internal scaling/details, NOT layout dimensions
|
||||
// Parent container controls actual rendered width/height via slot
|
||||
@@ -98,6 +177,66 @@ 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(() => {
|
||||
// Tour interactive steps: forward click to tour controller
|
||||
if (onTourEggClick && (tourVisualState === 'glowing_waiting_click' || tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2' || tourVisualState === 'crack_stage_3')) {
|
||||
setIsTapWiggling(true);
|
||||
onTourEggClick();
|
||||
return;
|
||||
}
|
||||
if (isTapWiggling || cracking) return; // Don't re-trigger during animation or cracking
|
||||
setIsTapWiggling(true);
|
||||
}, [isTapWiggling, cracking, onTourEggClick, tourVisualState]);
|
||||
|
||||
const handleWiggleEnd = useCallback(() => {
|
||||
setIsTapWiggling(false);
|
||||
}, []);
|
||||
|
||||
// Tour: auto-wiggle effect for show_hatch_card and glowing_waiting_click states
|
||||
const shouldAutoWiggle = tourVisualState === 'show_hatch_card' || tourVisualState === 'glowing_waiting_click';
|
||||
const autoWiggleTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!shouldAutoWiggle) {
|
||||
if (autoWiggleTimerRef.current) {
|
||||
clearInterval(autoWiggleTimerRef.current);
|
||||
autoWiggleTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Trigger an immediate wiggle, then repeat every 2.5s
|
||||
setIsTapWiggling(true);
|
||||
autoWiggleTimerRef.current = setInterval(() => {
|
||||
setIsTapWiggling((prev) => {
|
||||
if (!prev) return true;
|
||||
return prev;
|
||||
});
|
||||
}, 2500);
|
||||
return () => {
|
||||
if (autoWiggleTimerRef.current) {
|
||||
clearInterval(autoWiggleTimerRef.current);
|
||||
autoWiggleTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [shouldAutoWiggle]);
|
||||
|
||||
// Tour: whether the egg should show crack overlay
|
||||
// The crack stays visible during 'opening' so the shell fades out WITH its cracks intact.
|
||||
// Only 'idle' and 'hatching' (shell already gone) hide the crack.
|
||||
const tourShowCrack = tourVisualState !== 'idle' && tourVisualState !== 'hatching';
|
||||
|
||||
// Tour: crack intensity level (0 = small center crack, 1-3 = progressively expanding)
|
||||
// Level 0: small central crack (show_hatch_card, glowing_waiting_click)
|
||||
// Level 1: crack expands left/right with small branches (crack_stage_1)
|
||||
// Level 2: crack expands further toward edges, more branches (crack_stage_2)
|
||||
// Level 3: crack reaches near shell edges, about to split (crack_stage_3, opening)
|
||||
const tourCrackLevel = tourVisualState === 'crack_stage_1' ? 1
|
||||
: tourVisualState === 'crack_stage_2' ? 2
|
||||
: (tourVisualState === 'crack_stage_3' || tourVisualState === 'opening') ? 3
|
||||
: 0;
|
||||
|
||||
// Divine color constants
|
||||
const DIVINE_PRIMARY_GREEN = '#55C4A2';
|
||||
const _DIVINE_HIGHLIGHT_GREEN = '#7AD9B9';
|
||||
@@ -378,30 +517,54 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
}}
|
||||
>
|
||||
{/* Glow effect based on warmth - relative sizing */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full blur-xl transition-all duration-1000',
|
||||
animated && 'animate-pulse'
|
||||
)}
|
||||
style={{
|
||||
width: '120%',
|
||||
height: '120%',
|
||||
background: `radial-gradient(circle, ${glowColor} 0%, transparent 70%)`,
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
{(() => {
|
||||
const isGlowingTour = tourVisualState === 'glowing_waiting_click'
|
||||
|| tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2'
|
||||
|| tourVisualState === 'crack_stage_3';
|
||||
const isHatchLight = tourVisualState === 'opening' || tourVisualState === 'hatching';
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full blur-xl transition-all duration-1000',
|
||||
animated && !isGlowingTour && !isHatchLight && 'animate-pulse',
|
||||
isGlowingTour && 'animate-egg-tour-glow',
|
||||
isHatchLight && 'animate-egg-tour-glow',
|
||||
)}
|
||||
style={{
|
||||
width: isHatchLight ? '200%' : isGlowingTour ? '150%' : '120%',
|
||||
height: isHatchLight ? '200%' : isGlowingTour ? '150%' : '120%',
|
||||
background: isHatchLight
|
||||
? `radial-gradient(circle, #fff 0%, ${glowColor} 40%, transparent 70%)`
|
||||
: isGlowingTour
|
||||
? `radial-gradient(circle, ${glowColor} 0%, ${glowColor}80 30%, transparent 70%)`
|
||||
: `radial-gradient(circle, ${glowColor} 0%, transparent 70%)`,
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 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
|
||||
cracking && 'animate-egg-crack'
|
||||
// Cracking overrides other animations (legacy prop or tour crack stages)
|
||||
// During 'opening' the shell runs its own open animation, so suppress the shake
|
||||
(cracking || (tourCrackLevel >= 1 && tourVisualState !== 'opening')) && 'animate-egg-crack',
|
||||
// Opening/hatching: fade out the egg shell (crack overlay stays inside and fades with it)
|
||||
tourVisualState === 'opening' && 'animate-egg-tour-open',
|
||||
tourVisualState === 'hatching' && 'opacity-0',
|
||||
)}
|
||||
style={{
|
||||
width: '80%',
|
||||
@@ -412,7 +575,7 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
inset -0.5em -0.5em 1em ${shadow}33,
|
||||
inset 0.5em 0.5em 1em ${highlight}26
|
||||
`,
|
||||
filter: cracking ? 'brightness(1.1)' : 'brightness(1)',
|
||||
filter: (cracking || tourCrackLevel >= 1) ? 'brightness(1.1)' : 'brightness(1)',
|
||||
}}
|
||||
>
|
||||
{/* Highlight on the egg - uses color variants instead of white */}
|
||||
@@ -470,133 +633,181 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
renderLegacySpecialMark(effectiveSpecialMark)
|
||||
))}
|
||||
|
||||
{/* Crack pattern based on docs/aprovado.svg when cracking is true */}
|
||||
{cracking && (
|
||||
<svg
|
||||
className="absolute inset-0 pointer-events-none w-full h-full"
|
||||
viewBox="0 0 120 125"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
style={{
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Main horizontal crack (adapted from aprovado.svg) */}
|
||||
<path
|
||||
d="M10 62
|
||||
L20 60
|
||||
L30 64
|
||||
L40 59
|
||||
L50 65
|
||||
L60 58
|
||||
L70 66
|
||||
L80 57
|
||||
L90 67
|
||||
L100 59
|
||||
L110 65"
|
||||
stroke="rgba(0, 0, 0, 0.6)"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Crack pattern - stage-specific paths that grow outward from center */}
|
||||
{(cracking || tourShowCrack) && (() => {
|
||||
// Legacy cracking shows full crack; tour uses progressive stage-specific paths
|
||||
const level = cracking ? 3 : tourCrackLevel;
|
||||
return (
|
||||
<svg
|
||||
className="absolute inset-0 pointer-events-none w-full h-full transition-opacity duration-300"
|
||||
viewBox="0 0 120 125"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
{/*
|
||||
Stage-specific crack paths.
|
||||
Each level has its OWN distinct paths that expand outward from the egg center.
|
||||
The crack grows from a small central cluster to full-width fracture.
|
||||
|
||||
{/* Secondary cracks (adapted from aprovado.svg) */}
|
||||
<path
|
||||
d="M30 64 L28 70"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M50 65 L53 71"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M60 58 L57 52"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M80 57 L82 50"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M90 67 L95 72"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M100 59 L97 53"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M110 65 L113 69"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
Viewbox center is roughly (60, 62).
|
||||
Level 0: tiny central crack (~3-4 small connected segments near center)
|
||||
Level 1: extends left/right from center, first branches
|
||||
Level 2: reaches further toward edges, more fracture detail
|
||||
Level 3: crack reaches near shell edges, dense branching
|
||||
*/}
|
||||
|
||||
{/* Additional micro-cracks for detail */}
|
||||
<path
|
||||
d="M40 59 L38 55"
|
||||
stroke="rgba(0, 0, 0, 0.25)"
|
||||
strokeWidth="0.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M70 66 L73 70"
|
||||
stroke="rgba(0, 0, 0, 0.25)"
|
||||
strokeWidth="0.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M20 60 L18 56"
|
||||
stroke="rgba(0, 0, 0, 0.2)"
|
||||
strokeWidth="0.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* ── Level 0: Small central crack ── */}
|
||||
{/* A few short connected segments clustered around the center of the egg */}
|
||||
{level === 0 && (<>
|
||||
{/* Main tiny crack: ~15px wide, centered */}
|
||||
<path
|
||||
d="M53 63 L57 60 L63 64 L67 61"
|
||||
stroke="rgba(0, 0, 0, 0.5)"
|
||||
strokeWidth="1.2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Tiny upward branch from center */}
|
||||
<path
|
||||
d="M57 60 L56 57"
|
||||
stroke="rgba(0, 0, 0, 0.35)"
|
||||
strokeWidth="0.8"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Tiny downward branch */}
|
||||
<path
|
||||
d="M63 64 L65 67"
|
||||
stroke="rgba(0, 0, 0, 0.35)"
|
||||
strokeWidth="0.8"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Subtle highlight alongside main crack */}
|
||||
<path
|
||||
d="M54 64 L58 61 L64 65"
|
||||
stroke="rgba(255, 255, 255, 0.12)"
|
||||
strokeWidth="0.6"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>)}
|
||||
|
||||
{/* Crack highlights for depth (following the main crack pattern) */}
|
||||
<path
|
||||
d="M10 63
|
||||
L20 61
|
||||
L30 65
|
||||
L40 60
|
||||
L50 66
|
||||
L60 59
|
||||
L70 67
|
||||
L80 58
|
||||
L90 68
|
||||
L100 60
|
||||
L110 66"
|
||||
stroke="rgba(255, 255, 255, 0.15)"
|
||||
strokeWidth="0.8"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* ── Level 1: Medium crack expanding from center ── */}
|
||||
{/* Crack extends ~30px wide, first real branches appear */}
|
||||
{level === 1 && (<>
|
||||
{/* Main crack: wider than level 0, extends left and right */}
|
||||
<path
|
||||
d="M42 61 L48 64 L53 60 L60 65 L67 59 L73 63 L78 60"
|
||||
stroke="rgba(0, 0, 0, 0.55)"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Highlight */}
|
||||
<path
|
||||
d="M43 62 L49 65 L54 61 L61 66 L68 60 L74 64"
|
||||
stroke="rgba(255, 255, 255, 0.12)"
|
||||
strokeWidth="0.6"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Branch: upward left */}
|
||||
<path d="M48 64 L46 69" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
{/* Branch: upward from center-right */}
|
||||
<path d="M67 59 L65 54" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
{/* Branch: downward right */}
|
||||
<path d="M73 63 L76 68" stroke="rgba(0, 0, 0, 0.35)" strokeWidth="0.9" strokeLinecap="round" />
|
||||
{/* Small micro-branch */}
|
||||
<path d="M53 60 L51 56" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
</>)}
|
||||
|
||||
{/* Secondary crack highlights */}
|
||||
<path
|
||||
d="M30 65 L28 71"
|
||||
stroke="rgba(255, 255, 255, 0.1)"
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M60 59 L57 53"
|
||||
stroke="rgba(255, 255, 255, 0.1)"
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{/* ── Level 2: Larger crack reaching toward sides ── */}
|
||||
{/* Crack extends ~60px wide, more branching detail */}
|
||||
{level === 2 && (<>
|
||||
{/* Main crack: extends well toward both sides */}
|
||||
<path
|
||||
d="M30 63 L37 60 L44 65 L52 59 L60 64 L68 58 L76 63 L83 59 L90 64"
|
||||
stroke="rgba(0, 0, 0, 0.6)"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Highlight */}
|
||||
<path
|
||||
d="M31 64 L38 61 L45 66 L53 60 L61 65 L69 59 L77 64 L84 60"
|
||||
stroke="rgba(255, 255, 255, 0.12)"
|
||||
strokeWidth="0.7"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Branches: left side */}
|
||||
<path d="M37 60 L34 55" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.1" strokeLinecap="round" />
|
||||
<path d="M44 65 L41 71" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
{/* Branches: center */}
|
||||
<path d="M52 59 L50 53" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
<path d="M60 64 L63 70" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
{/* Branches: right side */}
|
||||
<path d="M68 58 L66 52" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.1" strokeLinecap="round" />
|
||||
<path d="M76 63 L79 69" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
<path d="M83 59 L86 54" stroke="rgba(0, 0, 0, 0.35)" strokeWidth="0.9" strokeLinecap="round" />
|
||||
{/* Micro-cracks */}
|
||||
<path d="M50 53 L48 50" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
<path d="M63 70 L66 73" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
</>)}
|
||||
|
||||
{/* ── Level 3: Full crack reaching shell edges ── */}
|
||||
{/* Crack spans nearly the full width, dense fracture network */}
|
||||
{level >= 3 && (<>
|
||||
{/* Main crack: nearly full width of egg */}
|
||||
<path
|
||||
d="M15 62 L23 59 L32 64 L40 58 L50 65 L60 57 L70 64 L80 58 L88 63 L96 59 L105 64"
|
||||
stroke="rgba(0, 0, 0, 0.65)"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Highlight */}
|
||||
<path
|
||||
d="M16 63 L24 60 L33 65 L41 59 L51 66 L61 58 L71 65 L81 59 L89 64 L97 60"
|
||||
stroke="rgba(255, 255, 255, 0.13)"
|
||||
strokeWidth="0.8"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Heavy branches: left region */}
|
||||
<path d="M23 59 L19 53" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
|
||||
<path d="M32 64 L28 72" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M28 72 L25 76" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.9" strokeLinecap="round" />
|
||||
{/* Heavy branches: center-left */}
|
||||
<path d="M40 58 L37 51" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M50 65 L47 73" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M37 51 L35 47" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.8" strokeLinecap="round" />
|
||||
{/* Heavy branches: center */}
|
||||
<path d="M60 57 L58 50" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
|
||||
<path d="M60 57 L63 68" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1.1" strokeLinecap="round" />
|
||||
{/* Heavy branches: center-right */}
|
||||
<path d="M70 64 L73 71" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M80 58 L83 50" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
|
||||
<path d="M83 50 L86 46" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.8" strokeLinecap="round" />
|
||||
{/* Heavy branches: right region */}
|
||||
<path d="M88 63 L91 70" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M96 59 L99 52" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M105 64 L109 70" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1.1" strokeLinecap="round" />
|
||||
{/* Micro-cracks (tertiary detail) */}
|
||||
<path d="M47 73 L44 77" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
<path d="M73 71 L76 75" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
<path d="M58 50 L55 46" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
<path d="M19 53 L17 49" stroke="rgba(0, 0, 0, 0.2)" strokeWidth="0.6" strokeLinecap="round" />
|
||||
<path d="M99 52 L102 48" stroke="rgba(0, 0, 0, 0.2)" strokeWidth="0.6" strokeLinecap="round" />
|
||||
</>)}
|
||||
</svg>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Title display for special eggs */}
|
||||
{blobbi?.title && (
|
||||
@@ -657,6 +868,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, type EggTourVisualState } 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
|
||||
========================================== */
|
||||
@@ -208,6 +320,49 @@
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Tour Visual State Animations
|
||||
========================================== */
|
||||
|
||||
/* Shell opening: scale up slightly then fade out with blur */
|
||||
@keyframes egg-tour-open {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.9;
|
||||
filter: brightness(1.4);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0;
|
||||
filter: brightness(2) blur(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-tour-open {
|
||||
animation: egg-tour-open 1.2s ease-in-out forwards;
|
||||
}
|
||||
|
||||
/* Pulsing glow for the "waiting for click" tour state */
|
||||
@keyframes egg-tour-glow {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-tour-glow {
|
||||
animation: egg-tour-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Responsive adjustments
|
||||
========================================== */
|
||||
@@ -234,7 +389,14 @@
|
||||
.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,
|
||||
.animate-egg-tour-glow,
|
||||
.animate-egg-tour-open {
|
||||
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,
|
||||
@@ -456,15 +456,18 @@ export function useBlobbiOnboarding({
|
||||
|
||||
updateCompanionEvent(eggEvent);
|
||||
|
||||
// 2. Update profile: deduct coins, add to has, set current_companion
|
||||
// 2. Update profile: deduct coins, add to has list
|
||||
// NOTE: We do NOT set current_companion here because the adopted Blobbi
|
||||
// is still an egg. The companion mechanic only becomes available after hatching.
|
||||
// Eggs should never be auto-assigned as the floating companion.
|
||||
// NOTE: blobbi_onboarding_done is NOT set here — adoption alone does not
|
||||
// complete onboarding. It is set when the first-hatch tour finishes.
|
||||
const newCoins = coins - BLOBBI_ADOPTION_COST;
|
||||
const newHas = [...profile.has, preview.d];
|
||||
|
||||
const profileUpdates: Record<string, string | string[]> = {
|
||||
coins: newCoins.toString(),
|
||||
has: newHas,
|
||||
current_companion: preview.d,
|
||||
onboarding_done: 'true',
|
||||
};
|
||||
|
||||
const updatedProfileTags = updateBlobbonautTags(profile.allTags, profileUpdates);
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
getLocalDayString,
|
||||
type BlobbiVisualTraits,
|
||||
type BlobbiStats,
|
||||
} from '@/lib/blobbi';
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -135,7 +135,6 @@ export function previewToEventTags(preview: BlobbiEggPreview): string[][] {
|
||||
['stage', preview.stage],
|
||||
['state', preview.state],
|
||||
['seed', preview.seed],
|
||||
['visible_to_others', 'true'],
|
||||
['generation', '1'],
|
||||
['breeding_ready', 'false'],
|
||||
['experience', '0'],
|
||||
@@ -181,7 +180,6 @@ export function previewToBlobbiCompanion(preview: BlobbiEggPreview) {
|
||||
isLegacy: false,
|
||||
lastInteraction: preview.createdAt,
|
||||
lastDecayAt: preview.createdAt,
|
||||
visibleToOthers: true,
|
||||
generation: 1,
|
||||
breedingReady: false,
|
||||
experience: 0,
|
||||
|
||||
@@ -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';
|
||||
@@ -45,20 +45,25 @@ interface ResolvedInventoryItem extends ShopItem {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function BlobbiInventoryModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
// ── Shared inventory content (used by both standalone modal and unified shop modal) ──
|
||||
|
||||
interface BlobbiInventoryContentProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
companion: BlobbiCompanion | null;
|
||||
onUseItem?: (itemId: string, quantity: number) => void;
|
||||
isUsingItem?: boolean;
|
||||
}
|
||||
|
||||
export function BlobbiInventoryContent({
|
||||
profile,
|
||||
companion,
|
||||
onUseItem,
|
||||
isUsingItem = false,
|
||||
}: BlobbiInventoryModalProps) {
|
||||
// State for use confirmation dialog
|
||||
}: BlobbiInventoryContentProps) {
|
||||
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [showUseDialog, setShowUseDialog] = useState(false);
|
||||
|
||||
// Resolve storage items with their metadata and usability from the shop catalog
|
||||
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
|
||||
if (!profile) return [];
|
||||
const stage = companion?.stage ?? 'egg';
|
||||
@@ -68,7 +73,6 @@ export function BlobbiInventoryModal({
|
||||
const item = getShopItemById(storageItem.itemId);
|
||||
if (!item) continue;
|
||||
|
||||
// Check if item can be used for current stage
|
||||
const usability = canUseItemForStage(storageItem.itemId, stage);
|
||||
|
||||
result.push({
|
||||
@@ -84,7 +88,6 @@ export function BlobbiInventoryModal({
|
||||
|
||||
const isEmpty = inventoryItems.length === 0;
|
||||
|
||||
// Handlers for use dialog
|
||||
const handleSelectItem = (item: ResolvedInventoryItem) => {
|
||||
if (!item.canUse || isUsingItem) return;
|
||||
setSelectedItem(item);
|
||||
@@ -95,7 +98,6 @@ export function BlobbiInventoryModal({
|
||||
const handleConfirmUse = () => {
|
||||
if (!selectedItem || !onUseItem || isUsingItem) return;
|
||||
onUseItem(selectedItem.itemId, quantity);
|
||||
// Reset state
|
||||
setShowUseDialog(false);
|
||||
setSelectedItem(null);
|
||||
setQuantity(1);
|
||||
@@ -109,7 +111,6 @@ export function BlobbiInventoryModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Quantity controls
|
||||
const maxQuantity = selectedItem?.quantity ?? 1;
|
||||
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
|
||||
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
|
||||
@@ -123,142 +124,117 @@ export function BlobbiInventoryModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header - Sticky */}
|
||||
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-500/20 flex items-center justify-center shrink-0">
|
||||
<Package className="size-4 sm:size-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="text-xl sm:text-2xl">Inventory</DialogTitle>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
{isEmpty ? 'No items yet' : `${inventoryItems.length} ${inventoryItems.length === 1 ? 'item' : 'items'}`}
|
||||
</p>
|
||||
</div>
|
||||
<>
|
||||
<div className="px-4 sm:px-6 py-3 sm:py-4">
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<Package className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
|
||||
<X className="size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
<h3 className="text-lg font-semibold mb-2">No Items Yet</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
Visit the Shop tab to purchase items for your Blobbi. Items you buy will appear here.
|
||||
</p>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<Package className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Items Yet</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
Visit the shop to purchase items for your Blobbi. Items you buy will appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:gap-3">
|
||||
{inventoryItems.map(item => (
|
||||
<div
|
||||
key={item.itemId}
|
||||
className={cn(
|
||||
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
|
||||
item.canUse ? "hover:border-primary/30" : "opacity-70"
|
||||
)}
|
||||
>
|
||||
{/* Top row on mobile: Icon + Name/Type + Quantity + Button */}
|
||||
<div className="flex items-center gap-3 sm:contents">
|
||||
{/* Item Icon */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
|
||||
<div className={cn(
|
||||
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
|
||||
!item.canUse && "grayscale"
|
||||
)}>
|
||||
{item.icon}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:gap-3">
|
||||
{inventoryItems.map(item => (
|
||||
<div
|
||||
key={item.itemId}
|
||||
className={cn(
|
||||
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
|
||||
item.canUse ? "hover:border-primary/30" : "opacity-70"
|
||||
)}
|
||||
>
|
||||
{/* Top row on mobile: Icon + Name/Type + Quantity + Button */}
|
||||
<div className="flex items-center gap-3 sm:contents">
|
||||
{/* Item Icon */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
|
||||
<div className={cn(
|
||||
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
|
||||
!item.canUse && "grayscale"
|
||||
)}>
|
||||
{item.icon}
|
||||
</div>
|
||||
|
||||
{/* Item Info - Name and Type */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
|
||||
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
|
||||
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
|
||||
{item.type}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Effect preview - desktop only inline */}
|
||||
<div className="hidden sm:block">
|
||||
<ItemEffectDisplay effect={item.effect} variant="inline" />
|
||||
</div>
|
||||
{/* Show blocked reason - desktop only inline */}
|
||||
{!item.canUse && item.reason && (
|
||||
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
|
||||
{item.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quantity Badge */}
|
||||
<Badge className="bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0 px-2 py-0.5 shrink-0 text-xs">
|
||||
×{item.quantity}
|
||||
</Badge>
|
||||
|
||||
{/* Use Button */}
|
||||
{onUseItem && (
|
||||
item.canUse ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSelectItem(item)}
|
||||
disabled={isUsingItem}
|
||||
className="shrink-0"
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled
|
||||
className="shrink-0"
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{item.reason || 'Cannot use this item'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile only: Effect preview and blocked reason below */}
|
||||
<div className="sm:hidden pl-13 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{/* Item Info - Name and Type */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
|
||||
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
|
||||
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
|
||||
{item.type}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Effect preview - desktop only inline */}
|
||||
<div className="hidden sm:block">
|
||||
<ItemEffectDisplay effect={item.effect} variant="inline" />
|
||||
</div>
|
||||
{/* Show blocked reason on mobile */}
|
||||
{/* Show blocked reason - desktop only inline */}
|
||||
{!item.canUse && item.reason && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
|
||||
{item.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quantity Badge */}
|
||||
<Badge className="bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0 px-2 py-0.5 shrink-0 text-xs">
|
||||
×{item.quantity}
|
||||
</Badge>
|
||||
|
||||
{/* Use Button */}
|
||||
{onUseItem && (
|
||||
item.canUse ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSelectItem(item)}
|
||||
disabled={isUsingItem}
|
||||
className="shrink-0"
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled
|
||||
className="shrink-0"
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{item.reason || 'Cannot use this item'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* Mobile only: Effect preview and blocked reason below */}
|
||||
<div className="sm:hidden pl-13 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{item.type}
|
||||
</Badge>
|
||||
<ItemEffectDisplay effect={item.effect} variant="inline" />
|
||||
</div>
|
||||
{!item.canUse && item.reason && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{item.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Use Item Confirmation Dialog */}
|
||||
{selectedItem && companion && (
|
||||
@@ -276,6 +252,49 @@ export function BlobbiInventoryModal({
|
||||
isUsing={isUsingItem}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Standalone Inventory Modal (kept for backwards compatibility) ──
|
||||
|
||||
export function BlobbiInventoryModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
profile,
|
||||
companion,
|
||||
onUseItem,
|
||||
isUsingItem = false,
|
||||
}: BlobbiInventoryModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header - Sticky */}
|
||||
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-500/20 flex items-center justify-center shrink-0">
|
||||
<Package className="size-4 sm:size-5 text-primary" />
|
||||
</div>
|
||||
<DialogTitle className="text-xl sm:text-2xl">Inventory</DialogTitle>
|
||||
</div>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
|
||||
<X className="size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<BlobbiInventoryContent
|
||||
profile={profile}
|
||||
companion={companion}
|
||||
onUseItem={onUseItem}
|
||||
isUsingItem={isUsingItem}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -309,15 +328,12 @@ function InventoryUseConfirmDialog({
|
||||
onConfirm,
|
||||
isUsing,
|
||||
}: InventoryUseConfirmDialogProps) {
|
||||
// Calculate total effect for the selected quantity by simulating sequential application
|
||||
// This matches the actual behavior when items are used (clamping at each step)
|
||||
const totalEffect = useMemo(() => {
|
||||
if (!item.effect) return null;
|
||||
|
||||
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
|
||||
const currentStats = { ...companion.stats };
|
||||
|
||||
// Apply effects N times in sequence with clamping at each step
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
for (const stat of statKeys) {
|
||||
const delta = item.effect[stat];
|
||||
@@ -327,7 +343,6 @@ function InventoryUseConfirmDialog({
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate actual deltas (may be less than effect * quantity due to clamping)
|
||||
const result: Record<string, number> = {};
|
||||
for (const stat of statKeys) {
|
||||
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
import type { ShopItem } from '../types/shop.types';
|
||||
import { ItemEffectDisplay } from './ItemEffectDisplay';
|
||||
import { formatCompactNumber } from '@/lib/utils';
|
||||
|
||||
interface BlobbiPurchaseDialogProps {
|
||||
@@ -150,13 +149,6 @@ export function BlobbiPurchaseDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Effects Summary */}
|
||||
{item.effect && Object.keys(item.effect).length > 0 && (
|
||||
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
|
||||
<h4 className="text-sm font-medium mb-2">Effects per item</h4>
|
||||
<ItemEffectDisplay effect={item.effect} variant="grid" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,178 +1,364 @@
|
||||
import { useState } from 'react';
|
||||
import { ShoppingBag, Utensils, Gamepad2, Heart, Droplets, Palette, X } from 'lucide-react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ShoppingBag, Package, Loader2, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
import { BlobbiShopItemRow } from './BlobbiShopItemRow';
|
||||
import { BlobbiPurchaseDialog } from './BlobbiPurchaseDialog';
|
||||
|
||||
import type { ShopItem, ShopItemCategory } from '../types/shop.types';
|
||||
import type { BlobbonautProfile } from '@/lib/blobbi';
|
||||
import { getShopItemsByType } from '../lib/blobbi-shop-items';
|
||||
import type { ShopItem } from '../types/shop.types';
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import { getLiveShopItems, getShopItemById } from '../lib/blobbi-shop-items';
|
||||
import { useBlobbiPurchaseItem } from '../hooks/useBlobbiPurchaseItem';
|
||||
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
import { cn, formatCompactNumber } from '@/lib/utils';
|
||||
|
||||
type TopTab = 'items' | 'shop';
|
||||
|
||||
/** Resolved inventory item with shop metadata and usability info */
|
||||
interface ResolvedInventoryItem extends ShopItem {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
canUse: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface BlobbiShopModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Initial tab to open on. Defaults to "items". */
|
||||
initialTab?: TopTab;
|
||||
// ── Inventory props (passed through) ──
|
||||
companion: BlobbiCompanion | null;
|
||||
onUseItem?: (itemId: string, quantity: number) => void;
|
||||
isUsingItem?: boolean;
|
||||
}
|
||||
|
||||
const CATEGORIES: Array<{
|
||||
type: ShopItemCategory;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}> = [
|
||||
{ type: 'food', label: 'Food', icon: <Utensils className="size-4" /> },
|
||||
{ type: 'toy', label: 'Toys', icon: <Gamepad2 className="size-4" /> },
|
||||
{ type: 'medicine', label: 'Medicine', icon: <Heart className="size-4" /> },
|
||||
{ type: 'hygiene', label: 'Hygiene', icon: <Droplets className="size-4" /> },
|
||||
{ type: 'accessory', label: 'Accessories', icon: <Palette className="size-4" /> },
|
||||
];
|
||||
|
||||
export function BlobbiShopModal({ open, onOpenChange, profile }: BlobbiShopModalProps) {
|
||||
const [activeCategory, setActiveCategory] = useState<ShopItemCategory>('food');
|
||||
const [selectedItem, setSelectedItem] = useState<ShopItem | null>(null);
|
||||
const [showPurchaseDialog, setShowPurchaseDialog] = useState(false);
|
||||
export function BlobbiShopModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
profile,
|
||||
initialTab = 'items',
|
||||
companion,
|
||||
onUseItem,
|
||||
isUsingItem,
|
||||
}: BlobbiShopModalProps) {
|
||||
const [topTab, setTopTab] = useState<TopTab>(initialTab);
|
||||
|
||||
const { mutate: purchaseItem, isPending: isPurchasing } = useBlobbiPurchaseItem(profile);
|
||||
const [purchasingItemId, setPurchasingItemId] = useState<string | null>(null);
|
||||
|
||||
const availableCoins = profile?.coins ?? 0;
|
||||
const items = getShopItemsByType(activeCategory);
|
||||
const allItems = getLiveShopItems();
|
||||
|
||||
const handlePurchaseClick = (item: ShopItem) => {
|
||||
setSelectedItem(item);
|
||||
setShowPurchaseDialog(true);
|
||||
// Reset to initialTab when modal re-opens
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
setTopTab(initialTab);
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
};
|
||||
|
||||
const handlePurchase = (quantity: number) => {
|
||||
if (!selectedItem) return;
|
||||
|
||||
// Instant purchase — one tap = one item
|
||||
const handleBuyItem = (item: ShopItem) => {
|
||||
if (isPurchasing || availableCoins < item.price) return;
|
||||
setPurchasingItemId(item.id);
|
||||
purchaseItem(
|
||||
{
|
||||
itemId: selectedItem.id,
|
||||
price: selectedItem.price,
|
||||
quantity,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowPurchaseDialog(false);
|
||||
setSelectedItem(null);
|
||||
},
|
||||
}
|
||||
{ itemId: item.id, price: item.price, quantity: 1 },
|
||||
{ onSettled: () => setPurchasingItemId(null) },
|
||||
);
|
||||
};
|
||||
|
||||
const effectivePurchasingId = isPurchasing ? purchasingItemId : null;
|
||||
|
||||
// ── Inventory items resolution ──
|
||||
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
|
||||
if (!profile) return [];
|
||||
const stage = companion?.stage ?? 'egg';
|
||||
|
||||
const result: ResolvedInventoryItem[] = [];
|
||||
for (const storageItem of profile.storage) {
|
||||
const item = getShopItemById(storageItem.itemId);
|
||||
if (!item) continue;
|
||||
|
||||
const usability = canUseItemForStage(storageItem.itemId, stage);
|
||||
|
||||
result.push({
|
||||
...item,
|
||||
itemId: storageItem.itemId,
|
||||
quantity: storageItem.quantity,
|
||||
canUse: usability.canUse,
|
||||
reason: usability.reason,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [profile, companion?.stage]);
|
||||
|
||||
// ── Inventory use item handler ──
|
||||
const [usingItemId, setUsingItemId] = useState<string | null>(null);
|
||||
|
||||
const handleUseItem = (item: ResolvedInventoryItem) => {
|
||||
if (!item.canUse || isUsingItem || !onUseItem) return;
|
||||
setUsingItemId(item.itemId);
|
||||
onUseItem(item.itemId, 1);
|
||||
};
|
||||
|
||||
// Clear usingItemId when isUsingItem goes false
|
||||
const effectiveUsingItemId = isUsingItem ? usingItemId : null;
|
||||
|
||||
const inventoryEmpty = inventoryItems.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header - Sticky */}
|
||||
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
||||
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-pink-500/20 flex items-center justify-center shrink-0">
|
||||
<ShoppingBag className="size-4 sm:size-5 text-primary" />
|
||||
</div>
|
||||
<DialogTitle className="text-xl sm:text-2xl truncate">Blobbi Shop</DialogTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge className="bg-gradient-to-r from-yellow-500 to-amber-500 text-white border-0 text-sm sm:text-base px-3 sm:px-4 py-1">
|
||||
{formatCompactNumber(availableCoins)} coins
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md w-[calc(100%-2rem)] max-h-[80vh] flex flex-col p-0 gap-0 overflow-hidden rounded-2xl [&>button:last-child]:hidden">
|
||||
|
||||
{/* Tab Bar (replaces header) */}
|
||||
<div className="flex items-center border-b bg-muted/30">
|
||||
{/* Tabs */}
|
||||
<button
|
||||
onClick={() => setTopTab('items')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-2 px-4 py-3.5 text-sm font-medium transition-colors relative',
|
||||
topTab === 'items'
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground/70'
|
||||
)}
|
||||
>
|
||||
<Package className="size-4" />
|
||||
Items
|
||||
{!inventoryEmpty && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 min-w-4">
|
||||
{inventoryItems.reduce((sum, i) => sum + i.quantity, 0)}
|
||||
</Badge>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||
<X className="size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
)}
|
||||
{topTab === 'items' && (
|
||||
<span className="absolute bottom-0 inset-x-4 h-0.5 bg-primary rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTopTab('shop')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-2 px-4 py-3.5 text-sm font-medium transition-colors relative',
|
||||
topTab === 'shop'
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground/70'
|
||||
)}
|
||||
>
|
||||
<ShoppingBag className="size-4" />
|
||||
Shop
|
||||
{topTab === 'shop' && (
|
||||
<span className="absolute bottom-0 inset-x-4 h-0.5 bg-primary rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Category Tabs - Part of sticky header area */}
|
||||
<div className="sticky top-[60px] sm:top-[72px] z-10 bg-background px-4 sm:px-6 pt-3 sm:pt-4 pb-2 border-b">
|
||||
<div className="flex gap-1.5 sm:gap-2 overflow-x-auto pb-1 -mx-1 px-1">
|
||||
{CATEGORIES.map(category => {
|
||||
const isActive = activeCategory === category.type;
|
||||
const itemCount = getShopItemsByType(category.type).length;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.type}
|
||||
onClick={() => setActiveCategory(category.type)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1.5 sm:py-2 rounded-lg transition-all whitespace-nowrap',
|
||||
'border text-sm sm:text-base',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-muted/50 text-muted-foreground border-transparent hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{category.icon}
|
||||
<span className="font-medium hidden xs:inline">{category.label}</span>
|
||||
<Badge variant="secondary" className="ml-0.5 sm:ml-1 text-xs">
|
||||
{itemCount}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* Coin badge + Close */}
|
||||
<div className="flex items-center gap-1.5 pr-3 pl-2">
|
||||
<Badge className="bg-gradient-to-r from-yellow-500 to-amber-500 text-white border-0 text-xs px-2 py-0.5">
|
||||
<span className="mr-1">🪙</span>{formatCompactNumber(availableCoins)}
|
||||
</Badge>
|
||||
<DialogClose className="rounded-full p-1 opacity-60 hover:opacity-100 transition-opacity">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content Area */}
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{/* Accessories Coming Soon Banner */}
|
||||
{activeCategory === 'accessory' && (
|
||||
<div className="mx-4 sm:mx-6 mt-3 sm:mt-4 p-4 sm:p-6 rounded-xl bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/20">
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="size-12 sm:size-16 rounded-xl sm:rounded-2xl bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center text-2xl sm:text-3xl relative shrink-0">
|
||||
🎨
|
||||
<div className="absolute -top-1 -right-1 text-base sm:text-xl">✨</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold mb-1">Accessories Coming Soon!</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Get ready to customize your Blobbi's appearance with amazing accessories and cosmetic items.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{topTab === 'shop' ? (
|
||||
<ShopGrid
|
||||
items={allItems}
|
||||
availableCoins={availableCoins}
|
||||
onBuy={handleBuyItem}
|
||||
purchasingItemId={effectivePurchasingId}
|
||||
/>
|
||||
) : (
|
||||
<ItemsGrid
|
||||
items={inventoryItems}
|
||||
onUseItem={handleUseItem}
|
||||
isUsingItem={isUsingItem}
|
||||
usingItemId={effectiveUsingItemId}
|
||||
onGoToShop={() => setTopTab('shop')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Items List */}
|
||||
<div className="px-4 sm:px-6 py-3 sm:py-4">
|
||||
<div className="space-y-2">
|
||||
{items.map(item => (
|
||||
<BlobbiShopItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
availableCoins={availableCoins}
|
||||
onPurchaseClick={handlePurchaseClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Purchase Dialog */}
|
||||
{selectedItem && (
|
||||
<BlobbiPurchaseDialog
|
||||
open={showPurchaseDialog}
|
||||
onOpenChange={setShowPurchaseDialog}
|
||||
item={selectedItem}
|
||||
availableCoins={availableCoins}
|
||||
onPurchase={handlePurchase}
|
||||
isPurchasing={isPurchasing}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shop Grid (tile layout, all items, cost in button) ───────────────────────
|
||||
|
||||
interface ShopGridProps {
|
||||
items: ShopItem[];
|
||||
availableCoins: number;
|
||||
onBuy: (item: ShopItem) => void;
|
||||
purchasingItemId: string | null;
|
||||
}
|
||||
|
||||
function ShopGrid({ items, availableCoins, onBuy, purchasingItemId }: ShopGridProps) {
|
||||
return (
|
||||
<div className="p-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{items.map(item => {
|
||||
const isDisabled = item.status === 'disabled';
|
||||
const isAffordable = !isDisabled && availableCoins >= item.price;
|
||||
const isBuying = purchasingItemId === item.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 p-3 rounded-xl border transition-all text-center',
|
||||
'bg-card/60 backdrop-blur-sm',
|
||||
isDisabled && 'opacity-50',
|
||||
!isDisabled && !isAffordable && 'opacity-70',
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="text-3xl leading-none mt-1">{item.icon}</div>
|
||||
|
||||
{/* Name */}
|
||||
<span className="text-xs font-medium truncate w-full">{item.name}</span>
|
||||
|
||||
{/* Buy button with integrated cost */}
|
||||
<button
|
||||
onClick={() => onBuy(item)}
|
||||
disabled={isDisabled || !isAffordable || !!purchasingItemId}
|
||||
className={cn(
|
||||
'w-full rounded-lg px-2 py-1.5 text-xs font-medium transition-colors',
|
||||
isDisabled
|
||||
? 'bg-muted text-muted-foreground cursor-not-allowed'
|
||||
: isAffordable
|
||||
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600 active:scale-95 transition-transform'
|
||||
: 'bg-muted text-muted-foreground cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isDisabled ? (
|
||||
'Soon'
|
||||
) : isBuying ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-1">
|
||||
<span>🪙</span> {formatCompactNumber(item.price)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Items Grid (inventory, tile layout) ──────────────────────────────────────
|
||||
|
||||
interface ItemsGridProps {
|
||||
items: ResolvedInventoryItem[];
|
||||
onUseItem: (item: ResolvedInventoryItem) => void;
|
||||
isUsingItem?: boolean;
|
||||
usingItemId: string | null;
|
||||
onGoToShop: () => void;
|
||||
}
|
||||
|
||||
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: ItemsGridProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
|
||||
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<Package className="size-8 text-muted-foreground/60" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
No items yet. Visit the shop to stock up!
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={onGoToShop} className="gap-2">
|
||||
<ShoppingBag className="size-3.5" />
|
||||
Browse Shop
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{items.map(item => {
|
||||
const isThisUsing = isUsingItem && usingItemId === item.itemId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.itemId}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 p-3 rounded-xl border transition-all text-center relative',
|
||||
'bg-card/60 backdrop-blur-sm',
|
||||
item.canUse ? 'hover:border-primary/40 hover:bg-accent/40' : 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{/* Quantity badge */}
|
||||
<Badge
|
||||
className="absolute top-1.5 right-1.5 text-[10px] px-1.5 py-0 h-4 min-w-4 bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0"
|
||||
>
|
||||
{item.quantity}
|
||||
</Badge>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={cn('text-3xl leading-none mt-1', !item.canUse && 'grayscale')}>{item.icon}</div>
|
||||
|
||||
{/* Name */}
|
||||
<span className="text-xs font-medium truncate w-full">{item.name}</span>
|
||||
|
||||
{/* Use button */}
|
||||
{item.canUse ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full h-7 text-xs"
|
||||
onClick={() => onUseItem(item)}
|
||||
disabled={isUsingItem}
|
||||
>
|
||||
{isThisUsing ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
'Use'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="w-full">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full h-7 text-xs"
|
||||
disabled
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{item.reason || 'Cannot use this item'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -177,39 +177,6 @@ export const BLOBBI_SHOP_ITEMS: ShopItem[] = [
|
||||
status: 'live',
|
||||
},
|
||||
|
||||
// ─── Accessory Items (Disabled) ─────────────────────────────────────────────
|
||||
{
|
||||
id: 'acc_hat',
|
||||
name: 'Party Hat',
|
||||
type: 'accessory',
|
||||
price: 75,
|
||||
icon: '🎩',
|
||||
status: 'disabled',
|
||||
},
|
||||
{
|
||||
id: 'acc_glasses',
|
||||
name: 'Cool Glasses',
|
||||
type: 'accessory',
|
||||
price: 60,
|
||||
icon: '🕶️',
|
||||
status: 'disabled',
|
||||
},
|
||||
{
|
||||
id: 'acc_bow',
|
||||
name: 'Bow Tie',
|
||||
type: 'accessory',
|
||||
price: 50,
|
||||
icon: '🎀',
|
||||
status: 'disabled',
|
||||
},
|
||||
{
|
||||
id: 'acc_crown',
|
||||
name: 'Crown',
|
||||
type: 'accessory',
|
||||
price: 100,
|
||||
icon: '👑',
|
||||
status: 'disabled',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -237,7 +204,7 @@ export function getLiveShopItems(): ShopItem[] {
|
||||
* Get all shop item categories with their counts
|
||||
*/
|
||||
export function getShopCategories(): Array<{ type: ShopItemCategory; count: number; label: string }> {
|
||||
const categories: ShopItemCategory[] = ['food', 'toy', 'medicine', 'hygiene', 'accessory'];
|
||||
const categories: ShopItemCategory[] = ['food', 'toy', 'medicine', 'hygiene'];
|
||||
|
||||
return categories.map(type => ({
|
||||
type,
|
||||
|
||||
@@ -7,8 +7,7 @@ export type ShopItemCategory =
|
||||
| 'food'
|
||||
| 'toy'
|
||||
| 'medicine'
|
||||
| 'hygiene'
|
||||
| 'accessory';
|
||||
| 'hygiene';
|
||||
|
||||
/**
|
||||
* Stat effects that items can apply to Blobbi
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* FirstHatchTourCard - Inline card shown below the egg during the first-hatch tour.
|
||||
*
|
||||
* Rendered directly in the BlobbiPage layout so the experience feels
|
||||
* focused and guided. Adapts its messaging based on the current tour step.
|
||||
*
|
||||
* When the post mission is completed, the card stays visible with a
|
||||
* celebratory completed state for ~2s (the parent auto-advances after
|
||||
* that delay). This ensures the user sees the checkmark before the
|
||||
* flow progresses to the egg-tap phase.
|
||||
*/
|
||||
|
||||
import { Send, Check, MousePointerClick } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { FirstHatchTourStepId } from '../lib/tour-types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FirstHatchTourCardProps {
|
||||
/** The Blobbi's display name */
|
||||
blobbiName: string;
|
||||
/** The exact phrase the user needs to include in their post */
|
||||
requiredPhrase: string;
|
||||
/** Whether the post mission has been completed */
|
||||
postCompleted: boolean;
|
||||
/** Open the post composer */
|
||||
onCreatePost: () => void;
|
||||
/** Current tour step id for adaptive messaging */
|
||||
currentStep: FirstHatchTourStepId | null;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FirstHatchTourCard({
|
||||
blobbiName,
|
||||
requiredPhrase,
|
||||
postCompleted,
|
||||
onCreatePost,
|
||||
currentStep,
|
||||
}: FirstHatchTourCardProps) {
|
||||
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
|
||||
|
||||
// Determine which phase of the card to show
|
||||
const isPostStep = currentStep === 'show_hatch_card';
|
||||
const isClickStep = currentStep === 'egg_glowing_waiting_click'
|
||||
|| currentStep === 'egg_crack_stage_1'
|
||||
|| currentStep === 'egg_crack_stage_2'
|
||||
|| currentStep === 'egg_crack_stage_3';
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm mx-auto space-y-4">
|
||||
{/* Title + description */}
|
||||
<div className="text-center space-y-1.5">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{isClickStep
|
||||
? `Tap ${capitalizedName} to hatch!`
|
||||
: postCompleted && isPostStep
|
||||
? `${capitalizedName} heard you!`
|
||||
: `${capitalizedName} is ready to hatch!`}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{isClickStep
|
||||
? `Tap the egg to help ${capitalizedName} break free.`
|
||||
: postCompleted && isPostStep
|
||||
? 'Your post was shared. Get ready to hatch...'
|
||||
: `Share a post to the Nostr network and help ${capitalizedName} break free.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mission card - only during post step */}
|
||||
{isPostStep && (
|
||||
<div className="rounded-xl border bg-card p-4 space-y-3">
|
||||
{postCompleted ? (
|
||||
/* ── Completed state — celebratory, stays visible ── */
|
||||
<div className="flex flex-col items-center gap-2 py-2">
|
||||
<div className="size-10 rounded-full bg-emerald-500/15 flex items-center justify-center">
|
||||
<Check className="size-5 text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
Post shared!
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Continuing in a moment...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Pending state — post mission ── */
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 size-5 rounded-full border-2 border-muted-foreground/30 shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">Share a hatch post</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your post must include:
|
||||
</p>
|
||||
<p className="text-xs font-medium text-primary break-words">
|
||||
{requiredPhrase}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onCreatePost}
|
||||
>
|
||||
<Send className="size-3.5 mr-2" />
|
||||
Create Post
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tap hint during click steps */}
|
||||
{isClickStep && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<MousePointerClick className="size-4" />
|
||||
<span>Tap the egg</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra hint for post step */}
|
||||
{isPostStep && !postCompleted && (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
You can add extra text before or after the required phrase.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* FirstHatchTourModal - Modal shown during the `show_hatch_modal` tour step.
|
||||
*
|
||||
* Tells the user their egg is about to hatch and guides them to create a post.
|
||||
* Contains a single mission: create the hatch post.
|
||||
*/
|
||||
|
||||
import { Egg, Send, Check } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FirstHatchTourModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The Blobbi's display name */
|
||||
blobbiName: string;
|
||||
/** The exact phrase the user needs to include in their post */
|
||||
requiredPhrase: string;
|
||||
/** Whether the post mission has been completed */
|
||||
postCompleted: boolean;
|
||||
/** Open the post composer */
|
||||
onCreatePost: () => void;
|
||||
/** Advance the tour (called after post is confirmed complete) */
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FirstHatchTourModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
blobbiName,
|
||||
requiredPhrase,
|
||||
postCompleted,
|
||||
onCreatePost,
|
||||
onContinue,
|
||||
}: FirstHatchTourModalProps) {
|
||||
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header with egg accent */}
|
||||
<div className="px-6 pt-8 pb-4 text-center space-y-3">
|
||||
<div className="mx-auto size-14 rounded-full bg-amber-500/10 flex items-center justify-center">
|
||||
<Egg className="size-7 text-amber-500" />
|
||||
</div>
|
||||
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
{capitalizedName} is ready to hatch!
|
||||
</DialogTitle>
|
||||
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Share a post to the Nostr network and help {capitalizedName} break free.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mission card */}
|
||||
<div className="px-6 pb-4">
|
||||
<div className="rounded-xl border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Status indicator */}
|
||||
<div className={
|
||||
postCompleted
|
||||
? 'mt-0.5 size-5 rounded-full bg-emerald-500/15 flex items-center justify-center shrink-0'
|
||||
: 'mt-0.5 size-5 rounded-full border-2 border-muted-foreground/30 shrink-0'
|
||||
}>
|
||||
{postCompleted && <Check className="size-3 text-emerald-500" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{postCompleted ? 'Post shared!' : 'Share a hatch post'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Post must include the phrase:
|
||||
</p>
|
||||
<p className="text-xs font-medium text-primary break-words">
|
||||
{requiredPhrase}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!postCompleted && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onCreatePost}
|
||||
>
|
||||
<Send className="size-3.5 mr-2" />
|
||||
Create Post
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 pb-6">
|
||||
{postCompleted ? (
|
||||
<Button className="w-full" onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
You can add extra text before or after the required phrase.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* useFirstHatchTour - State machine for the first-egg hatch tutorial.
|
||||
*
|
||||
* Orchestration only -- no rendering, no animations.
|
||||
* The hook manages:
|
||||
* - Ordered step progression
|
||||
* - Persisted state via localStorage (survives refresh / close)
|
||||
* - Derived booleans for UI consumption
|
||||
* - Safe advance / goTo / complete / reset actions
|
||||
*
|
||||
* Activation is handled separately by useFirstHatchTourActivation,
|
||||
* which calls `start()` when all preconditions are met.
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* Future integration points
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* 1. BlobbiPage (or a wrapper) calls useFirstHatchTourActivation
|
||||
* to decide whether to start the tour.
|
||||
* 2. UI components read `state.currentStepId` and render overlays,
|
||||
* spotlights, modals, or animation cues accordingly.
|
||||
* 3. Animation components call `actions.advance()` when their
|
||||
* sequence finishes (for autoAdvance steps).
|
||||
* 4. Interactive steps (e.g. "click the egg") call `actions.advance()`
|
||||
* on the user interaction.
|
||||
* 5. EggGraphic receives a visual-state prop derived from
|
||||
* `state.currentStepId` -- it does NOT own the tour logic.
|
||||
*/
|
||||
|
||||
import { useMemo, useCallback, useRef } from 'react';
|
||||
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
|
||||
import {
|
||||
FIRST_HATCH_TOUR_STEPS,
|
||||
FIRST_HATCH_TOUR_DEFAULT_STATE,
|
||||
type FirstHatchTourStepId,
|
||||
type FirstHatchTourPersistedState,
|
||||
type TourState,
|
||||
type TourActions,
|
||||
} from '../lib/tour-types';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* localStorage key for the first hatch tour state.
|
||||
* Not user-scoped because onboarding state is device-local and the tour
|
||||
* is inherently tied to "first ever egg on this device". If multi-user
|
||||
* support on the same device becomes a concern, scope by pubkey.
|
||||
*/
|
||||
const STORAGE_KEY = 'blobbi:tour:first-hatch';
|
||||
|
||||
/** Pre-computed lookup: stepId -> index */
|
||||
const STEP_INDEX_MAP = new Map<FirstHatchTourStepId, number>(
|
||||
FIRST_HATCH_TOUR_STEPS.map((step, i) => [step.id, i]),
|
||||
);
|
||||
|
||||
/** Index of the last step that is NOT the terminal 'complete' pseudo-step */
|
||||
const LAST_REAL_STEP_INDEX = FIRST_HATCH_TOUR_STEPS.length - 2;
|
||||
|
||||
// ─── Result Type ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseFirstHatchTourResult {
|
||||
/** Reactive tour state for UI consumption */
|
||||
state: TourState<FirstHatchTourStepId>;
|
||||
/** Actions to drive the tour forward */
|
||||
actions: TourActions<FirstHatchTourStepId>;
|
||||
/**
|
||||
* Convenience: check if the current step matches a given id.
|
||||
* Useful for conditional rendering: `isStep('egg_crack_stage_1')`.
|
||||
*/
|
||||
isStep: (stepId: FirstHatchTourStepId) => boolean;
|
||||
/**
|
||||
* Convenience: check if the current step is one of the given ids.
|
||||
* Useful for grouping: `isAnyStep('egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3')`.
|
||||
*/
|
||||
isAnyStep: (...stepIds: FirstHatchTourStepId[]) => boolean;
|
||||
/**
|
||||
* The current step definition (with autoAdvance metadata), or null.
|
||||
*/
|
||||
currentStepDef: (typeof FIRST_HATCH_TOUR_STEPS)[number] | null;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useFirstHatchTour(): UseFirstHatchTourResult {
|
||||
// ── Persisted state ──
|
||||
const [persisted, setPersisted] = useLocalStorage<FirstHatchTourPersistedState>(
|
||||
STORAGE_KEY,
|
||||
FIRST_HATCH_TOUR_DEFAULT_STATE,
|
||||
);
|
||||
|
||||
// Stable ref to current persisted state so callbacks never go stale.
|
||||
const persistedRef = useRef(persisted);
|
||||
persistedRef.current = persisted;
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
const updatePersisted = useCallback(
|
||||
(patch: Partial<FirstHatchTourPersistedState>) => {
|
||||
setPersisted((prev) => ({
|
||||
...prev,
|
||||
...patch,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
},
|
||||
[setPersisted],
|
||||
);
|
||||
|
||||
// ── Actions ──
|
||||
|
||||
const start = useCallback(() => {
|
||||
const p = persistedRef.current;
|
||||
// No-op if already active or completed
|
||||
if (p.completed || p.currentStepId !== null) return;
|
||||
|
||||
const firstStep = FIRST_HATCH_TOUR_STEPS[0];
|
||||
if (!firstStep) return;
|
||||
|
||||
updatePersisted({ currentStepId: firstStep.id });
|
||||
}, [updatePersisted]);
|
||||
|
||||
const advance = useCallback(() => {
|
||||
const p = persistedRef.current;
|
||||
if (p.completed || p.currentStepId === null) return;
|
||||
|
||||
const currentIndex = STEP_INDEX_MAP.get(p.currentStepId);
|
||||
if (currentIndex === undefined) return;
|
||||
|
||||
const nextIndex = currentIndex + 1;
|
||||
if (nextIndex >= FIRST_HATCH_TOUR_STEPS.length) {
|
||||
// Past the end -- complete
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStep = FIRST_HATCH_TOUR_STEPS[nextIndex];
|
||||
if (nextStep.id === 'complete') {
|
||||
// Reaching the 'complete' terminal step means the tour is done
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
} else {
|
||||
updatePersisted({ currentStepId: nextStep.id });
|
||||
}
|
||||
}, [updatePersisted]);
|
||||
|
||||
const goTo = useCallback(
|
||||
(stepId: FirstHatchTourStepId) => {
|
||||
if (!STEP_INDEX_MAP.has(stepId)) {
|
||||
throw new Error(`[FirstHatchTour] Unknown step id: "${stepId}"`);
|
||||
}
|
||||
|
||||
if (stepId === 'complete') {
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
} else {
|
||||
updatePersisted({ currentStepId: stepId, completed: false });
|
||||
}
|
||||
},
|
||||
[updatePersisted],
|
||||
);
|
||||
|
||||
const complete = useCallback(() => {
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
}, [updatePersisted]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setPersisted(FIRST_HATCH_TOUR_DEFAULT_STATE);
|
||||
}, [setPersisted]);
|
||||
|
||||
// ── Derived state ──
|
||||
|
||||
const currentStepIndex = persisted.currentStepId !== null
|
||||
? (STEP_INDEX_MAP.get(persisted.currentStepId) ?? -1)
|
||||
: -1;
|
||||
|
||||
const state = useMemo((): TourState<FirstHatchTourStepId> => {
|
||||
const isActive = persisted.currentStepId !== null && !persisted.completed;
|
||||
const totalSteps = FIRST_HATCH_TOUR_STEPS.length;
|
||||
|
||||
return {
|
||||
isActive,
|
||||
currentStepId: persisted.currentStepId,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
isLastStep: currentStepIndex === LAST_REAL_STEP_INDEX,
|
||||
isCompleted: persisted.completed,
|
||||
progress: persisted.completed
|
||||
? 1
|
||||
: currentStepIndex >= 0
|
||||
? currentStepIndex / LAST_REAL_STEP_INDEX
|
||||
: 0,
|
||||
};
|
||||
}, [persisted.currentStepId, persisted.completed, currentStepIndex]);
|
||||
|
||||
const actions = useMemo((): TourActions<FirstHatchTourStepId> => ({
|
||||
start,
|
||||
advance,
|
||||
goTo,
|
||||
complete,
|
||||
reset,
|
||||
}), [start, advance, goTo, complete, reset]);
|
||||
|
||||
// ── Convenience helpers ──
|
||||
|
||||
const isStep = useCallback(
|
||||
(stepId: FirstHatchTourStepId) => persisted.currentStepId === stepId,
|
||||
[persisted.currentStepId],
|
||||
);
|
||||
|
||||
const isAnyStep = useCallback(
|
||||
(...stepIds: FirstHatchTourStepId[]) => {
|
||||
return persisted.currentStepId !== null && stepIds.includes(persisted.currentStepId);
|
||||
},
|
||||
[persisted.currentStepId],
|
||||
);
|
||||
|
||||
const currentStepDef = currentStepIndex >= 0
|
||||
? FIRST_HATCH_TOUR_STEPS[currentStepIndex]
|
||||
: null;
|
||||
|
||||
return {
|
||||
state,
|
||||
actions,
|
||||
isStep,
|
||||
isAnyStep,
|
||||
currentStepDef,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* useFirstHatchTourActivation - Activation guard for the first-egg hatch tour.
|
||||
*
|
||||
* This hook checks all preconditions and calls `tour.actions.start()` when
|
||||
* the tour should activate. It is intentionally separated from the tour
|
||||
* state machine so that:
|
||||
* - The state machine stays generic and reusable.
|
||||
* - Activation rules are centralized in one place.
|
||||
* - The rules are easy to read and modify.
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* Activation rules (ALL must be true):
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* 1. The companions list is loaded (not loading / error).
|
||||
* 2. The user has exactly 1 Blobbi.
|
||||
* 3. That Blobbi is in the egg stage.
|
||||
* 4. No Blobbi is in baby or adult stage.
|
||||
* 5. The tour has not been completed yet (checked via profile tag
|
||||
* AND localStorage fallback).
|
||||
*
|
||||
* Completion is authoritative from the Blobbonaut profile event
|
||||
* (`blobbi_onboarding_done` tag). localStorage (`blobbi:tour:first-hatch`)
|
||||
* is a secondary signal for in-progress UI state and as a fallback
|
||||
* when the profile hasn't been updated yet.
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import type { UseFirstHatchTourResult } from './useFirstHatchTour';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FirstHatchTourActivationInput {
|
||||
/** The full list of the user's Blobbi companions */
|
||||
companions: BlobbiCompanion[];
|
||||
/** Whether the companions list is still loading */
|
||||
isLoading: boolean;
|
||||
/** The tour hook result (localStorage-based state machine) */
|
||||
tour: UseFirstHatchTourResult;
|
||||
/**
|
||||
* Whether onboarding is already marked complete in the Blobbonaut profile
|
||||
* event (`blobbi_onboarding_done` tag). This is the authoritative source.
|
||||
* When true, the tour will not activate regardless of localStorage state.
|
||||
*/
|
||||
profileOnboardingDone?: boolean;
|
||||
}
|
||||
|
||||
export interface FirstHatchTourActivationResult {
|
||||
/**
|
||||
* Whether all preconditions for activating the tour are met right now.
|
||||
* This is a derived boolean -- it does NOT mean the tour IS active,
|
||||
* just that it SHOULD be activated. The tour may already be active
|
||||
* from a previous render or a persisted state.
|
||||
*/
|
||||
shouldActivate: boolean;
|
||||
/**
|
||||
* Whether the tour is eligible (preconditions met and not yet completed).
|
||||
* Useful for hiding UI that should only appear during the tour window.
|
||||
*/
|
||||
isEligible: boolean;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Evaluates activation preconditions and auto-starts the tour when met.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const tour = useFirstHatchTour();
|
||||
* const activation = useFirstHatchTourActivation({
|
||||
* companions,
|
||||
* isLoading: companionsLoading,
|
||||
* tour,
|
||||
* profileOnboardingDone: profile?.onboardingDone,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useFirstHatchTourActivation({
|
||||
companions,
|
||||
isLoading,
|
||||
tour,
|
||||
profileOnboardingDone: _profileOnboardingDone = false,
|
||||
}: FirstHatchTourActivationInput): FirstHatchTourActivationResult {
|
||||
// ── Precondition evaluation ──
|
||||
|
||||
const { shouldActivate, isEligible } = useMemo(() => {
|
||||
// Can't evaluate until data is loaded
|
||||
if (isLoading) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// localStorage tour already completed — this is always authoritative
|
||||
if (tour.state.isCompleted) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// Must have exactly 1 companion
|
||||
if (companions.length !== 1) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
const onlyBlobbi = companions[0];
|
||||
|
||||
// That companion must be an egg
|
||||
if (onlyBlobbi.stage !== 'egg') {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// No baby or adult companions (redundant given length === 1 + stage === 'egg',
|
||||
// but kept explicit for clarity and future-proofing if rules change)
|
||||
const hasBabyOrAdult = companions.some(
|
||||
(c) => c.stage === 'baby' || c.stage === 'adult',
|
||||
);
|
||||
if (hasBabyOrAdult) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// ── TEMPORARY MIGRATION SAFEGUARD ──────────────────────────────
|
||||
// Some older accounts had `onboarding_done` migrated to
|
||||
// `blobbi_onboarding_done=true` before the first-hatch tour
|
||||
// existed, so they never experienced it. When the user is in the
|
||||
// exact single-egg/no-evolved-companions state (all checks above
|
||||
// passed), we intentionally ignore `profileOnboardingDone` so
|
||||
// those accounts can still enter the tour.
|
||||
//
|
||||
// This is safe because:
|
||||
// - The localStorage `tour.state.isCompleted` check above
|
||||
// already prevents re-triggering for users who HAVE finished
|
||||
// the tour.
|
||||
// - The egg-stage + single-companion guard means this only
|
||||
// fires for users who genuinely haven't hatched yet.
|
||||
//
|
||||
// TODO: Replace `blobbi_onboarding_done` with a dedicated
|
||||
// `blobbi_first_hatch_tour_done` tag so onboarding completion
|
||||
// and tour completion are tracked independently. Once that tag
|
||||
// is in place, remove this safeguard and gate activation on the
|
||||
// new tag instead.
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// (profileOnboardingDone is intentionally NOT checked here)
|
||||
|
||||
// All preconditions met
|
||||
const eligible = true;
|
||||
// Only activate if the tour is not already running
|
||||
const activate = !tour.state.isActive;
|
||||
|
||||
return { shouldActivate: activate, isEligible: eligible };
|
||||
}, [isLoading, companions, tour.state.isCompleted, tour.state.isActive]);
|
||||
|
||||
// ── Auto-start effect ──
|
||||
// When all preconditions are met and the tour hasn't started yet,
|
||||
// start it. This fires once and then `shouldActivate` flips to false
|
||||
// because `tour.state.isActive` becomes true.
|
||||
useEffect(() => {
|
||||
if (shouldActivate) {
|
||||
tour.actions.start();
|
||||
}
|
||||
}, [shouldActivate, tour.actions]);
|
||||
|
||||
return { shouldActivate, isEligible };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Blobbi Tour Module
|
||||
*
|
||||
* Provides the orchestration layer for guided tours / tutorials.
|
||||
* Currently implements the first-egg hatch tour.
|
||||
*
|
||||
* Architecture:
|
||||
* - tour-types.ts: Step definitions, persisted state shape, generic types
|
||||
* - useFirstHatchTour: State machine (step progression, persistence, actions)
|
||||
* - useFirstHatchTourActivation: Precondition guard (auto-starts when eligible)
|
||||
*
|
||||
* UI components import from this barrel and read tour state to decide
|
||||
* what to render. They call tour actions (advance, goTo, complete) in
|
||||
* response to user interactions or animation completions.
|
||||
*/
|
||||
|
||||
// ── Types (generic tour infrastructure) ──
|
||||
export type {
|
||||
TourStepDef,
|
||||
TourPersistedState,
|
||||
TourState,
|
||||
TourActions,
|
||||
} from './lib/tour-types';
|
||||
|
||||
// ── First Hatch Tour - Types & Constants ──
|
||||
export {
|
||||
FIRST_HATCH_TOUR_STEPS,
|
||||
FIRST_HATCH_TOUR_DEFAULT_STATE,
|
||||
} from './lib/tour-types';
|
||||
export type {
|
||||
FirstHatchTourStepId,
|
||||
FirstHatchTourPersistedState,
|
||||
} from './lib/tour-types';
|
||||
|
||||
// ── First Hatch Tour - Hooks ──
|
||||
export { useFirstHatchTour } from './hooks/useFirstHatchTour';
|
||||
export type { UseFirstHatchTourResult } from './hooks/useFirstHatchTour';
|
||||
|
||||
export { useFirstHatchTourActivation } from './hooks/useFirstHatchTourActivation';
|
||||
export type {
|
||||
FirstHatchTourActivationInput,
|
||||
FirstHatchTourActivationResult,
|
||||
} from './hooks/useFirstHatchTourActivation';
|
||||
|
||||
// ── First Hatch Tour - Components ──
|
||||
export { FirstHatchTourCard } from './components/FirstHatchTourCard';
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Tour System - Core Types
|
||||
*
|
||||
* Generic, reusable types for step-based guided tours.
|
||||
* The tour system is designed to be:
|
||||
* - Easy to extend with new tours (define steps + config)
|
||||
* - Easy to reorder steps (change the STEPS array)
|
||||
* - Persistent across page refreshes (localStorage)
|
||||
* - Decoupled from rendering (UI reads state, doesn't own it)
|
||||
*/
|
||||
|
||||
// ─── Generic Tour Infrastructure ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A tour step definition.
|
||||
*
|
||||
* Each step has a unique id and optional metadata that future UI layers
|
||||
* can use to decide what to render (spotlights, modals, animations, etc.).
|
||||
*/
|
||||
export interface TourStepDef<StepId extends string = string> {
|
||||
/** Unique identifier for this step */
|
||||
id: StepId;
|
||||
/**
|
||||
* Whether this step auto-advances (e.g. animations) or waits for
|
||||
* an explicit `advance()` / `goTo()` call from the UI.
|
||||
* Default: false (manual).
|
||||
*/
|
||||
autoAdvance?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted state for a tour.
|
||||
* Stored in localStorage so tours survive refresh / close / return.
|
||||
*/
|
||||
export interface TourPersistedState<StepId extends string = string> {
|
||||
/** Current step id, or null when the tour is not yet started */
|
||||
currentStepId: StepId | null;
|
||||
/** Whether the tour has been completed */
|
||||
completed: boolean;
|
||||
/** Unix ms timestamp of last state change (for debugging / analytics) */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full runtime state exposed by a tour hook.
|
||||
*/
|
||||
export interface TourState<StepId extends string = string> {
|
||||
/** Whether the tour is currently active (started and not yet completed) */
|
||||
isActive: boolean;
|
||||
/** Current step id, or null when idle / completed */
|
||||
currentStepId: StepId | null;
|
||||
/** 0-based index of the current step in the steps array, or -1 */
|
||||
currentStepIndex: number;
|
||||
/** Total number of steps */
|
||||
totalSteps: number;
|
||||
/** Whether the current step is the last one before completion */
|
||||
isLastStep: boolean;
|
||||
/** Whether the tour has been completed (persisted) */
|
||||
isCompleted: boolean;
|
||||
/** Progress as a fraction 0..1 */
|
||||
progress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions exposed by a tour hook.
|
||||
*/
|
||||
export interface TourActions<StepId extends string = string> {
|
||||
/** Start the tour from the first step (no-op if already active or completed) */
|
||||
start: () => void;
|
||||
/** Advance to the next step. Completes the tour if on the last step. */
|
||||
advance: () => void;
|
||||
/** Jump to a specific step by id. Throws if the step doesn't exist. */
|
||||
goTo: (stepId: StepId) => void;
|
||||
/** Mark the tour as completed and reset to idle. */
|
||||
complete: () => void;
|
||||
/** Reset the tour entirely (clears persisted state). For dev/testing. */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// ─── First Hatch Tour ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Step ids for the first-egg hatch tour.
|
||||
*
|
||||
* Flow:
|
||||
* 1. idle — initial state (auto-advances immediately)
|
||||
* 2. show_hatch_card — egg with initial crack + wiggle + inline card
|
||||
* 3. egg_glowing_waiting_click — post done, egg glows, waiting for user click
|
||||
* 4. egg_crack_stage_1 — click 1: crack expands
|
||||
* 5. egg_crack_stage_2 — click 2: crack expands further
|
||||
* 6. egg_crack_stage_3 — click 3: crack reaches edges
|
||||
* 7. egg_opening — shell opens (auto-advance after animation)
|
||||
* 8. egg_hatching — bright light + baby reveal (auto-advance)
|
||||
* 9. complete — terminal, marks tour done
|
||||
*
|
||||
* The order here matches the intended flow. To reorder steps,
|
||||
* change FIRST_HATCH_TOUR_STEPS (the array), not this type.
|
||||
*/
|
||||
export type FirstHatchTourStepId =
|
||||
| 'idle'
|
||||
| 'show_hatch_card'
|
||||
| 'egg_glowing_waiting_click'
|
||||
| 'egg_crack_stage_1'
|
||||
| 'egg_crack_stage_2'
|
||||
| 'egg_crack_stage_3'
|
||||
| 'egg_opening'
|
||||
| 'egg_hatching'
|
||||
| 'complete';
|
||||
|
||||
/**
|
||||
* Ordered step definitions for the first hatch tour.
|
||||
*
|
||||
* To add / remove / reorder steps, edit this array.
|
||||
* The tour state machine walks through these in order.
|
||||
*/
|
||||
export const FIRST_HATCH_TOUR_STEPS: TourStepDef<FirstHatchTourStepId>[] = [
|
||||
{ id: 'idle' },
|
||||
{ id: 'show_hatch_card' },
|
||||
{ id: 'egg_glowing_waiting_click' },
|
||||
{ id: 'egg_crack_stage_1' },
|
||||
{ id: 'egg_crack_stage_2' },
|
||||
{ id: 'egg_crack_stage_3' },
|
||||
{ id: 'egg_opening', autoAdvance: true },
|
||||
{ id: 'egg_hatching', autoAdvance: true },
|
||||
{ id: 'complete' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Persisted state shape for the first hatch tour.
|
||||
*/
|
||||
export type FirstHatchTourPersistedState = TourPersistedState<FirstHatchTourStepId>;
|
||||
|
||||
/**
|
||||
* Default persisted state for a brand-new first hatch tour.
|
||||
*/
|
||||
export const FIRST_HATCH_TOUR_DEFAULT_STATE: FirstHatchTourPersistedState = {
|
||||
currentStepId: null,
|
||||
completed: false,
|
||||
updatedAt: 0,
|
||||
};
|
||||
@@ -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,167 +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 { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
|
||||
|
||||
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]);
|
||||
|
||||
// Defense-in-depth: sanitize the final SVG before DOM injection.
|
||||
// The upstream pipeline validates inputs (normalizeHexColor, instanceId sanitization),
|
||||
// but this catches anything unexpected from the 3000+ lines of SVG string manipulation.
|
||||
const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]);
|
||||
// ── 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,
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: safeSvg }}
|
||||
/>
|
||||
>
|
||||
<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,161 +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 { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
|
||||
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]);
|
||||
|
||||
// Defense-in-depth: sanitize the final SVG before DOM injection.
|
||||
// The upstream pipeline validates inputs (normalizeHexColor, instanceId sanitization),
|
||||
// but this catches anything unexpected from the 3000+ lines of SVG string manipulation.
|
||||
const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]);
|
||||
// ── 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,
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: safeSvg }}
|
||||
/>
|
||||
>
|
||||
<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, type EggTourVisualState } 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, EggTourVisualState } from '@/blobbi/egg';
|
||||
|
||||
export interface BlobbiEggVisualProps {
|
||||
/** The Blobbi companion data from parseBlobbiEvent */
|
||||
@@ -34,6 +34,12 @@ export interface BlobbiEggVisualProps {
|
||||
animated?: boolean;
|
||||
/** Reaction state for music/sing animations */
|
||||
reaction?: EggReactionState;
|
||||
/** Status effects for egg visual feedback (dirty, sick, happy) */
|
||||
statusEffects?: EggStatusEffects;
|
||||
/** Tour visual state - driven externally by the tour orchestration layer */
|
||||
tourVisualState?: EggTourVisualState;
|
||||
/** Callback when the egg is clicked during an interactive tour step */
|
||||
onTourEggClick?: () => void;
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string;
|
||||
}
|
||||
@@ -67,6 +73,9 @@ export function BlobbiEggVisual({
|
||||
size = 'md',
|
||||
animated = false,
|
||||
reaction = 'idle',
|
||||
statusEffects,
|
||||
tourVisualState,
|
||||
onTourEggClick,
|
||||
className,
|
||||
}: BlobbiEggVisualProps) {
|
||||
// Memoize adapter output to avoid unnecessary re-renders
|
||||
@@ -99,6 +108,9 @@ export function BlobbiEggVisual({
|
||||
sizeVariant={config.sizeVariant}
|
||||
animated={animated && !isSleeping}
|
||||
reaction={effectiveReaction}
|
||||
statusEffects={isSleeping ? undefined : statusEffects}
|
||||
tourVisualState={tourVisualState}
|
||||
onTourEggClick={onTourEggClick}
|
||||
/>
|
||||
</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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user