diff --git a/LilAgents/CharacterContentView.swift b/LilAgents/CharacterContentView.swift index 01b78f5..d76b896 100644 --- a/LilAgents/CharacterContentView.swift +++ b/LilAgents/CharacterContentView.swift @@ -54,7 +54,46 @@ class CharacterContentView: NSView { return hitRect.contains(localPoint) ? self : nil } + // Track whether a drag was initiated so we can distinguish click vs drag. + private var mouseDownScreenPos: NSPoint = .zero + private var hasDragged = false + private static let dragThreshold: CGFloat = 4.0 + override func mouseDown(with event: NSEvent) { - character?.handleClick() + hasDragged = false + mouseDownScreenPos = NSEvent.mouseLocation + } + + override func mouseDragged(with event: NSEvent) { + guard let character = character, let win = window else { return } + + let currentScreenPos = NSEvent.mouseLocation + + if !hasDragged { + let dx = currentScreenPos.x - mouseDownScreenPos.x + let dy = currentScreenPos.y - mouseDownScreenPos.y + guard dx * dx + dy * dy >= Self.dragThreshold * Self.dragThreshold else { return } + hasDragged = true + character.beginDrag( + windowOriginAtDragStart: win.frame.origin, + cursorScreenPos: mouseDownScreenPos + ) + } + + character.continueDrag(cursorScreenPos: currentScreenPos) + } + + override func mouseUp(with event: NSEvent) { + if hasDragged, let character = character { + // We need dockTopY to compute the landing Y. + // Read it from the screen the window is currently on. + let screen = window?.screen ?? NSScreen.main + let dockTopY = screen?.visibleFrame.origin.y ?? 0 + character.endDrag(dockTopY: dockTopY) + } else { + // Short tap with no drag → treat as click + character?.handleClick() + } + hasDragged = false } } diff --git a/LilAgents/LilAgentsController.swift b/LilAgents/LilAgentsController.swift index ec6fce9..014dfe2 100644 --- a/LilAgents/LilAgentsController.swift +++ b/LilAgents/LilAgentsController.swift @@ -242,8 +242,10 @@ class LilAgentsController { char.update(dockX: dockX, dockWidth: dockWidth, dockTopY: dockTopY) } + // Don't override the elevated level of a dragged or physics-falling character. let sorted = activeChars.sorted { $0.positionProgress < $1.positionProgress } for (i, char) in sorted.enumerated() { + guard !char.isDragging && !char.isPhysicsFalling else { continue } char.window.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue + i) } } diff --git a/LilAgents/WalkerCharacter.swift b/LilAgents/WalkerCharacter.swift index fa30824..4d5a44c 100644 --- a/LilAgents/WalkerCharacter.swift +++ b/LilAgents/WalkerCharacter.swift @@ -77,6 +77,23 @@ class WalkerCharacter { var walkStartPixel: CGFloat = 0.0 var walkEndPixel: CGFloat = 0.0 + // Drag & physics state + var isDragging = false + var dragOffsetInWindow: NSPoint = .zero // cursor offset from window origin when drag started + var isPhysicsFalling = false + var physicsVelocityY: CGFloat = 0 // px/s, positive = up (AppKit) + var physicsVelocityX: CGFloat = 0 // px/s, positive = right + var physicsStartTime: CFTimeInterval = 0 + var physicsPosX: CGFloat = 0 // current window origin.x during fall + var physicsPosY: CGFloat = 0 // current window origin.y during fall + // Previous drag positions for velocity estimation + private var dragPrevPos: NSPoint = .zero + private var dragPrevTime: CFTimeInterval = 0 + private var dragCurrPos: NSPoint = .zero + private var dragCurrTime: CFTimeInterval = 0 + // Landing Y (Dock surface) computed when drag ends + private var landingY: CGFloat = 0 + // Onboarding var isOnboarding = false @@ -945,10 +962,151 @@ class WalkerCharacter { } } + // MARK: - Drag Handling + + func beginDrag(windowOriginAtDragStart: NSPoint, cursorScreenPos: NSPoint) { + // Pause normal walking / physics + isDragging = true + isPhysicsFalling = false + isWalking = false + isPaused = true + queuePlayer.pause() + queuePlayer.seek(to: .zero) + + // Offset of cursor inside the window frame (so character doesn't jump) + dragOffsetInWindow = NSPoint( + x: cursorScreenPos.x - windowOriginAtDragStart.x, + y: cursorScreenPos.y - windowOriginAtDragStart.y + ) + + dragCurrPos = cursorScreenPos + dragCurrTime = CACurrentMediaTime() + dragPrevPos = cursorScreenPos + dragPrevTime = dragCurrTime + + // Elevate window above siblings while dragging + window.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue + 20) + } + + func continueDrag(cursorScreenPos: NSPoint) { + guard isDragging else { return } + + dragPrevPos = dragCurrPos + dragPrevTime = dragCurrTime + dragCurrPos = cursorScreenPos + dragCurrTime = CACurrentMediaTime() + + let newOrigin = NSPoint( + x: cursorScreenPos.x - dragOffsetInWindow.x, + y: cursorScreenPos.y - dragOffsetInWindow.y + ) + window.setFrameOrigin(newOrigin) + } + + func endDrag(dockTopY: CGFloat) { + guard isDragging else { return } + isDragging = false + + // Estimate release velocity from last two drag samples + let dt = dragCurrTime - dragPrevTime + if dt > 0.001 { + physicsVelocityX = (dragCurrPos.x - dragPrevPos.x) / CGFloat(dt) + physicsVelocityY = (dragCurrPos.y - dragPrevPos.y) / CGFloat(dt) + } else { + physicsVelocityX = 0 + physicsVelocityY = 0 + } + + // Clamp to reasonable max speed + let maxSpeed: CGFloat = 3000 + physicsVelocityX = max(-maxSpeed, min(maxSpeed, physicsVelocityX)) + physicsVelocityY = max(-maxSpeed, min(maxSpeed, physicsVelocityY)) + + physicsPosX = window.frame.origin.x + physicsPosY = window.frame.origin.y + physicsStartTime = CACurrentMediaTime() + + // Compute the resting Y for this character + let bottomPadding = displayHeight * 0.15 + landingY = dockTopY - bottomPadding + yOffset + + isPhysicsFalling = true + pauseEndTime = CACurrentMediaTime() + Double.random(in: 2.0...5.0) + } + // MARK: - Frame Update func update(dockX: CGFloat, dockWidth: CGFloat, dockTopY: CGFloat) { currentTravelDistance = max(dockWidth - displayWidth, 0) + + // ── Dragging: window is positioned directly by continueDrag(), nothing to do ── + if isDragging { + updateThinkingBubble() + return + } + + // ── Physics falling / bouncing ── + if isPhysicsFalling { + let now = CACurrentMediaTime() + let dt = CGFloat(now - physicsStartTime) + physicsStartTime = now + + let gravity: CGFloat = -1800 // px/s², negative = downward in AppKit + let friction: CGFloat = 0.985 // horizontal damping per frame + + physicsVelocityY += gravity * dt + physicsVelocityX *= friction + + physicsPosX += physicsVelocityX * dt + physicsPosY += physicsVelocityY * dt + + // Clamp X to screen bounds + if let screen = window.screen ?? NSScreen.main { + let minX = screen.frame.minX + let maxX = screen.frame.maxX - displayWidth + if physicsPosX < minX { + physicsPosX = minX + physicsVelocityX = abs(physicsVelocityX) * 0.5 + } else if physicsPosX > maxX { + physicsPosX = maxX + physicsVelocityX = -abs(physicsVelocityX) * 0.5 + } + } + + // Landing check + if physicsPosY <= landingY { + physicsPosY = landingY + let restitution: CGFloat = 0.38 + let bounceCutoff: CGFloat = 80 + + if abs(physicsVelocityY) > bounceCutoff { + // Bounce back up + physicsVelocityY = -physicsVelocityY * restitution + physicsVelocityX *= 0.7 + } else { + // Settle on the ground + isPhysicsFalling = false + physicsVelocityY = 0 + physicsVelocityX = 0 + + // Snap positionProgress to where we landed + if currentTravelDistance > 0 { + let landedPixel = physicsPosX - dockX - currentFlipCompensation + positionProgress = min(max(landedPixel / currentTravelDistance, 0), 1) + } + window.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue) + window.setFrameOrigin(NSPoint(x: physicsPosX, y: physicsPosY)) + updateThinkingBubble() + return + } + } + + window.setFrameOrigin(NSPoint(x: physicsPosX, y: physicsPosY)) + updateThinkingBubble() + return + } + + // ── Normal walk / idle logic ── if isIdleForPopover { let travelDistance = currentTravelDistance let x = dockX + travelDistance * positionProgress + currentFlipCompensation