Skip to content

feat: 020 — drag and toss pigs with physics - #22

Merged
archae0pteryx merged 1 commit into
mainfrom
feat/020-drag-toss
May 3, 2026
Merged

feat: 020 — drag and toss pigs with physics#22
archae0pteryx merged 1 commit into
mainfrom
feat/020-drag-toss

Conversation

@archae0pteryx

@archae0pteryx archae0pteryx commented May 3, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Extracts computeTossVelocity(samples, windowMs, now) as pure function; velocity capped at PIG_SPEED * 6
  • Exports DRAG_THRESHOLD = 4, TOSS_VELOCITY_WINDOW_MS = 80, FRICTION = 0.97, PIG_SPEED
  • usePigMovement return type changed from PigState[] to { pigs, startDrag, moveDrag, endDrag }
  • Dragged pig position follows pointer; pointer history buffered for toss velocity on release
  • tickPig applies FRICTION each frame; speed cap raised to PIG_SPEED * 6
  • PigSprite uses setPointerCapture for reliable drag; click fires only when movement < DRAG_THRESHOLD
  • Wide hit-rect sent during drag (same as when detail card is open)

Test plan

  • 8 unit tests: DRAG_THRESHOLD value, computeTossVelocity with constant speed, window filtering, empty/single-sample edge cases, velocity clamping (scalar + diagonal)
  • task check green
  • Pure click still opens PigDetail
  • Drag moves pig in real time
  • Fast release sends pig flying; pig decelerates and bounces at edges
  • App launches at runtime without crash

Closes #18

Summary by CodeRabbit

Release Notes

  • New Features
    • Pigs can now be dragged and tossed around the interface with momentum physics.
    • Added the ability to create new tasks directly from the pig detail panel.
    • Improved pointer interaction handling for drag gestures.

@coderabbitai

coderabbitai Bot commented May 3, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ae2d76c9-298b-4c4c-8b4b-ecf27bfecea9

📥 Commits

Reviewing files that changed from the base of the PR and between d5a1555 and 7762966.

📒 Files selected for processing (5)
  • src/components/App.test.tsx
  • src/components/App.tsx
  • src/components/PigSprite.tsx
  • src/hooks/usePigMovement.test.ts
  • src/hooks/usePigMovement.ts

📝 Walkthrough

Walkthrough

This PR implements drag-and-toss physics for pigs. It adds pointer-driven drag handlers to PigSprite, updates usePigMovement to track drag state and compute toss velocity from pointer history, wires drag callbacks through App, applies per-frame friction and increased velocity caps, and distinguishes clicks from drags using a 4px threshold.

Changes

Drag-and-Toss Physics System

Layer / File(s) Summary
Data Shape & Exports
src/hooks/usePigMovement.ts
New exports: constants DRAG_THRESHOLD (4), TOSS_VELOCITY_WINDOW_MS, FRICTION; types PointerSample and PigMovementResult; function computeTossVelocity(samples, windowMs, now) derives velocity from pointer history over a time window and clamps to PIG_SPEED * 6.
Component Props
src/components/PigSprite.tsx
PigSpriteProps gains drag callbacks: onDragStart(x, y), onDragMove(x, y), and onDragEnd() returning { wasDrag: boolean }.
Core Drag State & Physics
src/hooks/usePigMovement.ts
usePigMovement now returns PigMovementResult with pigs and memoized handlers. Refs track active drag (dragIdRef, dragStartRef), pointer history (pointerHistoryRef). moveDrag updates dragged pig position and maintains bounded pointer history. endDrag computes toss velocity and sets dragged pig's vx/vy if threshold exceeded. tickPig applies per-frame friction (vx/vy *= FRICTION), clamps speed to PIG_SPEED * 6, and is skipped for dragged pigs. syncRects now accepts wide flag for full-viewport rects during drag.
Pointer Event Handling
src/components/PigSprite.tsx
Replaces static onClick with onPointerDown/Move/Up handlers. Tracks drag start position and state via refs. Calls onDragStart on down, onDragMove only after exceeding DRAG_THRESHOLD, and onDragEnd on up. Conditionally invokes original onClick() only if wasDrag is false. Cursor style driven by dragging state.
Component Wiring
src/components/App.tsx
Destructures { pigs, startDrag, moveDrag, endDrag } from usePigMovement. Adds async handleAddTask(text) handler calling focusWriter.appendTask(selectedFocus.id, text). Wires drag callbacks to each PigSprite: onDragStart={(x, y) => startDrag(pig.id, x, y)}, onDragMove={moveDrag}, onDragEnd={endDrag}. Passes onAddTask={handleAddTask} to PigDetail.
Tests & Test Mocks
src/components/App.test.tsx, src/hooks/usePigMovement.test.ts
App.test.tsx mock returns full PigMovementResult shape with drag handlers. usePigMovement.test.ts adds makeSamples helper, validates DRAG_THRESHOLD === 4, and tests computeTossVelocity across empty/single-sample, out-of-window, constant-speed, and diagonal clamping scenarios.

