diff --git a/.gitignore b/.gitignore index f5f24a6..051b6ed 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-ssr +tmp *.local .env diff --git a/AGENTS.md b/AGENTS.md index 66ca602..4ef7970 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ An **optional** Cloudflare Worker (`worker/`) provides weekly AI summaries via W **GitHub Pages base path:** Production URL is `https://projects.sillylittle.tech/mentell/`. CI sets `VITE_BASE=/mentell/` in `.github/workflows/gh-pages.yml`. Local dev uses `base: /`. Use `publicUrl()` for static assets under `public/`. -**UI assets:** Edit PNGs in [`asset/`](asset/) (source), then run `npm run sync:assets` to copy into `public/asset/`. Production builds run sync automatically. Reference assets in React via `publicUrl('/asset/…')`. +**UI assets:** Edit PNGs in [`asset/`](asset/) (source), then run `npm run sync:assets` to copy into `public/asset/`. Character SVGs live in [`asset/char/`](asset/char/) (also synced to `public/asset/char/`); `sync:assets` regenerates [`src/features/character/charManifest.generated.ts`](src/features/character/charManifest.generated.ts) from `charprod.svg` inkscape labels (DNI / III / TOGGLE). Test customization at `/character-lab`. Production builds run sync automatically. Reference PNGs in React via `publicUrl('/asset/…')`. ### Development commands diff --git a/VERSION b/VERSION index 0a182f2..6ecac68 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.2 \ No newline at end of file +1.9.5 \ No newline at end of file diff --git a/asset/char/charprod.svg b/asset/char/charprod.svg new file mode 100644 index 0000000..9ebaad5 --- /dev/null +++ b/asset/char/charprod.svg @@ -0,0 +1,1558 @@ + + + + diff --git a/asset/char/headshot.svg b/asset/char/headshot.svg new file mode 100644 index 0000000..2644e4d --- /dev/null +++ b/asset/char/headshot.svg @@ -0,0 +1,619 @@ + + + + diff --git a/asset/shop/README.md b/asset/shop/README.md new file mode 100644 index 0000000..82ba8bd --- /dev/null +++ b/asset/shop/README.md @@ -0,0 +1,126 @@ +# Shoppe item catalog format + +`shoppe-items.json` defines purchasable items for the in-app Shoppe. + +Default cosmetics are **not** stored in this catalog: + +- Default stamp artwork comes from `asset/shop/stamp.svg`. +- Default cursor artwork comes from `asset/shop/pointer.svg`. + +Keep those defaults out of `shoppe-items.json` so unlockable items stay additive. + +## Root format + +- `version` (number): schema version. +- `items` (array): list of shop item objects. + +## Shared item fields + +Each item includes: + +- `id` (string, unique) +- `type` (`image` | `theme` | `stamp` | `cursor`) +- `name` (string) +- `description` (string) +- `cost` (number, points) +- `preview` (optional string, usually `/asset/...`, currently used by `image` items) + +## Item-specific fields + +### `theme` + +```json +{ + "type": "theme", + "theme": { + "light": { + "deskBg": "#...", + "paperBg": "#...", + "paperBorder": "rgba(...)", + "accent": "#...", + "overlay": "radial-gradient(...)" + }, + "dark": { + "deskBg": "#...", + "paperBg": "#...", + "paperBorder": "rgba(...)", + "accent": "#...", + "overlay": "radial-gradient(...)" + } + } +} +``` + +Theme preview cards are generated from the four desk/paper colors (`light` + `dark`) and do not depend on `preview` images. + +### `stamp` + +```json +{ + "type": "stamp", + "stamp": { + "text": "Airmail", + "ink": "#...", + "outline": "#...", + "textColor": "#...", + "tiltDeg": -14, + "opacity": 0.24 + } +} +``` + +Stamp previews and submit-animation stamps are generated from these fields. Keep `text` short (recommended <= 12 chars) for best fit. + +### `cursor` + +```json +{ + "type": "cursor", + "cursor": { + "primary": "#...", + "secondary": "#...", + "outline": "#...", + "textPrimary": "#...", + "hotspot": { + "default": [3, 3], + "pointer": [4, 2], + "text": [8, 14] + } + } +} +``` + +Cursor preview cards render a live hover box by generating `default`, `pointer`, and `text` cursors from `asset/shop/pointer.svg`. + +### `image` + +```json +{ + "type": "image", + "image": { + "url": "/asset/..." + } +} +``` + +## Asset contracts + +### `asset/shop/pointer.svg` + +- Preferred: groups with `data-context="default|pointer|text"`. +- Supported fallback: per-shape `inkscape:label` hints containing `point` / `pointer` / `text`. +- Optional color hooks: + - `data-fill="primary|secondary|outline|text"` + - `data-stroke="primary|outline"` + +### `asset/shop/stamp.svg` + +- Used directly as the default non-shop stamp artwork. +- Unlockable stamp items are generated from catalog values (`stamp.text`, `ink`, `outline`, etc.). + +## Iteration checklist + +1. Edit `shoppe-items.json` (add/remove/update items). +2. If changing default art, edit `asset/shop/stamp.svg` and/or `asset/shop/pointer.svg`. +3. Run `npm run build:check` to verify parsing/rendering. +4. Verify `/shop` preview cards and submit animation manually in the browser. diff --git a/asset/shop/pointer.svg b/asset/shop/pointer.svg new file mode 100644 index 0000000..18f6dc2 --- /dev/null +++ b/asset/shop/pointer.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + diff --git a/asset/shop/shoppe-items.json b/asset/shop/shoppe-items.json new file mode 100644 index 0000000..8fa1c49 --- /dev/null +++ b/asset/shop/shoppe-items.json @@ -0,0 +1,496 @@ +{ + "version": 1, + "items": [ + { + "id": "theme-sunrise-paper", + "type": "theme", + "name": "Sunrise Paper", + "description": "Soft amber desk tones with warm paper for daylight journaling.", + "cost": 60, + "preview": "/asset/light.png", + "theme": { + "light": { + "deskBg": "#d6b08a", + "paperBg": "#fff2db", + "paperBorder": "rgba(130, 78, 35, 0.2)", + "accent": "#cf552a", + "overlay": "radial-gradient(circle at 18% 12%, rgba(255, 229, 168, 0.32), transparent 58%)" + }, + "dark": { + "deskBg": "#1c1620", + "paperBg": "#2a202b", + "paperBorder": "rgba(240, 168, 108, 0.24)", + "accent": "#ff8d4d", + "overlay": "radial-gradient(circle at 15% 20%, rgba(255, 165, 98, 0.16), transparent 62%)" + } + } + }, + { + "id": "theme-aurora-night", + "type": "theme", + "name": "Aurora Night", + "description": "A cooler desk tint for late-night writing sessions.", + "cost": 70, + "preview": "/asset/dark.png", + "theme": { + "light": { + "deskBg": "#9fb2c7", + "paperBg": "#eef7ff", + "paperBorder": "rgba(46, 88, 132, 0.22)", + "accent": "#2a6db5", + "overlay": "radial-gradient(circle at 82% 10%, rgba(183, 237, 255, 0.34), transparent 56%)" + }, + "dark": { + "deskBg": "#0f1728", + "paperBg": "#17243a", + "paperBorder": "rgba(147, 212, 255, 0.24)", + "accent": "#5fa4ff", + "overlay": "radial-gradient(circle at 80% 12%, rgba(95, 164, 255, 0.2), transparent 62%)" + } + } + }, + { + "id": "theme-rose-noir", + "type": "theme", + "name": "Rose Noir", + "description": "Muted burgundy ink vibes with softer high-contrast accents.", + "cost": 80, + "preview": "/asset/mentell-icon.png", + "theme": { + "light": { + "deskBg": "#c9a0a7", + "paperBg": "#fff0f4", + "paperBorder": "rgba(118, 47, 66, 0.24)", + "accent": "#b1345a", + "overlay": "radial-gradient(circle at 10% 88%, rgba(255, 180, 204, 0.34), transparent 56%)" + }, + "dark": { + "deskBg": "#1c1219", + "paperBg": "#2a1722", + "paperBorder": "rgba(235, 140, 176, 0.24)", + "accent": "#f072a3", + "overlay": "radial-gradient(circle at 15% 84%, rgba(240, 114, 163, 0.19), transparent 62%)" + } + } + }, + { + "id": "stamp-airmail", + "type": "stamp", + "name": "Airmail Stamp", + "description": "Postal blue variant for outgoing reflections.", + "cost": 35, + "preview": "/asset/stamp.png", + "stamp": { + "text": "Airmail", + "ink": "#266ac9", + "outline": "#1d4e94", + "textColor": "#1d4e94", + "tiltDeg": -12, + "opacity": 0.25 + } + }, + { + "id": "stamp-secret", + "type": "stamp", + "name": "Secret Stamp", + "description": "Stealthy violet stamp with softer letterpress text.", + "cost": 40, + "preview": "/asset/stamp.png", + "stamp": { + "text": "Secret", + "ink": "#6f4eb3", + "outline": "#4b347a", + "textColor": "#4b347a", + "tiltDeg": -10, + "opacity": 0.24 + } + }, + { + "id": "cursor-comet", + "type": "cursor", + "name": "Comet Cursor", + "description": "Crisp sky-blue cursor set for default, pointer, and text.", + "cost": 30, + "preview": "/asset/projector.png", + "cursor": { + "primary": "#78c5ff", + "secondary": "#f0f8ff", + "outline": "#1a4e83", + "textPrimary": "#5ab8ff", + "hotspot": { + "default": [2, 2], + "pointer": [3, 2], + "text": [5, 9] + } + } + }, + { + "id": "cursor-ember", + "type": "cursor", + "name": "Ember Cursor", + "description": "Warm amber cursor set with high contrast outlines.", + "cost": 30, + "preview": "/asset/envelope.png", + "cursor": { + "primary": "#ff8f4a", + "secondary": "#fff3dc", + "outline": "#7f3b1b", + "textPrimary": "#ffb45b", + "hotspot": { + "default": [2, 2], + "pointer": [3, 2], + "text": [5, 9] + } + } + }, + { + "id": "accessory-pet-cat", + "type": "characterAccessory", + "name": "Desk Cat Companion", + "description": "A small cat friend for the character scene.", + "cost": 160, + "characterAccessory": { + "scope": "pet", + "exclusiveGroup": "pet", + "toggles": [ + { + "groupKey": "togglepet", + "optionIds": ["Catbase"] + } + ], + "fillKeys": ["catbase"] + } + }, + { + "id": "accessory-pet-dog", + "type": "characterAccessory", + "name": "Desk Dog Companion", + "description": "A small dog friend for the character scene.", + "cost": 160, + "characterAccessory": { + "scope": "pet", + "exclusiveGroup": "pet", + "toggles": [ + { + "groupKey": "togglepet", + "optionIds": ["Dogbase"] + } + ], + "fillKeys": ["dogbase"] + } + }, + { + "id": "accessory-watch", + "type": "characterAccessory", + "name": "Tiny Watch", + "description": "A little watch that follows both wrists during character poses.", + "cost": 95, + "characterAccessory": { + "scope": "wrist", + "exclusiveGroup": "wrist", + "fillKeys": ["watchr"], + "defaultChoiceId": "right", + "choices": [ + { + "id": "right", + "label": "Right", + "toggles": [{ "groupKey": "togglewrist", "optionIds": ["WatchR"] }], + "anchoredIds": { "armR": ["WatchR"] } + }, + { + "id": "left", + "label": "Left", + "toggles": [{ "groupKey": "togglewrist", "optionIds": ["WatchL"] }], + "anchoredIds": { "armL": ["WatchL"] } + }, + { + "id": "both", + "label": "Both", + "toggles": [{ "groupKey": "togglewrist", "optionIds": ["WatchR", "WatchL"] }], + "anchoredIds": { "armL": ["WatchL"], "armR": ["WatchR"] } + } + ] + } + }, + { + "id": "accessory-wristband", + "type": "characterAccessory", + "name": "Wristband", + "description": "Simple wristbands that stay anchored through arm animations.", + "cost": 85, + "characterAccessory": { + "scope": "wrist", + "exclusiveGroup": "wrist", + "fillKeys": ["bandr", "bandl"], + "defaultChoiceId": "right", + "choices": [ + { + "id": "right", + "label": "Right", + "toggles": [{ "groupKey": "togglewrist", "optionIds": ["BandR"] }], + "anchoredIds": { "armR": ["BandR"] } + }, + { + "id": "left", + "label": "Left", + "toggles": [{ "groupKey": "togglewrist", "optionIds": ["BandL"] }], + "anchoredIds": { "armL": ["BandL"] } + }, + { + "id": "both", + "label": "Both", + "toggles": [{ "groupKey": "togglewrist", "optionIds": ["BandR", "BandL"] }], + "anchoredIds": { "armL": ["BandL"], "armR": ["BandR"] } + } + ] + } + }, + { + "id": "accessory-hair-object", + "type": "characterAccessory", + "name": "Lemon Hair Pin", + "description": "A colorable hair object for the character.", + "cost": 120, + "characterAccessory": { + "scope": "hair", + "exclusiveGroup": "hair", + "parts": ["Lemmon"], + "fillKeys": ["lemmon"] + } + }, + { + "id": "accessory-full-body-ghost", + "type": "characterAccessory", + "name": "Ghost Costume", + "description": "A premium full-body costume that replaces the base skin.", + "cost": 475, + "characterAccessory": { + "scope": "fullBody", + "exclusiveGroup": "fullBody", + "parts": ["GHOST", "GHOST_armL_joint", "GHOST_armR_joint"], + "hideSkin": true, + "hideBaseClothes": true, + "anchoredIds": { + "armL": ["GHOST_armL_joint"], + "armR": ["GHOST_armR_joint"] + } + } + }, + { + "id": "accessory-full-body-skeleton", + "type": "characterAccessory", + "name": "Skeleton Costume", + "description": "A premium full-body skeleton costume that replaces the base skin.", + "cost": 525, + "characterAccessory": { + "scope": "fullBody", + "exclusiveGroup": "fullBody", + "parts": ["SKELETON", "SKELETON_armL_joint", "SKELETON_armR_joint"], + "hideSkin": true, + "hideBaseClothes": true, + "anchoredIds": { + "armL": ["SKELETON_armL_joint"], + "armR": ["SKELETON_armR_joint"] + } + } + }, + { + "id": "accessory-boots", + "type": "characterAccessory", + "name": "Boots", + "description": "A pair of sturdy character boots.", + "cost": 140, + "characterAccessory": { + "scope": "fullBody", + "exclusiveGroup": "shoes", + "toggles": [{ "groupKey": "toggleshoes", "optionIds": ["BOOTS"] }] + } + }, + { + "id": "accessory-sneakers", + "type": "characterAccessory", + "name": "Sneakers", + "description": "A pair of casual character sneakers.", + "cost": 140, + "characterAccessory": { + "scope": "fullBody", + "exclusiveGroup": "shoes", + "toggles": [{ "groupKey": "toggleshoes", "optionIds": ["SNEEKER"] }] + } + }, + { + "id": "accessory-heels", + "type": "characterAccessory", + "name": "Heels", + "description": "A pair of character heels.", + "cost": 140, + "characterAccessory": { + "scope": "fullBody", + "exclusiveGroup": "shoes", + "toggles": [{ "groupKey": "toggleshoes", "optionIds": ["HEEL"] }] + } + }, + { + "id": "accessory-gloves", + "type": "characterAccessory", + "name": "Gloves", + "description": "Colorable gloves for both hands.", + "cost": 125, + "characterAccessory": { + "scope": "wrist", + "exclusiveGroup": "wrist", + "toggles": [{ "groupKey": "togglewrist", "optionIds": ["GloveR", "GloveL"] }], + "fillKeys": ["glover", "glovel"], + "anchoredIds": { "armL": ["GloveL"], "armR": ["GloveR"] } + } + }, + { + "id": "accessory-pockets", + "type": "characterAccessory", + "name": "Pockets", + "description": "Colorable trouser pockets.", + "cost": 80, + "characterAccessory": { + "scope": "pants", + "exclusiveGroup": "pants", + "toggles": [{ "groupKey": "togglepants", "optionIds": ["PocketR", "PocketL"] }], + "fillKeys": ["pocketr", "pocketl"] + } + }, + { + "id": "accessory-shirt-star", + "type": "characterAccessory", + "name": "Star Shirt Patch", + "description": "A colorable shirt patch for the character.", + "cost": 110, + "characterAccessory": { + "scope": "fullBody", + "exclusiveGroup": "shirtPatch", + "toggles": [{ "groupKey": "layer6", "optionIds": ["STAR"] }], + "fillKeys": ["star"] + } + }, + { + "id": "accessory-shirt-circle", + "type": "characterAccessory", + "name": "Circle Shirt Patch", + "description": "A colorable circle shirt patch for the character.", + "cost": 100, + "characterAccessory": { + "scope": "fullBody", + "exclusiveGroup": "shirtPatch", + "toggles": [{ "groupKey": "layer6", "optionIds": ["CIRCLE"] }], + "fillKeys": ["circle"] + } + }, + { + "id": "accessory-shirt-square", + "type": "characterAccessory", + "name": "Square Shirt Patch", + "description": "A colorable square shirt patch for the character.", + "cost": 100, + "characterAccessory": { + "scope": "fullBody", + "exclusiveGroup": "shirtPatch", + "toggles": [{ "groupKey": "layer6", "optionIds": ["SQUARE"] }], + "fillKeys": ["square"] + } + }, + { + "id": "accessory-glasses-square", + "type": "characterAccessory", + "name": "Square Glasses", + "description": "Square glasses for the character.", + "cost": 130, + "characterAccessory": { + "scope": "face", + "exclusiveGroup": "face", + "toggles": [{ "groupKey": "toggleface", "optionIds": ["Glassessquare"] }] + } + }, + { + "id": "accessory-glasses-circle", + "type": "characterAccessory", + "name": "Circle Glasses", + "description": "Round glasses for the character.", + "cost": 130, + "characterAccessory": { + "scope": "face", + "exclusiveGroup": "face", + "toggles": [{ "groupKey": "toggleface", "optionIds": ["Glassescircle"] }] + } + }, + { + "id": "accessory-bandana", + "type": "characterAccessory", + "name": "Bandana", + "description": "A colorable face bandana.", + "cost": 120, + "characterAccessory": { + "scope": "face", + "exclusiveGroup": "face", + "toggles": [{ "groupKey": "toggleface", "optionIds": ["Bandana"] }], + "fillKeys": ["bandana"] + } + }, + { + "id": "accessory-mask", + "type": "characterAccessory", + "name": "Mask", + "description": "A face mask for the character.", + "cost": 120, + "characterAccessory": { + "scope": "face", + "exclusiveGroup": "face", + "toggles": [{ "groupKey": "toggleface", "optionIds": ["Mask"] }] + } + }, + { + "id": "accessory-cowboy-hat", + "type": "characterAccessory", + "name": "Cowboy Hat", + "description": "A wide-brimmed cowboy hat.", + "cost": 170, + "characterAccessory": { + "scope": "hat", + "exclusiveGroup": "hat", + "toggles": [{ "groupKey": "togglehat", "optionIds": ["Cowboy"] }] + } + }, + { + "id": "accessory-headcover", + "type": "characterAccessory", + "name": "Headcover", + "description": "A translucent full headcover.", + "cost": 180, + "characterAccessory": { + "scope": "hat", + "exclusiveGroup": "hat", + "toggles": [{ "groupKey": "togglehat", "optionIds": ["Headcover"] }] + } + }, + { + "id": "accessory-crown", + "type": "characterAccessory", + "name": "Crown", + "description": "A bright crown for celebratory days.", + "cost": 220, + "characterAccessory": { + "scope": "hat", + "exclusiveGroup": "hat", + "toggles": [{ "groupKey": "togglehat", "optionIds": ["Crown"] }] + } + }, + { + "id": "image-gift-print", + "type": "image", + "name": "Gift Print", + "description": "A collectible image item example for future inventory drops.", + "cost": 20, + "preview": "/asset/gift_small.png", + "image": { + "url": "/asset/gift_large.png" + } + } + ] +} diff --git a/asset/shop/stamp.svg b/asset/shop/stamp.svg new file mode 100644 index 0000000..9737ad9 --- /dev/null +++ b/asset/shop/stamp.svg @@ -0,0 +1,447 @@ + + + + diff --git a/docs/CHARACTER_CUSTOMIZATION.md b/docs/CHARACTER_CUSTOMIZATION.md new file mode 100644 index 0000000..5f0dcbd --- /dev/null +++ b/docs/CHARACTER_CUSTOMIZATION.md @@ -0,0 +1,50 @@ +# Character customization and animation iteration + +This guide covers how to iterate on the desk character system quickly. + +## Source files + +- Body SVG source: `asset/char/charprod.svg` +- Headshot / icon SVG source: `asset/char/headshot.svg` +- Generated manifest: `src/features/character/charManifest.generated.ts` +- Manifest generator: `scripts/generate-char-manifest.mjs` + +## Adding clothing / visual options + +Character options are discovered from Inkscape labels in `charprod.svg`. + +- Fill targets: label with `_III` +- Toggle groups: label with `_TOGGLE` +- Global fill groups: toggle-layer labels that also include `_III` +- Excluded shapes: label with `_DNI` + +After SVG edits, regenerate metadata: + +1. `node scripts/generate-char-manifest.mjs` +2. Confirm `charManifest.generated.ts` changed as expected +3. Verify Character Lab controls in `/character-lab` + +The control order/labels for Character Lab live in `src/features/character/charLabControls.ts`. + +## Arm animation tuning + +- Pose targets: `src/features/character/characterPoses.ts` +- Motion interpolation and pivots: `src/features/character/useArmPoseAnimation.ts` +- Runtime SVG composition / layer promotion: `src/features/character/MentellCharacter.tsx` + +If arm layers ever slip behind torso/hair after SVG reorder changes, keep `promoteAnimatedArmLayers()` in sync with your new arm/sleeve IDs. + +## Icon framing + +- Browser tab icon sync: `src/features/character/CharacterTabIconSync.tsx` + +`FAVICON_HEAD_VIEWBOX` controls icon crop. Update it whenever `headshot.svg` layout changes, then verify hair toggles do not shift icon centering. + +## Recommended validation flow + +1. `npm run build:check` +2. `npm run lint` +3. Manual browser checks: + - `/character-lab` hair/clothing toggles and pose animation + - top-nav Character icon alignment + - browser tab icon updates while changing hair diff --git a/package.json b/package.json index 43071e4..974f755 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "mentell", "private": true, - "version": "1.7.2", + "version": "1.9.5", "type": "module", "scripts": { "sync:assets": "node scripts/sync-assets.mjs", + "pose:lab": "elixir scripts/pose_lab.exs", "dev": "vite", "dev:debug": "vite --mode debug", "build": "npm run sync:assets && tsc -b && vite build", diff --git a/public/asset/char/charprod.svg b/public/asset/char/charprod.svg new file mode 100644 index 0000000..9ebaad5 --- /dev/null +++ b/public/asset/char/charprod.svg @@ -0,0 +1,1558 @@ + + + + diff --git a/public/asset/char/headshot.svg b/public/asset/char/headshot.svg new file mode 100644 index 0000000..2644e4d --- /dev/null +++ b/public/asset/char/headshot.svg @@ -0,0 +1,619 @@ + + + + diff --git a/scripts/generate-char-manifest.mjs b/scripts/generate-char-manifest.mjs new file mode 100644 index 0000000..589a2cd --- /dev/null +++ b/scripts/generate-char-manifest.mjs @@ -0,0 +1,595 @@ +/** + * Parses asset/char/charprod.svg inkscape:label conventions into a TS manifest. + * DNI = do not customize, III = fill color, TOGGLE = mutually exclusive siblings. + * toggle*_III parents = global fill across all style variants. + */ +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const svgPath = path.join(root, 'asset', 'char', 'charprod.svg') +const headshotPath = path.join(root, 'asset', 'char', 'headshot.svg') +const outPath = path.join(root, 'src', 'features', 'character', 'charManifest.generated.ts') + +const ID_RE = /\bid="([^"]+)"/ +const STYLE_RE = /style="([^"]*)"/ + +function classifyLabel(label) { + const u = label.toUpperCase() + if (u.endsWith('_DNI')) return 'dni' + if (u.endsWith('_TOGGLE_III') || u.endsWith('_TOGGLE')) return 'toggle' + if (label.toLowerCase().endsWith('_toggle')) return 'toggle' + if (/toggle.*_III$/i.test(label)) return 'globalFill' + if (u.endsWith('_III')) return 'iii' + return null +} + +function humanize(label) { + return label + .replace(/_(TOGGLE|Toggle|III|DNI).*$/i, '') + .replace(/_/g, ' ') + .replace(/\btoggle\b/gi, '') + .trim() + .replace(/\b\w/g, (c) => c.toUpperCase()) || label +} + +function globalFillKey(label) { + if (/shirt/i.test(label)) return 'shirt' + if (/sleeve/i.test(label)) return 'sleeves' + return humanize(label).toLowerCase().replace(/\s+/g, '_') +} + +function parseFill(style) { + if (!style) return null + const m = style.match(/(?:^|;)\s*fill:(#[0-9a-fA-F]{3,8})/) + if (m) return m[1] + return null +} + +function styleForElementId(block, id) { + const tagRe = /<(?:path|ellipse|rect)\b[^>]*>/gi + let m + while ((m = tagRe.exec(block)) !== null) { + const tag = m[0] + if (tag.match(ID_RE)?.[1] === id) return tag.match(STYLE_RE)?.[1] ?? null + } + return null +} + +function isVisibleOpening(tag) { + const style = tag.match(STYLE_RE)?.[1] ?? '' + if (!style.includes('display:')) return true + return /display:\s*inline/.test(style) +} + +function pathStartX(pathTag) { + const d = pathTag.match(/\bd="([^"]*)"/)?.[1] + if (!d) return null + const m = d.match(/-?\d*\.?\d+/) + return m ? Number.parseFloat(m[0]) : null +} + +/** Direct-child toggle options only (one nesting level under parent). */ +function directToggleChildren(parentInner) { + const children = [] + let depth = 0 + for (let i = 0; i < parentInner.length; i++) { + if (parentInner.startsWith('') + const tag = slice.slice(0, tagEnd + 1) + if (label && id && classifyLabel(label) === 'toggle') { + children.push({ id, label, visible: isVisibleOpening(tag) }) + } + } + depth++ + i = parentInner.indexOf('>', i) + continue + } + if (parentInner.startsWith('', i)) { + depth = Math.max(0, depth - 1) + i += 3 + continue + } + if ( + depth === 0 && + (parentInner.startsWith('') + const tag = slice.slice(0, tagEnd + 1) + if (label && id && classifyLabel(label) === 'toggle') { + children.push({ id, label, visible: isVisibleOpening(tag) }) + } + } + } + return children +} + +function extractBalancedGroup(xml, startIndex) { + const open = xml.indexOf('>', startIndex) + if (open === -1) return null + let depth = 1 + let i = open + 1 + while (i < xml.length && depth > 0) { + const nextOpen = xml.indexOf('', i) + if (nextClose === -1) break + if (nextOpen !== -1 && nextOpen < nextClose) { + depth++ + i = nextOpen + 2 + } else { + depth-- + i = nextClose + 4 + } + } + return xml.slice(startIndex, i) +} + +function collectSolidFillPathIds(block) { + const ids = [] + const tagRe = /<(path|ellipse|rect)\b[^>]*>/g + let m + while ((m = tagRe.exec(block)) !== null) { + const tag = m[0] + const id = tag.match(ID_RE)?.[1] + const style = tag.match(STYLE_RE)?.[1] + if (id && parseFill(style)) ids.push(id) + } + return [...new Set(ids)] +} + +function collectSleeveIdsBySide(xml, sleeveParentId) { + const parentMatch = new RegExp( + `]*id="${sleeveParentId}"[^>]*inkscape:label="[^"]*"[^>]*>`, + 'i', + ).exec(xml) + if (!parentMatch) return { left: [], right: [] } + + const block = extractBalancedGroup(xml, parentMatch.index) + if (!block) return { left: [], right: [] } + + const midX = -485 + const left = [] + const right = [] + const pathRe = /]*>/g + let pm + while ((pm = pathRe.exec(block)) !== null) { + const tag = pm[0] + const id = tag.match(ID_RE)?.[1] + if (!id) continue + const x = pathStartX(tag) + if (x == null) continue + if (x < midX) left.push(id) + else right.push(id) + } + return { left, right } +} + +/** armL / armR paths (gradient fills) tinted with skin. */ +function collectArmSkinPathIds(xml) { + const accessoryStart = xml.search(/]*inkscape:label="accessoryengine"/i) + const bodyXml = accessoryStart >= 0 ? xml.slice(0, accessoryStart) : xml + const ids = [] + const re = /]*inkscape:label="arm[LR]"[^>]*>/gi + let m + while ((m = re.exec(bodyXml)) !== null) { + const id = m[0].match(ID_RE)?.[1] + if (id) ids.push(id) + } + return [...new Set(ids)] +} + +function fillKeyForToggleOption(label) { + return humanize(label).toLowerCase().replace(/\s+/g, '_') +} + +function collectAccessoryFillables(xml, accessoryStart) { + if (accessoryStart < 0) return [] + const accessoryXml = xml.slice(accessoryStart) + const found = [] + const seen = new Set() + const tagRe = /<(g|path|ellipse|rect)\b[^>]*inkscape:label="([^"]*_TOGGLE_III)"[^>]*>/gi + let m + while ((m = tagRe.exec(accessoryXml)) !== null) { + const tag = m[0] + const tagName = m[1] + const label = m[2] + const id = tag.match(ID_RE)?.[1] + if (!id) continue + const key = fillKeyForToggleOption(label) + if (seen.has(key)) continue + const absoluteIndex = accessoryStart + m.index + const block = tagName.toLowerCase() === 'g' ? extractBalancedGroup(xml, absoluteIndex) : tag + if (!block) continue + const targetIds = collectSolidFillPathIds(block) + const tagFill = parseFill(tag.match(STYLE_RE)?.[1]) + if (!targetIds.length && tagFill) targetIds.push(id) + if (!targetIds.length) continue + const defaultFill = + targetIds + .map((targetId) => { + return parseFill(styleForElementId(block, targetId)) + }) + .find(Boolean) ?? tagFill ?? '#261b4f' + seen.add(key) + found.push({ + id, + key, + label: humanize(label), + defaultFill, + targetIds, + }) + } + return found +} + +/** All path/ellipse under togglehair (layer16) with a solid hex fill. */ +function collectHairFillIds(xml) { + const parentMatch = /]*id="layer16"[^>]*inkscape:label="[^"]*"[^>]*>/i.exec(xml) + if (!parentMatch) return [] + + const block = extractBalancedGroup(xml, parentMatch.index) + if (!block) return [] + + const ids = [] + const tagRe = /<(path|ellipse)\b[^>]*>/g + let m + while ((m = tagRe.exec(block)) !== null) { + const tag = m[0] + const id = tag.match(ID_RE)?.[1] + const style = tag.match(STYLE_RE)?.[1] ?? '' + const fill = parseFill(style) + if (!id || !fill || fill.toLowerCase() === '#ffffff') continue + ids.push(id) + } + return [...new Set(ids)] +} + +/** Canonical look from headshot.svg (toggles + solid fills). */ +function extractAppearanceFromSvg(xml) { + const fills = {} + const toggles = {} + + const tagRe = /<(path|g)\b[^>]*inkscape:label="([^"]+)"[^>]*>/g + let tm + while ((tm = tagRe.exec(xml)) !== null) { + const label = tm[2] + if (classifyLabel(label) !== 'iii') continue + if (label.toUpperCase().includes('TOGGLE')) continue + const tag = tm[0] + const id = tag.match(ID_RE)?.[1] + if (!id) continue + const color = parseFill(tag.match(STYLE_RE)?.[1]) + if (color) fills[id] = color + } + + const hairFillIds = collectHairFillIds(xml) + if (hairFillIds.length) { + const block = + extractBalancedGroup(xml, xml.search(/]*id="layer16"/i)) ?? '' + const hairColor = hairFillIds + .map((id) => { + const fill = parseFill(styleForElementId(block, id)) + return fill && fill.toLowerCase() !== '#ffffff' ? fill : null + }) + .find(Boolean) + if (hairColor) fills.hair_fill = hairColor + } + + const parentRe = /]*inkscape:label="([^"]*)"[^>]*>/gi + while ((tm = parentRe.exec(xml)) !== null) { + const parentLabel = tm[1] + if (!/toggle/i.test(parentLabel)) continue + if (!/inkscape:groupmode="layer"/.test(tm[0])) continue + + const parentId = tm[0].match(ID_RE)?.[1] ?? parentLabel + const block = extractBalancedGroup(xml, tm.index) + if (!block) continue + const inner = block.slice(block.indexOf('>') + 1, block.lastIndexOf('')) + + if (classifyLabel(parentLabel) === 'globalFill') { + const key = globalFillKey(parentLabel) + const targetIds = collectSolidFillPathIds(block) + const color = targetIds + .map((id) => { + return parseFill(styleForElementId(block, id)) + }) + .find(Boolean) + if (color) fills[key] = color + } + + const children = directToggleChildren(inner) + if (children.length < 2) continue + toggles[parentId] = children.find((c) => c.visible)?.id ?? children[0].id + } + + const blushMatch = xml.match( + /]*id="(g102)"[^>]*inkscape:label="blush_TOGGLE"[^>]*style="([^"]*)"/, + ) + if (blushMatch) { + toggles.blush = isVisibleOpening(blushMatch[0]) ? 'on' : 'off' + } + + return { fills, toggles } +} + +/** Open-eye layers (*_BLK) vs closed-eye overlay (label exactly BLK). */ +function collectBlinkLayers(xml) { + const openLayerIds = [] + let closedLayerId = null + const re = /]*inkscape:label="([^"]*)"[^>]*>/gi + let m + while ((m = re.exec(xml)) !== null) { + const label = m[1] + const id = m[0].match(ID_RE)?.[1] + if (!id) continue + if (label === 'BLK') closedLayerId = id + else if (label.endsWith('_BLK')) openLayerIds.push(id) + } + if (!openLayerIds.length || !closedLayerId) return null + return { + openLayerIds, + closedLayerId, + closedDurationMs: 120, + minIntervalMs: 2000, + maxIntervalMs: 4500, + } +} + +function applyHeadshotDefaults(structure, headshotDefaults) { + const baseFillKeys = new Set(['path45', 'path65', 'hair_fill']) + for (const f of structure.fillables) { + if (!baseFillKeys.has(f.key)) continue + const fromHeadshot = headshotDefaults.fills[f.key] + if (fromHeadshot) f.defaultFill = fromHeadshot + } + for (const g of structure.globalFillGroups) { + const fromHeadshot = headshotDefaults.fills[g.key] + if (fromHeadshot) g.defaultFill = fromHeadshot + } + for (const group of structure.toggleGroups) { + const fromHeadshot = headshotDefaults.toggles[group.key] + if (fromHeadshot) group.defaultOption = fromHeadshot + } + + const appearanceDefaults = { + fills: Object.fromEntries( + structure.fillables.map((f) => [f.key, f.defaultFill]).concat( + structure.globalFillGroups.map((g) => [g.key, g.defaultFill]), + ), + ), + toggles: Object.fromEntries( + structure.toggleGroups.map((g) => [g.key, g.defaultOption]), + ), + } + + return appearanceDefaults +} + +async function main() { + const xml = await readFile(svgPath, 'utf8') + const headshotXml = await readFile(headshotPath, 'utf8') + + const fillables = [] + const globalFillGroups = [] + const toggleGroups = [] + const globalFillParentIds = new Set() + const accessoryStart = xml.search(/]*inkscape:label="accessoryengine"/i) + + const viewBoxMatch = xml.match(/viewBox="([^"]+)"/) + const viewBox = viewBoxMatch?.[1] ?? '0 0 296 374' + + const arms = { + armL: { jointId: 'armL_joint', pivotX: 0, pivotY: 0, sleeveIds: [] }, + armR: { jointId: 'armR_joint', pivotX: 0, pivotY: 0, sleeveIds: [] }, + } + + const sleeveSides = collectSleeveIdsBySide(xml, 'layer19') + arms.armL.sleeveIds = sleeveSides.left + arms.armR.sleeveIds = sleeveSides.right + + const tagRe = /<(path|g)\b[^>]*inkscape:label="([^"]+)"[^>]*>/g + let tm + while ((tm = tagRe.exec(xml)) !== null) { + const label = tm[2] + if (classifyLabel(label) !== 'iii') continue + if (label.toUpperCase().includes('TOGGLE')) continue + const tag = tm[0] + const id = tag.match(ID_RE)?.[1] + if (!id) continue + const style = tag.match(STYLE_RE)?.[1] + fillables.push({ + id, + key: id, + label: humanize(label), + defaultFill: parseFill(style) ?? '#ddae67', + }) + } + + const armSkinIds = collectArmSkinPathIds(xml) + const skinFillable = fillables.find((f) => f.id === 'path45') + if (skinFillable && armSkinIds.length) { + skinFillable.targetIds = [skinFillable.id, ...armSkinIds] + } + + const hairFillIds = collectHairFillIds(xml) + if (hairFillIds.length) { + const block = extractBalancedGroup( + xml, + xml.search(/]*id="layer16"/i), + ) ?? '' + const defaultFill = + hairFillIds + .map((id) => { + const fill = parseFill(styleForElementId(block, id)) + return fill && fill.toLowerCase() !== '#ffffff' ? fill : null + }) + .find(Boolean) ?? '#311e00' + + fillables.push({ + id: 'hair_fill', + key: 'hair_fill', + label: 'Hair', + defaultFill, + targetIds: hairFillIds, + }) + } + + for (const fillable of collectAccessoryFillables(xml, accessoryStart)) { + if (!fillables.some((entry) => entry.key === fillable.key)) { + fillables.push(fillable) + } + } + + const parentRe = /]*inkscape:label="([^"]*)"[^>]*>/gi + while ((tm = parentRe.exec(xml)) !== null) { + const parentLabel = tm[1] + if (!/toggle/i.test(parentLabel)) continue + if (!/inkscape:groupmode="layer"/.test(tm[0])) continue + + const parentId = tm[0].match(ID_RE)?.[1] ?? parentLabel + const block = extractBalancedGroup(xml, tm.index) + if (!block) continue + const inner = block.slice(block.indexOf('>') + 1, block.lastIndexOf('')) + + if (classifyLabel(parentLabel) === 'globalFill') { + globalFillParentIds.add(parentId) + const targetIds = collectSolidFillPathIds(block) + const defaultFill = + targetIds + .map((id) => { + return parseFill(styleForElementId(block, id)) + }) + .find(Boolean) ?? '#261b4f' + + globalFillGroups.push({ + key: globalFillKey(parentLabel), + label: humanize(parentLabel.replace(/_III$/i, '')), + parentId, + defaultFill, + targetIds, + }) + } + + const children = directToggleChildren(inner) + const isAccessoryParent = accessoryStart >= 0 && tm.index > accessoryStart + for (const child of isAccessoryParent ? children : []) { + if (!/_III$/i.test(child.label)) continue + const childMatch = new RegExp( + `<(?:g|path|ellipse|rect)\\b[^>]*id="${child.id}"[^>]*`, + 'i', + ).exec(block) + if (!childMatch) continue + const childBlock = block.startsWith(' { + return parseFill(styleForElementId(childBlock, id)) + }) + .find(Boolean) ?? childFill ?? '#261b4f' + const key = fillKeyForToggleOption(child.label) + if (fillables.some((entry) => entry.key === key)) continue + fillables.push({ + id: child.id, + key, + label: humanize(child.label), + defaultFill, + targetIds, + }) + } + if (children.length < 2) continue + const defaultOption = children.find((c) => c.visible)?.id ?? children[0].id + toggleGroups.push({ + key: parentId, + label: humanize(parentLabel.replace(/_III$/i, '')), + parentId, + defaultOption, + options: children.map((c) => ({ + id: c.id, + label: humanize(c.label), + })), + }) + } + + const blushMatch = xml.match( + /]*id="(g102)"[^>]*inkscape:label="blush_TOGGLE"[^>]*style="([^"]*)"/, + ) + if (blushMatch) { + toggleGroups.push({ + key: 'blush', + label: 'Blush', + parentId: 'g102', + defaultOption: isVisibleOpening(blushMatch[0]) ? 'on' : 'off', + options: [ + { id: 'on', label: 'On' }, + { id: 'off', label: 'Off' }, + ], + elementId: blushMatch[1], + }) + } + + const headshotViewBoxMatch = headshotXml.match(/viewBox="([^"]+)"/) + const headshotViewBox = headshotViewBoxMatch?.[1] ?? '0 0 100 100' + + const headshotDefaults = extractAppearanceFromSvg(headshotXml) + const appearanceDefaults = applyHeadshotDefaults( + { fillables, globalFillGroups, toggleGroups }, + headshotDefaults, + ) + + const blink = collectBlinkLayers(xml) + + const manifest = { + viewBox, + arms, + fillables, + globalFillGroups, + toggleGroups, + appearanceDefaults, + ...(blink ? { blink } : {}), + headshotViewBox, + assets: { + character: 'char/charprod.svg', + headshot: 'char/headshot.svg', + }, + } + + await mkdir(path.dirname(outPath), { recursive: true }) + const ts = `// Generated by scripts/generate-char-manifest.mjs — do not edit by hand. +export const charManifest = ${JSON.stringify(manifest, null, 2)} as const + +export type CharManifest = typeof charManifest +export type CharacterPoseId = + | 'idle' + | 'present' + | 'think' + | 'write' + | 'shop' + | 'wave' +` + await writeFile(outPath, ts) + console.log( + `Wrote char manifest (${fillables.length} fills, ${globalFillGroups.length} global, ${toggleGroups.length} toggles) → ${path.relative(root, outPath)}`, + ) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/scripts/pose_lab.exs b/scripts/pose_lab.exs new file mode 100644 index 0000000..cd6ec45 --- /dev/null +++ b/scripts/pose_lab.exs @@ -0,0 +1,706 @@ +#!/usr/bin/env elixir + +defmodule MentellPoseLab do + @root Path.expand("..", __DIR__) + @poses_path Path.join(@root, "src/features/character/characterPoses.ts") + @manifest_path Path.join(@root, "src/features/character/charManifest.generated.ts") + @svg_path Path.join(@root, "asset/char/charprod.svg") + @default_out Path.join(@root, "tmp/character-pose-lab.html") + + def main(argv) do + case argv do + [] -> help() + ["help"] -> help() + ["list"] -> list_poses() + ["get", pose] -> get_pose(pose) + ["set", pose, arm_l, arm_r] -> set_pose(pose, arm_l, arm_r) + ["preview" | rest] -> preview(rest) + ["app" | rest] -> app(rest) + _ -> abort("Unknown command.\n\n#{usage()}") + end + end + + defp help do + IO.puts(usage()) + end + + defp usage do + """ + Mentell character pose lab + + Commands: + elixir scripts/pose_lab.exs list + elixir scripts/pose_lab.exs get wave + elixir scripts/pose_lab.exs set wave 104 -28 + elixir scripts/pose_lab.exs preview [--pose wave] [--out tmp/character-pose-lab.html] + elixir scripts/pose_lab.exs app [--pose wave] [--port 4057] + + preview writes a standalone HTML tool. + app starts a tiny local desktop-style web app that reloads current assets on refresh + and can save pose numbers back to characterPoses.ts. + """ + end + + defp list_poses do + poses() + |> Enum.each(fn {name, left, right} -> + IO.puts( + String.pad_trailing(name, 10) <> + " armL: #{format_number(left)}\tarmR: #{format_number(right)}" + ) + end) + end + + defp get_pose(pose) do + case Enum.find(poses(), fn {name, _, _} -> name == pose end) do + nil -> abort("Pose not found: #{pose}") + {name, left, right} -> + IO.puts("#{name}: { armL: #{format_number(left)}, armR: #{format_number(right)} }") + end + end + + defp set_pose(pose, arm_l, arm_r) do + left = parse_number!(arm_l, "armL") + right = parse_number!(arm_r, "armR") + write_pose!(pose, left, right) + IO.puts("Updated #{Path.relative_to_cwd(@poses_path)}") + IO.puts("#{pose}: { armL: #{format_number(left)}, armR: #{format_number(right)} }") + end + + defp write_pose!(pose, left, right) do + source = File.read!(@poses_path) + + pattern = + ~r/(#{Regex.escape(pose)}:\s*\{\s*armL:\s*)-?\d+(?:\.\d+)?(,\s*armR:\s*)-?\d+(?:\.\d+)?(\s*\})/ + + unless Regex.match?(pattern, source) do + raise ArgumentError, "Pose not found in #{@poses_path}: #{pose}" + end + + next = + Regex.replace(pattern, source, fn _, before_left, middle, after_right -> + before_left <> format_number(left) <> middle <> format_number(right) <> after_right + end) + + File.write!(@poses_path, next) + end + + defp preview(args) do + {opts, rest, invalid} = + OptionParser.parse(args, + strict: [out: :string, pose: :string], + aliases: [o: :out, p: :pose] + ) + + if rest != [] or invalid != [] do + abort("Invalid preview options.\n\n#{usage()}") + end + + out = opts[:out] || @default_out + pose = opts[:pose] || "wave" + File.mkdir_p!(Path.dirname(out)) + File.write!(out, html(pose, false)) + IO.puts("Wrote #{Path.relative_to_cwd(out)}") + IO.puts("Open it in your browser to preview poses and cosmetics.") + end + + defp app(args) do + {opts, rest, invalid} = + OptionParser.parse(args, + strict: [port: :integer, pose: :string], + aliases: [p: :pose] + ) + + if rest != [] or invalid != [] do + abort("Invalid app options.\n\n#{usage()}") + end + + port = opts[:port] || 4057 + pose = opts[:pose] || "wave" + {:ok, socket} = :gen_tcp.listen(port, [:binary, active: false, packet: :raw, reuseaddr: true]) + url = "http://127.0.0.1:#{port}/" + + IO.puts("Mentell Pose Lab app running at #{url}") + IO.puts("Refresh the page after editing SVG assets or regenerating the manifest.") + IO.puts("Press Ctrl+C to stop.") + + accept_loop(socket, pose) + end + + defp accept_loop(socket, pose) do + {:ok, client} = :gen_tcp.accept(socket) + spawn(fn -> handle_client(client, pose) end) + accept_loop(socket, pose) + end + + defp handle_client(client, pose) do + request = read_request(client, "") + response = route(request, pose) + :ok = :gen_tcp.send(client, response) + :gen_tcp.close(client) + rescue + error -> + body = "Pose lab error: #{Exception.message(error)}" + :gen_tcp.send(client, response(500, "text/plain; charset=utf-8", body)) + :gen_tcp.close(client) + end + + defp read_request(client, acc) do + {:ok, chunk} = :gen_tcp.recv(client, 0, 1000) + next = acc <> chunk + + if String.contains?(next, "\r\n\r\n") do + [head, body] = String.split(next, "\r\n\r\n", parts: 2) + content_length = content_length(head) + + if byte_size(body) >= content_length do + next + else + read_request(client, next) + end + else + read_request(client, next) + end + end + + defp content_length(head) do + head + |> String.split("\r\n") + |> Enum.find_value(0, fn line -> + case String.split(line, ":", parts: 2) do + [name, value] -> + if String.downcase(name) == "content-length" do + value |> String.trim() |> String.to_integer() + end + + _ -> + nil + end + end) + end + + defp route(request, pose) do + [head, body] = String.split(request, "\r\n\r\n", parts: 2) + [request_line | _headers] = String.split(head, "\r\n") + [method, path | _] = String.split(request_line, " ") + + case {method, path} do + {"GET", "/"} -> + response(200, "text/html; charset=utf-8", html(pose, true)) + + {"HEAD", "/"} -> + response(200, "text/html; charset=utf-8", "") + + {"GET", "/health"} -> + response(200, "text/plain; charset=utf-8", "ok") + + {"HEAD", "/health"} -> + response(200, "text/plain; charset=utf-8", "") + + {"POST", "/api/pose"} -> + save_pose_from_body(body) + + _ -> + response(404, "text/plain; charset=utf-8", "not found") + end + end + + defp save_pose_from_body(body) do + params = URI.decode_query(body) + pose = Map.fetch!(params, "pose") + left = parse_number!(Map.fetch!(params, "armL"), "armL") + right = parse_number!(Map.fetch!(params, "armR"), "armR") + write_pose!(pose, left, right) + + response( + 200, + "application/json; charset=utf-8", + ~s({"ok":true,"message":"Saved #{pose}: { armL: #{format_number(left)}, armR: #{format_number(right)} }"}) + ) + rescue + error -> + response(400, "application/json; charset=utf-8", ~s({"ok":false,"message":#{inspect(Exception.message(error))}})) + end + + defp response(status, content_type, body) do + reason = + case status do + 200 -> "OK" + 400 -> "Bad Request" + 404 -> "Not Found" + 500 -> "Internal Server Error" + end + + [ + "HTTP/1.1 #{status} #{reason}\r\n", + "content-type: #{content_type}\r\n", + "cache-control: no-store\r\n", + "content-length: #{byte_size(body)}\r\n", + "connection: close\r\n", + "\r\n", + body + ] + end + + defp poses do + source = File.read!(@poses_path) + regex = ~r/(\w+):\s*\{\s*armL:\s*(-?\d+(?:\.\d+)?),\s*armR:\s*(-?\d+(?:\.\d+)?)\s*\}/ + + Regex.scan(regex, source) + |> Enum.map(fn [_, name, left, right] -> + {name, parse_number!(left, "armL"), parse_number!(right, "armR")} + end) + end + + defp manifest_json do + source = File.read!(@manifest_path) + + case Regex.run(~r/export const charManifest = (\{.*?\}) as const/s, source) do + [_, json] -> json + _ -> abort("Could not parse char manifest from #{@manifest_path}") + end + end + + defp html(initial_pose, server_mode) do + svg = File.read!(@svg_path) + poses_json = poses_to_json(poses()) + + """ + + + + + + Mentell Pose Lab + + + +
+
+

