diff --git a/.github/workflows/whiskerfolk-ci.yml b/.github/workflows/whiskerfolk-ci.yml new file mode 100644 index 0000000..a056a50 --- /dev/null +++ b/.github/workflows/whiskerfolk-ci.yml @@ -0,0 +1,71 @@ +name: Whiskerfolk CI + +on: + push: + branches: + - kitty-crush-preproduction + paths: + - 'projects/kitty-crush/**' + - '.github/workflows/whiskerfolk-ci.yml' + pull_request: + branches: + - main + paths: + - 'projects/kitty-crush/**' + - '.github/workflows/whiskerfolk-ci.yml' + +concurrency: + group: whiskerfolk-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Static Contracts + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate web prototype syntax + run: node --check projects/kitty-crush/prototype-web/app.js + + - name: Validate Godot main scene + run: grep -F 'run/main_scene="res://scenes/Main.tscn"' projects/kitty-crush/godot/project.godot + + - name: Validate Android haptics permission + run: grep -F 'permissions/vibrate=true' projects/kitty-crush/godot/export_presets.cfg + + - name: Validate third-party license retention + run: test -s projects/kitty-crush/godot/THIRD_PARTY_MATCH3_PETS_LICENSE.txt + + - name: Guard runtime from test packages + run: | + if grep -qi 'xunit\|Microsoft.NET.Test.Sdk\|coverlet' projects/kitty-crush/godot/Whiskerfolk.csproj; then + echo "Test-only package leaked into runtime project." + exit 1 + fi + + test: + name: Build + Core Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: projects/kitty-crush/godot + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore tests + run: dotnet restore tests/Whiskerfolk.Tests.csproj + + - name: Build Godot runtime + run: dotnet build Whiskerfolk.csproj --configuration Release + + - name: Test engine-agnostic core + run: dotnet test tests/Whiskerfolk.Tests.csproj --configuration Release --logger "console;verbosity=normal" diff --git a/projects/kitty-crush/ANALYTICS_AND_EXPERIMENTS.md b/projects/kitty-crush/ANALYTICS_AND_EXPERIMENTS.md new file mode 100644 index 0000000..fc420b5 --- /dev/null +++ b/projects/kitty-crush/ANALYTICS_AND_EXPERIMENTS.md @@ -0,0 +1,74 @@ +# WHISKERFOLK — Analytics & Experimentation + +## North-star research question +Does the player form a memorable relationship with Mochi before the end of session one? + +## First-session funnel +1. app_start +2. first_mochi_seen +3. box_inspected +4. first_puzzle_started +5. first_puzzle_completed +6. first_trust_response +7. shelter_puzzle_completed +8. rescue_started +9. carrier_choice_completed +10. box_moment_started +11. box_moment_completed +12. home_first_entry +13. home_observation_30s +14. first_object_interaction +15. first_memory_created + +## Behavioral measures +### Attachment proxies +- seconds observing Mochi with no required input; +- voluntary cat taps that are not goal-driven; +- repeat Catbook opens; +- memory opens/shares; +- placement of objects near cat-favorite zones; +- next-session time-to-first-cat-interaction. + +### Puzzle measures +- first move latency; +- invalid swap rate; +- moves remaining; +- retry rate; +- cascade frequency; +- objective comprehension. + +### Home measures +- time before opening secondary UI; +- number of autonomous behaviors observed; +- number of object interactions; +- object placement completion. + +## Qualitative playtest +After session one ask, without showing prompts: +1. What was the cat’s name? +2. Tell me something about his personality. +3. What moment do you remember most? +4. What do you think Mochi likes? +5. Would you open this tomorrow if no new level reward were waiting? +6. What would you change in his room? +7. Did any part feel like “another match-3”? + +## GO thresholds for small prototype study +These are internal decision thresholds, not market guarantees: +- ≥80% unaided Mochi name recall; +- ≥70% can state one accurate personality trait; +- ≥60% choose the Box Moment/rescue as a memorable moment rather than generic puzzle; +- ≥50% say they would return primarily to see/continue with Mochi; +- median ≥30 sec non-required Home observation; +- <20% describe the product primarily as generic match-3. + +## Experiment order +A/B only after baseline exists. + +1. Box Moment pacing: 12s vs 18s. +2. Direct tutorial vs environmental tutorial. +3. Objective world-change immediacy. +4. Home UI delayed vs immediately available. +5. First object: blanket vs scratcher. + +Never A/B core ethical rules. diff --git a/projects/kitty-crush/BRAND_DIRECTION.md b/projects/kitty-crush/BRAND_DIRECTION.md new file mode 100644 index 0000000..42453df --- /dev/null +++ b/projects/kitty-crush/BRAND_DIRECTION.md @@ -0,0 +1,55 @@ +# WHISKERFOLK — Brand Direction v0.1 + +## Working brand +**WHISKERFOLK** + +Status: strong working title; not yet trademark-cleared. + +## Why this name +- Cat-specific without saying "cat" generically. +- Implies a family/community of distinct characters. +- Scales beyond Mochi without losing emotional intimacy. +- Does not anchor the product to match-3. +- Supports IP, character pages, merch and social voice. + +## Brand promise +**Every cat has a way of becoming part of your home.** + +## Product sentence +A premium cozy mobile game where you find frightened cats, help them through tactile puzzles, earn their trust, bring them home, and discover their personalities as they begin to feel safe. + +## Emotional territory +Warmth, rescue, trust, homecoming, quiet humor, tiny surprises, chosen family. + +## What WHISKERFOLK is NOT +- shelter-management simulator; +- Tamagotchi obligation loop; +- gacha collector; +- generic match-3 skin; +- hyperactive kids UI; +- sad-animal guilt product. + +## Verbal tone +Short, warm, observant, never babyish. + +Good: +- “He came back.” +- “Mochi found the warm spot.” +- “Someone moved your blanket.” +- “He’s still deciding if you’re safe.” + +Avoid: +- “OMG SO CUTE!!!” +- “Your pet is starving!” +- “Come back NOW or lose your streak.” +- generic fantasy copy. + +## Logo direction +A wordmark with one subtle feline behavior cue: +- tail-like terminal stroke; +- paired whisker cuts; +- soft “W” silhouette; +- no paw-print cliché as primary mark. + +## App icon direction +Mochi’s face partly emerging from a box opening. Large eyes, asymmetrical ear/mark, warm interior light, no text. diff --git a/projects/kitty-crush/DESIGN_SYSTEM.md b/projects/kitty-crush/DESIGN_SYSTEM.md new file mode 100644 index 0000000..7faa678 --- /dev/null +++ b/projects/kitty-crush/DESIGN_SYSTEM.md @@ -0,0 +1,105 @@ +# WHISKERFOLK — Premium Mobile Design System v0.1 + +## Quality bar +Premium-casual mobile: warm, tactile, elegant, character-first. + +The interface should feel expensive because it is **controlled**, not because it has more effects. + +## Composition +The world occupies 80–90% of meaningful visual attention. +UI floats only where necessary. + +### Home +- no dashboard grid; +- one unobtrusive top status strip at most; +- contextual controls appear after tapping cat/object; +- collections live in bottom sheets; +- primary CTA appears only when the player has a clear next rescue beat. + +### Puzzle +- board dominates; +- rescue objective visible as world context above/behind board; +- moves/objective compact; +- boosters secondary; +- no casino clutter. + +## Visual DNA +### Base colors +- Ink: #27312E +- Soft ink: #51605B +- Milk: #FFF9EF +- Warm paper: #F5E9D7 +- Moss: #6F8F78 +- Sage light: #AFC8B4 +- Caramel: #C88B5A +- Peach light: #F2C7A8 +- Rain blue: #7896A6 +- Night blue: #273B49 + +### Accent rule +One emotional accent per scene. +Do not run blue, purple, pink, green and gold simultaneously. + +## Shape language +- radius family: 14 / 20 / 28; +- cards only when content genuinely needs containment; +- no nested-card labyrinth; +- large soft silhouettes; +- minimal borders; +- shadows broad and low-contrast. + +## Typography +Use one rounded humanist sans family with: +- Display: 700 +- Title: 650 +- Body: 450–500 +- Caption: 550 + +No more than 4 text sizes on a normal screen. + +## Touch +- minimum 48 dp target; +- primary tap areas 56–64 dp; +- destructive/exit actions visually separated; +- back behavior always predictable. + +## Motion +### UI +- 160–220ms micro transitions; +- 280–420ms sheets; +- ease-out for entry; +- ease-in for exit; +- no gratuitous bounce. + +### World +Animals use slower asymmetric timing. +The cat may pause before completing an animation. + +## Particle budget +Routine: +0–8 particles. +Special: +8–24. +Signature rescue moments: +controlled cinematic particles only when motivated by environment. + +## Haptic hierarchy +1. micro selection — very light +2. match — light +3. special creation — medium-soft +4. rescue success — medium +5. box latch/open — distinctive single pulse +6. affection milestone — subtle double pulse + +## Accessibility +- no color-only puzzle types; +- pattern/shape distinction; +- reduced motion mode; +- haptics toggle; +- subtitles/captions for meaningful vocalization cues; +- contrast target AA where text is involved; +- respect larger system text where possible. + +## Performance +Visual quality must not depend on huge overdraw or 4K assets. +Use atlases, compressed textures, pooled FX, scene culling and limited simultaneous live actors. diff --git a/projects/kitty-crush/INITIAL_ROSTER.md b/projects/kitty-crush/INITIAL_ROSTER.md new file mode 100644 index 0000000..d68bbf2 --- /dev/null +++ b/projects/kitty-crush/INITIAL_ROSTER.md @@ -0,0 +1,73 @@ +# WHISKERFOLK — Initial Eight-Cat Roster + +Only Mochi is P0 production. Cats 2–8 remain design-ready until Mochi passes attachment validation. + +## 1. Mochi — The Cautious Observer +Visual: cream coat, caramel ear/eye comma, moss-green eyes. +Rescue: rainy storefront box. +Favorite object: cardboard box. +Signature: approaches, retreats, looks back, returns. +Relationship lesson: safety is chosen, not forced. + +## 2. Churro — The Confident Food Critic +Visual: long orange tabby, darker cinnamon stripes, white chin. +Rescue: bakery loading alley where he has learned delivery schedules. +Favorite object: kitchen mat / puzzle feeder. +Signature: inspects every bowl, pushes the “wrong” one aside. +Personality: social, shameless, opportunistic, dramatic about food. +Relationship lesson: affection can look like showing up every day. + +## 3. Nori — The High-Ground Strategist +Visual: near-black coat with blue-green sheen and one white toe. +Rescue: rooftop/service stairwell. +Favorite object: tall shelf / window perch. +Signature: watches from above before dropping silently into the room. +Personality: independent, calm, highly observant. +Relationship lesson: proximity is not the only form of trust. + +## 4. Chispa — The Kinetic Calico +Visual: compact calico with one orange “lightning” face patch. +Rescue: mechanic courtyard full of moving objects and hiding spots. +Favorite object: feather wand / rolling spring. +Signature: pounce → skid → pretend it was intentional. +Personality: fearless curiosity, short attention, comic confidence. +Relationship lesson: safety can unlock play. + +## 5. Trufa — The Soft Giant +Visual: broad brown tabby, plush cheeks, tiny serious eyes. +Rescue: quiet park storage shed. +Favorite object: oversized cushion. +Signature: slowly occupies an object clearly too small for him. +Personality: patient, tactile, comfort-seeking. +Relationship lesson: calm presence can be deeply expressive. + +## 6. Luma — The Sun Collector +Visual: pale silver coat, warm cream chest, amber eyes. +Rescue: greenhouse/service garden. +Favorite object: sun patch / woven mat. +Signature: relocates with the sun throughout the day. +Personality: gentle, routine-loving, easily fascinated by light. +Relationship lesson: home becomes a map of safe places. + +## 7. Tinta — The Tiny Engineer +Visual: tuxedo cat with ink-black mask and white “gloves”. +Rescue: back room where she repeatedly opens a loose cabinet. +Favorite object: puzzle box / cabinet latch. +Signature: studies mechanisms, then solves them. +Personality: clever, persistent, mildly mischievous. +Relationship lesson: intelligence itself becomes character. + +## 8. Pipa — The Social Bridge +Visual: tortoiseshell with a warm split-color muzzle. +Rescue: community courtyard where several cats tolerate her. +Favorite object: shared blanket. +Signature: inserts herself exactly between two cats and settles. +Personality: socially bold, adaptable, affectionate without clinginess. +Relationship lesson: later cats change earlier cats. + +# Roster rules +- No duplicate silhouette. +- No “same cat, different fur”. +- Every cat changes the room behavior graph. +- Every rescue teaches a different kind of trust. +- Each cat must own one screenshot-worthy behavior. diff --git a/projects/kitty-crush/LIVEOPS_POST_GO.md b/projects/kitty-crush/LIVEOPS_POST_GO.md new file mode 100644 index 0000000..6694a01 --- /dev/null +++ b/projects/kitty-crush/LIVEOPS_POST_GO.md @@ -0,0 +1,29 @@ +# WHISKERFOLK — Post-GO LiveOps Outline + +This file is intentionally **post-GO**. Nothing here is allowed to delay Mochi validation. + +## Cadence concept +- weekly: small Home rotation / photo prompt; +- biweekly: lightweight cosmetic set; +- monthly: rescue chapter or relationship event; +- seasonal: one cohesive room theme + memory set. + +## Content pillars +1. New rescue stories. +2. New interactions between existing cats. +3. Home sets. +4. Character memories. +5. Seasonal ambience. + +## Best scalable content +Prefer content that reuses systems but creates new emotional combinations: +- one new object can produce 8 cat-specific reactions; +- one new cat can create many pair interactions; +- one room ambience can change behavior weights. + +## Avoid +- event calendar overload; +- seven simultaneous countdown banners; +- event-specific currencies everywhere; +- chores disguised as engagement; +- content that expires before casual players can see it. diff --git a/projects/kitty-crush/MASTER_EXECUTION_PLAN.md b/projects/kitty-crush/MASTER_EXECUTION_PLAN.md new file mode 100644 index 0000000..629d1ee --- /dev/null +++ b/projects/kitty-crush/MASTER_EXECUTION_PLAN.md @@ -0,0 +1,898 @@ +# KITTY PROJECT — MASTER EXECUTION PLAN + +## 1. Executive verdict + +### VERDICT: MODIFY — strong concept, insufficiently proven product + +The original concept contains a potentially powerful emotional core: + +**puzzle → rescue → cat → home → interaction → decoration → return** + +But in its earlier framing, the risk was that the cat became a reward attached to an otherwise generic match-3. That is not defensible enough. + +The reinvention is: + +**rescue fantasy first; puzzle second.** + +The core opportunity is not to outperform Royal Match at content volume, economy sophistication or level production. The opportunity is to create a player relationship that those products do not center: + +> **“I found this cat, I helped it, it learned to trust me, and now it lives in my home.”** + +This changes the core product from a puzzle game with a pet meta into an emotional rescue-and-coexistence game powered by puzzles. + +--- + +## 2. Current-state audit + +### 2.1 What exists as product direction + +Approved or strongly supported concepts already exist: + +- cats are rescued rather than merely unlocked; +- rescued cats remain permanently in the home; +- the home becomes progressively more alive; +- decoration, toys, outfits and spaces support expression; +- cats must have distinct personalities; +- the emotional rescue moment is more important than a generic reward chest; +- one small but deep home is preferred over many empty rooms; +- monetization should lean toward identity, expression and collection; +- the first validation target is attachment to Mochi. + +### 2.2 What does NOT yet exist + +There is no dedicated production repository in the connected GitHub account. + +Therefore the following are not yet proven: + +- production architecture; +- actual visual style; +- actual Mochi design; +- implemented onboarding; +- implemented rescue arc; +- implemented living-room AI; +- integrated rescue-puzzle objectives; +- production-ready animation system; +- audio language; +- final economy; +- live analytics; +- tested D1/D7 retention; +- tested attachment. + +This is a **preproduction project**, not a game that is ready for content scaling. + +--- + +## 3. Candidate technical foundations reviewed + +### A. immane/match3-pets + +Relevant strengths: + +- Godot 4.6 .NET + C#; +- 8×8 board; +- match detection; +- cascades; +- bombs/rainbow/cross specials; +- gravity and spawn systems; +- deadlock checking; +- object pooling; +- 14-state game state machine; +- event bus; +- dynamic board scaling; +- pet data/services; +- room showcase; +- currency layer; +- 41 tests across 10 test files. + +Useful as a **technical accelerator**, not as product identity. + +Major conflicts with our vision: + +- 30-second countdown; +- score-attack framing; +- gacha acquisition; +- pity system; +- pet needs; +- pet collection as reward system; +- multiple pet species; +- “pull” economy rather than rescue relationship. + +### B. iuhoay/cat-run + +Useful ideas: + +- autonomous cat movement; +- room objects; +- target-based movement; +- state transitions such as idle/walk/sleep/eat; +- simple room interaction loop. + +Conflicts: + +- hunger/thirst/hygiene decay; +- obligation-based maintenance; +- explicit refill timers; +- care loop can become chores. + +Use only as a behavioral reference. + +--- + +## 4. Product gap + +| Area | Current state | Target state | +|---|---|---| +| Fantasy | Cats as reward/meta | Rescue, trust and coexistence | +| First session | Undefined | 10–15 minute Mochi emotional arc | +| Puzzle | Generic match-3 candidate | Rescue-context puzzle language | +| Cat | Collectible asset | Character with memory/personality | +| Home | Decoration destination | Living place worth opening by itself | +| Collection | Broad/unfocused | 4 clear collection territories | +| Monetization | Undecided / gacha risk | Identity, home and expression | +| Retention | Level progression | Attachment + curiosity + progression | +| Virality | Share button risk | Naturally shareable cat moments | +| Content | Potential scale-first | Attachment-first vertical slice | +| Technical | No owned repo | Dedicated maintainable Kitty codebase | + +--- + +# 5. Master roadmap + +## PHASE 0 — PREPRODUCTION SOURCE OF TRUTH +**Status:** COMPLETED + +### Objective +Prevent contradictory development and establish a canonical product direction. + +### Deliverables +- master roadmap; +- product bible; +- technical foundation report; +- GO/MODIFY criteria; +- constraints. + +### Acceptance +Any future agent should be able to understand the product without prior chat history. + +--- + +## PHASE 1 — PRODUCT LOCK + NAMING TERRITORY +**Status:** NOT STARTED + +### Objective +Lock the commercial fantasy before implementation. + +### Define +- one-sentence fantasy; +- target audience; +- emotional promise; +- core loop; +- emotional loop; +- meta loop; +- differentiation; +- non-goals; +- naming criteria. + +### Naming requirement +“Kitty Crush” remains a codename only. + +Generate at least 30 names and evaluate: + +- memorability; +- international pronunciation; +- cat association without genericity; +- ability to become an IP; +- trademark/store collision risk; +- visual branding potential. + +### Acceptance +A stranger can hear one sentence and understand why this is not “another match-3”. + +--- + +## PHASE 2 — MOCHI CHARACTER BIBLE +**Status:** NOT STARTED + +### Objective +Make one cat worth caring about before creating eight. + +### Mochi must have +- silhouette; +- coat/markings; +- eyes; +- body proportions; +- personality; +- fears; +- likes; +- dislikes; +- quirks; +- comfort object; +- favorite location; +- trust states; +- reaction vocabulary; +- signature pose; +- signature sound; +- signature behavior; +- relationship arc; +- commercial/IP recognizability. + +### Behavioral principle +Mochi must still be interesting when the player does nothing. + +### Minimum animation design +- breathe; +- blink; +- ear twitch; +- tail idle; +- cautious walk; +- normal walk; +- run; +- sit; +- lie down; +- sleep; +- wake; +- stretch; +- sniff; +- hide; +- peek; +- startled; +- curious; +- playful; +- approach; +- retreat; +- accept affection; +- seek attention. + +### Acceptance +A static silhouette plus two or three behaviors should identify Mochi. + +--- + +## PHASE 3 — MOCHI VERTICAL SLICE SCRIPT +**Status:** NOT STARTED + +### Objective +Design the first 10–15 minutes before mass production. + +### Emotional sequence +**curiosity → concern → effort → trust → danger → rescue → relief → belonging** + +### Target flow + +#### Minute 0:00–1:00 — Cold open +- rain/ambient sound; +- cardboard box; +- quiet movement; +- first small meow; +- glimpse of eyes; +- no menu overload. + +#### 1:00–3:30 — First help +Goal: obtain food or warmth. + +Puzzle tutorial is embedded in helping Mochi. + +#### 3:30–5:00 — First trust response +Mochi does not immediately become affectionate. + +He: +- watches; +- hesitates; +- sniffs; +- retreats; +- returns. + +#### 5:00–7:30 — Second help +Shelter/material/warmth objective. + +Puzzle vocabulary remains connected to the scene. + +#### 7:30–9:30 — Threat escalation +A safe, non-traumatic but emotionally legible danger: +- weather; +- street movement; +- unstable shelter; +- sudden noise. + +The player should think: +**“I need to get him out of here.”** + +#### 9:30–11:30 — Rescue climax +A short, more deliberate interactive puzzle sequence. + +#### 11:30–13:00 — Release +Quiet transition. No reward spam. + +#### 13:00–15:00 — The Box Moment +Mochi arrives home. + +Sequence: +1. transport box is placed; +2. silence; +3. small movement; +4. ears/eyes appear; +5. cautious exit; +6. room scan; +7. hide; +8. re-emerge; +9. discover first favorite spot; +10. settle. + +Final message: + +> **MOCHI NOW LIVES WITH YOU.** + +### Acceptance +The ending should create a stronger emotional peak than a conventional “Level Complete” screen. + +--- + +## PHASE 4 — DEDICATED TECHNICAL FOUNDATION +**Status:** BLOCKED BY DEDICATED REPO CREATION + +### Preferred direction +Use Godot 4.x + C# unless implementation discovery proves another stack superior. + +### Why +The reviewed open-source base already demonstrates: +- deterministic board logic; +- testability; +- mobile renderer setup; +- dynamic layout; +- event-driven architecture; +- object pooling. + +### Reuse candidate +From a legal/technical standpoint, MIT-licensed components from `match3-pets` can be adapted if the required license notice is retained. + +Candidate reusable concepts/classes: +- BoardData; +- MatchDetector; +- GravitySystem; +- SpawnSystem; +- ValidMoveChecker; +- ScoreCalculator only if converted from score focus to objective focus; +- TileManager/object pool; +- AnimationController patterns; +- GameStateMachine; +- EventBus; +- persistence interfaces. + +### Do not inherit blindly +- GachaDrawService; +- pity; +- generic pet acquisition; +- timer pressure; +- pet hunger/energy; +- score-attack framing. + +### Acceptance +A dedicated Kitty repo builds cleanly and has automated tests for the board core before visual expansion. + +--- + +## PHASE 5 — RESCUE PUZZLE SYSTEM +**Status:** NOT STARTED + +### Objective +Stop the puzzle from feeling like a disconnected minigame. + +### Design +Create contextual objectives such as: +- food; +- blankets; +- pawprints; +- shelter pieces; +- water drops; +- toy pieces; +- medicine only if handled appropriately; +- keys/doors; +- safe-path obstacles. + +### Critical rule +Text alone cannot re-skin a generic objective. + +Bad: +“Match 30 red pieces — this helps Mochi.” + +Better: +The board uses world objects and blockers that visibly advance the rescue state. + +### Keep +- cascades; +- special-piece satisfaction; +- combo readability; +- board responsiveness. + +### Change +- score hierarchy; +- timer pressure; +- meaningless colors; +- generic win state. + +### Acceptance +A spectator understands what the puzzle is accomplishing in the rescue. + +--- + +## PHASE 6 — LIVING ROOM / ILLUSION OF LIFE +**Status:** NOT STARTED + +### Objective +Make the home worth opening even without a puzzle. + +### MVP +- one room; +- Mochi; +- 5–8 meaningful objects; +- navigation; +- resting spots; +- toys; +- hiding spots; +- window/visual focus; +- autonomous behavior. + +### Behavior model +Use a lightweight utility/state architecture: + +**personality + current context + environment + relationship + weighted randomness** + +Potential states: +- idle; +- observe; +- patrol; +- rest; +- sleep; +- play; +- investigate; +- hide; +- seek player; +- seek object; +- react to sound; +- interact with another cat later. + +### Critical anti-pattern +Do not use hunger/thirst/hygiene timers as primary retention. + +### Principle +**Attachment without guilt.** + +### Acceptance +During a 60-second no-input test, Mochi should create at least one believable, charming behavior without feeling hyperactive. + +--- + +## PHASE 7 — PREMIUM-CASUAL ART DIRECTION +**Status:** NOT STARTED + +### Target +Top-tier mobile polish, not hyperrealism. + +### Visual attributes +- warm; +- tactile; +- rounded; +- cozy; +- expressive; +- readable; +- restrained; +- distinctive. + +### Avoid +- template UI; +- random gradients; +- excessive glassmorphism; +- childish stock art; +- emoji as production art; +- inconsistent illustration; +- noisy HUD; +- generic mobile buttons; +- unnecessary particles. + +### Character priority +Mochi > environment > puzzle > secondary UI. + +### Environment +The room should feel: +- lived-in; +- layered; +- soft-lit; +- spatially readable; +- interactive; +- photographable. + +### Acceptance +A screenshot without the logo should still look like it belongs to the same universe. + +--- + +## PHASE 8 — UX, MOTION, AUDIO AND HAPTICS +**Status:** NOT STARTED + +### UX +- one primary action per screen; +- minimal HUD during emotional moments; +- no dashboard-first home; +- large touch targets; +- no color-only puzzle readability; +- clear back behavior; +- predictable navigation. + +### Motion +Animation is P0, not polish-later. + +Use: +- anticipation; +- easing; +- follow-through; +- micro-pauses; +- soft secondary motion. + +Avoid: +- constant bouncing; +- excessive screen shake; +- confetti for routine actions. + +### Audio +Build a layered language: +- rain; +- box/cardboard; +- paws; +- fabric; +- purr; +- meow variants; +- room ambience; +- toy; +- match; +- cascade; +- rescue; +- home arrival. + +### Haptics +Reserve hierarchy for: +- selection; +- special creation; +- combo impact; +- rescue; +- box opening; +- meaningful affection. + +### Acceptance +The game feels pleasant with headphones and restrained with sound off. + +--- + +## PHASE 9 — FOUR COLLECTION TERRITORIES +**Status:** NOT STARTED + +### Exactly four for MVP + +1. **Catbook** — rescued cats, story, personality, bond. +2. **Memories** — photos, firsts, special moments. +3. **Home** — furniture, toys, interactive objects. +4. **Style** — collars, light outfits, accessories. + +### Acceptance +Every collection serves attachment or expression. + +--- + +## PHASE 10 — MULTI-CAT EXPANSION TO EIGHT +**Status:** NOT STARTED + +### Only starts after Mochi passes validation. + +Each cat must have: +- unique silhouette; +- unique rescue context; +- personality; +- signature behavior; +- favorite object; +- relationship tendencies; +- emotional arc. + +### Rule +Do not create eight color variants of the same actor. + +--- + +## PHASE 11 — ETHICAL MONETIZATION PROTOTYPE +**Status:** NOT STARTED + +### Priority +Monetize: +- identity; +- home; +- self-expression; +- optional collection; +- seasonal aesthetics. + +Potential: +- cosmetic sets; +- collars/accessories; +- premium furniture; +- room themes; +- optional passes after value is proven; +- rewarded ads only when truly optional. + +Avoid as core: +- punitive lives; +- forced wait; +- aggressive revive spend; +- gacha as primary cat acquisition; +- casino presentation; +- guilt-based care. + +### Acceptance +A purchase can be explained as: +**“I want this for my cat/home,”** +not: +**“The game made me miserable until I paid.”** + +--- + +## PHASE 12 — ANALYTICS + ATTACHMENT VALIDATION +**Status:** NOT STARTED + +### Core events +- first_mochi_seen; +- first_help_action; +- first_puzzle_start; +- first_puzzle_complete; +- first_trust_response; +- rescue_started; +- rescue_completed; +- box_moment_started; +- box_moment_completed; +- home_first_entry; +- first_affection; +- first_object_interaction; +- first_memory; +- first_cosmetic_equipped. + +### Attachment study +Measure: +- recall; +- recognition; +- care; +- curiosity; +- return intent; +- observation time; +- personalization intent; +- spontaneous share intent. + +### Key qualitative questions +- What do you remember about Mochi? +- What do you think Mochi likes? +- Would you open the game tomorrow just to see him? +- What moment would you show someone? +- What would you buy or decorate specifically for him? + +### Acceptance +Players describe Mochi as a character, not as “the cat”. + +--- + +## PHASE 13 — PERFORMANCE, QA AND RELEASE HARDENING +**Status:** NOT STARTED + +### Target +Smooth Android performance on mid-range hardware. + +Audit: +- FPS; +- memory; +- texture sizes; +- draw calls; +- loading; +- scene transitions; +- input latency; +- animation cost; +- save corruption; +- lifecycle/background handling; +- touch behavior; +- accessibility. + +### Tests +- board core; +- objectives; +- save/load; +- state transitions; +- cat behavior; +- unlock logic; +- purchase abstraction later; +- offline behavior. + +--- + +## PHASE 14 — GO GATE +**Status:** NOT STARTED + +### GO means +Not “it runs”. + +GO means Mochi works. + +Required evidence: +- players remember Mochi; +- players can describe personality; +- first-session completion is healthy; +- rescue moment is understood; +- box moment is emotionally salient; +- users voluntarily observe Mochi; +- users express return intent; +- at least one naturally shareable moment exists; +- home feels alive; +- puzzle feels connected to rescue; +- performance is stable; +- monetization does not corrupt the fantasy. + +If these are weak: +**MODIFY.** + +If the fantasy still fails after high-quality execution: +**KILL.** + +Only after GO: +- mass levels; +- eight cats; +- events; +- seasonal content; +- expanded rooms; +- LiveOps; +- large content factory. + +--- + +# 6. Design target + +## Overall bar +**Premium casual / top-tier mobile polish.** + +This is not a promise to match Dream Games' production budget or content scale. + +It means: +- high readability; +- consistent materials; +- confident composition; +- smooth transitions; +- polished touch response; +- memorable character animation; +- strong sound design; +- very little visual junk. + +## Benchmark principles + +### Royal Match +Relevant principles: +- coherent world language; +- smooth rounded forms; +- match pieces that belong to the universe; +- polished boosters and feedback; +- character/world consistency. + +Do not imitate its product structure blindly. + +### Cats & Soup +Relevant principles: +- strong cat appeal; +- distinctive cats; +- costumes; +- photos; +- decoration; +- relaxed audio; +- repeated return to observe cats. + +### Neko Atsume +Relevant principles: +- low-pressure observation; +- anticipation of what cats may do; +- collection generated through presence and curiosity. + +Our unique territory remains: +**rescue + trust + coexistence.** + +--- + +# 7. Current scorecard + +This evaluates current **product readiness**, not future potential. + +| Area | Current | +|---|---:| +| Product fantasy | 8.0 | +| Differentiation concept | 7.5 | +| Emotional attachment proof | 2.0 | +| Mochi character design | 1.5 | +| Visual identity | 2.0 | +| UI/UX implementation | 1.0 | +| Motion | 1.0 | +| Puzzle integration | 4.0 | +| Home experience | 3.5 | +| Audio | 1.0 | +| Retention proof | 2.0 | +| Monetization alignment | 6.5 | +| Virality proof | 2.0 | +| Technical readiness | 4.5 | +| Scalability | 4.0 | + +### Interpretation +The idea has improved substantially, but the product is not yet “GO”. + +The highest leverage work is: +1. Mochi; +2. 15-minute vertical slice; +3. living-room illusion of life; +4. rescue-integrated puzzle; +5. premium sensory polish. + +--- + +# 8. Risk register + +1. **Generic match-3 gravity** — puzzle dominates the fantasy. +2. **Cute but forgettable Mochi** — attractive asset without personality. +3. **Overproduction before validation** — levels/cats made too early. +4. **Home as menu** — room becomes a dashboard instead of a place. +5. **Tamagotchi burden** — care becomes chores. +6. **Feature creep** — too many collections/systems. +7. **Monetization contamination** — gacha/lives weaken trust. +8. **Animation underinvestment** — cat never feels alive. +9. **Narrative skinning** — rescue is only text over generic boards. +10. **Inconsistent art pipeline** — AI/assets create visual fragmentation. +11. **Weak mobile performance** — too many animated cats/particles. +12. **No analytics discipline** — “people liked it” replaces evidence. + +--- + +# 9. Asset requirements for vertical slice + +## MUST HAVE +- Mochi final or near-final visual design; +- Mochi core sprite/rig; +- 10–12 priority animations; +- box/transport carrier; +- rainy rescue environment; +- home living room; +- 5–8 room objects; +- puzzle tile set connected to rescue; +- 2–3 contextual blockers/objectives; +- main UI kit; +- sound pack for rain/paws/meows/purr/puzzle/box; +- basic haptic mapping; +- rescue transition; +- home arrival scene. + +## SHOULD HAVE +- 20+ microexpressions/secondary motions; +- memory/photo presentation; +- one collar/accessory; +- one toy with special interaction; +- one favorite resting spot; +- day/night lighting variation. + +## LATER +- cats 2–8; +- seasonal sets; +- multiple rooms; +- extensive wardrobe; +- events; +- LiveOps catalog. + +--- + +# 10. Final committee decision + +## MODIFY — proceed with the vertical slice + +The product should continue. + +But the next milestone is **not** “build more levels”. + +It is: + +> **Prove that Mochi becomes emotionally meaningful.** + +The most important deliverable in the entire project is now the first 10–15 minutes and the living room immediately after. + +If that works, the project earns the right to scale. diff --git a/projects/kitty-crush/MOCHI_CHARACTER_BIBLE.md b/projects/kitty-crush/MOCHI_CHARACTER_BIBLE.md new file mode 100644 index 0000000..4f6f3c9 --- /dev/null +++ b/projects/kitty-crush/MOCHI_CHARACTER_BIBLE.md @@ -0,0 +1,162 @@ +# MOCHI — Character Bible v0.1 + +## Role +Mochi is not mascot decoration. Mochi is the proof that WHISKERFOLK works. + +## Archetype +**The cautious observer who secretly wants closeness.** + +He is not helpless. He evaluates safety. + +## Visual identity +- Small young adult / older kitten proportions. +- Cream-white coat with one warm caramel ear and a small caramel “comma” marking beside one eye. +- Slightly oversized ears. +- Round but not chibi-ball body. +- Long expressive tail with darker tip. +- Moss-green eyes, large but grounded. +- One tiny nick on the colored ear: subtle rescue history, not trauma spectacle. + +## Silhouette test +Readable through: +- one asymmetrical colored ear; +- tall ear pair; +- low cautious shoulder line; +- long question-mark tail. + +## Personality +Core traits: +- cautious; +- observant; +- box-obsessed; +- quietly playful; +- food-motivated but not glutton caricature; +- prefers indirect affection; +- startles easily, recovers through curiosity. + +## Likes +- cardboard; +- soft folded fabric; +- sun patches; +- feather movement; +- watching rain from safety; +- sitting near the player without immediately touching. + +## Dislikes +- abrupt movement; +- metallic clatter; +- being cornered; +- direct grabbing; +- noisy toy spam. + +## Signature behavior +**The double-check.** +Mochi approaches something interesting, retreats two steps, looks back, then returns. + +This is his recognizable behavioral fingerprint. + +## Signature pose +Half inside a cardboard box with only head + one paw visible. + +## Signature sound +A short questioning “mrrp?” more often than a full meow. + +## Relationship arc +0 UNKNOWN — only sound/eyes. +1 WATCHING — tracks player. +2 TOLERATING — accepts help at distance. +3 TESTING — approaches/retreats. +4 TRUSTING ACTION — allows rescue. +5 OVERWHELMED — home arrival/hide. +6 SETTLING — chooses first safe spot. +7 INITIATING — first voluntary approach. +8 FAMILIAR — sleeps near player activity. +9 BONDED — seeks player during selected states. + +## Body-language vocabulary +### Fear / uncertainty +- ears rotate outward/back; +- body lower; +- tail close to body; +- eye contact broken quickly; +- freeze before retreat. + +### Curiosity +- head tilt; +- forward ears; +- slow lean; +- nose twitch; +- single paw step. + +### Comfort +- asymmetrical blink; +- shoulders release; +- tail loose; +- loaf/sphinx pose; +- slow eye-close. + +### Affection +Never immediate. +- chooses nearby location; +- tail touch; +- cheek rub later; +- sleeps with back exposed; +- voluntary approach. + +## Priority production animations +P0: +1. breathing idle +2. blink +3. ear twitch +4. tail idle +5. cautious walk +6. normal walk +7. sit +8. hide +9. peek +10. sniff +11. startled +12. settle/lie down +13. sleep +14. wake/stretch +15. approach/retreat double-check +16. accept gentle pet +17. box enter +18. box emerge + +P1: +- play pounce; +- feather tracking; +- window watch; +- grooming; +- happy trot; +- cheek rub; +- curl sleep; +- paw tap. + +## Interaction rules +Mochi should not accept every tap. +Low bond: +- player taps near him → looks; +- second rapid tap → backs away; +- slow contextual interaction → remains. + +High bond: +- tap near head → lean-in possibility; +- tap on favorite object → shared interaction; +- repeated rapid taps still produce boundary reaction. + +## Anti-cliché rules +- no permanent smile; +- no heart particles for every touch; +- no giant anime blush; +- no baby-talk text; +- no “sad because you were away” guilt. + +## Merch/IP test +Must work as: +- app icon; +- sticker with box; +- plush with colored ear; +- looping GIF double-check; +- silhouette enamel pin. diff --git a/projects/kitty-crush/MONETIZATION_BLUEPRINT.md b/projects/kitty-crush/MONETIZATION_BLUEPRINT.md new file mode 100644 index 0000000..d4c9c06 --- /dev/null +++ b/projects/kitty-crush/MONETIZATION_BLUEPRINT.md @@ -0,0 +1,90 @@ +# WHISKERFOLK — Monetization Blueprint + +## Principle +**Monetize identity, expression and expansion of affection. Never monetize neglect.** + +## Free experience must include +- complete rescue arcs; +- all core cats obtainable through play; +- core home interactions; +- Catbook; +- Memories; +- functional home customization; +- no mandatory purchase to keep a cat healthy/happy. + +## Primary revenue surfaces + +### 1. Home Collections +Premium coordinated sets: +- furniture; +- window treatments; +- rugs; +- wall accents; +- interactive cat objects. + +Rule: premium sets may be prettier or more expressive, but free objects remain charming and functional. + +### 2. Cat Style +- collars; +- tags; +- light accessories; +- seasonal bandanas; +- tiny hats only if animation quality remains high. + +Avoid full-body costumes that erase character identity. + +### 3. Memory Presentation +Optional: +- premium photo frames; +- album themes; +- animated memory cards; +- share layouts. + +The memory itself is never paywalled after it is earned. + +### 4. Seasonal Pass +Only after retention is proven. + +Structure: +- cosmetics; +- home set progression; +- optional bonus memories; +- no power advantage required. + +### 5. Rewarded Ads +Optional only: +- preview alternate decor; +- bonus cosmetic token; +- reroll a cosmetic shop row later. + +Never: +- “watch to feed Mochi”; +- “watch or lose streak”; +- forced interstitial after rescue. + +## Currency architecture +Keep MVP to: +1. **Soft currency** — earned from puzzles/rescue/home goals. +2. **Premium currency** — later, if commerce validates it. + +Do not launch with 5–7 currencies. + +## Store UX +- Store is never visible in the first rescue. +- No giant discount banners in Home. +- Product is shown in-context on the cat/home. +- “Try in room” before purchase whenever practical. + +## Ethical fail-state policy +Puzzle failure can cost: +- time; +- retry; +- optional strategy change. + +It should not exploit emotional attachment: +Never use “Mochi will stay in the rain unless you pay.” + +## Revenue validation questions +- Did the player buy because the item expressed taste? +- Did the purchase make Home feel more “theirs”? +- Would they still feel good about the purchase a week later? diff --git a/projects/kitty-crush/PHASE_STATUS.md b/projects/kitty-crush/PHASE_STATUS.md new file mode 100644 index 0000000..6ee7d1c --- /dev/null +++ b/projects/kitty-crush/PHASE_STATUS.md @@ -0,0 +1,55 @@ +# WHISKERFOLK — Phase Status + +Updated: 2026-10-01 + +## Executive state +**MODIFY → VERTICAL SLICE BUILD IN PROGRESS** + +The project has moved beyond concept-only work. Product, character, systems and a playable web interaction prototype now exist in Git. + +## Phase matrix + +| Phase | Status | What exists now | What still blocks completion | +|---|---|---|---| +| 0. Source of truth | COMPLETE | README, master plan, product bible, technical foundation | — | +| 1. Product lock + naming | PARTIAL COMPLETE | Working brand WHISKERFOLK, promise, positioning | Formal trademark/store clearance | +| 2. Mochi Character Bible | DESIGN COMPLETE | Full visual/personality/behavior bible | Final production art/rig/animation | +| 3. Mochi vertical slice | PROTOTYPE COMPLETE | Playable web flow from rain → home | Production Godot implementation + audio/art | +| 4. Technical foundation | CI VALIDATED | Godot/C# runtime + pure .NET Core split, MIT puzzle base adapted, 57/57 tests, 0 warnings/errors | Physical-device Godot/Android execution | +| 5. Rescue puzzle system | RUNTIME PROTOTYPE | Web + Godot flow, food/shelter/safe-path objectives, cascades, specials core, state integration | Level-authoring tooling + final presentation | +| 6. Living room | RUNTIME PROTOTYPE | Godot Home, BehaviorScheduler, box affinity, blanket interaction, autonomous movement, replay-safe Box Moment | Production rig/navigation + richer object interactions | +| 7. Premium art direction | DESIGN COMPLETE | Visual DNA + design system | Final artist/asset pass | +| 8. UX / motion / audio / haptics | RUNTIME PARTIAL | Mochi microexpressions, motion proxy, procedural sound language, haptics, Android Back handling | Final sound assets, production animation rig, device feel tuning | +| 9. Four collections | DESIGN COMPLETE | Catbook, Memories, Home, Style | Production UI/data wiring | +| 10. Eight-cat expansion | DESIGN COMPLETE | Eight distinct cat concepts + data roster | Locked until Mochi validates | +| 11. Monetization | DESIGN COMPLETE | Ethical store/currency/rewarded-ad blueprint | Real economy balancing after retention data | +| 12. Analytics / validation | DESIGN COMPLETE | Funnel, events, attachment study, thresholds | Actual analytics SDK + user study | +| 13. QA / performance | CI ACTIVE | Static contracts + Godot C# build + 57 core tests green; Android export preset configured | Physical Android install/FPS/memory/thermal QA | +| 14. GO gate | BLOCKED | Criteria defined | Requires production-near build + human playtests | + +## Hard truth + +We can complete architecture, design, prototypes and automation in Git. + +We cannot truthfully mark these as finished without external execution: +- final AAA character art; +- final animation; +- actual Android FPS/memory measurements; +- sound/haptic feel on hardware; +- attachment data from real players; +- trademark clearance. + +Those are not paperwork blockers. They are the evidence that separates a convincing plan from a real game. + +## Immediate next production order + +1. Implement Mochi production actor in Godot. +2. Build the rescue scene state machine around RescueArc. +3. Integrate ObjectiveProgress with the puzzle board. +4. Add Home navigation/object interaction. +5. Wire save/resume. +6. Add audio/haptics. +7. Replace proxy visual assets with production art. +8. Run device QA. +9. Run 5–15 person attachment playtest. +10. Evaluate GO / MODIFY. diff --git a/projects/kitty-crush/PRODUCT_BIBLE.md b/projects/kitty-crush/PRODUCT_BIBLE.md new file mode 100644 index 0000000..6ba0375 --- /dev/null +++ b/projects/kitty-crush/PRODUCT_BIBLE.md @@ -0,0 +1,416 @@ +# KITTY PROJECT — PRODUCT BIBLE + +## Product statement + +A cozy mobile game about finding vulnerable cats, helping them through tactile puzzles, earning their trust, rescuing them and building a living home where their personalities continue to unfold. + +## One-line fantasy + +> **I rescue cats, earn their trust, bring them home, and discover who they become when they finally feel safe.** + +## What the player should feel + +Not: +- “I completed level 20.” +- “I unlocked rare cat #07.” +- “I filled a collection slot.” + +Instead: +- “Mochi finally came out of the box.” +- “He sleeps in the chair I picked.” +- “He was scared of me before.” +- “He did something funny while I was gone.” +- “That toy is perfect for him.” +- “I want to see him again.” + +--- + +# Emotional loop + +**DISCOVER → CARE → HELP → TRUST → RESCUE → BOND → EXPRESS → OBSERVE → MISS → RETURN** + +This is the strategic heart of the product. + +--- + +# Core loop + +**HOME → RESCUE/STORY GOAL → PUZZLE → VISIBLE PROGRESS → RESCUE/RESOURCE → HOME CHANGE → CAT REACTION → MEMORY/EXPRESSION → NEXT DESIRE** + +The player should always understand why the next puzzle matters. + +--- + +# Meta loop + +**CAT → RELATIONSHIP → HOME → COLLECTION → NEW CAT → NEW RELATIONSHIPS → NEW BEHAVIORS → DEEPER HOME** + +--- + +# Mochi + +Mochi is the first proof of product. + +## Required role +- vulnerable but not pathetic; +- cautious but curious; +- visually iconic; +- expressive; +- capable of warmth without instantly loving the player. + +## Relationship arc + +### 0 — Unknown +Only eyes, sound, movement. + +### 1 — Aware +Mochi watches the player. + +### 2 — Tolerates help +Accepts food/warmth but keeps distance. + +### 3 — Tests safety +Approaches, retreats, observes. + +### 4 — Rescue +Allows the player to help fully. + +### 5 — New home +Overwhelmed by environment; hides. + +### 6 — First comfort +Chooses a spot. + +### 7 — First voluntary approach +Important future milestone. + +### 8 — Bonded +Not “owned”; safe and familiar. + +--- + +# The Box Moment + +The arrival home must be a signature scene. + +## Rules +- no reward carousel; +- no 5 currencies; +- no confetti storm; +- no forced tutorial overlays; +- no instant outfit screen. + +Use pacing. + +The player should have room to watch. + +The cat is the reward. + +--- + +# Living-room philosophy + +The room is not a menu background. + +It is a simulation space. + +## It must support +- wandering; +- resting; +- looking outside; +- hiding; +- toy interactions; +- object preferences; +- player attention; +- small surprises; +- later cat-to-cat interactions. + +## Important +Quiet behavior is content. + +A cat sleeping peacefully for 20 seconds can be more emotionally convincing than a constant stream of exaggerated animations. + +--- + +# Attachment without guilt + +Do not turn the game into work. + +Avoid: +- urgent hunger; +- thirst decay; +- dirty litter obligations; +- illness from absence; +- punishment for not logging in; +- “Mochi is sad because you left” manipulation. + +Prefer: +- positive surprises on return; +- small discoveries; +- new favorite spots; +- photos/memories; +- environmental reactions; +- affection milestones. + +--- + +# Puzzle philosophy + +The match-3 system may survive, but only if it earns its place. + +## The board should feel like part of the world + +Examples: +- food pieces; +- blanket patches; +- cardboard/shelter pieces; +- drops; +- pawprints; +- toys; +- safe-path obstacles. + +## Puzzle goals +Progress must visibly change something outside the board. + +For example: +- collect blanket pieces → box gains a dry cover; +- clear rain blockers → water recedes; +- collect food → Mochi eats; +- clear debris → safe path opens. + +## Avoid +“Collect 30 purple pieces because the mission says purple means food.” + +--- + +# Home collection model + +Exactly four MVP territories: + +## CATBOOK +Who they are. + +Includes: +- rescue story; +- personality; +- bond; +- likes; +- discovered behaviors. + +## MEMORIES +What happened. + +Includes: +- first meeting; +- box moment; +- first sleep; +- funny interactions; +- photos; +- special events. + +## HOME +Where life happens. + +Includes: +- furniture; +- rest spots; +- toys; +- scratching; +- interactive decoration. + +## STYLE +How the player expresses affection. + +Includes: +- collars; +- tags; +- small accessories; +- selected light outfits. + +--- + +# Monetization principles + +## Good purchase thought +> “Mochi would look adorable with this.” + +> “I want this chair because he loves sleeping there.” + +> “This room theme feels like us.” + +## Bad purchase thought +> “I have to pay because the game blocked me.” + +### Primary monetizable surfaces +- home aesthetics; +- interactive furniture; +- collars; +- accessories; +- premium cosmetic sets; +- seasonal visual content; +- optional memory presentation; +- later pass structures only if value is clear. + +### Avoid as primary +- lives; +- energy; +- punitive cooldowns; +- gacha rescue; +- aggressive fail/revive; +- predatory scarcity. + +--- + +# Viral design + +Do not bribe sharing. + +Create shareable events: +- first rescue; +- box exit; +- first voluntary cuddle; +- bizarre sleeping position; +- two cats sharing an object; +- funny toy failure; +- favorite outfit; +- rare spontaneous room event. + +Share language should feel like: +> “Look what Mochi did.” + +Not: +> “Share to earn 100 coins.” + +--- + +# Art direction + +## Style target +Premium cozy stylization. + +### Shapes +- round; +- soft; +- readable; +- minimal sharp corners. + +### Materials +- matte; +- warm; +- tactile; +- fabric/cardboard/wood should feel distinct. + +### Lighting +- soft directional warmth; +- controlled highlights; +- readable cat silhouette. + +### Color +Use a disciplined palette. + +The cats must remain readable against the room. + +### UI +The UI should support the world, not dominate it. + +--- + +# Character design standard + +Each cat needs: +- silhouette; +- palette; +- eye language; +- ear language; +- tail language; +- idle vocabulary; +- motion rhythm; +- emotional archetype. + +Characters should differ through behavior, not only fur. + +--- + +# Motion language + +## Cats +Naturalistic stylization: +- micro-pauses; +- anticipation; +- asymmetry; +- sudden curiosity; +- slow comfort. + +## UI +- spring only when appropriate; +- soft easing; +- very little unnecessary bounce. + +## Puzzle +- immediate touch; +- crisp swaps; +- readable cascades; +- satisfying specials; +- controlled screen shake. + +--- + +# Audio language + +Target: +**cozy with headphones.** + +Important layers: +- cardboard; +- fabric; +- paws; +- soft room ambience; +- rain; +- window; +- purr; +- meow variation; +- toy sounds; +- understated UI; +- match/cascade layers. + +Silence is also a tool. + +--- + +# Haptics + +Use sparingly: +- select; +- valid match; +- special piece; +- large combo; +- rescue confirmation; +- box moment; +- meaningful affection. + +--- + +# Product constraints + +Before attachment validation: +- max 1 room; +- max 1 production-ready cat; +- no mass level production; +- no clans; +- no PvP; +- no giant crafting; +- no seven currencies; +- no 20 feature tabs; +- no LiveOps factory. + +--- + +# Success statement + +The vertical slice succeeds when players stop describing the experience as: + +> “a cute cat puzzle” + +and start saying something closer to: + +> “I rescued this little cat called Mochi and now he lives in my room.” diff --git a/projects/kitty-crush/QA_RELEASE_GATES.md b/projects/kitty-crush/QA_RELEASE_GATES.md new file mode 100644 index 0000000..e4d9903 --- /dev/null +++ b/projects/kitty-crush/QA_RELEASE_GATES.md @@ -0,0 +1,72 @@ +# WHISKERFOLK — QA / Release Gates + +## Gate A — Prototype Integrity +- JS parses successfully. +- Five vertical-slice scenes are wired. +- Reduced-motion CSS exists. +- No shop/currency in first-rescue UI. +- Match board has valid-move recovery. +- Puzzle failure does not hard-stop story. + +## Gate B — Production Core +Required on Godot project: +- build succeeds; +- unit tests pass; +- no unlicensed assets/code; +- save schema versioned; +- rescue state can resume after app background; +- no state loss after scene change. + +## Gate C — Mobile Interaction +Test on at least: +- low/mid Android; +- current mid Android; +- tall narrow aspect ratio; +- tablet ratio. + +Check: +- 48dp+ targets; +- safe areas; +- back behavior; +- orientation lock if chosen; +- touch drag/tap conflict; +- interrupted audio; +- app background/restore; +- notification interruption; +- low battery/performance mode. + +## Performance target +- target 60 FPS on representative mid Android; +- no sustained frame spikes during cascades; +- no runaway particle count; +- memory stable across 20 puzzle/home transitions; +- room animation update can throttle offscreen/nonessential actors. + +## Accessibility +- objective tiles differentiated by shape/pattern, not color only; +- reduced motion; +- haptics toggle; +- sound toggle; +- text contrast; +- readable dynamic text where feasible. + +## Emotional QA +Reviewers specifically flag: +- instant-love behavior; +- manipulative sadness; +- guilt copy; +- constant animation noise; +- “reward screen” overpowering rescue; +- generic mobile UI; +- Home becoming dashboard. + +## GO build definition +A build can be called Vertical Slice Candidate only when: +- full Mochi arc runs start-to-home; +- no blocker bug; +- character animation is production-near; +- audio pass exists; +- puzzle context changes world state visibly; +- analytics fires; +- save/resume works; +- at least one device playtest is recorded. diff --git a/projects/kitty-crush/README.md b/projects/kitty-crush/README.md new file mode 100644 index 0000000..24e470d --- /dev/null +++ b/projects/kitty-crush/README.md @@ -0,0 +1,51 @@ +# Kitty Project — Preproduction Source of Truth + +**Working codename:** Kitty Crush / Kitty Rescue +**Status:** PREPRODUCTION — MODIFY +**Branch purpose:** isolated strategy/design documentation only. No production project has been overwritten. + +This folder is the current source of truth for the reinvention of the cat-rescue game. + +## Decision + +We are **not** building a generic match-3 with cats as rewards. + +The target product is: + +> **Find them → help them → earn their trust → rescue them → bring them home → live with them → miss them → return.** + +The puzzle is a delivery mechanism for the fantasy. The cat is the product. + +## North-star hypothesis + +> **Can a player fall in love with one digital cat within the first 10–15 minutes and genuinely want to come back to see it again?** + +If Mochi does not work, mass-producing levels, cats, rooms, events or monetization is prohibited. + +## Documents + +- `MASTER_EXECUTION_PLAN.md` — strategic audit, full phased roadmap, vertical slice and GO criteria. +- `PRODUCT_BIBLE.md` — product fantasy, emotional loop, Mochi, home, collections, monetization and design principles. +- `TECHNICAL_FOUNDATION.md` — candidate technical base, what can be reused, rejected or rebuilt. + +## Current technical reality + +There is currently **no dedicated Kitty repository** in the connected GitHub account. Therefore this branch is a temporary preproduction home only. + +Two open-source references were reviewed: + +1. `immane/match3-pets` — Godot 4.6 .NET / C#, MIT. Useful match-3 architecture and tests; product direction must be heavily modified. +2. `iuhoay/cat-run` — browser virtual-pet room prototype. Useful as a behavior/room reference; punitive need-decay is not aligned with our target. + +A dedicated Kitty repository should be created when implementation begins. These documents should then move there unchanged as the initial design/decision baseline. + +## Non-negotiables + +- 1 room before 10 rooms. +- 1 unforgettable cat before 8 cats. +- 4 collection territories before feature sprawl. +- Monetize identity and expression before frustration. +- No punitive Tamagotchi loop. +- No gacha as the emotional center. +- No 500-level production run before attachment validation. +- Premium-casual quality target, not “prototype-looking but functional”. diff --git a/projects/kitty-crush/TECHNICAL_FOUNDATION.md b/projects/kitty-crush/TECHNICAL_FOUNDATION.md new file mode 100644 index 0000000..df7d735 --- /dev/null +++ b/projects/kitty-crush/TECHNICAL_FOUNDATION.md @@ -0,0 +1,384 @@ +# KITTY PROJECT — TECHNICAL FOUNDATION + +## 1. Purpose + +This document identifies what can be accelerated from reviewed open-source references without inheriting product decisions that conflict with Kitty's new direction. + +--- + +# 2. Reference A — immane/match3-pets + +Repository: +https://github.com/immane/match3-pets + +License: +MIT. + +Engine: +Godot 4.6 .NET / C#. + +## Strong reusable foundation + +### Core board +- BoardData +- MatchDetector +- GravitySystem +- SpawnSystem +- ValidMoveChecker +- MatchResult + +These are valuable because they separate board logic from presentation. + +### Runtime/gameplay +- Board +- GameStateMachine +- TileManager +- AnimationController + +### Infrastructure +- EventBus +- ServiceInitializer +- persistent-storage abstraction +- object pooling +- dynamic board layout + +### Tests +The repository documents 41 tests across 10 xUnit files. + +This is unusually valuable for rapid iteration because the puzzle core can be modified without relying only on manual playtesting. + +--- + +# 3. What must NOT be inherited as product strategy + +## Countdown timer +The 30-second score-attack model fights the cozy/emotional pacing. + +Default action: +**REMOVE from the vertical slice.** + +## Gacha +Current reference includes: +- banners; +- pull cost; +- rarity; +- pity; +- multi-pull. + +Default action: +**DO NOT USE for rescuing cats.** + +Cat acquisition through rescue is strategically superior for this product identity. + +## Pet needs +The reference includes needs/care framing. + +Default action: +**REPLACE with relationship/bond state and preference discovery.** + +## Generic pet roster +Cat/dog/bunny/duck weakens the specific IP thesis. + +Default action: +**CATS ONLY for initial product.** + +--- + +# 4. Proposed owned architecture + +When the dedicated repo is created, aim for a data-driven architecture similar to: + +``` +src/ + core/ + puzzle/ + BoardData + MatchDetector + GravitySystem + SpawnSystem + ObjectiveSystem + BlockerSystem + ValidMoveChecker + gameplay/ + puzzle/ + Board + TileManager + PuzzleStateMachine + PuzzlePresentation + rescue/ + RescueArc + RescueBeat + RescueDirector + RescueProgress + cats/ + CatDefinition + CatInstance + CatActor + CatBehaviorController + CatPersonality + CatBondState + CatPreferenceProfile + home/ + HomeScene + HomeObjectDefinition + HomeObjectInstance + InteractionSlot + NavigationSurface + BehaviorScheduler + memories/ + MemoryDefinition + MemoryTrigger + MemoryCollection + ui/ + onboarding/ + home/ + catbook/ + memories/ + style/ + services/ + SaveService + AnalyticsService + AudioService + HapticsService + EconomyService + tests/ +``` + +Names are illustrative, not mandatory. + +--- + +# 5. Cat behavior architecture + +Avoid hardcoding every cat scene separately. + +Recommended model: + +``` +BehaviorScore = + personalityWeight ++ contextWeight ++ objectAffinity ++ bondModifier ++ recencyPenalty ++ smallRandomness +``` + +Then select from allowed behaviors. + +Example: + +Mochi sees: +- box; +- window; +- toy; +- player; +- bed. + +His personality can make: +- box hiding high probability; +- player approach low at low bond; +- window watching medium; +- toy curiosity medium-high. + +After bond increases: +- approach probability rises; +- hide frequency falls; +- player-near sleeping becomes possible. + +This creates visible relationship progression without requiring complex AI. + +--- + +# 6. Rescue architecture + +A RescueArc should be data-driven. + +Example conceptual data: + +``` +MochiArc + beat 1: discover + beat 2: food objective + beat 3: trust reaction + beat 4: shelter objective + beat 5: threat + beat 6: rescue objective + beat 7: transition + beat 8: box moment +``` + +Each beat can trigger: +- scene state; +- dialogue/copy; +- puzzle objective; +- animation; +- sound; +- cat bond update; +- memory unlock. + +This keeps narrative and puzzle connected. + +--- + +# 7. Puzzle objective system + +Do not hardwire level goals into UI text. + +Define objective types. + +Examples: +- CollectItem; +- ClearBlocker; +- RevealPath; +- ProtectObject; +- DropObject; +- ChargeObject. + +The same mechanical primitive can be skinned carefully for different rescue contexts. + +--- + +# 8. Living-room interactions + +Every interactive home object should describe: + +- interaction points; +- valid cat poses; +- behavior tags; +- duration range; +- animation key; +- sound key; +- memory triggers; +- personality affinities. + +Example: + +``` +CardboardBox + tags: hide, sleep, curious + slots: + inside + top + MochiAffinity: very_high +``` + +This lets content designers add future objects without custom scripting for every cat/object combination. + +--- + +# 9. Save data + +Minimum persistent state: + +- rescued cats; +- bond state; +- discovered preferences; +- discovered behaviors; +- home inventory; +- placed objects; +- memories; +- story/rescue progress; +- soft currency later; +- cosmetic ownership; +- settings; +- analytics consent/state as required. + +Version save schemas from the start. + +--- + +# 10. Analytics architecture + +Use a thin abstraction so gameplay never depends directly on a vendor SDK. + +Example: +`AnalyticsService.Track(eventName, payload)` + +This allows: +- local debug logging; +- later provider swap; +- tests; +- privacy control. + +--- + +# 11. Performance target + +Target a stable 60 FPS on mid-range Android where practical. + +Budget aggressively: +- texture atlases; +- pooled puzzle objects; +- limited simultaneous particles; +- limited live cat count in MVP; +- preloaded critical audio; +- lazy load noncritical collections; +- avoid huge 4K spritesheets. + +The visual target is premium, not wasteful. + +--- + +# 12. Testing strategy + +## Unit +- match detection; +- cascades; +- valid moves; +- objectives; +- blockers; +- rescue state progression; +- bond thresholds; +- save migration. + +## Integration +- puzzle completion updates rescue; +- rescue updates home; +- home unlock triggers memory; +- cat preference affects behavior. + +## Playtest +- first 15 minutes; +- no-input home observation; +- interruption/background/resume; +- low-end mobile touch; +- save/reload after each beat. + +--- + +# 13. License discipline + +If code from `immane/match3-pets` is incorporated: +- retain the MIT copyright/license notice as required; +- document which files were adapted; +- avoid presenting third-party code as original. + +For `iuhoay/cat-run`, use design concepts only until the exact license text is verified in the repository itself, even though its README states MIT. + +--- + +# 14. Technical verdict + +### RECOMMENDED + +Use `match3-pets` as a **puzzle-engine reference/accelerator**, not as the product shell. + +Rebuild the player-facing architecture around: +- rescue; +- character; +- home; +- bond; +- memories. + +### Most important technical transformation + +Replace: + +**PetAcquired → PetNeeds → Gacha → PetRoom** + +with: + +**CatDiscovered → RescueArc → BondState → LivingHome → Memory** + +That transformation is the code-level expression of the product strategy. diff --git a/projects/kitty-crush/TECHNICAL_VALIDATION.md b/projects/kitty-crush/TECHNICAL_VALIDATION.md new file mode 100644 index 0000000..ef5be18 --- /dev/null +++ b/projects/kitty-crush/TECHNICAL_VALIDATION.md @@ -0,0 +1,85 @@ +# WHISKERFOLK — Technical Validation + +Updated: 2026-10-02 + +## Current validated baseline + +GitHub Actions workflow: +`.github/workflows/whiskerfolk-ci.yml` + +Latest validated build/test evidence after the runtime/core split and mobile/audio work: + +- Godot C# runtime build: **SUCCESS** +- Compiler warnings: **0** +- Compiler errors: **0** +- Core tests: **57 / 57 PASSED** +- Web prototype JavaScript syntax: **PASS** +- Godot main scene contract: **PASS** +- Android VIBRATE permission contract: **PASS** +- Third-party MIT license retention: **PASS** +- Runtime test-package isolation: **PASS** + +Reference successful workflow lineage: +- run 47: Home object affinity fix — success +- run 49: living Home + replay-safe Box Moment — success +- run 51: Mochi microexpressions — success +- current audio/back baseline: build 0 warnings / 0 errors, 57/57 tests in CI logs + +## What CI currently proves + +It proves: +- C# code compiles against Godot.NET.Sdk 4.6.3; +- the engine-agnostic domain core is unit-tested; +- rescue arc order is valid; +- economy wallet guards are valid; +- Mochi identity/roster contracts are intact; +- Home affinity logic respects Mochi's box preference; +- match detection/gravity/spawn/score/special/move logic passes tests; +- Android/project configuration contracts remain present; +- web prototype JS remains syntactically valid. + +## What CI does NOT prove + +It does not prove: +- Android APK installs on the user's physical device; +- real FPS/memory/thermal performance; +- actual haptic strength/feel; +- final audio quality on phone speakers/headphones; +- final animation/art quality; +- Play Console readiness; +- human emotional attachment. + +Those remain release gates. + +## Architecture baseline + +``` +Whiskerfolk.Core (.NET 8, engine-agnostic) +├── puzzle +├── objectives +├── cats / bond / behavior +├── rescue +├── home +├── memories +├── economy +├── analytics contract +└── save model + +Whiskerfolk (Godot 4.6 C#) +├── scene/presentation +├── Mochi proxy renderer +├── mobile lifecycle +├── save adapter +├── haptics +├── procedural audio proxy +└── Home runtime + +Whiskerfolk.Tests +└── xUnit domain/core contract suite +``` + +## Quality rule + +A green CI run is necessary but not sufficient for GO. + +GO still requires production-near art/audio/animation, Android device QA and attachment playtesting. diff --git a/projects/kitty-crush/VERTICAL_SLICE_SPEC.md b/projects/kitty-crush/VERTICAL_SLICE_SPEC.md new file mode 100644 index 0000000..03adb18 --- /dev/null +++ b/projects/kitty-crush/VERTICAL_SLICE_SPEC.md @@ -0,0 +1,155 @@ +# WHISKERFOLK — Mochi Vertical Slice v0.1 + +## Target duration +10–15 minutes first-time experience. + +## Target emotional curve +curiosity → concern → usefulness → trust → urgency → rescue → quiet relief → belonging + +## Scene 1 — RAIN / THE BOX +No logo-first menu. + +Black screen. +Rain. +Cardboard scrape. +A small “mrrp?” + +Camera reveals a damp alley edge / protected storefront corner. +Mochi's eyes are visible in a box. + +Player only receives one contextual prompt: +**Look closer.** + +No currency, profile, inbox, pass, streak, shop or event icons. + +### Goal +Player identifies a living character before identifying game systems. + +## Scene 2 — FIRST HELP / FOOD +Mochi will not exit. + +A tactile puzzle appears as the foreground interaction. +Objective: recover 6 food portions from blocked pieces. + +On completion: +- no star rating; +- camera returns to world; +- food appears physically; +- Mochi waits, then eats only after player gives distance. + +First relationship signal: +**He stayed.** + +## Scene 3 — SHELTER +Rain intensifies. +The box roof begins to sag. + +Second puzzle introduces: +- blanket pieces; +- soaked cardboard blocker; +- one special-piece tutorial. + +Completion visibly changes the box: +- dry cloth cover; +- warmer interior; +- rain sound softens locally. + +Mochi performs the first double-check toward the player. + +## Scene 4 — TRUST +No puzzle for 20–40 seconds. + +Player can: +- hold hand nearby; +- move feather slowly; +- wait. + +Correct low-pressure action increases trust. + +Rapid tapping causes a mild retreat, not punishment. + +Mochi approaches, sniffs, retreats, returns. + +## Scene 5 — RESCUE PRESSURE +Water begins entering the corner / street noise grows. +The danger is environmental, not graphic. + +Third puzzle: +- clear a safe path; +- drop the carrier latch key; +- protect one “safe tile” from flooding. + +This is the first multi-objective puzzle. + +## Scene 6 — RESCUE +Mochi stops at the carrier. + +Player cannot drag him in. + +One quiet interaction: +**Open it and wait.** + +Mochi: +- sniffs; +- circles; +- enters voluntarily. + +Audio drops to rain + carrier latch. + +## Scene 7 — TRANSITION +Short travel montage: +- car/window rain blur; +- carrier breathing; +- no reward summary. + +## Scene 8 — THE BOX MOMENT +Warm room. +Carrier/box placed on floor. + +Player is told nothing for several seconds. + +Mochi: +1. peeks; +2. retreats; +3. peeks again; +4. exits; +5. freezes at room scale; +6. finds the cardboard box left nearby; +7. enters; +8. settles. + +Text: +**Mochi lives here now.** + +No confetti. + +A small Catbook portrait appears only after the moment lands. + +## Scene 9 — FIRST HOME FREEDOM +Player can spend 1–2 minutes doing nothing. + +Mochi autonomously: +- watches window; +- moves to box; +- sniffs blanket; +- looks toward player. + +One object can be placed: +a folded blanket or small scratcher. + +Choice produces immediate world change. + +## Vertical-slice end +Mochi settles. +A single next-day teaser: +**He hasn't explored the window yet.** + +Return to Home, not a level map. + +## Acceptance criteria +- 90%+ understand that they rescued Mochi. +- Players can name one Mochi personality trait unaided. +- At least half voluntarily wait/watch during Box Moment without tapping through. +- Players do not describe the first session primarily as “match-3”. +- Home observation without prompt reaches measurable dwell time. +- No screen presents more than one dominant CTA during the first 15 minutes. diff --git a/projects/kitty-crush/assets/README.md b/projects/kitty-crush/assets/README.md new file mode 100644 index 0000000..66b7482 --- /dev/null +++ b/projects/kitty-crush/assets/README.md @@ -0,0 +1,33 @@ +# Visual Assets + +These assets are **original Whiskerfolk direction assets** created for the vertical slice. + +## Files +- `mochi_app_icon_direction.svg` — app-icon/character key-art direction. +- `whiskerfolk_wordmark_direction.svg` — provisional wordmark direction. + +## Status +**PRODUCTION DIRECTION — NOT FINAL ART** + +They are intentionally usable in prototypes, but the AAA gate still requires: +- final character model/sprite rig; +- expression sheet; +- animation pass; +- art-director review; +- store-size raster exports; +- trademark/name clearance before final wordmark lock. + +## Visual invariants to preserve +Mochi: +- cream body; +- caramel right ear; +- caramel comma near one eye; +- moss-green eyes; +- cardboard association; +- cautious rather than permanently smiling expression. + +Brand: +- warm milk background; +- deep ink; +- restrained caramel accent; +- subtle whisker/tail logic, not generic paw-print branding. diff --git a/projects/kitty-crush/assets/brand/whiskerfolk_wordmark_direction.svg b/projects/kitty-crush/assets/brand/whiskerfolk_wordmark_direction.svg new file mode 100644 index 0000000..4bca027 --- /dev/null +++ b/projects/kitty-crush/assets/brand/whiskerfolk_wordmark_direction.svg @@ -0,0 +1,12 @@ + +Whiskerfolk wordmark concept + + + + + + +WHISKERFOLK + + + \ No newline at end of file diff --git a/projects/kitty-crush/assets/mochi/mochi_app_icon_direction.svg b/projects/kitty-crush/assets/mochi/mochi_app_icon_direction.svg new file mode 100644 index 0000000..183bbcb --- /dev/null +++ b/projects/kitty-crush/assets/mochi/mochi_app_icon_direction.svg @@ -0,0 +1,60 @@ + +Whiskerfolk Mochi icon +Mochi peeking from a warm cardboard box against a deep rain-blue background. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/projects/kitty-crush/godot/.gitignore b/projects/kitty-crush/godot/.gitignore new file mode 100644 index 0000000..9ff2426 --- /dev/null +++ b/projects/kitty-crush/godot/.gitignore @@ -0,0 +1,8 @@ +.godot/ +bin/ +obj/ +build/ +*.apk +*.aab +*.keystore +.godot/export_credentials.cfg diff --git a/projects/kitty-crush/godot/ANDROID_BUILD.md b/projects/kitty-crush/godot/ANDROID_BUILD.md new file mode 100644 index 0000000..d17ece9 --- /dev/null +++ b/projects/kitty-crush/godot/ANDROID_BUILD.md @@ -0,0 +1,56 @@ +# Android Build — Whiskerfolk Vertical Slice + +## Godot baseline +- Godot 4.6 .NET +- .NET 8 +- Android target uses ARM64 only for the vertical slice. + +## Local prerequisites for Godot 4.6 Android export +Based on the official Godot 4.6 Android export documentation: +- OpenJDK 17 +- Android SDK Platform-Tools 35.0.0+ +- Android SDK Build-Tools 35.0.1 +- Android SDK Platform 35 +- Android command-line tools (latest) +- CMake 3.10.2.4988404 +- NDK r28b / 28.1.13356709 + +## Godot editor settings +Set: +- Java SDK Path +- Android SDK Path + +Do not commit machine-specific SDK paths. + +## Haptics +`HapticsService` uses `Input.VibrateHandheld()`. + +The Android preset explicitly enables: +``` +permissions/vibrate=true +``` + +Without that permission, Android handheld vibration will not run. + +## Build +From Godot: +1. Open `project.godot`. +2. Allow C# restore/build. +3. Project → Export. +4. Select **Android Vertical Slice**. +5. Export debug APK for device testing. + +CLI after Android/Godot setup: +```bash +godot --path . --export-debug "Android Vertical Slice" build/Whiskerfolk-vertical-slice.apk +``` + +## Play Store later +Do NOT store release keystore/passwords in this repository. + +For release builds, credentials belong in Godot's local export credentials configuration / secure CI secrets. + +The production release should be AAB, signed with the final EchauriApps/Play Console signing setup. + +## C# Android caution +Godot 4.6 documentation still labels C# Android export support as experimental. Device QA is therefore a mandatory release gate, not optional. diff --git a/projects/kitty-crush/godot/THIRD_PARTY_MATCH3_PETS_LICENSE.txt b/projects/kitty-crush/godot/THIRD_PARTY_MATCH3_PETS_LICENSE.txt new file mode 100644 index 0000000..980ca57 --- /dev/null +++ b/projects/kitty-crush/godot/THIRD_PARTY_MATCH3_PETS_LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lam K. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/projects/kitty-crush/godot/Whiskerfolk.csproj b/projects/kitty-crush/godot/Whiskerfolk.csproj new file mode 100644 index 0000000..96c1b61 --- /dev/null +++ b/projects/kitty-crush/godot/Whiskerfolk.csproj @@ -0,0 +1,27 @@ + + + net8.0 + true + Whiskerfolk + false + LatestMajor + enable + + + + + + + + + + + + + + + + + + + diff --git a/projects/kitty-crush/godot/core/Whiskerfolk.Core.csproj b/projects/kitty-crush/godot/core/Whiskerfolk.Core.csproj new file mode 100644 index 0000000..5b602bb --- /dev/null +++ b/projects/kitty-crush/godot/core/Whiskerfolk.Core.csproj @@ -0,0 +1,21 @@ + + + net8.0 + Whiskerfolk + enable + enable + false + + + + + + + + + + + + + + diff --git a/projects/kitty-crush/godot/export_presets.cfg b/projects/kitty-crush/godot/export_presets.cfg new file mode 100644 index 0000000..10762fd --- /dev/null +++ b/projects/kitty-crush/godot/export_presets.cfg @@ -0,0 +1,45 @@ +[preset.0] + +name="Android Vertical Slice" +platform="Android" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="tests/*,build/*,.godot/*" +export_path="build/Whiskerfolk-vertical-slice.apk" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" +gradle_build/use_gradle_build=true +gradle_build/export_format=0 +gradle_build/min_sdk="24" +gradle_build/target_sdk="35" +architectures/armeabi-v7a=false +architectures/arm64-v8a=true +architectures/x86=false +architectures/x86_64=false +version/code=1 +version/name="0.1.0" +package/unique_name="com.echauriapps.whiskerfolk" +package/name="Whiskerfolk" +screen/immersive_mode=true +screen/support_small=true +screen/support_normal=true +screen/support_large=true +screen/support_xlarge=true +user_data_backup=false +command_line/extra_args="" +xr_features/xr_mode=0 +permissions/vibrate=true diff --git a/projects/kitty-crush/godot/project.godot b/projects/kitty-crush/godot/project.godot new file mode 100644 index 0000000..bcf1e1e --- /dev/null +++ b/projects/kitty-crush/godot/project.godot @@ -0,0 +1,26 @@ +; Engine configuration file. +config_version=5 + +[application] +config/name="Whiskerfolk" +config/description="A premium cozy cat rescue game about trust, home and character." +config/version="0.1.0-vertical-slice" +run/main_scene="res://scenes/Main.tscn" +config/quit_on_go_back=false + +[display] +window/size/viewport_width=1080 +window/size/viewport_height=1920 +window/stretch/mode="canvas_items" +window/stretch/aspect="expand" + +[dotnet] +project/assembly_name="Whiskerfolk" + +[rendering] +renderer/rendering_method="mobile" +textures/canvas_textures/default_texture_filter=1 +rendering/2d/batching=true + +[input_devices] +pointing/emulate_touch_from_mouse=true diff --git a/projects/kitty-crush/godot/scenes/Main.tscn b/projects/kitty-crush/godot/scenes/Main.tscn new file mode 100644 index 0000000..b0a8588 --- /dev/null +++ b/projects/kitty-crush/godot/scenes/Main.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/app/WhiskerfolkBootstrap.cs" id="1_app"] + +[node name="Whiskerfolk" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_app") diff --git a/projects/kitty-crush/godot/scripts/analytics/DebugAnalyticsService.cs b/projects/kitty-crush/godot/scripts/analytics/DebugAnalyticsService.cs new file mode 100644 index 0000000..79c5b9e --- /dev/null +++ b/projects/kitty-crush/godot/scripts/analytics/DebugAnalyticsService.cs @@ -0,0 +1,14 @@ +using Godot; +using System.Collections.Generic; +using System.Text.Json; + +namespace Whiskerfolk.Analytics; + +public sealed class DebugAnalyticsService : IAnalyticsService +{ + public void Track(string eventName, IReadOnlyDictionary? payload = null) + { + var suffix = payload is null ? string.Empty : " " + JsonSerializer.Serialize(payload); + GD.Print($"[analytics] {eventName}{suffix}"); + } +} diff --git a/projects/kitty-crush/godot/scripts/analytics/IAnalyticsService.cs b/projects/kitty-crush/godot/scripts/analytics/IAnalyticsService.cs new file mode 100644 index 0000000..12b2585 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/analytics/IAnalyticsService.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace Whiskerfolk.Analytics; + +public interface IAnalyticsService +{ + void Track(string eventName, IReadOnlyDictionary? payload = null); +} + +public static class AnalyticsEvents +{ + public const string FirstMochiSeen = "first_mochi_seen"; + public const string FirstHelpAction = "first_help_action"; + public const string FirstPuzzleStarted = "first_puzzle_started"; + public const string FirstPuzzleCompleted = "first_puzzle_completed"; + public const string FirstTrustResponse = "first_trust_response"; + public const string RescueStarted = "rescue_started"; + public const string RescueCompleted = "rescue_completed"; + public const string BoxMomentStarted = "box_moment_started"; + public const string BoxMomentCompleted = "box_moment_completed"; + public const string HomeFirstEntry = "home_first_entry"; + public const string FirstAffection = "first_affection"; + public const string FirstObjectInteraction = "first_object_interaction"; + public const string FirstMemoryCreated = "first_memory_created"; + public const string FirstCosmeticEquipped = "first_cosmetic_equipped"; +} diff --git a/projects/kitty-crush/godot/scripts/app/MochiActor.cs b/projects/kitty-crush/godot/scripts/app/MochiActor.cs new file mode 100644 index 0000000..87b7ebf --- /dev/null +++ b/projects/kitty-crush/godot/scripts/app/MochiActor.cs @@ -0,0 +1,203 @@ +using Godot; +using System; + +namespace Whiskerfolk.App; + +public partial class MochiActor : Control +{ + private static readonly Color Cream = new("#F7EEDF"); + private static readonly Color CreamShadow = new("#E8D9C2"); + private static readonly Color Caramel = new("#C58A5D"); + private static readonly Color Eye = new("#4D6B55"); + private static readonly Color Nose = new("#9F6C65"); + private static readonly Color Ink = new("#5C514A"); + + private readonly Random _random = new(2190); + private float _time; + private float _trust; + private string _mood = "watching"; + + private float _nextBlink = 2.2f; + private float _blinkRemaining; + private float _nextEarTwitch = 1.6f; + private float _earTwitchRemaining; + + public float Trust + { + get => _trust; + set + { + _trust = Mathf.Clamp(value, 0, 100); + QueueRedraw(); + } + } + + public string Mood + { + get => _mood; + set + { + _mood = value; + QueueRedraw(); + } + } + + public override void _Ready() + { + MouseFilter = MouseFilterEnum.Ignore; + CustomMinimumSize = new Vector2(220, 220); + SetProcess(true); + } + + public override void _Process(double delta) + { + var dt = (float)delta; + _time += dt; + + _nextBlink -= dt; + if (_nextBlink <= 0f) + { + _blinkRemaining = 0.11f; + _nextBlink = 2.4f + (float)_random.NextDouble() * 4.2f; + } + _blinkRemaining = Mathf.Max(0f, _blinkRemaining - dt); + + _nextEarTwitch -= dt; + if (_nextEarTwitch <= 0f) + { + _earTwitchRemaining = 0.18f; + _nextEarTwitch = 3.0f + (float)_random.NextDouble() * 6.0f; + } + _earTwitchRemaining = Mathf.Max(0f, _earTwitchRemaining - dt); + + QueueRedraw(); + } + + public override void _Draw() + { + var relaxed = _mood == "home"; + var curious = _mood == "curious"; + var hiding = _mood == "hiding"; + + var breathAmplitude = relaxed ? 1.6f : 2.2f; + var breath = Mathf.Sin(_time * (relaxed ? 1.55f : 1.9f)) * breathAmplitude; + var crouch = hiding ? 8f : 0f; + var headShift = new Vector2( + curious ? 3f : 0f, + crouch + (curious ? -2f : 0f) + ); + + Vector2 H(float x, float y) => new(x + headShift.X, y + headShift.Y); + + var bodyCenter = new Vector2(112, 137 + crouch + breath * 0.35f); + var headCenter = H(105, 82 + breath * 0.22f); + + var tailEnergy = curious ? 0.16f : relaxed ? 0.05f : 0.09f; + var tailWave = Mathf.Sin(_time * (curious ? 2.1f : 1.15f)) * tailEnergy; + + // Tail: mood changes the rhythm, but movement stays small and cat-like. + DrawArc( + new Vector2(144, 139 + crouch), + 53, + -1.12f + tailWave, + 0.62f + tailWave, + 26, + CreamShadow, + 18, + true + ); + DrawArc( + new Vector2(144, 139 + crouch), + 53, + 0.30f + tailWave, + 0.62f + tailWave, + 8, + Caramel, + 19, + true + ); + + // Body. + DrawCircle(bodyCenter, hiding ? 57 : 61, CreamShadow); + DrawCircle(bodyCenter + new Vector2(-8, -4), hiding ? 51 : 55, Cream); + + var twitch = _earTwitchRemaining > 0f + ? Mathf.Sin((_earTwitchRemaining / 0.18f) * Mathf.Pi) * 5f + : 0f; + + // Ears. Mochi's caramel ear carries most of the micro-twitch. + DrawColoredPolygon( + new Vector2[] { H(48, 63), H(66, 18), H(89, 68) }, + Cream + ); + DrawColoredPolygon( + new Vector2[] { H(123, 62), H(151 + twitch, 17 - twitch), H(161, 73) }, + Caramel + ); + DrawColoredPolygon( + new Vector2[] { H(57, 57), H(67, 32), H(80, 61) }, + new Color("#DDB9A7") + ); + DrawColoredPolygon( + new Vector2[] { H(133, 58), H(149 + twitch * 0.6f, 31 - twitch * 0.6f), H(153, 63) }, + new Color("#D7A483") + ); + + // Head. + DrawCircle(headCenter, hiding ? 51 : 53, Cream); + + // Caramel comma marking. + DrawCircle(H(137, 74), 13, Caramel); + DrawCircle(H(130, 64), 8, Cream); + + var blinking = _blinkRemaining > 0f; + var eyeHeight = blinking ? 1.4f : hiding ? 6.8f : relaxed ? 7.6f : 8.5f; + var gaze = curious ? Mathf.Sin(_time * 0.55f) * 2.0f : 0f; + + DrawEllipse(H(85 + gaze, 87), new Vector2(6.5f, eyeHeight), Eye); + DrawEllipse(H(124 + gaze, 87), new Vector2(6.5f, eyeHeight), Eye); + + if (!blinking) + { + DrawCircle(H(83 + gaze, 84), 2.0f, Colors.White); + DrawCircle(H(122 + gaze, 84), 2.0f, Colors.White); + } + + // Nose and mouth: neutral, not a permanent cartoon smile. + DrawCircle(H(105, 103), 4.8f, Nose); + DrawLine(H(105, 107), H(105, 112), Ink, 1.6f, true); + DrawArc(H(99, 111), 7, 0.1f, 1.1f, 8, Ink, 1.4f, true); + DrawArc(H(111, 111), 7, 2.0f, 3.0f, 8, Ink, 1.4f, true); + + // Whiskers. + DrawLine(H(83, 104), H(48, 98), Ink, 1.1f, true); + DrawLine(H(82, 110), H(45, 111), Ink, 1.1f, true); + DrawLine(H(128, 104), H(164, 98), Ink, 1.1f, true); + DrawLine(H(128, 110), H(166, 112), Ink, 1.1f, true); + + // Front paws. The second paw becomes visible as trust/comfort increases. + DrawCircle(new Vector2(82, 181 + crouch), 18, Cream); + if (_trust > 38 || relaxed) + DrawCircle(new Vector2(117, 181 + crouch), 18, Cream); + + if (hiding) + { + // Soft occlusion cue; the final rig will use actual object layering. + DrawCircle(new Vector2(108, 148 + crouch), 54, new Color(0.15f, 0.15f, 0.15f, 0.07f)); + } + } + + private void DrawEllipse(Vector2 center, Vector2 radii, Color color) + { + var points = new Vector2[24]; + for (var i = 0; i < points.Length; i++) + { + var angle = Mathf.Tau * i / points.Length; + points[i] = center + new Vector2( + Mathf.Cos(angle) * radii.X, + Mathf.Sin(angle) * radii.Y + ); + } + DrawColoredPolygon(points, color); + } +} diff --git a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs new file mode 100644 index 0000000..34caf7f --- /dev/null +++ b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs @@ -0,0 +1,1167 @@ +using Godot; +using System; +using System.Collections.Generic; +using System.Linq; +using Whiskerfolk.Cats; +using Whiskerfolk.Puzzle; +using Whiskerfolk.Rescue; +using Whiskerfolk.Home; +using Whiskerfolk.Memories; +using Whiskerfolk.Analytics; +using Whiskerfolk.Services; +using Whiskerfolk.Save; + +namespace Whiskerfolk.App; + +public partial class WhiskerfolkBootstrap : Control +{ + private static readonly Color Ink = new("#27312E"); + private static readonly Color SoftInk = new("#51605B"); + private static readonly Color Milk = new("#FFF9EF"); + private static readonly Color Paper = new("#F5E9D7"); + private static readonly Color Moss = new("#6F8F78"); + private static readonly Color Sage = new("#AFC8B4"); + private static readonly Color Caramel = new("#C88B5A"); + private static readonly Color Rain = new("#7896A6"); + private static readonly Color Night = new("#273B49"); + + private readonly Color[] _tileColors = + { + new("#C88B5A"), + new("#8BA892"), + new("#7896A6"), + new("#D29E80"), + new("#7D946D") + }; + + private readonly string[] _tileMarks = { "●", "▦", "◆", "✦", "○" }; + + private RescueDirector _rescue = null!; + private BondState _bond = new(); + private readonly IAnalyticsService _analytics = new DebugAnalyticsService(); + private readonly HapticsService _haptics = new(); + private readonly AudioService _audio = new(); + private readonly SaveService _saveService = new(); + private SaveGame _save = null!; + private BoardData? _board; + private ObjectiveProgress? _objective; + private GridContainer? _grid; + private Label? _objectiveLabel; + private Label? _movesLabel; + private int _moves; + private Vector2I? _selected; + private bool _inputLocked; + private bool _holdingTrust; + private float _holdProgress; + private ProgressBar? _trustBar; + private Label? _trustCopy; + private MochiActor? _mochi; + private int _trustApproachPhase; + private bool _homeActive; + private float _homeBehaviorTimer; + private Control? _homeRoomRoot; + private CatState? _homeCatState; + private readonly BehaviorScheduler _homeScheduler = new(seed: 20261001); + private readonly List _homeObjects = new(); + private readonly Random _homeRandom = new(20261001); + private ColorRect? _activeSheetOverlay; + private PanelContainer? _activeSheetPanel; + + public override void _Ready() + { + SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + + AddChild(_audio); + _audio.Register("match", ProceduralAudioFactory.SoftTone(440f, 0.10f, 0.07f, 0.08f)); + _audio.Register("mrrp", ProceduralAudioFactory.QuestionMrrp()); + _audio.Register("rescue", ProceduralAudioFactory.RescueChime()); + _audio.Register("purr", ProceduralAudioFactory.Purr()); + _audio.Register("cardboard", ProceduralAudioFactory.Cardboard()); + + _save = _saveService.LoadOrCreate(); + if (_save.CatTrust.TryGetValue("mochi", out var savedTrust)) + _bond.AddTrust(savedTrust); + + _rescue = new RescueDirector(RescueArc.MochiFirstNight(), _save.CurrentRescueBeat); + ResumeFromSave(); + } + + public override void _Notification(int what) + { + if (what == NotificationApplicationPaused) + { + PersistCurrentState(); + return; + } + + if (what != NotificationWMGoBackRequest) + return; + + if (CloseActiveSheet()) + return; + + PersistCurrentState(); + GetTree().Quit(); + } + + private void PersistCurrentState() + { + if (_save is null) + return; + + _save.CurrentRescueBeat = _rescue?.BeatIndex ?? _save.CurrentRescueBeat; + _save.CatTrust["mochi"] = _bond.Trust; + _saveService.Save(_save); + } + + private bool CloseActiveSheet() + { + if (_activeSheetOverlay is null && _activeSheetPanel is null) + return false; + + _activeSheetOverlay?.QueueFree(); + _activeSheetPanel?.QueueFree(); + _activeSheetOverlay = null; + _activeSheetPanel = null; + return true; + } + + private void ResumeFromSave() + { + switch (_rescue.BeatIndex) + { + case 0: + BuildIntro(); + break; + case 1: + BuildFoodPuzzle(); + break; + case 2: + BuildTrust(); + break; + case 3: + BuildShelterPuzzle(); + break; + case 4: + AdvanceStory("water_rises"); + BuildSafePathPuzzle(); + break; + case 5: + BuildSafePathPuzzle(); + break; + case 6: + BuildRescue(); + break; + default: + BuildHome(); + break; + } + } + + private bool AdvanceStory(string completedBeatId) + { + if (!_rescue.TryAdvance(completedBeatId)) + return false; + + _save.CurrentRescueBeat = _rescue.BeatIndex; + _save.CatTrust["mochi"] = _bond.Trust; + _saveService.Save(_save); + return true; + } + + public override void _Process(double delta) + { + if (_holdingTrust && _trustBar is not null) + ProcessTrust((float)delta); + + if (_homeActive) + { + _homeBehaviorTimer -= (float)delta; + if (_homeBehaviorTimer <= 0f) + RunHomeBehavior(); + } + } + + private void ProcessTrust(float delta) + { + // Deliberately slower than a normal progress meter: this is a relationship beat, + // not a button-hold skill check. + _holdProgress = Mathf.Min(100f, _holdProgress + delta * 6.2f); + _bond.AddTrust(delta * 4.5f); + + if (_trustBar is not null) + _trustBar.Value = _holdProgress; + + if (_mochi is not null) + _mochi.Trust = _holdProgress; + + if (_mochi is not null && _trustApproachPhase == 0 && _holdProgress >= 30f) + { + _trustApproachPhase = 1; + MoveMochi(new Vector2(108, 220), 0.7); + if (_trustCopy is not null) + _trustCopy.Text = "He moved closer."; + } + else if (_mochi is not null && _trustApproachPhase == 1 && _holdProgress >= 52f) + { + _trustApproachPhase = 2; + MoveMochi(new Vector2(84, 220), 0.55); + if (_trustCopy is not null) + _trustCopy.Text = "Then he backed away."; + } + else if (_mochi is not null && _trustApproachPhase == 2 && _holdProgress >= 72f) + { + _trustApproachPhase = 3; + MoveMochi(new Vector2(132, 220), 0.85); + _mochi.Mood = "curious"; + if (_trustCopy is not null) + _trustCopy.Text = "He looked back. Then came closer again."; + } + + if (_holdProgress < 100f) + return; + + _holdingTrust = false; + _bond.SetForStoryBeat(BondLevel.Testing); + _analytics.Track(AnalyticsEvents.FirstTrustResponse); + _haptics.Affection(); + AdvanceStory("first_trust"); + BuildShelterPuzzle(); + } + + private void MoveMochi(Vector2 target, double duration) + { + if (_mochi is null) return; + var tween = CreateTween(); + tween.SetTrans(Tween.TransitionType.Cubic); + tween.SetEase(Tween.EaseType.InOut); + tween.TweenProperty(_mochi, "position", target, duration); + } + + private void ClearScreen(Color background) + { + _homeActive = false; + _homeRoomRoot = null; + _activeSheetOverlay = null; + _activeSheetPanel = null; + + foreach (var child in GetChildren()) + { + if (child == _audio) + continue; + + RemoveChild(child); + child.QueueFree(); + } + + var bg = new ColorRect + { + Color = background, + MouseFilter = MouseFilterEnum.Ignore + }; + bg.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + AddChild(bg); + } + + private MarginContainer SafeLayer(int horizontal = 28, int vertical = 34) + { + var margin = new MarginContainer(); + margin.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + margin.AddThemeConstantOverride("margin_left", horizontal); + margin.AddThemeConstantOverride("margin_right", horizontal); + margin.AddThemeConstantOverride("margin_top", vertical); + margin.AddThemeConstantOverride("margin_bottom", vertical); + AddChild(margin); + return margin; + } + + private Label Label(string text, int size, Color color, bool bold = false) + { + var label = new Label + { + Text = text, + Modulate = color, + AutowrapMode = TextServer.AutowrapMode.WordSmart + }; + label.AddThemeFontSizeOverride("font_size", size); + if (bold) + label.AddThemeColorOverride("font_shadow_color", new Color(0,0,0,0.04f)); + return label; + } + + private Button Button(string text, bool primary = true) + { + var b = new Button + { + Text = text, + CustomMinimumSize = new Vector2(0, 58), + FocusMode = FocusModeEnum.None, + MouseDefaultCursorShape = CursorShape.PointingHand + }; + b.AddThemeFontSizeOverride("font_size", 18); + b.AddThemeColorOverride("font_color", primary ? Ink : Milk); + b.AddThemeColorOverride("font_hover_color", primary ? Ink : Milk); + b.AddThemeStyleboxOverride("normal", Box(primary ? Milk : new Color(1,1,1,0.08f), 20)); + b.AddThemeStyleboxOverride("hover", Box(primary ? new Color("#FFFDF7") : new Color(1,1,1,0.12f), 20)); + b.AddThemeStyleboxOverride("pressed", Box(primary ? Paper : new Color(1,1,1,0.16f), 20)); + return b; + } + + private StyleBoxFlat Box(Color color, int radius) + { + return new StyleBoxFlat + { + BgColor = color, + CornerRadiusTopLeft = radius, + CornerRadiusTopRight = radius, + CornerRadiusBottomLeft = radius, + CornerRadiusBottomRight = radius, + ContentMarginLeft = 16, + ContentMarginRight = 16, + ContentMarginTop = 12, + ContentMarginBottom = 12 + }; + } + + private void BuildIntro() + { + ClearScreen(Night); + _analytics.Track(AnalyticsEvents.FirstMochiSeen); + GetTree().CreateTimer(0.75).Timeout += () => _audio.PlayOneShot("mrrp", -10f); + + var layer = SafeLayer(); + var column = new VBoxContainer + { + SizeFlagsVertical = SizeFlags.ExpandFill + }; + column.AddThemeConstantOverride("separation", 14); + layer.AddChild(column); + + var brand = Label("WHISKERFOLK", 13, new Color(1,1,1,0.42f), true); + column.AddChild(brand); + column.AddChild(Spacer(36)); + + var kicker = Label("A RAINY CORNER · 11:47 PM", 12, new Color(1,1,1,0.55f), true); + column.AddChild(kicker); + + var title = Label("You heard\nsomething.", 50, Milk, true); + column.AddChild(title); + + var copy = Label("Cardboard shifted under the awning. Then a very small sound.", 17, new Color(1,1,1,0.68f)); + column.AddChild(copy); + + column.AddChild(Spacer(28)); + + var stage = new PanelContainer + { + CustomMinimumSize = new Vector2(0, 560), + SizeFlagsVertical = SizeFlags.ExpandFill + }; + stage.AddThemeStyleboxOverride("panel", Box(new Color("#1D3441"), 34)); + column.AddChild(stage); + + var stageRoot = new Control(); + stage.AddChild(stageRoot); + + var rainGlow = new ColorRect + { + Color = new Color("#34525E"), + Position = new Vector2(28, 40), + Size = new Vector2(330, 360), + MouseFilter = MouseFilterEnum.Ignore + }; + stageRoot.AddChild(rainGlow); + + var boxPanel = new PanelContainer + { + Position = new Vector2(86, 260), + Size = new Vector2(250, 150) + }; + boxPanel.AddThemeStyleboxOverride("panel", Box(new Color("#8E6B47"), 12)); + stageRoot.AddChild(boxPanel); + + var eyes = Label("• •", 32, new Color("#9EBC91"), true); + eyes.HorizontalAlignment = HorizontalAlignment.Center; + eyes.VerticalAlignment = VerticalAlignment.Center; + boxPanel.AddChild(eyes); + + var hint = Label("There. Again.", 15, new Color(1,1,1,0.62f)); + column.AddChild(hint); + + var action = Button("Look closer"); + action.Pressed += () => + { + _audio.PlayOneShot("cardboard", -11f); + AdvanceStory("discover_box"); + BuildFoodPuzzle(); + }; + column.AddChild(action); + } + + private Control Spacer(float height) + { + return new Control { CustomMinimumSize = new Vector2(1, height) }; + } + + private void BuildFoodPuzzle() + { + BuildPuzzle( + MochiObjectives.All["collect_food"], + "FIRST HELP", + "Find something he can eat.", + 18, + () => + { + _bond.SetForStoryBeat(BondLevel.Tolerating); + AdvanceStory("food_help"); + BuildTrust(); + } + ); + } + + private void BuildShelterPuzzle() + { + BuildPuzzle( + MochiObjectives.All["collect_blanket"], + "SHELTER", + "Give the box a dry cover.", + 16, + () => + { + AdvanceStory("shelter_help"); + AdvanceStory("water_rises"); + BuildSafePathPuzzle(); + } + ); + } + + private void BuildPuzzle( + ObjectiveDefinition definition, + string kicker, + string titleText, + int moves, + Action onComplete) + { + ClearScreen(Milk); + _objective = new ObjectiveProgress(definition); + _moves = moves; + _analytics.Track(AnalyticsEvents.FirstPuzzleStarted, new Dictionary { ["objective"] = definition.Id }); + _selected = null; + _inputLocked = false; + + var layer = SafeLayer(22, 26); + var column = new VBoxContainer(); + column.AddThemeConstantOverride("separation", 12); + layer.AddChild(column); + + var top = new HBoxContainer(); + top.AddThemeConstantOverride("separation", 16); + column.AddChild(top); + + var head = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill }; + head.AddChild(Label(kicker, 11, Moss, true)); + head.AddChild(Label(titleText, 26, Ink, true)); + top.AddChild(head); + + _movesLabel = Label($"Moves {_moves}", 15, SoftInk, true); + top.AddChild(_movesLabel); + + var peek = new PanelContainer { CustomMinimumSize = new Vector2(0, 68) }; + peek.AddThemeStyleboxOverride("panel", Box(Paper, 22)); + var peekText = Label(definition.Id == "collect_food" ? "Two green eyes. He is watching." : "The rain is heavier now.", 14, SoftInk); + peekText.HorizontalAlignment = HorizontalAlignment.Center; + peekText.VerticalAlignment = VerticalAlignment.Center; + peek.AddChild(peekText); + column.AddChild(peek); + + _objectiveLabel = Label($"0 / {definition.TargetCount}", 16, Ink, true); + _objectiveLabel.HorizontalAlignment = HorizontalAlignment.Center; + column.AddChild(_objectiveLabel); + + _grid = new GridContainer + { + Columns = 7, + SizeFlagsHorizontal = SizeFlags.ExpandFill, + SizeFlagsVertical = SizeFlags.ExpandFill + }; + _grid.AddThemeConstantOverride("h_separation", 6); + _grid.AddThemeConstantOverride("v_separation", 6); + column.AddChild(_grid); + + var tip = Label("Match 3. Every match should change something that matters.", 13, SoftInk); + tip.HorizontalAlignment = HorizontalAlignment.Center; + column.AddChild(tip); + + InitializeBoard(); + RenderBoard(); + + _objective.Changed += progress => + { + if (_objectiveLabel is not null) + _objectiveLabel.Text = $"{progress.Current} / {progress.Definition.TargetCount}"; + + if (progress.Complete) + { + _inputLocked = true; + _analytics.Track(AnalyticsEvents.FirstPuzzleCompleted, new Dictionary { ["objective"] = progress.Definition.Id }); + _save.CatTrust["mochi"] = _bond.Trust; + _saveService.Save(_save); + var complete = Button(definition.Id == "collect_food" ? "He stayed. Keep watching" : "Dry enough. Stay with him"); + complete.Pressed += onComplete; + column.AddChild(complete); + } + }; + } + + private void InitializeBoard() + { + _board = new BoardData(7, 7, 5); + var rng = new RandomNumberGenerator(); + rng.Randomize(); + + for (int row = 0; row < 7; row++) + for (int col = 0; col < 7; col++) + { + int type; + do + { + type = rng.RandiRange(0, 4); + } while ( + (col >= 2 && _board.GetTile(row, col - 1).CrystalType == type && _board.GetTile(row, col - 2).CrystalType == type) || + (row >= 2 && _board.GetTile(row - 1, col).CrystalType == type && _board.GetTile(row - 2, col).CrystalType == type) + ); + + _board.GetTile(row, col).SetCrystal(type); + } + + if (!ValidMoveChecker.HasAnyValidMove(_board)) + InitializeBoard(); + } + + private void RenderBoard() + { + if (_grid is null || _board is null) return; + + foreach (var child in _grid.GetChildren()) + { + _grid.RemoveChild(child); + child.QueueFree(); + } + + for (int row = 0; row < 7; row++) + for (int col = 0; col < 7; col++) + { + var type = _board.GetTile(row, col).CrystalType; + var tile = new Button + { + Text = _tileMarks[type], + CustomMinimumSize = new Vector2(58, 58), + FocusMode = FocusModeEnum.None + }; + tile.AddThemeFontSizeOverride("font_size", 26); + tile.AddThemeColorOverride("font_color", Milk); + tile.AddThemeStyleboxOverride("normal", Box(_tileColors[type], 16)); + tile.AddThemeStyleboxOverride("hover", Box(_tileColors[type].Lightened(0.07f), 16)); + tile.AddThemeStyleboxOverride("pressed", Box(_tileColors[type].Darkened(0.07f), 16)); + var p = new Vector2I(col, row); + tile.Pressed += () => OnTilePressed(p); + _grid.AddChild(tile); + } + } + + private void OnTilePressed(Vector2I position) + { + if (_inputLocked || _board is null || _objective is null) + return; + + if (_selected is null) + { + _selected = position; + return; + } + + var first = _selected.Value; + _selected = null; + + if (Math.Abs(first.X - position.X) + Math.Abs(first.Y - position.Y) != 1) + { + _selected = position; + return; + } + + _board.Swap(first.Y, first.X, position.Y, position.X); + var matches = MatchDetector.DetectAll(_board); + + if (!matches.HasMatches()) + { + _board.Swap(first.Y, first.X, position.Y, position.X); + RenderBoard(); + return; + } + + _moves--; + _haptics.Selection(); + if (_movesLabel is not null) + _movesLabel.Text = $"Moves {_moves}"; + + ResolveBoard(matches); + + if (_moves <= 0 && !_objective.Complete) + { + _moves += 6; + if (_movesLabel is not null) + _movesLabel.Text = $"Moves {_moves}"; + } + } + + private void ResolveBoard(MatchResult match) + { + if (_board is null || _objective is null) return; + + var objectiveType = _objective.Definition.TargetKey switch + { + "food" => 0, + "blanket" => 1, + "flood" => 2, + _ => -1 + }; + + int cascadeGuard = 0; + var current = match; + + while (current.HasMatches() && cascadeGuard++ < 12) + { + var positions = current.GetAllPositions().Distinct().ToList(); + _haptics.Match(); + _audio.PlayOneShot("match", -15f); + + foreach (var pos in positions) + { + var cell = _board.GetTile(pos.Y, pos.X); + if (cell.CrystalType == objectiveType) + _objective.Add(); + + cell.Clear(); + } + + GravitySystem.ApplyGravity(_board); + SpawnSystem.FillEmpty(_board); + current = MatchDetector.DetectAll(_board); + } + + RenderBoard(); + } + + private void BuildTrust() + { + ClearScreen(Night); + _holdProgress = 8; + _holdingTrust = false; + _trustApproachPhase = 0; + + var layer = SafeLayer(28, 34); + var column = new VBoxContainer + { + SizeFlagsVertical = SizeFlags.ExpandFill + }; + column.AddThemeConstantOverride("separation", 14); + layer.AddChild(column); + + column.AddChild(Label("TRUST", 12, Sage, true)); + column.AddChild(Label("Don’t reach for him.", 44, Milk, true)); + _trustCopy = Label("Stay close. Let him make the next move.", 17, new Color(1,1,1,0.7f)); + column.AddChild(_trustCopy); + + var stage = new Control + { + CustomMinimumSize = new Vector2(0, 650), + SizeFlagsVertical = SizeFlags.ExpandFill + }; + column.AddChild(stage); + + var box = new PanelContainer + { + Position = new Vector2(20, 310), + Size = new Vector2(220, 135) + }; + box.AddThemeStyleboxOverride("panel", Box(new Color("#79593B"), 12)); + stage.AddChild(box); + + _mochi = new MochiActor + { + Position = new Vector2(70, 220), + Size = new Vector2(220, 220), + Trust = _holdProgress + }; + stage.AddChild(_mochi); + + _trustBar = new ProgressBar + { + MinValue = 0, + MaxValue = 100, + Value = _holdProgress, + ShowPercentage = false, + CustomMinimumSize = new Vector2(0, 8) + }; + _trustBar.AddThemeStyleboxOverride("background", Box(new Color(1,1,1,0.12f), 8)); + _trustBar.AddThemeStyleboxOverride("fill", Box(Sage, 8)); + column.AddChild(_trustBar); + + var hold = Button("Hold still", false); + hold.ButtonDown += () => _holdingTrust = true; + hold.ButtonUp += () => + { + if (_holdProgress < 100) + { + _holdingTrust = false; + _holdProgress = Mathf.Max(8, _holdProgress - 6); + if (_trustBar is not null) _trustBar.Value = _holdProgress; + if (_trustCopy is not null) _trustCopy.Text = "Too soon. Try again, slowly."; + } + }; + column.AddChild(hold); + } + + private void BuildSafePathPuzzle() + { + BuildPuzzle( + MochiObjectives.All["safe_path_carrier"], + "SAFE PATH", + "Clear the water from his way.", + 14, + () => + { + AdvanceStory("safe_path"); + BuildRescue(); + } + ); + } + + private void BuildRescue() + { + ClearScreen(new Color("#20353F")); + _analytics.Track(AnalyticsEvents.RescueStarted); + + var layer = SafeLayer(); + var column = new VBoxContainer + { + SizeFlagsVertical = SizeFlags.ExpandFill + }; + column.AddThemeConstantOverride("separation", 14); + layer.AddChild(column); + + column.AddChild(Label("ONE LAST THING", 12, Sage, true)); + column.AddChild(Label("Open it.\nThen wait.", 46, Milk, true)); + column.AddChild(Label("The water is getting closer. He has to choose the safe place himself.", 17, new Color(1,1,1,0.7f))); + + var stage = new PanelContainer + { + CustomMinimumSize = new Vector2(0, 620), + SizeFlagsVertical = SizeFlags.ExpandFill + }; + stage.AddThemeStyleboxOverride("panel", Box(new Color("#294550"), 30)); + column.AddChild(stage); + + var stageRoot = new Control(); + stage.AddChild(stageRoot); + + _mochi = new MochiActor + { + Position = new Vector2(30, 285), + Size = new Vector2(220, 220), + Trust = 52 + }; + stageRoot.AddChild(_mochi); + + var carrier = new PanelContainer + { + Position = new Vector2(245, 310), + Size = new Vector2(180, 145) + }; + carrier.AddThemeStyleboxOverride("panel", Box(new Color("#C8B898"), 22)); + stageRoot.AddChild(carrier); + + var bars = Label("│ │ │ │", 28, new Color("#454D4B"), true); + bars.HorizontalAlignment = HorizontalAlignment.Center; + bars.VerticalAlignment = VerticalAlignment.Center; + carrier.AddChild(bars); + + var open = Button("Open the carrier"); + open.Pressed += () => + { + _bond.SetForStoryBeat(BondLevel.TrustingAction); + _analytics.Track(AnalyticsEvents.RescueCompleted); + _haptics.Rescue(); + _audio.PlayOneShot("rescue", -9f); + AdvanceStory("carrier_choice"); + open.Text = "Wait…"; + open.Disabled = true; + GetTree().CreateTimer(1.3).Timeout += () => + { + if (_mochi is not null) + { + var tween = CreateTween(); + tween.SetTrans(Tween.TransitionType.Cubic); + tween.SetEase(Tween.EaseType.InOut); + tween.TweenProperty(_mochi, "position", new Vector2(228, 292), 1.5); + tween.Parallel().TweenProperty(_mochi, "modulate:a", 0.0f, 1.5); + tween.Finished += BuildHome; + } + }; + }; + column.AddChild(open); + } + + private void BuildHome() + { + var returningHome = _save.RescuedCats.Contains("mochi") || _rescue.BeatIndex >= 9; + + ClearScreen(new Color("#E9D8C0")); + _bond.SetForStoryBeat(BondLevel.NewHome); + + if (!returningHome && _rescue.Current.Id == "ride_home") + AdvanceStory("ride_home"); + + if (!returningHome) + _analytics.Track(AnalyticsEvents.BoxMomentStarted); + else + { + // Repair older/partially written saves without replaying first-time analytics. + _save.RescuedCats.Add("mochi"); + _save.Memories.Add(MochiMemories.FirstNight.Id); + _saveService.Save(_save); + } + + var layer = SafeLayer(); + var column = new VBoxContainer + { + SizeFlagsVertical = SizeFlags.ExpandFill + }; + column.AddThemeConstantOverride("separation", 12); + layer.AddChild(column); + + column.AddChild(Label("HOME", 12, Moss, true)); + var homeTitle = Label( + returningHome ? "Mochi is\nhome." : "Give him\na moment.", + 46, + Ink, + true + ); + column.AddChild(homeTitle); + + var homeCopy = Label( + returningHome + ? "He has already started choosing his favorite places." + : "New room. New sounds. Let him decide when to come out.", + 16, + SoftInk + ); + column.AddChild(homeCopy); + + var room = new PanelContainer + { + CustomMinimumSize = new Vector2(0, 650), + SizeFlagsVertical = SizeFlags.ExpandFill + }; + room.AddThemeStyleboxOverride("panel", Box(new Color("#F2E5D1"), 32)); + column.AddChild(room); + + var roomRoot = new Control(); + room.AddChild(roomRoot); + _homeRoomRoot = roomRoot; + + var sofa = new PanelContainer + { + Position = new Vector2(26, 215), + Size = new Vector2(300, 170) + }; + sofa.AddThemeStyleboxOverride("panel", Box(new Color("#94AA96"), 36)); + roomRoot.AddChild(sofa); + + var homeBox = new PanelContainer + { + Position = new Vector2(295, 360), + Size = new Vector2(150, 105) + }; + homeBox.AddThemeStyleboxOverride("panel", Box(new Color("#9B724B"), 10)); + roomRoot.AddChild(homeBox); + + PanelContainer? carrier = null; + if (!returningHome) + { + carrier = new PanelContainer + { + Position = new Vector2(300, 285), + Size = new Vector2(165, 130) + }; + carrier.AddThemeStyleboxOverride("panel", Box(new Color("#C8B898"), 20)); + roomRoot.AddChild(carrier); + + var bars = Label("│ │ │ │", 24, new Color("#565B54"), true); + bars.HorizontalAlignment = HorizontalAlignment.Center; + bars.VerticalAlignment = VerticalAlignment.Center; + carrier.AddChild(bars); + } + + _mochi = new MochiActor + { + Position = returningHome ? new Vector2(170, 300) : new Vector2(265, 300), + Size = new Vector2(220, 220), + Trust = 64, + Mood = returningHome ? "home" : "curious", + Modulate = returningHome ? Colors.White : new Color(1,1,1,0) + }; + roomRoot.AddChild(_mochi); + + _homeObjects.Clear(); + _homeObjects.Add(HomeCatalog.CardboardBox); + _homeCatState = new CatState { CatId = "mochi" }; + _homeCatState.Bond.AddTrust(_bond.Trust); + + if (_save.OwnedHomeObjects.Contains(HomeCatalog.FoldedBlanket.Id)) + { + AddBlanketVisual(roomRoot); + _homeObjects.Add(HomeCatalog.FoldedBlanket); + } + + var actions = new HBoxContainer(); + actions.AddThemeConstantOverride("separation", 8); + column.AddChild(actions); + + var catbook = Button("Catbook"); + catbook.SizeFlagsHorizontal = SizeFlags.ExpandFill; + catbook.Pressed += () => ShowSheet( + "MOCHI · 01", + "Cautious observer", + "He approaches twice before he trusts once.\n\nLikes: cardboard, soft fabric, sun patches.\nDiscovered tonight: the double-check." + ); + actions.AddChild(catbook); + + var memory = Button("First memory"); + memory.SizeFlagsHorizontal = SizeFlags.ExpandFill; + memory.Pressed += () => ShowSheet( + "FIRST NIGHT", + MochiMemories.FirstNight.Title, + MochiMemories.FirstNight.Description + ); + actions.AddChild(memory); + + var blanket = Button( + _save.OwnedHomeObjects.Contains(HomeCatalog.FoldedBlanket.Id) + ? "Blanket placed" + : "Place his blanket" + ); + blanket.Disabled = _save.OwnedHomeObjects.Contains(HomeCatalog.FoldedBlanket.Id); + blanket.Pressed += () => + { + if (_homeRoomRoot is null) return; + + blanket.Disabled = true; + blanket.Text = "Blanket placed"; + AddBlanketVisual(_homeRoomRoot); + + if (!_homeObjects.Contains(HomeCatalog.FoldedBlanket)) + _homeObjects.Add(HomeCatalog.FoldedBlanket); + + _save.OwnedHomeObjects.Add(HomeCatalog.FoldedBlanket.Id); + _saveService.Save(_save); + _analytics.Track(AnalyticsEvents.FirstObjectInteraction, + new Dictionary { ["object"] = HomeCatalog.FoldedBlanket.Id }); + _haptics.Affection(); + + if (_mochi is not null) + { + _mochi.Mood = "curious"; + MoveMochi(new Vector2(38, 335), 1.4); + } + }; + column.AddChild(blanket); + + if (returningHome) + { + StartHomeLife(); + } + else + { + PlayBoxMoment(carrier!, homeTitle, homeCopy); + } + } + + private void PlayBoxMoment(PanelContainer carrier, Label homeTitle, Label homeCopy) + { + if (_mochi is null) return; + + // Peek → retreat → second look → choose the room → settle near the familiar box. + var sequence = CreateTween(); + sequence.SetTrans(Tween.TransitionType.Cubic); + sequence.SetEase(Tween.EaseType.InOut); + sequence.TweenInterval(0.9); + sequence.TweenProperty(_mochi, "modulate:a", 1.0f, 0.45); + sequence.TweenProperty(_mochi, "position", new Vector2(235, 300), 0.7); + sequence.TweenInterval(0.45); + sequence.TweenProperty(_mochi, "position", new Vector2(262, 300), 0.4); + sequence.TweenInterval(0.5); + sequence.TweenProperty(_mochi, "position", new Vector2(220, 305), 0.75); + sequence.TweenProperty(_mochi, "position", new Vector2(238, 330), 1.05); + sequence.Parallel().TweenProperty(carrier, "modulate:a", 0.35f, 0.9); + sequence.Finished += () => CompleteBoxMoment(homeTitle, homeCopy); + } + + private void CompleteBoxMoment(Label homeTitle, Label homeCopy) + { + if (_rescue.Current.Id == "box_moment") + AdvanceStory("box_moment"); + + _save.RescuedCats.Add("mochi"); + _save.Memories.Add(MochiMemories.FirstNight.Id); + _save.CatTrust["mochi"] = _bond.Trust; + _saveService.Save(_save); + + _analytics.Track(AnalyticsEvents.BoxMomentCompleted); + _analytics.Track(AnalyticsEvents.HomeFirstEntry); + _analytics.Track(AnalyticsEvents.FirstMemoryCreated); + _haptics.Affection(); + _audio.PlayOneShot("purr", -10f); + + homeTitle.Text = "Mochi lives\nhere now."; + homeCopy.Text = "No reward chest. No score screen. He chose the warm corner."; + if (_mochi is not null) + _mochi.Mood = "home"; + + StartHomeLife(); + } + + private void AddBlanketVisual(Control roomRoot) + { + var cloth = new PanelContainer + { + Position = new Vector2(46, 410), + Size = new Vector2(170, 70), + MouseFilter = MouseFilterEnum.Ignore + }; + cloth.AddThemeStyleboxOverride("panel", Box(new Color("#D8B99A"), 28)); + roomRoot.AddChild(cloth); + } + + private void StartHomeLife() + { + if (_homeCatState is null || _mochi is null) + return; + + _homeActive = true; + _homeBehaviorTimer = 3.5f; + } + + private void RunHomeBehavior() + { + if (!_homeActive || _mochi is null || _homeCatState is null) + return; + + var behavior = _homeScheduler.SelectHomeBehavior( + CatCatalog.Mochi, + _homeCatState, + _homeObjects + ); + + _homeCatState.CurrentBehavior = behavior; + _homeCatState.SeenBehaviors.Add(behavior); + + Vector2? target = null; + var mood = "home"; + + if (behavior.StartsWith($"{HomeCatalog.CardboardBox.Id}:")) + { + _homeCatState.CurrentObjectId = HomeCatalog.CardboardBox.Id; + if (behavior.EndsWith(":inside")) + { + target = new Vector2(244, 338); + mood = "hiding"; + } + else if (behavior.EndsWith(":peek")) + { + target = new Vector2(232, 323); + mood = "curious"; + } + else + { + target = new Vector2(255, 286); + } + } + else if (behavior.StartsWith($"{HomeCatalog.FoldedBlanket.Id}:")) + { + _homeCatState.CurrentObjectId = HomeCatalog.FoldedBlanket.Id; + target = new Vector2(36, 336); + mood = "home"; + } + else if (behavior == "watch_player") + { + _homeCatState.CurrentObjectId = null; + target = new Vector2(128, 310); + mood = "curious"; + } + else if (behavior == "groom") + { + _homeCatState.CurrentObjectId = null; + target = new Vector2(155, 315); + mood = "home"; + } + else + { + _homeCatState.CurrentObjectId = null; + } + + _mochi.Mood = mood; + + if (target.HasValue) + MoveMochi(target.Value, 1.2); + + _homeBehaviorTimer = 5.0f + (float)_homeRandom.NextDouble() * 4.5f; + } + + private void ShowSheet(string kicker, string title, string copy) + { + CloseActiveSheet(); + + var overlay = new ColorRect + { + Color = new Color(0.05f,0.07f,0.07f,0.36f), + MouseFilter = MouseFilterEnum.Stop + }; + overlay.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + AddChild(overlay); + _activeSheetOverlay = overlay; + + var panel = new PanelContainer + { + AnchorLeft = 0.04f, + AnchorRight = 0.96f, + AnchorTop = 0.48f, + AnchorBottom = 0.98f, + OffsetLeft = 0, + OffsetRight = 0, + OffsetTop = 0, + OffsetBottom = 0 + }; + panel.AddThemeStyleboxOverride("panel", Box(Milk, 30)); + AddChild(panel); + _activeSheetPanel = panel; + + var margin = new MarginContainer(); + margin.AddThemeConstantOverride("margin_left", 24); + margin.AddThemeConstantOverride("margin_right", 24); + margin.AddThemeConstantOverride("margin_top", 24); + margin.AddThemeConstantOverride("margin_bottom", 24); + panel.AddChild(margin); + + var col = new VBoxContainer(); + col.AddThemeConstantOverride("separation", 10); + margin.AddChild(col); + col.AddChild(Label(kicker, 11, Moss, true)); + col.AddChild(Label(title, 33, Ink, true)); + col.AddChild(Label(copy, 16, SoftInk)); + + var close = Button("Close"); + close.Pressed += () => CloseActiveSheet(); + col.AddChild(close); + } +} diff --git a/projects/kitty-crush/godot/scripts/cats/BondState.cs b/projects/kitty-crush/godot/scripts/cats/BondState.cs new file mode 100644 index 0000000..54c5af9 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/cats/BondState.cs @@ -0,0 +1,59 @@ +namespace Whiskerfolk.Cats; + +public enum BondLevel +{ + Unknown = 0, + Watching = 1, + Tolerating = 2, + Testing = 3, + TrustingAction = 4, + NewHome = 5, + Settling = 6, + Initiating = 7, + Familiar = 8, + Bonded = 9 +} + +public sealed class BondState +{ + public BondLevel Level { get; private set; } = BondLevel.Unknown; + public float Trust { get; private set; } = 0f; + + public void AddTrust(float amount) + { + Trust = System.Math.Clamp(Trust + amount, 0f, 100f); + Level = Trust switch + { + < 6f => BondLevel.Unknown, + < 16f => BondLevel.Watching, + < 28f => BondLevel.Tolerating, + < 42f => BondLevel.Testing, + < 55f => BondLevel.TrustingAction, + < 64f => BondLevel.NewHome, + < 73f => BondLevel.Settling, + < 83f => BondLevel.Initiating, + < 94f => BondLevel.Familiar, + _ => BondLevel.Bonded + }; + } + + public void SetForStoryBeat(BondLevel minimum) + { + if (Level >= minimum) return; + Level = minimum; + Trust = minimum switch + { + BondLevel.Unknown => 0, + BondLevel.Watching => 8, + BondLevel.Tolerating => 20, + BondLevel.Testing => 34, + BondLevel.TrustingAction => 49, + BondLevel.NewHome => 60, + BondLevel.Settling => 68, + BondLevel.Initiating => 78, + BondLevel.Familiar => 89, + BondLevel.Bonded => 100, + _ => Trust + }; + } +} diff --git a/projects/kitty-crush/godot/scripts/cats/CatBehaviorController.cs b/projects/kitty-crush/godot/scripts/cats/CatBehaviorController.cs new file mode 100644 index 0000000..67e1a96 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/cats/CatBehaviorController.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Whiskerfolk.Cats; + +public sealed record BehaviorOption( + string Key, + IReadOnlySet Tags, + float BaseWeight, + string? ObjectId = null, + float RecencyPenalty = 0f +); + +public sealed class CatBehaviorController +{ + private readonly Random _random; + + public CatBehaviorController(int? seed = null) + { + _random = seed.HasValue ? new Random(seed.Value) : new Random(); + } + + public string Choose( + CatDefinition definition, + CatState state, + IReadOnlyList options, + IReadOnlyDictionary? context = null) + { + if (options.Count == 0) return "idle"; + + var scored = new List<(BehaviorOption option, float score)>(); + + foreach (var option in options) + { + var score = option.BaseWeight; + + foreach (var tag in option.Tags) + { + if (definition.BaseAffinities.TryGetValue(tag, out var affinity)) + score += affinity; + + if (context is not null && context.TryGetValue(tag, out var contextual)) + score += contextual; + } + + // Low bond makes player-seeking behaviors rarer. + if (option.Tags.Contains("player")) + score += ((float)state.Bond.Level - 4f) * 0.13f; + + // Mochi should never feel like an animation machine. + if (state.CurrentBehavior == option.Key) + score -= 0.65f + option.RecencyPenalty; + + score += (float)(_random.NextDouble() * 0.22 - 0.11); + scored.Add((option, score)); + } + + return scored + .OrderByDescending(x => x.score) + .First().option.Key; + } +} diff --git a/projects/kitty-crush/godot/scripts/cats/CatDefinition.cs b/projects/kitty-crush/godot/scripts/cats/CatDefinition.cs new file mode 100644 index 0000000..3a82efd --- /dev/null +++ b/projects/kitty-crush/godot/scripts/cats/CatDefinition.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; + +namespace Whiskerfolk.Cats; + +public sealed record CatDefinition( + string Id, + string DisplayName, + string Archetype, + IReadOnlySet TraitTags, + IReadOnlyDictionary BaseAffinities, + string SignatureBehaviorKey, + string SignatureSoundKey +); + +public static class CatCatalog +{ + public static readonly CatDefinition Mochi = new( + "mochi", + "Mochi", + "Cautious Observer", + new HashSet { "cautious", "curious", "box-loving", "quiet-playful" }, + new Dictionary + { + ["box"] = 1.00f, + ["soft_fabric"] = 0.82f, + ["window"] = 0.66f, + ["feather"] = 0.58f, + ["player"] = 0.18f, + ["metal_noise"] = -0.80f + }, + "double_check", + "mrrp_question" + ); + + public static readonly CatDefinition Churro = new( + "churro","Churro","Confident Food Critic", + new HashSet{"social","food-motivated","dramatic","bold"}, + new Dictionary{{"food",1.0f},{"player",0.55f},{"puzzle_feeder",0.9f},{"box",0.2f}}, + "bowl_shuffle","chirrup_short" + ); + + public static readonly CatDefinition Nori = new( + "nori","Nori","High-Ground Strategist", + new HashSet{"independent","calm","observant","perch-loving"}, + new Dictionary{{"high_perch",1.0f},{"window",0.9f},{"player",0.15f},{"noise",-0.4f}}, + "silent_drop","soft_trill" + ); + + public static readonly CatDefinition Chispa = new( + "chispa","Chispa","Kinetic Calico", + new HashSet{"playful","bold","chaotic","curious"}, + new Dictionary{{"feather",1.0f},{"spring",0.95f},{"player",0.55f},{"high_perch",0.45f}}, + "pounce_skid","bright_mrrp" + ); + + public static readonly CatDefinition Trufa = new( + "trufa","Trufa","Soft Giant", + new HashSet{"calm","tactile","comfort-seeking","patient"}, + new Dictionary{{"soft_fabric",1.0f},{"large_cushion",1.0f},{"player",0.52f},{"box",0.4f}}, + "too_small_bed","low_purr" + ); + + public static readonly CatDefinition Luma = new( + "luma","Luma","Sun Collector", + new HashSet{"gentle","routine-loving","light-seeking","quiet"}, + new Dictionary{{"sun_patch",1.0f},{"window",0.92f},{"woven_mat",0.78f},{"player",0.3f}}, + "follow_the_sun","airy_mew" + ); + + public static readonly CatDefinition Tinta = new( + "tinta","Tinta","Tiny Engineer", + new HashSet{"clever","persistent","mischievous","mechanism-curious"}, + new Dictionary{{"puzzle_box",1.0f},{"cabinet",0.95f},{"player",0.45f},{"feather",0.25f}}, + "solve_latch","question_chirp" + ); + + public static readonly CatDefinition Pipa = new( + "pipa","Pipa","Social Bridge", + new HashSet{"social","adaptable","warm","mediator"}, + new Dictionary{{"shared_blanket",1.0f},{"cat_company",1.0f},{"player",0.66f},{"window",0.4f}}, + "settle_between","friendly_trill" + ); + + public static readonly IReadOnlyList InitialRoster = + new List { Mochi, Churro, Nori, Chispa, Trufa, Luma, Tinta, Pipa }; +} diff --git a/projects/kitty-crush/godot/scripts/cats/CatState.cs b/projects/kitty-crush/godot/scripts/cats/CatState.cs new file mode 100644 index 0000000..05fe5d4 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/cats/CatState.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace Whiskerfolk.Cats; + +public sealed class CatState +{ + public string CatId { get; init; } = string.Empty; + public BondState Bond { get; } = new(); + public string CurrentBehavior { get; set; } = "idle"; + public string? CurrentObjectId { get; set; } + public HashSet DiscoveredPreferences { get; } = new(); + public HashSet SeenBehaviors { get; } = new(); + public Dictionary ObjectFamiliarity { get; } = new(); +} diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/BoardData.cs b/projects/kitty-crush/godot/scripts/core/puzzle/BoardData.cs new file mode 100644 index 0000000..0452b70 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/core/puzzle/BoardData.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; + +namespace Whiskerfolk.Puzzle; + +public sealed class BoardData +{ + public int Cols { get; } + public int Rows { get; } + public CellData[] Tiles { get; } + public int NumCrystalTypes { get; } + + public BoardData(int cols = 8, int rows = 8, int types = 5) + { + if (cols <= 0) throw new ArgumentOutOfRangeException(nameof(cols)); + if (rows <= 0) throw new ArgumentOutOfRangeException(nameof(rows)); + if (types <= 0) throw new ArgumentOutOfRangeException(nameof(types)); + + Cols = cols; + Rows = rows; + NumCrystalTypes = types; + Tiles = new CellData[Cols * Rows]; + + for (var i = 0; i < Tiles.Length; i++) + { + var pos = RowCol(i); + Tiles[i] = new CellData { Row = pos.Y, Col = pos.X }; + } + } + + public CellData GetTile(int row, int col) => Tiles[GetIndex(row, col)]; + + public void SetTile(int row, int col, CellData tile) + { + if (tile is null) throw new ArgumentNullException(nameof(tile)); + tile.Row = row; + tile.Col = col; + Tiles[GetIndex(row, col)] = tile; + } + + public int GetIndex(int row, int col) + { + if (!IsInBounds(row, col)) + throw new ArgumentOutOfRangeException($"Cell ({row},{col}) is outside {Rows}x{Cols} board."); + return row * Cols + col; + } + + public GridPos RowCol(int index) + { + if (index < 0 || index >= Tiles.Length) + throw new ArgumentOutOfRangeException(nameof(index)); + return new GridPos(index % Cols, index / Cols); + } + + public bool IsInBounds(int row, int col) => + row >= 0 && row < Rows && col >= 0 && col < Cols; + + public void Swap(int row1, int col1, int row2, int col2) + { + var idx1 = GetIndex(row1, col1); + var idx2 = GetIndex(row2, col2); + + (Tiles[idx1], Tiles[idx2]) = (Tiles[idx2], Tiles[idx1]); + + Tiles[idx1].Row = row1; + Tiles[idx1].Col = col1; + Tiles[idx2].Row = row2; + Tiles[idx2].Col = col2; + } + + public CellSnapshot[] DuplicateData() + { + var data = new CellSnapshot[Tiles.Length]; + for (var i = 0; i < Tiles.Length; i++) + { + var tile = Tiles[i]; + data[i] = new CellSnapshot(tile.CrystalType, tile.SpecialType, tile.IsEmpty, tile.IsLocked, tile.LockHp); + } + return data; + } + + public void RestoreFromData(IReadOnlyList data) + { + if (data.Count != Tiles.Length) + throw new ArgumentException("Snapshot size does not match board.", nameof(data)); + + for (var i = 0; i < Tiles.Length; i++) + { + var snapshot = data[i]; + var tile = Tiles[i]; + tile.CrystalType = snapshot.CrystalType; + tile.SpecialType = snapshot.SpecialType; + tile.IsEmpty = snapshot.IsEmpty; + tile.IsLocked = snapshot.IsLocked; + tile.LockHp = snapshot.LockHp; + } + } + + public void Clear() + { + foreach (var tile in Tiles) + tile.Clear(); + } + + public int CountType(int type) + { + var count = 0; + foreach (var tile in Tiles) + if (!tile.IsEmpty && tile.CrystalType == type) + count++; + return count; + } + + public int GetEmptyCount() + { + var count = 0; + foreach (var tile in Tiles) + if (tile.IsEmpty) + count++; + return count; + } + + public sealed class CellData + { + public int CrystalType { get; set; } = -1; + public int SpecialType { get; set; } = -1; + public int Row { get; set; } = -1; + public int Col { get; set; } = -1; + public bool IsEmpty { get; set; } = true; + public bool IsLocked { get; set; } + public int LockHp { get; set; } + + public CellData(int type = -1, int special = -1) + { + CrystalType = type; + SpecialType = special; + IsEmpty = type < 0; + } + + public void Clear() + { + CrystalType = -1; + SpecialType = -1; + IsEmpty = true; + } + + public void SetCrystal(int type, int special = -1) + { + CrystalType = type; + SpecialType = special; + IsEmpty = false; + } + + public bool IsNormal() => !IsEmpty && SpecialType == -1; + public bool IsSpecial() => !IsEmpty && SpecialType != -1; + + public override string ToString() + { + if (IsEmpty) return "EMPTY"; + var suffix = SpecialType switch { 0 => "B", 1 => "R", 2 => "C", _ => "" }; + return CrystalType + suffix; + } + } + + public sealed record CellSnapshot( + int CrystalType, + int SpecialType, + bool IsEmpty, + bool IsLocked, + int LockHp + ); + + public sealed class MoveRecord + { + public GridPos From { get; init; } + public GridPos To { get; init; } + public CellSnapshot[] Snapshot { get; init; } = Array.Empty(); + public int ScoreGained { get; init; } + } +} diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/GravitySystem.cs b/projects/kitty-crush/godot/scripts/core/puzzle/GravitySystem.cs new file mode 100644 index 0000000..ea83647 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/core/puzzle/GravitySystem.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; + +namespace Whiskerfolk.Puzzle; + +public static class GravitySystem +{ + private const float FallBase = 0.1f; + private const float FallPerRow = 0.08f; + + public static List ApplyGravity(BoardData board) + { + var allFalls = new List(); + for (var col = 0; col < board.Cols; col++) + allFalls.AddRange(ProcessColumn(board, col)); + return allFalls; + } + + private static List ProcessColumn(BoardData board, int col) + { + var falls = new List(); + var writeRow = board.Rows - 1; + + for (var readRow = board.Rows - 1; readRow >= 0; readRow--) + { + var tile = board.GetTile(readRow, col); + if (tile.IsEmpty) continue; + + if (readRow != writeRow) + { + falls.Add(new FallInfo + { + FromRow = readRow, + ToRow = writeRow, + Col = col, + CrystalType = tile.CrystalType, + SpecialType = tile.SpecialType + }); + + board.Swap(readRow, col, writeRow, col); + } + + writeRow--; + } + + for (var row = writeRow; row >= 0; row--) + board.GetTile(row, col).Clear(); + + return falls; + } + + public sealed class FallInfo + { + public int FromRow { get; init; } + public int ToRow { get; init; } + public int Col { get; init; } + public int CrystalType { get; init; } = -1; + public int SpecialType { get; init; } = -1; + + public int GetDistance() => ToRow - FromRow; + public float GetDuration() => FallBase + GetDistance() * FallPerRow; + } +} diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/GridPos.cs b/projects/kitty-crush/godot/scripts/core/puzzle/GridPos.cs new file mode 100644 index 0000000..5081a76 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/core/puzzle/GridPos.cs @@ -0,0 +1,6 @@ +namespace Whiskerfolk.Puzzle; + +public readonly record struct GridPos(int X, int Y) +{ + public static GridPos operator +(GridPos a, GridPos b) => new(a.X + b.X, a.Y + b.Y); +} diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/MatchDetector.cs b/projects/kitty-crush/godot/scripts/core/puzzle/MatchDetector.cs new file mode 100644 index 0000000..5d28d6a --- /dev/null +++ b/projects/kitty-crush/godot/scripts/core/puzzle/MatchDetector.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Whiskerfolk.Puzzle; + +public static class MatchDetector +{ + private static readonly GridPos[] Directions = + { + new(0, -1), + new(1, 0), + new(0, 1), + new(-1, 0) + }; + + public static MatchResult DetectAll(BoardData board) + { + var result = new MatchResult + { + MatchedFlags = new byte[board.Cols * board.Rows] + }; + + MarkHorizontal(board, result.MatchedFlags); + MarkVertical(board, result.MatchedFlags); + + var visited = new byte[result.MatchedFlags.Length]; + + for (var i = 0; i < result.MatchedFlags.Length; i++) + { + if (result.MatchedFlags[i] == 0 || visited[i] != 0) + continue; + + var region = FloodFill(board, result.MatchedFlags, visited, i); + if (region.Count < 3) continue; + + var group = Classify(region, board); + result.Groups.Add(group); + + var special = DetermineSpecial(group); + if (special.SpecialType != (int)SpecialType.None) + result.SpecialSpawns.Add(special); + } + + result.TotalMatched = result.MatchedFlags.Count(x => x != 0); + return result; + } + + private static void MarkHorizontal(BoardData board, byte[] flags) + { + for (var row = 0; row < board.Rows; row++) + { + var col = 0; + while (col < board.Cols) + { + var tile = board.GetTile(row, col); + if (tile.IsEmpty) + { + col++; + continue; + } + + var type = tile.CrystalType; + var start = col++; + while (col < board.Cols) + { + var next = board.GetTile(row, col); + if (next.IsEmpty || next.CrystalType != type) break; + col++; + } + + if (col - start < 3) continue; + for (var c = start; c < col; c++) + flags[board.GetIndex(row, c)] = 1; + } + } + } + + private static void MarkVertical(BoardData board, byte[] flags) + { + for (var col = 0; col < board.Cols; col++) + { + var row = 0; + while (row < board.Rows) + { + var tile = board.GetTile(row, col); + if (tile.IsEmpty) + { + row++; + continue; + } + + var type = tile.CrystalType; + var start = row++; + while (row < board.Rows) + { + var next = board.GetTile(row, col); + if (next.IsEmpty || next.CrystalType != type) break; + row++; + } + + if (row - start < 3) continue; + for (var r = start; r < row; r++) + flags[board.GetIndex(r, col)] = 1; + } + } + } + + private static List FloodFill( + BoardData board, + byte[] flags, + byte[] visited, + int startIndex) + { + var region = new List(); + var stack = new Stack(); + stack.Push(startIndex); + visited[startIndex] = 1; + + var type = board.Tiles[startIndex].CrystalType; + + while (stack.Count > 0) + { + var index = stack.Pop(); + var pos = board.RowCol(index); + region.Add(pos); + + foreach (var direction in Directions) + { + var next = pos + direction; + if (!board.IsInBounds(next.Y, next.X)) continue; + + var nextIndex = board.GetIndex(next.Y, next.X); + if (visited[nextIndex] != 0 || flags[nextIndex] == 0) continue; + if (board.Tiles[nextIndex].CrystalType != type) continue; + + visited[nextIndex] = 1; + stack.Push(nextIndex); + } + } + + return region; + } + + private static MatchResult.MatchGroup Classify(List region, BoardData board) + { + var group = new MatchResult.MatchGroup + { + Positions = region, + CrystalType = board.GetTile(region[0].Y, region[0].X).CrystalType + }; + + var rowGroups = region.GroupBy(p => p.Y).ToDictionary(g => g.Key, g => g.Count()); + var colGroups = region.GroupBy(p => p.X).ToDictionary(g => g.Key, g => g.Count()); + + if (rowGroups.Count == 1) + { + group.Shape = (int)MatchShape.Horizontal; + group.MatchLength = region.Count; + group.Pivot = region.OrderBy(p => p.X).ElementAt(region.Count / 2); + return group; + } + + if (colGroups.Count == 1) + { + group.Shape = (int)MatchShape.Vertical; + group.MatchLength = region.Count; + group.Pivot = region.OrderBy(p => p.Y).ElementAt(region.Count / 2); + return group; + } + + var pivot = region.FirstOrDefault(p => + rowGroups.TryGetValue(p.Y, out var rowCount) && rowCount >= 3 && + colGroups.TryGetValue(p.X, out var colCount) && colCount >= 3); + + group.Pivot = pivot == default && !region.Contains(default) ? region[0] : pivot; + + var set = region.ToHashSet(); + var left = set.Contains(group.Pivot + new GridPos(-1, 0)); + var right = set.Contains(group.Pivot + new GridPos(1, 0)); + var up = set.Contains(group.Pivot + new GridPos(0, -1)); + var down = set.Contains(group.Pivot + new GridPos(0, 1)); + var directions = new[] { left, right, up, down }.Count(x => x); + + group.Shape = directions switch + { + 4 => (int)MatchShape.Cross, + 3 => (int)MatchShape.TShape, + _ => (int)MatchShape.LShape + }; + group.MatchLength = region.Count; + return group; + } + + private static MatchResult.SpecialSpawn DetermineSpecial(MatchResult.MatchGroup group) + { + var spawn = new MatchResult.SpecialSpawn + { + Position = group.Pivot, + CrystalType = group.CrystalType, + SpecialType = (int)SpecialType.None + }; + + if (group.Shape is (int)MatchShape.Horizontal or (int)MatchShape.Vertical) + { + if (group.MatchLength >= 5) + spawn.SpecialType = (int)SpecialType.Rainbow; + else if (group.MatchLength >= 4) + spawn.SpecialType = (int)SpecialType.Bomb; + + return spawn; + } + + if (group.Positions.Count >= 5) + spawn.SpecialType = (int)SpecialType.Cross; + + return spawn; + } +} diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/MatchResult.cs b/projects/kitty-crush/godot/scripts/core/puzzle/MatchResult.cs new file mode 100644 index 0000000..4706e9a --- /dev/null +++ b/projects/kitty-crush/godot/scripts/core/puzzle/MatchResult.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; + +namespace Whiskerfolk.Puzzle; + +public sealed class MatchResult +{ + public List Groups { get; set; } = new(); + public byte[] MatchedFlags { get; set; } = System.Array.Empty(); + public List SpecialSpawns { get; set; } = new(); + public int TotalMatched { get; set; } + + public bool HasMatches() => TotalMatched > 0; + + public List GetAllPositions() + { + var all = new List(); + foreach (var group in Groups) + all.AddRange(group.Positions); + return all; + } + + public sealed class MatchGroup + { + public int Shape { get; set; } + public List Positions { get; set; } = new(); + public GridPos Pivot { get; set; } = new(-1, -1); + public int MatchLength { get; set; } + public int CrystalType { get; set; } = -1; + public int Size() => Positions.Count; + } + + public sealed class SpecialSpawn + { + public GridPos Position { get; set; } + public int SpecialType { get; set; } = -1; + public int CrystalType { get; set; } = -1; + + public override string ToString() => + $"SpecialSpawn(type={SpecialType}, pos={Position})"; + } +} diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/PuzzleEnums.cs b/projects/kitty-crush/godot/scripts/core/puzzle/PuzzleEnums.cs new file mode 100644 index 0000000..959be24 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/core/puzzle/PuzzleEnums.cs @@ -0,0 +1,41 @@ +namespace Whiskerfolk.Puzzle; + +public enum CrystalType +{ + Food = 0, + Blanket = 1, + Water = 2, + Pawprint = 3, + Nature = 4, + Empty = -1 +} + +public enum SpecialType +{ + None = -1, + Bomb = 0, + Rainbow = 1, + Cross = 2, + + // Compatibility aliases for the adapted MIT test suite. + NONE = None, + BOMB = Bomb, + RAINBOW = Rainbow, + CROSS = Cross +} + +public enum MatchShape +{ + Horizontal = 0, + Vertical = 1, + LShape = 2, + TShape = 3, + Cross = 4, + + // Compatibility aliases for the adapted MIT test suite. + H_LINE = Horizontal, + V_LINE = Vertical, + L_SHAPE = LShape, + T_SHAPE = TShape, + CROSS = Cross +} diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/ScoreCalculator.cs b/projects/kitty-crush/godot/scripts/core/puzzle/ScoreCalculator.cs new file mode 100644 index 0000000..e76415c --- /dev/null +++ b/projects/kitty-crush/godot/scripts/core/puzzle/ScoreCalculator.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; + +namespace Whiskerfolk.Puzzle; + +public class ScoreCalculator +{ + public static int CalculateGroupScore(MatchResult.MatchGroup group) + { + switch (group.Shape) + { + case 0: + case 1: + return LineScore(group.MatchLength); + case 2: + return 180; + case 3: + return 180; + case 4: + return 230; + } + return 0; + } + + private static int LineScore(int length) + { + switch (length) + { + case 3: return 30; + case 4: return 60; + case 5: return 100; + } + return 100 + (length - 5) * 50; + } + + public static int CalculateTotal(MatchResult matchResult) + { + int total = 0; + foreach (var group in matchResult.Groups) + { + total += CalculateGroupScore(group); + } + return total; + } + + public static int ApplyCombo(int baseScore, int comboDepth) + { + if (comboDepth <= 1) + return baseScore; + return baseScore * comboDepth; + } +} diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/SpawnSystem.cs b/projects/kitty-crush/godot/scripts/core/puzzle/SpawnSystem.cs new file mode 100644 index 0000000..2a97751 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/core/puzzle/SpawnSystem.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; + +namespace Whiskerfolk.Puzzle; + +public static class SpawnSystem +{ + public static List FillEmpty(BoardData board, Random? random = null) + { + random ??= Random.Shared; + var spawns = new List(); + + for (var col = 0; col < board.Cols; col++) + for (var row = 0; row < board.Rows; row++) + { + var tile = board.GetTile(row, col); + if (!tile.IsEmpty) continue; + + var crystalType = random.Next(board.NumCrystalTypes); + spawns.Add(new SpawnInfo + { + Row = row, + Col = col, + CrystalType = crystalType + }); + tile.SetCrystal(crystalType); + } + + return spawns; + } + + public sealed class SpawnInfo + { + public int Row { get; init; } + public int Col { get; init; } + public int CrystalType { get; init; } = -1; + public float GetEnterOffsetY() => -(Row + 1) * 76.0f; + } +} diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/ValidMoveChecker.cs b/projects/kitty-crush/godot/scripts/core/puzzle/ValidMoveChecker.cs new file mode 100644 index 0000000..c850060 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/core/puzzle/ValidMoveChecker.cs @@ -0,0 +1,99 @@ +namespace Whiskerfolk.Puzzle; + +public class ValidMoveChecker +{ + public static bool HasAnyValidMove(BoardData board) + { + for (int row = 0; row < board.Rows; row++) + { + for (int col = 0; col < board.Cols; col++) + { + if (col + 1 < board.Cols) + { + if (WouldMatch(board, row, col, row, col + 1)) + return true; + } + if (row + 1 < board.Rows) + { + if (WouldMatch(board, row, col, row + 1, col)) + return true; + } + } + } + return false; + } + + private static bool WouldMatch(BoardData board, int r1, int c1, int r2, int c2) + { + var t1 = board.GetTile(r1, c1); + var t2 = board.GetTile(r2, c2); + + if (t1.IsEmpty || t2.IsEmpty) + return false; + + board.Swap(r1, c1, r2, c2); + + bool hasMatch = QuickCheck(board, r1, c1) || QuickCheck(board, r2, c2); + + board.Swap(r1, c1, r2, c2); + + return hasMatch; + } + + private static bool QuickCheck(BoardData board, int row, int col) + { + var tile = board.GetTile(row, col); + if (tile.IsEmpty) + return false; + int t = tile.CrystalType; + + int leftCount = 0; + int c = col - 1; + while (c >= 0) + { + var ct = board.GetTile(row, c); + if (ct.IsEmpty || ct.CrystalType != t) + break; + leftCount++; + c--; + } + + int rightCount = 0; + c = col + 1; + while (c < board.Cols) + { + var ct = board.GetTile(row, c); + if (ct.IsEmpty || ct.CrystalType != t) + break; + rightCount++; + c++; + } + + if (1 + leftCount + rightCount >= 3) + return true; + + int upCount = 0; + int r = row - 1; + while (r >= 0) + { + var ct = board.GetTile(r, col); + if (ct.IsEmpty || ct.CrystalType != t) + break; + upCount++; + r--; + } + + int downCount = 0; + r = row + 1; + while (r < board.Rows) + { + var ct = board.GetTile(r, col); + if (ct.IsEmpty || ct.CrystalType != t) + break; + downCount++; + r++; + } + + return 1 + upCount + downCount >= 3; + } +} diff --git a/projects/kitty-crush/godot/scripts/economy/EconomyModels.cs b/projects/kitty-crush/godot/scripts/economy/EconomyModels.cs new file mode 100644 index 0000000..b6fbd70 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/economy/EconomyModels.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; + +namespace Whiskerfolk.Economy; + +public enum CurrencyKind +{ + Soft, + Premium +} + +public enum CatalogCategory +{ + Home, + Style, + MemoryPresentation +} + +public sealed record CatalogItem( + string Id, + string DisplayName, + CatalogCategory Category, + CurrencyKind Currency, + int Price, + bool Interactive, + IReadOnlySet Tags +); + +public sealed class Wallet +{ + public int Soft { get; private set; } + public int Premium { get; private set; } + + public void Grant(CurrencyKind kind, int amount) + { + if (amount <= 0) return; + if (kind == CurrencyKind.Soft) Soft += amount; + else Premium += amount; + } + + public bool TrySpend(CurrencyKind kind, int amount) + { + if (amount < 0) return false; + + if (kind == CurrencyKind.Soft) + { + if (Soft < amount) return false; + Soft -= amount; + return true; + } + + if (Premium < amount) return false; + Premium -= amount; + return true; + } +} diff --git a/projects/kitty-crush/godot/scripts/economy/ICommerceService.cs b/projects/kitty-crush/godot/scripts/economy/ICommerceService.cs new file mode 100644 index 0000000..636f528 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/economy/ICommerceService.cs @@ -0,0 +1,22 @@ +using System.Threading.Tasks; + +namespace Whiskerfolk.Economy; + +public interface ICommerceService +{ + Task IsAvailableAsync(); + Task PurchaseAsync(string productId); + Task RestoreAsync(); +} + +public sealed record PurchaseResult(bool Success, string ProductId, string? Error = null); + +public sealed class DisabledCommerceService : ICommerceService +{ + public Task IsAvailableAsync() => Task.FromResult(false); + + public Task PurchaseAsync(string productId) => + Task.FromResult(new PurchaseResult(false, productId, "Commerce is intentionally disabled in the vertical slice.")); + + public Task RestoreAsync() => Task.CompletedTask; +} diff --git a/projects/kitty-crush/godot/scripts/home/BehaviorScheduler.cs b/projects/kitty-crush/godot/scripts/home/BehaviorScheduler.cs new file mode 100644 index 0000000..7ce6126 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/home/BehaviorScheduler.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using Whiskerfolk.Cats; + +namespace Whiskerfolk.Home; + +public sealed class BehaviorScheduler +{ + private readonly CatBehaviorController _controller; + + public BehaviorScheduler(int? seed = null) + { + _controller = new CatBehaviorController(seed); + } + + public string SelectHomeBehavior( + CatDefinition cat, + CatState state, + IReadOnlyList objects) + { + var options = new List + { + new("quiet_idle", new HashSet(), 0.55f), + new("watch_player", new HashSet{"player"}, 0.18f), + new("groom", new HashSet{"comfort"}, 0.35f) + }; + + foreach (var obj in objects) + { + var objectBoost = obj.CatAffinityOverrides.TryGetValue(cat.Id, out var affinity) + ? affinity + : 0f; + + foreach (var slot in obj.Slots) + { + // Object identity matters as much as the pose/interaction slot. + // Mochi should recognize "box" even when a slot is tagged "hide" or "sleep". + var tags = new HashSet(obj.Tags); + tags.UnionWith(slot.BehaviorTags); + + options.Add(new BehaviorOption( + $"{obj.Id}:{slot.Id}", + tags, + 0.28f + objectBoost, + obj.Id, + state.CurrentObjectId == obj.Id ? 0.25f : 0f + )); + } + } + + return _controller.Choose(cat, state, options); + } +} diff --git a/projects/kitty-crush/godot/scripts/home/HomeObjectDefinition.cs b/projects/kitty-crush/godot/scripts/home/HomeObjectDefinition.cs new file mode 100644 index 0000000..ead6c69 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/home/HomeObjectDefinition.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +namespace Whiskerfolk.Home; + +public sealed record InteractionSlot( + string Id, + IReadOnlySet BehaviorTags, + string AnimationKey, + string? SoundKey = null +); + +public sealed record HomeObjectDefinition( + string Id, + string DisplayName, + IReadOnlySet Tags, + IReadOnlyList Slots, + IReadOnlyDictionary CatAffinityOverrides +); + +public static class HomeCatalog +{ + public static readonly HomeObjectDefinition CardboardBox = new( + "cardboard_box_01", + "The Box", + new HashSet { "box", "hide", "sleep", "curious" }, + new List + { + new("inside", new HashSet{"hide","sleep"}, "box_inside"), + new("peek", new HashSet{"curious"}, "box_peek"), + new("top", new HashSet{"sit"}, "box_top") + }, + new Dictionary { ["mochi"] = 1.0f } + ); + + public static readonly HomeObjectDefinition FoldedBlanket = new( + "folded_blanket_01", + "Folded Blanket", + new HashSet { "soft_fabric", "sleep", "comfort" }, + new List + { + new("center", new HashSet{"sleep","rest"}, "blanket_settle") + }, + new Dictionary { ["mochi"] = 0.82f } + ); +} diff --git a/projects/kitty-crush/godot/scripts/memories/MemoryDefinition.cs b/projects/kitty-crush/godot/scripts/memories/MemoryDefinition.cs new file mode 100644 index 0000000..0bf6617 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/memories/MemoryDefinition.cs @@ -0,0 +1,22 @@ +namespace Whiskerfolk.Memories; + +public sealed record MemoryDefinition( + string Id, + string CatId, + string Title, + string Description, + string TriggerKey, + bool Shareable +); + +public static class MochiMemories +{ + public static readonly MemoryDefinition FirstNight = new( + "mochi_first_night_home", + "mochi", + "The warm corner", + "Mochi left the carrier on his own and chose the box beside the sofa.", + "box_moment_complete", + true + ); +} diff --git a/projects/kitty-crush/godot/scripts/puzzle/Objectives.cs b/projects/kitty-crush/godot/scripts/puzzle/Objectives.cs new file mode 100644 index 0000000..f874f2b --- /dev/null +++ b/projects/kitty-crush/godot/scripts/puzzle/Objectives.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; + +namespace Whiskerfolk.Puzzle; + +public enum ObjectiveKind +{ + CollectItem, + ClearBlocker, + RevealPath, + DropObject, + ProtectObject, + ChargeObject +} + +public sealed record ObjectiveDefinition( + string Id, + ObjectiveKind Kind, + string TargetKey, + int TargetCount, + string WorldResultKey +); + +public sealed class ObjectiveProgress +{ + public ObjectiveDefinition Definition { get; } + public int Current { get; private set; } + public bool Complete => Current >= Definition.TargetCount; + + public event Action? Changed; + + public ObjectiveProgress(ObjectiveDefinition definition) + { + Definition = definition; + } + + public void Add(int amount = 1) + { + if (Complete) return; + Current = Math.Min(Definition.TargetCount, Current + Math.Max(0, amount)); + Changed?.Invoke(this); + } +} + +public static class MochiObjectives +{ + public static readonly IReadOnlyDictionary All = + new Dictionary + { + ["collect_food"] = new("collect_food", ObjectiveKind.CollectItem, "food", 6, "place_food"), + ["collect_blanket"] = new("collect_blanket", ObjectiveKind.CollectItem, "blanket", 8, "cover_box"), + ["safe_path_carrier"] = new("safe_path_carrier", ObjectiveKind.RevealPath, "flood", 10, "open_carrier_path") + }; +} diff --git a/projects/kitty-crush/godot/scripts/rescue/RescueArc.cs b/projects/kitty-crush/godot/scripts/rescue/RescueArc.cs new file mode 100644 index 0000000..fb19f6b --- /dev/null +++ b/projects/kitty-crush/godot/scripts/rescue/RescueArc.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace Whiskerfolk.Rescue; + +public sealed class RescueArc +{ + public string Id { get; init; } = string.Empty; + public string CatId { get; init; } = string.Empty; + public IReadOnlyList Beats { get; init; } = new List(); + + public static RescueArc MochiFirstNight() => new() + { + Id = "mochi_first_night", + CatId = "mochi", + Beats = new List + { + new("discover_box", RescueBeatType.Discover, "You heard something.", MinimumDurationSeconds: 6f), + new("food_help", RescueBeatType.Puzzle, "Find something he can eat.", "collect_food"), + new("first_trust", RescueBeatType.Trust, "Let him make the next move.", MinimumDurationSeconds: 18f), + new("shelter_help", RescueBeatType.Puzzle, "Give the box a dry cover.", "collect_blanket"), + new("water_rises", RescueBeatType.Threat, "The water is getting closer.", MinimumDurationSeconds: 8f), + new("safe_path", RescueBeatType.Puzzle, "Make a safe path.", "safe_path_carrier"), + new("carrier_choice", RescueBeatType.Rescue, "Open it. Then wait.", MinimumDurationSeconds: 10f), + new("ride_home", RescueBeatType.Transition, "Almost home.", MinimumDurationSeconds: 7f), + new("box_moment", RescueBeatType.HomeArrival, "Mochi lives here now.", MinimumDurationSeconds: 18f), + new("first_home", RescueBeatType.FreeObserve, "Let him explore.", MinimumDurationSeconds: 45f) + } + }; +} diff --git a/projects/kitty-crush/godot/scripts/rescue/RescueBeat.cs b/projects/kitty-crush/godot/scripts/rescue/RescueBeat.cs new file mode 100644 index 0000000..ebf975d --- /dev/null +++ b/projects/kitty-crush/godot/scripts/rescue/RescueBeat.cs @@ -0,0 +1,23 @@ +namespace Whiskerfolk.Rescue; + +public enum RescueBeatType +{ + Discover, + Puzzle, + Trust, + Threat, + Rescue, + Transition, + HomeArrival, + FreeObserve +} + +public sealed record RescueBeat( + string Id, + RescueBeatType Type, + string Title, + string? PuzzleObjectiveId = null, + string? RequiredMemoryId = null, + float MinimumDurationSeconds = 0f, + bool CanSkip = false +); diff --git a/projects/kitty-crush/godot/scripts/rescue/RescueDirector.cs b/projects/kitty-crush/godot/scripts/rescue/RescueDirector.cs new file mode 100644 index 0000000..e5bf6a4 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/rescue/RescueDirector.cs @@ -0,0 +1,43 @@ +using System; + +namespace Whiskerfolk.Rescue; + +public sealed class RescueDirector +{ + public RescueArc Arc { get; } + public int BeatIndex { get; private set; } + public RescueBeat Current => Arc.Beats[BeatIndex]; + public bool IsComplete => BeatIndex >= Arc.Beats.Count - 1; + + public event Action? BeatChanged; + + public RescueDirector(RescueArc arc, int startBeatIndex = 0) + { + Arc = arc; + BeatIndex = Math.Clamp(startBeatIndex, 0, Math.Max(0, arc.Beats.Count - 1)); + } + + public bool TryAdvance(string completedBeatId) + { + if (!string.Equals(Current.Id, completedBeatId, StringComparison.Ordinal)) + return false; + + if (IsComplete) return false; + + BeatIndex++; + BeatChanged?.Invoke(Current); + return true; + } + + public void RestoreBeat(int beatIndex) + { + BeatIndex = Math.Clamp(beatIndex, 0, Math.Max(0, Arc.Beats.Count - 1)); + BeatChanged?.Invoke(Current); + } + + public void Reset() + { + BeatIndex = 0; + BeatChanged?.Invoke(Current); + } +} diff --git a/projects/kitty-crush/godot/scripts/save/SaveGame.cs b/projects/kitty-crush/godot/scripts/save/SaveGame.cs new file mode 100644 index 0000000..b67fe0c --- /dev/null +++ b/projects/kitty-crush/godot/scripts/save/SaveGame.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace Whiskerfolk.Save; + +public sealed class SaveGame +{ + public const int CurrentSchema = 1; + + public int SchemaVersion { get; set; } = CurrentSchema; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + public string CurrentRescueArcId { get; set; } = "mochi_first_night"; + public int CurrentRescueBeat { get; set; } + public HashSet RescuedCats { get; set; } = new(); + public Dictionary CatTrust { get; set; } = new(); + public HashSet Memories { get; set; } = new(); + public HashSet OwnedHomeObjects { get; set; } = new(); + public HashSet OwnedCosmetics { get; set; } = new(); + public Dictionary Settings { get; set; } = new(); +} diff --git a/projects/kitty-crush/godot/scripts/save/SaveService.cs b/projects/kitty-crush/godot/scripts/save/SaveService.cs new file mode 100644 index 0000000..4e02466 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/save/SaveService.cs @@ -0,0 +1,61 @@ +using Godot; +using System; +using System.Text.Json; + +namespace Whiskerfolk.Save; + +public sealed class SaveService +{ + private const string Path = "user://whiskerfolk_save.json"; + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = false + }; + + public SaveGame LoadOrCreate() + { + if (!FileAccess.FileExists(Path)) + return new SaveGame(); + + using var file = FileAccess.Open(Path, FileAccess.ModeFlags.Read); + var json = file.GetAsText(); + + try + { + var save = JsonSerializer.Deserialize(json, JsonOptions); + if (save is null) + return new SaveGame(); + + return Migrate(save); + } + catch (Exception ex) + { + GD.PushWarning($"[Whiskerfolk] Save could not be parsed. Starting safe fallback. {ex.Message}"); + return new SaveGame(); + } + } + + public void Save(SaveGame save) + { + save.SchemaVersion = SaveGame.CurrentSchema; + save.UpdatedAt = DateTimeOffset.UtcNow; + var json = JsonSerializer.Serialize(save, JsonOptions); + + using var file = FileAccess.Open(Path, FileAccess.ModeFlags.Write); + file.StoreString(json); + file.Flush(); + } + + private SaveGame Migrate(SaveGame save) + { + // Schema 1 is current. Future migrations must be explicit and sequential. + if (save.SchemaVersion > SaveGame.CurrentSchema) + { + GD.PushWarning("[Whiskerfolk] Save schema is newer than this build."); + return save; + } + + save.SchemaVersion = SaveGame.CurrentSchema; + return save; + } +} diff --git a/projects/kitty-crush/godot/scripts/services/AudioService.cs b/projects/kitty-crush/godot/scripts/services/AudioService.cs new file mode 100644 index 0000000..e9f86f2 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/services/AudioService.cs @@ -0,0 +1,28 @@ +using Godot; +using System.Collections.Generic; + +namespace Whiskerfolk.Services; + +public partial class AudioService : Node +{ + public bool Enabled { get; set; } = true; + + private readonly Dictionary _streams = new(); + + public void Register(string key, AudioStream stream) => _streams[key] = stream; + + public void PlayOneShot(string key, float volumeDb = 0f) + { + if (!Enabled || !_streams.TryGetValue(key, out var stream)) + return; + + var player = new AudioStreamPlayer + { + Stream = stream, + VolumeDb = volumeDb + }; + AddChild(player); + player.Finished += player.QueueFree; + player.Play(); + } +} diff --git a/projects/kitty-crush/godot/scripts/services/HapticsService.cs b/projects/kitty-crush/godot/scripts/services/HapticsService.cs new file mode 100644 index 0000000..969168b --- /dev/null +++ b/projects/kitty-crush/godot/scripts/services/HapticsService.cs @@ -0,0 +1,20 @@ +using Godot; + +namespace Whiskerfolk.Services; + +public sealed class HapticsService +{ + public bool Enabled { get; set; } = true; + + public void Selection() => Vibrate(7); + public void Match() => Vibrate(10); + public void Special() => Vibrate(18); + public void Rescue() => Vibrate(24); + public void Affection() => Vibrate(12); + + private void Vibrate(int milliseconds) + { + if (!Enabled) return; + Input.VibrateHandheld(milliseconds); + } +} diff --git a/projects/kitty-crush/godot/scripts/services/ProceduralAudioFactory.cs b/projects/kitty-crush/godot/scripts/services/ProceduralAudioFactory.cs new file mode 100644 index 0000000..446e5d8 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/services/ProceduralAudioFactory.cs @@ -0,0 +1,154 @@ +using Godot; +using System; + +namespace Whiskerfolk.Services; + +public static class ProceduralAudioFactory +{ + private const int SampleRate = 44100; + + public static AudioStreamWav SoftTone( + float frequency, + float durationSeconds, + float amplitude = 0.16f, + float harmonic = 0.0f) + { + var frames = Math.Max(1, (int)(SampleRate * durationSeconds)); + var data = new byte[frames * 2]; + + for (var i = 0; i < frames; i++) + { + var t = (float)i / SampleRate; + var progress = (float)i / Math.Max(1, frames - 1); + var envelope = SoftEnvelope(progress); + var sample = + MathF.Sin(MathF.Tau * frequency * t) + + MathF.Sin(MathF.Tau * frequency * 2f * t) * harmonic; + + Write16(data, i, sample * envelope * amplitude); + } + + return Stream(data); + } + + public static AudioStreamWav RescueChime() + { + var duration = 0.72f; + var frames = (int)(SampleRate * duration); + var data = new byte[frames * 2]; + var notes = new[] { 392f, 523.25f, 659.25f }; + + for (var i = 0; i < frames; i++) + { + var t = (float)i / SampleRate; + var progress = (float)i / frames; + var envelope = MathF.Pow(1f - progress, 1.7f); + var sample = 0f; + + for (var n = 0; n < notes.Length; n++) + { + var delayed = t - n * 0.085f; + if (delayed < 0f) continue; + var noteEnvelope = MathF.Exp(-delayed * 5.4f); + sample += MathF.Sin(MathF.Tau * notes[n] * delayed) * noteEnvelope; + } + + Write16(data, i, sample * envelope * 0.10f); + } + + return Stream(data); + } + + public static AudioStreamWav QuestionMrrp() + { + var duration = 0.34f; + var frames = (int)(SampleRate * duration); + var data = new byte[frames * 2]; + + for (var i = 0; i < frames; i++) + { + var t = (float)i / SampleRate; + var progress = (float)i / frames; + + // Small upward inflection with a quiet second formant. + var frequency = 315f + progress * 135f + MathF.Sin(progress * MathF.PI) * 45f; + var phase = MathF.Tau * frequency * t; + var envelope = MathF.Sin(MathF.PI * Math.Clamp(progress, 0f, 1f)); + var sample = + MathF.Sin(phase) * 0.72f + + MathF.Sin(phase * 1.74f) * 0.22f + + MathF.Sin(phase * 0.51f) * 0.10f; + + Write16(data, i, sample * envelope * 0.12f); + } + + return Stream(data); + } + + public static AudioStreamWav Purr() + { + var duration = 1.6f; + var frames = (int)(SampleRate * duration); + var data = new byte[frames * 2]; + + for (var i = 0; i < frames; i++) + { + var t = (float)i / SampleRate; + var progress = (float)i / frames; + var fade = MathF.Min(1f, MathF.Min(progress * 8f, (1f - progress) * 8f)); + + var pulse = 0.55f + MathF.Sin(MathF.Tau * 25f * t) * 0.45f; + var body = + MathF.Sin(MathF.Tau * 58f * t) * 0.62f + + MathF.Sin(MathF.Tau * 116f * t) * 0.16f; + + Write16(data, i, body * pulse * fade * 0.09f); + } + + return Stream(data); + } + + public static AudioStreamWav Cardboard() + { + var duration = 0.23f; + var frames = (int)(SampleRate * duration); + var data = new byte[frames * 2]; + var random = new Random(545); + + var filtered = 0f; + for (var i = 0; i < frames; i++) + { + var progress = (float)i / frames; + var noise = (float)(random.NextDouble() * 2.0 - 1.0); + filtered = filtered * 0.72f + noise * 0.28f; + var envelope = MathF.Pow(1f - progress, 1.8f); + Write16(data, i, filtered * envelope * 0.10f); + } + + return Stream(data); + } + + private static float SoftEnvelope(float progress) + { + var attack = MathF.Min(1f, progress / 0.08f); + var release = MathF.Min(1f, (1f - progress) / 0.28f); + return MathF.Max(0f, MathF.Min(attack, release)); + } + + private static AudioStreamWav Stream(byte[] data) => new() + { + Data = data, + Format = AudioStreamWav.FormatEnum.Format16Bits, + MixRate = SampleRate, + Stereo = false, + LoopMode = AudioStreamWav.LoopModeEnum.Disabled + }; + + private static void Write16(byte[] data, int frame, float value) + { + var clamped = Math.Clamp(value, -1f, 1f); + var sample = (short)(clamped * short.MaxValue); + data[frame * 2] = (byte)(sample & 0xff); + data[frame * 2 + 1] = (byte)((sample >> 8) & 0xff); + } +} diff --git a/projects/kitty-crush/godot/tests/BoardDataTests.cs b/projects/kitty-crush/godot/tests/BoardDataTests.cs new file mode 100644 index 0000000..eb82ca7 --- /dev/null +++ b/projects/kitty-crush/godot/tests/BoardDataTests.cs @@ -0,0 +1,83 @@ +using Xunit; + +using Whiskerfolk.Puzzle; + +namespace Whiskerfolk.Tests; + +public class BoardDataTests +{ + [Fact] + public void TestIndexConversion() + { + var board = new BoardData(8, 8); + Assert.Equal(0, board.GetIndex(0, 0)); + Assert.Equal(63, board.GetIndex(7, 7)); + Assert.Equal(29, board.GetIndex(3, 5)); + var rc = board.RowCol(29); + Assert.Equal(new GridPos(5, 3), rc); // (col=5, row=3) + } + + [Fact] + public void TestSwap() + { + var board = new BoardData(8, 8); + board.GetTile(0, 0).SetCrystal(0); // RED + board.GetTile(0, 1).SetCrystal(1); // BLUE + board.Swap(0, 0, 0, 1); + Assert.Equal(1, board.GetTile(0, 0).CrystalType); + Assert.Equal(0, board.GetTile(0, 1).CrystalType); + Assert.Equal(0, board.GetTile(0, 0).Row); + Assert.Equal(0, board.GetTile(0, 0).Col); + } + + [Fact] + public void TestDuplicateRestore() + { + var board = new BoardData(8, 8); + board.GetTile(0, 0).SetCrystal(0); + board.GetTile(1, 1).SetCrystal(1); + var data = board.DuplicateData(); + board.GetTile(0, 0).Clear(); + board.RestoreFromData(data); + Assert.Equal(0, board.GetTile(0, 0).CrystalType); + Assert.Equal(1, board.GetTile(1, 1).CrystalType); + } + + [Fact] + public void TestIsInBounds() + { + var board = new BoardData(8, 8); + Assert.True(board.IsInBounds(0, 0)); + Assert.True(board.IsInBounds(7, 7)); + Assert.False(board.IsInBounds(-1, 0)); + Assert.False(board.IsInBounds(0, -1)); + Assert.False(board.IsInBounds(8, 0)); + Assert.False(board.IsInBounds(0, 8)); + } + + [Fact] + public void TestCountType() + { + var board = new BoardData(8, 8); + board.GetTile(0, 0).SetCrystal(0); + board.GetTile(0, 1).SetCrystal(0); + board.GetTile(0, 2).SetCrystal(0); + board.GetTile(0, 3).SetCrystal(0); + board.GetTile(0, 4).SetCrystal(0); + board.GetTile(1, 0).SetCrystal(1); + board.GetTile(1, 1).SetCrystal(1); + board.GetTile(1, 2).SetCrystal(1); + Assert.Equal(5, board.CountType(0)); + Assert.Equal(3, board.CountType(1)); + Assert.Equal(0, board.CountType(2)); + } + + [Fact] + public void TestClearBoard() + { + var board = new BoardData(8, 8); + board.GetTile(0, 0).SetCrystal(0); + board.Clear(); + Assert.Equal(64, board.GetEmptyCount()); + } +} diff --git a/projects/kitty-crush/godot/tests/BoardGenerationTests.cs b/projects/kitty-crush/godot/tests/BoardGenerationTests.cs new file mode 100644 index 0000000..e3077e5 --- /dev/null +++ b/projects/kitty-crush/godot/tests/BoardGenerationTests.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using Xunit; +using Whiskerfolk.Puzzle; + +namespace Whiskerfolk.Tests; + +public class BoardGenerationTests +{ + [Fact] + public void TestGenerateNoInitialMatches() + { + for (var i = 0; i < 20; i++) + { + var board = new BoardData(8, 8); + GenerateNoMatch(board, new Random(100 + i)); + var result = MatchDetector.DetectAll(board); + Assert.False(result.HasMatches(), "Generated board should have no initial matches"); + } + } + + [Fact] + public void TestAllCellsFilled() + { + for (var i = 0; i < 10; i++) + { + var board = new BoardData(8, 8); + GenerateNoMatch(board, new Random(200 + i)); + Assert.Equal(0, board.GetEmptyCount()); + } + } + + private static void GenerateNoMatch(BoardData board, Random random) + { + for (var row = 0; row < board.Rows; row++) + for (var col = 0; col < board.Cols; col++) + { + var forbidden = new HashSet(); + + if (col >= 2) + { + var l1 = board.GetTile(row, col - 1); + var l2 = board.GetTile(row, col - 2); + if (!l1.IsEmpty && !l2.IsEmpty && l1.CrystalType == l2.CrystalType) + forbidden.Add(l1.CrystalType); + } + + if (row >= 2) + { + var u1 = board.GetTile(row - 1, col); + var u2 = board.GetTile(row - 2, col); + if (!u1.IsEmpty && !u2.IsEmpty && u1.CrystalType == u2.CrystalType) + forbidden.Add(u1.CrystalType); + } + + var allowed = new List(); + for (var type = 0; type < board.NumCrystalTypes; type++) + if (!forbidden.Contains(type)) + allowed.Add(type); + + board.GetTile(row, col).SetCrystal(allowed[random.Next(allowed.Count)]); + } + } +} diff --git a/projects/kitty-crush/godot/tests/BondStateTests.cs b/projects/kitty-crush/godot/tests/BondStateTests.cs new file mode 100644 index 0000000..4ec1ae1 --- /dev/null +++ b/projects/kitty-crush/godot/tests/BondStateTests.cs @@ -0,0 +1,34 @@ +using Whiskerfolk.Cats; +using Xunit; + +namespace Whiskerfolk.Tests; + +public class BondStateTests +{ + [Fact] + public void TrustProgressesThroughRelationshipLevels() + { + var bond = new BondState(); + Assert.Equal(BondLevel.Unknown, bond.Level); + + bond.AddTrust(35); + Assert.Equal(BondLevel.Testing, bond.Level); + + bond.AddTrust(30); + Assert.Equal(BondLevel.Settling, bond.Level); + + bond.AddTrust(35); + Assert.Equal(BondLevel.Bonded, bond.Level); + Assert.Equal(100, bond.Trust); + } + + [Fact] + public void StoryBeatNeverMovesBondBackward() + { + var bond = new BondState(); + bond.AddTrust(90); + var before = bond.Level; + bond.SetForStoryBeat(BondLevel.Tolerating); + Assert.Equal(before, bond.Level); + } +} diff --git a/projects/kitty-crush/godot/tests/CatBehaviorControllerTests.cs b/projects/kitty-crush/godot/tests/CatBehaviorControllerTests.cs new file mode 100644 index 0000000..36349cf --- /dev/null +++ b/projects/kitty-crush/godot/tests/CatBehaviorControllerTests.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using Whiskerfolk.Cats; +using Xunit; + +namespace Whiskerfolk.Tests; + +public class CatBehaviorControllerTests +{ + [Fact] + public void MochiStronglyPrefersBoxOverMetalNoise() + { + var state = new CatState { CatId = "mochi" }; + var controller = new CatBehaviorController(seed: 12); + + var options = new List + { + new("hide_in_box", new HashSet{"box"}, 0.4f), + new("inspect_metal", new HashSet{"metal_noise"}, 0.4f) + }; + + var selected = controller.Choose(CatCatalog.Mochi, state, options); + Assert.Equal("hide_in_box", selected); + } +} diff --git a/projects/kitty-crush/godot/tests/EconomyTests.cs b/projects/kitty-crush/godot/tests/EconomyTests.cs new file mode 100644 index 0000000..6165021 --- /dev/null +++ b/projects/kitty-crush/godot/tests/EconomyTests.cs @@ -0,0 +1,29 @@ +using Whiskerfolk.Economy; +using Xunit; + +namespace Whiskerfolk.Tests; + +public class EconomyTests +{ + [Fact] + public void WalletNeverSpendsMoreThanOwned() + { + var wallet = new Wallet(); + wallet.Grant(CurrencyKind.Soft, 100); + + Assert.False(wallet.TrySpend(CurrencyKind.Soft, 101)); + Assert.Equal(100, wallet.Soft); + + Assert.True(wallet.TrySpend(CurrencyKind.Soft, 40)); + Assert.Equal(60, wallet.Soft); + } + + [Fact] + public void NegativeTransactionsAreRejected() + { + var wallet = new Wallet(); + wallet.Grant(CurrencyKind.Premium, -10); + Assert.Equal(0, wallet.Premium); + Assert.False(wallet.TrySpend(CurrencyKind.Premium, -1)); + } +} diff --git a/projects/kitty-crush/godot/tests/EdgeCasesTests.cs b/projects/kitty-crush/godot/tests/EdgeCasesTests.cs new file mode 100644 index 0000000..67c873a --- /dev/null +++ b/projects/kitty-crush/godot/tests/EdgeCasesTests.cs @@ -0,0 +1,50 @@ +using Xunit; + +using Whiskerfolk.Puzzle; + +namespace Whiskerfolk.Tests; + +public class EdgeCasesTests +{ + [Fact] + public void TestMaxCascadeLoopProtection() + { + var board = new BoardData(8, 8); + for (int row = 0; row < 8; row++) + for (int col = 0; col < 8; col++) + board.GetTile(row, col).SetCrystal((row + col) % 5); + + int cascadeDepth = 0; + while (cascadeDepth < 20) + { + var result = MatchDetector.DetectAll(board); + if (!result.HasMatches()) + break; + cascadeDepth++; + foreach (var pos in result.GetAllPositions()) + board.GetTile(pos.Y, pos.X).Clear(); + GravitySystem.ApplyGravity(board); + SpawnSystem.FillEmpty(board); + } + + Assert.True(cascadeDepth < 20, "Cascade loop should terminate before MAX_CASCADE_LOOPS"); + } + + [Fact] + public void TestEmptyBoardOperations() + { + var board = new BoardData(8, 8); + + var result = MatchDetector.DetectAll(board); + Assert.False(result.HasMatches()); + Assert.Equal(0, result.TotalMatched); + Assert.Empty(result.Groups); + + var falls = GravitySystem.ApplyGravity(board); + Assert.Empty(falls); + + var spawns = SpawnSystem.FillEmpty(board); + Assert.Equal(64, spawns.Count); + Assert.Equal(0, board.GetEmptyCount()); + } +} diff --git a/projects/kitty-crush/godot/tests/FullRescueArcTests.cs b/projects/kitty-crush/godot/tests/FullRescueArcTests.cs new file mode 100644 index 0000000..177b887 --- /dev/null +++ b/projects/kitty-crush/godot/tests/FullRescueArcTests.cs @@ -0,0 +1,33 @@ +using Whiskerfolk.Rescue; +using Xunit; + +namespace Whiskerfolk.Tests; + +public class FullRescueArcTests +{ + [Fact] + public void MochiFirstNightHasIntentionalTenBeatArc() + { + var arc = RescueArc.MochiFirstNight(); + Assert.Equal(10, arc.Beats.Count); + Assert.Equal("discover_box", arc.Beats[0].Id); + Assert.Equal("first_home", arc.Beats[^1].Id); + } + + [Fact] + public void FullArcCanAdvanceInAuthoredOrder() + { + var director = new RescueDirector(RescueArc.MochiFirstNight()); + var completed = 0; + + while (!director.IsComplete) + { + var current = director.Current.Id; + Assert.True(director.TryAdvance(current)); + completed++; + } + + Assert.Equal(9, completed); + Assert.Equal("first_home", director.Current.Id); + } +} diff --git a/projects/kitty-crush/godot/tests/GravitySystemTests.cs b/projects/kitty-crush/godot/tests/GravitySystemTests.cs new file mode 100644 index 0000000..fb50c24 --- /dev/null +++ b/projects/kitty-crush/godot/tests/GravitySystemTests.cs @@ -0,0 +1,59 @@ +using Xunit; + +using Whiskerfolk.Puzzle; + +namespace Whiskerfolk.Tests; + +public class GravitySystemTests +{ + [Fact] + public void TestSingleFall() + { + var board = new BoardData(8, 8); + board.GetTile(0, 0).SetCrystal(0); + var falls = GravitySystem.ApplyGravity(board); + Assert.Single(falls); + var f = falls[0] as GravitySystem.FallInfo; + Assert.Equal(0, f.FromRow); + Assert.Equal(7, f.ToRow); + Assert.Equal(7, f.GetDistance()); + Assert.Equal(0, board.GetTile(7, 0).CrystalType); + } + + [Fact] + public void TestMultipleFalls() + { + var board = new BoardData(8, 8); + board.GetTile(0, 0).SetCrystal(0); + board.GetTile(2, 0).SetCrystal(1); + board.GetTile(3, 0).SetCrystal(2); + board.GetTile(4, 0).Clear(); + board.GetTile(5, 0).SetCrystal(3); + GravitySystem.ApplyGravity(board); + Assert.Equal(3, board.GetTile(7, 0).CrystalType); + Assert.Equal(2, board.GetTile(6, 0).CrystalType); + Assert.Equal(1, board.GetTile(5, 0).CrystalType); + Assert.Equal(0, board.GetTile(4, 0).CrystalType); + Assert.True(board.GetTile(0, 0).IsEmpty); + } + + [Fact] + public void TestNoFalls() + { + var board = new BoardData(8, 8); + for (int r = 0; r < 8; r++) + board.GetTile(r, 0).SetCrystal(r % 5); + var falls = GravitySystem.ApplyGravity(board); + Assert.Empty(falls); + } + + [Fact] + public void TestEmptyColumn() + { + var board = new BoardData(8, 8); + var falls = GravitySystem.ApplyGravity(board); + Assert.Empty(falls); + for (int r = 0; r < 8; r++) + Assert.True(board.GetTile(r, 0).IsEmpty); + } +} diff --git a/projects/kitty-crush/godot/tests/HomeBehaviorTests.cs b/projects/kitty-crush/godot/tests/HomeBehaviorTests.cs new file mode 100644 index 0000000..a81001c --- /dev/null +++ b/projects/kitty-crush/godot/tests/HomeBehaviorTests.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using Whiskerfolk.Cats; +using Whiskerfolk.Home; +using Xunit; + +namespace Whiskerfolk.Tests; + +public class HomeBehaviorTests +{ + [Fact] + public void LowBondMochiStillPrefersHisBoxToSeekingPlayer() + { + var state = new CatState { CatId = "mochi" }; + var scheduler = new BehaviorScheduler(seed: 7); + + var selected = scheduler.SelectHomeBehavior( + CatCatalog.Mochi, + state, + new List { HomeCatalog.CardboardBox } + ); + + Assert.Contains("cardboard_box_01", selected); + } +} diff --git a/projects/kitty-crush/godot/tests/MatchDetectorTests.cs b/projects/kitty-crush/godot/tests/MatchDetectorTests.cs new file mode 100644 index 0000000..9765adf --- /dev/null +++ b/projects/kitty-crush/godot/tests/MatchDetectorTests.cs @@ -0,0 +1,97 @@ +using Xunit; + +using Whiskerfolk.Puzzle; + +namespace Whiskerfolk.Tests; + +public class MatchDetectorTests +{ + [Fact] + public void TestHorizontal3() + { + var board = new BoardData(8, 8); + board.GetTile(0, 0).SetCrystal(0); + board.GetTile(0, 1).SetCrystal(0); + board.GetTile(0, 2).SetCrystal(0); + var result = MatchDetector.DetectAll(board); + Assert.True(result.HasMatches()); + Assert.Equal(3, result.TotalMatched); + Assert.Single(result.Groups); + } + + [Fact] + public void TestHorizontal4() + { + var board = new BoardData(8, 8); + board.GetTile(0, 0).SetCrystal(0); + board.GetTile(0, 1).SetCrystal(0); + board.GetTile(0, 2).SetCrystal(0); + board.GetTile(0, 3).SetCrystal(0); + var result = MatchDetector.DetectAll(board); + Assert.True(result.HasMatches()); + Assert.Equal(4, result.TotalMatched); + } + + [Fact] + public void TestHorizontal5() + { + var board = new BoardData(8, 8); + for (int c = 0; c < 5; c++) + board.GetTile(0, c).SetCrystal(0); + var result = MatchDetector.DetectAll(board); + Assert.True(result.HasMatches()); + Assert.Equal(5, result.TotalMatched); + } + + [Fact] + public void TestVertical3() + { + var board = new BoardData(8, 8); + board.GetTile(0, 0).SetCrystal(1); + board.GetTile(1, 0).SetCrystal(1); + board.GetTile(2, 0).SetCrystal(1); + var result = MatchDetector.DetectAll(board); + Assert.True(result.HasMatches()); + Assert.Equal(3, result.TotalMatched); + } + + [Fact] + public void TestMultipleMatches() + { + var board = new BoardData(8, 8); + board.GetTile(0, 0).SetCrystal(0); + board.GetTile(0, 1).SetCrystal(0); + board.GetTile(0, 2).SetCrystal(0); + board.GetTile(2, 3).SetCrystal(1); + board.GetTile(2, 4).SetCrystal(1); + board.GetTile(2, 5).SetCrystal(1); + var result = MatchDetector.DetectAll(board); + Assert.True(result.HasMatches()); + Assert.Equal(6, result.TotalMatched); + } + + [Fact] + public void TestLShape() + { + var board = new BoardData(8, 8); + board.GetTile(0, 0).SetCrystal(0); + board.GetTile(1, 0).SetCrystal(0); + board.GetTile(2, 0).SetCrystal(0); + board.GetTile(2, 1).SetCrystal(0); + board.GetTile(2, 2).SetCrystal(0); + var result = MatchDetector.DetectAll(board); + Assert.True(result.HasMatches()); + } + + [Fact] + public void TestNoMatch() + { + var board = new BoardData(8, 8); + for (int row = 0; row < 8; row++) + for (int col = 0; col < 8; col++) + board.GetTile(row, col).SetCrystal((row + col) % 5); + var result = MatchDetector.DetectAll(board); + Assert.False(result.HasMatches()); + Assert.Equal(0, result.TotalMatched); + } +} diff --git a/projects/kitty-crush/godot/tests/ObjectiveProgressTests.cs b/projects/kitty-crush/godot/tests/ObjectiveProgressTests.cs new file mode 100644 index 0000000..80e9527 --- /dev/null +++ b/projects/kitty-crush/godot/tests/ObjectiveProgressTests.cs @@ -0,0 +1,17 @@ +using Whiskerfolk.Puzzle; +using Xunit; + +namespace Whiskerfolk.Tests; + +public class ObjectiveProgressTests +{ + [Fact] + public void ObjectiveClampsAtTarget() + { + var objective = new ObjectiveProgress(MochiObjectives.All["collect_food"]); + objective.Add(4); + objective.Add(10); + Assert.Equal(6, objective.Current); + Assert.True(objective.Complete); + } +} diff --git a/projects/kitty-crush/godot/tests/ProductContractTests.cs b/projects/kitty-crush/godot/tests/ProductContractTests.cs new file mode 100644 index 0000000..db7a79c --- /dev/null +++ b/projects/kitty-crush/godot/tests/ProductContractTests.cs @@ -0,0 +1,34 @@ +using System.Linq; +using Whiskerfolk.Cats; +using Whiskerfolk.Home; +using Xunit; + +namespace Whiskerfolk.Tests; + +public class ProductContractTests +{ + [Fact] + public void InitialRosterContainsEightDistinctCats() + { + Assert.Equal(8, CatCatalog.InitialRoster.Count); + Assert.Equal(8, CatCatalog.InitialRoster.Select(x => x.Id).Distinct().Count()); + Assert.Equal(8, CatCatalog.InitialRoster.Select(x => x.SignatureBehaviorKey).Distinct().Count()); + } + + [Fact] + public void MochiKeepsHisIdentityInvariants() + { + Assert.Equal("mochi", CatCatalog.Mochi.Id); + Assert.Contains("box-loving", CatCatalog.Mochi.TraitTags); + Assert.Equal("double_check", CatCatalog.Mochi.SignatureBehaviorKey); + Assert.True(CatCatalog.Mochi.BaseAffinities["box"] > CatCatalog.Mochi.BaseAffinities["player"]); + } + + [Fact] + public void HomeCatalogGivesMochiStrongBoxAffinity() + { + Assert.True(HomeCatalog.CardboardBox.CatAffinityOverrides["mochi"] >= 1f); + Assert.Contains(HomeCatalog.CardboardBox.Slots, slot => slot.Id == "inside"); + Assert.Contains(HomeCatalog.CardboardBox.Slots, slot => slot.Id == "peek"); + } +} diff --git a/projects/kitty-crush/godot/tests/RescueDirectorTests.cs b/projects/kitty-crush/godot/tests/RescueDirectorTests.cs new file mode 100644 index 0000000..839ae38 --- /dev/null +++ b/projects/kitty-crush/godot/tests/RescueDirectorTests.cs @@ -0,0 +1,29 @@ +using Whiskerfolk.Rescue; +using Xunit; + +namespace Whiskerfolk.Tests; + +public class RescueDirectorTests +{ + [Fact] + public void ArcAdvancesOnlyWhenCurrentBeatCompletes() + { + var director = new RescueDirector(RescueArc.MochiFirstNight()); + Assert.Equal("discover_box", director.Current.Id); + + Assert.False(director.TryAdvance("food_help")); + Assert.Equal("discover_box", director.Current.Id); + + Assert.True(director.TryAdvance("discover_box")); + Assert.Equal("food_help", director.Current.Id); + } + + [Fact] + public void ResetReturnsToDiscovery() + { + var director = new RescueDirector(RescueArc.MochiFirstNight()); + director.TryAdvance("discover_box"); + director.Reset(); + Assert.Equal("discover_box", director.Current.Id); + } +} diff --git a/projects/kitty-crush/godot/tests/ReshuffleTests.cs b/projects/kitty-crush/godot/tests/ReshuffleTests.cs new file mode 100644 index 0000000..998a2f5 --- /dev/null +++ b/projects/kitty-crush/godot/tests/ReshuffleTests.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using Xunit; +using Whiskerfolk.Puzzle; + +namespace Whiskerfolk.Tests; + +public class ReshuffleTests +{ + [Fact] + public void TestReshuffleCanBePerformedWithoutEngineCollections() + { + var board = new BoardData(8, 8); + for (var row = 0; row < 8; row++) + for (var col = 0; col < 8; col++) + board.GetTile(row, col).SetCrystal((row + col) % 5); + + var values = new List(); + foreach (var tile in board.Tiles) + values.Add(tile.CrystalType); + + Shuffle(values, new Random(42)); + + for (var i = 0; i < board.Tiles.Length; i++) + board.Tiles[i].SetCrystal(values[i]); + + Assert.Equal(0, board.GetEmptyCount()); + Assert.Equal(64, values.Count); + } + + private static void Shuffle(IList list, Random random) + { + for (var i = list.Count - 1; i > 0; i--) + { + var j = random.Next(i + 1); + (list[i], list[j]) = (list[j], list[i]); + } + } +} diff --git a/projects/kitty-crush/godot/tests/ScoreCalculatorTests.cs b/projects/kitty-crush/godot/tests/ScoreCalculatorTests.cs new file mode 100644 index 0000000..32d0c50 --- /dev/null +++ b/projects/kitty-crush/godot/tests/ScoreCalculatorTests.cs @@ -0,0 +1,128 @@ +using Xunit; + +using Whiskerfolk.Puzzle; + +namespace Whiskerfolk.Tests; + +public class ScoreCalculatorTests +{ + [Fact] + public void TestLine3Score() + { + var group = new MatchResult.MatchGroup + { + Shape = (int)MatchShape.H_LINE, + MatchLength = 3, + Positions = new() + { + new GridPos(0, 0), + new GridPos(1, 0), + new GridPos(2, 0), + }, + }; + Assert.Equal(30, ScoreCalculator.CalculateGroupScore(group)); + } + + [Fact] + public void TestLine4Score() + { + var group = new MatchResult.MatchGroup + { + Shape = (int)MatchShape.V_LINE, + MatchLength = 4, + }; + Assert.Equal(60, ScoreCalculator.CalculateGroupScore(group)); + } + + [Fact] + public void TestLine5Score() + { + var group = new MatchResult.MatchGroup + { + Shape = (int)MatchShape.H_LINE, + MatchLength = 5, + }; + Assert.Equal(100, ScoreCalculator.CalculateGroupScore(group)); + } + + [Fact] + public void TestLShapeScore() + { + var group = new MatchResult.MatchGroup + { + Shape = (int)MatchShape.L_SHAPE, + MatchLength = 5, + }; + Assert.Equal(180, ScoreCalculator.CalculateGroupScore(group)); + } + + [Fact] + public void TestTShapeScore() + { + var group = new MatchResult.MatchGroup + { + Shape = (int)MatchShape.T_SHAPE, + MatchLength = 5, + }; + Assert.Equal(180, ScoreCalculator.CalculateGroupScore(group)); + } + + [Fact] + public void TestCrossScore() + { + var group = new MatchResult.MatchGroup + { + Shape = (int)MatchShape.CROSS, + MatchLength = 5, + }; + Assert.Equal(230, ScoreCalculator.CalculateGroupScore(group)); + } + + [Fact] + public void TestCalculateTotalSingleGroup() + { + var result = new MatchResult(); + result.Groups.Add(new MatchResult.MatchGroup + { + Shape = (int)MatchShape.H_LINE, + MatchLength = 3, + }); + result.Groups.Add(new MatchResult.MatchGroup + { + Shape = (int)MatchShape.H_LINE, + MatchLength = 4, + }); + Assert.Equal(90, ScoreCalculator.CalculateTotal(result)); + } + + [Fact] + public void TestCalculateTotalNoGroups() + { + var result = new MatchResult(); + Assert.Equal(0, ScoreCalculator.CalculateTotal(result)); + } + + [Fact] + public void TestApplyComboDepth0() + { + Assert.Equal(100, ScoreCalculator.ApplyCombo(100, 0)); + } + + [Fact] + public void TestApplyComboDepth1() + { + Assert.Equal(100, ScoreCalculator.ApplyCombo(100, 1)); + } + + [Fact] + public void TestApplyComboDepth2() + { + Assert.Equal(200, ScoreCalculator.ApplyCombo(100, 2)); + } + + [Fact] + public void TestApplyComboDepth3() + { + Assert.Equal(300, ScoreCalculator.ApplyCombo(100, 3)); + } +} diff --git a/projects/kitty-crush/godot/tests/SpecialTilesTests.cs b/projects/kitty-crush/godot/tests/SpecialTilesTests.cs new file mode 100644 index 0000000..d05742c --- /dev/null +++ b/projects/kitty-crush/godot/tests/SpecialTilesTests.cs @@ -0,0 +1,65 @@ +using Xunit; + +using Whiskerfolk.Puzzle; + +namespace Whiskerfolk.Tests; + +public class SpecialTilesTests +{ + [Fact] + public void TestSpecial4Bomb() + { + var board = new BoardData(8, 8); + for (int c = 0; c < 4; c++) + board.GetTile(0, c).SetCrystal(0); + var result = MatchDetector.DetectAll(board); + Assert.True(result.HasMatches()); + var hasBomb = false; + foreach (var spawn in result.SpecialSpawns) + { + if (spawn.SpecialType == (int)SpecialType.BOMB) + hasBomb = true; + } + Assert.True(hasBomb, "4-in-a-row should produce BOMB special"); + } + + [Fact] + public void TestSpecial5Rainbow() + { + var board = new BoardData(8, 8); + for (int c = 0; c < 5; c++) + board.GetTile(0, c).SetCrystal(0); + var result = MatchDetector.DetectAll(board); + var hasRainbow = false; + foreach (var spawn in result.SpecialSpawns) + { + if (spawn.SpecialType == (int)SpecialType.RAINBOW) + hasRainbow = true; + } + Assert.True(hasRainbow, "5-in-a-row should produce RAINBOW special"); + } + + [Fact] + public void TestSpecialLCross() + { + var board = new BoardData(8, 8); + for (int r = 0; r < 3; r++) + board.GetTile(r, 0).SetCrystal(0); + for (int c = 1; c < 4; c++) + board.GetTile(2, c).SetCrystal(0); + var result = MatchDetector.DetectAll(board); + Assert.True(result.HasMatches()); + Assert.True(result.TotalMatched > 0); + } + + [Fact] + public void TestNoSpecialFor3() + { + var board = new BoardData(8, 8); + board.GetTile(0, 0).SetCrystal(0); + board.GetTile(0, 1).SetCrystal(0); + board.GetTile(0, 2).SetCrystal(0); + var result = MatchDetector.DetectAll(board); + Assert.Empty(result.SpecialSpawns); + } +} diff --git a/projects/kitty-crush/godot/tests/SwapClearCascadeTests.cs b/projects/kitty-crush/godot/tests/SwapClearCascadeTests.cs new file mode 100644 index 0000000..ff3eb45 --- /dev/null +++ b/projects/kitty-crush/godot/tests/SwapClearCascadeTests.cs @@ -0,0 +1,78 @@ +using Xunit; + +using Whiskerfolk.Puzzle; + +namespace Whiskerfolk.Tests; + +public class SwapClearCascadeTests +{ + [Fact] + public void TestSwapDetectionAndClear() + { + var board = new BoardData(8, 8); + board.GetTile(0, 0).SetCrystal(0); + board.GetTile(0, 2).SetCrystal(0); + board.GetTile(0, 3).SetCrystal(0); + board.GetTile(0, 4).SetCrystal(1); + board.GetTile(0, 5).SetCrystal(1); + + board.Swap(0, 0, 0, 1); + var result = MatchDetector.DetectAll(board); + Assert.True(result.HasMatches(), "Swap should produce matches"); + + var positions = result.GetAllPositions(); + foreach (var pos in positions) + board.GetTile(pos.Y, pos.X).Clear(); + + foreach (var pos in positions) + Assert.True(board.GetTile(pos.Y, pos.X).IsEmpty, "Cleared tiles should be empty"); + + GravitySystem.ApplyGravity(board); + SpawnSystem.FillEmpty(board); + + Assert.Equal(0, board.GetEmptyCount()); + } + + [Fact] + public void TestCascadeTrigger() + { + var board = new BoardData(8, 8); + board.GetTile(0, 0).SetCrystal(0); + board.GetTile(1, 0).SetCrystal(1); + board.GetTile(2, 0).SetCrystal(1); + board.GetTile(3, 0).SetCrystal(1); + board.GetTile(4, 0).SetCrystal(2); + board.GetTile(5, 0).SetCrystal(0); + board.GetTile(6, 0).SetCrystal(0); + board.GetTile(7, 0).SetCrystal(3); + + var result = MatchDetector.DetectAll(board); + Assert.True(result.HasMatches(), "Should detect initial 3-vertical match of B"); + + foreach (var pos in result.GetAllPositions()) + board.GetTile(pos.Y, pos.X).Clear(); + + GravitySystem.ApplyGravity(board); + SpawnSystem.FillEmpty(board); + + Assert.Equal(0, board.GetEmptyCount()); + } + + [Fact] + public void TestNoCrashOnInvalidSwap() + { + var board = new BoardData(8, 8); + for (int row = 0; row < 8; row++) + for (int col = 0; col < 8; col++) + board.GetTile(row, col).SetCrystal((row + col) % 5); + + var snapshot = board.DuplicateData(); + board.Swap(0, 0, 0, 1); + var result = MatchDetector.DetectAll(board); + + if (!result.HasMatches()) + board.RestoreFromData(snapshot); + + Assert.Equal(0, board.GetEmptyCount()); + } +} diff --git a/projects/kitty-crush/godot/tests/ValidMoveCheckerTests.cs b/projects/kitty-crush/godot/tests/ValidMoveCheckerTests.cs new file mode 100644 index 0000000..29ae44b --- /dev/null +++ b/projects/kitty-crush/godot/tests/ValidMoveCheckerTests.cs @@ -0,0 +1,31 @@ +using Xunit; + +using Whiskerfolk.Puzzle; + +namespace Whiskerfolk.Tests; + +public class ValidMoveCheckerTests +{ + [Fact] + public void TestHasValidMove() + { + var board = new BoardData(8, 8); + board.GetTile(0, 0).SetCrystal(0); + board.GetTile(0, 1).SetCrystal(0); + board.GetTile(0, 2).SetCrystal(1); + board.GetTile(0, 3).SetCrystal(0); + Assert.True(ValidMoveChecker.HasAnyValidMove(board)); + } + + [Fact] + public void TestHasValidMoveReturnsBool() + { + var board = new BoardData(8, 8); + int[] pattern = { 0, 1, 2, 4, 3, 1, 2, 3 }; + for (int row = 0; row < 8; row++) + for (int col = 0; col < 8; col++) + board.GetTile(row, col).SetCrystal(pattern[(row + col) % pattern.Length]); + var result = ValidMoveChecker.HasAnyValidMove(board); + Assert.IsType(result); + } +} diff --git a/projects/kitty-crush/godot/tests/Whiskerfolk.Tests.csproj b/projects/kitty-crush/godot/tests/Whiskerfolk.Tests.csproj new file mode 100644 index 0000000..1ac097b --- /dev/null +++ b/projects/kitty-crush/godot/tests/Whiskerfolk.Tests.csproj @@ -0,0 +1,25 @@ + + + net8.0 + true + false + enable + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/projects/kitty-crush/prototype-web/README.md b/projects/kitty-crush/prototype-web/README.md new file mode 100644 index 0000000..34911ad --- /dev/null +++ b/projects/kitty-crush/prototype-web/README.md @@ -0,0 +1,45 @@ +# Whiskerfolk — Web Vertical Slice Prototype + +This is a **playable interaction prototype** for the first-session Mochi rescue arc. + +It is not the final production renderer and does not replace the intended Godot/mobile implementation. It exists to validate: + +- emotional sequence; +- scene pacing; +- puzzle-to-rescue connection; +- interaction hierarchy; +- the no-dashboard first session; +- the Box Moment; +- first Home experience. + +## Flow +1. Rain / box discovery +2. Food rescue puzzle +3. Trust interaction +4. Shelter puzzle +5. Carrier rescue +6. Home / Box Moment +7. Catbook, Memory and first blanket placement + +## Design rules represented +- one dominant CTA; +- no currencies or shop in first session; +- world-first composition; +- low-pressure trust; +- no punitive needs; +- no generic “3 stars / level complete” reward; +- Catbook and Memories are secondary surfaces. + +## Run +Serve this folder with any static HTTP server. + +Example: +```bash +python -m http.server 8000 +``` + +Then open: +`http://localhost:8000` + +## Quality note +The CSS-built Mochi and environment are deliberately **art-direction proxies**, not final production character art. Final AAA quality requires a proper character rig/sprites/animation/audio pipeline in the production game. diff --git a/projects/kitty-crush/prototype-web/VERCEL_DEPLOY.md b/projects/kitty-crush/prototype-web/VERCEL_DEPLOY.md new file mode 100644 index 0000000..6042743 --- /dev/null +++ b/projects/kitty-crush/prototype-web/VERCEL_DEPLOY.md @@ -0,0 +1,29 @@ +# Vercel Deployment — Whiskerfolk Prototype + +## Target +Team: **EchauriApps** + +Recommended Vercel project name: `whiskerfolk` + +Repository: `faechaurig-coder/UI-UX` + +Branch: `kitty-crush-preproduction` + +Root Directory: `projects/kitty-crush/prototype-web` + +Framework preset: **Other / Static** + +Build command: **None** + +Output directory: **static root** + +Install command: **None** + +## Expected production URL +Vercel will normally assign: `https://whiskerfolk.vercel.app` + +If that slug is unavailable, use: `https://play-whiskerfolk.vercel.app` + +## Important +This deploy is only the **web vertical-slice preview**. +It does not replace the Godot/Android production game. \ No newline at end of file diff --git a/projects/kitty-crush/prototype-web/app.js b/projects/kitty-crush/prototype-web/app.js new file mode 100644 index 0000000..8e6a65f --- /dev/null +++ b/projects/kitty-crush/prototype-web/app.js @@ -0,0 +1,413 @@ +const $ = (s) => document.querySelector(s); +const $$ = (s) => [...document.querySelectorAll(s)]; + +const scenes = { + intro: $('#scene-intro'), + puzzle: $('#scene-puzzle'), + trust: $('#scene-trust'), + rescue: $('#scene-rescue'), + home: $('#scene-home'), +}; + +let stage = 0; +let objective = { type:'food', target:6, current:0 }; +let selected = null; +let board = []; +let moves = 18; +let inputLocked = false; +let trust = 8; +let soundOn = true; + +const palette = { + food:'#C88B5A', + blanket:'#8BA892', + drop:'#7D9EAE', + paw:'#D29E80', + leaf:'#728D66' +}; + +const glyphs = { + food: (c)=>``, + blanket: (c)=>``, + drop: (c)=>``, + paw: (c)=>``, + leaf: (c)=>``, +}; + +const types = Object.keys(glyphs); + +function showScene(name){ + Object.values(scenes).forEach(s=>s.classList.remove('scene--active')); + scenes[name].classList.add('scene--active'); +} + +function haptic(ms=10){ + if(navigator.vibrate) navigator.vibrate(ms); +} + +function toast(msg){ + const t=$('#toast'); + t.textContent=msg; + t.classList.add('toast--show'); + clearTimeout(toast._t); + toast._t=setTimeout(()=>t.classList.remove('toast--show'),1700); +} + +function startFoodPuzzle(){ + stage=1; + objective={type:'food',target:6,current:0}; + moves=18; + configurePuzzle('FIRST HELP','Find something he can eat.','food'); + showScene('puzzle'); + generateBoard(); +} + +function startShelterPuzzle(){ + stage=2; + objective={type:'blanket',target:8,current:0}; + moves=16; + configurePuzzle('SHELTER','Give the box a dry cover.','blanket'); + $('#rescue-peek-copy').textContent='The rain is getting heavier.'; + $('#board-tip').textContent='Blanket pieces will change his shelter.'; + showScene('puzzle'); + generateBoard(); +} + +function startSafePathPuzzle(){ + stage=3; + objective={type:'drop',target:10,current:0}; + moves=14; + configurePuzzle('SAFE PATH','Clear the water from his way.','key'); + $('#rescue-peek-copy').textContent='The water is reaching the box.'; + $('#board-tip').textContent='Clear water pieces so Mochi can reach the carrier.'; + showScene('puzzle'); + generateBoard(); +} + +function configurePuzzle(kicker,title,type){ + $('#objective-kicker').textContent=kicker; + $('#objective-title').textContent=title; + $('#objective-count').textContent=`0 / ${objective.target}`; + const icon=$('#objective-icon'); + icon.className=`objective-glyph ${type}-glyph`; + $('#moves-count').textContent=moves; + $('#level-complete').hidden=true; +} + +function generateBoard(){ + const el=$('#board'); + el.innerHTML=''; + board=[]; + selected=null; + for(let r=0;r<7;r++){ + board[r]=[]; + for(let c=0;c<7;c++){ + let type; + do { + type=types[Math.floor(Math.random()*types.length)]; + } while( + (c>=2 && board[r][c-1]===type && board[r][c-2]===type) || + (r>=2 && board[r-1][c]===type && board[r-2][c]===type) + ); + board[r][c]=type; + } + } + ensureMove(); + renderBoard(); +} + +function renderBoard(){ + const el=$('#board'); + el.innerHTML=''; + board.forEach((row,r)=>row.forEach((type,c)=>{ + const b=document.createElement('button'); + b.className='tile'; + b.dataset.r=r; + b.dataset.c=c; + b.setAttribute('role','gridcell'); + b.setAttribute('aria-label',type); + b.innerHTML=glyphs[type](palette[type]); + b.addEventListener('click',()=>onTile(r,c,b)); + el.appendChild(b); + })); +} + +function onTile(r,c,node){ + if(inputLocked) return; + haptic(6); + if(!selected){ + selected={r,c}; + node.classList.add('tile--selected'); + return; + } + const dr=Math.abs(selected.r-r), dc=Math.abs(selected.c-c); + if(dr+dc!==1){ + selected={r,c}; + $$('.tile').forEach(x=>x.classList.remove('tile--selected')); + node.classList.add('tile--selected'); + return; + } + const a=selected; + selected=null; + swap(a.r,a.c,r,c); + const matches=findMatches(); + if(!matches.length){ + swap(a.r,a.c,r,c); + haptic(16); + renderBoard(); + toast('That one does not connect.'); + return; + } + moves--; + $('#moves-count').textContent=moves; + resolveMatches(matches); +} + +function swap(r1,c1,r2,c2){ + [board[r1][c1],board[r2][c2]]=[board[r2][c2],board[r1][c1]]; +} + +function findMatches(){ + const set=new Set(); + for(let r=0;r<7;r++){ + let run=1; + for(let c=1;c<=7;c++){ + if(c<7 && board[r][c] && board[r][c]===board[r][c-1]) run++; + else{ + if(run>=3) for(let k=0;k=3) for(let k=0;kx.split(',').map(Number)); +} + +async function resolveMatches(matches){ + inputLocked=true; + const nodes=$$('.tile'); + matches.forEach(([r,c])=>{ + const idx=r*7+c; + nodes[idx]?.classList.add('tile--matched'); + if(board[r][c]===objective.type) objective.current++; + }); + $('#objective-count').textContent=`${Math.min(objective.current,objective.target)} / ${objective.target}`; + haptic(matches.length>=5?22:10); + await delay(230); + + for(const [r,c] of matches) board[r][c]=null; + collapse(); + fill(); + renderBoard(); + await delay(150); + + const cascade=findMatches(); + if(cascade.length){ + await resolveMatches(cascade); + return; + } + + inputLocked=false; + if(objective.current>=objective.target){ + completePuzzle(); + }else if(moves<=0){ + moves+=6; + $('#moves-count').textContent=moves; + toast('Mochi waits. Take six more moves.'); + } +} + +function collapse(){ + for(let c=0;c<7;c++){ + const vals=[]; + for(let r=6;r>=0;r--) if(board[r][c]) vals.push(board[r][c]); + for(let r=6;r>=0;r--) board[r][c]=vals[6-r]||null; + } +} + +function fill(){ + for(let r=0;r<7;r++) for(let c=0;c<7;c++) if(!board[r][c]){ + board[r][c]=types[Math.floor(Math.random()*types.length)]; + } +} + +function hasMove(){ + const dirs=[[0,1],[1,0]]; + for(let r=0;r<7;r++)for(let c=0;c<7;c++)for(const[dR,dC]of dirs){ + const rr=r+dR,cc=c+dC; + if(rr>=7||cc>=7)continue; + swap(r,c,rr,cc); + const good=findMatches().length>0; + swap(r,c,rr,cc); + if(good)return true; + } + return false; +} +function ensureMove(){ if(!hasMove()) generateBoard(); } + +function completePuzzle(){ + const panel=$('#level-complete'); + if(stage===1){ + $('#complete-title').textContent='He stayed.'; + $('#complete-copy').textContent='You set the food down and gave him space.'; + $('#complete-btn').textContent='Keep watching'; + }else if(stage===2){ + $('#complete-title').textContent='Dry enough.'; + $('#complete-copy').textContent='The cover muffles the rain. He looks at you twice.'; + $('#complete-btn').textContent='Make a safe path'; + }else{ + $('#complete-title').textContent='The way is clear.'; + $('#complete-copy').textContent='Mochi can reach the carrier without stepping into the water.'; + $('#complete-btn').textContent='Open the carrier'; + } + panel.hidden=false; +} + +function delay(ms){return new Promise(r=>setTimeout(r,ms));} + +function enterTrust(){ + showScene('trust'); + trust=8; + $('#trust-fill').style.width='8%'; + $('#trust-copy').textContent='Stay close. Let him make the next move.'; +} + +let holding=false; +function beginHold(){ + if(holding)return; + holding=true; + const zone=$('#hand-zone'); + zone.style.transform='scale(.98)'; + const start=performance.now(); + const tick=(now)=>{ + if(!holding)return; + const elapsed=now-start; + trust=Math.min(100,8+elapsed/32); + $('#trust-fill').style.width=`${trust}%`; + const mochi=$('#mochi-trust'); + if(trust>35) mochi.style.left='39%'; + if(trust>62){ + $('#trust-copy').textContent='He came closer. Then backed away. Then came back.'; + mochi.style.left='45%'; + } + if(trust>=100){ + holding=false; + zone.style.transform=''; + haptic(18); + $('#trust-copy').textContent='He chose the next step.'; + setTimeout(()=>startShelterPuzzle(),1000); + return; + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); +} +function endHold(){ + if(trust<100){ + holding=false; + $('#hand-zone').style.transform=''; + trust=Math.max(8,trust-8); + $('#trust-fill').style.width=`${trust}%`; + $('#trust-copy').textContent='Too soon. Try again, slowly.'; + } +} + +async function rescueSequence(){ + const carrier=$('.carrier'); + carrier.classList.add('carrier--open'); + $('#open-carrier').disabled=true; + $('#open-carrier').textContent='Wait…'; + haptic(14); + await delay(1200); + $('#mochi-rescue').classList.add('mochi--enter'); + await delay(2500); + enterHome(); +} + +async function enterHome(){ + showScene('home'); + await delay(1100); + const m=$('#mochi-home'); + m.classList.remove('mochi--hidden'); + await delay(900); + m.classList.add('mochi--home-settled'); + $('#home-box').style.transform='translateX(-50%) translateX(95px) scale(.9)'; + $('#home-box').style.opacity='.72'; + await delay(1100); + $('#home-message').hidden=false; + haptic(22); + await delay(1700); + $('#home-ui').hidden=false; +} + +function openSheet(kind){ + const sheet=$('#sheet'); + const c=$('#sheet-content'); + if(kind==='catbook'){ + c.innerHTML=` +