Sequence Diagram

sequenceDiagram
    actor User
    participant PigSprite
    participant App
    participant usePigMovement
    participant Physics as Physics Engine

    User->>PigSprite: onPointerDown (x, y)
    PigSprite->>usePigMovement: startDrag(pigId, x, y)
    usePigMovement->>usePigMovement: Store drag state, clear pointer history

    loop While pointer moves
        User->>PigSprite: onPointerMove (x, y)
        PigSprite->>PigSprite: Compute delta from start
        alt Delta >= DRAG_THRESHOLD
            PigSprite->>usePigMovement: moveDrag(x, y)
            usePigMovement->>usePigMovement: Update pig position, record pointer sample
        end
    end

    User->>PigSprite: onPointerUp
    PigSprite->>usePigMovement: endDrag()
    
    alt wasDrag === true (threshold exceeded)
        usePigMovement->>usePigMovement: computeTossVelocity(pointer history)
        usePigMovement->>usePigMovement: Set pig vx/vy to computed velocity
        usePigMovement->>Physics: Pig coasts with friction
    else wasDrag === false (pure click)
        PigSprite->>App: onClick()
        App->>App: Open PigDetail
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related issues

  • 020 — Drag and toss pigs with physics #18 (020 — Drag and toss pigs with physics): This PR implements the full feature specification including drag detection, pointer tracking, toss velocity computation, friction physics, and the click-vs-drag distinction via threshold.

Possibly related PRs

  • PR #10: Introduced the initial usePigMovement, PigSprite, and App component scaffolding that this PR extends with drag/toss APIs and handlers.
  • PR #20: Added HITBOX_PADDING and buildHitRects logic to usePigMovement that this PR now integrates with via the updated syncRects wide-rect behavior during active drags.
  • PR #21: Related task-flow changes to App.handleAddTask and PigDetail.onAddTask wiring that complements the drag/toss feature with task input capability.

Poem

A rabbit hops and grins with glee,
As pigs now dance where fingers be!
Drag them swift, then let them fly, 🐷✨
Watch them coast 'neath open sky,
Friction slows them down just right—
Ranch gameplay feels so right! 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main feature: dragging and tossing pigs with physics simulation.
Linked Issues check ✅ Passed The PR implements all coding requirements from issue #18: drag/toss API in usePigMovement, pointer handlers in PigSprite, friction/velocity clamping, click-vs-drag threshold, toss velocity computation, and task input in PigDetail.
Out of Scope Changes check ✅ Passed All changes are directly aligned with issue #18 objectives: drag/toss mechanics, physics (friction/clamping), task input UI, and supporting test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/020-drag-toss

