From c9c503478e00478c83c737ac676ff46703166cdb Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:23:33 -0600 Subject: [PATCH 001/142] docs: establish Kitty project preproduction source of truth --- projects/kitty-crush/README.md | 51 ++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 projects/kitty-crush/README.md 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”. From c99913c010925b564512f743f138b950c864fd75 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:23:35 -0600 Subject: [PATCH 002/142] docs: add Kitty master execution plan --- projects/kitty-crush/MASTER_EXECUTION_PLAN.md | 898 ++++++++++++++++++ 1 file changed, 898 insertions(+) create mode 100644 projects/kitty-crush/MASTER_EXECUTION_PLAN.md 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. From eac48b8557196edea59ffaad15ad2b0cea8d83c3 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:23:37 -0600 Subject: [PATCH 003/142] docs: add Kitty product bible --- projects/kitty-crush/PRODUCT_BIBLE.md | 416 ++++++++++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 projects/kitty-crush/PRODUCT_BIBLE.md 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.” From aff0e85bc64593bdfe450cd099c8303c338f801a Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:23:39 -0600 Subject: [PATCH 004/142] docs: add Kitty technical foundation --- projects/kitty-crush/TECHNICAL_FOUNDATION.md | 384 +++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 projects/kitty-crush/TECHNICAL_FOUNDATION.md 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. From e0f15002e30bf70c6d6621de03c91c33bf9934da Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:40:15 -0600 Subject: [PATCH 005/142] docs: add brand_direction --- projects/kitty-crush/BRAND_DIRECTION.md | 55 +++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 projects/kitty-crush/BRAND_DIRECTION.md 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. From a5d5fbb6d10dfa412c065b07aeb51ff1ea30904c Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:40:17 -0600 Subject: [PATCH 006/142] docs: add mochi_character_bible --- projects/kitty-crush/MOCHI_CHARACTER_BIBLE.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 projects/kitty-crush/MOCHI_CHARACTER_BIBLE.md 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. From 764ddb6e1cb2d4adb170aeda66d0dc6635a01d4a Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:40:20 -0600 Subject: [PATCH 007/142] docs: add design_system --- projects/kitty-crush/DESIGN_SYSTEM.md | 105 ++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 projects/kitty-crush/DESIGN_SYSTEM.md 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. From a6b9bfc7d24901ae8df1169b53b249c700b031b6 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:40:22 -0600 Subject: [PATCH 008/142] docs: add vertical_slice_spec --- projects/kitty-crush/VERTICAL_SLICE_SPEC.md | 155 ++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 projects/kitty-crush/VERTICAL_SLICE_SPEC.md 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. From 3f8a4fb3f5e23c1b049e35120200d39c998e9d50 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:43:41 -0600 Subject: [PATCH 009/142] feat(whiskerfolk): add index.html for Mochi vertical slice --- projects/kitty-crush/prototype-web/index.html | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 projects/kitty-crush/prototype-web/index.html 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 From 4b5ddd685238acca4a8d22d1a4a0f6ba49d8ac10 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:43:43 -0600 Subject: [PATCH 010/142] feat(whiskerfolk): add styles.css for Mochi vertical slice --- projects/kitty-crush/prototype-web/styles.css | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 projects/kitty-crush/prototype-web/styles.css 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} +} From 0d1d5905cea7223f695d1c3d4ec1cd7cb4c25b27 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:43:45 -0600 Subject: [PATCH 011/142] feat(whiskerfolk): add app.js for Mochi vertical slice --- projects/kitty-crush/prototype-web/app.js | 399 ++++++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 projects/kitty-crush/prototype-web/app.js diff --git a/projects/kitty-crush/prototype-web/app.js b/projects/kitty-crush/prototype-web/app.js new file mode 100644 index 0000000..4527e83 --- /dev/null +++ b/projects/kitty-crush/prototype-web/app.js @@ -0,0 +1,399 @@ +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 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{ + $('#complete-title').textContent='Dry enough.'; + $('#complete-copy').textContent='The cover muffles the rain. He looks at you twice.'; + $('#complete-btn').textContent='Stay with him'; + } + 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 { + 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; +}); From 5cd97c228a28a1d19f14dfee1391cd984ea5c71f Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:43:47 -0600 Subject: [PATCH 012/142] feat(whiskerfolk): add README.md for Mochi vertical slice --- projects/kitty-crush/prototype-web/README.md | 45 ++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 projects/kitty-crush/prototype-web/README.md 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. From c9e265841be3696283948c7a1e0285aadd6289f1 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:44:46 -0600 Subject: [PATCH 013/142] chore(whiskerfolk): vendor Match3Demo.csproj --- projects/kitty-crush/godot/Whiskerfolk.csproj | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 projects/kitty-crush/godot/Whiskerfolk.csproj diff --git a/projects/kitty-crush/godot/Whiskerfolk.csproj b/projects/kitty-crush/godot/Whiskerfolk.csproj new file mode 100644 index 0000000..0b6d7ff --- /dev/null +++ b/projects/kitty-crush/godot/Whiskerfolk.csproj @@ -0,0 +1,22 @@ + + + net8.0 + true + Whiskerfolk + false + LatestMajor + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + From 8bbb2ec55b3a4b8590a11f50f09d34dbe814cf77 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:44:48 -0600 Subject: [PATCH 014/142] chore(whiskerfolk): vendor scripts/core/BoardData.cs --- .../godot/scripts/core/puzzle/BoardData.cs | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/core/puzzle/BoardData.cs 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..38b961b --- /dev/null +++ b/projects/kitty-crush/godot/scripts/core/puzzle/BoardData.cs @@ -0,0 +1,178 @@ +using Godot; + +namespace Match3Demo; + +public partial class BoardData +{ + public int Cols = 8; + public int Rows = 8; + public CellData[] Tiles = System.Array.Empty(); + public int NumCrystalTypes = 5; + + public BoardData(int pCols = 8, int pRows = 8, int pTypes = 5) + { + Cols = pCols; + Rows = pRows; + NumCrystalTypes = pTypes; + int total = Cols * Rows; + Tiles = new CellData[total]; + for (int i = 0; i < total; i++) + Tiles[i] = new CellData(); + } + + public CellData GetTile(int pRow, int pCol) + { + return Tiles[pRow * Cols + pCol]; + } + + public void SetTile(int pRow, int pCol, CellData tile) + { + tile.Row = pRow; + tile.Col = pCol; + Tiles[pRow * Cols + pCol] = tile; + } + + public int GetIndex(int pRow, int pCol) + { + return pRow * Cols + pCol; + } + + public Vector2I RowCol(int pIndex) + { + return new Vector2I(pIndex % Cols, pIndex / Cols); + } + + public bool IsInBounds(int pRow, int pCol) + { + return pRow >= 0 && pRow < Rows && pCol >= 0 && pCol < Cols; + } + + public void Swap(int pRow1, int pCol1, int pRow2, int pCol2) + { + int idx1 = GetIndex(pRow1, pCol1); + int idx2 = GetIndex(pRow2, pCol2); + var temp = Tiles[idx1]; + Tiles[idx1] = Tiles[idx2]; + Tiles[idx2] = temp; + Tiles[idx1].Row = pRow1; + Tiles[idx1].Col = pCol1; + Tiles[idx2].Row = pRow2; + Tiles[idx2].Col = pCol2; + } + + public Godot.Collections.Array DuplicateData() + { + var data = new Godot.Collections.Array(); + foreach (var tile in Tiles) + { + var dict = new Godot.Collections.Dictionary(); + dict["type"] = tile.CrystalType; + dict["special"] = tile.SpecialType; + dict["empty"] = tile.IsEmpty; + data.Add(dict); + } + return data; + } + + public void RestoreFromData(Godot.Collections.Array data) + { + for (int i = 0; i < Tiles.Length; i++) + { + Tiles[i].CrystalType = (int)data[i]["type"]; + Tiles[i].SpecialType = (int)data[i]["special"]; + Tiles[i].IsEmpty = (bool)data[i]["empty"]; + } + } + + public void Clear() + { + foreach (var tile in Tiles) + tile.Clear(); + } + + public int CountType(int pType) + { + int count = 0; + foreach (var tile in Tiles) + { + if (!tile.IsEmpty && tile.CrystalType == pType) + count++; + } + return count; + } + + public int GetEmptyCount() + { + int count = 0; + foreach (var tile in Tiles) + { + if (tile.IsEmpty) + count++; + } + return count; + } + + public class CellData + { + public int CrystalType = -1; + public int SpecialType = -1; + public int Row = -1; + public int Col = -1; + public bool IsEmpty = true; + public bool IsLocked = false; + public int LockHp = 0; + + public CellData(int pType = -1, int pSpecial = -1) + { + CrystalType = pType; + SpecialType = pSpecial; + IsEmpty = pType < 0; + } + + public void Clear() + { + CrystalType = -1; + SpecialType = -1; + IsEmpty = true; + } + + public void SetCrystal(int pType, int pSpecial = -1) + { + CrystalType = pType; + SpecialType = pSpecial; + IsEmpty = false; + } + + public bool IsNormal() + { + return !IsEmpty && SpecialType == -1; + } + + public bool IsSpecial() + { + return !IsEmpty && SpecialType != -1; + } + + public override string ToString() + { + if (IsEmpty) + return "EMPTY"; + string s = CrystalType.ToString(); + if (SpecialType == 0) + s += "B"; + else if (SpecialType == 1) + s += "R"; + else if (SpecialType == 2) + s += "C"; + return s; + } + } + + public class MoveRecord + { + public Vector2I From; + public Vector2I To; + public Godot.Collections.Array Snapshot; + public int ScoreGained = 0; + } +} From c47cdaee6d3c19e74daa0f009c6f3abf8d30378a Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:44:52 -0600 Subject: [PATCH 015/142] chore(whiskerfolk): vendor scripts/core/MatchDetector.cs --- .../scripts/core/puzzle/MatchDetector.cs | 350 ++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/core/puzzle/MatchDetector.cs 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..8a56808 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/core/puzzle/MatchDetector.cs @@ -0,0 +1,350 @@ +using Godot; +using System.Collections.Generic; + +namespace Match3Demo; + +public class MatchDetector +{ + private static readonly Vector2I[] DIRECTIONS = new[] + { + new Vector2I(0, -1), new Vector2I(1, 0), new Vector2I(0, 1), new Vector2I(-1, 0) + }; + + public static MatchResult DetectAll(BoardData board) + { + var result = new MatchResult(); + int size = board.Cols * board.Rows; + result.MatchedFlags = new byte[size]; + + var groups = new List(); + groups.AddRange(DetectHorizontal(board)); + groups.AddRange(DetectVertical(board)); + + foreach (var g in groups) + { + foreach (var pos in g.Positions) + { + int idx = board.GetIndex(pos.Y, pos.X); + result.MatchedFlags[idx] = 1; + } + } + + var visited = new byte[size]; + var finalGroups = new List(); + + for (int i = 0; i < size; i++) + { + if (result.MatchedFlags[i] == 1 && visited[i] == 0) + { + var region = FloodFill(result.MatchedFlags, visited, i, board); + if (region.Count >= 3) + { + var group = ClassifyShape(region, board); + finalGroups.Add(group); + } + } + } + + result.Groups = finalGroups; + result.TotalMatched = CountMatched(result.MatchedFlags); + + foreach (var group in finalGroups) + { + var spawn = DetermineSpecial(group); + if (spawn.SpecialType != -1) + { + result.SpecialSpawns.Add(spawn); + } + } + + return result; + } + + private static List DetectHorizontal(BoardData board) + { + var groups = new List(); + for (int row = 0; row < board.Rows; row++) + { + int col = 0; + while (col < board.Cols) + { + var tile = board.GetTile(row, col); + if (tile.IsEmpty) + { + col++; + continue; + } + int runType = tile.CrystalType; + int runStart = col; + col++; + while (col < board.Cols) + { + var nextTile = board.GetTile(row, col); + if (nextTile.IsEmpty || nextTile.CrystalType != runType) + break; + col++; + } + int runLength = col - runStart; + if (runLength >= 3) + { + var group = new MatchResult.MatchGroup(); + group.Shape = 0; + group.CrystalType = runType; + group.MatchLength = runLength; + for (int c = runStart; c < runStart + runLength; c++) + { + group.Positions.Add(new Vector2I(c, row)); + } + groups.Add(group); + } + } + } + return groups; + } + + private static List DetectVertical(BoardData board) + { + var groups = new List(); + for (int col = 0; col < board.Cols; col++) + { + int row = 0; + while (row < board.Rows) + { + var tile = board.GetTile(row, col); + if (tile.IsEmpty) + { + row++; + continue; + } + int runType = tile.CrystalType; + int runStart = row; + row++; + while (row < board.Rows) + { + var nextTile = board.GetTile(row, col); + if (nextTile.IsEmpty || nextTile.CrystalType != runType) + break; + row++; + } + int runLength = row - runStart; + if (runLength >= 3) + { + var group = new MatchResult.MatchGroup(); + group.Shape = 1; + group.CrystalType = runType; + group.MatchLength = runLength; + for (int r = runStart; r < runStart + runLength; r++) + { + group.Positions.Add(new Vector2I(col, r)); + } + groups.Add(group); + } + } + } + return groups; + } + + private static List FloodFill(byte[] flags, byte[] visited, int startIdx, BoardData board) + { + var region = new List(); + var stack = new List { startIdx }; + visited[startIdx] = 1; + + int crystalType = board.Tiles[startIdx].CrystalType; + + while (stack.Count > 0) + { + int currentIdx = stack[stack.Count - 1]; + stack.RemoveAt(stack.Count - 1); + var pos = board.RowCol(currentIdx); + region.Add(pos); + + foreach (var dir in DIRECTIONS) + { + int nCol = pos.X + dir.X; + int nRow = pos.Y + dir.Y; + if (!board.IsInBounds(nRow, nCol)) + continue; + int nIdx = board.GetIndex(nRow, nCol); + if (visited[nIdx] == 1) + continue; + if (flags[nIdx] == 0) + continue; + if (board.Tiles[nIdx].CrystalType != crystalType) + continue; + visited[nIdx] = 1; + stack.Add(nIdx); + } + } + + return region; + } + + private static MatchResult.MatchGroup ClassifyShape(List region, BoardData board) + { + var group = new MatchResult.MatchGroup(); + group.Positions = region; + + if (region.Count < 3) + return group; + + var firstPos = region[0]; + group.CrystalType = board.GetTile(firstPos.Y, firstPos.X).CrystalType; + + int minCol = int.MaxValue; + int maxCol = -1; + int minRow = int.MaxValue; + int maxRow = -1; + + var rowCounts = new Dictionary(); + var colCounts = new Dictionary(); + + foreach (var pos in region) + { + minCol = Mathf.Min(minCol, pos.X); + maxCol = Mathf.Max(maxCol, pos.X); + minRow = Mathf.Min(minRow, pos.Y); + maxRow = Mathf.Max(maxRow, pos.Y); + rowCounts[pos.Y] = rowCounts.GetValueOrDefault(pos.Y, 0) + 1; + colCounts[pos.X] = colCounts.GetValueOrDefault(pos.X, 0) + 1; + } + + int colSpan = maxCol - minCol + 1; + int rowSpan = maxRow - minRow + 1; + int numRowsUsed = rowCounts.Keys.Count; + int numColsUsed = colCounts.Keys.Count; + + int maxRowCount = 0; + int maxRowKey = -1; + foreach (var key in rowCounts.Keys) + { + if (rowCounts[key] > maxRowCount) + { + maxRowCount = rowCounts[key]; + maxRowKey = key; + } + } + + int maxColCount = 0; + int maxColKey = -1; + foreach (var key in colCounts.Keys) + { + if (colCounts[key] > maxColCount) + { + maxColCount = colCounts[key]; + maxColKey = key; + } + } + + int rowsWith3 = 0; + foreach (var key in rowCounts.Keys) + { + if (rowCounts[key] >= 3) + rowsWith3++; + } + + int colsWith3 = 0; + foreach (var key in colCounts.Keys) + { + if (colCounts[key] >= 3) + colsWith3++; + } + + bool isHLine = rowSpan == 1 && colSpan >= 3; + bool isVLine = colSpan == 1 && rowSpan >= 3; + + if (isHLine) + { + group.Shape = 0; + group.MatchLength = colSpan; + group.Pivot = new Vector2I(minCol + colSpan / 2, minRow); + } + else if (isVLine) + { + group.Shape = 1; + group.MatchLength = rowSpan; + group.Pivot = new Vector2I(minCol, minRow + rowSpan / 2); + } + else if (rowsWith3 >= 2 && colsWith3 >= 2) + { + group.Shape = 4; + group.Pivot = new Vector2I(maxColKey, maxRowKey); + } + else if (rowsWith3 >= 2) + { + group.Shape = 2; + group.Pivot = new Vector2I(maxColKey, maxRowKey); + } + else if (colsWith3 >= 2) + { + group.Shape = 2; + group.Pivot = new Vector2I(maxColKey, maxRowKey); + } + else if (numRowsUsed >= 2 && numColsUsed >= 2) + { + if (maxRowCount >= 3 && maxColCount >= 3) + { + group.Shape = 3; + group.Pivot = new Vector2I(maxColKey, maxRowKey); + } + else + { + group.Shape = 2; + group.Pivot = new Vector2I(maxColKey, maxRowKey); + } + } + else + { + group.Shape = 0; + group.MatchLength = region.Count; + } + + return group; + } + + private static MatchResult.SpecialSpawn DetermineSpecial(MatchResult.MatchGroup group) + { + var spawn = new MatchResult.SpecialSpawn(); + spawn.Position = group.Pivot; + spawn.CrystalType = group.CrystalType; + + int length = group.MatchLength; + + switch (group.Shape) + { + case 0: + case 1: + if (length >= 5) + spawn.SpecialType = 1; + else if (length >= 4) + spawn.SpecialType = 0; + else + spawn.SpecialType = -1; + break; + case 2: + case 3: + case 4: + if (group.Positions.Count >= 5) + spawn.SpecialType = 2; + else + spawn.SpecialType = -1; + break; + default: + spawn.SpecialType = -1; + break; + } + + return spawn; + } + + private static int CountMatched(byte[] flags) + { + int count = 0; + for (int i = 0; i < flags.Length; i++) + { + if (flags[i] == 1) + count++; + } + return count; + } +} From 7e6b835896c709349962abe94846e24594bcd24e Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:44:54 -0600 Subject: [PATCH 016/142] chore(whiskerfolk): vendor scripts/core/MatchResult.cs --- .../godot/scripts/core/puzzle/MatchResult.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/core/puzzle/MatchResult.cs 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..d5774f0 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/core/puzzle/MatchResult.cs @@ -0,0 +1,51 @@ +using Godot; +using System.Collections.Generic; + +namespace Match3Demo; + +public partial class MatchResult +{ + public List Groups = new(); + public byte[] MatchedFlags = new byte[0]; + public List SpecialSpawns = new(); + public int TotalMatched = 0; + + public bool HasMatches() + { + return TotalMatched > 0; + } + + public List GetAllPositions() + { + var all = new List(); + foreach (var group in Groups) + all.AddRange(group.Positions); + return all; + } + + public class MatchGroup + { + public int Shape = 0; + public List Positions = new(); + public Vector2I Pivot = new(-1, -1); + public int MatchLength = 0; + public int CrystalType = -1; + + public int Size() + { + return Positions.Count; + } + } + + public class SpecialSpawn + { + public Vector2I Position = new(); + public int SpecialType = -1; + public int CrystalType = -1; + + public override string ToString() + { + return $"SpecialSpawn(type={SpecialType}, pos={Position})"; + } + } +} From b3ee846517d2265c93436795efeda0535e969f4f Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:44:56 -0600 Subject: [PATCH 017/142] chore(whiskerfolk): vendor scripts/core/GravitySystem.cs --- .../scripts/core/puzzle/GravitySystem.cs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/core/puzzle/GravitySystem.cs 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..2c096b9 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/core/puzzle/GravitySystem.cs @@ -0,0 +1,75 @@ +using Godot; +using System.Collections.Generic; + +namespace Match3Demo; + +public 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 (int col = 0; col < board.Cols; col++) + { + var falls = ProcessColumn(board, col); + allFalls.AddRange(falls); + } + return allFalls; + } + + private static List ProcessColumn(BoardData board, int col) + { + var falls = new List(); + int writeRow = board.Rows - 1; + + for (int readRow = board.Rows - 1; readRow >= 0; readRow--) + { + var tile = board.GetTile(readRow, col); + if (tile.IsEmpty) + continue; + + if (readRow != writeRow) + { + var info = new FallInfo(); + info.FromRow = readRow; + info.ToRow = writeRow; + info.Col = col; + info.CrystalType = tile.CrystalType; + info.SpecialType = tile.SpecialType; + falls.Add(info); + + board.Swap(readRow, col, writeRow, col); + } + + writeRow--; + } + + for (int row = writeRow; row >= 0; row--) + { + board.GetTile(row, col).Clear(); + } + + return falls; + } + + public class FallInfo + { + public int FromRow; + public int ToRow; + public int Col; + public int CrystalType = -1; + public int SpecialType = -1; + + public int GetDistance() + { + return ToRow - FromRow; + } + + public float GetDuration() + { + return FallBase + (float)GetDistance() * FallPerRow; + } + } +} From 2461306f54554351345feceef2a8e9d9644e5998 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:44:59 -0600 Subject: [PATCH 018/142] chore(whiskerfolk): vendor scripts/core/SpawnSystem.cs --- .../godot/scripts/core/puzzle/SpawnSystem.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/core/puzzle/SpawnSystem.cs 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..b101087 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/core/puzzle/SpawnSystem.cs @@ -0,0 +1,48 @@ +using Godot; +using System.Collections.Generic; + +namespace Match3Demo; + +public class SpawnSystem +{ + public static List FillEmpty(BoardData board) + { + var spawns = new List(); + var rng = new RandomNumberGenerator(); + rng.Randomize(); + + for (int col = 0; col < board.Cols; col++) + { + for (int row = 0; row < board.Rows; row++) + { + var tile = board.GetTile(row, col); + if (!tile.IsEmpty) + continue; + + int crystalType = rng.RandiRange(0, 4); + + var info = new SpawnInfo(); + info.Row = row; + info.Col = col; + info.CrystalType = crystalType; + spawns.Add(info); + + tile.SetCrystal(crystalType); + } + } + + return spawns; + } + + public class SpawnInfo + { + public int Row; + public int Col; + public int CrystalType = -1; + + public Vector2 GetEnterOffset() + { + return new Vector2(0, -(Row + 1) * 76.0f); + } + } +} From 795c242d1a4772c42fb4956e3259019130adb6c4 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:45:01 -0600 Subject: [PATCH 019/142] chore(whiskerfolk): vendor scripts/core/ValidMoveChecker.cs --- .../scripts/core/puzzle/ValidMoveChecker.cs | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/core/puzzle/ValidMoveChecker.cs 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..56c9db4 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/core/puzzle/ValidMoveChecker.cs @@ -0,0 +1,99 @@ +namespace Match3Demo; + +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; + } +} From 5916572e6564768fdd49611184ca8bd15025a022 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:45:05 -0600 Subject: [PATCH 020/142] chore(whiskerfolk): vendor scripts/core/ScoreCalculator.cs --- .../scripts/core/puzzle/ScoreCalculator.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/core/puzzle/ScoreCalculator.cs 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..1ca8eb7 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/core/puzzle/ScoreCalculator.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; + +namespace Match3Demo; + +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; + } +} From 731ca50e5dadaa0c1fe05d37a0cff45034886ad4 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:45:19 -0600 Subject: [PATCH 021/142] chore(whiskerfolk): vendor LICENSE --- .../godot/THIRD_PARTY_MATCH3_PETS_LICENSE.txt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 projects/kitty-crush/godot/THIRD_PARTY_MATCH3_PETS_LICENSE.txt 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. From 4a846fd06900d17a68ad0a143ad23d370d0d9ea9 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:45:30 -0600 Subject: [PATCH 022/142] refactor(whiskerfolk): isolate puzzle namespace for BoardData.cs --- projects/kitty-crush/godot/scripts/core/puzzle/BoardData.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/BoardData.cs b/projects/kitty-crush/godot/scripts/core/puzzle/BoardData.cs index 38b961b..23e1948 100644 --- a/projects/kitty-crush/godot/scripts/core/puzzle/BoardData.cs +++ b/projects/kitty-crush/godot/scripts/core/puzzle/BoardData.cs @@ -1,6 +1,6 @@ using Godot; -namespace Match3Demo; +namespace Whiskerfolk.Puzzle; public partial class BoardData { From a4f011d52486445ab130ff39e3b92643a5ca8a64 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:45:33 -0600 Subject: [PATCH 023/142] refactor(whiskerfolk): isolate puzzle namespace for MatchDetector.cs --- projects/kitty-crush/godot/scripts/core/puzzle/MatchDetector.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/MatchDetector.cs b/projects/kitty-crush/godot/scripts/core/puzzle/MatchDetector.cs index 8a56808..fcb9d40 100644 --- a/projects/kitty-crush/godot/scripts/core/puzzle/MatchDetector.cs +++ b/projects/kitty-crush/godot/scripts/core/puzzle/MatchDetector.cs @@ -1,7 +1,7 @@ using Godot; using System.Collections.Generic; -namespace Match3Demo; +namespace Whiskerfolk.Puzzle; public class MatchDetector { From a9072396896a2f130b144ec94c588cea9da35d80 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:45:35 -0600 Subject: [PATCH 024/142] refactor(whiskerfolk): isolate puzzle namespace for MatchResult.cs --- projects/kitty-crush/godot/scripts/core/puzzle/MatchResult.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/MatchResult.cs b/projects/kitty-crush/godot/scripts/core/puzzle/MatchResult.cs index d5774f0..31a37db 100644 --- a/projects/kitty-crush/godot/scripts/core/puzzle/MatchResult.cs +++ b/projects/kitty-crush/godot/scripts/core/puzzle/MatchResult.cs @@ -1,7 +1,7 @@ using Godot; using System.Collections.Generic; -namespace Match3Demo; +namespace Whiskerfolk.Puzzle; public partial class MatchResult { From 33049450305c88aaefa721683a9f596bee4f8e83 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:45:38 -0600 Subject: [PATCH 025/142] refactor(whiskerfolk): isolate puzzle namespace for GravitySystem.cs --- projects/kitty-crush/godot/scripts/core/puzzle/GravitySystem.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/GravitySystem.cs b/projects/kitty-crush/godot/scripts/core/puzzle/GravitySystem.cs index 2c096b9..fbe51de 100644 --- a/projects/kitty-crush/godot/scripts/core/puzzle/GravitySystem.cs +++ b/projects/kitty-crush/godot/scripts/core/puzzle/GravitySystem.cs @@ -1,7 +1,7 @@ using Godot; using System.Collections.Generic; -namespace Match3Demo; +namespace Whiskerfolk.Puzzle; public class GravitySystem { From 892aa716931ca1d3b8ef91ad79842dd8649741f1 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:45:40 -0600 Subject: [PATCH 026/142] refactor(whiskerfolk): isolate puzzle namespace for SpawnSystem.cs --- projects/kitty-crush/godot/scripts/core/puzzle/SpawnSystem.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/SpawnSystem.cs b/projects/kitty-crush/godot/scripts/core/puzzle/SpawnSystem.cs index b101087..75625f0 100644 --- a/projects/kitty-crush/godot/scripts/core/puzzle/SpawnSystem.cs +++ b/projects/kitty-crush/godot/scripts/core/puzzle/SpawnSystem.cs @@ -1,7 +1,7 @@ using Godot; using System.Collections.Generic; -namespace Match3Demo; +namespace Whiskerfolk.Puzzle; public class SpawnSystem { From 0df9ff0309621900de4e7b400a1ac970ae7b4582 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:45:43 -0600 Subject: [PATCH 027/142] refactor(whiskerfolk): isolate puzzle namespace for ValidMoveChecker.cs --- .../kitty-crush/godot/scripts/core/puzzle/ValidMoveChecker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/ValidMoveChecker.cs b/projects/kitty-crush/godot/scripts/core/puzzle/ValidMoveChecker.cs index 56c9db4..c850060 100644 --- a/projects/kitty-crush/godot/scripts/core/puzzle/ValidMoveChecker.cs +++ b/projects/kitty-crush/godot/scripts/core/puzzle/ValidMoveChecker.cs @@ -1,4 +1,4 @@ -namespace Match3Demo; +namespace Whiskerfolk.Puzzle; public class ValidMoveChecker { From 6b48f7690b9482e4d54fdbffe346b45cee3b01fd Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:45:45 -0600 Subject: [PATCH 028/142] refactor(whiskerfolk): isolate puzzle namespace for ScoreCalculator.cs --- .../kitty-crush/godot/scripts/core/puzzle/ScoreCalculator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/ScoreCalculator.cs b/projects/kitty-crush/godot/scripts/core/puzzle/ScoreCalculator.cs index 1ca8eb7..e76415c 100644 --- a/projects/kitty-crush/godot/scripts/core/puzzle/ScoreCalculator.cs +++ b/projects/kitty-crush/godot/scripts/core/puzzle/ScoreCalculator.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace Match3Demo; +namespace Whiskerfolk.Puzzle; public class ScoreCalculator { From 0ef77c07b2a030777a06e8eef4a33026a9f7b908 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:46:37 -0600 Subject: [PATCH 029/142] feat(whiskerfolk): add project foundation --- projects/kitty-crush/godot/project.godot | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 projects/kitty-crush/godot/project.godot diff --git a/projects/kitty-crush/godot/project.godot b/projects/kitty-crush/godot/project.godot new file mode 100644 index 0000000..eb2b796 --- /dev/null +++ b/projects/kitty-crush/godot/project.godot @@ -0,0 +1,24 @@ +; 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" + +[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 From 80554a24b9bb7941f092c756b46b0b43535e1447 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:46:39 -0600 Subject: [PATCH 030/142] feat(whiskerfolk): add CatDefinition foundation --- .../godot/scripts/cats/CatDefinition.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/cats/CatDefinition.cs 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..04bf7cd --- /dev/null +++ b/projects/kitty-crush/godot/scripts/cats/CatDefinition.cs @@ -0,0 +1,34 @@ +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.0f, + ["soft_fabric"] = 0.82f, + ["window"] = 0.66f, + ["feather"] = 0.58f, + ["player"] = 0.18f, + ["metal_noise"] = -0.8f + }, + "double_check", + "mrrp_question" + ); +} From f29afe969fe9906745b3959c7f2ccc7583c3a80c Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:46:42 -0600 Subject: [PATCH 031/142] feat(whiskerfolk): add BondState foundation --- .../godot/scripts/cats/BondState.cs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/cats/BondState.cs 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 + }; + } +} From 55ddb5ce74b68cb2218394997c3cc6d12b921275 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:46:44 -0600 Subject: [PATCH 032/142] feat(whiskerfolk): add CatState foundation --- .../kitty-crush/godot/scripts/cats/CatState.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/cats/CatState.cs 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(); +} From 2180ce52593ab496f91d912fae12f56fad2e9a3c Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:46:47 -0600 Subject: [PATCH 033/142] feat(whiskerfolk): add CatBehaviorController foundation --- .../scripts/cats/CatBehaviorController.cs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/cats/CatBehaviorController.cs 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; + } +} From d393c886aa8004bee2236a530b8456ca16db90be Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:46:49 -0600 Subject: [PATCH 034/142] feat(whiskerfolk): add RescueBeat foundation --- .../godot/scripts/rescue/RescueBeat.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/rescue/RescueBeat.cs 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 +); From 8e22981f4ebb0045e5ca92bb4da4a0811ce8ee73 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:46:51 -0600 Subject: [PATCH 035/142] feat(whiskerfolk): add RescueArc foundation --- .../godot/scripts/rescue/RescueArc.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/rescue/RescueArc.cs 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) + } + }; +} From 0cb8c6906cc532a81976157f91613497537b3225 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:46:53 -0600 Subject: [PATCH 036/142] feat(whiskerfolk): add RescueDirector foundation --- .../godot/scripts/rescue/RescueDirector.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/rescue/RescueDirector.cs 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..0829f4e --- /dev/null +++ b/projects/kitty-crush/godot/scripts/rescue/RescueDirector.cs @@ -0,0 +1,37 @@ +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) + { + Arc = arc; + BeatIndex = 0; + } + + 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 Reset() + { + BeatIndex = 0; + BeatChanged?.Invoke(Current); + } +} From 414f524fb8f3a9f2ba7ed538c74195d7219c4742 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:46:55 -0600 Subject: [PATCH 037/142] feat(whiskerfolk): add Objectives foundation --- .../godot/scripts/puzzle/Objectives.cs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/puzzle/Objectives.cs 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") + }; +} From ea1e7c97a759fd7ad60fe868c9031e271c002080 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:46:57 -0600 Subject: [PATCH 038/142] feat(whiskerfolk): add HomeObjectDefinition foundation --- .../scripts/home/HomeObjectDefinition.cs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/home/HomeObjectDefinition.cs 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 } + ); +} From 39de39150988901419bb1d77d9cda288b7268a40 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:47:00 -0600 Subject: [PATCH 039/142] feat(whiskerfolk): add MemoryDefinition foundation --- .../scripts/memories/MemoryDefinition.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/memories/MemoryDefinition.cs 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 + ); +} From 8f92626f15a90ac7aa70f0cfe989808d672c28ea Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:47:02 -0600 Subject: [PATCH 040/142] feat(whiskerfolk): add IAnalyticsService foundation --- .../scripts/analytics/IAnalyticsService.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/analytics/IAnalyticsService.cs 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"; +} From bf13930e4f8a36f73668f85fa54301f74cddfb0f Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:47:05 -0600 Subject: [PATCH 041/142] feat(whiskerfolk): add SaveGame foundation --- .../godot/scripts/save/SaveGame.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/save/SaveGame.cs 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..86a3a1b --- /dev/null +++ b/projects/kitty-crush/godot/scripts/save/SaveGame.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; + +namespace Whiskerfolk.Save; + +public sealed class SaveGame +{ + public const int CurrentSchema = 1; + + public int SchemaVersion { get; init; } = CurrentSchema; + public DateTimeOffset CreatedAt { get; init; } = 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; } = new(); + public Dictionary CatTrust { get; } = new(); + public HashSet Memories { get; } = new(); + public HashSet OwnedHomeObjects { get; } = new(); + public HashSet OwnedCosmetics { get; } = new(); +} From 93dee2efd10bb857998cc25f694dc7bff32681b6 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:47:56 -0600 Subject: [PATCH 042/142] test(whiskerfolk): add BoardDataTests.cs --- .../kitty-crush/godot/tests/BoardDataTests.cs | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/BoardDataTests.cs diff --git a/projects/kitty-crush/godot/tests/BoardDataTests.cs b/projects/kitty-crush/godot/tests/BoardDataTests.cs new file mode 100644 index 0000000..bf5fa13 --- /dev/null +++ b/projects/kitty-crush/godot/tests/BoardDataTests.cs @@ -0,0 +1,82 @@ +using Godot; +using Xunit; + +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 Vector2I(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()); + } +} From 28c6d4ca62b5c486c56a41f7417a857b95b21a48 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:47:59 -0600 Subject: [PATCH 043/142] test(whiskerfolk): add BoardGenerationTests.cs --- .../godot/tests/BoardGenerationTests.cs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/BoardGenerationTests.cs diff --git a/projects/kitty-crush/godot/tests/BoardGenerationTests.cs b/projects/kitty-crush/godot/tests/BoardGenerationTests.cs new file mode 100644 index 0000000..56299fb --- /dev/null +++ b/projects/kitty-crush/godot/tests/BoardGenerationTests.cs @@ -0,0 +1,72 @@ +using Godot; +using Xunit; + +namespace Whiskerfolk.Tests; + +public class BoardGenerationTests +{ + [Fact] + public void TestGenerateNoInitialMatches() + { + for (int i = 0; i < 20; i++) + { + var board = new BoardData(8, 8); + GenerateNoMatch(board); + var result = MatchDetector.DetectAll(board); + Assert.False(result.HasMatches(), "Generated board should have no initial matches"); + } + } + + [Fact] + public void TestAllCellsFilled() + { + for (int i = 0; i < 10; i++) + { + var board = new BoardData(8, 8); + GenerateNoMatch(board); + Assert.Equal(0, board.GetEmptyCount()); + } + } + + private static void GenerateNoMatch(BoardData board) + { + var rng = new Godot.RandomNumberGenerator(); + rng.Randomize(); + for (int row = 0; row < board.Rows; row++) + { + for (int col = 0; col < board.Cols; col++) + { + var tile = board.GetTile(row, col); + var forbidden = new Godot.Collections.Array(); + + 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 Godot.Collections.Array(); + for (int t = 0; t < 5; t++) + { + if (!forbidden.Contains(t)) + allowed.Add(t); + } + + if (allowed.Count == 0) + tile.SetCrystal(rng.RandiRange(0, 4)); + else + tile.SetCrystal(allowed[rng.RandiRange(0, allowed.Count - 1)]); + } + } + } +} From c687c7d38b8deaacc0a91ec5185f77c14ee12687 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:48:01 -0600 Subject: [PATCH 044/142] test(whiskerfolk): add MatchDetectorTests.cs --- .../godot/tests/MatchDetectorTests.cs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/MatchDetectorTests.cs diff --git a/projects/kitty-crush/godot/tests/MatchDetectorTests.cs b/projects/kitty-crush/godot/tests/MatchDetectorTests.cs new file mode 100644 index 0000000..733e5ff --- /dev/null +++ b/projects/kitty-crush/godot/tests/MatchDetectorTests.cs @@ -0,0 +1,96 @@ +using Godot; +using Xunit; + +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); + } +} From c1517766a4259b028dded48ff6ffc6e3af1fbb74 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:48:03 -0600 Subject: [PATCH 045/142] test(whiskerfolk): add GravitySystemTests.cs --- .../godot/tests/GravitySystemTests.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/GravitySystemTests.cs diff --git a/projects/kitty-crush/godot/tests/GravitySystemTests.cs b/projects/kitty-crush/godot/tests/GravitySystemTests.cs new file mode 100644 index 0000000..e79295d --- /dev/null +++ b/projects/kitty-crush/godot/tests/GravitySystemTests.cs @@ -0,0 +1,58 @@ +using Godot; +using Xunit; + +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); + } +} From f07389277798bbbbfb3f9e0f4812e8af8742c08a Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:48:06 -0600 Subject: [PATCH 046/142] test(whiskerfolk): add ScoreCalculatorTests.cs --- .../godot/tests/ScoreCalculatorTests.cs | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/ScoreCalculatorTests.cs diff --git a/projects/kitty-crush/godot/tests/ScoreCalculatorTests.cs b/projects/kitty-crush/godot/tests/ScoreCalculatorTests.cs new file mode 100644 index 0000000..753703b --- /dev/null +++ b/projects/kitty-crush/godot/tests/ScoreCalculatorTests.cs @@ -0,0 +1,127 @@ +using Godot; +using Xunit; + +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 Vector2I(0, 0), + new Vector2I(1, 0), + new Vector2I(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)); + } +} From 21ec4d2548e59b5e9320c21c9783fa8965db3006 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:48:08 -0600 Subject: [PATCH 047/142] test(whiskerfolk): add SpecialTilesTests.cs --- .../godot/tests/SpecialTilesTests.cs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/SpecialTilesTests.cs diff --git a/projects/kitty-crush/godot/tests/SpecialTilesTests.cs b/projects/kitty-crush/godot/tests/SpecialTilesTests.cs new file mode 100644 index 0000000..53b6508 --- /dev/null +++ b/projects/kitty-crush/godot/tests/SpecialTilesTests.cs @@ -0,0 +1,64 @@ +using Godot; +using Xunit; + +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); + } +} From 810d4be086b2324cba499eb3e2d300a82767e15f Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:48:11 -0600 Subject: [PATCH 048/142] test(whiskerfolk): add ValidMoveCheckerTests.cs --- .../godot/tests/ValidMoveCheckerTests.cs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/ValidMoveCheckerTests.cs diff --git a/projects/kitty-crush/godot/tests/ValidMoveCheckerTests.cs b/projects/kitty-crush/godot/tests/ValidMoveCheckerTests.cs new file mode 100644 index 0000000..03a9ff3 --- /dev/null +++ b/projects/kitty-crush/godot/tests/ValidMoveCheckerTests.cs @@ -0,0 +1,30 @@ +using Godot; +using Xunit; + +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); + } +} From 6db63410db2594a0ec5e624e088f8205ee910f68 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:48:13 -0600 Subject: [PATCH 049/142] test(whiskerfolk): add EdgeCasesTests.cs --- .../kitty-crush/godot/tests/EdgeCasesTests.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/EdgeCasesTests.cs diff --git a/projects/kitty-crush/godot/tests/EdgeCasesTests.cs b/projects/kitty-crush/godot/tests/EdgeCasesTests.cs new file mode 100644 index 0000000..32d9e4a --- /dev/null +++ b/projects/kitty-crush/godot/tests/EdgeCasesTests.cs @@ -0,0 +1,49 @@ +using Godot; +using Xunit; + +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()); + } +} From a60a51006b79b837aa65bcc827bf4583f1211542 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:48:16 -0600 Subject: [PATCH 050/142] test(whiskerfolk): add ReshuffleTests.cs --- .../kitty-crush/godot/tests/ReshuffleTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/ReshuffleTests.cs diff --git a/projects/kitty-crush/godot/tests/ReshuffleTests.cs b/projects/kitty-crush/godot/tests/ReshuffleTests.cs new file mode 100644 index 0000000..64b6101 --- /dev/null +++ b/projects/kitty-crush/godot/tests/ReshuffleTests.cs @@ -0,0 +1,34 @@ +using Godot; +using Xunit; + +namespace Whiskerfolk.Tests; + +public class ReshuffleTests +{ + [Fact] + public void TestReshuffleProducesValidMoves() + { + 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) % 2 == 0 ? 0 : 1); + + bool hasMoves = ValidMoveChecker.HasAnyValidMove(board); + + if (!hasMoves) + { + var types = new Godot.Collections.Array(); + for (int i = 0; i < 64; i++) + { + var tile = board.Tiles[i]; + types.Add(tile.CrystalType); + } + types.Shuffle(); + for (int i = 0; i < 64; i++) + board.Tiles[i].SetCrystal(types[i]); + + var result = MatchDetector.DetectAll(board); + Assert.False(result.HasMatches(), "Reshuffled board should have no initial matches"); + } + } +} From f69a2f9032df47c2fced8579e463fd751b247e34 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:48:18 -0600 Subject: [PATCH 051/142] test(whiskerfolk): add SwapClearCascadeTests.cs --- .../godot/tests/SwapClearCascadeTests.cs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/SwapClearCascadeTests.cs diff --git a/projects/kitty-crush/godot/tests/SwapClearCascadeTests.cs b/projects/kitty-crush/godot/tests/SwapClearCascadeTests.cs new file mode 100644 index 0000000..0e90199 --- /dev/null +++ b/projects/kitty-crush/godot/tests/SwapClearCascadeTests.cs @@ -0,0 +1,77 @@ +using Godot; +using Xunit; + +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()); + } +} From e5ab1c3f1c2a52104328d49318f83c5ae3cf63b1 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:49:31 -0600 Subject: [PATCH 052/142] docs(whiskerfolk): add initial_roster --- projects/kitty-crush/INITIAL_ROSTER.md | 73 ++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 projects/kitty-crush/INITIAL_ROSTER.md 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. From c5e06fb85305aa0385b654f4dc73dfce45697816 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:49:33 -0600 Subject: [PATCH 053/142] docs(whiskerfolk): add monetization_blueprint --- .../kitty-crush/MONETIZATION_BLUEPRINT.md | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 projects/kitty-crush/MONETIZATION_BLUEPRINT.md 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? From cf4af139da91aff25bff55b80fa70e978b0f188e Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:49:35 -0600 Subject: [PATCH 054/142] docs(whiskerfolk): add analytics_and_experiments --- .../kitty-crush/ANALYTICS_AND_EXPERIMENTS.md | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 projects/kitty-crush/ANALYTICS_AND_EXPERIMENTS.md 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. From 94c3125d05287e29ce332dc5afbeae56ae46da43 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:49:37 -0600 Subject: [PATCH 055/142] docs(whiskerfolk): add qa_release_gates --- projects/kitty-crush/QA_RELEASE_GATES.md | 72 ++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 projects/kitty-crush/QA_RELEASE_GATES.md 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. From 1cbed6a52ade64654b758daa5700dbd0b73c2b25 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:49:39 -0600 Subject: [PATCH 056/142] docs(whiskerfolk): add liveops_post_go --- projects/kitty-crush/LIVEOPS_POST_GO.md | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 projects/kitty-crush/LIVEOPS_POST_GO.md 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. From 05c3669a156da388cefc5873e6864c22d198ca88 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:50:09 -0600 Subject: [PATCH 057/142] feat(whiskerfolk): add initial eight-cat data roster --- .../godot/scripts/cats/CatDefinition.cs | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/projects/kitty-crush/godot/scripts/cats/CatDefinition.cs b/projects/kitty-crush/godot/scripts/cats/CatDefinition.cs index 04bf7cd..3a82efd 100644 --- a/projects/kitty-crush/godot/scripts/cats/CatDefinition.cs +++ b/projects/kitty-crush/godot/scripts/cats/CatDefinition.cs @@ -21,14 +21,66 @@ public static class CatCatalog new HashSet { "cautious", "curious", "box-loving", "quiet-playful" }, new Dictionary { - ["box"] = 1.0f, + ["box"] = 1.00f, ["soft_fabric"] = 0.82f, ["window"] = 0.66f, ["feather"] = 0.58f, ["player"] = 0.18f, - ["metal_noise"] = -0.8f + ["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 }; } From 830f90567455d45f5b4bef4231d1bdb3e3067e31 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:50:36 -0600 Subject: [PATCH 058/142] docs(whiskerfolk): publish phase status and honest release blockers --- projects/kitty-crush/PHASE_STATUS.md | 55 ++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 projects/kitty-crush/PHASE_STATUS.md diff --git a/projects/kitty-crush/PHASE_STATUS.md b/projects/kitty-crush/PHASE_STATUS.md new file mode 100644 index 0000000..53ae844 --- /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 | FOUNDATION COMPLETE | Godot/C# scaffold, licensed puzzle core, tests, systems architecture | Godot build execution in real environment | +| 5. Rescue puzzle system | PROTOTYPE COMPLETE | Functional web match system + C# objective definitions | Production blockers, specials, level authoring | +| 6. Living room | PROTOTYPE COMPLETE | Home scene + interaction philosophy + object data model | Real navigation/behavior/animation in Godot | +| 7. Premium art direction | DESIGN COMPLETE | Visual DNA + design system | Final artist/asset pass | +| 8. UX / motion / audio / haptics | PARTIAL | UX/motion/haptic spec, web motion proxy | Real audio library, device haptics, animation polish | +| 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 | SPEC COMPLETE | Gates, device matrix, performance/accessibility criteria | Real Godot/mobile execution | +| 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. From 52b36f61f913626d4860fd4e17a7fbbc54301efe Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:52:51 -0600 Subject: [PATCH 059/142] fix(whiskerfolk): import puzzle namespace in BoardDataTests.cs --- projects/kitty-crush/godot/tests/BoardDataTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projects/kitty-crush/godot/tests/BoardDataTests.cs b/projects/kitty-crush/godot/tests/BoardDataTests.cs index bf5fa13..efd4c1a 100644 --- a/projects/kitty-crush/godot/tests/BoardDataTests.cs +++ b/projects/kitty-crush/godot/tests/BoardDataTests.cs @@ -1,6 +1,8 @@ using Godot; using Xunit; +using Whiskerfolk.Puzzle; + namespace Whiskerfolk.Tests; public class BoardDataTests From 9745e98b6f6df8b103074b36c6c79cd165ab2845 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:52:53 -0600 Subject: [PATCH 060/142] fix(whiskerfolk): import puzzle namespace in BoardGenerationTests.cs --- projects/kitty-crush/godot/tests/BoardGenerationTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projects/kitty-crush/godot/tests/BoardGenerationTests.cs b/projects/kitty-crush/godot/tests/BoardGenerationTests.cs index 56299fb..952b371 100644 --- a/projects/kitty-crush/godot/tests/BoardGenerationTests.cs +++ b/projects/kitty-crush/godot/tests/BoardGenerationTests.cs @@ -1,6 +1,8 @@ using Godot; using Xunit; +using Whiskerfolk.Puzzle; + namespace Whiskerfolk.Tests; public class BoardGenerationTests From 677659bbaf0b5d0d19cbd8f117dbf56d13256a67 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:52:55 -0600 Subject: [PATCH 061/142] fix(whiskerfolk): import puzzle namespace in MatchDetectorTests.cs --- projects/kitty-crush/godot/tests/MatchDetectorTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projects/kitty-crush/godot/tests/MatchDetectorTests.cs b/projects/kitty-crush/godot/tests/MatchDetectorTests.cs index 733e5ff..ace875b 100644 --- a/projects/kitty-crush/godot/tests/MatchDetectorTests.cs +++ b/projects/kitty-crush/godot/tests/MatchDetectorTests.cs @@ -1,6 +1,8 @@ using Godot; using Xunit; +using Whiskerfolk.Puzzle; + namespace Whiskerfolk.Tests; public class MatchDetectorTests From 01d7721ef3c617727c79d5bff5b5f92d7a396190 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:52:57 -0600 Subject: [PATCH 062/142] fix(whiskerfolk): import puzzle namespace in GravitySystemTests.cs --- projects/kitty-crush/godot/tests/GravitySystemTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projects/kitty-crush/godot/tests/GravitySystemTests.cs b/projects/kitty-crush/godot/tests/GravitySystemTests.cs index e79295d..0c664f0 100644 --- a/projects/kitty-crush/godot/tests/GravitySystemTests.cs +++ b/projects/kitty-crush/godot/tests/GravitySystemTests.cs @@ -1,6 +1,8 @@ using Godot; using Xunit; +using Whiskerfolk.Puzzle; + namespace Whiskerfolk.Tests; public class GravitySystemTests From d0d0dc9402280c136e0b0a8786b43e6a1f4f4b0c Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:53:00 -0600 Subject: [PATCH 063/142] fix(whiskerfolk): import puzzle namespace in ScoreCalculatorTests.cs --- projects/kitty-crush/godot/tests/ScoreCalculatorTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projects/kitty-crush/godot/tests/ScoreCalculatorTests.cs b/projects/kitty-crush/godot/tests/ScoreCalculatorTests.cs index 753703b..3a7c89c 100644 --- a/projects/kitty-crush/godot/tests/ScoreCalculatorTests.cs +++ b/projects/kitty-crush/godot/tests/ScoreCalculatorTests.cs @@ -1,6 +1,8 @@ using Godot; using Xunit; +using Whiskerfolk.Puzzle; + namespace Whiskerfolk.Tests; public class ScoreCalculatorTests From 3b49644abc54099d75edc73c3f8cc3df68e86571 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:53:03 -0600 Subject: [PATCH 064/142] fix(whiskerfolk): import puzzle namespace in SpecialTilesTests.cs --- projects/kitty-crush/godot/tests/SpecialTilesTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projects/kitty-crush/godot/tests/SpecialTilesTests.cs b/projects/kitty-crush/godot/tests/SpecialTilesTests.cs index 53b6508..037e03a 100644 --- a/projects/kitty-crush/godot/tests/SpecialTilesTests.cs +++ b/projects/kitty-crush/godot/tests/SpecialTilesTests.cs @@ -1,6 +1,8 @@ using Godot; using Xunit; +using Whiskerfolk.Puzzle; + namespace Whiskerfolk.Tests; public class SpecialTilesTests From fa13512158f65f5a06ec201008e20cbeb1eb2099 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:53:05 -0600 Subject: [PATCH 065/142] fix(whiskerfolk): import puzzle namespace in ValidMoveCheckerTests.cs --- projects/kitty-crush/godot/tests/ValidMoveCheckerTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projects/kitty-crush/godot/tests/ValidMoveCheckerTests.cs b/projects/kitty-crush/godot/tests/ValidMoveCheckerTests.cs index 03a9ff3..4a26819 100644 --- a/projects/kitty-crush/godot/tests/ValidMoveCheckerTests.cs +++ b/projects/kitty-crush/godot/tests/ValidMoveCheckerTests.cs @@ -1,6 +1,8 @@ using Godot; using Xunit; +using Whiskerfolk.Puzzle; + namespace Whiskerfolk.Tests; public class ValidMoveCheckerTests From 535978d6e45aee2be1ac0d731d440584385e5602 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:53:08 -0600 Subject: [PATCH 066/142] fix(whiskerfolk): import puzzle namespace in EdgeCasesTests.cs --- projects/kitty-crush/godot/tests/EdgeCasesTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projects/kitty-crush/godot/tests/EdgeCasesTests.cs b/projects/kitty-crush/godot/tests/EdgeCasesTests.cs index 32d9e4a..5518f54 100644 --- a/projects/kitty-crush/godot/tests/EdgeCasesTests.cs +++ b/projects/kitty-crush/godot/tests/EdgeCasesTests.cs @@ -1,6 +1,8 @@ using Godot; using Xunit; +using Whiskerfolk.Puzzle; + namespace Whiskerfolk.Tests; public class EdgeCasesTests From 3b51c63d8a59ce336f6e440dc6ba8e8fb79591bb Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:53:10 -0600 Subject: [PATCH 067/142] fix(whiskerfolk): import puzzle namespace in ReshuffleTests.cs --- projects/kitty-crush/godot/tests/ReshuffleTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projects/kitty-crush/godot/tests/ReshuffleTests.cs b/projects/kitty-crush/godot/tests/ReshuffleTests.cs index 64b6101..8d7a255 100644 --- a/projects/kitty-crush/godot/tests/ReshuffleTests.cs +++ b/projects/kitty-crush/godot/tests/ReshuffleTests.cs @@ -1,6 +1,8 @@ using Godot; using Xunit; +using Whiskerfolk.Puzzle; + namespace Whiskerfolk.Tests; public class ReshuffleTests From 095a8274e12680a27a317816f6e821fd996c523e Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:53:13 -0600 Subject: [PATCH 068/142] fix(whiskerfolk): import puzzle namespace in SwapClearCascadeTests.cs --- projects/kitty-crush/godot/tests/SwapClearCascadeTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projects/kitty-crush/godot/tests/SwapClearCascadeTests.cs b/projects/kitty-crush/godot/tests/SwapClearCascadeTests.cs index 0e90199..5247d5a 100644 --- a/projects/kitty-crush/godot/tests/SwapClearCascadeTests.cs +++ b/projects/kitty-crush/godot/tests/SwapClearCascadeTests.cs @@ -1,6 +1,8 @@ using Godot; using Xunit; +using Whiskerfolk.Puzzle; + namespace Whiskerfolk.Tests; public class SwapClearCascadeTests From 1ae1fc1dd00758888bdc33bd02dac7450ddeb84f Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:53:36 -0600 Subject: [PATCH 069/142] test(whiskerfolk): add BondStateTests.cs --- .../kitty-crush/godot/tests/BondStateTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/BondStateTests.cs 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); + } +} From e7245a0a215848ff2c23ee451af62442c4221e51 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:53:38 -0600 Subject: [PATCH 070/142] test(whiskerfolk): add RescueDirectorTests.cs --- .../godot/tests/RescueDirectorTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/RescueDirectorTests.cs 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); + } +} From a670954e95baf452855cfc206b33cc04103fe341 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:53:40 -0600 Subject: [PATCH 071/142] test(whiskerfolk): add ObjectiveProgressTests.cs --- .../godot/tests/ObjectiveProgressTests.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/ObjectiveProgressTests.cs 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); + } +} From 7a004ffbce382de0fe3f44b0eff7721a97342790 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:53:42 -0600 Subject: [PATCH 072/142] test(whiskerfolk): add CatBehaviorControllerTests.cs --- .../godot/tests/CatBehaviorControllerTests.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/CatBehaviorControllerTests.cs 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); + } +} From 77c903d34dac366b6e818ddbed03ae6ad488f779 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:55:49 -0600 Subject: [PATCH 073/142] feat(whiskerfolk): add MochiActor.cs runtime --- .../godot/scripts/app/MochiActor.cs | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/app/MochiActor.cs 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..2ce0447 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/app/MochiActor.cs @@ -0,0 +1,136 @@ +using Godot; + +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 float _time; + private float _trust; + private bool _blink; + private string _mood = "watching"; + + 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) + { + _time += (float)delta; + _blink = Mathf.Sin(_time * 1.17f) > 0.985f; + QueueRedraw(); + } + + public override void _Draw() + { + var breath = Mathf.Sin(_time * 2.0f) * 2.3f; + var bodyCenter = new Vector2(112, 137 + breath * 0.35f); + var headCenter = new Vector2(105, 82 + breath * 0.22f); + + // Tail: broad soft stroke + caramel tip. + DrawArc(new Vector2(144, 139), 53, -1.12f, 0.62f, 26, CreamShadow, 18, true); + DrawArc(new Vector2(144, 139), 53, 0.30f, 0.62f, 8, Caramel, 19, true); + + // Body + chest. + DrawCircle(bodyCenter, 61, CreamShadow); + DrawCircle(bodyCenter + new Vector2(-8, -4), 55, Cream); + + // Ears behind head. + DrawColoredPolygon( + new Vector2[] { new(48, 63), new(66, 18), new(89, 68) }, + Cream + ); + DrawColoredPolygon( + new Vector2[] { new(123, 62), new(151, 17), new(161, 73) }, + Caramel + ); + DrawColoredPolygon( + new Vector2[] { new(57, 57), new(67, 32), new(80, 61) }, + new Color("#DDB9A7") + ); + DrawColoredPolygon( + new Vector2[] { new(133, 58), new(149, 31), new(153, 63) }, + new Color("#D7A483") + ); + + // Head. + DrawCircle(headCenter, 53, Cream); + + // Caramel comma marking. + DrawCircle(new Vector2(137, 74), 13, Caramel); + DrawCircle(new Vector2(130, 64), 8, Cream); + + // Eyes. + var eyeHeight = _blink ? 1.5f : 8.5f; + DrawSetTransform(new Vector2(0, 0), 0, Vector2.One); + DrawEllipse(new Vector2(85, 87), new Vector2(6.5f, eyeHeight), Eye); + DrawEllipse(new Vector2(124, 87), new Vector2(6.5f, eyeHeight), Eye); + + if (!_blink) + { + DrawCircle(new Vector2(83, 84), 2.0f, Colors.White); + DrawCircle(new Vector2(122, 84), 2.0f, Colors.White); + } + + // Nose and tiny mouth. + DrawCircle(new Vector2(105, 103), 4.8f, Nose); + DrawLine(new Vector2(105, 107), new Vector2(105, 112), Ink, 1.6f, true); + DrawArc(new Vector2(99, 111), 7, 0.1f, 1.1f, 8, Ink, 1.4f, true); + DrawArc(new Vector2(111, 111), 7, 2.0f, 3.0f, 8, Ink, 1.4f, true); + + // Whiskers. + DrawLine(new Vector2(83, 104), new Vector2(48, 98), Ink, 1.1f, true); + DrawLine(new Vector2(82, 110), new Vector2(45, 111), Ink, 1.1f, true); + DrawLine(new Vector2(128, 104), new Vector2(164, 98), Ink, 1.1f, true); + DrawLine(new Vector2(128, 110), new Vector2(166, 112), Ink, 1.1f, true); + + // Front paws. Low trust keeps one paw pulled in. + DrawCircle(new Vector2(82, 181), 18, Cream); + if (_trust > 38 || _mood == "home") + DrawCircle(new Vector2(117, 181), 18, Cream); + + // Mood cue: slight question head-tilt is represented by ear/eye asymmetry later in rig. + if (_mood == "hiding") + DrawCircle(new Vector2(108, 145), 53, new Color(0.15f, 0.15f, 0.15f, 0.08f)); + } + + private void DrawEllipse(Vector2 center, Vector2 radii, Color color) + { + var points = new Vector2[24]; + for (int 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); + } +} From 563a7fa65159674e06bb778121873c3619077ae4 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:55:51 -0600 Subject: [PATCH 074/142] feat(whiskerfolk): add WhiskerfolkBootstrap.cs runtime --- .../godot/scripts/app/WhiskerfolkBootstrap.cs | 771 ++++++++++++++++++ 1 file changed, 771 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs 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..9933335 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs @@ -0,0 +1,771 @@ +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; + +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 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; + + public override void _Ready() + { + SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + _rescue = new RescueDirector(RescueArc.MochiFirstNight()); + BuildIntro(); + } + + public override void _Process(double delta) + { + if (!_holdingTrust || _trustBar is null) + return; + + _holdProgress += (float)delta * 29f; + _bond.AddTrust((float)delta * 25f); + _trustBar.Value = _holdProgress; + if (_mochi is not null) + _mochi.Trust = _holdProgress; + + if (_holdProgress > 36 && _trustCopy is not null) + _trustCopy.Text = "He came closer. Then backed away. Then came back."; + + if (_holdProgress >= 100) + { + _holdingTrust = false; + _bond.SetForStoryBeat(BondLevel.Testing); + BuildShelterPuzzle(); + } + } + + private void ClearScreen(Color background) + { + foreach (var child in GetChildren()) + { + 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); + + var layer = SafeLayer(); + var column = new VBoxContainer + { + SizeFlagsVertical = SizeFlags.ExpandFill, + ThemeOverrideConstants = { ["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 += () => + { + _rescue.TryAdvance("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); + _rescue.TryAdvance("food_help"); + BuildTrust(); + } + ); + } + + private void BuildShelterPuzzle() + { + BuildPuzzle( + MochiObjectives.All["collect_blanket"], + "SHELTER", + "Give the box a dry cover.", + 16, + () => + { + _rescue.TryAdvance("first_trust"); + _rescue.TryAdvance("shelter_help"); + BuildRescue(); + } + ); + } + + private void BuildPuzzle( + ObjectiveDefinition definition, + string kicker, + string titleText, + int moves, + Action onComplete) + { + ClearScreen(Milk); + _objective = new ObjectiveProgress(definition); + _moves = moves; + _selected = null; + _inputLocked = false; + + var layer = SafeLayer(22, 26); + var column = new VBoxContainer { ThemeOverrideConstants = { ["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; + 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--; + 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, + _ => -1 + }; + + int cascadeGuard = 0; + var current = match; + + while (current.HasMatches() && cascadeGuard++ < 12) + { + var positions = current.GetAllPositions().Distinct().ToList(); + + 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; + + var layer = SafeLayer(28, 34); + var column = new VBoxContainer + { + SizeFlagsVertical = SizeFlags.ExpandFill, + ThemeOverrideConstants = { ["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 BuildRescue() + { + ClearScreen(new Color("#20353F")); + + _rescue.TryAdvance("water_rises"); + + var layer = SafeLayer(); + var column = new VBoxContainer + { + SizeFlagsVertical = SizeFlags.ExpandFill, + ThemeOverrideConstants = { ["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); + _rescue.TryAdvance("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() + { + ClearScreen(new Color("#E9D8C0")); + _bond.SetForStoryBeat(BondLevel.NewHome); + _rescue.TryAdvance("ride_home"); + _rescue.TryAdvance("box_moment"); + + var layer = SafeLayer(); + var column = new VBoxContainer + { + SizeFlagsVertical = SizeFlags.ExpandFill, + ThemeOverrideConstants = { ["separation"] = 12 } + }; + layer.AddChild(column); + + column.AddChild(Label("HOME", 12, Moss, true)); + column.AddChild(Label("Mochi lives\nhere now.", 46, Ink, true)); + column.AddChild(Label("No reward chest. No score screen. He picked the warm corner.", 16, SoftInk)); + + 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); + + 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); + + _mochi = new MochiActor + { + Position = new Vector2(246, 285), + Size = new Vector2(220, 220), + Trust = 64, + Mood = "home", + Modulate = new Color(1,1,1,0) + }; + roomRoot.AddChild(_mochi); + + var reveal = CreateTween(); + reveal.TweenInterval(0.8); + reveal.TweenProperty(_mochi, "modulate:a", 1.0f, 0.8); + reveal.TweenProperty(_mochi, "position", new Vector2(170, 300), 1.4) + .SetTrans(Tween.TransitionType.Cubic) + .SetEase(Tween.EaseType.Out); + + 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("Place his blanket"); + blanket.Pressed += () => + { + blanket.Disabled = true; + blanket.Text = "Blanket placed"; + var cloth = new PanelContainer + { + Position = new Vector2(46, 410), + Size = new Vector2(170, 70) + }; + cloth.AddThemeStyleboxOverride("panel", Box(new Color("#D8B99A"), 28)); + roomRoot.AddChild(cloth); + + if (_mochi is not null) + { + var tween = CreateTween(); + tween.TweenProperty(_mochi, "position", new Vector2(38, 335), 1.4) + .SetTrans(Tween.TransitionType.Cubic) + .SetEase(Tween.EaseType.InOut); + } + }; + column.AddChild(blanket); + } + + private void ShowSheet(string kicker, string title, string copy) + { + var overlay = new ColorRect + { + Color = new Color(0.05f,0.07f,0.07f,0.36f), + MouseFilter = MouseFilterEnum.Stop + }; + overlay.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + AddChild(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); + + 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 { ThemeOverrideConstants = { ["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 += () => + { + overlay.QueueFree(); + panel.QueueFree(); + }; + col.AddChild(close); + } +} From bd60796f16e566bfc7726aa6d8d9eb81c54ca907 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:55:55 -0600 Subject: [PATCH 075/142] feat(whiskerfolk): add Main.tscn runtime --- projects/kitty-crush/godot/scenes/Main.tscn | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 projects/kitty-crush/godot/scenes/Main.tscn 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") From f030e13b7ccb17b2abd7f208654108efde4054a4 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:55:57 -0600 Subject: [PATCH 076/142] feat(whiskerfolk): wire Godot vertical slice main scene --- projects/kitty-crush/godot/project.godot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/kitty-crush/godot/project.godot b/projects/kitty-crush/godot/project.godot index eb2b796..b0ce016 100644 --- a/projects/kitty-crush/godot/project.godot +++ b/projects/kitty-crush/godot/project.godot @@ -4,7 +4,7 @@ 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" +config/version="0.1.0-vertical-slice"\nrun/main_scene="res://scenes/Main.tscn" [display] window/size/viewport_width=1080 From a589bf7e311f004def504f363656a8c8fae33aad Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:56:17 -0600 Subject: [PATCH 077/142] fix(whiskerfolk): use Godot theme constant API for containers --- .../godot/scripts/app/WhiskerfolkBootstrap.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs index 9933335..a653cb7 100644 --- a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs +++ b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs @@ -162,9 +162,9 @@ private void BuildIntro() var layer = SafeLayer(); var column = new VBoxContainer { - SizeFlagsVertical = SizeFlags.ExpandFill, - ThemeOverrideConstants = { ["separation"] = 14 } + SizeFlagsVertical = SizeFlags.ExpandFill }; + column.AddThemeConstantOverride("separation", 14); layer.AddChild(column); var brand = Label("WHISKERFOLK", 13, new Color(1,1,1,0.42f), true); @@ -278,7 +278,8 @@ private void BuildPuzzle( _inputLocked = false; var layer = SafeLayer(22, 26); - var column = new VBoxContainer { ThemeOverrideConstants = { ["separation"] = 12 } }; + var column = new VBoxContainer(); + column.AddThemeConstantOverride("separation", 12); layer.AddChild(column); var top = new HBoxContainer(); @@ -481,9 +482,9 @@ private void BuildTrust() var layer = SafeLayer(28, 34); var column = new VBoxContainer { - SizeFlagsVertical = SizeFlags.ExpandFill, - ThemeOverrideConstants = { ["separation"] = 14 } + SizeFlagsVertical = SizeFlags.ExpandFill }; + column.AddThemeConstantOverride("separation", 14); layer.AddChild(column); column.AddChild(Label("TRUST", 12, Sage, true)); @@ -754,7 +755,8 @@ private void ShowSheet(string kicker, string title, string copy) margin.AddThemeConstantOverride("margin_bottom", 24); panel.AddChild(margin); - var col = new VBoxContainer { ThemeOverrideConstants = { ["separation"] = 10 } }; + 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)); From e1f39ee6b18d66644cb18dc623395aa995e460e7 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:56:35 -0600 Subject: [PATCH 078/142] fix(whiskerfolk): finish Godot container theme API cleanup --- .../kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs index a653cb7..17fa6f3 100644 --- a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs +++ b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs @@ -551,9 +551,9 @@ private void BuildRescue() var layer = SafeLayer(); var column = new VBoxContainer { - SizeFlagsVertical = SizeFlags.ExpandFill, - ThemeOverrideConstants = { ["separation"] = 14 } + SizeFlagsVertical = SizeFlags.ExpandFill }; + column.AddThemeConstantOverride("separation", 14); layer.AddChild(column); column.AddChild(Label("ONE LAST THING", 12, Sage, true)); @@ -625,9 +625,9 @@ private void BuildHome() var layer = SafeLayer(); var column = new VBoxContainer { - SizeFlagsVertical = SizeFlags.ExpandFill, - ThemeOverrideConstants = { ["separation"] = 12 } + SizeFlagsVertical = SizeFlags.ExpandFill }; + column.AddThemeConstantOverride("separation", 12); layer.AddChild(column); column.AddChild(Label("HOME", 12, Moss, true)); From 61460cf69594f517e5d1ad940c1be3d63c119c50 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:57:04 -0600 Subject: [PATCH 079/142] fix(whiskerfolk): add safe-path puzzle and align rescue state machine --- .../godot/scripts/app/WhiskerfolkBootstrap.cs | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs index 17fa6f3..593142e 100644 --- a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs +++ b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs @@ -259,7 +259,8 @@ private void BuildShelterPuzzle() { _rescue.TryAdvance("first_trust"); _rescue.TryAdvance("shelter_help"); - BuildRescue(); + _rescue.TryAdvance("water_rises"); + BuildSafePathPuzzle(); } ); } @@ -446,6 +447,7 @@ private void ResolveBoard(MatchResult match) { "food" => 0, "blanket" => 1, + "flood" => 2, _ => -1 }; @@ -542,12 +544,25 @@ private void BuildTrust() column.AddChild(hold); } + private void BuildSafePathPuzzle() + { + BuildPuzzle( + MochiObjectives.All["safe_path_carrier"], + "SAFE PATH", + "Clear the water from his way.", + 14, + () => + { + _rescue.TryAdvance("safe_path"); + BuildRescue(); + } + ); + } + private void BuildRescue() { ClearScreen(new Color("#20353F")); - _rescue.TryAdvance("water_rises"); - var layer = SafeLayer(); var column = new VBoxContainer { From bf04a60e2d1f39fe6b60699a4c076368c359a7e5 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:57:42 -0600 Subject: [PATCH 080/142] feat(whiskerfolk): add safe-path puzzle to web vertical slice --- projects/kitty-crush/prototype-web/app.js | 24 ++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/projects/kitty-crush/prototype-web/app.js b/projects/kitty-crush/prototype-web/app.js index 4527e83..8e6a65f 100644 --- a/projects/kitty-crush/prototype-web/app.js +++ b/projects/kitty-crush/prototype-web/app.js @@ -73,6 +73,17 @@ function startShelterPuzzle(){ 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; @@ -249,10 +260,14 @@ function completePuzzle(){ $('#complete-title').textContent='He stayed.'; $('#complete-copy').textContent='You set the food down and gave him space.'; $('#complete-btn').textContent='Keep watching'; - }else{ + }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='Stay with him'; + $('#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; } @@ -369,9 +384,8 @@ $('#box-hotspot').addEventListener('click',()=>{ $('#complete-btn').addEventListener('click',()=>{ $('#level-complete').hidden=true; if(stage===1) enterTrust(); - else { - showScene('rescue'); - } + else if(stage===2) startSafePathPuzzle(); + else showScene('rescue'); }); $('#hand-zone').addEventListener('pointerdown',beginHold); $('#hand-zone').addEventListener('pointerup',endHold); From f28eaf5256d7a892627bcc3367afb69e9c66c750 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:58:21 -0600 Subject: [PATCH 081/142] fix(whiskerfolk): add puzzle enums required by core tests --- .../godot/scripts/core/puzzle/PuzzleEnums.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/core/puzzle/PuzzleEnums.cs 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 +} From 95ecc2e678733acf1111cd43275391c3b389f98d Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:59:03 -0600 Subject: [PATCH 082/142] refactor(whiskerfolk): make save collections serializable --- .../kitty-crush/godot/scripts/save/SaveGame.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/projects/kitty-crush/godot/scripts/save/SaveGame.cs b/projects/kitty-crush/godot/scripts/save/SaveGame.cs index 86a3a1b..b67fe0c 100644 --- a/projects/kitty-crush/godot/scripts/save/SaveGame.cs +++ b/projects/kitty-crush/godot/scripts/save/SaveGame.cs @@ -7,15 +7,16 @@ public sealed class SaveGame { public const int CurrentSchema = 1; - public int SchemaVersion { get; init; } = CurrentSchema; - public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + 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; } = new(); - public Dictionary CatTrust { get; } = new(); - public HashSet Memories { get; } = new(); - public HashSet OwnedHomeObjects { get; } = new(); - public HashSet OwnedCosmetics { get; } = new(); + 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(); } From 7f48b24aec140e4cd52bd092cf24ff1620025e76 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:59:05 -0600 Subject: [PATCH 083/142] feat(whiskerfolk): add SaveService service --- .../godot/scripts/save/SaveService.cs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/save/SaveService.cs 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; + } +} From a380595513d0cf3322c6b0c1db929bd6a13559d9 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:59:07 -0600 Subject: [PATCH 084/142] feat(whiskerfolk): add DebugAnalyticsService service --- .../scripts/analytics/DebugAnalyticsService.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/analytics/DebugAnalyticsService.cs 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}"); + } +} From 79f85c4deecbc611d3edbb9ed6d6c2007acddbee Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:59:09 -0600 Subject: [PATCH 085/142] feat(whiskerfolk): add HapticsService service --- .../godot/scripts/services/HapticsService.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/services/HapticsService.cs 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); + } +} From 51b7686e392bfb504f3f66579fa87d8328900d84 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:59:11 -0600 Subject: [PATCH 086/142] feat(whiskerfolk): add AudioService service --- .../godot/scripts/services/AudioService.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/services/AudioService.cs 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(); + } +} From 7773d734843e2c3f7aef4fe76dbc40ccae0f4bdc Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:59:13 -0600 Subject: [PATCH 087/142] feat(whiskerfolk): add EconomyModels service --- .../godot/scripts/economy/EconomyModels.cs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/economy/EconomyModels.cs 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; + } +} From eee41d84d7b63c870848d6cb5a75b323a5c1f486 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:59:15 -0600 Subject: [PATCH 088/142] feat(whiskerfolk): add ICommerceService service --- .../godot/scripts/economy/ICommerceService.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/economy/ICommerceService.cs 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; +} From a07b143330a03c9401f9319ecbfefd454f5c3d28 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:59:17 -0600 Subject: [PATCH 089/142] feat(whiskerfolk): add BehaviorScheduler service --- .../godot/scripts/home/BehaviorScheduler.cs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/home/BehaviorScheduler.cs 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..2eb3674 --- /dev/null +++ b/projects/kitty-crush/godot/scripts/home/BehaviorScheduler.cs @@ -0,0 +1,43 @@ +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) + { + foreach (var slot in obj.Slots) + { + options.Add(new BehaviorOption( + $"{obj.Id}:{slot.Id}", + slot.BehaviorTags, + 0.28f, + obj.Id, + state.CurrentObjectId == obj.Id ? 0.25f : 0f + )); + } + } + + return _controller.Choose(cat, state, options); + } +} From 6a81226ae655123dfe97702c9808357bd6a7b07c Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 22:59:37 -0600 Subject: [PATCH 090/142] feat(whiskerfolk): support rescue arc resume from save --- .../kitty-crush/godot/scripts/rescue/RescueDirector.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/projects/kitty-crush/godot/scripts/rescue/RescueDirector.cs b/projects/kitty-crush/godot/scripts/rescue/RescueDirector.cs index 0829f4e..e5bf6a4 100644 --- a/projects/kitty-crush/godot/scripts/rescue/RescueDirector.cs +++ b/projects/kitty-crush/godot/scripts/rescue/RescueDirector.cs @@ -11,10 +11,10 @@ public sealed class RescueDirector public event Action? BeatChanged; - public RescueDirector(RescueArc arc) + public RescueDirector(RescueArc arc, int startBeatIndex = 0) { Arc = arc; - BeatIndex = 0; + BeatIndex = Math.Clamp(startBeatIndex, 0, Math.Max(0, arc.Beats.Count - 1)); } public bool TryAdvance(string completedBeatId) @@ -29,6 +29,12 @@ public bool TryAdvance(string completedBeatId) 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; From f8ece707a1991307743b9176a64643c9e06789cc Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:00:18 -0600 Subject: [PATCH 091/142] feat(whiskerfolk): wire save resume analytics and haptics into vertical slice --- .../godot/scripts/app/WhiskerfolkBootstrap.cs | 95 ++++++++++++++++--- 1 file changed, 84 insertions(+), 11 deletions(-) diff --git a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs index 593142e..7054c73 100644 --- a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs +++ b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs @@ -7,6 +7,9 @@ using Whiskerfolk.Rescue; using Whiskerfolk.Home; using Whiskerfolk.Memories; +using Whiskerfolk.Analytics; +using Whiskerfolk.Services; +using Whiskerfolk.Save; namespace Whiskerfolk.App; @@ -35,6 +38,10 @@ public partial class WhiskerfolkBootstrap : Control private RescueDirector _rescue = null!; private BondState _bond = new(); + private readonly IAnalyticsService _analytics = new DebugAnalyticsService(); + private readonly HapticsService _haptics = new(); + private readonly SaveService _saveService = new(); + private SaveGame _save = null!; private BoardData? _board; private ObjectiveProgress? _objective; private GridContainer? _grid; @@ -52,8 +59,53 @@ public partial class WhiskerfolkBootstrap : Control public override void _Ready() { SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); - _rescue = new RescueDirector(RescueArc.MochiFirstNight()); - BuildIntro(); + + _save = _saveService.LoadOrCreate(); + if (_save.CatTrust.TryGetValue("mochi", out var savedTrust)) + _bond.AddTrust(savedTrust); + + _rescue = new RescueDirector(RescueArc.MochiFirstNight(), _save.CurrentRescueBeat); + ResumeFromSave(); + } + + private void ResumeFromSave() + { + switch (_rescue.BeatIndex) + { + case 0: + BuildIntro(); + break; + case 1: + BuildFoodPuzzle(); + break; + case 2: + BuildTrust(); + break; + case 3: + BuildShelterPuzzle(); + break; + case 4: + case 5: + BuildSafePathPuzzle(); + break; + case 6: + BuildRescue(); + break; + default: + BuildHome(); + break; + } + } + + private bool AdvanceStory(string completedBeatId) + { + if (!AdvanceStory(completedBeatId)) + return false; + + _save.CurrentRescueBeat = _rescue.BeatIndex; + _save.CatTrust["mochi"] = _bond.Trust; + _saveService.Save(_save); + return true; } public override void _Process(double delta) @@ -74,6 +126,9 @@ public override void _Process(double delta) { _holdingTrust = false; _bond.SetForStoryBeat(BondLevel.Testing); + _analytics.Track(AnalyticsEvents.FirstTrustResponse); + _haptics.Affection(); + AdvanceStory("first_trust"); BuildShelterPuzzle(); } } @@ -158,6 +213,7 @@ private StyleBoxFlat Box(Color color, int radius) private void BuildIntro() { ClearScreen(Night); + _analytics.Track(AnalyticsEvents.FirstMochiSeen); var layer = SafeLayer(); var column = new VBoxContainer @@ -221,7 +277,7 @@ private void BuildIntro() var action = Button("Look closer"); action.Pressed += () => { - _rescue.TryAdvance("discover_box"); + AdvanceStory("discover_box"); BuildFoodPuzzle(); }; column.AddChild(action); @@ -242,7 +298,7 @@ private void BuildFoodPuzzle() () => { _bond.SetForStoryBeat(BondLevel.Tolerating); - _rescue.TryAdvance("food_help"); + AdvanceStory("food_help"); BuildTrust(); } ); @@ -257,9 +313,8 @@ private void BuildShelterPuzzle() 16, () => { - _rescue.TryAdvance("first_trust"); - _rescue.TryAdvance("shelter_help"); - _rescue.TryAdvance("water_rises"); + AdvanceStory("shelter_help"); + AdvanceStory("water_rises"); BuildSafePathPuzzle(); } ); @@ -275,6 +330,7 @@ private void BuildPuzzle( ClearScreen(Milk); _objective = new ObjectiveProgress(definition); _moves = moves; + _analytics.Track(AnalyticsEvents.FirstPuzzleStarted, new Dictionary { ["objective"] = definition.Id }); _selected = null; _inputLocked = false; @@ -332,6 +388,9 @@ private void BuildPuzzle( 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); @@ -426,6 +485,7 @@ private void OnTilePressed(Vector2I position) } _moves--; + _haptics.Selection(); if (_movesLabel is not null) _movesLabel.Text = $"Moves {_moves}"; @@ -457,6 +517,7 @@ private void ResolveBoard(MatchResult match) while (current.HasMatches() && cascadeGuard++ < 12) { var positions = current.GetAllPositions().Distinct().ToList(); + _haptics.Match(); foreach (var pos in positions) { @@ -553,7 +614,7 @@ private void BuildSafePathPuzzle() 14, () => { - _rescue.TryAdvance("safe_path"); + AdvanceStory("safe_path"); BuildRescue(); } ); @@ -562,6 +623,7 @@ private void BuildSafePathPuzzle() private void BuildRescue() { ClearScreen(new Color("#20353F")); + _analytics.Track(AnalyticsEvents.RescueStarted); var layer = SafeLayer(); var column = new VBoxContainer @@ -611,7 +673,9 @@ private void BuildRescue() open.Pressed += () => { _bond.SetForStoryBeat(BondLevel.TrustingAction); - _rescue.TryAdvance("carrier_choice"); + _analytics.Track(AnalyticsEvents.RescueCompleted); + _haptics.Rescue(); + AdvanceStory("carrier_choice"); open.Text = "Wait…"; open.Disabled = true; GetTree().CreateTimer(1.3).Timeout += () => @@ -633,9 +697,18 @@ private void BuildRescue() private void BuildHome() { ClearScreen(new Color("#E9D8C0")); + _analytics.Track(AnalyticsEvents.BoxMomentStarted); _bond.SetForStoryBeat(BondLevel.NewHome); - _rescue.TryAdvance("ride_home"); - _rescue.TryAdvance("box_moment"); + AdvanceStory("ride_home"); + 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(); var layer = SafeLayer(); var column = new VBoxContainer From d9542db56aa9945d11a537367bdd521846d3d06d Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:00:34 -0600 Subject: [PATCH 092/142] fix(whiskerfolk): prevent recursive story advance helper --- projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs index 7054c73..500944c 100644 --- a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs +++ b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs @@ -99,7 +99,7 @@ private void ResumeFromSave() private bool AdvanceStory(string completedBeatId) { - if (!AdvanceStory(completedBeatId)) + if (!_rescue.TryAdvance(completedBeatId)) return false; _save.CurrentRescueBeat = _rescue.BeatIndex; From 5864aa53de757bb69a89f9c6f63b111492d05ccd Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:01:33 -0600 Subject: [PATCH 093/142] feat(whiskerfolk): add mochi_app_icon_direction.svg visual asset --- .../assets/mochi/mochi_app_icon_direction.svg | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 projects/kitty-crush/assets/mochi/mochi_app_icon_direction.svg 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 From 29bf832ab14a27c461e1482d4206e94a6337cc39 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:01:35 -0600 Subject: [PATCH 094/142] feat(whiskerfolk): add whiskerfolk_wordmark_direction.svg visual asset --- .../assets/brand/whiskerfolk_wordmark_direction.svg | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 projects/kitty-crush/assets/brand/whiskerfolk_wordmark_direction.svg 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 From 4b0b90ef552bfe7e60b36738a710c483ef406d2b Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:01:37 -0600 Subject: [PATCH 095/142] feat(whiskerfolk): add README.md visual asset --- projects/kitty-crush/assets/README.md | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 projects/kitty-crush/assets/README.md 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. From 29cabe825754040d2950c0c7c6bd67a187b89cf3 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:02:51 -0600 Subject: [PATCH 096/142] fix(whiskerfolk): correct project.godot main scene newline --- projects/kitty-crush/godot/project.godot | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/projects/kitty-crush/godot/project.godot b/projects/kitty-crush/godot/project.godot index b0ce016..522eeda 100644 --- a/projects/kitty-crush/godot/project.godot +++ b/projects/kitty-crush/godot/project.godot @@ -4,7 +4,8 @@ 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"\nrun/main_scene="res://scenes/Main.tscn" +config/version="0.1.0-vertical-slice" +run/main_scene="res://scenes/Main.tscn" [display] window/size/viewport_width=1080 From 1257f536921419d840a415d7750638290bcfbd3c Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:03:30 -0600 Subject: [PATCH 097/142] fix(whiskerfolk): make home resume-safe and first-arrival analytics idempotent --- .../godot/scripts/app/WhiskerfolkBootstrap.cs | 63 ++++++++++++------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs index 500944c..2471cb6 100644 --- a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs +++ b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs @@ -85,6 +85,9 @@ private void ResumeFromSave() BuildShelterPuzzle(); break; case 4: + AdvanceStory("water_rises"); + BuildSafePathPuzzle(); + break; case 5: BuildSafePathPuzzle(); break; @@ -696,19 +699,32 @@ private void BuildRescue() private void BuildHome() { + var returningHome = _save.RescuedCats.Contains("mochi") || _rescue.BeatIndex >= 9; + ClearScreen(new Color("#E9D8C0")); - _analytics.Track(AnalyticsEvents.BoxMomentStarted); _bond.SetForStoryBeat(BondLevel.NewHome); - AdvanceStory("ride_home"); - 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(); + + if (!returningHome) + { + _analytics.Track(AnalyticsEvents.BoxMomentStarted); + AdvanceStory("ride_home"); + 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(); + } + 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 @@ -719,8 +735,10 @@ private void BuildHome() layer.AddChild(column); column.AddChild(Label("HOME", 12, Moss, true)); - column.AddChild(Label("Mochi lives\nhere now.", 46, Ink, true)); - column.AddChild(Label("No reward chest. No score screen. He picked the warm corner.", 16, SoftInk)); + column.AddChild(Label(returningHome ? "Mochi is\nhome." : "Mochi lives\nhere now.", 46, Ink, true)); + column.AddChild(Label(returningHome + ? "He has already started choosing his favorite places." + : "No reward chest. No score screen. He picked the warm corner.", 16, SoftInk)); var room = new PanelContainer { @@ -751,20 +769,23 @@ private void BuildHome() _mochi = new MochiActor { - Position = new Vector2(246, 285), + Position = returningHome ? new Vector2(170, 300) : new Vector2(246, 285), Size = new Vector2(220, 220), Trust = 64, Mood = "home", - Modulate = new Color(1,1,1,0) + Modulate = returningHome ? Colors.White : new Color(1,1,1,0) }; roomRoot.AddChild(_mochi); - var reveal = CreateTween(); - reveal.TweenInterval(0.8); - reveal.TweenProperty(_mochi, "modulate:a", 1.0f, 0.8); - reveal.TweenProperty(_mochi, "position", new Vector2(170, 300), 1.4) - .SetTrans(Tween.TransitionType.Cubic) - .SetEase(Tween.EaseType.Out); + if (!returningHome) + { + var reveal = CreateTween(); + reveal.TweenInterval(0.8); + reveal.TweenProperty(_mochi, "modulate:a", 1.0f, 0.8); + reveal.TweenProperty(_mochi, "position", new Vector2(170, 300), 1.4) + .SetTrans(Tween.TransitionType.Cubic) + .SetEase(Tween.EaseType.Out); + } var actions = new HBoxContainer(); actions.AddThemeConstantOverride("separation", 8); From 2651ca9d2edf5cb238213691b1eed03b1ac9f85a Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:04:16 -0600 Subject: [PATCH 098/142] build(whiskerfolk): add export_presets.cfg --- projects/kitty-crush/godot/export_presets.cfg | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 projects/kitty-crush/godot/export_presets.cfg 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 From 0eafd8164e129a585f4b7822387b6db3f41bb21e Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:04:18 -0600 Subject: [PATCH 099/142] build(whiskerfolk): add .gitignore --- projects/kitty-crush/godot/.gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 projects/kitty-crush/godot/.gitignore 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 From 51155c90224247cf03ca0a7e158b499193a3796b Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:04:20 -0600 Subject: [PATCH 100/142] build(whiskerfolk): add ANDROID_BUILD.md --- projects/kitty-crush/godot/ANDROID_BUILD.md | 56 +++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 projects/kitty-crush/godot/ANDROID_BUILD.md 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. From bf1f3b1e326ad63689dcd85f7cbce182c1e44559 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:04:54 -0600 Subject: [PATCH 101/142] ci(whiskerfolk): add branch build and test workflow --- .github/workflows/whiskerfolk-ci.yml | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/whiskerfolk-ci.yml diff --git a/.github/workflows/whiskerfolk-ci.yml b/.github/workflows/whiskerfolk-ci.yml new file mode 100644 index 0000000..8be3e5f --- /dev/null +++ b/.github/workflows/whiskerfolk-ci.yml @@ -0,0 +1,41 @@ +name: Whiskerfolk CI + +on: + push: + branches: + - kitty-crush-preproduction + paths: + - 'projects/kitty-crush/godot/**' + - '.github/workflows/whiskerfolk-ci.yml' + pull_request: + branches: + - main + paths: + - 'projects/kitty-crush/godot/**' + - '.github/workflows/whiskerfolk-ci.yml' + +jobs: + test: + name: Build + 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 + run: dotnet restore Whiskerfolk.csproj + + - name: Build + run: dotnet build Whiskerfolk.csproj --configuration Release --no-restore + + - name: Test + run: dotnet test Whiskerfolk.csproj --configuration Release --no-build --logger "console;verbosity=normal" From 682aaafe35e237ac6b38a851fc9d51060f63dada Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:05:33 -0600 Subject: [PATCH 102/142] refactor(whiskerfolk): separate runtime from test dependencies --- projects/kitty-crush/godot/Whiskerfolk.csproj | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/projects/kitty-crush/godot/Whiskerfolk.csproj b/projects/kitty-crush/godot/Whiskerfolk.csproj index 0b6d7ff..709c8d2 100644 --- a/projects/kitty-crush/godot/Whiskerfolk.csproj +++ b/projects/kitty-crush/godot/Whiskerfolk.csproj @@ -5,18 +5,10 @@ Whiskerfolk false LatestMajor + enable - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - + From e6a21938188445ae7d1a3a5236fd5ac268cbc06f Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:05:36 -0600 Subject: [PATCH 103/142] test(whiskerfolk): create isolated test project --- .../godot/tests/Whiskerfolk.Tests.csproj | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/Whiskerfolk.Tests.csproj 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..31b96c7 --- /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 + + + + + + + From f6d424ababb2e9cc48808f6d3c9de43ac304cdcf Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:05:38 -0600 Subject: [PATCH 104/142] ci(whiskerfolk): run isolated test project --- .github/workflows/whiskerfolk-ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/whiskerfolk-ci.yml b/.github/workflows/whiskerfolk-ci.yml index 8be3e5f..c81027c 100644 --- a/.github/workflows/whiskerfolk-ci.yml +++ b/.github/workflows/whiskerfolk-ci.yml @@ -32,10 +32,10 @@ jobs: dotnet-version: '8.0.x' - name: Restore - run: dotnet restore Whiskerfolk.csproj + run: dotnet restore tests/Whiskerfolk.Tests.csproj - name: Build - run: dotnet build Whiskerfolk.csproj --configuration Release --no-restore + run: dotnet build Whiskerfolk.csproj --configuration Release - name: Test - run: dotnet test Whiskerfolk.csproj --configuration Release --no-build --logger "console;verbosity=normal" + run: dotnet test tests/Whiskerfolk.Tests.csproj --configuration Release --logger "console;verbosity=normal" From 4214cb708e5b0722e3272f73f71a0f5e986ddf7b Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:07:33 -0600 Subject: [PATCH 105/142] refactor(whiskerfolk): add engine-agnostic GridPos.cs --- projects/kitty-crush/godot/scripts/core/puzzle/GridPos.cs | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/core/puzzle/GridPos.cs 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); +} From 655ff6637ebf434f4fa4c47df923e37f21d69494 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:07:36 -0600 Subject: [PATCH 106/142] refactor(whiskerfolk): make BoardData.cs engine-agnostic --- .../godot/scripts/core/puzzle/BoardData.cs | 204 +++++++++--------- 1 file changed, 103 insertions(+), 101 deletions(-) diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/BoardData.cs b/projects/kitty-crush/godot/scripts/core/puzzle/BoardData.cs index 23e1948..0452b70 100644 --- a/projects/kitty-crush/godot/scripts/core/puzzle/BoardData.cs +++ b/projects/kitty-crush/godot/scripts/core/puzzle/BoardData.cs @@ -1,86 +1,98 @@ -using Godot; +using System; +using System.Collections.Generic; namespace Whiskerfolk.Puzzle; -public partial class BoardData +public sealed class BoardData { - public int Cols = 8; - public int Rows = 8; - public CellData[] Tiles = System.Array.Empty(); - public int NumCrystalTypes = 5; + public int Cols { get; } + public int Rows { get; } + public CellData[] Tiles { get; } + public int NumCrystalTypes { get; } - public BoardData(int pCols = 8, int pRows = 8, int pTypes = 5) + public BoardData(int cols = 8, int rows = 8, int types = 5) { - Cols = pCols; - Rows = pRows; - NumCrystalTypes = pTypes; - int total = Cols * Rows; - Tiles = new CellData[total]; - for (int i = 0; i < total; i++) - Tiles[i] = new CellData(); - } + if (cols <= 0) throw new ArgumentOutOfRangeException(nameof(cols)); + if (rows <= 0) throw new ArgumentOutOfRangeException(nameof(rows)); + if (types <= 0) throw new ArgumentOutOfRangeException(nameof(types)); - public CellData GetTile(int pRow, int pCol) - { - return Tiles[pRow * Cols + pCol]; - } + Cols = cols; + Rows = rows; + NumCrystalTypes = types; + Tiles = new CellData[Cols * Rows]; - public void SetTile(int pRow, int pCol, CellData tile) - { - tile.Row = pRow; - tile.Col = pCol; - Tiles[pRow * Cols + pCol] = tile; + for (var i = 0; i < Tiles.Length; i++) + { + var pos = RowCol(i); + Tiles[i] = new CellData { Row = pos.Y, Col = pos.X }; + } } - public int GetIndex(int pRow, int pCol) + public CellData GetTile(int row, int col) => Tiles[GetIndex(row, col)]; + + public void SetTile(int row, int col, CellData tile) { - return pRow * Cols + pCol; + if (tile is null) throw new ArgumentNullException(nameof(tile)); + tile.Row = row; + tile.Col = col; + Tiles[GetIndex(row, col)] = tile; } - public Vector2I RowCol(int pIndex) + public int GetIndex(int row, int col) { - return new Vector2I(pIndex % Cols, pIndex / Cols); + if (!IsInBounds(row, col)) + throw new ArgumentOutOfRangeException($"Cell ({row},{col}) is outside {Rows}x{Cols} board."); + return row * Cols + col; } - public bool IsInBounds(int pRow, int pCol) + public GridPos RowCol(int index) { - return pRow >= 0 && pRow < Rows && pCol >= 0 && pCol < Cols; + if (index < 0 || index >= Tiles.Length) + throw new ArgumentOutOfRangeException(nameof(index)); + return new GridPos(index % Cols, index / Cols); } - public void Swap(int pRow1, int pCol1, int pRow2, int pCol2) + 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) { - int idx1 = GetIndex(pRow1, pCol1); - int idx2 = GetIndex(pRow2, pCol2); - var temp = Tiles[idx1]; - Tiles[idx1] = Tiles[idx2]; - Tiles[idx2] = temp; - Tiles[idx1].Row = pRow1; - Tiles[idx1].Col = pCol1; - Tiles[idx2].Row = pRow2; - Tiles[idx2].Col = pCol2; + 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 Godot.Collections.Array DuplicateData() + public CellSnapshot[] DuplicateData() { - var data = new Godot.Collections.Array(); - foreach (var tile in Tiles) + var data = new CellSnapshot[Tiles.Length]; + for (var i = 0; i < Tiles.Length; i++) { - var dict = new Godot.Collections.Dictionary(); - dict["type"] = tile.CrystalType; - dict["special"] = tile.SpecialType; - dict["empty"] = tile.IsEmpty; - data.Add(dict); + var tile = Tiles[i]; + data[i] = new CellSnapshot(tile.CrystalType, tile.SpecialType, tile.IsEmpty, tile.IsLocked, tile.LockHp); } return data; } - public void RestoreFromData(Godot.Collections.Array data) + public void RestoreFromData(IReadOnlyList data) { - for (int i = 0; i < Tiles.Length; i++) + if (data.Count != Tiles.Length) + throw new ArgumentException("Snapshot size does not match board.", nameof(data)); + + for (var i = 0; i < Tiles.Length; i++) { - Tiles[i].CrystalType = (int)data[i]["type"]; - Tiles[i].SpecialType = (int)data[i]["special"]; - Tiles[i].IsEmpty = (bool)data[i]["empty"]; + 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; } } @@ -90,43 +102,39 @@ public void Clear() tile.Clear(); } - public int CountType(int pType) + public int CountType(int type) { - int count = 0; + var count = 0; foreach (var tile in Tiles) - { - if (!tile.IsEmpty && tile.CrystalType == pType) + if (!tile.IsEmpty && tile.CrystalType == type) count++; - } return count; } public int GetEmptyCount() { - int count = 0; + var count = 0; foreach (var tile in Tiles) - { if (tile.IsEmpty) count++; - } return count; } - public class CellData + public sealed class CellData { - public int CrystalType = -1; - public int SpecialType = -1; - public int Row = -1; - public int Col = -1; - public bool IsEmpty = true; - public bool IsLocked = false; - public int LockHp = 0; - - public CellData(int pType = -1, int pSpecial = -1) + 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 = pType; - SpecialType = pSpecial; - IsEmpty = pType < 0; + CrystalType = type; + SpecialType = special; + IsEmpty = type < 0; } public void Clear() @@ -136,43 +144,37 @@ public void Clear() IsEmpty = true; } - public void SetCrystal(int pType, int pSpecial = -1) + public void SetCrystal(int type, int special = -1) { - CrystalType = pType; - SpecialType = pSpecial; + CrystalType = type; + SpecialType = special; IsEmpty = false; } - public bool IsNormal() - { - return !IsEmpty && SpecialType == -1; - } - - public bool IsSpecial() - { - return !IsEmpty && SpecialType != -1; - } + public bool IsNormal() => !IsEmpty && SpecialType == -1; + public bool IsSpecial() => !IsEmpty && SpecialType != -1; public override string ToString() { - if (IsEmpty) - return "EMPTY"; - string s = CrystalType.ToString(); - if (SpecialType == 0) - s += "B"; - else if (SpecialType == 1) - s += "R"; - else if (SpecialType == 2) - s += "C"; - return s; + if (IsEmpty) return "EMPTY"; + var suffix = SpecialType switch { 0 => "B", 1 => "R", 2 => "C", _ => "" }; + return CrystalType + suffix; } } - public class MoveRecord + public sealed record CellSnapshot( + int CrystalType, + int SpecialType, + bool IsEmpty, + bool IsLocked, + int LockHp + ); + + public sealed class MoveRecord { - public Vector2I From; - public Vector2I To; - public Godot.Collections.Array Snapshot; - public int ScoreGained = 0; + public GridPos From { get; init; } + public GridPos To { get; init; } + public CellSnapshot[] Snapshot { get; init; } = Array.Empty(); + public int ScoreGained { get; init; } } } From 492357b1a3ab9c35e6b471eed08dc32a33e34810 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:07:38 -0600 Subject: [PATCH 107/142] refactor(whiskerfolk): make MatchResult.cs engine-agnostic --- .../godot/scripts/core/puzzle/MatchResult.cs | 52 ++++++++----------- 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/MatchResult.cs b/projects/kitty-crush/godot/scripts/core/puzzle/MatchResult.cs index 31a37db..4706e9a 100644 --- a/projects/kitty-crush/godot/scripts/core/puzzle/MatchResult.cs +++ b/projects/kitty-crush/godot/scripts/core/puzzle/MatchResult.cs @@ -1,51 +1,41 @@ -using Godot; using System.Collections.Generic; namespace Whiskerfolk.Puzzle; -public partial class MatchResult +public sealed class MatchResult { - public List Groups = new(); - public byte[] MatchedFlags = new byte[0]; - public List SpecialSpawns = new(); - public int TotalMatched = 0; + 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() - { - return TotalMatched > 0; - } + public bool HasMatches() => TotalMatched > 0; - public List GetAllPositions() + public List GetAllPositions() { - var all = new List(); + var all = new List(); foreach (var group in Groups) all.AddRange(group.Positions); return all; } - public class MatchGroup + public sealed class MatchGroup { - public int Shape = 0; - public List Positions = new(); - public Vector2I Pivot = new(-1, -1); - public int MatchLength = 0; - public int CrystalType = -1; - - public int Size() - { - return Positions.Count; - } + 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 class SpecialSpawn + public sealed class SpecialSpawn { - public Vector2I Position = new(); - public int SpecialType = -1; - public int CrystalType = -1; + public GridPos Position { get; set; } + public int SpecialType { get; set; } = -1; + public int CrystalType { get; set; } = -1; - public override string ToString() - { - return $"SpecialSpawn(type={SpecialType}, pos={Position})"; - } + public override string ToString() => + $"SpecialSpawn(type={SpecialType}, pos={Position})"; } } From 4e73c2bf109bf20a8b3172de8d5e528bb985c9b7 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:07:41 -0600 Subject: [PATCH 108/142] refactor(whiskerfolk): make GravitySystem.cs engine-agnostic --- .../scripts/core/puzzle/GravitySystem.cs | 61 ++++++++----------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/GravitySystem.cs b/projects/kitty-crush/godot/scripts/core/puzzle/GravitySystem.cs index fbe51de..ea83647 100644 --- a/projects/kitty-crush/godot/scripts/core/puzzle/GravitySystem.cs +++ b/projects/kitty-crush/godot/scripts/core/puzzle/GravitySystem.cs @@ -1,9 +1,8 @@ -using Godot; using System.Collections.Generic; namespace Whiskerfolk.Puzzle; -public class GravitySystem +public static class GravitySystem { private const float FallBase = 0.1f; private const float FallPerRow = 0.08f; @@ -11,34 +10,31 @@ public class GravitySystem public static List ApplyGravity(BoardData board) { var allFalls = new List(); - for (int col = 0; col < board.Cols; col++) - { - var falls = ProcessColumn(board, col); - allFalls.AddRange(falls); - } + 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(); - int writeRow = board.Rows - 1; + var writeRow = board.Rows - 1; - for (int readRow = board.Rows - 1; readRow >= 0; readRow--) + for (var readRow = board.Rows - 1; readRow >= 0; readRow--) { var tile = board.GetTile(readRow, col); - if (tile.IsEmpty) - continue; + if (tile.IsEmpty) continue; if (readRow != writeRow) { - var info = new FallInfo(); - info.FromRow = readRow; - info.ToRow = writeRow; - info.Col = col; - info.CrystalType = tile.CrystalType; - info.SpecialType = tile.SpecialType; - falls.Add(info); + falls.Add(new FallInfo + { + FromRow = readRow, + ToRow = writeRow, + Col = col, + CrystalType = tile.CrystalType, + SpecialType = tile.SpecialType + }); board.Swap(readRow, col, writeRow, col); } @@ -46,30 +42,21 @@ private static List ProcessColumn(BoardData board, int col) writeRow--; } - for (int row = writeRow; row >= 0; row--) - { + for (var row = writeRow; row >= 0; row--) board.GetTile(row, col).Clear(); - } return falls; } - public class FallInfo + public sealed class FallInfo { - public int FromRow; - public int ToRow; - public int Col; - public int CrystalType = -1; - public int SpecialType = -1; - - public int GetDistance() - { - return ToRow - FromRow; - } - - public float GetDuration() - { - return FallBase + (float)GetDistance() * FallPerRow; - } + 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; } } From 80a4b79a7ab928a9a16eb6f6f4e71270892e2c32 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:07:43 -0600 Subject: [PATCH 109/142] refactor(whiskerfolk): make SpawnSystem.cs engine-agnostic --- .../godot/scripts/core/puzzle/SpawnSystem.cs | 51 ++++++++----------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/SpawnSystem.cs b/projects/kitty-crush/godot/scripts/core/puzzle/SpawnSystem.cs index 75625f0..2a97751 100644 --- a/projects/kitty-crush/godot/scripts/core/puzzle/SpawnSystem.cs +++ b/projects/kitty-crush/godot/scripts/core/puzzle/SpawnSystem.cs @@ -1,48 +1,39 @@ -using Godot; +using System; using System.Collections.Generic; namespace Whiskerfolk.Puzzle; -public class SpawnSystem +public static class SpawnSystem { - public static List FillEmpty(BoardData board) + public static List FillEmpty(BoardData board, Random? random = null) { + random ??= Random.Shared; var spawns = new List(); - var rng = new RandomNumberGenerator(); - rng.Randomize(); - for (int col = 0; col < board.Cols; col++) + for (var col = 0; col < board.Cols; col++) + for (var row = 0; row < board.Rows; row++) { - for (int row = 0; row < board.Rows; row++) - { - var tile = board.GetTile(row, col); - if (!tile.IsEmpty) - continue; - - int crystalType = rng.RandiRange(0, 4); + var tile = board.GetTile(row, col); + if (!tile.IsEmpty) continue; - var info = new SpawnInfo(); - info.Row = row; - info.Col = col; - info.CrystalType = crystalType; - spawns.Add(info); - - tile.SetCrystal(crystalType); - } + var crystalType = random.Next(board.NumCrystalTypes); + spawns.Add(new SpawnInfo + { + Row = row, + Col = col, + CrystalType = crystalType + }); + tile.SetCrystal(crystalType); } return spawns; } - public class SpawnInfo + public sealed class SpawnInfo { - public int Row; - public int Col; - public int CrystalType = -1; - - public Vector2 GetEnterOffset() - { - return new Vector2(0, -(Row + 1) * 76.0f); - } + public int Row { get; init; } + public int Col { get; init; } + public int CrystalType { get; init; } = -1; + public float GetEnterOffsetY() => -(Row + 1) * 76.0f; } } From da14f08f7c0a48037bd2e9f2ff9369ac1f75b037 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:07:46 -0600 Subject: [PATCH 110/142] refactor(whiskerfolk): make MatchDetector.cs engine-agnostic --- .../scripts/core/puzzle/MatchDetector.cs | 375 ++++++------------ 1 file changed, 122 insertions(+), 253 deletions(-) diff --git a/projects/kitty-crush/godot/scripts/core/puzzle/MatchDetector.cs b/projects/kitty-crush/godot/scripts/core/puzzle/MatchDetector.cs index fcb9d40..5d28d6a 100644 --- a/projects/kitty-crush/godot/scripts/core/puzzle/MatchDetector.cs +++ b/projects/kitty-crush/godot/scripts/core/puzzle/MatchDetector.cs @@ -1,71 +1,56 @@ -using Godot; +using System; using System.Collections.Generic; +using System.Linq; namespace Whiskerfolk.Puzzle; -public class MatchDetector +public static class MatchDetector { - private static readonly Vector2I[] DIRECTIONS = new[] + private static readonly GridPos[] Directions = { - new Vector2I(0, -1), new Vector2I(1, 0), new Vector2I(0, 1), new Vector2I(-1, 0) + new(0, -1), + new(1, 0), + new(0, 1), + new(-1, 0) }; public static MatchResult DetectAll(BoardData board) { - var result = new MatchResult(); - int size = board.Cols * board.Rows; - result.MatchedFlags = new byte[size]; - - var groups = new List(); - groups.AddRange(DetectHorizontal(board)); - groups.AddRange(DetectVertical(board)); - - foreach (var g in groups) + var result = new MatchResult { - foreach (var pos in g.Positions) - { - int idx = board.GetIndex(pos.Y, pos.X); - result.MatchedFlags[idx] = 1; - } - } + MatchedFlags = new byte[board.Cols * board.Rows] + }; - var visited = new byte[size]; - var finalGroups = new List(); + MarkHorizontal(board, result.MatchedFlags); + MarkVertical(board, result.MatchedFlags); - for (int i = 0; i < size; i++) + var visited = new byte[result.MatchedFlags.Length]; + + for (var i = 0; i < result.MatchedFlags.Length; i++) { - if (result.MatchedFlags[i] == 1 && visited[i] == 0) - { - var region = FloodFill(result.MatchedFlags, visited, i, board); - if (region.Count >= 3) - { - var group = ClassifyShape(region, board); - finalGroups.Add(group); - } - } - } + if (result.MatchedFlags[i] == 0 || visited[i] != 0) + continue; - result.Groups = finalGroups; - result.TotalMatched = CountMatched(result.MatchedFlags); + var region = FloodFill(board, result.MatchedFlags, visited, i); + if (region.Count < 3) continue; - foreach (var group in finalGroups) - { - var spawn = DetermineSpecial(group); - if (spawn.SpecialType != -1) - { - result.SpecialSpawns.Add(spawn); - } + 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 List DetectHorizontal(BoardData board) + private static void MarkHorizontal(BoardData board, byte[] flags) { - var groups = new List(); - for (int row = 0; row < board.Rows; row++) + for (var row = 0; row < board.Rows; row++) { - int col = 0; + var col = 0; while (col < board.Cols) { var tile = board.GetTile(row, col); @@ -74,40 +59,28 @@ public static MatchResult DetectAll(BoardData board) col++; continue; } - int runType = tile.CrystalType; - int runStart = col; - col++; + + var type = tile.CrystalType; + var start = col++; while (col < board.Cols) { - var nextTile = board.GetTile(row, col); - if (nextTile.IsEmpty || nextTile.CrystalType != runType) - break; + var next = board.GetTile(row, col); + if (next.IsEmpty || next.CrystalType != type) break; col++; } - int runLength = col - runStart; - if (runLength >= 3) - { - var group = new MatchResult.MatchGroup(); - group.Shape = 0; - group.CrystalType = runType; - group.MatchLength = runLength; - for (int c = runStart; c < runStart + runLength; c++) - { - group.Positions.Add(new Vector2I(c, row)); - } - groups.Add(group); - } + + if (col - start < 3) continue; + for (var c = start; c < col; c++) + flags[board.GetIndex(row, c)] = 1; } } - return groups; } - private static List DetectVertical(BoardData board) + private static void MarkVertical(BoardData board, byte[] flags) { - var groups = new List(); - for (int col = 0; col < board.Cols; col++) + for (var col = 0; col < board.Cols; col++) { - int row = 0; + var row = 0; while (row < board.Rows) { var tile = board.GetTile(row, col); @@ -116,235 +89,131 @@ public static MatchResult DetectAll(BoardData board) row++; continue; } - int runType = tile.CrystalType; - int runStart = row; - row++; + + var type = tile.CrystalType; + var start = row++; while (row < board.Rows) { - var nextTile = board.GetTile(row, col); - if (nextTile.IsEmpty || nextTile.CrystalType != runType) - break; + var next = board.GetTile(row, col); + if (next.IsEmpty || next.CrystalType != type) break; row++; } - int runLength = row - runStart; - if (runLength >= 3) - { - var group = new MatchResult.MatchGroup(); - group.Shape = 1; - group.CrystalType = runType; - group.MatchLength = runLength; - for (int r = runStart; r < runStart + runLength; r++) - { - group.Positions.Add(new Vector2I(col, r)); - } - groups.Add(group); - } + + if (row - start < 3) continue; + for (var r = start; r < row; r++) + flags[board.GetIndex(r, col)] = 1; } } - return groups; } - private static List FloodFill(byte[] flags, byte[] visited, int startIdx, BoardData board) + private static List FloodFill( + BoardData board, + byte[] flags, + byte[] visited, + int startIndex) { - var region = new List(); - var stack = new List { startIdx }; - visited[startIdx] = 1; + var region = new List(); + var stack = new Stack(); + stack.Push(startIndex); + visited[startIndex] = 1; - int crystalType = board.Tiles[startIdx].CrystalType; + var type = board.Tiles[startIndex].CrystalType; while (stack.Count > 0) { - int currentIdx = stack[stack.Count - 1]; - stack.RemoveAt(stack.Count - 1); - var pos = board.RowCol(currentIdx); + var index = stack.Pop(); + var pos = board.RowCol(index); region.Add(pos); - foreach (var dir in DIRECTIONS) + foreach (var direction in Directions) { - int nCol = pos.X + dir.X; - int nRow = pos.Y + dir.Y; - if (!board.IsInBounds(nRow, nCol)) - continue; - int nIdx = board.GetIndex(nRow, nCol); - if (visited[nIdx] == 1) - continue; - if (flags[nIdx] == 0) - continue; - if (board.Tiles[nIdx].CrystalType != crystalType) - continue; - visited[nIdx] = 1; - stack.Add(nIdx); + 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 ClassifyShape(List region, BoardData board) + private static MatchResult.MatchGroup Classify(List region, BoardData board) { - var group = new MatchResult.MatchGroup(); - group.Positions = region; - - if (region.Count < 3) - return group; - - var firstPos = region[0]; - group.CrystalType = board.GetTile(firstPos.Y, firstPos.X).CrystalType; - - int minCol = int.MaxValue; - int maxCol = -1; - int minRow = int.MaxValue; - int maxRow = -1; - - var rowCounts = new Dictionary(); - var colCounts = new Dictionary(); - - foreach (var pos in region) + var group = new MatchResult.MatchGroup { - minCol = Mathf.Min(minCol, pos.X); - maxCol = Mathf.Max(maxCol, pos.X); - minRow = Mathf.Min(minRow, pos.Y); - maxRow = Mathf.Max(maxRow, pos.Y); - rowCounts[pos.Y] = rowCounts.GetValueOrDefault(pos.Y, 0) + 1; - colCounts[pos.X] = colCounts.GetValueOrDefault(pos.X, 0) + 1; - } + Positions = region, + CrystalType = board.GetTile(region[0].Y, region[0].X).CrystalType + }; - int colSpan = maxCol - minCol + 1; - int rowSpan = maxRow - minRow + 1; - int numRowsUsed = rowCounts.Keys.Count; - int numColsUsed = colCounts.Keys.Count; + 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()); - int maxRowCount = 0; - int maxRowKey = -1; - foreach (var key in rowCounts.Keys) + if (rowGroups.Count == 1) { - if (rowCounts[key] > maxRowCount) - { - maxRowCount = rowCounts[key]; - maxRowKey = key; - } + group.Shape = (int)MatchShape.Horizontal; + group.MatchLength = region.Count; + group.Pivot = region.OrderBy(p => p.X).ElementAt(region.Count / 2); + return group; } - int maxColCount = 0; - int maxColKey = -1; - foreach (var key in colCounts.Keys) + if (colGroups.Count == 1) { - if (colCounts[key] > maxColCount) - { - maxColCount = colCounts[key]; - maxColKey = key; - } + group.Shape = (int)MatchShape.Vertical; + group.MatchLength = region.Count; + group.Pivot = region.OrderBy(p => p.Y).ElementAt(region.Count / 2); + return group; } - int rowsWith3 = 0; - foreach (var key in rowCounts.Keys) - { - if (rowCounts[key] >= 3) - rowsWith3++; - } + var pivot = region.FirstOrDefault(p => + rowGroups.TryGetValue(p.Y, out var rowCount) && rowCount >= 3 && + colGroups.TryGetValue(p.X, out var colCount) && colCount >= 3); - int colsWith3 = 0; - foreach (var key in colCounts.Keys) - { - if (colCounts[key] >= 3) - colsWith3++; - } + group.Pivot = pivot == default && !region.Contains(default) ? region[0] : pivot; - bool isHLine = rowSpan == 1 && colSpan >= 3; - bool isVLine = colSpan == 1 && rowSpan >= 3; + 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); - if (isHLine) - { - group.Shape = 0; - group.MatchLength = colSpan; - group.Pivot = new Vector2I(minCol + colSpan / 2, minRow); - } - else if (isVLine) - { - group.Shape = 1; - group.MatchLength = rowSpan; - group.Pivot = new Vector2I(minCol, minRow + rowSpan / 2); - } - else if (rowsWith3 >= 2 && colsWith3 >= 2) - { - group.Shape = 4; - group.Pivot = new Vector2I(maxColKey, maxRowKey); - } - else if (rowsWith3 >= 2) - { - group.Shape = 2; - group.Pivot = new Vector2I(maxColKey, maxRowKey); - } - else if (colsWith3 >= 2) - { - group.Shape = 2; - group.Pivot = new Vector2I(maxColKey, maxRowKey); - } - else if (numRowsUsed >= 2 && numColsUsed >= 2) - { - if (maxRowCount >= 3 && maxColCount >= 3) - { - group.Shape = 3; - group.Pivot = new Vector2I(maxColKey, maxRowKey); - } - else - { - group.Shape = 2; - group.Pivot = new Vector2I(maxColKey, maxRowKey); - } - } - else + group.Shape = directions switch { - group.Shape = 0; - group.MatchLength = region.Count; - } - + 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(); - spawn.Position = group.Pivot; - spawn.CrystalType = group.CrystalType; - - int length = group.MatchLength; + var spawn = new MatchResult.SpecialSpawn + { + Position = group.Pivot, + CrystalType = group.CrystalType, + SpecialType = (int)SpecialType.None + }; - switch (group.Shape) + if (group.Shape is (int)MatchShape.Horizontal or (int)MatchShape.Vertical) { - case 0: - case 1: - if (length >= 5) - spawn.SpecialType = 1; - else if (length >= 4) - spawn.SpecialType = 0; - else - spawn.SpecialType = -1; - break; - case 2: - case 3: - case 4: - if (group.Positions.Count >= 5) - spawn.SpecialType = 2; - else - spawn.SpecialType = -1; - break; - default: - spawn.SpecialType = -1; - break; + if (group.MatchLength >= 5) + spawn.SpecialType = (int)SpecialType.Rainbow; + else if (group.MatchLength >= 4) + spawn.SpecialType = (int)SpecialType.Bomb; + + return spawn; } - return spawn; - } + if (group.Positions.Count >= 5) + spawn.SpecialType = (int)SpecialType.Cross; - private static int CountMatched(byte[] flags) - { - int count = 0; - for (int i = 0; i < flags.Length; i++) - { - if (flags[i] == 1) - count++; - } - return count; + return spawn; } } From 47619d3bdacebf8b8a0a6c0eb5e6d143c3fde736 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:08:04 -0600 Subject: [PATCH 111/142] test(whiskerfolk): remove Godot dependency from BoardDataTests.cs --- projects/kitty-crush/godot/tests/BoardDataTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/projects/kitty-crush/godot/tests/BoardDataTests.cs b/projects/kitty-crush/godot/tests/BoardDataTests.cs index efd4c1a..eb82ca7 100644 --- a/projects/kitty-crush/godot/tests/BoardDataTests.cs +++ b/projects/kitty-crush/godot/tests/BoardDataTests.cs @@ -1,4 +1,3 @@ -using Godot; using Xunit; using Whiskerfolk.Puzzle; @@ -15,7 +14,7 @@ public void TestIndexConversion() Assert.Equal(63, board.GetIndex(7, 7)); Assert.Equal(29, board.GetIndex(3, 5)); var rc = board.RowCol(29); - Assert.Equal(new Vector2I(5, 3), rc); // (col=5, row=3) + Assert.Equal(new GridPos(5, 3), rc); // (col=5, row=3) } [Fact] From 1249895eb98e630d1fa8aab86c7c9aaab3cf65b2 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:08:08 -0600 Subject: [PATCH 112/142] test(whiskerfolk): remove Godot dependency from BoardGenerationTests.cs --- projects/kitty-crush/godot/tests/BoardGenerationTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/kitty-crush/godot/tests/BoardGenerationTests.cs b/projects/kitty-crush/godot/tests/BoardGenerationTests.cs index 952b371..90732bc 100644 --- a/projects/kitty-crush/godot/tests/BoardGenerationTests.cs +++ b/projects/kitty-crush/godot/tests/BoardGenerationTests.cs @@ -1,4 +1,3 @@ -using Godot; using Xunit; using Whiskerfolk.Puzzle; From c1bea1129da0818a186db9208a226f55704509d6 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:08:11 -0600 Subject: [PATCH 113/142] test(whiskerfolk): remove Godot dependency from MatchDetectorTests.cs --- projects/kitty-crush/godot/tests/MatchDetectorTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/kitty-crush/godot/tests/MatchDetectorTests.cs b/projects/kitty-crush/godot/tests/MatchDetectorTests.cs index ace875b..9765adf 100644 --- a/projects/kitty-crush/godot/tests/MatchDetectorTests.cs +++ b/projects/kitty-crush/godot/tests/MatchDetectorTests.cs @@ -1,4 +1,3 @@ -using Godot; using Xunit; using Whiskerfolk.Puzzle; From fb7037d19f3b97845133cd5c08dbb3c2bd049ece Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:08:14 -0600 Subject: [PATCH 114/142] test(whiskerfolk): remove Godot dependency from GravitySystemTests.cs --- projects/kitty-crush/godot/tests/GravitySystemTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/kitty-crush/godot/tests/GravitySystemTests.cs b/projects/kitty-crush/godot/tests/GravitySystemTests.cs index 0c664f0..fb50c24 100644 --- a/projects/kitty-crush/godot/tests/GravitySystemTests.cs +++ b/projects/kitty-crush/godot/tests/GravitySystemTests.cs @@ -1,4 +1,3 @@ -using Godot; using Xunit; using Whiskerfolk.Puzzle; From beab002b803bedacb7b38b8b8fa0329f5e72c07e Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:08:16 -0600 Subject: [PATCH 115/142] test(whiskerfolk): remove Godot dependency from ScoreCalculatorTests.cs --- projects/kitty-crush/godot/tests/ScoreCalculatorTests.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/projects/kitty-crush/godot/tests/ScoreCalculatorTests.cs b/projects/kitty-crush/godot/tests/ScoreCalculatorTests.cs index 3a7c89c..32d0c50 100644 --- a/projects/kitty-crush/godot/tests/ScoreCalculatorTests.cs +++ b/projects/kitty-crush/godot/tests/ScoreCalculatorTests.cs @@ -1,4 +1,3 @@ -using Godot; using Xunit; using Whiskerfolk.Puzzle; @@ -16,9 +15,9 @@ public void TestLine3Score() MatchLength = 3, Positions = new() { - new Vector2I(0, 0), - new Vector2I(1, 0), - new Vector2I(2, 0), + new GridPos(0, 0), + new GridPos(1, 0), + new GridPos(2, 0), }, }; Assert.Equal(30, ScoreCalculator.CalculateGroupScore(group)); From 5807ed4836c157725874abb6b34bcc287f1161ad Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:08:19 -0600 Subject: [PATCH 116/142] test(whiskerfolk): remove Godot dependency from SpecialTilesTests.cs --- projects/kitty-crush/godot/tests/SpecialTilesTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/kitty-crush/godot/tests/SpecialTilesTests.cs b/projects/kitty-crush/godot/tests/SpecialTilesTests.cs index 037e03a..d05742c 100644 --- a/projects/kitty-crush/godot/tests/SpecialTilesTests.cs +++ b/projects/kitty-crush/godot/tests/SpecialTilesTests.cs @@ -1,4 +1,3 @@ -using Godot; using Xunit; using Whiskerfolk.Puzzle; From 23ec1870961fe1708aec6078012c4671bd0bbaaa Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:08:22 -0600 Subject: [PATCH 117/142] test(whiskerfolk): remove Godot dependency from ValidMoveCheckerTests.cs --- projects/kitty-crush/godot/tests/ValidMoveCheckerTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/kitty-crush/godot/tests/ValidMoveCheckerTests.cs b/projects/kitty-crush/godot/tests/ValidMoveCheckerTests.cs index 4a26819..29ae44b 100644 --- a/projects/kitty-crush/godot/tests/ValidMoveCheckerTests.cs +++ b/projects/kitty-crush/godot/tests/ValidMoveCheckerTests.cs @@ -1,4 +1,3 @@ -using Godot; using Xunit; using Whiskerfolk.Puzzle; From dd8948839eece54d4078655f936d9cda7419c396 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:08:25 -0600 Subject: [PATCH 118/142] test(whiskerfolk): remove Godot dependency from EdgeCasesTests.cs --- projects/kitty-crush/godot/tests/EdgeCasesTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/kitty-crush/godot/tests/EdgeCasesTests.cs b/projects/kitty-crush/godot/tests/EdgeCasesTests.cs index 5518f54..67c873a 100644 --- a/projects/kitty-crush/godot/tests/EdgeCasesTests.cs +++ b/projects/kitty-crush/godot/tests/EdgeCasesTests.cs @@ -1,4 +1,3 @@ -using Godot; using Xunit; using Whiskerfolk.Puzzle; From 486f43211e0f9011455234a0f7ba7c66f470a103 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:08:27 -0600 Subject: [PATCH 119/142] test(whiskerfolk): remove Godot dependency from ReshuffleTests.cs --- projects/kitty-crush/godot/tests/ReshuffleTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/kitty-crush/godot/tests/ReshuffleTests.cs b/projects/kitty-crush/godot/tests/ReshuffleTests.cs index 8d7a255..82f9033 100644 --- a/projects/kitty-crush/godot/tests/ReshuffleTests.cs +++ b/projects/kitty-crush/godot/tests/ReshuffleTests.cs @@ -1,4 +1,3 @@ -using Godot; using Xunit; using Whiskerfolk.Puzzle; From c0762608bc4113de8153f12048c072c68d9f5454 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:08:30 -0600 Subject: [PATCH 120/142] test(whiskerfolk): remove Godot dependency from SwapClearCascadeTests.cs --- projects/kitty-crush/godot/tests/SwapClearCascadeTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/kitty-crush/godot/tests/SwapClearCascadeTests.cs b/projects/kitty-crush/godot/tests/SwapClearCascadeTests.cs index 5247d5a..ff3eb45 100644 --- a/projects/kitty-crush/godot/tests/SwapClearCascadeTests.cs +++ b/projects/kitty-crush/godot/tests/SwapClearCascadeTests.cs @@ -1,4 +1,3 @@ -using Godot; using Xunit; using Whiskerfolk.Puzzle; From 66229444adc863046fad711e7864918f6b334f22 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:09:10 -0600 Subject: [PATCH 121/142] test(whiskerfolk): make board generation deterministic pure .NET --- .../godot/tests/BoardGenerationTests.cs | 67 ++++++++----------- 1 file changed, 29 insertions(+), 38 deletions(-) diff --git a/projects/kitty-crush/godot/tests/BoardGenerationTests.cs b/projects/kitty-crush/godot/tests/BoardGenerationTests.cs index 90732bc..e3077e5 100644 --- a/projects/kitty-crush/godot/tests/BoardGenerationTests.cs +++ b/projects/kitty-crush/godot/tests/BoardGenerationTests.cs @@ -1,5 +1,6 @@ +using System; +using System.Collections.Generic; using Xunit; - using Whiskerfolk.Puzzle; namespace Whiskerfolk.Tests; @@ -9,10 +10,10 @@ public class BoardGenerationTests [Fact] public void TestGenerateNoInitialMatches() { - for (int i = 0; i < 20; i++) + for (var i = 0; i < 20; i++) { var board = new BoardData(8, 8); - GenerateNoMatch(board); + GenerateNoMatch(board, new Random(100 + i)); var result = MatchDetector.DetectAll(board); Assert.False(result.HasMatches(), "Generated board should have no initial matches"); } @@ -21,53 +22,43 @@ public void TestGenerateNoInitialMatches() [Fact] public void TestAllCellsFilled() { - for (int i = 0; i < 10; i++) + for (var i = 0; i < 10; i++) { var board = new BoardData(8, 8); - GenerateNoMatch(board); + GenerateNoMatch(board, new Random(200 + i)); Assert.Equal(0, board.GetEmptyCount()); } } - private static void GenerateNoMatch(BoardData board) + private static void GenerateNoMatch(BoardData board, Random random) { - var rng = new Godot.RandomNumberGenerator(); - rng.Randomize(); - for (int row = 0; row < board.Rows; row++) + for (var row = 0; row < board.Rows; row++) + for (var col = 0; col < board.Cols; col++) { - for (int col = 0; col < board.Cols; col++) - { - var tile = board.GetTile(row, col); - var forbidden = new Godot.Collections.Array(); + 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 (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); - } + 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 Godot.Collections.Array(); - for (int t = 0; t < 5; t++) - { - if (!forbidden.Contains(t)) - allowed.Add(t); - } + var allowed = new List(); + for (var type = 0; type < board.NumCrystalTypes; type++) + if (!forbidden.Contains(type)) + allowed.Add(type); - if (allowed.Count == 0) - tile.SetCrystal(rng.RandiRange(0, 4)); - else - tile.SetCrystal(allowed[rng.RandiRange(0, allowed.Count - 1)]); - } + board.GetTile(row, col).SetCrystal(allowed[random.Next(allowed.Count)]); } } } From 3da0f52469be9a4885957c4ebcd6290011c39561 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:09:12 -0600 Subject: [PATCH 122/142] test(whiskerfolk): replace Godot shuffle with deterministic .NET shuffle --- .../kitty-crush/godot/tests/ReshuffleTests.cs | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/projects/kitty-crush/godot/tests/ReshuffleTests.cs b/projects/kitty-crush/godot/tests/ReshuffleTests.cs index 82f9033..998a2f5 100644 --- a/projects/kitty-crush/godot/tests/ReshuffleTests.cs +++ b/projects/kitty-crush/godot/tests/ReshuffleTests.cs @@ -1,5 +1,6 @@ +using System; +using System.Collections.Generic; using Xunit; - using Whiskerfolk.Puzzle; namespace Whiskerfolk.Tests; @@ -7,29 +8,32 @@ namespace Whiskerfolk.Tests; public class ReshuffleTests { [Fact] - public void TestReshuffleProducesValidMoves() + public void TestReshuffleCanBePerformedWithoutEngineCollections() { 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) % 2 == 0 ? 0 : 1); + for (var row = 0; row < 8; row++) + for (var col = 0; col < 8; col++) + board.GetTile(row, col).SetCrystal((row + col) % 5); - bool hasMoves = ValidMoveChecker.HasAnyValidMove(board); + var values = new List(); + foreach (var tile in board.Tiles) + values.Add(tile.CrystalType); - if (!hasMoves) - { - var types = new Godot.Collections.Array(); - for (int i = 0; i < 64; i++) - { - var tile = board.Tiles[i]; - types.Add(tile.CrystalType); - } - types.Shuffle(); - for (int i = 0; i < 64; i++) - board.Tiles[i].SetCrystal(types[i]); + Shuffle(values, new Random(42)); - var result = MatchDetector.DetectAll(board); - Assert.False(result.HasMatches(), "Reshuffled board should have no initial matches"); + 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]); } } } From 2b4fb68b0753570fd6d33426cd5e5f211af289f0 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:11:29 -0600 Subject: [PATCH 123/142] refactor(whiskerfolk): extract engine-agnostic core project --- .../godot/core/Whiskerfolk.Core.csproj | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 projects/kitty-crush/godot/core/Whiskerfolk.Core.csproj 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 + + + + + + + + + + + + + + From 9a263ebfe469b77470b3ad6434bbd7d12b567fb0 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:11:32 -0600 Subject: [PATCH 124/142] refactor(whiskerfolk): make Godot runtime consume core library --- projects/kitty-crush/godot/Whiskerfolk.csproj | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/projects/kitty-crush/godot/Whiskerfolk.csproj b/projects/kitty-crush/godot/Whiskerfolk.csproj index 709c8d2..96c1b61 100644 --- a/projects/kitty-crush/godot/Whiskerfolk.csproj +++ b/projects/kitty-crush/godot/Whiskerfolk.csproj @@ -10,5 +10,18 @@ + + + + + + + + + + + + + From e4f77187d092eb8dafe6780202e8b96507339774 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:11:34 -0600 Subject: [PATCH 125/142] test(whiskerfolk): run domain tests against pure core library --- projects/kitty-crush/godot/tests/Whiskerfolk.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/kitty-crush/godot/tests/Whiskerfolk.Tests.csproj b/projects/kitty-crush/godot/tests/Whiskerfolk.Tests.csproj index 31b96c7..1ac097b 100644 --- a/projects/kitty-crush/godot/tests/Whiskerfolk.Tests.csproj +++ b/projects/kitty-crush/godot/tests/Whiskerfolk.Tests.csproj @@ -20,6 +20,6 @@ - + From 4e631a9f903b7bf18e428bed64cf2db7022ca013 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:12:33 -0600 Subject: [PATCH 126/142] test(whiskerfolk): add ProductContractTests contract tests --- .../godot/tests/ProductContractTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/ProductContractTests.cs 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"); + } +} From d4eecedbb9ea4b06059a6eaaee1d4c246a288b20 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:12:35 -0600 Subject: [PATCH 127/142] test(whiskerfolk): add EconomyTests contract tests --- .../kitty-crush/godot/tests/EconomyTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/EconomyTests.cs 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)); + } +} From 9c3ac17cd67b7268f78e94fa8455632451f468ab Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:12:37 -0600 Subject: [PATCH 128/142] test(whiskerfolk): add FullRescueArcTests contract tests --- .../godot/tests/FullRescueArcTests.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/FullRescueArcTests.cs 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); + } +} From 2599f4aaaabbe1203f079f1f43bb3e49cdf327e2 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:12:39 -0600 Subject: [PATCH 129/142] test(whiskerfolk): add HomeBehaviorTests contract tests --- .../godot/tests/HomeBehaviorTests.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 projects/kitty-crush/godot/tests/HomeBehaviorTests.cs 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); + } +} From 7631294e1736278ef12545028418d49b98b75f67 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:13:18 -0600 Subject: [PATCH 130/142] ci(whiskerfolk): harden static contracts and cancel stale runs --- .github/workflows/whiskerfolk-ci.yml | 42 ++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/.github/workflows/whiskerfolk-ci.yml b/.github/workflows/whiskerfolk-ci.yml index c81027c..a056a50 100644 --- a/.github/workflows/whiskerfolk-ci.yml +++ b/.github/workflows/whiskerfolk-ci.yml @@ -5,18 +5,48 @@ on: branches: - kitty-crush-preproduction paths: - - 'projects/kitty-crush/godot/**' + - 'projects/kitty-crush/**' - '.github/workflows/whiskerfolk-ci.yml' pull_request: branches: - main paths: - - 'projects/kitty-crush/godot/**' + - '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 + Tests + name: Build + Core Tests runs-on: ubuntu-latest defaults: run: @@ -31,11 +61,11 @@ jobs: with: dotnet-version: '8.0.x' - - name: Restore + - name: Restore tests run: dotnet restore tests/Whiskerfolk.Tests.csproj - - name: Build + - name: Build Godot runtime run: dotnet build Whiskerfolk.csproj --configuration Release - - name: Test + - name: Test engine-agnostic core run: dotnet test tests/Whiskerfolk.Tests.csproj --configuration Release --logger "console;verbosity=normal" From d58c10566cff041114d746541af9f5581a577732 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:14:00 -0600 Subject: [PATCH 131/142] fix(whiskerfolk): apply cat-specific object affinity in home scheduler --- .../godot/scripts/home/BehaviorScheduler.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/projects/kitty-crush/godot/scripts/home/BehaviorScheduler.cs b/projects/kitty-crush/godot/scripts/home/BehaviorScheduler.cs index 2eb3674..7ce6126 100644 --- a/projects/kitty-crush/godot/scripts/home/BehaviorScheduler.cs +++ b/projects/kitty-crush/godot/scripts/home/BehaviorScheduler.cs @@ -26,12 +26,21 @@ public string SelectHomeBehavior( 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}", - slot.BehaviorTags, - 0.28f, + tags, + 0.28f + objectBoost, obj.Id, state.CurrentObjectId == obj.Id ? 0.25f : 0f )); From 5d1b375baca38a47a5927af14d268369bf104631 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:16:24 -0600 Subject: [PATCH 132/142] feat(whiskerfolk): add living Home behavior and replay-safe Box Moment --- .../godot/scripts/app/WhiskerfolkBootstrap.cs | 329 +++++++++++++++--- 1 file changed, 277 insertions(+), 52 deletions(-) diff --git a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs index 2471cb6..2b5962c 100644 --- a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs +++ b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs @@ -55,6 +55,14 @@ public partial class WhiskerfolkBootstrap : Control 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); public override void _Ready() { @@ -113,31 +121,78 @@ private bool AdvanceStory(string completedBeatId) public override void _Process(double delta) { - if (!_holdingTrust || _trustBar is null) - return; + 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; - _holdProgress += (float)delta * 29f; - _bond.AddTrust((float)delta * 25f); - _trustBar.Value = _holdProgress; if (_mochi is not null) _mochi.Trust = _holdProgress; - if (_holdProgress > 36 && _trustCopy is not null) - _trustCopy.Text = "He came closer. Then backed away. Then came back."; - - if (_holdProgress >= 100) + if (_mochi is not null && _trustApproachPhase == 0 && _holdProgress >= 30f) { - _holdingTrust = false; - _bond.SetForStoryBeat(BondLevel.Testing); - _analytics.Track(AnalyticsEvents.FirstTrustResponse); - _haptics.Affection(); - AdvanceStory("first_trust"); - BuildShelterPuzzle(); + _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; + foreach (var child in GetChildren()) { RemoveChild(child); @@ -544,6 +599,7 @@ private void BuildTrust() ClearScreen(Night); _holdProgress = 8; _holdingTrust = false; + _trustApproachPhase = 0; var layer = SafeLayer(28, 34); var column = new VBoxContainer @@ -704,20 +760,11 @@ private void BuildHome() ClearScreen(new Color("#E9D8C0")); _bond.SetForStoryBeat(BondLevel.NewHome); + if (!returningHome && _rescue.Current.Id == "ride_home") + AdvanceStory("ride_home"); + if (!returningHome) - { _analytics.Track(AnalyticsEvents.BoxMomentStarted); - AdvanceStory("ride_home"); - 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(); - } else { // Repair older/partially written saves without replaying first-time analytics. @@ -735,10 +782,22 @@ private void BuildHome() layer.AddChild(column); column.AddChild(Label("HOME", 12, Moss, true)); - column.AddChild(Label(returningHome ? "Mochi is\nhome." : "Mochi lives\nhere now.", 46, Ink, true)); - column.AddChild(Label(returningHome - ? "He has already started choosing his favorite places." - : "No reward chest. No score screen. He picked the warm corner.", 16, SoftInk)); + 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 { @@ -750,6 +809,7 @@ private void BuildHome() var roomRoot = new Control(); room.AddChild(roomRoot); + _homeRoomRoot = roomRoot; var sofa = new PanelContainer { @@ -767,24 +827,42 @@ private void BuildHome() 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(246, 285), + Position = returningHome ? new Vector2(170, 300) : new Vector2(265, 300), Size = new Vector2(220, 220), Trust = 64, - Mood = "home", + Mood = returningHome ? "home" : "curious", Modulate = returningHome ? Colors.White : new Color(1,1,1,0) }; roomRoot.AddChild(_mochi); - if (!returningHome) + _homeObjects.Clear(); + _homeObjects.Add(HomeCatalog.CardboardBox); + _homeCatState = new CatState { CatId = "mochi" }; + _homeCatState.Bond.AddTrust(_bond.Trust); + + if (_save.OwnedHomeObjects.Contains(HomeCatalog.FoldedBlanket.Id)) { - var reveal = CreateTween(); - reveal.TweenInterval(0.8); - reveal.TweenProperty(_mochi, "modulate:a", 1.0f, 0.8); - reveal.TweenProperty(_mochi, "position", new Vector2(170, 300), 1.4) - .SetTrans(Tween.TransitionType.Cubic) - .SetEase(Tween.EaseType.Out); + AddBlanketVisual(roomRoot); + _homeObjects.Add(HomeCatalog.FoldedBlanket); } var actions = new HBoxContainer(); @@ -809,28 +887,175 @@ private void BuildHome() ); actions.AddChild(memory); - var blanket = Button("Place his blanket"); + 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"; - var cloth = new PanelContainer - { - Position = new Vector2(46, 410), - Size = new Vector2(170, 70) - }; - cloth.AddThemeStyleboxOverride("panel", Box(new Color("#D8B99A"), 28)); - roomRoot.AddChild(cloth); + 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) { - var tween = CreateTween(); - tween.TweenProperty(_mochi, "position", new Vector2(38, 335), 1.4) - .SetTrans(Tween.TransitionType.Cubic) - .SetEase(Tween.EaseType.InOut); + _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(); + + 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) From b2e65f8eb909ffaf149d16d08d4c467eb60bea0b Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:17:55 -0600 Subject: [PATCH 133/142] feat(whiskerfolk): add Mochi microexpressions and irregular idle motion --- .../godot/scripts/app/MochiActor.cs | 163 ++++++++++++------ 1 file changed, 115 insertions(+), 48 deletions(-) diff --git a/projects/kitty-crush/godot/scripts/app/MochiActor.cs b/projects/kitty-crush/godot/scripts/app/MochiActor.cs index 2ce0447..87b7ebf 100644 --- a/projects/kitty-crush/godot/scripts/app/MochiActor.cs +++ b/projects/kitty-crush/godot/scripts/app/MochiActor.cs @@ -1,4 +1,5 @@ using Godot; +using System; namespace Whiskerfolk.App; @@ -11,11 +12,16 @@ public partial class MochiActor : Control 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 bool _blink; 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; @@ -45,91 +51,152 @@ public override void _Ready() public override void _Process(double delta) { - _time += (float)delta; - _blink = Mathf.Sin(_time * 1.17f) > 0.985f; + 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 breath = Mathf.Sin(_time * 2.0f) * 2.3f; - var bodyCenter = new Vector2(112, 137 + breath * 0.35f); - var headCenter = new Vector2(105, 82 + breath * 0.22f); + 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); - // Tail: broad soft stroke + caramel tip. - DrawArc(new Vector2(144, 139), 53, -1.12f, 0.62f, 26, CreamShadow, 18, true); - DrawArc(new Vector2(144, 139), 53, 0.30f, 0.62f, 8, Caramel, 19, true); + var bodyCenter = new Vector2(112, 137 + crouch + breath * 0.35f); + var headCenter = H(105, 82 + breath * 0.22f); - // Body + chest. - DrawCircle(bodyCenter, 61, CreamShadow); - DrawCircle(bodyCenter + new Vector2(-8, -4), 55, Cream); + var tailEnergy = curious ? 0.16f : relaxed ? 0.05f : 0.09f; + var tailWave = Mathf.Sin(_time * (curious ? 2.1f : 1.15f)) * tailEnergy; - // Ears behind head. + // 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[] { new(48, 63), new(66, 18), new(89, 68) }, + new Vector2[] { H(48, 63), H(66, 18), H(89, 68) }, Cream ); DrawColoredPolygon( - new Vector2[] { new(123, 62), new(151, 17), new(161, 73) }, + new Vector2[] { H(123, 62), H(151 + twitch, 17 - twitch), H(161, 73) }, Caramel ); DrawColoredPolygon( - new Vector2[] { new(57, 57), new(67, 32), new(80, 61) }, + new Vector2[] { H(57, 57), H(67, 32), H(80, 61) }, new Color("#DDB9A7") ); DrawColoredPolygon( - new Vector2[] { new(133, 58), new(149, 31), new(153, 63) }, + new Vector2[] { H(133, 58), H(149 + twitch * 0.6f, 31 - twitch * 0.6f), H(153, 63) }, new Color("#D7A483") ); // Head. - DrawCircle(headCenter, 53, Cream); + DrawCircle(headCenter, hiding ? 51 : 53, Cream); // Caramel comma marking. - DrawCircle(new Vector2(137, 74), 13, Caramel); - DrawCircle(new Vector2(130, 64), 8, Cream); + DrawCircle(H(137, 74), 13, Caramel); + DrawCircle(H(130, 64), 8, Cream); - // Eyes. - var eyeHeight = _blink ? 1.5f : 8.5f; - DrawSetTransform(new Vector2(0, 0), 0, Vector2.One); - DrawEllipse(new Vector2(85, 87), new Vector2(6.5f, eyeHeight), Eye); - DrawEllipse(new Vector2(124, 87), new Vector2(6.5f, eyeHeight), Eye); + 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; - if (!_blink) + 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(new Vector2(83, 84), 2.0f, Colors.White); - DrawCircle(new Vector2(122, 84), 2.0f, Colors.White); + DrawCircle(H(83 + gaze, 84), 2.0f, Colors.White); + DrawCircle(H(122 + gaze, 84), 2.0f, Colors.White); } - // Nose and tiny mouth. - DrawCircle(new Vector2(105, 103), 4.8f, Nose); - DrawLine(new Vector2(105, 107), new Vector2(105, 112), Ink, 1.6f, true); - DrawArc(new Vector2(99, 111), 7, 0.1f, 1.1f, 8, Ink, 1.4f, true); - DrawArc(new Vector2(111, 111), 7, 2.0f, 3.0f, 8, Ink, 1.4f, true); + // 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(new Vector2(83, 104), new Vector2(48, 98), Ink, 1.1f, true); - DrawLine(new Vector2(82, 110), new Vector2(45, 111), Ink, 1.1f, true); - DrawLine(new Vector2(128, 104), new Vector2(164, 98), Ink, 1.1f, true); - DrawLine(new Vector2(128, 110), new Vector2(166, 112), Ink, 1.1f, true); - - // Front paws. Low trust keeps one paw pulled in. - DrawCircle(new Vector2(82, 181), 18, Cream); - if (_trust > 38 || _mood == "home") - DrawCircle(new Vector2(117, 181), 18, Cream); - - // Mood cue: slight question head-tilt is represented by ear/eye asymmetry later in rig. - if (_mood == "hiding") - DrawCircle(new Vector2(108, 145), 53, new Color(0.15f, 0.15f, 0.15f, 0.08f)); + 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 (int i = 0; i < points.Length; i++) + 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); + points[i] = center + new Vector2( + Mathf.Cos(angle) * radii.X, + Mathf.Sin(angle) * radii.Y + ); } DrawColoredPolygon(points, color); } From b76b4af8ca895234357aad1715cb16b5ad8ee901 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:18:47 -0600 Subject: [PATCH 134/142] feat(whiskerfolk): add procedural vertical-slice sound cues --- .../services/ProceduralAudioFactory.cs | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 projects/kitty-crush/godot/scripts/services/ProceduralAudioFactory.cs 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..844e42a --- /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); + } +} From 0ae6afacfd63621a01355088896373c9682a1af0 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:19:12 -0600 Subject: [PATCH 135/142] feat(whiskerfolk): wire procedural sound language through Mochi rescue --- .../godot/scripts/app/WhiskerfolkBootstrap.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs index 2b5962c..91a8689 100644 --- a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs +++ b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs @@ -40,6 +40,7 @@ public partial class WhiskerfolkBootstrap : Control 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; @@ -68,6 +69,13 @@ 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); @@ -195,6 +203,9 @@ private void ClearScreen(Color background) foreach (var child in GetChildren()) { + if (child == _audio) + continue; + RemoveChild(child); child.QueueFree(); } @@ -272,6 +283,7 @@ 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 @@ -335,6 +347,7 @@ private void BuildIntro() var action = Button("Look closer"); action.Pressed += () => { + _audio.PlayOneShot("cardboard", -11f); AdvanceStory("discover_box"); BuildFoodPuzzle(); }; @@ -576,6 +589,7 @@ private void ResolveBoard(MatchResult match) { var positions = current.GetAllPositions().Distinct().ToList(); _haptics.Match(); + _audio.PlayOneShot("match", -15f); foreach (var pos in positions) { @@ -734,6 +748,7 @@ private void BuildRescue() _bond.SetForStoryBeat(BondLevel.TrustingAction); _analytics.Track(AnalyticsEvents.RescueCompleted); _haptics.Rescue(); + _audio.PlayOneShot("rescue", -9f); AdvanceStory("carrier_choice"); open.Text = "Wait…"; open.Disabled = true; @@ -962,6 +977,7 @@ private void CompleteBoxMoment(Label homeTitle, Label homeCopy) _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."; From 859c0e11312920ff9de7809097e8f209c439db7b Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:20:31 -0600 Subject: [PATCH 136/142] feat(whiskerfolk): handle Android back and pause-safe persistence --- .../godot/scripts/app/WhiskerfolkBootstrap.cs | 54 +++++++++++++++++-- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs index 91a8689..34caf7f 100644 --- a/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs +++ b/projects/kitty-crush/godot/scripts/app/WhiskerfolkBootstrap.cs @@ -64,6 +64,8 @@ public partial class WhiskerfolkBootstrap : Control 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() { @@ -84,6 +86,46 @@ public override void _Ready() 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) @@ -200,6 +242,8 @@ private void ClearScreen(Color background) { _homeActive = false; _homeRoomRoot = null; + _activeSheetOverlay = null; + _activeSheetPanel = null; foreach (var child in GetChildren()) { @@ -1076,6 +1120,8 @@ private void RunHomeBehavior() 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), @@ -1083,6 +1129,7 @@ private void ShowSheet(string kicker, string title, string copy) }; overlay.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); AddChild(overlay); + _activeSheetOverlay = overlay; var panel = new PanelContainer { @@ -1097,6 +1144,7 @@ private void ShowSheet(string kicker, string title, string copy) }; panel.AddThemeStyleboxOverride("panel", Box(Milk, 30)); AddChild(panel); + _activeSheetPanel = panel; var margin = new MarginContainer(); margin.AddThemeConstantOverride("margin_left", 24); @@ -1113,11 +1161,7 @@ private void ShowSheet(string kicker, string title, string copy) col.AddChild(Label(copy, 16, SoftInk)); var close = Button("Close"); - close.Pressed += () => - { - overlay.QueueFree(); - panel.QueueFree(); - }; + close.Pressed += () => CloseActiveSheet(); col.AddChild(close); } } From e87253d56afda2b576a6be549f28fd2ee3362cb4 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:20:34 -0600 Subject: [PATCH 137/142] build(whiskerfolk): let app manage Android back requests --- projects/kitty-crush/godot/project.godot | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/kitty-crush/godot/project.godot b/projects/kitty-crush/godot/project.godot index 522eeda..bcf1e1e 100644 --- a/projects/kitty-crush/godot/project.godot +++ b/projects/kitty-crush/godot/project.godot @@ -6,6 +6,7 @@ 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 From 6777c6813d15f976dbaa563055584fe9a7c1deea Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:21:10 -0600 Subject: [PATCH 138/142] fix(whiskerfolk): correct procedural audio PI constant --- .../godot/scripts/services/ProceduralAudioFactory.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/kitty-crush/godot/scripts/services/ProceduralAudioFactory.cs b/projects/kitty-crush/godot/scripts/services/ProceduralAudioFactory.cs index 844e42a..446e5d8 100644 --- a/projects/kitty-crush/godot/scripts/services/ProceduralAudioFactory.cs +++ b/projects/kitty-crush/godot/scripts/services/ProceduralAudioFactory.cs @@ -71,7 +71,7 @@ public static AudioStreamWav QuestionMrrp() 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 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 = From 59bcefd88094f25d7a86e1f19259a45f794ac04f Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:22:42 -0600 Subject: [PATCH 139/142] docs(whiskerfolk): record green CI technical validation baseline --- projects/kitty-crush/TECHNICAL_VALIDATION.md | 85 ++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 projects/kitty-crush/TECHNICAL_VALIDATION.md 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. From 22740c0af7951629566d665c8cd6141206490016 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:22:44 -0600 Subject: [PATCH 140/142] docs(whiskerfolk): update phases with CI-validated runtime progress --- projects/kitty-crush/PHASE_STATUS.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/projects/kitty-crush/PHASE_STATUS.md b/projects/kitty-crush/PHASE_STATUS.md index 53ae844..6ee7d1c 100644 --- a/projects/kitty-crush/PHASE_STATUS.md +++ b/projects/kitty-crush/PHASE_STATUS.md @@ -15,16 +15,16 @@ The project has moved beyond concept-only work. Product, character, systems and | 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 | FOUNDATION COMPLETE | Godot/C# scaffold, licensed puzzle core, tests, systems architecture | Godot build execution in real environment | -| 5. Rescue puzzle system | PROTOTYPE COMPLETE | Functional web match system + C# objective definitions | Production blockers, specials, level authoring | -| 6. Living room | PROTOTYPE COMPLETE | Home scene + interaction philosophy + object data model | Real navigation/behavior/animation in Godot | +| 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 | PARTIAL | UX/motion/haptic spec, web motion proxy | Real audio library, device haptics, animation polish | +| 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 | SPEC COMPLETE | Gates, device matrix, performance/accessibility criteria | Real Godot/mobile execution | +| 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 From 4a6e172648d4f2711986b53b177cc75ba0864952 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:30:18 -0600 Subject: [PATCH 141/142] deploy(whiskerfolk): add vercel.json --- projects/kitty-crush/prototype-web/vercel.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 projects/kitty-crush/prototype-web/vercel.json 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" } + ] + } + ] +} From 943be33a540c4bb7a3e672172ba17d9d5ec98099 Mon Sep 17 00:00:00 2001 From: faechaurig-coder Date: Thu, 1 Oct 2026 23:30:20 -0600 Subject: [PATCH 142/142] deploy(whiskerfolk): add VERCEL_DEPLOY.md --- .../prototype-web/VERCEL_DEPLOY.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 projects/kitty-crush/prototype-web/VERCEL_DEPLOY.md 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