Mentell Pose Lab

+

Tune arm rotations and cosmetics against the current production SVG.

+

Edit assets in asset/char, run sync if labels changed, then refresh this app.

+ + + + + + + +

Cosmetics

+
+
+ +

Output

+ +
+ + + +
+

+
+ +
+
#{svg}
+
+
+ + + + + """ + end + + defp poses_to_json(poses) do + entries = + poses + |> Enum.map(fn {name, left, right} -> + ~s("#{name}":{"armL":#{format_number(left)},"armR":#{format_number(right)}}) + end) + + "{" <> Enum.join(entries, ",") <> "}" + end + + defp parse_number!(value, label) do + case Float.parse(value) do + {number, ""} -> number + _ -> abort("Invalid #{label} number: #{value}") + end + end + + defp format_number(number) when is_float(number) do + if number == Float.round(number) do + Integer.to_string(round(number)) + else + :erlang.float_to_binary(number, decimals: 2) + |> String.trim_trailing("0") + |> String.trim_trailing(".") + end + end + + defp abort(message) do + IO.puts(:stderr, message) + System.halt(1) + end +end + +MentellPoseLab.main(System.argv()) diff --git a/scripts/sync-assets.mjs b/scripts/sync-assets.mjs index 8f6bbd9..71b35dc 100644 --- a/scripts/sync-assets.mjs +++ b/scripts/sync-assets.mjs @@ -1,10 +1,13 @@ import { copyFile, mkdir, readdir } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const srcDir = path.join(root, 'asset') const destDir = path.join(root, 'public', 'asset') +const charSrcDir = path.join(srcDir, 'char') +const charDestDir = path.join(destDir, 'char') const MENTELL_ICON_SRC = 'mentellicon-Default-1024x1024@1x.png' const MENTELL_ICON_DEST = 'mentell-icon.png' @@ -36,6 +39,28 @@ async function main() { } console.log(`Synced ${copied} PNG(s) from asset/ → public/asset/`) + + try { + const charFiles = await readdir(charSrcDir) + await mkdir(charDestDir, { recursive: true }) + let svgCopied = 0 + for (const file of charFiles.filter((f) => f.toLowerCase().endsWith('.svg'))) { + await copyFile(path.join(charSrcDir, file), path.join(charDestDir, file)) + svgCopied++ + } + if (svgCopied) console.log(`Synced ${svgCopied} SVG(s) from asset/char/ → public/asset/char/`) + } catch { + console.warn('Warning: asset/char/ not found; skipping character SVG sync') + } + + await new Promise((resolve, reject) => { + const child = spawn('node', ['scripts/generate-char-manifest.mjs'], { + cwd: root, + stdio: 'inherit', + }) + child.on('error', reject) + child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`manifest exit ${code}`)))) + }) } main() diff --git a/src/App.tsx b/src/App.tsx index 4629a86..cc6d624 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,8 +28,15 @@ import { ShareDashboardPage } from './features/share/ShareDashboardPage' import { SyncOnboardingBanner } from './features/settings/SyncOnboardingBanner' import { AppLegalFooter } from './components/AppLegalFooter' import { PrivacyPolicyPage } from './features/legal/PrivacyPolicyPage' +import { CharacterLabPage } from './features/character/CharacterLabPage' +import { CharacterNavIcon } from './features/character/CharacterNavIcon' +import { DeskCharacterLayout } from './features/character/DeskCharacterLayout' +import { LeftDeskMascot } from './features/character/LeftDeskMascot' +import { MobileHeaderMascot } from './features/character/MobileHeaderMascot' +import { CharacterTabIconSync } from './features/character/CharacterTabIconSync' import { isFirebaseSyncEnabled, isShareLinksEnabled } from './shared/features/featureFlags' import { useAuthOptional } from './shared/firebase/AuthProvider' +import { ShopCosmeticEffects } from './features/shop/shopCosmetics' type ScoreChangeOptions = { deferOverlay?: boolean } @@ -106,7 +113,10 @@ function App() { return (
+ + +
@@ -126,6 +136,7 @@ function App() { element={} /> } /> + } /> } /> {isShareLinksEnabled() ? ( } /> @@ -198,8 +209,13 @@ function TopBar({ {!settings.disablePoints ? (
+
- ) : null} + ) : ( +
+ +
+ )}
@@ -222,6 +238,7 @@ function TopBar({ + @@ -292,6 +309,13 @@ function TopBar({ onNavigate={() => setMobileMenuOpen(false)} className="w-full" /> + setMobileMenuOpen(false)} + className="w-full" + />
Appearance = { Notepad: '/asset/notepad.png', Shoppe: '/asset/shoppe.png', Settings: '/asset/setting.png', + } function navIconFor(label: string) { @@ -371,6 +396,7 @@ function DeskLink({ className?: string }) { const icon = navIconFor(label) + const isCharacter = label === 'Character' return (
- {icon ? ( + {isCharacter ? ( + + ) : icon ? ( ) : null}
@@ -434,6 +462,7 @@ function HomePlaceholder({ title="Draft today’s letter" subtitle="Draft it like stationery — then review and submit." > +
+ ) } function WeekPlaceholder() { - return + return ( + + + + ) } function NotesPlaceholder() { return ( -
- - -
+ +
+ + +
+
) } @@ -497,11 +533,13 @@ function ShopPlaceholder({ onScoreChange: (delta: number, hint: string | null) => void }) { return ( - { - onScoreChange(delta, hint) - }} - /> + + { + onScoreChange(delta, hint) + }} + /> + ) } diff --git a/src/db/schema.ts b/src/db/schema.ts index 36890ac..104659b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -56,11 +56,20 @@ export type PackageRow = { openedScoreDelta?: number } +/** Singleton row (`id` is always `default`) for desk character customization. */ +export type CharacterAppearanceRow = { + id: 'default' + updatedAt: number + fills: Record + toggles: Record +} + export class MentellDB extends Dexie { entries!: Table notes!: Table stickies!: Table packages!: Table + characterAppearance!: Table constructor(name: string) { super(name) @@ -123,6 +132,14 @@ export class MentellDB extends Dexie { if (!row.coordSpace) row.coordSpace = 'board' }) }) + + this.version(5).stores({ + entries: '&id, dateKey, createdAt, updatedAt, sentiment, warningLevel', + notes: '&id, createdAt, updatedAt, tag', + stickies: '&id, createdAt, updatedAt, zIndex', + packages: '&id, kind, periodKey, createdAt, updatedAt, openedAt', + characterAppearance: '&id, updatedAt', + }) } } diff --git a/src/features/character/CharacterCorner.tsx b/src/features/character/CharacterCorner.tsx new file mode 100644 index 0000000..b77fb62 --- /dev/null +++ b/src/features/character/CharacterCorner.tsx @@ -0,0 +1,21 @@ +import { useLocation } from 'react-router-dom' +import { MentellCharacter } from './MentellCharacter' +import { poseForPathname } from './characterPoses' +import { useCharacterAppearance } from './useCharacterAppearance' + +/** Small decorative character for desk screens. */ +export function CharacterCorner({ className }: { className?: string }) { + const { pathname } = useLocation() + const pose = poseForPathname(pathname) + const { appearance, ready } = useCharacterAppearance() + + if (!ready) return null + + return ( + + ) +} diff --git a/src/features/character/CharacterLabPage.tsx b/src/features/character/CharacterLabPage.tsx new file mode 100644 index 0000000..07a41de --- /dev/null +++ b/src/features/character/CharacterLabPage.tsx @@ -0,0 +1,284 @@ +import { useEffect, useMemo, useState } from 'react' +import { MentellCharacter } from './MentellCharacter' +import { charManifest } from './charManifest' +import { POSE_LABELS } from './characterPoses' +import type { CharacterPoseId } from './charManifest' +import { + LAB_COLOR_ORDER, + LAB_POSE_ORDER, + LAB_TOGGLE_CONFIG, +} from './charLabControls' +import { OptionDial } from './lab/OptionDial' +import { LabSwitch } from './lab/LabSwitch' +import { useCharacterAppearance } from './useCharacterAppearance' +import { + equipCharacterAccessoryItem, + loadShopInventory, + setCharacterAccessoryChoice, + subscribeShopInventory, + type ShopInventory, +} from '../shop/shopInventory' +import { + loadShopCatalog, + type CharacterAccessoryItem, + type ShopCatalogItem, +} from '../shop/shopCatalog' +import { accessoryChoiceId, exclusiveAccessoryIdsFor } from '../shop/shopCharacterAccessories' + +function isCharacterAccessory(item: ShopCatalogItem): item is CharacterAccessoryItem { + return item.type === 'characterAccessory' +} + +export function CharacterLabPage() { + const { appearance, setAppearance, resetAppearance, ready } = useCharacterAppearance() + const [pose, setPose] = useState('wave') + const catalog = useMemo(() => loadShopCatalog(), []) + const [inventory, setInventory] = useState(() => loadShopInventory()) + + useEffect(() => subscribeShopInventory((next) => setInventory(next)), []) + + const ownedAccessories = useMemo(() => { + const owned = new Set(inventory.ownedItemIds) + return catalog.items.filter(isCharacterAccessory).filter((item) => owned.has(item.id)) + }, [catalog.items, inventory.ownedItemIds]) + + const equippedAccessoryIds = useMemo( + () => new Set(inventory.equipped.characterAccessoryIds), + [inventory.equipped.characterAccessoryIds], + ) + + const toggleByKey = useMemo(() => { + const map = new Map( + charManifest.toggleGroups.map((g) => [g.key, g]), + ) + return map + }, []) + function setFill(key: string, value: string) { + setAppearance((prev) => ({ + ...prev, + fills: { ...prev.fills, [key]: value }, + })) + } + + function setToggle(groupKey: string, optionId: string) { + setAppearance((prev) => ({ + ...prev, + toggles: { ...prev.toggles, [groupKey]: optionId }, + })) + } + + function colorValue(key: string, fallback: string) { + return appearance.fills[key] ?? fallback + } + + function defaultForColorKey(key: string) { + const fillable = charManifest.fillables.find((f) => f.key === key) + if (fillable) return fillable.defaultFill + const global = charManifest.globalFillGroups.find((g) => g.key === key) + return global?.defaultFill ?? '#000000' + } + + function toggleAccessory(item: CharacterAccessoryItem) { + equipCharacterAccessoryItem(item.id, { + exclusiveWith: exclusiveAccessoryIdsFor(item, catalog.items), + }) + } + + return ( +
+
Character lab
+
+ Preview looks and poses. Changes save locally, sync to cloud backup (when enabled), and + update both the desk mascot and browser tab icon. +
+ +
+
+
+ {ready ? ( + + ) : ( +
Loading saved look…
+ )} +
+
+ +
+
+ Look +
+ {LAB_COLOR_ORDER.map(({ key, label }) => ( + + ))} +
+
+ {LAB_TOGGLE_CONFIG.filter((c) => c.kind === 'dial').map((cfg) => { + const group = toggleByKey.get(cfg.groupKey) + if (!group) return null + const activeId = appearance.toggles[cfg.groupKey] ?? group.defaultOption + const valueIndex = Math.max( + 0, + group.options.findIndex((o) => o.id === activeId), + ) + return ( + setToggle(cfg.groupKey, id)} + /> + ) + })} +
+
+ +
+ Shoppe accessories + {ownedAccessories.length === 0 ? ( +
+ Unlock character accessories in the Shoppe to equip and customize them here. +
+ ) : ( +
+ {ownedAccessories.map((item) => { + const equipped = equippedAccessoryIds.has(item.id) + const colorKeys = item.characterAccessory.fillKeys ?? [] + const choices = item.characterAccessory.choices ?? [] + const selectedChoiceId = accessoryChoiceId(item, inventory) + return ( +
+
+
+
{item.name}
+
+ {equipped ? 'Equipped' : 'Owned'} +
+
+ +
+ + {choices.length > 0 ? ( +
+ {choices.map((choice) => ( + + ))} +
+ ) : null} + + {colorKeys.length > 0 ? ( +
+ {colorKeys.map((key) => ( + + ))} +
+ ) : null} +
+ ) + })} +
+ )} +
+ +
+ Face + {LAB_TOGGLE_CONFIG.filter((c) => c.kind === 'switch').map((cfg) => { + const group = toggleByKey.get(cfg.groupKey) + if (!group || !('elementId' in group)) return null + const on = (appearance.toggles[cfg.groupKey] ?? group.defaultOption) === 'on' + return ( + setToggle(cfg.groupKey, checked ? 'on' : 'off')} + /> + ) + })} +
+ +
+ Pose +
+ {LAB_POSE_ORDER.map((id) => ( + + ))} +
+

Pose preview applies to the full character only.

+
+ + +
+
+
+ ) +} diff --git a/src/features/character/CharacterNavIcon.tsx b/src/features/character/CharacterNavIcon.tsx new file mode 100644 index 0000000..d1a7dcd --- /dev/null +++ b/src/features/character/CharacterNavIcon.tsx @@ -0,0 +1,49 @@ +import { publicUrl } from '../../shared/publicUrl' +import { useMemo } from 'react' +import headshotSvg from '../../../asset/char/headshot.svg?raw' +import { applyCharacterAppearance } from './applyCharacterAppearance' +import type { CharacterAppearance } from './characterAppearance' +import { fixHeadshotPaintOrder } from './characterPaintOrder' +import { useCharacterAppearance } from './useCharacterAppearance' + +const NAV_BADGE_VIEWBOX = '0 0 100 100' + +function buildBadgeSrc(appearance: CharacterAppearance) { + const parsed = new DOMParser().parseFromString(headshotSvg, 'image/svg+xml') + const svg = parsed.querySelector('svg') + if (!(svg instanceof SVGSVGElement)) return null + svg.setAttribute('width', '96') + svg.setAttribute('height', '96') + svg.setAttribute('viewBox', NAV_BADGE_VIEWBOX) + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet') + fixHeadshotPaintOrder(svg) + if (appearance) applyCharacterAppearance(svg, appearance) + const serialized = new XMLSerializer().serializeToString(svg) + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serialized)}` +} + +/** Desk nav icon — live headshot using saved character appearance. */ +export function CharacterNavIcon({ className }: { className?: string }) { + const { appearance, ready } = useCharacterAppearance() + const badgeSrc = useMemo(() => buildBadgeSrc(appearance), [appearance]) + + if (!ready) { + return ( + + ) + } + + return ( + + ) +} diff --git a/src/features/character/CharacterTabIconSync.tsx b/src/features/character/CharacterTabIconSync.tsx new file mode 100644 index 0000000..ae4d39f --- /dev/null +++ b/src/features/character/CharacterTabIconSync.tsx @@ -0,0 +1,50 @@ +import { useEffect } from 'react' +import headshotSvg from '../../../asset/char/headshot.svg?raw' +import { applyCharacterAppearance } from './applyCharacterAppearance' +import { defaultCharacterAppearance } from './characterAppearance' +import { fixHeadshotPaintOrder } from './characterPaintOrder' +import { useCharacterAppearance } from './useCharacterAppearance' + +const FAVICON_SIZE = '96' +// Crops the generated headshot so hair toggles do not shift icon framing. +const FAVICON_HEAD_VIEWBOX = '16 4 68 74' + +function buildCharacterFaviconDataUrl(appearance: ReturnType) { + const parsed = new DOMParser().parseFromString(headshotSvg, 'image/svg+xml') + const svg = parsed.querySelector('svg') + if (!(svg instanceof SVGSVGElement)) return null + svg.setAttribute('width', FAVICON_SIZE) + svg.setAttribute('height', FAVICON_SIZE) + svg.setAttribute('viewBox', FAVICON_HEAD_VIEWBOX) + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet') + fixHeadshotPaintOrder(svg) + applyCharacterAppearance(svg, appearance) + const serialized = new XMLSerializer().serializeToString(svg) + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serialized)}` +} + +function ensureFaviconLink(): HTMLLinkElement { + const existing = document.querySelector( + 'link[rel="icon"], link[rel="shortcut icon"]', + ) + if (existing) return existing + const created = document.createElement('link') + created.rel = 'icon' + document.head.appendChild(created) + return created +} + +export function CharacterTabIconSync() { + const { appearance, ready } = useCharacterAppearance() + + useEffect(() => { + if (!ready) return + const favicon = ensureFaviconLink() + const dataUrl = buildCharacterFaviconDataUrl(appearance) + if (!dataUrl) return + favicon.type = 'image/svg+xml' + favicon.href = dataUrl + }, [appearance, ready]) + + return null +} diff --git a/src/features/character/DeskCharacterLayout.tsx b/src/features/character/DeskCharacterLayout.tsx new file mode 100644 index 0000000..be3ba90 --- /dev/null +++ b/src/features/character/DeskCharacterLayout.tsx @@ -0,0 +1,6 @@ +import type { ReactNode } from 'react' + +/** Desk page content only — mascot lives in the header overlay, not in document flow. */ +export function DeskCharacterLayout({ children }: { children: ReactNode }) { + return
{children}
+} diff --git a/src/features/character/LeftDeskMascot.tsx b/src/features/character/LeftDeskMascot.tsx new file mode 100644 index 0000000..0834e50 --- /dev/null +++ b/src/features/character/LeftDeskMascot.tsx @@ -0,0 +1,22 @@ +import { CharacterCorner } from './CharacterCorner' + +/** + * Desktop mascot in the viewport gutter left of the centered max-w-4xl column. + * Does not affect document flow or header layout. + */ +export function LeftDeskMascot() { + return ( +
+ +
+ ) +} diff --git a/src/features/character/MentellCharacter.tsx b/src/features/character/MentellCharacter.tsx new file mode 100644 index 0000000..15f5f55 --- /dev/null +++ b/src/features/character/MentellCharacter.tsx @@ -0,0 +1,153 @@ +import { useLayoutEffect, useMemo, useRef, useState } from 'react' +import charSvg from '../../../asset/char/charprod.svg?raw' +import headshotSvg from '../../../asset/char/headshot.svg?raw' +import { applyCharacterAppearance } from './applyCharacterAppearance' +import { + fixCharacterPaintOrder, + fixHeadshotPaintOrder, +} from './characterPaintOrder' +import { charManifest } from './charManifest' +import { + defaultCharacterAppearance, + type CharacterAppearance, +} from './characterAppearance' +import type { CharacterPoseId } from './charManifest' +import { CHARACTER_POSES } from './characterPoses' +import { useCharacterBlink } from './characterBlink' +import { useArmPoseAnimation } from './useArmPoseAnimation' +import { usePetAccessoryAnimation } from './usePetAccessoryAnimation' +import { useCharacterAppearance } from './useCharacterAppearance' +import { useEquippedCharacterAccessories } from '../shop/shopCharacterAccessories' +import type { CharacterAccessoryItem } from '../shop/shopCatalog' + +const DEFAULT_APPEARANCE = defaultCharacterAppearance() + +const SVG_SOURCE = { + character: charSvg, + headshot: headshotSvg, +} as const + +let nextPaintServerInstanceId = 0 + +function namespaceSvgPaintServers(svg: SVGSVGElement) { + nextPaintServerInstanceId += 1 + const suffix = `mentell-svg-${nextPaintServerInstanceId}` + const idMap = new Map() + svg.querySelectorAll('defs [id]').forEach((el) => { + const id = el.id + if (!id) return + const nextId = `${id}-${suffix}` + idMap.set(id, nextId) + el.id = nextId + }) + if (!idMap.size) return + + const replaceRefs = (value: string) => + value + .replace(/url\(#([^)]+)\)/g, (match, id: string) => { + const nextId = idMap.get(id) + return nextId ? `url(#${nextId})` : match + }) + .replace(/^#(.+)$/, (match, id: string) => { + const nextId = idMap.get(id) + return nextId ? `#${nextId}` : match + }) + + svg.querySelectorAll('*').forEach((el) => { + for (const attr of ['style', 'fill', 'stroke', 'filter', 'href', 'xlink:href']) { + const value = el.getAttribute(attr) + if (!value) continue + const nextValue = replaceRefs(value) + if (nextValue !== value) el.setAttribute(attr, nextValue) + } + }) +} + +export type CharacterAsset = keyof typeof SVG_SOURCE + +export type MentellCharacterProps = { + pose: CharacterPoseId + asset?: CharacterAsset + appearance?: CharacterAppearance + className?: string + title?: string + characterAccessories?: CharacterAccessoryItem[] +} + +export function MentellCharacter({ + pose, + asset = 'character', + appearance: appearanceProp, + className, + title, + characterAccessories, +}: MentellCharacterProps) { + const svgRef = useRef(null) + const hostRef = useRef(null) + const [svgGeneration, setSvgGeneration] = useState(0) + const { appearance: storedAppearance } = useCharacterAppearance() + const equippedAccessories = useEquippedCharacterAccessories(characterAccessories === undefined) + const accessories = characterAccessories ?? equippedAccessories + const appearance = appearanceProp ?? storedAppearance ?? DEFAULT_APPEARANCE + const appearanceKey = JSON.stringify(appearance) + const accessoryKey = JSON.stringify(accessories.map((item) => item.id)) + const armPose = CHARACTER_POSES[pose] + const isBody = asset === 'character' + const anchoredIds = useMemo( + () => ({ + armL: accessories.flatMap((item) => item.characterAccessory.anchoredIds?.armL ?? []), + armR: accessories.flatMap((item) => item.characterAccessory.anchoredIds?.armR ?? []), + }), + [accessories], + ) + + useLayoutEffect(() => { + const host = hostRef.current + if (!host) return + host.innerHTML = SVG_SOURCE[asset] + const svg = host.querySelector('svg') + if (!svg) return + svgRef.current = svg + svg.setAttribute('width', '100%') + svg.setAttribute('height', '100%') + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet') + svg.style.display = 'block' + svg.style.overflow = asset === 'headshot' ? 'hidden' : 'visible' + if (asset === 'character') { + fixCharacterPaintOrder(svg) + } else { + fixHeadshotPaintOrder(svg) + } + namespaceSvgPaintServers(svg) + applyCharacterAppearance(svg, appearance, accessories) + setSvgGeneration((g) => g + 1) + }, [appearance, appearanceKey, accessories, accessoryKey, asset]) + + useArmPoseAnimation( + svgRef, + isBody ? armPose : { armL: 0, armR: 0 }, + svgGeneration, + anchoredIds, + ) + useCharacterBlink(svgRef, isBody ? svgGeneration : -1) + usePetAccessoryAnimation(svgRef, isBody ? svgGeneration : -1) + + const viewBox = + asset === 'headshot' ? charManifest.headshotViewBox : charManifest.viewBox + const [, , vbW, vbH] = viewBox.split(/\s+/).map(Number) + + return ( +
+
+
+ ) +} diff --git a/src/features/character/MobileHeaderMascot.tsx b/src/features/character/MobileHeaderMascot.tsx new file mode 100644 index 0000000..a4cc13a --- /dev/null +++ b/src/features/character/MobileHeaderMascot.tsx @@ -0,0 +1,20 @@ +import { useLocation } from 'react-router-dom' +import { MentellCharacter } from './MentellCharacter' +import { poseForPathname } from './characterPoses' +import { useCharacterAppearance } from './useCharacterAppearance' + +/** Small route-aware mascot beside score on mobile (hidden md+ where side column shows). */ +export function MobileHeaderMascot() { + const { pathname } = useLocation() + const { appearance, ready } = useCharacterAppearance() + if (!ready) return null + + return ( + + ) +} diff --git a/src/features/character/applyCharacterAppearance.ts b/src/features/character/applyCharacterAppearance.ts new file mode 100644 index 0000000..807e780 --- /dev/null +++ b/src/features/character/applyCharacterAppearance.ts @@ -0,0 +1,353 @@ +import { applyBlinkOpenState } from './characterBlink' +import { charManifest } from './charManifest' +import type { CharacterAppearance } from './characterAppearance' +import type { CharacterAccessoryItem } from '../shop/shopCatalog' + +const SVG_NS = 'http://www.w3.org/2000/svg' +const SVG_INSTANCE_ATTR = 'data-mentell-appearance-instance' + +let nextSvgInstanceId = 0 + +const EYE_TOGGLE_GRADIENT_FILL: Record = { + g104: '#c8c9cd', + g105: '#986334', + g107: '#4599ba', +} + +function setToggleOptionVisible(el: SVGElement, show: boolean) { + el.style.display = show ? 'inline' : 'none' +} + +function setElementVisible(el: SVGElement, show: boolean) { + el.style.display = show ? 'inline' : 'none' +} + +function bringToFront(el: SVGElement) { + const svg = el.ownerSVGElement + if (!svg || el.parentNode === svg) { + el.parentNode?.appendChild(el) + return + } + svg.appendChild(el) +} + +function normalizeLookup(value: string) { + return value + .toLowerCase() + .replace(/_(toggle|iii|dni).*$/i, '') + .replace(/^toggle/, '') + .replace(/[^a-z0-9]+/g, '') +} + +function toggleGroupMatches( + group: (typeof charManifest.toggleGroups)[number], + groupKey: string, +) { + const normalized = normalizeLookup(groupKey) + return ( + group.key === groupKey || + group.parentId === groupKey || + normalizeLookup(group.label) === normalized || + normalizeLookup(group.key) === normalized + ) +} + +function optionMatches(option: { id: string; label: string }, optionId: string) { + const normalized = normalizeLookup(optionId) + return option.id === optionId || normalizeLookup(option.label) === normalized +} + +function resolveAccessoryToggle(toggle: { groupKey: string; optionId: string }) { + const group = charManifest.toggleGroups.find((entry) => + toggleGroupMatches(entry, toggle.groupKey), + ) + const option = group?.options.find((entry) => optionMatches(entry, toggle.optionId)) + if (!group || !option) return null + return { groupKey: group.key, optionId: option.id } +} + +function isAccessoryToggleGroup(group: (typeof charManifest.toggleGroups)[number]) { + return ['layer2', 'layer3', 'layer4', 'layer5', 'layer6', 'layer7', 'layer8'].includes( + group.parentId, + ) +} + +function isBaseFillKey(key: string) { + return key === 'path45' || key === 'path65' || key === 'hair_fill' +} + +function accessoryToggleRows(accessory: CharacterAccessoryItem) { + const rows = accessory.characterAccessory.toggles ?? [] + const legacy = accessory.characterAccessory.toggle + ? [ + { + groupKey: accessory.characterAccessory.toggle.groupKey, + optionIds: [accessory.characterAccessory.toggle.optionId], + }, + ] + : [] + return [...rows, ...legacy] +} + +function setPartVisibleByKey(svg: SVGSVGElement, key: string, show: boolean) { + const byId = svg.getElementById(key) + if (byId instanceof SVGElement) { + setElementVisible(byId, show) + return + } + const normalized = normalizeLookup(key) + svg.querySelectorAll('*').forEach((el) => { + const label = el.getAttribute('inkscape:label') ?? '' + if (normalizeLookup(label) === normalized) setElementVisible(el, show) + }) +} + +function setPetFaceVisible(svg: SVGSVGElement, show: boolean) { + for (const key of ['facebase_DNI', 'FACETOGGLE']) { + const normalized = normalizeLookup(key) + svg.querySelectorAll('*').forEach((el) => { + const label = el.getAttribute('inkscape:label') ?? '' + if (normalizeLookup(label) !== normalized) return + setElementVisible(el, show) + if (show) bringToFront(el) + }) + } +} + +function hidePartByKey(svg: SVGSVGElement, key: string) { + setPartVisibleByKey(svg, key, false) +} + +function parseHexColor(color: string): [number, number, number] | null { + const hex = color.trim().replace(/^#/, '') + if (hex.length === 3) { + return hex.split('').map((part) => parseInt(part + part, 16)) as [number, number, number] + } + if (hex.length === 6 || hex.length === 8) { + return [0, 2, 4].map((start) => parseInt(hex.slice(start, start + 2), 16)) as [ + number, + number, + number, + ] + } + return null +} + +function darkenColor(color: string, amount = 0.42) { + const rgb = parseHexColor(color) + if (!rgb) return color + const [r, g, b] = rgb.map((channel) => Math.max(0, Math.round(channel * (1 - amount)))) + return `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, '0')).join('')}` +} + +function hasVisibleStroke(el: SVGElement) { + const stroke = el.style.stroke || el.getAttribute('stroke') || '' + if (!stroke || stroke === 'none') return false + return true +} + +function applyHairFill(el: SVGElement, color: string) { + el.style.fill = color + if (hasVisibleStroke(el)) { + el.style.stroke = darkenColor(color) + el.style.strokeOpacity = '1' + } +} + +function ensureDefs(svg: SVGSVGElement) { + const existing = svg.querySelector('defs') + if (existing instanceof SVGDefsElement) return existing + const defs = document.createElementNS(SVG_NS, 'defs') + svg.insertBefore(defs, svg.firstChild) + return defs +} + +function setStop(stop: SVGStopElement, color: string, opacity: string, offset: string) { + stop.setAttribute('offset', offset) + stop.style.stopColor = color + stop.style.stopOpacity = opacity +} + +function ensureEyeGradient(svg: SVGSVGElement, id: string, color: string) { + const existing = svg.getElementById(id) + if (existing instanceof SVGLinearGradientElement) return existing + + const gradient = document.createElementNS(SVG_NS, 'linearGradient') + gradient.id = id + gradient.setAttribute('x1', '0') + gradient.setAttribute('y1', '0') + gradient.setAttribute('x2', '0') + gradient.setAttribute('y2', '1') + + const shadow = document.createElementNS(SVG_NS, 'stop') + setStop(shadow, '#000000', '1', '0') + gradient.appendChild(shadow) + + const iris = document.createElementNS(SVG_NS, 'stop') + setStop(iris, color, '1', '0.72') + gradient.appendChild(iris) + + ensureDefs(svg).appendChild(gradient) + return gradient +} + +function svgInstanceId(svg: SVGSVGElement) { + const existing = svg.getAttribute(SVG_INSTANCE_ATTR) + if (existing) return existing + nextSvgInstanceId += 1 + const id = String(nextSvgInstanceId) + svg.setAttribute(SVG_INSTANCE_ATTR, id) + return id +} + +function applyEyeGradient(svg: SVGSVGElement, activeToggle: SVGGElement, active: string) { + const color = EYE_TOGGLE_GRADIENT_FILL[active] + if (!color) return + + const instanceId = svgInstanceId(svg) + activeToggle.querySelectorAll('path').forEach((path, index) => { + const gradientId = `mentell-eye-${instanceId}-${active}-${index}` + ensureEyeGradient(svg, gradientId, color) + path.style.fill = `url(#${gradientId})` + path.style.opacity = '1' + }) +} + +export function applyCharacterAppearance( + svg: SVGSVGElement, + appearance: CharacterAppearance, + accessories: CharacterAccessoryItem[] = [], +) { + const activeAccessoryFillKeys = new Set( + accessories.flatMap((accessory) => accessory.characterAccessory.fillKeys ?? []), + ) + + for (const fillable of charManifest.fillables) { + if (!isBaseFillKey(fillable.key) && !activeAccessoryFillKeys.has(fillable.key)) { + continue + } + const color = appearance.fills[fillable.key] ?? fillable.defaultFill + const targetIds = + 'targetIds' in fillable && fillable.targetIds + ? [...fillable.targetIds] + : [fillable.id] + for (const id of targetIds) { + const el = svg.getElementById(id) + if (!(el instanceof SVGElement)) continue + if (fillable.key === 'hair_fill') { + applyHairFill(el, color) + continue + } + el.style.fill = color + } + } + + for (const group of charManifest.globalFillGroups) { + const color = appearance.fills[group.key] ?? group.defaultFill + for (const id of group.targetIds) { + const el = svg.getElementById(id) + if (!(el instanceof SVGElement)) continue + el.style.fill = color + } + } + + const accessoryToggles = new Map>() + const accessoryPartKeys = new Set() + for (const accessory of accessories) { + for (const row of accessoryToggleRows(accessory)) { + for (const optionId of row.optionIds) { + const resolved = resolveAccessoryToggle({ groupKey: row.groupKey, optionId }) + if (!resolved) continue + const active = accessoryToggles.get(resolved.groupKey) ?? new Set() + active.add(resolved.optionId) + accessoryToggles.set(resolved.groupKey, active) + } + } + for (const part of accessory.characterAccessory.parts ?? []) { + accessoryPartKeys.add(part) + } + } + let activeSkinHidingAccessory = false + + for (const group of charManifest.toggleGroups) { + const activeAccessoryOptions = accessoryToggles.get(group.key) + const active = activeAccessoryOptions + ? [...activeAccessoryOptions][0] + : isAccessoryToggleGroup(group) + ? null + : appearance.toggles[group.key] ?? group.defaultOption + + if (group.key === 'blush' && 'elementId' in group) { + const blush = svg.getElementById(group.elementId) + if (blush instanceof SVGElement) { + blush.style.display = active === 'on' ? 'inline' : 'none' + blush.style.opacity = active === 'on' ? '1' : '0' + blush.style.visibility = active === 'on' ? 'visible' : 'hidden' + } + continue + } + + for (const opt of group.options) { + const el = svg.getElementById(opt.id) + if (!(el instanceof SVGElement)) continue + const show = activeAccessoryOptions ? activeAccessoryOptions.has(opt.id) : opt.id === active + setToggleOptionVisible( + el, + show, + ) + if (show && activeAccessoryOptions && group.key !== 'layer2') bringToFront(el) + } + + if (group.key === 'layer18') { + if (!active) continue + const activeToggle = svg.getElementById(active) + if (activeToggle instanceof SVGGElement) { + activeToggle.style.opacity = '1' + applyEyeGradient(svg, activeToggle, active) + } + } + } + + setPetFaceVisible(svg, accessoryToggles.has('layer2')) + + const knownAccessoryParts = [ + 'GHOST', + 'GHOST_armL_joint', + 'GHOST_armR_joint', + 'SKELETON', + 'SKELETON_armL_joint', + 'SKELETON_armR_joint', + 'Lemmon', + ] + for (const part of knownAccessoryParts) { + if (!accessoryPartKeys.has(part)) hidePartByKey(svg, part) + } + + for (const accessory of accessories) { + for (const part of accessory.characterAccessory.parts ?? []) { + setPartVisibleByKey(svg, part, true) + const el = svg.getElementById(part) + if (el instanceof SVGElement) bringToFront(el) + } + if (accessory.characterAccessory.hideSkin) { + activeSkinHidingAccessory = true + } + if (accessory.characterAccessory.hideBaseClothes) { + for (const part of ['layer15', 'layer19', 'path65', 'shoesDNI']) { + setPartVisibleByKey(svg, part, false) + } + } + } + + if (activeSkinHidingAccessory) { + const skin = charManifest.fillables.find((fillable) => fillable.key === 'path45') + const targetIds = + skin && 'targetIds' in skin && skin.targetIds ? [...skin.targetIds] : skin ? [skin.id] : [] + for (const id of targetIds) { + const el = svg.getElementById(id) + if (el instanceof SVGElement) setElementVisible(el, false) + } + } + + applyBlinkOpenState(svg) +} diff --git a/src/features/character/charLabControls.ts b/src/features/character/charLabControls.ts new file mode 100644 index 0000000..f4f51bd --- /dev/null +++ b/src/features/character/charLabControls.ts @@ -0,0 +1,36 @@ +import type { CharacterPoseId } from './charManifest' + +export type LabControlKind = 'dial' | 'switch' | 'color' | 'pose' + +export type LabToggleConfig = { + groupKey: string + label: string + kind: 'dial' | 'switch' + dialLabels?: string[] +} + +export const LAB_TOGGLE_CONFIG: LabToggleConfig[] = [ + { groupKey: 'layer16', label: 'Hair', kind: 'dial', dialLabels: ['1', '2', '3'] }, + { groupKey: 'layer15', label: 'Shirt', kind: 'dial', dialLabels: ['1', '2', '3'] }, + { groupKey: 'layer19', label: 'Sleeves', kind: 'dial', dialLabels: ['1', '2'] }, + { groupKey: 'layer18', label: 'Eyes', kind: 'dial', dialLabels: ['1', '2', '3'] }, + { groupKey: 'layer17', label: 'Expression', kind: 'dial', dialLabels: ['1', '2', '3'] }, + { groupKey: 'blush', label: 'Blush', kind: 'switch' }, +] + +export const LAB_COLOR_ORDER: { key: string; label: string }[] = [ + { key: 'path45', label: 'Skin' }, + { key: 'path65', label: 'Pants' }, + { key: 'hair_fill', label: 'Hair' }, + { key: 'shirt', label: 'Shirt' }, + { key: 'sleeves', label: 'Sleeves' }, +] + +export const LAB_POSE_ORDER: CharacterPoseId[] = [ + 'idle', + 'present', + 'think', + 'write', + 'shop', + 'wave', +] diff --git a/src/features/character/charManifest.generated.ts b/src/features/character/charManifest.generated.ts new file mode 100644 index 0000000..feb4d34 --- /dev/null +++ b/src/features/character/charManifest.generated.ts @@ -0,0 +1,576 @@ +// Generated by scripts/generate-char-manifest.mjs — do not edit by hand. +export const charManifest = { + "viewBox": "0 0 296 374", + "arms": { + "armL": { + "jointId": "armL_joint", + "pivotX": 0, + "pivotY": 0, + "sleeveIds": [ + "path124", + "path126" + ] + }, + "armR": { + "jointId": "armR_joint", + "pivotX": 0, + "pivotY": 0, + "sleeveIds": [ + "path125", + "path127" + ] + } + }, + "fillables": [ + { + "id": "path45", + "key": "path45", + "label": "Skin", + "defaultFill": "#ddae67", + "targetIds": [ + "path45", + "path1", + "path1-3" + ] + }, + { + "id": "path65", + "key": "path65", + "label": "Pants", + "defaultFill": "#5042ae" + }, + { + "id": "hair_fill", + "key": "hair_fill", + "label": "Hair", + "defaultFill": "#311e00", + "targetIds": [ + "path85", + "path87-4", + "path87-4-4", + "path91", + "path94", + "path95", + "path96", + "path99", + "path97", + "path100" + ] + }, + { + "id": "g86", + "key": "catbase", + "label": "Catbase", + "defaultFill": "#ffce67", + "targetIds": [ + "path77", + "path78", + "path79", + "path81", + "path82", + "path86", + "path86-4" + ] + }, + { + "id": "g91", + "key": "dogbase", + "label": "Dogbase", + "defaultFill": "#725d34", + "targetIds": [ + "ellipse86", + "ellipse87", + "ellipse88", + "ellipse89", + "ellipse90", + "path92", + "path92-9" + ] + }, + { + "id": "path3", + "key": "bandr", + "label": "BandR", + "defaultFill": "#c32033", + "targetIds": [ + "path3" + ] + }, + { + "id": "path3-0", + "key": "bandl", + "label": "BandL", + "defaultFill": "#c32033", + "targetIds": [ + "path3-0" + ] + }, + { + "id": "path13", + "key": "watchr", + "label": "WatchR", + "defaultFill": "#35090e", + "targetIds": [ + "path13" + ] + }, + { + "id": "path16", + "key": "glover", + "label": "GloveR", + "defaultFill": "#35090e", + "targetIds": [ + "path16" + ] + }, + { + "id": "path16-0", + "key": "glovel", + "label": "GloveL", + "defaultFill": "#35090e", + "targetIds": [ + "path16-0" + ] + }, + { + "id": "path10", + "key": "pocketr", + "label": "PocketR", + "defaultFill": "#c32033", + "targetIds": [ + "path10" + ] + }, + { + "id": "path10-7", + "key": "pocketl", + "label": "PocketL", + "defaultFill": "#2c2460", + "targetIds": [ + "path10-7" + ] + }, + { + "id": "path21", + "key": "circle", + "label": "CIRCLE", + "defaultFill": "#d38d5f", + "targetIds": [ + "path21" + ] + }, + { + "id": "rect21", + "key": "square", + "label": "SQUARE", + "defaultFill": "#d38d5f", + "targetIds": [ + "rect21" + ] + }, + { + "id": "path22", + "key": "star", + "label": "STAR", + "defaultFill": "#d38d5f", + "targetIds": [ + "path22" + ] + }, + { + "id": "path40", + "key": "bandana", + "label": "Bandana", + "defaultFill": "#800000", + "targetIds": [ + "path40" + ] + }, + { + "id": "g55", + "key": "lemmon", + "label": "Lemmon", + "defaultFill": "#ffe166", + "targetIds": [ + "path54", + "path55" + ] + } + ], + "globalFillGroups": [ + { + "key": "shirt", + "label": "Toggleshirt", + "parentId": "layer15", + "defaultFill": "#261b4f", + "targetIds": [ + "path67", + "path118", + "path119", + "path120" + ] + }, + { + "key": "sleeves", + "label": "Togglesleeve", + "parentId": "layer19", + "defaultFill": "#261b4f", + "targetIds": [ + "path124", + "path125", + "path126", + "path127" + ] + } + ], + "toggleGroups": [ + { + "key": "layer15", + "label": "Toggleshirt", + "parentId": "layer15", + "defaultOption": "path67", + "options": [ + { + "id": "path67", + "label": "Tshirt" + }, + { + "id": "path118", + "label": "Longshirt" + }, + { + "id": "g120", + "label": "Crop" + } + ] + }, + { + "key": "layer16", + "label": "Togglehair", + "parentId": "layer16", + "defaultOption": "g96", + "options": [ + { + "id": "g89", + "label": "Long" + }, + { + "id": "g93", + "label": "Short" + }, + { + "id": "g96", + "label": "Pony" + } + ] + }, + { + "key": "layer17", + "label": "Togglemouth", + "parentId": "layer17", + "defaultOption": "g103", + "options": [ + { + "id": "path51-8", + "label": "Positive" + }, + { + "id": "g103", + "label": "NEU" + }, + { + "id": "path51-8-5", + "label": "Negative" + } + ] + }, + { + "key": "layer18", + "label": "Toggleeyecolour BLK", + "parentId": "layer18", + "defaultOption": "g105", + "options": [ + { + "id": "g104", + "label": "Default" + }, + { + "id": "g105", + "label": "Brown" + }, + { + "id": "g107", + "label": "Blue" + } + ] + }, + { + "key": "layer19", + "label": "Togglesleeve", + "parentId": "layer19", + "defaultOption": "g127", + "options": [ + { + "id": "g125", + "label": "Longshirtsleeve" + }, + { + "id": "g127", + "label": "Shortsleeve" + } + ] + }, + { + "key": "layer2", + "label": "Togglepet", + "parentId": "layer2", + "defaultOption": "g91", + "options": [ + { + "id": "g86", + "label": "Catbase" + }, + { + "id": "g91", + "label": "Dogbase" + } + ] + }, + { + "key": "layer18-6", + "label": "Toggleeyecolour BLK", + "parentId": "layer18-6", + "defaultOption": "g105-1", + "options": [ + { + "id": "g104-5", + "label": "Default" + }, + { + "id": "g105-1", + "label": "Brown" + }, + { + "id": "g107-1", + "label": "Blue" + } + ] + }, + { + "key": "layer3", + "label": "Togglewrist", + "parentId": "layer3", + "defaultOption": "path3", + "options": [ + { + "id": "path3", + "label": "BandR" + }, + { + "id": "path3-0", + "label": "BandL" + }, + { + "id": "g15", + "label": "WatchR" + }, + { + "id": "g15-5", + "label": "WatchL" + }, + { + "id": "path16", + "label": "GloveR" + }, + { + "id": "path16-0", + "label": "GloveL" + } + ] + }, + { + "key": "layer4", + "label": "Togglepants", + "parentId": "layer4", + "defaultOption": "path10-7", + "options": [ + { + "id": "path10", + "label": "PocketR" + }, + { + "id": "path10-7", + "label": "PocketL" + } + ] + }, + { + "key": "layer5", + "label": "Toggleshoes", + "parentId": "layer5", + "defaultOption": "g16", + "options": [ + { + "id": "g16", + "label": "BOOTS" + }, + { + "id": "g20", + "label": "SNEEKER" + }, + { + "id": "g21", + "label": "HEEL" + } + ] + }, + { + "key": "layer6", + "label": "Toggleshirt", + "parentId": "layer6", + "defaultOption": "path22", + "options": [ + { + "id": "path21", + "label": "CIRCLE" + }, + { + "id": "rect21", + "label": "SQUARE" + }, + { + "id": "path22", + "label": "STAR" + } + ] + }, + { + "key": "layer7", + "label": "Toggleface", + "parentId": "layer7", + "defaultOption": "g27", + "options": [ + { + "id": "g27", + "label": "Glassessquare" + }, + { + "id": "g32", + "label": "Glassescircle" + }, + { + "id": "path40", + "label": "Bandana" + }, + { + "id": "g47", + "label": "Mask" + } + ] + }, + { + "key": "layer8", + "label": "Togglehat", + "parentId": "layer8", + "defaultOption": "g51", + "options": [ + { + "id": "g51", + "label": "Cowboy" + }, + { + "id": "path52", + "label": "Headcover" + }, + { + "id": "path53", + "label": "Crown" + } + ] + }, + { + "key": "blush", + "label": "Blush", + "parentId": "g102", + "defaultOption": "on", + "options": [ + { + "id": "on", + "label": "On" + }, + { + "id": "off", + "label": "Off" + } + ], + "elementId": "g102" + } + ], + "appearanceDefaults": { + "fills": { + "path45": "#ddae67", + "path65": "#5042ae", + "hair_fill": "#311e00", + "catbase": "#ffce67", + "dogbase": "#725d34", + "bandr": "#c32033", + "bandl": "#c32033", + "watchr": "#35090e", + "glover": "#35090e", + "glovel": "#35090e", + "pocketr": "#c32033", + "pocketl": "#2c2460", + "circle": "#d38d5f", + "square": "#d38d5f", + "star": "#d38d5f", + "bandana": "#800000", + "lemmon": "#ffe166", + "shirt": "#261b4f", + "sleeves": "#261b4f" + }, + "toggles": { + "layer15": "path67", + "layer16": "g96", + "layer17": "g103", + "layer18": "g105", + "layer19": "g127", + "layer2": "g91", + "layer18-6": "g105-1", + "layer3": "path3", + "layer4": "path10-7", + "layer5": "g16", + "layer6": "path22", + "layer7": "g27", + "layer8": "g51", + "blush": "on" + } + }, + "blink": { + "openLayerIds": [ + "layer14", + "layer18", + "g100", + "layer14-9", + "layer18-6", + "g100-4" + ], + "closedLayerId": "g11-7", + "closedDurationMs": 120, + "minIntervalMs": 2000, + "maxIntervalMs": 4500 + }, + "headshotViewBox": "0 0 100 100", + "assets": { + "character": "char/charprod.svg", + "headshot": "char/headshot.svg" + } +} as const + +export type CharManifest = typeof charManifest +export type CharacterPoseId = + | 'idle' + | 'present' + | 'think' + | 'write' + | 'shop' + | 'wave' diff --git a/src/features/character/charManifest.ts b/src/features/character/charManifest.ts new file mode 100644 index 0000000..0c1c002 --- /dev/null +++ b/src/features/character/charManifest.ts @@ -0,0 +1 @@ +export { charManifest, type CharManifest, type CharacterPoseId } from './charManifest.generated' diff --git a/src/features/character/characterAppearance.ts b/src/features/character/characterAppearance.ts new file mode 100644 index 0000000..cf629a7 --- /dev/null +++ b/src/features/character/characterAppearance.ts @@ -0,0 +1,14 @@ +import { charManifest } from './charManifest' + +export type CharacterAppearance = { + fills: Record + toggles: Record +} + +export function defaultCharacterAppearance(): CharacterAppearance { + const defaults = charManifest.appearanceDefaults + return { + fills: { ...defaults.fills }, + toggles: { ...defaults.toggles }, + } +} diff --git a/src/features/character/characterAppearanceService.ts b/src/features/character/characterAppearanceService.ts new file mode 100644 index 0000000..5687f8b --- /dev/null +++ b/src/features/character/characterAppearanceService.ts @@ -0,0 +1,97 @@ +import { getDb } from '../../db/schema' +import { notifyLocalDataChanged } from '../../shared/sync/localDataEvents' +import { + defaultCharacterAppearance, + type CharacterAppearance, +} from './characterAppearance' + +export const CHARACTER_APPEARANCE_ROW_ID = 'default' as const +export const CHARACTER_APPEARANCE_CHANGED_EVENT = 'mentell.character.appearance.changed' + +let cache: CharacterAppearance | null = null +let loadPromise: Promise | null = null +let saveTimer: ReturnType | undefined + +function mergeWithDefaults(stored: CharacterAppearance): CharacterAppearance { + const defaults = defaultCharacterAppearance() + return { + fills: { ...defaults.fills, ...stored.fills }, + toggles: { ...defaults.toggles, ...stored.toggles }, + } +} + +function notifyAppearanceChanged() { + window.dispatchEvent(new CustomEvent(CHARACTER_APPEARANCE_CHANGED_EVENT)) +} + +export function getCachedCharacterAppearance(): CharacterAppearance | null { + return cache +} + +export async function loadCharacterAppearance(): Promise { + if (cache) return cache + if (loadPromise) return loadPromise + + loadPromise = (async () => { + const row = await getDb().characterAppearance.get(CHARACTER_APPEARANCE_ROW_ID) + if (row) { + cache = mergeWithDefaults({ fills: row.fills, toggles: row.toggles }) + } else { + cache = defaultCharacterAppearance() + } + return cache + })() + + return loadPromise +} + +export async function saveCharacterAppearance(appearance: CharacterAppearance) { + cache = appearance + loadPromise = Promise.resolve(cache) + const row = { + id: CHARACTER_APPEARANCE_ROW_ID, + updatedAt: Date.now(), + fills: appearance.fills, + toggles: appearance.toggles, + } + await getDb().characterAppearance.put(row) + notifyAppearanceChanged() + notifyLocalDataChanged() +} + +export function scheduleSaveCharacterAppearance(appearance: CharacterAppearance) { + cache = appearance + if (saveTimer !== undefined) clearTimeout(saveTimer) + saveTimer = setTimeout(() => { + saveTimer = undefined + void saveCharacterAppearance(appearance) + }, 400) +} + +export async function resetCharacterAppearance(): Promise { + const next = defaultCharacterAppearance() + if (saveTimer !== undefined) { + clearTimeout(saveTimer) + saveTimer = undefined + } + await saveCharacterAppearance(next) + return next +} + +export async function clearCharacterAppearance() { + if (saveTimer !== undefined) { + clearTimeout(saveTimer) + saveTimer = undefined + } + cache = null + loadPromise = null + await getDb().characterAppearance.clear() + notifyAppearanceChanged() + notifyLocalDataChanged() +} + +export function applyCharacterAppearanceFromCloud(appearance: CharacterAppearance) { + cache = mergeWithDefaults(appearance) + loadPromise = Promise.resolve(cache) + notifyAppearanceChanged() +} diff --git a/src/features/character/characterBlink.ts b/src/features/character/characterBlink.ts new file mode 100644 index 0000000..f96f2f6 --- /dev/null +++ b/src/features/character/characterBlink.ts @@ -0,0 +1,80 @@ +import { useEffect } from 'react' +import { charManifest } from './charManifest' +import { shouldReduceMotion } from '../../shared/motion/useMotionPrefs' + +function setLayerVisible(svg: SVGSVGElement, id: string, show: boolean) { + const el = svg.getElementById(id) + if (!(el instanceof SVGElement)) return + el.style.display = show ? 'inline' : 'none' +} + +function blinkLayerIds(svg: SVGSVGElement) { + const openLayerIds = new Set(charManifest.blink?.openLayerIds ?? []) + const closedLayerIds = new Set( + charManifest.blink ? [charManifest.blink.closedLayerId] : [], + ) + svg.querySelectorAll('*').forEach((el) => { + const label = el.getAttribute('inkscape:label') ?? '' + if (!el.id) return + if (label === 'BLK') closedLayerIds.add(el.id) + else if (label.endsWith('_BLK')) openLayerIds.add(el.id) + }) + return { openLayerIds: [...openLayerIds], closedLayerIds: [...closedLayerIds] } +} + +/** Default: open-eye *_BLK layers on, closed-eye BLK layer off. */ +export function applyBlinkOpenState(svg: SVGSVGElement) { + const { openLayerIds, closedLayerIds } = blinkLayerIds(svg) + for (const id of openLayerIds) setLayerVisible(svg, id, true) + for (const id of closedLayerIds) setLayerVisible(svg, id, false) +} + +/** Blink frame: hide *_BLK, show BLK overlay. */ +export function applyBlinkClosedState(svg: SVGSVGElement) { + const { openLayerIds, closedLayerIds } = blinkLayerIds(svg) + for (const id of openLayerIds) setLayerVisible(svg, id, false) + for (const id of closedLayerIds) setLayerVisible(svg, id, true) +} + +export function useCharacterBlink( + svgRef: React.RefObject, + svgGeneration = 0, +) { + useEffect(() => { + if (svgGeneration < 0) return + const blink = charManifest.blink + if (!blink || shouldReduceMotion()) return + + const svg = svgRef.current + if (!svg) return + + let cancelled = false + let intervalTimer: ReturnType | undefined + let closeTimer: ReturnType | undefined + + const scheduleNext = () => { + const wait = + blink.minIntervalMs + + Math.random() * (blink.maxIntervalMs - blink.minIntervalMs) + intervalTimer = setTimeout(() => { + if (cancelled) return + applyBlinkClosedState(svg) + closeTimer = setTimeout(() => { + if (cancelled) return + applyBlinkOpenState(svg) + scheduleNext() + }, blink.closedDurationMs) + }, wait) + } + + applyBlinkOpenState(svg) + scheduleNext() + + return () => { + cancelled = true + if (intervalTimer !== undefined) clearTimeout(intervalTimer) + if (closeTimer !== undefined) clearTimeout(closeTimer) + applyBlinkOpenState(svg) + } + }, [svgRef, svgGeneration]) +} diff --git a/src/features/character/characterPaintOrder.ts b/src/features/character/characterPaintOrder.ts new file mode 100644 index 0000000..5ba8435 --- /dev/null +++ b/src/features/character/characterPaintOrder.ts @@ -0,0 +1,69 @@ +import { charManifest } from './charManifest' + +/** Maps charprod art-space paths into the headshot viewBox. */ +export const HEADSHOT_CHAR_MATRIX = + 'matrix(0.44774806,0,0,0.44774806,264.3503,-101.61394)' + +const HEADSHOT_EYE_TOGGLE_IDS = ['g104', 'g105', 'g107'] as const + +const EYE_STACK_IDS = ['layer14', 'layer18', 'g100'] as const + +function raiseAfterAnchor( + svg: SVGSVGElement, + anchorId: string, + ids: readonly string[], +): Element | null { + const anchor = svg.getElementById(anchorId) + const parent = anchor?.parentElement ?? svg + if (!anchor) return null + let insertRef: ChildNode | null = anchor.nextSibling + for (const id of ids) { + const el = svg.getElementById(id) + if (!el) continue + parent.insertBefore(el, insertRef) + insertRef = el.nextSibling + } + return parent +} + +export function normalizeHeadshotEyeToggles(svg: SVGSVGElement) { + for (const id of HEADSHOT_EYE_TOGGLE_IDS) { + const group = svg.getElementById(id) + if (group) group.setAttribute('transform', HEADSHOT_CHAR_MATRIX) + } +} + +/** + * Headshot: hair (layer16) must stay under eye whites; iris + pupils stay on top. + */ +export function fixHeadshotPaintOrder(svg: SVGSVGElement) { + normalizeHeadshotEyeToggles(svg) + + const layer16 = svg.getElementById('layer16') + const layer14 = svg.getElementById('layer14') + const parent = layer16?.parentElement + if (layer14 && parent && layer16) { + parent.insertBefore(layer14, layer16.nextSibling) + } +} + +/** + * Full body: stack whites → iris → pupils above hair/shirt, then arms/sleeves on top. + */ +export function fixCharacterPaintOrder(svg: SVGSVGElement) { + const parent = raiseAfterAnchor(svg, 'layer13', EYE_STACK_IDS) + if (!parent) return + + const sleeveParentId = + charManifest.globalFillGroups.find((group) => group.key === 'sleeves')?.parentId ?? 'layer19' + const frontIds = [ + charManifest.arms.armL.jointId, + charManifest.arms.armR.jointId, + sleeveParentId, + ] + + for (const id of frontIds) { + const el = svg.getElementById(id) + if (el) parent.appendChild(el) + } +} diff --git a/src/features/character/characterPoses.ts b/src/features/character/characterPoses.ts new file mode 100644 index 0000000..a379849 --- /dev/null +++ b/src/features/character/characterPoses.ts @@ -0,0 +1,40 @@ +import type { CharacterPoseId } from './charManifest' + +export type ArmPose = { armL: number; armR: number } + +export const CHARACTER_POSES: Record = { + idle: { armL: 0, armR: 0 }, + present: { armL: -38, armR: -53 }, + think: { armL: 0, armR: 131 }, + write: { armL: -34, armR: 37 }, + shop: { armL: 22, armR: -75 }, + wave: { armL: 104, armR: -28 }, +} + +export const POSE_LABELS: Record = { + idle: 'Idle', + present: 'Present', + think: 'Think', + write: 'Write', + shop: 'Shop', + wave: 'Wave', +} + +export const ALL_POSE_IDS = Object.keys(CHARACTER_POSES) as CharacterPoseId[] + +export function poseForPathname(pathname: string): CharacterPoseId { + switch (pathname) { + case '/': + return 'present' + case '/week': + return 'think' + case '/notes': + return 'write' + case '/shop': + return 'shop' + case '/settings': + return 'idle' + default: + return 'idle' + } +} diff --git a/src/features/character/lab/LabSwitch.tsx b/src/features/character/lab/LabSwitch.tsx new file mode 100644 index 0000000..10a3ba5 --- /dev/null +++ b/src/features/character/lab/LabSwitch.tsx @@ -0,0 +1,30 @@ +export function LabSwitch({ + label, + checked, + onChange, +}: { + label: string + checked: boolean + onChange: (on: boolean) => void +}) { + return ( + + ) +} diff --git a/src/features/character/lab/OptionDial.tsx b/src/features/character/lab/OptionDial.tsx new file mode 100644 index 0000000..aaae6d7 --- /dev/null +++ b/src/features/character/lab/OptionDial.tsx @@ -0,0 +1,44 @@ +export function OptionDial({ + label, + options, + valueIndex, + onChange, + segmentLabels, +}: { + label: string + options: { id: string; label: string }[] + valueIndex: number + onChange: (optionId: string) => void + segmentLabels?: string[] +}) { + return ( +
+
{label}
+
+ {options.map((opt, i) => { + const pressed = i === valueIndex + const seg = segmentLabels?.[i] ?? String(i + 1) + return ( + + ) + })} +
+
+ ) +} diff --git a/src/features/character/useArmPoseAnimation.ts b/src/features/character/useArmPoseAnimation.ts new file mode 100644 index 0000000..3bb90d2 --- /dev/null +++ b/src/features/character/useArmPoseAnimation.ts @@ -0,0 +1,227 @@ +import { animate } from 'framer-motion' +import { useEffect, useRef } from 'react' +import { charManifest } from './charManifest' +import type { ArmPose } from './characterPoses' +import { motionDuration } from '../../shared/motion/useMotionPrefs' + +function shoulderPivot(joint: SVGGElement): { cx: number; cy: number } { + const path = joint.querySelector('path') + if (path) { + try { + const point = path.getPointAtLength(0) + if (Number.isFinite(point.x) && Number.isFinite(point.y)) { + return { cx: point.x, cy: point.y } + } + } catch { + // Fall through to the geometric fallback below. + } + } + const box = joint.getBBox() + return { cx: box.x + box.width / 2, cy: box.y } +} + +function elementPointToSvg( + el: SVGGraphicsElement, + local: { cx: number; cy: number }, +): { cx: number; cy: number } { + const svg = el.ownerSVGElement + const elementMatrix = el.getScreenCTM() + const svgMatrix = svg?.getScreenCTM() + if (!svg || !elementMatrix || !svgMatrix) return local + const pt = svg.createSVGPoint() + pt.x = local.cx + pt.y = local.cy + const asScreen = pt.matrixTransform(elementMatrix) + const asSvg = asScreen.matrixTransform(svgMatrix.inverse()) + return { cx: asSvg.x, cy: asSvg.y } +} + +function svgPointToElement( + el: SVGGraphicsElement, + svgPoint: { cx: number; cy: number }, +): { cx: number; cy: number } { + const svg = el.ownerSVGElement + const elementMatrix = el.getScreenCTM() + const svgMatrix = svg?.getScreenCTM() + if (!svg || !elementMatrix || !svgMatrix) return svgPoint + const pt = svg.createSVGPoint() + pt.x = svgPoint.cx + pt.y = svgPoint.cy + const asScreen = pt.matrixTransform(svgMatrix) + const asElement = asScreen.matrixTransform(elementMatrix.inverse()) + return { cx: asElement.x, cy: asElement.y } +} + +type RotatingNode = { + el: SVGGraphicsElement + baseTransform: string + pivot: { cx: number; cy: number } +} + +const BASE_TRANSFORM_ATTR = 'data-mentell-base-transform' + +function getBaseTransform(el: SVGGraphicsElement) { + const stored = el.getAttribute(BASE_TRANSFORM_ATTR) + if (stored !== null) return stored + const initial = el.getAttribute('transform') ?? '' + el.setAttribute(BASE_TRANSFORM_ATTR, initial) + return initial +} + +function setRotateWithBase(node: RotatingNode, deg: number) { + const rotate = `rotate(${deg} ${node.pivot.cx} ${node.pivot.cy})` + node.el.setAttribute( + 'transform', + node.baseTransform ? `${node.baseTransform} ${rotate}` : rotate, + ) +} + +function isRendered(el: SVGGraphicsElement) { + return getComputedStyle(el).display !== 'none' +} + +function normalizeLookup(value: string) { + return value + .toLowerCase() + .replace(/_(toggle|iii|dni).*$/i, '') + .replace(/[^a-z0-9]+/g, '') +} + +function resolveGraphics(svg: SVGSVGElement, key: string) { + const byId = svg.getElementById(key) + if (byId instanceof SVGGraphicsElement) return [byId] + const normalized = normalizeLookup(key) + return Array.from(svg.querySelectorAll('*')).filter((el): el is SVGGraphicsElement => { + if (!(el instanceof SVGGraphicsElement)) return false + const label = el.getAttribute('inkscape:label') ?? '' + return normalizeLookup(label) === normalized + }) +} + +export function useArmPoseAnimation( + svgRef: React.RefObject, + pose: ArmPose, + svgGeneration = 0, + anchoredIds?: { armL?: readonly string[]; armR?: readonly string[] }, +) { + const prevPose = useRef(pose) + + useEffect(() => { + prevPose.current = { armL: 0, armR: 0 } + }, [svgGeneration]) + + useEffect(() => { + const svg = svgRef.current + if (!svg) return + const nextPose: ArmPose = { armL: pose.armL, armR: pose.armR } + + const armL = svg.getElementById(charManifest.arms.armL.jointId) as SVGGElement | null + const armR = svg.getElementById(charManifest.arms.armR.jointId) as SVGGElement | null + if (!armL || !armR) return + + const armLBase = getBaseTransform(armL) + const armRBase = getBaseTransform(armR) + armL.setAttribute('transform', armLBase) + armR.setAttribute('transform', armRBase) + + const pivotLLocal = shoulderPivot(armL) + const pivotRLocal = shoulderPivot(armR) + const pivotL = elementPointToSvg(armL, pivotLLocal) + const pivotR = elementPointToSvg(armR, pivotRLocal) + + const armLNode: RotatingNode = { + el: armL, + baseTransform: armLBase, + pivot: pivotLLocal, + } + const armRNode: RotatingNode = { + el: armR, + baseTransform: armRBase, + pivot: pivotRLocal, + } + + const leftSleeveIds = [ + ...charManifest.arms.armL.sleeveIds, + ...(anchoredIds?.armL ?? []), + ] + const rightSleeveIds = [ + ...charManifest.arms.armR.sleeveIds, + ...(anchoredIds?.armR ?? []), + ] + + const leftSleeves = leftSleeveIds + .flatMap((id) => resolveGraphics(svg, id)) + .filter((el): el is SVGGraphicsElement => el instanceof SVGGraphicsElement && isRendered(el)) + const rightSleeves = rightSleeveIds + .flatMap((id) => resolveGraphics(svg, id)) + .filter((el): el is SVGGraphicsElement => el instanceof SVGGraphicsElement && isRendered(el)) + + const leftSleeveNodes: RotatingNode[] = leftSleeves.map((el) => { + const baseTransform = getBaseTransform(el) + el.setAttribute('transform', baseTransform) + return { + el, + baseTransform, + pivot: svgPointToElement(el, pivotL), + } + }) + const rightSleeveNodes: RotatingNode[] = rightSleeves.map((el) => { + const baseTransform = getBaseTransform(el) + el.setAttribute('transform', baseTransform) + return { + el, + baseTransform, + pivot: svgPointToElement(el, pivotR), + } + }) + + const apply = (degL: number, degR: number) => { + setRotateWithBase(armLNode, degL) + setRotateWithBase(armRNode, degR) + for (const node of leftSleeveNodes) { + setRotateWithBase(node, degL) + } + for (const node of rightSleeveNodes) { + setRotateWithBase(node, degR) + } + } + + const duration = motionDuration(0.55) + + if (duration === 0) { + apply(nextPose.armL, nextPose.armR) + prevPose.current = nextPose + return + } + + const fromL = prevPose.current.armL + const fromR = prevPose.current.armR + let currentL = fromL + let currentR = fromR + + const controlsL = animate(fromL, nextPose.armL, { + type: 'spring', + duration, + bounce: 0.22, + onUpdate: (v) => { + currentL = v + apply(currentL, currentR) + }, + }) + const controlsR = animate(fromR, nextPose.armR, { + type: 'spring', + duration, + bounce: 0.22, + onUpdate: (v) => { + currentR = v + apply(currentL, currentR) + }, + }) + + prevPose.current = nextPose + return () => { + controlsL.stop() + controlsR.stop() + } + }, [svgRef, pose.armL, pose.armR, svgGeneration, anchoredIds]) +} diff --git a/src/features/character/useCharacterAppearance.ts b/src/features/character/useCharacterAppearance.ts new file mode 100644 index 0000000..e8e4ca0 --- /dev/null +++ b/src/features/character/useCharacterAppearance.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useState } from 'react' +import { + defaultCharacterAppearance, + type CharacterAppearance, +} from './characterAppearance' +import { + CHARACTER_APPEARANCE_CHANGED_EVENT, + getCachedCharacterAppearance, + loadCharacterAppearance, + resetCharacterAppearance, + scheduleSaveCharacterAppearance, +} from './characterAppearanceService' + +export function useCharacterAppearance() { + const [appearance, setAppearanceState] = useState( + () => getCachedCharacterAppearance() ?? defaultCharacterAppearance(), + ) + const [ready, setReady] = useState(() => getCachedCharacterAppearance() !== null) + + useEffect(() => { + let cancelled = false + void loadCharacterAppearance().then((loaded) => { + if (cancelled) return + setAppearanceState(loaded) + setReady(true) + }) + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + const onExternalChange = () => { + const cached = getCachedCharacterAppearance() + if (cached) setAppearanceState(cached) + } + window.addEventListener(CHARACTER_APPEARANCE_CHANGED_EVENT, onExternalChange) + return () => + window.removeEventListener(CHARACTER_APPEARANCE_CHANGED_EVENT, onExternalChange) + }, []) + + const setAppearance = useCallback( + (next: CharacterAppearance | ((prev: CharacterAppearance) => CharacterAppearance)) => { + setAppearanceState((prev) => { + const resolved = typeof next === 'function' ? next(prev) : next + scheduleSaveCharacterAppearance(resolved) + return resolved + }) + }, + [], + ) + + const resetAppearance = useCallback(async () => { + const next = await resetCharacterAppearance() + setAppearanceState(next) + }, []) + + return { appearance, setAppearance, resetAppearance, ready } +} diff --git a/src/features/character/usePetAccessoryAnimation.ts b/src/features/character/usePetAccessoryAnimation.ts new file mode 100644 index 0000000..0a97ef5 --- /dev/null +++ b/src/features/character/usePetAccessoryAnimation.ts @@ -0,0 +1,90 @@ +import { useEffect } from 'react' +import { shouldReduceMotion } from '../../shared/motion/useMotionPrefs' + +const BASE_TRANSFORM_ATTR = 'data-mentell-pet-base-transform' + +type TailNode = { + el: SVGGraphicsElement + baseTransform: string + pivot: { cx: number; cy: number } + kind: 'cat' | 'dog' +} + +function getBaseTransform(el: SVGGraphicsElement) { + const stored = el.getAttribute(BASE_TRANSFORM_ATTR) + if (stored !== null) return stored + const initial = el.getAttribute('transform') ?? '' + el.setAttribute(BASE_TRANSFORM_ATTR, initial) + return initial +} + +function isRendered(el: SVGGraphicsElement) { + return getComputedStyle(el).display !== 'none' +} + +function petKind(el: Element): 'cat' | 'dog' | null { + let current: Element | null = el + while (current) { + const label = (current.getAttribute('inkscape:label') ?? '').toLowerCase() + if (label.includes('catbase')) return 'cat' + if (label.includes('dogbase')) return 'dog' + current = current.parentElement + } + return null +} + +function collectTailNodes(svg: SVGSVGElement) { + return Array.from(svg.querySelectorAll('[inkscape\\:label="tail"]')) + .filter((el) => el instanceof SVGGraphicsElement && isRendered(el)) + .map((el): TailNode | null => { + const kind = petKind(el) + if (!kind) return null + const box = el.getBBox() + return { + el, + baseTransform: getBaseTransform(el), + pivot: { cx: box.x + box.width / 2, cy: box.y + box.height / 2 }, + kind, + } + }) + .filter((node): node is TailNode => node !== null) +} + +export function usePetAccessoryAnimation( + svgRef: React.RefObject, + svgGeneration = 0, +) { + useEffect(() => { + const svg = svgRef.current + if (!svg || shouldReduceMotion()) return undefined + + const tails = collectTailNodes(svg) + if (!tails.length) return undefined + + let raf = 0 + const start = performance.now() + + const tick = (now: number) => { + const elapsed = now - start + for (const node of tails) { + const amplitude = node.kind === 'dog' ? 12 : 4 + const speed = node.kind === 'dog' ? 0.018 : 0.0045 + const deg = Math.sin(elapsed * speed) * amplitude + const rotate = `rotate(${deg.toFixed(3)} ${node.pivot.cx} ${node.pivot.cy})` + node.el.setAttribute( + 'transform', + node.baseTransform ? `${node.baseTransform} ${rotate}` : rotate, + ) + } + raf = requestAnimationFrame(tick) + } + + raf = requestAnimationFrame(tick) + return () => { + cancelAnimationFrame(raf) + for (const node of tails) { + node.el.setAttribute('transform', node.baseTransform) + } + } + }, [svgRef, svgGeneration]) +} diff --git a/src/features/compose/SubmitAnimation.tsx b/src/features/compose/SubmitAnimation.tsx index 44f846d..68695a6 100644 --- a/src/features/compose/SubmitAnimation.tsx +++ b/src/features/compose/SubmitAnimation.tsx @@ -1,8 +1,8 @@ import { AnimatePresence, motion } from 'framer-motion' import { useEffect, useState } from 'react' import { motionDuration, shouldReduceMotion } from '../../shared/motion/useMotionPrefs' -import { publicUrl } from '../../shared/publicUrl' import { RopeWrap } from './RopeWrap' +import { useEquippedStampAsset } from '../shop/shopStampAsset' type Phase = 'stamp' | 'rope' | 'mailbox' @@ -16,11 +16,10 @@ export function SubmitAnimation({ const [phase, setPhase] = useState('stamp') const [stampLanded, setStampLanded] = useState(false) const reduced = shouldReduceMotion() + const stamp = useEquippedStampAsset() useEffect(() => { if (!open) return - setPhase('stamp') - setStampLanded(false) const d = (ms: number) => motionDuration(ms) || (reduced ? 50 : ms) @@ -92,9 +91,13 @@ export function SubmitAnimation({ {stampLanded ? ( ) : null} @@ -125,11 +128,11 @@ export function SubmitAnimation({ > void refreshNotifications(), 2000) return () => window.clearInterval(id) - }, [open, notifSnap?.serviceWorkerReady, notifSnap?.serviceWorkerState, notifSnap?.serviceWorkerRegistered]) + }, [open, notifSnap]) async function runNotifAction( label: string, @@ -678,6 +679,8 @@ export function DebugPanel() { await database.notes.clear() await database.stickies.clear() await database.packages.clear() + await database.characterAppearance.clear() + clearShopInventory() localStorage.removeItem(scopedStorageKey('mentell.score.total')) localStorage.removeItem(scopedStorageKey('mentell.score.streak')) localStorage.removeItem(scopedStorageKey('mentell.score.lastDay')) @@ -759,4 +762,3 @@ export function DebugPanel() { ) } - diff --git a/src/features/legal/PrivacyPolicyPage.tsx b/src/features/legal/PrivacyPolicyPage.tsx index 7057c24..93cff38 100644 --- a/src/features/legal/PrivacyPolicyPage.tsx +++ b/src/features/legal/PrivacyPolicyPage.tsx @@ -79,9 +79,9 @@ export function PrivacyPolicyPage() {

Local-first by default

- Your journal entries, notes, score, and most settings are stored on your device (IndexedDB - and browser storage). They are not sent to a server unless you turn on optional cloud - features below. + Your journal entries, notes, score, character look, and most settings are stored on your + device (IndexedDB and browser storage). They are not sent to a server unless you turn on + optional cloud features below.

@@ -99,8 +99,9 @@ export function PrivacyPolicyPage() {

With sync turned on, journal data and related settings you choose to back up are stored in Cloud Firestore under your account, keyed to your Firebase user id. - You can sign out, disable sync, delete local data, or delete your cloud account from - Settings. Cloud data is not intended for provider-managed or multi-patient use. + This includes character customization and shop cosmetics you unlock/equip. You can sign + out, disable sync, delete local data, or delete your cloud account from Settings. Cloud + data is not intended for provider-managed or multi-patient use.

) : (

diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx index 22b894d..456fd2d 100644 --- a/src/features/settings/SettingsPage.tsx +++ b/src/features/settings/SettingsPage.tsx @@ -7,6 +7,7 @@ import { browserTimezone } from '../../shared/settings/appSettings' import { AccountSyncSection } from './AccountSyncSection' import { SettingsAccountFeatures } from './SettingsAccountFeatures' import { SettingsDebugCloudSection } from './SettingsDebugCloudSection' +import { DeskCharacterLayout } from '../character/DeskCharacterLayout' const WEEKDAY_OPTIONS = [ { value: 0, label: 'Sunday' }, @@ -48,6 +49,7 @@ export function SettingsPage() { }, [settings.globalName, settings.globalNameManuallySet]) return ( +

Settings
@@ -192,5 +194,6 @@ export function SettingsPage() {
+ ) } diff --git a/src/features/shop/Shoppe.tsx b/src/features/shop/Shoppe.tsx index 34d81eb..6c4a2fe 100644 --- a/src/features/shop/Shoppe.tsx +++ b/src/features/shop/Shoppe.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { format } from 'date-fns' import { getScoreSnapshot, spendScore } from '../score/scoreService' import { @@ -7,11 +7,138 @@ import { type CollectedCat, } from './catCollection' import { WeekTimelineCard } from './WeekTimelineCard' +import { MentellCharacter } from '../character/MentellCharacter' +import { defaultCharacterAppearance } from '../character/characterAppearance' import { useAppSettings } from '../../shared/settings/useAppSettings' +import { publicUrl } from '../../shared/publicUrl' +import { SCORE_CHANGED_EVENT } from '../score/scoreEvents' +import { + equipShopItem, + loadShopInventory, + subscribeShopInventory, + unlockShopItem, + type ShopInventory, +} from './shopInventory' +import { + loadShopCatalog, + type CharacterAccessoryItem, + type CursorItem, + type ShopCatalogItem, + type ThemeItem, +} from './shopCatalog' +import { renderCursorCssValue } from './shopCursorAsset' +import { renderStampPreviewForItem } from './shopStampAsset' const CAT_COST = 250 type CatApiRow = { id?: string; url?: string } +type EquippableType = 'theme' | 'stamp' | 'cursor' + +function ThemePreview({ item }: { item: ThemeItem }) { + return ( +
+
+
+ Light desk + + Dark desk + +
+
+ ) +} + +function CursorHoverPreview({ item }: { item: CursorItem }) { + const defaultCursor = renderCursorCssValue(item, 'default') + const pointerCursor = renderCursorCssValue(item, 'pointer') ?? defaultCursor + const textCursor = renderCursorCssValue(item, 'text') ?? pointerCursor + return ( +
+
+ hover box + + +
+
+ ) +} + +function previewAccessoryItem(item: CharacterAccessoryItem): CharacterAccessoryItem { + const choice = + item.characterAccessory.choices?.find( + (entry) => entry.id === item.characterAccessory.defaultChoiceId, + ) ?? item.characterAccessory.choices?.[0] + if (!choice) return item + return { + ...item, + characterAccessory: { + ...item.characterAccessory, + toggles: choice.toggles?.length ? choice.toggles : item.characterAccessory.toggles, + parts: choice.parts?.length ? choice.parts : item.characterAccessory.parts, + anchoredIds: choice.anchoredIds ?? item.characterAccessory.anchoredIds, + }, + } +} + +function CharacterAccessoryPreview({ item }: { item: CharacterAccessoryItem }) { + const previewItem = previewAccessoryItem(item) + return ( +
+ +
+ ) +} + +function ShopItemPreview({ item, preview }: { item: ShopCatalogItem; preview: string | null }) { + if (item.type === 'theme') return + if (item.type === 'cursor') return + if (item.type === 'characterAccessory') return + if (!preview) return null + return ( + + ) +} export function Shoppe({ onScoreChange, @@ -21,20 +148,81 @@ export function Shoppe({ const { settings } = useAppSettings() const pointsOn = !settings.disablePoints const [busy, setBusy] = useState(false) + const [busyItemId, setBusyItemId] = useState(null) const [error, setError] = useState(null) + const [status, setStatus] = useState(null) const [catUrl, setCatUrl] = useState(null) const [catId, setCatId] = useState(null) + const catalog = useMemo(() => loadShopCatalog(), []) + const [inventory, setInventory] = useState(() => loadShopInventory()) const [collection, setCollection] = useState(() => loadCatCollection()) const [selectedCat, setSelectedCat] = useState(null) const [balance, setBalance] = useState(() => getScoreSnapshot().total) useEffect(() => { - setBalance(getScoreSnapshot().total) - }, [collection]) + return subscribeShopInventory((next) => setInventory(next)) + }, []) + + useEffect(() => { + const refreshScore = () => setBalance(getScoreSnapshot().total) + window.addEventListener(SCORE_CHANGED_EVENT, refreshScore) + return () => window.removeEventListener(SCORE_CHANGED_EVENT, refreshScore) + }, []) + + function itemPreview(item: ShopCatalogItem) { + if (item.type === 'stamp') return renderStampPreviewForItem(item) + if (item.type === 'theme' || item.type === 'cursor') return null + if (!item.preview) return null + if (item.preview.startsWith('/')) return publicUrl(item.preview) + return item.preview + } + + function itemOwned(itemId: string) { + return inventory.ownedItemIds.includes(itemId) + } + + function equippedId(type: EquippableType) { + if (type === 'theme') return inventory.equipped.themeId + if (type === 'stamp') return inventory.equipped.stampId + return inventory.equipped.cursorId + } + + function itemTypeLabel(item: ShopCatalogItem) { + if (item.type === 'theme') return 'Theme' + if (item.type === 'stamp') return 'Stamp' + if (item.type === 'cursor') return 'Cursor set' + if (item.type === 'characterAccessory') { + if (item.characterAccessory.scope === 'fullBody') return 'Full-body accessory' + if (item.characterAccessory.scope === 'hair') return 'Hair accessory' + if (item.characterAccessory.scope === 'wrist') return 'Wrist accessory' + if (item.characterAccessory.scope === 'shirt') return 'Shirt accessory' + if (item.characterAccessory.scope === 'shoes') return 'Shoe accessory' + if (item.characterAccessory.scope === 'pants') return 'Pants accessory' + if (item.characterAccessory.scope === 'face') return 'Face accessory' + if (item.characterAccessory.scope === 'hat') return 'Hat accessory' + return 'Pet accessory' + } + return 'Image' + } + + function catalogHint(item: ShopCatalogItem) { + if (item.type === 'theme') return 'Applies different desk/paper styling in light + dark mode.' + if (item.type === 'stamp') return 'Updates the default submit stamp artwork in the send animation.' + if (item.type === 'cursor') return 'Applies custom default, pointer, and text cursors.' + if (item.type === 'characterAccessory') { + return 'Unlocks an accessory for the Character lab.' + } + return 'Collectible image item for future gallery drops.' + } + + function canEquip(item: ShopCatalogItem): item is Extract { + return item.type === 'theme' || item.type === 'stamp' || item.type === 'cursor' + } async function buyCatPhoto() { if (busy || !pointsOn) return setBusy(true) + setStatus(null) setError(null) try { const current = getScoreSnapshot().total @@ -73,6 +261,7 @@ export function Shoppe({ setCollection(addCollectedCat({ id: first.id, url: first.url })) setBalance(getScoreSnapshot().total) onScoreChange(-CAT_COST, 'Cat photo collected') + setStatus('Mystery cat collected.') } catch { setError('Failed to reach cat photo service.') } finally { @@ -80,6 +269,57 @@ export function Shoppe({ } } + async function buyShopItem(item: ShopCatalogItem) { + if (busy || busyItemId || !pointsOn) return + setError(null) + setStatus(null) + setBusyItemId(item.id) + try { + if (itemOwned(item.id)) { + setStatus(`${item.name} is already unlocked.`) + return + } + const current = getScoreSnapshot().total + if (current < item.cost) { + setError(`You need ${item.cost - current} more points for ${item.name}.`) + return + } + const spend = spendScore(item.cost) + if (!spend.ok) { + setError( + spend.reason === 'insufficient' + ? 'Not enough points to complete this purchase.' + : 'Cannot complete purchase due to invalid score state.', + ) + return + } + unlockShopItem(item.id) + if (canEquip(item)) { + equipShopItem(item.type, item.id) + } + setBalance(getScoreSnapshot().total) + onScoreChange(-item.cost, `${item.name} unlocked`) + setStatus( + item.type === 'characterAccessory' + ? `${item.name} unlocked. Customize it in Character lab.` + : canEquip(item) + ? `${item.name} unlocked and equipped.` + : `${item.name} unlocked.`, + ) + } finally { + setBusyItemId(null) + } + } + + function toggleEquip(item: Extract) { + if (!itemOwned(item.id)) return + const current = equippedId(item.type) + const next = current === item.id ? null : item.id + equipShopItem(item.type, next) + setStatus(next ? `${item.name} equipped.` : `${itemTypeLabel(item)} unequipped.`) + setError(null) + } + return (
@@ -93,6 +333,10 @@ export function Shoppe({
collected
{collection.length}
+
+
shop unlocks
+
{inventory.ownedItemIds.length}
+
{pointsOn ? (
balance
@@ -105,6 +349,73 @@ export function Shoppe({ +
+
Customization shelves
+
+ Unlock themes, stamp variants, and cursor sets from a JSON catalog. +
+ {!pointsOn ? ( +
+ Points are turned off in Settings — enable the points system to unlock shop items. +
+ ) : null} +
+ {catalog.items.map((item) => { + const owned = itemOwned(item.id) + const equippable = canEquip(item) + const accessory = item.type === 'characterAccessory' + const equipped = equippable && equippedId(item.type) === item.id + const preview = itemPreview(item) + return ( +
+
+
+
{item.name}
+
{itemTypeLabel(item)}
+
+
{item.cost} pts
+
+
{item.description}
+
{catalogHint(item)}
+ +
+ {!owned ? ( + + ) : equippable ? ( + + ) : ( + + Owned + + )} + {owned ? ( + + {equipped ? 'Equipped' : accessory ? 'Owned' : 'Unlocked'} + + ) : null} +
+
+ ) + })} +
+
+
@@ -136,6 +447,11 @@ export function Shoppe({ {error}
) : null} + {status ? ( +
+ {status} +
+ ) : null}
diff --git a/src/features/shop/shopCatalog.ts b/src/features/shop/shopCatalog.ts new file mode 100644 index 0000000..05c21a4 --- /dev/null +++ b/src/features/shop/shopCatalog.ts @@ -0,0 +1,361 @@ +import catalogJson from '../../../asset/shop/shoppe-items.json?raw' + +export type ShopItemType = 'image' | 'theme' | 'stamp' | 'cursor' | 'characterAccessory' + +type ShopItemBase = { + id: string + type: ShopItemType + name: string + description: string + cost: number + preview?: string +} + +export type ThemePalette = { + deskBg: string + paperBg?: string + paperBorder?: string + accent?: string + overlay?: string +} + +export type ThemeItem = ShopItemBase & { + type: 'theme' + theme: { + light: ThemePalette + dark: ThemePalette + } +} + +export type StampItem = ShopItemBase & { + type: 'stamp' + stamp: { + text: string + ink: string + outline: string + textColor?: string + tiltDeg?: number + opacity?: number + } +} + +export type CursorItem = ShopItemBase & { + type: 'cursor' + cursor: { + primary: string + secondary: string + outline: string + textPrimary?: string + hotspot?: { + default?: [number, number] + pointer?: [number, number] + text?: [number, number] + } + } +} + +export type ImageItem = ShopItemBase & { + type: 'image' + image: { + url: string + } +} + +export type CharacterAccessoryItem = ShopItemBase & { + type: 'characterAccessory' + characterAccessory: { + scope: 'pet' | 'wrist' | 'hair' | 'fullBody' | 'shirt' | 'shoes' | 'pants' | 'face' | 'hat' + exclusiveGroup?: string + toggle?: { + groupKey: string + optionId: string + } + toggles?: { + groupKey: string + optionIds: string[] + }[] + parts?: string[] + fillKeys?: string[] + hideSkin?: boolean + hideBaseClothes?: boolean + anchoredIds?: { + armL?: string[] + armR?: string[] + } + choices?: { + id: string + label: string + toggles?: { + groupKey: string + optionIds: string[] + }[] + parts?: string[] + anchoredIds?: { + armL?: string[] + armR?: string[] + } + }[] + defaultChoiceId?: string + } +} + +export type ShopCatalogItem = + | ThemeItem + | StampItem + | CursorItem + | ImageItem + | CharacterAccessoryItem + +export type ShopCatalog = { + version: number + items: ShopCatalogItem[] +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + return value as Record +} + +function asString(value: unknown, fallback = '') { + return typeof value === 'string' ? value : fallback +} + +function asNumber(value: unknown, fallback = 0) { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function asTuple(value: unknown): [number, number] | undefined { + if (!Array.isArray(value) || value.length !== 2) return undefined + const first = Number(value[0]) + const second = Number(value[1]) + if (!Number.isFinite(first) || !Number.isFinite(second)) return undefined + return [Math.trunc(first), Math.trunc(second)] +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((row): row is string => typeof row === 'string' && row.trim().length > 0) +} + +function parseThemePalette(value: unknown): ThemePalette { + const row = asRecord(value) + if (!row) return { deskBg: '' } + return { + deskBg: asString(row.deskBg), + paperBg: asString(row.paperBg) || undefined, + paperBorder: asString(row.paperBorder) || undefined, + accent: asString(row.accent) || undefined, + overlay: asString(row.overlay) || undefined, + } +} + +function parseItem(value: unknown): ShopCatalogItem | null { + const row = asRecord(value) + if (!row) return null + const type = asString(row.type) as ShopItemType + const base: ShopItemBase = { + id: asString(row.id), + type, + name: asString(row.name), + description: asString(row.description), + cost: Math.max(0, Math.trunc(asNumber(row.cost))), + preview: asString(row.preview) || undefined, + } + if (!base.id || !base.name || !base.description) return null + if (type === 'theme') { + const theme = asRecord(row.theme) + if (!theme) return null + const parsed: ThemeItem = { + ...base, + type: 'theme', + theme: { + light: parseThemePalette(theme.light), + dark: parseThemePalette(theme.dark), + }, + } + if (!parsed.theme.light.deskBg || !parsed.theme.dark.deskBg) return null + return parsed + } + if (type === 'stamp') { + const stamp = asRecord(row.stamp) + if (!stamp) return null + const parsed: StampItem = { + ...base, + type: 'stamp', + stamp: { + text: asString(stamp.text), + ink: asString(stamp.ink), + outline: asString(stamp.outline), + textColor: asString(stamp.textColor) || undefined, + tiltDeg: asNumber(stamp.tiltDeg), + opacity: asNumber(stamp.opacity), + }, + } + if (!parsed.stamp.text || !parsed.stamp.ink || !parsed.stamp.outline) return null + return parsed + } + if (type === 'cursor') { + const cursor = asRecord(row.cursor) + if (!cursor) return null + const hotspot = asRecord(cursor.hotspot) + const parsed: CursorItem = { + ...base, + type: 'cursor', + cursor: { + primary: asString(cursor.primary), + secondary: asString(cursor.secondary), + outline: asString(cursor.outline), + textPrimary: asString(cursor.textPrimary) || undefined, + hotspot: { + default: asTuple(hotspot?.default), + pointer: asTuple(hotspot?.pointer), + text: asTuple(hotspot?.text), + }, + }, + } + if (!parsed.cursor.primary || !parsed.cursor.secondary || !parsed.cursor.outline) return null + return parsed + } + if (type === 'image') { + const image = asRecord(row.image) + if (!image) return null + const parsed: ImageItem = { + ...base, + type: 'image', + image: { url: asString(image.url) }, + } + if (!parsed.image.url) return null + return parsed + } + if (type === 'characterAccessory') { + const accessory = asRecord(row.characterAccessory) + if (!accessory) return null + const scope = asString(accessory.scope) + if ( + scope !== 'pet' && + scope !== 'wrist' && + scope !== 'hair' && + scope !== 'fullBody' && + scope !== 'shirt' && + scope !== 'shoes' && + scope !== 'pants' && + scope !== 'face' && + scope !== 'hat' + ) { + return null + } + const toggle = asRecord(accessory.toggle) + const toggles = Array.isArray(accessory.toggles) ? accessory.toggles : [] + const choices = Array.isArray(accessory.choices) ? accessory.choices : [] + const anchoredIds = asRecord(accessory.anchoredIds) + const parsed: CharacterAccessoryItem = { + ...base, + type: 'characterAccessory', + characterAccessory: { + scope, + exclusiveGroup: asString(accessory.exclusiveGroup) || undefined, + toggle: + toggle && asString(toggle.groupKey) && asString(toggle.optionId) + ? { + groupKey: asString(toggle.groupKey), + optionId: asString(toggle.optionId), + } + : undefined, + toggles: toggles + .map((entry) => { + const toggleRow = asRecord(entry) + return toggleRow && asString(toggleRow.groupKey) + ? { + groupKey: asString(toggleRow.groupKey), + optionIds: asStringArray(toggleRow.optionIds), + } + : null + }) + .filter( + (entry): entry is { groupKey: string; optionIds: string[] } => + Boolean(entry && entry.optionIds.length > 0), + ), + parts: asStringArray(accessory.parts), + fillKeys: asStringArray(accessory.fillKeys), + hideSkin: accessory.hideSkin === true, + hideBaseClothes: accessory.hideBaseClothes === true, + anchoredIds: { + armL: asStringArray(anchoredIds?.armL), + armR: asStringArray(anchoredIds?.armR), + }, + choices: choices + .map((entry) => { + const choice = asRecord(entry) + if (!choice || !asString(choice.id) || !asString(choice.label)) return null + const choiceAnchors = asRecord(choice.anchoredIds) + const choiceToggles = Array.isArray(choice.toggles) ? choice.toggles : [] + return { + id: asString(choice.id), + label: asString(choice.label), + toggles: choiceToggles + .map((toggleEntry) => { + const toggleRow = asRecord(toggleEntry) + return toggleRow && asString(toggleRow.groupKey) + ? { + groupKey: asString(toggleRow.groupKey), + optionIds: asStringArray(toggleRow.optionIds), + } + : null + }) + .filter( + (row): row is { groupKey: string; optionIds: string[] } => + Boolean(row && row.optionIds.length > 0), + ), + parts: asStringArray(choice.parts), + anchoredIds: { + armL: asStringArray(choiceAnchors?.armL), + armR: asStringArray(choiceAnchors?.armR), + }, + } + }) + .filter( + ( + choice, + ): choice is { + id: string + label: string + toggles: { groupKey: string; optionIds: string[] }[] + parts: string[] + anchoredIds: { armL: string[]; armR: string[] } + } => choice !== null, + ), + defaultChoiceId: asString(accessory.defaultChoiceId) || undefined, + }, + } + if ( + !parsed.characterAccessory.toggle && + !parsed.characterAccessory.toggles?.length && + !parsed.characterAccessory.parts?.length + ) { + return null + } + return parsed + } + return null +} + +let catalogCache: ShopCatalog | null = null + +export function loadShopCatalog(): ShopCatalog { + if (catalogCache) return catalogCache + let parsed: unknown + try { + parsed = JSON.parse(catalogJson) + } catch { + catalogCache = { version: 1, items: [] } + return catalogCache + } + const root = asRecord(parsed) + const rawItems = Array.isArray(root?.items) ? root.items : [] + const items = rawItems.map(parseItem).filter((row): row is ShopCatalogItem => row !== null) + catalogCache = { + version: Math.max(1, Math.trunc(asNumber(root?.version, 1))), + items, + } + return catalogCache +} diff --git a/src/features/shop/shopCharacterAccessories.ts b/src/features/shop/shopCharacterAccessories.ts new file mode 100644 index 0000000..c8fc54d --- /dev/null +++ b/src/features/shop/shopCharacterAccessories.ts @@ -0,0 +1,86 @@ +import { useEffect, useMemo, useState } from 'react' +import { + loadShopCatalog, + type CharacterAccessoryItem, + type ShopCatalogItem, +} from './shopCatalog' +import { + loadShopInventory, + subscribeShopInventory, + type ShopInventory, +} from './shopInventory' + +function isCharacterAccessory(item: ShopCatalogItem): item is CharacterAccessoryItem { + return item.type === 'characterAccessory' +} + +function accessoryExclusiveKey(item: CharacterAccessoryItem) { + return ( + item.characterAccessory.exclusiveGroup ?? + item.characterAccessory.toggle?.groupKey ?? + item.characterAccessory.scope + ) +} + +export function exclusiveAccessoryIdsFor( + item: CharacterAccessoryItem, + items = loadShopCatalog().items, +) { + const key = accessoryExclusiveKey(item) + return items + .filter(isCharacterAccessory) + .filter((entry) => entry.id !== item.id && accessoryExclusiveKey(entry) === key) + .map((entry) => entry.id) +} + +export function equippedCharacterAccessories( + inventory: ShopInventory, + items = loadShopCatalog().items, +) { + const owned = new Set(inventory.ownedItemIds) + const equipped = new Set(inventory.equipped.characterAccessoryIds) + return items + .filter(isCharacterAccessory) + .filter((item) => owned.has(item.id) && equipped.has(item.id)) + .map((item) => applyAccessoryChoice(item, inventory)) +} + +export function accessoryChoiceId(item: CharacterAccessoryItem, inventory: ShopInventory) { + return ( + inventory.equipped.characterAccessoryChoices[item.id] ?? + item.characterAccessory.defaultChoiceId ?? + item.characterAccessory.choices?.[0]?.id ?? + '' + ) +} + +export function applyAccessoryChoice( + item: CharacterAccessoryItem, + inventory: ShopInventory, +): CharacterAccessoryItem { + const choiceId = accessoryChoiceId(item, inventory) + const choice = item.characterAccessory.choices?.find((entry) => entry.id === choiceId) + if (!choice) return item + return { + ...item, + characterAccessory: { + ...item.characterAccessory, + toggles: choice.toggles?.length ? choice.toggles : item.characterAccessory.toggles, + parts: choice.parts?.length ? choice.parts : item.characterAccessory.parts, + anchoredIds: choice.anchoredIds ?? item.characterAccessory.anchoredIds, + }, + } +} + +export function useEquippedCharacterAccessories(enabled = true) { + const catalog = useMemo(() => loadShopCatalog(), []) + const [inventory, setInventory] = useState(() => loadShopInventory()) + useEffect(() => { + if (!enabled) return undefined + return subscribeShopInventory((next) => setInventory(next)) + }, [enabled]) + return useMemo( + () => (enabled ? equippedCharacterAccessories(inventory, catalog.items) : []), + [catalog.items, enabled, inventory], + ) +} diff --git a/src/features/shop/shopCosmetics.tsx b/src/features/shop/shopCosmetics.tsx new file mode 100644 index 0000000..d97a88c --- /dev/null +++ b/src/features/shop/shopCosmetics.tsx @@ -0,0 +1,95 @@ +import { useEffect, useMemo, useState } from 'react' +import { useTheme } from '../../shared/theme/useTheme' +import { + loadShopCatalog, + type CursorItem, + type ShopCatalogItem, + type ThemeItem, +} from './shopCatalog' +import { renderCursorCssValue } from './shopCursorAsset' +import { + loadShopInventory, + subscribeShopInventory, + type ShopInventory, +} from './shopInventory' + +function findEquippedItem( + items: ShopCatalogItem[], + itemType: T['type'], + id: string | null, +): T | null { + if (!id) return null + const item = items.find((entry) => entry.id === id && entry.type === itemType) + return (item as T | undefined) ?? null +} + +function setThemeCssVar(name: string, value?: string) { + if (value) document.documentElement.style.setProperty(name, value) + else document.documentElement.style.removeProperty(name) +} + +function applyThemeCosmetics(mode: 'light' | 'dark', themeItem: ThemeItem | null) { + if (!themeItem) { + setThemeCssVar('--desk-bg') + setThemeCssVar('--paper-bg') + setThemeCssVar('--paper-border') + setThemeCssVar('--accent') + setThemeCssVar('--shop-theme-overlay') + return + } + const palette = mode === 'dark' ? themeItem.theme.dark : themeItem.theme.light + setThemeCssVar('--desk-bg', palette.deskBg) + setThemeCssVar('--paper-bg', palette.paperBg) + setThemeCssVar('--paper-border', palette.paperBorder) + setThemeCssVar('--accent', palette.accent) + setThemeCssVar('--shop-theme-overlay', palette.overlay) +} + +function applyCursorCosmetics(cursorItem: CursorItem | null) { + if (!cursorItem) { + document.documentElement.style.removeProperty('--shop-cursor-default') + document.documentElement.style.removeProperty('--shop-cursor-pointer') + document.documentElement.style.removeProperty('--shop-cursor-text') + return + } + const base = renderCursorCssValue(cursorItem, 'default') + const pointer = renderCursorCssValue(cursorItem, 'pointer') + const text = renderCursorCssValue(cursorItem, 'text') + if (base) document.documentElement.style.setProperty('--shop-cursor-default', base) + if (pointer) document.documentElement.style.setProperty('--shop-cursor-pointer', pointer) + if (text) document.documentElement.style.setProperty('--shop-cursor-text', text) +} + +function useShopInventoryState() { + const [inventory, setInventory] = useState(() => loadShopInventory()) + useEffect(() => subscribeShopInventory((next) => setInventory(next)), []) + return inventory +} + +function useShopCatalogState() { + return useMemo(() => loadShopCatalog(), []) +} + +export function ShopCosmeticEffects() { + const { mode } = useTheme() + const catalog = useShopCatalogState() + const inventory = useShopInventoryState() + const equippedTheme = useMemo( + () => findEquippedItem(catalog.items, 'theme', inventory.equipped.themeId), + [catalog.items, inventory.equipped.themeId], + ) + const equippedCursor = useMemo( + () => findEquippedItem(catalog.items, 'cursor', inventory.equipped.cursorId), + [catalog.items, inventory.equipped.cursorId], + ) + + useEffect(() => { + applyThemeCosmetics(mode, equippedTheme) + }, [mode, equippedTheme]) + + useEffect(() => { + applyCursorCosmetics(equippedCursor) + }, [equippedCursor]) + + return null +} diff --git a/src/features/shop/shopCursorAsset.ts b/src/features/shop/shopCursorAsset.ts new file mode 100644 index 0000000..5703052 --- /dev/null +++ b/src/features/shop/shopCursorAsset.ts @@ -0,0 +1,131 @@ +import pointerTemplateSvg from '../../../asset/shop/pointer.svg?raw' +import type { CursorItem } from './shopCatalog' + +export type CursorContext = 'default' | 'pointer' | 'text' + +const FALLBACK_HOTSPOT: Record = { + default: [3, 3], + pointer: [3, 3], + text: [7, 12], +} +const CURSOR_RENDER_SIZE = '24' + +function serializeSvgElement(svg: SVGSVGElement) { + const raw = new XMLSerializer().serializeToString(svg) + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(raw)}` +} + +function hintedContext(hint: string | undefined | null): CursorContext | null { + const normalized = hint?.trim().toLowerCase() + if (!normalized) return null + if (normalized.includes('point') || normalized.includes('pointer')) return 'pointer' + if (normalized.includes('text')) return 'text' + if (normalized.includes('default') || normalized.includes('base')) return 'default' + return null +} + +function hintForElement(el: SVGElement): string { + const dataContext = el.getAttribute('data-context') ?? '' + const inkLabel = el.getAttribute('inkscape:label') ?? '' + return `${dataContext} ${inkLabel}`.trim() +} + +function applyLegacyContextLayers(svg: SVGSVGElement, context: CursorContext): boolean { + const contextLayers = svg.querySelectorAll('g[data-context]') + if (!contextLayers.length) return false + contextLayers.forEach((layer) => { + const active = layer.dataset.context === context + layer.style.display = active ? 'inline' : 'none' + }) + return true +} + +function applyHintedContextVisibility(svg: SVGSVGElement, context: CursorContext) { + const drawables = Array.from( + svg.querySelectorAll('path,rect,circle,ellipse,polygon,polyline'), + ) + if (!drawables.length) return + + let foundContextHint = false + drawables.forEach((el) => { + const hinted = hintedContext(hintForElement(el)) + if (!hinted) return + foundContextHint = true + el.style.display = hinted === context ? 'inline' : 'none' + }) + if (!foundContextHint) return + + if (context === 'default') { + const explicitDefault = drawables.find((el) => hintedContext(hintForElement(el)) === 'default') + const outlineFallback = drawables.find((el) => el.dataset.fill === 'outline') + const unlabeledFallback = drawables.find((el) => hintedContext(hintForElement(el)) === null) + const active = explicitDefault ?? outlineFallback ?? unlabeledFallback + if (active) active.style.display = 'inline' + return + } + + drawables.forEach((el) => { + if (hintedContext(hintForElement(el)) === null) { + el.style.display = 'none' + } + }) +} + +function colorForContext(item: CursorItem, context: CursorContext) { + if (context === 'text') return item.cursor.textPrimary ?? item.cursor.primary + if (context === 'default') return item.cursor.outline + return item.cursor.primary +} + +function applyCursorColors(svg: SVGSVGElement, item: CursorItem, context: CursorContext) { + const fills = svg.querySelectorAll('[data-fill]') + fills.forEach((el) => { + const role = el.dataset.fill + if (role === 'primary') el.style.fill = item.cursor.primary + if (role === 'secondary') el.style.fill = item.cursor.secondary + if (role === 'outline') el.style.fill = item.cursor.outline + if (role === 'text') el.style.fill = item.cursor.textPrimary ?? item.cursor.primary + }) + + const strokes = svg.querySelectorAll('[data-stroke]') + strokes.forEach((el) => { + const role = el.dataset.stroke + if (role === 'outline') el.style.stroke = item.cursor.outline + if (role === 'primary') el.style.stroke = item.cursor.primary + }) + + // New pointer assets do not need data-fill/data-stroke attributes; + // tint unlabeled geometry from context to keep previews and live cursors aligned. + const drawables = svg.querySelectorAll('path,rect,circle,ellipse,polygon,polyline') + drawables.forEach((el) => { + if (el.dataset.fill || el.dataset.stroke) return + const fillAttr = el.getAttribute('fill')?.toLowerCase() + if (fillAttr !== 'none') { + el.style.fill = colorForContext(item, context) + } + const strokeAttr = el.getAttribute('stroke')?.toLowerCase() + if (strokeAttr && strokeAttr !== 'none') { + el.style.stroke = item.cursor.outline + } + }) +} + +export function renderCursorCssValue(item: CursorItem, context: CursorContext): string | null { + const doc = new DOMParser().parseFromString(pointerTemplateSvg, 'image/svg+xml') + const svg = doc.documentElement + if (!(svg instanceof SVGSVGElement)) return null + + svg.setAttribute('width', CURSOR_RENDER_SIZE) + svg.setAttribute('height', CURSOR_RENDER_SIZE) + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet') + + const usedLegacyContext = applyLegacyContextLayers(svg, context) + if (!usedLegacyContext) { + applyHintedContextVisibility(svg, context) + } + applyCursorColors(svg, item, context) + + const hotspot = item.cursor.hotspot?.[context] + const [hx, hy] = hotspot ?? FALLBACK_HOTSPOT[context] + return `url("${serializeSvgElement(svg)}") ${hx} ${hy}, auto` +} diff --git a/src/features/shop/shopInventory.ts b/src/features/shop/shopInventory.ts new file mode 100644 index 0000000..91f2fa6 --- /dev/null +++ b/src/features/shop/shopInventory.ts @@ -0,0 +1,192 @@ +import { scopedStorageKey } from '../../shared/storage/storageScope' +import { notifyLocalDataChanged } from '../../shared/sync/localDataEvents' + +const SHOP_INVENTORY_KEY = scopedStorageKey('mentell.shop.inventory') +export const SHOP_INVENTORY_CHANGED_EVENT = 'mentell.shop.inventory.changed' + +export type EquippedShopItems = { + themeId: string | null + stampId: string | null + cursorId: string | null + characterAccessoryIds: string[] + characterAccessoryChoices: Record +} + +export type ShopInventory = { + ownedItemIds: string[] + equipped: EquippedShopItems + updatedAt: number +} + +type WriteOptions = { + notifySync?: boolean + preserveUpdatedAt?: boolean +} + +const DEFAULT_SHOP_INVENTORY: ShopInventory = { + ownedItemIds: [], + equipped: { + themeId: null, + stampId: null, + cursorId: null, + characterAccessoryIds: [], + characterAccessoryChoices: {}, + }, + updatedAt: 0, +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + return value as Record +} + +function sanitizeInventory(input: unknown): ShopInventory { + const row = asRecord(input) + const equippedRow = asRecord(row?.equipped) + const owned = Array.isArray(row?.ownedItemIds) ? row?.ownedItemIds : [] + const ownedItemIds = [...new Set(owned.filter((id): id is string => typeof id === 'string'))] + const updatedAtRaw = Number(row?.updatedAt) + return { + ownedItemIds, + equipped: { + themeId: typeof equippedRow?.themeId === 'string' ? equippedRow.themeId : null, + stampId: typeof equippedRow?.stampId === 'string' ? equippedRow.stampId : null, + cursorId: typeof equippedRow?.cursorId === 'string' ? equippedRow.cursorId : null, + characterAccessoryIds: Array.isArray(equippedRow?.characterAccessoryIds) + ? [ + ...new Set( + equippedRow.characterAccessoryIds.filter( + (id): id is string => typeof id === 'string', + ), + ), + ] + : [], + characterAccessoryChoices: Object.fromEntries( + Object.entries(asRecord(equippedRow?.characterAccessoryChoices) ?? {}).filter( + (entry): entry is [string, string] => + typeof entry[0] === 'string' && typeof entry[1] === 'string', + ), + ), + }, + updatedAt: Number.isFinite(updatedAtRaw) ? Math.max(0, Math.trunc(updatedAtRaw)) : 0, + } +} + +function emitInventoryChanged(next: ShopInventory) { + window.dispatchEvent(new CustomEvent(SHOP_INVENTORY_CHANGED_EVENT, { detail: next })) +} + +function writeInventory(next: ShopInventory, options?: WriteOptions): ShopInventory { + const notifySync = options?.notifySync ?? true + const preserveUpdatedAt = options?.preserveUpdatedAt ?? false + const normalized: ShopInventory = { + ...next, + updatedAt: preserveUpdatedAt ? next.updatedAt : Date.now(), + } + localStorage.setItem(SHOP_INVENTORY_KEY, JSON.stringify(normalized)) + emitInventoryChanged(normalized) + if (notifySync) notifyLocalDataChanged() + return normalized +} + +export function loadShopInventory(): ShopInventory { + try { + const raw = localStorage.getItem(SHOP_INVENTORY_KEY) + if (!raw) return { ...DEFAULT_SHOP_INVENTORY } + return sanitizeInventory(JSON.parse(raw)) + } catch { + return { ...DEFAULT_SHOP_INVENTORY } + } +} + +export function updateShopInventory( + updater: (current: ShopInventory) => ShopInventory, + options?: WriteOptions, +) { + const current = loadShopInventory() + const next = sanitizeInventory(updater(current)) + return writeInventory(next, options) +} + +export function unlockShopItem(itemId: string) { + const clean = itemId.trim() + if (!clean) return loadShopInventory() + return updateShopInventory((current) => { + if (current.ownedItemIds.includes(clean)) return current + return { ...current, ownedItemIds: [...current.ownedItemIds, clean] } + }) +} + +export function equipShopItem(kind: 'theme' | 'stamp' | 'cursor', itemId: string | null) { + const clean = itemId?.trim() || null + return updateShopInventory((current) => ({ + ...current, + equipped: { + ...current.equipped, + ...(kind === 'theme' ? { themeId: clean } : null), + ...(kind === 'stamp' ? { stampId: clean } : null), + ...(kind === 'cursor' ? { cursorId: clean } : null), + }, + })) +} + +export function equipCharacterAccessoryItem( + itemId: string, + options?: { exclusiveWith?: string[] }, +) { + const clean = itemId.trim() + if (!clean) return loadShopInventory() + const exclusiveWith = new Set(options?.exclusiveWith ?? []) + return updateShopInventory((current) => { + const currentlyEquipped = current.equipped.characterAccessoryIds.includes(clean) + const nextIds = currentlyEquipped + ? current.equipped.characterAccessoryIds.filter((id) => id !== clean) + : [ + ...current.equipped.characterAccessoryIds.filter((id) => !exclusiveWith.has(id)), + clean, + ] + return { + ...current, + equipped: { + ...current.equipped, + characterAccessoryIds: nextIds, + }, + } + }) +} + +export function setCharacterAccessoryChoice(itemId: string, choiceId: string) { + const cleanItem = itemId.trim() + const cleanChoice = choiceId.trim() + if (!cleanItem || !cleanChoice) return loadShopInventory() + return updateShopInventory((current) => ({ + ...current, + equipped: { + ...current.equipped, + characterAccessoryChoices: { + ...current.equipped.characterAccessoryChoices, + [cleanItem]: cleanChoice, + }, + }, + })) +} + +export function applyShopInventoryFromCloud(input: unknown) { + const parsed = sanitizeInventory(input) + return writeInventory(parsed, { notifySync: false, preserveUpdatedAt: true }) +} + +export function clearShopInventory() { + localStorage.removeItem(SHOP_INVENTORY_KEY) + const cleared = { ...DEFAULT_SHOP_INVENTORY } + emitInventoryChanged(cleared) +} + +export function subscribeShopInventory(cb: (inventory: ShopInventory) => void) { + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail + cb(detail ?? loadShopInventory()) + } + window.addEventListener(SHOP_INVENTORY_CHANGED_EVENT, handler) + return () => window.removeEventListener(SHOP_INVENTORY_CHANGED_EVENT, handler) +} diff --git a/src/features/shop/shopStampAsset.ts b/src/features/shop/shopStampAsset.ts new file mode 100644 index 0000000..a757ddc --- /dev/null +++ b/src/features/shop/shopStampAsset.ts @@ -0,0 +1,144 @@ +import { useEffect, useMemo, useState } from 'react' +import stampTemplateSvg from '../../../asset/shop/stamp.svg?raw' +import { loadShopCatalog, type StampItem } from './shopCatalog' +import { loadShopInventory, subscribeShopInventory, type ShopInventory } from './shopInventory' + +const DEFAULT_STAMP_TEXT = 'STAMP' +const DEFAULT_STAMP_INK = '#c61d1d' +const DEFAULT_STAMP_OUTLINE = '#9e1717' +const DEFAULT_STAMP_TEXT_COLOR = '#9e1717' +const DEFAULT_STAMP_SRC = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(stampTemplateSvg)}` +const DEFAULT_STAMP_ROTATION = -12 +const DEFAULT_STAMP_OPACITY = 0.88 + +export type EquippedStampAsset = { + src: string + isCustom: boolean + text: string + ink: string + outline: string + textColor: string +} + +function serializeSvgElement(svg: SVGSVGElement) { + const raw = new XMLSerializer().serializeToString(svg) + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(raw)}` +} + +function clampNumber(value: number | undefined, fallback: number, min: number, max: number) { + if (!Number.isFinite(value)) return fallback + return Math.min(max, Math.max(min, value as number)) +} + +function normalizedStampText(value: string) { + const clean = value.replace(/\s+/g, ' ').trim().toUpperCase() + if (!clean) return DEFAULT_STAMP_TEXT + return clean.slice(0, 20) +} + +function escapeXmlText(value: string) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') +} + +function svgElementById(doc: Document, id: string) { + const el = doc.getElementById(id) + return el instanceof SVGElement ? el : null +} + +function renderSimpleStampDataUri(item: StampItem): string { + const text = escapeXmlText(normalizedStampText(item.stamp.text)) + const textColor = item.stamp.textColor ?? item.stamp.outline + const tilt = clampNumber(item.stamp.tiltDeg, DEFAULT_STAMP_ROTATION, -36, 36) + const opacity = clampNumber(item.stamp.opacity, DEFAULT_STAMP_OPACITY, 0.32, 1) + const viewBox = '0 0 256 256' + const svg = `` + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` +} + +function renderLegacyTemplateDataUri(item: StampItem, doc: Document): string { + const svg = doc.documentElement + if (!(svg instanceof SVGSVGElement)) return renderSimpleStampDataUri(item) + + const stampRoot = svgElementById(doc, 'stamp-root') + const border = svgElementById(doc, 'stamp-border') + const inner = svgElementById(doc, 'stamp-inner') + const text = svgElementById(doc, 'stamp-text') + const tilt = clampNumber(item.stamp.tiltDeg, DEFAULT_STAMP_ROTATION, -36, 36) + const opacity = clampNumber(item.stamp.opacity, DEFAULT_STAMP_OPACITY, 0.32, 1) + + if (stampRoot) { + stampRoot.setAttribute('transform', `rotate(${tilt} 128 128)`) + stampRoot.style.opacity = String(opacity) + } + if (border) { + border.style.stroke = item.stamp.outline + border.style.fill = 'none' + } + if (inner) { + inner.style.stroke = item.stamp.ink + inner.style.fill = 'none' + inner.style.strokeDasharray = '8 7' + } + if (text) { + text.style.fill = item.stamp.textColor ?? item.stamp.outline + text.setAttribute('font-size', '84') + text.setAttribute('letter-spacing', '2.8') + text.textContent = normalizedStampText(item.stamp.text) + } + return serializeSvgElement(svg) +} + +function renderStampDataUri(item: StampItem): string { + const doc = new DOMParser().parseFromString(stampTemplateSvg, 'image/svg+xml') + const svg = doc.documentElement + if (!(svg instanceof SVGSVGElement)) return renderSimpleStampDataUri(item) + + const hasLegacyTargets = Boolean( + svgElementById(doc, 'stamp-root') && + svgElementById(doc, 'stamp-border') && + svgElementById(doc, 'stamp-inner') && + svgElementById(doc, 'stamp-text'), + ) + if (hasLegacyTargets) return renderLegacyTemplateDataUri(item, doc) + return renderSimpleStampDataUri(item) +} + +function findEquippedStamp(inventory: ShopInventory): StampItem | null { + const stampId = inventory.equipped.stampId + if (!stampId) return null + const item = loadShopCatalog().items.find((entry) => entry.id === stampId && entry.type === 'stamp') + return (item as StampItem | undefined) ?? null +} + +export function renderStampPreviewForItem(item: StampItem) { + return renderStampDataUri(item) +} + +export function useEquippedStampAsset(): EquippedStampAsset { + const [inventory, setInventory] = useState(() => loadShopInventory()) + useEffect(() => subscribeShopInventory((next) => setInventory(next)), []) + return useMemo(() => { + const equipped = findEquippedStamp(inventory) + if (!equipped) { + return { + src: DEFAULT_STAMP_SRC, + isCustom: false, + text: DEFAULT_STAMP_TEXT, + ink: DEFAULT_STAMP_INK, + outline: DEFAULT_STAMP_OUTLINE, + textColor: DEFAULT_STAMP_TEXT_COLOR, + } + } + return { + src: renderStampDataUri(equipped), + isCustom: true, + text: normalizedStampText(equipped.stamp.text), + ink: equipped.stamp.ink, + outline: equipped.stamp.outline, + textColor: equipped.stamp.textColor ?? equipped.stamp.outline, + } + }, [inventory]) +} diff --git a/src/index.css b/src/index.css index 6e3bfa8..3d82877 100644 --- a/src/index.css +++ b/src/index.css @@ -20,6 +20,10 @@ --pill-neg-bg: rgba(198, 29, 29, 0.12); --pill-neg-border: rgba(198, 29, 29, 0.45); --pill-neg-ink: #8b1818; + --shop-theme-overlay: none; + --shop-cursor-default: auto; + --shop-cursor-pointer: pointer; + --shop-cursor-text: text; color-scheme: light; background: var(--desk-bg); @@ -54,6 +58,7 @@ body { margin: 0; min-height: 100svh; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + cursor: var(--shop-cursor-default); } #root { @@ -62,7 +67,31 @@ body { .desk { min-height: 100svh; - background: var(--desk-bg); + background: + var(--shop-theme-overlay), + var(--desk-bg); + background-attachment: fixed; +} + +button, +[role='button'], +a, +summary, +input[type='checkbox'], +input[type='radio'], +input[type='range'], +select { + cursor: var(--shop-cursor-pointer); +} + +input[type='text'], +input[type='email'], +input[type='search'], +input[type='password'], +input[type='url'], +input[type='number'], +textarea { + cursor: var(--shop-cursor-text); } .paper { diff --git a/src/shared/account/accountDataService.ts b/src/shared/account/accountDataService.ts index c7691cb..7738d2c 100644 --- a/src/shared/account/accountDataService.ts +++ b/src/shared/account/accountDataService.ts @@ -1,6 +1,8 @@ import { deleteUser } from 'firebase/auth' import { collection, deleteDoc, doc, getDocs } from 'firebase/firestore' import { getDb } from '../../db/schema' +import { clearCharacterAppearance } from '../../features/character/characterAppearanceService' +import { clearShopInventory } from '../../features/shop/shopInventory' import { formatShareCode } from '../../features/share/shareLinkUrl' import { SCORE_CHANGED_EVENT } from '../../features/score/scoreEvents' import { getFirebaseAuth, getFirebaseFirestore } from '../firebase/firebaseApp' @@ -32,6 +34,8 @@ export async function clearLocalJournalData() { getDb().stickies.clear(), getDb().packages.clear(), ]) + await clearCharacterAppearance() + clearShopInventory() for (const key of SCORE_KEYS) localStorage.removeItem(key) window.dispatchEvent(new CustomEvent(SCORE_CHANGED_EVENT)) } @@ -54,7 +58,14 @@ export async function deleteCloudAccount(uid: string) { deleteSubcollection(uid, 'shareLinks'), ]) - const metaIds = ['score', 'settings', 'aiProfile', 'shopCats'] as const + const metaIds = [ + 'score', + 'settings', + 'aiProfile', + 'shopCats', + 'shopInventory', + 'characterAppearance', + ] as const await Promise.all( metaIds.map((id) => deleteDoc(doc(fs(), 'users', uid, 'meta', id)).catch(() => {})), ) @@ -65,6 +76,7 @@ export async function deleteAccount(uid: string) { await clearLocalJournalData() localStorage.removeItem(scopedStorageKey('mentell.ai.profile')) localStorage.removeItem(scopedStorageKey('mentell.shop.cats')) + localStorage.removeItem(scopedStorageKey('mentell.shop.inventory')) disableSync() saveSyncState({ enabled: false, lastSyncedAt: null, lastError: null }) @@ -80,6 +92,7 @@ export async function deleteAccount(uid: string) { if (code === 'auth/requires-recent-login') { throw new Error( 'Google needs a fresh sign-in to delete your account. Sign out, sign in again, then retry.', + { cause: e }, ) } throw e diff --git a/src/shared/sync/syncService.ts b/src/shared/sync/syncService.ts index 71f0cd7..3d60709 100644 --- a/src/shared/sync/syncService.ts +++ b/src/shared/sync/syncService.ts @@ -25,6 +25,11 @@ import { loadAppSettings, type AppSettings } from '../settings/appSettings' import { getScoreSnapshot } from '../../features/score/scoreService' import { loadAiProfile, type AiProfile } from '../../features/compilation/aiProfile' import { loadCatCollection } from '../../features/shop/catCollection' +import { + CHARACTER_APPEARANCE_ROW_ID, + applyCharacterAppearanceFromCloud, +} from '../../features/character/characterAppearanceService' +import { loadShopInventory, applyShopInventoryFromCloud } from '../../features/shop/shopInventory' import { scheduleSharePayloadRefresh } from '../../features/share/shareRefresh' import { LOCAL_DATA_CHANGED_EVENT } from './localDataEvents' @@ -136,6 +141,57 @@ async function pullMeta(uid: string) { localStorage.setItem(scopedStorageKey('mentell.shop.cats'), JSON.stringify(data.cats)) } } + + const characterSnap = await getDoc(userRef(uid, 'meta', 'characterAppearance')) + if (characterSnap.exists()) { + const data = characterSnap.data() as { + appearance?: { fills?: Record; toggles?: Record } + updatedAt?: number + } + const remoteUpdatedAt = + typeof data.updatedAt === 'number' && Number.isFinite(data.updatedAt) + ? Math.trunc(data.updatedAt) + : 0 + const local = await getDb().characterAppearance.get(CHARACTER_APPEARANCE_ROW_ID) + const localUpdatedAt = local?.updatedAt ?? 0 + if ( + data.appearance && + typeof data.appearance === 'object' && + remoteUpdatedAt >= localUpdatedAt + ) { + const fills = + data.appearance.fills && typeof data.appearance.fills === 'object' + ? data.appearance.fills + : {} + const toggles = + data.appearance.toggles && typeof data.appearance.toggles === 'object' + ? data.appearance.toggles + : {} + await getDb().characterAppearance.put({ + id: CHARACTER_APPEARANCE_ROW_ID, + updatedAt: remoteUpdatedAt, + fills, + toggles, + }) + applyCharacterAppearanceFromCloud({ fills, toggles }) + } + } + + const inventorySnap = await getDoc(userRef(uid, 'meta', 'shopInventory')) + if (inventorySnap.exists()) { + const data = inventorySnap.data() as { inventory?: unknown; updatedAt?: number } + const remoteUpdatedAt = + typeof data.updatedAt === 'number' && Number.isFinite(data.updatedAt) + ? Math.trunc(data.updatedAt) + : 0 + const localInventory = loadShopInventory() + if (data.inventory && remoteUpdatedAt >= localInventory.updatedAt) { + applyShopInventoryFromCloud({ + ...(data.inventory as object), + updatedAt: remoteUpdatedAt, + }) + } + } } async function pushCollection( @@ -162,6 +218,8 @@ async function pushCollection + +declare module '*.svg?raw' { + const content: string + export default content +} /// +declare module 'virtual:pwa-register' { + export function registerSW(options?: { + immediate?: boolean + onNeedRefresh?: () => void + onOfflineReady?: () => void + }): (reloadPage?: boolean) => Promise +} + interface ImportMetaEnv { readonly VITE_APP_VERSION: string readonly VITE_ENABLE_WEEKLY_AI_SUMMARY?: string