Review rate limit: 6/10 reviews remaining, refill in 18 minutes and 36 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/App.tsx`:
- Around line 34-41: The component-level function handleAddTask is performing
business I/O by calling focusWriter.appendTask(selectedFocus.id, text); move
this mutation behind a hook or action (e.g., create a useFocusActions hook with
an appendTask method) so the view remains I/O-free: implement a new hook
function (useFocusActions.appendTask) that accepts focusId and text, calls
focusWriter.appendTask and handles/logs errors, then have the component receive
that callback (either via the hook or as a prop) and replace direct calls to
focusWriter.appendTask in handleAddTask with invoking the provided
appendTask(selectedFocus.id, text); keep selectedFocus only as a view-level
prop/state and ensure error handling stays inside the hook.

In `@src/components/PigDetail.tsx`:
- Line 13: The onAddTask callback in PigDetail is currently fire-and-forget and
the component clears taskInput immediately; change the onAddTask signature to
return a Promise (resolve on success / reject or false on failure) and update
the caller (the append handler in App.tsx) to return that promise/result so
callers know success/failure; in PigDetail await the returned promise from
onAddTask and only clear taskInput when it resolves successfully (handle
rejection by leaving the draft intact and surfacing an error), and apply the
same change for the other occurrence referenced around the second block (lines
74-85) so both code paths wait for append success before clearing the input.

In `@src/components/PigSprite.tsx`:
- Around line 57-80: The pointer handlers lack cleanup for canceled/losing
pointer captures causing onDragEnd not to run; add handlers for onPointerCancel
and onLostPointerCapture that mirror the onPointerUp cleanup: call
e.currentTarget.releasePointerCapture(e.pointerId) if needed, clear
startPosRef.current, call onDragEnd() (and ignore its wasDrag result here),
reset isDraggingRef.current = false, and avoid calling onClick(); ensure these
handlers use the same refs and callbacks (startPosRef, isDraggingRef, onDragEnd)
as onPointerUp so dragIdRef in usePigMovement is always cleared.
- Around line 49-80: Restore native keyboard activation by adding a real onClick
handler to the button and suppressing it only when the preceding pointer
interaction was a drag: introduce a suppressClickRef (useRef<boolean>(false)),
set suppressClickRef.current = wasDrag inside the existing onPointerUp after
calling onDragEnd(), and add onClick={(e) => { if (suppressClickRef.current) {
suppressClickRef.current = false; e.preventDefault(); return; } onClick(); }} to
the button; keep the existing isDraggingRef, startPosRef,
onDragMove/onDragStart, and DRAG_THRESHOLD logic intact.

In `@src/hooks/usePigMovement.ts`:
- Around line 196-215: startDrag currently stores the raw pointer position;
change it to record the initial pointer-to-pig offset (store pointer.x - pig.x
and pointer.y - pig.y in dragStartRef) so the pig doesn't snap when dragging
begins. In moveDrag, compute the new pig position by subtracting that stored
offset from the pointer coordinates, then clamp the computed x and y to the
valid range [0, screenWidth - PIG_SIZE] and [0, screenHeight - PIG_SIZE] before
calling setPigs. Update references to dragIdRef, dragStartRef, moveDrag,
startDrag, setPigs and pigsRef accordingly so pointerHistory logic and
velocity/direction (direction4(p.vx, p.vy)) remain unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c78de0dc-bab1-495a-ab2a-d74e655d5cfc

📥 Commits

Reviewing files that changed from the base of the PR and between 9d896cf and d5a1555.

📒 Files selected for processing (6)
  • src/components/App.test.tsx
  • src/components/App.tsx
  • src/components/PigDetail.tsx
  • src/components/PigSprite.tsx
  • src/hooks/usePigMovement.test.ts
  • src/hooks/usePigMovement.ts

Comment thread src/components/App.tsx
Comment on lines +34 to +41
async function handleAddTask(text: string) {
if (!selectedFocus) return;
try {
await focusWriter.appendTask(selectedFocus.id, text);
} catch {
// focusWriter already logs the typed error
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Move the new task mutation out of the component.

This adds another direct write call inside src/components, which keeps business I/O in the view layer instead of a hook/API boundary. Please push appendTask behind a hook/action and pass the resulting callback into the component.

As per coding guidelines, "Frontend React components in src/components/ should be view-only with no fetch and no direct I/O."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/App.tsx` around lines 34 - 41, The component-level function
handleAddTask is performing business I/O by calling
focusWriter.appendTask(selectedFocus.id, text); move this mutation behind a hook
or action (e.g., create a useFocusActions hook with an appendTask method) so the
view remains I/O-free: implement a new hook function
(useFocusActions.appendTask) that accepts focusId and text, calls
focusWriter.appendTask and handles/logs errors, then have the component receive
that callback (either via the hook or as a prop) and replace direct calls to
focusWriter.appendTask in handleAddTask with invoking the provided
appendTask(selectedFocus.id, text); keep selectedFocus only as a view-level
prop/state and ensure error handling stays inside the hook.