CATBOOK / 01

+

Mochi

+

Cautious observer. Box enthusiast. Still deciding what “home” means.

+
+
M
+
+ Discovered trait +

He approaches twice before he trusts once.

+
+
`; + }else{ + c.innerHTML=` +

MEMORY / FIRST NIGHT

+

He came out.

+
+ The warm corner +

Mochi left the carrier on his own and chose the box beside the sofa.

+
`; + } + sheet.hidden=false; +} + +$('#look-btn').addEventListener('click',startFoodPuzzle); +$('#box-hotspot').addEventListener('click',()=>{ + $('#intro-hint').textContent='Two green eyes. Very small.'; + $('#look-btn').textContent='Help him'; +}); +$('#complete-btn').addEventListener('click',()=>{ + $('#level-complete').hidden=true; + if(stage===1) enterTrust(); + else if(stage===2) startSafePathPuzzle(); + else showScene('rescue'); +}); +$('#hand-zone').addEventListener('pointerdown',beginHold); +$('#hand-zone').addEventListener('pointerup',endHold); +$('#hand-zone').addEventListener('pointercancel',endHold); +$('#hand-zone').addEventListener('pointerleave',()=>{if(holding) endHold()}); +$('#open-carrier').addEventListener('click',rescueSequence); +$('#sound-toggle').addEventListener('click',()=>{ + soundOn=!soundOn; + $('#sound-toggle span').textContent=soundOn?'♪':'×'; + toast(soundOn?'Sound on':'Sound off'); +}); +$$('.home-pill').forEach(b=>b.addEventListener('click',()=>openSheet(b.dataset.sheet))); +$('#sheet-close').addEventListener('click',()=>$('#sheet').hidden=true); +$('#blanket-btn').addEventListener('click',()=>{ + $('#blanket').hidden=false; + $('#blanket-btn').textContent='Blanket placed'; + $('#blanket-btn').disabled=true; + $('#mochi-home').style.left='24%'; + toast('Mochi noticed immediately.'); + haptic(12); +}); + +document.addEventListener('visibilitychange',()=>{ + if(document.hidden) holding=false; +}); diff --git a/projects/kitty-crush/prototype-web/index.html b/projects/kitty-crush/prototype-web/index.html new file mode 100644 index 0000000..54b6f9c --- /dev/null +++ b/projects/kitty-crush/prototype-web/index.html @@ -0,0 +1,221 @@ + + + + + + + Whiskerfolk — Mochi Vertical Slice + + + +
+
+ +
+ +
WHISKERFOLK
+ +
+

A rainy corner. 11:47 PM.

+

You heard something.

+
+ +
+
+
+
+ + +
+ +
+

There. Again.

+ +
+
+ +
+
+ +
+ FIRST HELP +

Find something he can eat.

+
+ + 0 / 6 +
+
+ +
+ +
+
+ +
+

He is watching.

+
+ +
+ + + + +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ +
+

TRUST

+

Don’t reach for him.

+

Stay close. Let him make the next move.

+
+ +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +
+

ONE LAST THING

+

Open it. Then wait.

+

The water is getting closer. He has to choose you.

+
+ +
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + +
+
+ +
+ + + \ No newline at end of file diff --git a/projects/kitty-crush/prototype-web/styles.css b/projects/kitty-crush/prototype-web/styles.css new file mode 100644 index 0000000..f1361dd --- /dev/null +++ b/projects/kitty-crush/prototype-web/styles.css @@ -0,0 +1,184 @@ +:root{ + --ink:#27312E; + --ink-soft:#51605B; + --milk:#FFF9EF; + --paper:#F5E9D7; + --moss:#6F8F78; + --sage:#AFC8B4; + --caramel:#C88B5A; + --peach:#F2C7A8; + --rain:#7896A6; + --night:#273B49; + --shadow:0 18px 55px rgba(20,31,33,.18); + --safe-top:max(18px,env(safe-area-inset-top)); + --safe-bottom:max(20px,env(safe-area-inset-bottom)); + font-family:Inter,ui-rounded,"SF Pro Rounded","Nunito",system-ui,-apple-system,sans-serif; +} +*{box-sizing:border-box;-webkit-tap-highlight-color:transparent} +html,body{margin:0;width:100%;height:100%;background:#13212a;color:var(--ink);overflow:hidden} +button{font:inherit;color:inherit} +.app-shell{position:relative;width:100%;height:100%;max-width:540px;margin:0 auto;background:var(--milk);overflow:hidden;box-shadow:0 0 100px rgba(0,0,0,.35)} +.scene{position:absolute;inset:0;display:none;overflow:hidden} +.scene--active{display:block;animation:sceneIn .55s cubic-bezier(.2,.8,.2,1)} +@keyframes sceneIn{from{opacity:0;transform:scale(1.015)}to{opacity:1;transform:none}} +.eyebrow{margin:0 0 8px;font-size:11px;line-height:1.2;font-weight:800;letter-spacing:.16em;text-transform:uppercase;color:rgba(255,255,255,.68)} +.story-copy{position:absolute;z-index:9;left:26px;right:26px} +.story-copy--top{top:calc(var(--safe-top) + 20px)} +.story-copy--bottom{bottom:calc(var(--safe-bottom) + 18px);color:var(--milk)} +.story-copy h1,.story-copy h2{margin:0;max-width:410px;font-size:clamp(30px,8vw,44px);line-height:.98;letter-spacing:-.04em;font-weight:780} +.story-copy h2{font-size:clamp(28px,7vw,40px)} +.story-copy p:not(.eyebrow){max-width:420px;margin:12px 0 0;font-size:15px;line-height:1.45;color:rgba(255,255,255,.78)} +.brand-ghost{position:absolute;z-index:8;top:var(--safe-top);left:26px;font-size:10px;font-weight:900;letter-spacing:.22em;color:rgba(255,255,255,.35)} +.bottom-action{position:absolute;z-index:20;left:22px;right:22px;bottom:var(--safe-bottom);display:flex;align-items:center;justify-content:space-between;gap:16px} +.soft-copy{margin:0;color:rgba(255,255,255,.65);font-size:14px} +.primary-btn{border:0;border-radius:22px;padding:16px 22px;background:var(--milk);color:var(--ink);font-weight:780;box-shadow:0 14px 30px rgba(0,0,0,.18);min-height:54px;cursor:pointer;transition:transform .16s ease,box-shadow .16s ease} +.primary-btn:active{transform:scale(.975);box-shadow:0 8px 18px rgba(0,0,0,.15)} +.primary-btn--small{min-height:48px;padding:12px 18px;border-radius:18px;background:var(--ink);color:#fff;box-shadow:none} + +/* Rain intro */ +.scene-rain{background: + radial-gradient(circle at 72% 34%,rgba(164,199,209,.16),transparent 28%), + linear-gradient(180deg,#1d3441 0%,#263e49 42%,#192a31 100%)} +.scene-vignette{position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,.06),rgba(2,11,15,.28)),radial-gradient(circle at 50% 48%,transparent 35%,rgba(0,0,0,.26) 100%)} +.rain{position:absolute;inset:-20%;opacity:.33;background-image:repeating-linear-gradient(105deg,transparent 0 16px,rgba(255,255,255,.28) 17px 18px,transparent 19px 38px);animation:rainfall .9s linear infinite;transform:translate3d(0,-4%,0)} +.rain--window{opacity:.2} +@keyframes rainfall{to{transform:translate3d(-4%,10%,0)}} +.alley-stage{position:absolute;inset:0} +.awning{position:absolute;left:-10%;right:34%;top:32%;height:16px;background:#18252a;transform:skewY(-6deg);box-shadow:0 26px 50px rgba(0,0,0,.22)} +.puddle{position:absolute;border-radius:50%;background:radial-gradient(ellipse,rgba(163,195,205,.24),rgba(63,98,111,.05) 70%);filter:blur(.3px)} +.puddle-a{width:240px;height:48px;bottom:19%;left:2%;transform:rotate(-6deg)} +.puddle-b{width:170px;height:34px;bottom:29%;right:-10%;transform:rotate(10deg)} +.rescue-box{position:absolute;z-index:6;width:220px;height:150px;left:50%;bottom:23%;transform:translateX(-50%);border:0;background:transparent;cursor:pointer} +.box-front{position:absolute;inset:28px 0 0;background:linear-gradient(145deg,#8e6b47,#6f5034);clip-path:polygon(5% 4%,96% 0,92% 100%,0 96%);box-shadow:var(--shadow)} +.box-flap{position:absolute;left:8px;right:12px;top:11px;height:52px;background:#a98256;clip-path:polygon(0 26%,47% 0,100% 28%,94% 100%,7% 90%)} +.box-shadow{position:absolute;left:7%;right:4%;bottom:-6px;height:18px;background:rgba(0,0,0,.28);filter:blur(9px);border-radius:50%} +.eyes{position:absolute;z-index:10;left:50%;top:63px;transform:translateX(-50%);display:flex;gap:20px;opacity:.78;animation:blinkEyes 5.2s infinite} +.eyes i,.mini-eyes i{display:block;width:13px;height:17px;background:#9ebc91;border-radius:50% 50% 45% 45%;box-shadow:0 0 13px rgba(169,210,149,.35)} +@keyframes blinkEyes{0%,44%,48%,100%{transform:translateX(-50%) scaleY(1)}46%{transform:translateX(-50%) scaleY(.08)}} + +/* Puzzle */ +.scene-puzzle{background:linear-gradient(180deg,#f9f1e5 0%,#fff9ef 50%,#f4eadb 100%);padding-top:var(--safe-top)} +.puzzle-header{height:150px;padding:20px 18px 8px;display:grid;grid-template-columns:48px 1fr 48px;gap:10px;align-items:start} +.icon-btn{width:48px;height:48px;border:0;border-radius:18px;background:rgba(39,49,46,.07);font-size:28px;font-weight:500;display:grid;place-items:center} +.objective-wrap{text-align:center} +.objective-wrap .eyebrow{color:#7c877f;margin-top:2px} +.objective-wrap h2{margin:0 auto;font-size:21px;line-height:1.04;letter-spacing:-.025em;max-width:290px} +.objective-progress{display:inline-flex;align-items:center;gap:8px;margin-top:10px;padding:7px 11px;border-radius:999px;background:#fff;box-shadow:0 8px 18px rgba(67,71,64,.08);font-size:13px} +.objective-glyph{width:18px;height:18px;display:inline-block;position:relative} +.food-glyph{border-radius:50% 50% 42% 42%;background:#cf8c5d;box-shadow:inset 0 -3px rgba(88,53,35,.18)} +.food-glyph:after{content:"";position:absolute;width:8px;height:4px;border-radius:50%;background:#f0c28d;left:5px;top:3px} +.blanket-glyph{background:repeating-linear-gradient(45deg,#91ad97 0 4px,#acc4b0 4px 8px);border-radius:4px} +.key-glyph{border:3px solid #cf9d53;border-radius:50%} +.key-glyph:after{content:"";position:absolute;width:11px;height:3px;background:#cf9d53;left:10px;top:8px;box-shadow:6px 4px 0 -1px #cf9d53} +.rescue-peek{height:70px;display:flex;align-items:center;justify-content:center;gap:12px;color:var(--ink-soft);font-size:13px} +.mini-box{width:54px;height:40px;position:relative;border-radius:7px;background:#8e6b47;box-shadow:0 8px 16px rgba(81,55,36,.14)} +.mini-eyes{position:absolute;left:50%;top:14px;transform:translateX(-50%);display:flex;gap:7px} +.mini-eyes i{width:5px;height:7px;box-shadow:none} +.board{width:min(92vw,455px);aspect-ratio:1;margin:0 auto;padding:9px;display:grid;grid-template-columns:repeat(7,1fr);gap:6px;border-radius:28px;background:rgba(115,137,118,.13);box-shadow:inset 0 0 0 1px rgba(255,255,255,.7),0 24px 55px rgba(73,69,58,.12)} +.tile{border:0;border-radius:18px;position:relative;display:grid;place-items:center;background:#fff;box-shadow:0 5px 0 rgba(73,70,60,.09),0 9px 16px rgba(69,62,48,.08);cursor:pointer;transition:transform .14s cubic-bezier(.2,.9,.3,1),box-shadow .14s ease,opacity .14s ease} +.tile:active{transform:scale(.9)} +.tile--selected{transform:scale(1.08);box-shadow:0 5px 0 rgba(73,70,60,.08),0 0 0 4px rgba(111,143,120,.2),0 12px 24px rgba(69,62,48,.12)} +.tile--matched{animation:pop .25s ease forwards} +@keyframes pop{50%{transform:scale(1.16)}to{transform:scale(.3);opacity:0}} +.tile svg{width:72%;height:72%;overflow:visible;filter:drop-shadow(0 2px 0 rgba(0,0,0,.05))} +.puzzle-footer{height:86px;padding:15px 24px 0;display:flex;align-items:flex-start;justify-content:space-between;gap:20px;color:var(--ink-soft)} +.moves-pill{display:flex;align-items:center;gap:7px;padding:9px 12px;background:#fff;border-radius:14px;box-shadow:0 8px 18px rgba(67,71,64,.07);font-size:12px} +.moves-pill strong{font-size:16px;color:var(--ink)} +#board-tip{margin:8px 0 0;text-align:right;font-size:12px;max-width:180px} +.level-complete{position:absolute;z-index:30;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:36px;text-align:center;background:rgba(255,249,239,.94);backdrop-filter:blur(16px)} +.level-complete[hidden]{display:none} +.level-complete .eyebrow{color:var(--moss)} +.level-complete h2{margin:4px 0 8px;font-size:42px;letter-spacing:-.05em} +.level-complete p:not(.eyebrow){margin:0 0 25px;color:var(--ink-soft);max-width:300px;line-height:1.45} +.success-pulse{width:80px;height:80px;border-radius:50%;background:radial-gradient(circle,#fff 0 24%,#b6cbb8 25% 50%,rgba(182,203,184,.15) 51%);animation:softPulse 1.8s ease-in-out infinite} +@keyframes softPulse{50%{transform:scale(1.06);opacity:.82}} + +/* Mochi vector via CSS */ +.scene-trust{background:linear-gradient(180deg,#324854,#21343e 100%)} +.trust-room{position:absolute;inset:0} +.rain-window{position:absolute;right:7%;top:12%;width:38%;height:34%;border:8px solid #18282f;border-radius:8px;background:linear-gradient(180deg,#5f7d8b,#344d59);overflow:hidden;box-shadow:inset 0 0 0 2px rgba(255,255,255,.08)} +.trust-box{position:absolute;left:14%;bottom:29%;width:170px;height:104px;background:#79593b;transform:skewY(-2deg);border-radius:7px;box-shadow:0 18px 30px rgba(0,0,0,.18)} +.mochi{position:absolute;width:150px;height:150px;transform-origin:50% 85%;filter:drop-shadow(0 16px 12px rgba(0,0,0,.16))} +.mochi .body{position:absolute;left:32px;top:66px;width:88px;height:66px;border-radius:48% 52% 45% 43%;background:#f3ead9} +.mochi .head{position:absolute;left:32px;top:22px;width:82px;height:78px;border-radius:47% 49% 44% 46%;background:#fbf5e8} +.mochi .ear{position:absolute;top:-18px;width:31px;height:40px;background:#f7efdf;clip-path:polygon(50% 0,100% 100%,0 82%)} +.mochi .ear:after{content:"";position:absolute;inset:9px 8px 7px;background:#ddb9a7;clip-path:inherit} +.mochi .ear--left{left:3px;transform:rotate(-8deg)} +.mochi .ear--right{right:2px;transform:scaleX(-1) rotate(-8deg)} +.mochi .ear--caramel{background:#c58a5d} +.mochi .mark{position:absolute;right:15px;top:22px;width:18px;height:24px;background:#c58a5d;border-radius:70% 35% 60% 45%;transform:rotate(-18deg);opacity:.9} +.mochi .eye{position:absolute;top:37px;width:10px;height:15px;background:#55735c;border-radius:50%;box-shadow:inset 0 0 0 3px #344b3b,0 0 0 1px rgba(0,0,0,.08)} +.mochi .eye:after{content:"";position:absolute;width:3px;height:4px;left:2px;top:2px;border-radius:50%;background:#fff} +.mochi .eye--left{left:21px} +.mochi .eye--right{right:19px} +.mochi .nose{position:absolute;left:50%;top:54px;width:8px;height:6px;transform:translateX(-50%);background:#9f6c65;border-radius:55% 55% 75% 75%} +.mochi .tail{position:absolute;left:99px;top:82px;width:64px;height:20px;border:14px solid #eadfcb;border-left-color:transparent;border-bottom-color:transparent;border-radius:50%;transform:rotate(25deg)} +.mochi .tail:after{content:"";position:absolute;width:17px;height:15px;border-radius:50%;background:#b77b52;right:-11px;top:-13px} +.mochi .paw{position:absolute;left:39px;top:117px;width:30px;height:18px;border-radius:50%;background:#f5ebda} +.whiskers{position:absolute;top:55px;width:34px;height:1px;background:rgba(74,63,54,.42)} +.whiskers:before,.whiskers:after{content:"";position:absolute;width:34px;height:1px;background:inherit;left:0} +.whiskers:before{top:-7px;transform:rotate(8deg)} +.whiskers:after{top:7px;transform:rotate(-8deg)} +.whiskers--left{left:-20px;transform:rotate(4deg)} +.whiskers--right{right:-21px;transform:scaleX(-1) rotate(4deg)} +.mochi--trust{left:34%;bottom:23%;transform:translateX(-50%) scale(.95);animation:mochiBreath 3s ease-in-out infinite} +@keyframes mochiBreath{50%{transform:translateX(-50%) scale(.96,.975)}} +.hand-zone{position:absolute;right:9%;bottom:23%;width:120px;height:104px;border:1px solid rgba(255,255,255,.18);border-radius:28px;background:rgba(255,255,255,.06);color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;backdrop-filter:blur(8px);cursor:pointer} +.hand-shape{font-size:32px;transform:rotate(-12deg);opacity:.75} +.hand-zone span:last-child{font-size:12px;font-weight:700;color:rgba(255,255,255,.72)} +.trust-meter{margin-top:16px;width:180px;height:6px;background:rgba(255,255,255,.16);border-radius:10px;overflow:hidden} +.trust-meter span{display:block;width:8%;height:100%;background:#c8d9c7;border-radius:inherit;transition:width .45s ease} + +/* Rescue */ +.scene-rescue{background:linear-gradient(180deg,#263e49,#172b34)} +.rescue-sky{position:absolute;inset:0;background:radial-gradient(circle at 50% 18%,rgba(160,192,201,.14),transparent 32%)} +.waterline{position:absolute;left:-20%;right:-20%;bottom:0;height:27%;background:linear-gradient(180deg,rgba(75,113,126,.15),rgba(75,113,126,.65));clip-path:polygon(0 9%,12% 5%,24% 11%,38% 5%,50% 10%,63% 4%,77% 9%,90% 3%,100% 8%,100% 100%,0 100%);animation:waterShift 2.8s ease-in-out infinite alternate} +@keyframes waterShift{to{transform:translateX(3%) translateY(-4px)}} +.carrier{position:absolute;left:54%;bottom:23%;width:170px;height:126px;border-radius:18px 18px 24px 24px;background:#c8b898;box-shadow:0 20px 30px rgba(0,0,0,.2)} +.carrier:before{content:"";position:absolute;inset:16px 14px 18px 12px;border-radius:13px;background:repeating-linear-gradient(90deg,#8d816a 0 4px,transparent 4px 16px),linear-gradient(#6a6457,#554f45)} +.carrier-door{position:absolute;z-index:3;right:12px;top:15px;width:79px;height:94px;border:4px solid #444b49;border-radius:12px;background:repeating-linear-gradient(90deg,transparent 0 10px,rgba(45,53,52,.8) 10px 13px),repeating-linear-gradient(0deg,transparent 0 10px,rgba(45,53,52,.8) 10px 13px);transform-origin:left center;transition:transform .7s cubic-bezier(.2,.8,.2,1)} +.carrier--open .carrier-door{transform:perspective(300px) rotateY(-72deg)} +.carrier-handle{position:absolute;left:55px;top:-20px;width:60px;height:28px;border:8px solid #9f9175;border-bottom:0;border-radius:22px 22px 0 0} +.mochi--rescue{left:24%;bottom:22%;transform:scale(.82);transition:transform 2.3s cubic-bezier(.2,.75,.2,1),left 2.3s cubic-bezier(.2,.75,.2,1),opacity .5s 2s} +.mochi--rescue.mochi--enter{left:60%;transform:scale(.6);opacity:0} + +/* Home */ +.scene-home{background:linear-gradient(180deg,#f8eddb 0%,#e8d9c2 68%,#d5c3a9 68%,#c5ae91 100%)} +.home-light{position:absolute;left:-20%;top:-10%;width:100%;height:72%;background:radial-gradient(circle at 42% 35%,rgba(255,255,255,.9),rgba(255,255,255,.05) 56%,transparent 70%)} +.window-home{position:absolute;right:8%;top:10%;width:33%;height:27%;border:11px solid #8b6f53;background:linear-gradient(#8ca7b3,#637f8a);border-radius:5px;overflow:hidden;box-shadow:0 18px 25px rgba(92,67,46,.12)} +.sofa{position:absolute;left:7%;bottom:25%;width:61%;height:27%} +.sofa-back{position:absolute;left:0;right:0;top:0;height:69%;border-radius:42px 42px 20px 20px;background:#8ea58f;box-shadow:inset 0 -12px rgba(68,90,72,.08)} +.sofa-seat{position:absolute;left:5%;right:-4%;bottom:0;height:44%;border-radius:18px 26px 23px 23px;background:#9eb3a0;box-shadow:0 18px 24px rgba(68,54,40,.12)} +.sofa-leg{position:absolute;bottom:-18px;width:15px;height:25px;background:#755c43;border-radius:4px} +.sofa-leg.l{left:18%}.sofa-leg.r{right:10%} +.rug{position:absolute;width:68%;height:16%;left:17%;bottom:6%;border-radius:50%;background:repeating-linear-gradient(90deg,#d8b99a 0 14px,#e4c8aa 14px 28px);opacity:.65;transform:perspective(300px) rotateX(55deg)} +.home-box{position:absolute;z-index:8;width:160px;height:106px;left:50%;bottom:21%;transform:translateX(-50%);background:#9b724b;border-radius:6px 7px 9px 5px;box-shadow:0 16px 25px rgba(80,54,35,.18);transition:transform .9s ease,opacity .8s ease} +.home-box-flap{position:absolute;left:7px;right:7px;top:-28px;height:40px;background:#ac8257;clip-path:polygon(0 50%,46% 0,100% 47%,88% 100%,10% 91%)} +.mochi--home{z-index:10;left:50%;bottom:20%;transform:translateX(-50%) scale(.72);transition:all 1.2s cubic-bezier(.2,.8,.2,1)} +.mochi--hidden{opacity:0;transform:translateX(-50%) scale(.4) translateY(30px)} +.mochi--home-settled{left:28%;bottom:19%;transform:translateX(-50%) scale(.72)} +.home-message{position:absolute;z-index:20;top:var(--safe-top);left:25px;right:25px;padding-top:22px;color:var(--ink)} +.home-message .eyebrow{color:var(--moss)} +.home-message h1{margin:0;font-size:38px;line-height:1;letter-spacing:-.045em} +.home-message p:not(.eyebrow){margin:9px 0;color:var(--ink-soft)} +.home-ui{position:absolute;z-index:21;left:18px;right:18px;bottom:var(--safe-bottom);display:flex;gap:8px;align-items:center} +.home-pill{border:0;background:rgba(255,249,239,.88);backdrop-filter:blur(12px);min-height:48px;border-radius:18px;padding:0 13px;display:flex;gap:8px;align-items:center;font-size:12px;font-weight:750;box-shadow:0 10px 25px rgba(68,55,43,.10)} +.home-pill-dot{width:18px;height:18px;border-radius:50%;background:linear-gradient(#c58a5d,#a36d49)} +.home-pill-dot.memory{background:linear-gradient(#8ca69a,#6d887c)} +.blanket{position:absolute;z-index:7;left:11%;bottom:18%;width:150px;height:64px;border-radius:52% 44% 34% 28%;background:repeating-linear-gradient(45deg,#cfae8f 0 9px,#dfc3a8 9px 18px);box-shadow:0 10px 17px rgba(70,51,37,.12);transform:rotate(-4deg)} +.sheet{position:absolute;z-index:50;left:0;right:0;bottom:0;min-height:48%;padding:26px 26px calc(var(--safe-bottom) + 16px);background:rgba(255,249,239,.98);border-radius:30px 30px 0 0;box-shadow:0 -20px 60px rgba(56,43,32,.16);animation:sheetUp .35s ease} +@keyframes sheetUp{from{transform:translateY(100%)}to{transform:none}} +.sheet-handle{position:absolute;top:9px;left:50%;transform:translateX(-50%);width:42px;height:5px;border-radius:6px;background:#d2c6b5} +.sheet-close{position:absolute;right:20px;top:18px;width:38px;height:38px;border:0;border-radius:14px;background:#eee5d8;font-size:23px} +.sheet h2{font-size:31px;margin:14px 0 8px;letter-spacing:-.04em} +.sheet p{line-height:1.5;color:var(--ink-soft)} +.profile-row{display:flex;align-items:center;gap:16px;margin-top:20px} +.profile-avatar{width:74px;height:74px;border-radius:26px;background:linear-gradient(145deg,#f4e6d0,#d9b38d);display:grid;place-items:center;font-weight:900;color:#5e4b3c} +.memory-card{margin-top:20px;border-radius:23px;padding:20px;background:linear-gradient(145deg,#304a56,#263b44);color:#fff;min-height:150px;display:flex;flex-direction:column;justify-content:flex-end;box-shadow:var(--shadow)} +.memory-card p{color:rgba(255,255,255,.7);margin-bottom:0} +.toast{position:fixed;z-index:100;left:50%;bottom:24px;transform:translate(-50%,120px);padding:12px 16px;border-radius:16px;background:#26312e;color:#fff;font-size:13px;font-weight:700;box-shadow:0 12px 30px rgba(0,0,0,.22);transition:transform .3s ease} +.toast--show{transform:translate(-50%,0)} + +@media (prefers-reduced-motion:reduce){ + *,*:before,*:after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important} +} diff --git a/projects/kitty-crush/prototype-web/vercel.json b/projects/kitty-crush/prototype-web/vercel.json new file mode 100644 index 0000000..d52911c --- /dev/null +++ b/projects/kitty-crush/prototype-web/vercel.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "cleanUrls": true, + "trailingSlash": false, + "headers": [ + { + "source": "/(.*)", + "headers": [ + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" } + ] + } + ] +}