readonly viewportH: number;
readonly onClose: () => void;
readonly onClearTask: (index: number) => void;
readonly onAddTask: (text: string) => void;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't clear the draft until the append result is known.

onAddTask is typed as fire-and-forget, but the input is cleared immediately after it runs. In src/components/App.tsx:34-40, the new append path catches failures, so a failed write drops the user's draft with no recovery path. Make this callback report success/failure (or return a rejecting promise) and only clear taskInput on success.

Also applies to: 74-85

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/PigDetail.tsx` at line 13, The onAddTask callback in PigDetail
is currently fire-and-forget and the component clears taskInput immediately;
change the onAddTask signature to return a Promise (resolve on success / reject
or false on failure) and update the caller (the append handler in App.tsx) to
return that promise/result so callers know success/failure; in PigDetail await
the returned promise from onAddTask and only clear taskInput when it resolves
successfully (handle rejection by leaving the draft intact and surfacing an
error), and apply the same change for the other occurrence referenced around the
second block (lines 74-85) so both code paths wait for append success before
clearing the input.

Comment on lines 49 to +80
<button
type="button"
className="pig-sprite"
style={{ left: x, top: y + bob }}
onClick={onClick}
style={{
left: x,
top: y + bob,
cursor: isDraggingRef.current ? "grabbing" : "pointer",
}}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
startPosRef.current = { x: e.clientX, y: e.clientY };
isDraggingRef.current = false;
onDragStart(e.clientX, e.clientY);
}}
onPointerMove={(e) => {
if (!startPosRef.current) return;
const dx = e.clientX - startPosRef.current.x;
const dy = e.clientY - startPosRef.current.y;
if (!isDraggingRef.current && Math.sqrt(dx * dx + dy * dy) >= DRAG_THRESHOLD) {
isDraggingRef.current = true;
}
if (isDraggingRef.current) {
onDragMove(e.clientX, e.clientY);
}
}}
onPointerUp={(e) => {
e.currentTarget.releasePointerCapture(e.pointerId);
startPosRef.current = null;
const { wasDrag } = onDragEnd();
isDraggingRef.current = false;
if (!wasDrag) onClick();
}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore native keyboard activation for the button.

Removing the button's onClick means Enter/Space no longer opens PigDetail; the only open path now lives in onPointerUp, which never runs for keyboard users. Keep a real click handler for non-pointer activation and suppress it only after an actual drag.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/PigSprite.tsx` around lines 49 - 80, Restore native keyboard
activation by adding a real onClick handler to the button and suppressing it
only when the preceding pointer interaction was a drag: introduce a
suppressClickRef (useRef<boolean>(false)), set suppressClickRef.current =
wasDrag inside the existing onPointerUp after calling onDragEnd(), and add
onClick={(e) => { if (suppressClickRef.current) { suppressClickRef.current =
false; e.preventDefault(); return; } onClick(); }} to the button; keep the
existing isDraggingRef, startPosRef, onDragMove/onDragStart, and DRAG_THRESHOLD
logic intact.

Comment on lines +57 to +80
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
startPosRef.current = { x: e.clientX, y: e.clientY };
isDraggingRef.current = false;
onDragStart(e.clientX, e.clientY);
}}
onPointerMove={(e) => {
if (!startPosRef.current) return;
const dx = e.clientX - startPosRef.current.x;
const dy = e.clientY - startPosRef.current.y;
if (!isDraggingRef.current && Math.sqrt(dx * dx + dy * dy) >= DRAG_THRESHOLD) {
isDraggingRef.current = true;
}
if (isDraggingRef.current) {
onDragMove(e.clientX, e.clientY);
}
}}
onPointerUp={(e) => {
e.currentTarget.releasePointerCapture(e.pointerId);
startPosRef.current = null;
const { wasDrag } = onDragEnd();
isDraggingRef.current = false;
if (!wasDrag) onClick();
}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle canceled pointer captures.

There is no onPointerCancel / onLostPointerCapture cleanup path here. If the gesture is interrupted, onDragEnd() never runs, and dragIdRef in src/hooks/usePigMovement.ts:218-246 stays set, leaving that pig frozen until another interaction resets the drag state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/PigSprite.tsx` around lines 57 - 80, The pointer handlers lack
cleanup for canceled/losing pointer captures causing onDragEnd not to run; add
handlers for onPointerCancel and onLostPointerCapture that mirror the
onPointerUp cleanup: call e.currentTarget.releasePointerCapture(e.pointerId) if
needed, clear startPosRef.current, call onDragEnd() (and ignore its wasDrag
result here), reset isDraggingRef.current = false, and avoid calling onClick();
ensure these handlers use the same refs and callbacks (startPosRef,
isDraggingRef, onDragEnd) as onPointerUp so dragIdRef in usePigMovement is
always cleared.

Comment on lines +196 to +215
const startDrag = useCallback((pigId: string, x: number, y: number) => {
dragIdRef.current = pigId;
dragStartRef.current = { x, y };
pointerHistoryRef.current = [{ x, y, t: performance.now() }];
}, []);

const moveDrag = useCallback((x: number, y: number) => {
if (!dragIdRef.current) return;
const now = performance.now();
pointerHistoryRef.current.push({ x, y, t: now });
// Keep only last 200ms of history to bound memory.
pointerHistoryRef.current = pointerHistoryRef.current.filter((s) => s.t >= now - 200);

setPigs((prev) => {
const next = prev.map((p) =>
p.id === dragIdRef.current ? { ...p, x, y, direction: direction4(p.vx, p.vy) } : p,
);
pigsRef.current = next;
return next;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve the grab offset and clamp the dragged position.

moveDrag() writes raw clientX/clientY into p.x/p.y, so the pig snaps its top-left to the cursor as soon as dragging starts and can be dragged partially past the right/bottom edges. Record the initial pointer-to-pig offset in startDrag() and clamp the computed position to [0, screen - PIG_SIZE] before updating state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/usePigMovement.ts` around lines 196 - 215, startDrag currently
stores the raw pointer position; change it to record the initial pointer-to-pig
offset (store pointer.x - pig.x and pointer.y - pig.y in dragStartRef) so the
pig doesn't snap when dragging begins. In moveDrag, compute the new pig position
by subtracting that stored offset from the pointer coordinates, then clamp the
computed x and y to the valid range [0, screenWidth - PIG_SIZE] and [0,
screenHeight - PIG_SIZE] before calling setPigs. Update references to dragIdRef,
dragStartRef, moveDrag, startDrag, setPigs and pigsRef accordingly so
pointerHistory logic and velocity/direction (direction4(p.vx, p.vy)) remain
unchanged.

Extract computeTossVelocity as pure function (testable). Add
DRAG_THRESHOLD, TOSS_VELOCITY_WINDOW_MS, FRICTION constants.
usePigMovement returns {pigs, startDrag, moveDrag, endDrag};
dragged pig position follows pointer, pointer history tracked for
velocity on release. tickPig applies FRICTION each frame; speed
cap raised to PIG_SPEED*6 for post-toss deceleration. PigSprite
uses pointer capture for drag; click fires only when movement
is below DRAG_THRESHOLD.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

020 — Drag and toss pigs with physics

1 participant