Skip to content

Commit 6e45e86

Browse files
committed
Fix pet action anchoring and crop stability
1 parent e473cc9 commit 6e45e86

6 files changed

Lines changed: 302 additions & 59 deletions

File tree

Sources/MacArkPet/Models/PetModel.swift

Lines changed: 108 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ final class PetModel: ObservableObject {
3333
var nextMoodChange = Date().addingTimeInterval(8)
3434
var lastTick = Date()
3535
var lastDragEventAt = Date.distantPast
36+
var resumeWalkingAt = Date.distantPast
3637
private var lastPokeAt = Date.distantPast
38+
private var visualCropRectsByKind: [String: CGRect] = [:]
3739

3840
var hasSpineAssets: Bool {
3941
atlasURL != nil && skeletonURL != nil && imageURL != nil
@@ -67,15 +69,16 @@ final class PetModel: ObservableObject {
6769
}
6870

6971
func finishOneShotAction(kind: String) {
70-
if kind == "interact", mood == .happy {
71-
mood = .idle
72-
} else if kind == "special", mood == .special {
73-
mood = .idle
74-
} else {
72+
let shouldFinish = (kind == "interact" && mood == .happy)
73+
|| (kind == "special" && mood == .special)
74+
guard shouldFinish else {
7575
return
7676
}
77+
7778
velocity = CGVector(dx: 0, dy: 0)
79+
resumeWalkingAt = Date().addingTimeInterval(2.0)
7880
nextMoodChange = Date().addingTimeInterval(TimeInterval.random(in: 8...14))
81+
mood = .idle
7982
}
8083

8184
func animationKind() -> String {
@@ -94,18 +97,18 @@ final class PetModel: ObservableObject {
9497
}
9598

9699
func contactInset(forWindowSize size: CGSize) -> CGFloat {
97-
guard hasSpineAssets, visualCropKind == animationKind() else { return 0 }
100+
guard hasSpineAssets else { return 0 }
98101

99102
switch animationKind() {
100103
case "rest":
101-
return min(max(size.height * 0.30, 24), 76)
104+
return min(max(size.height * 0.18, 14), 46)
102105
case "sleep":
103-
if size.width > size.height * 1.2 {
104-
return min(max(size.height * 0.04, 0), 12)
106+
if size.width > size.height * 1.25 {
107+
return min(max(size.height * 0.035, 3), 12)
105108
}
106-
return min(max(size.height * 0.28, 22), 70)
109+
return min(max(size.height * 0.16, 12), 42)
107110
default:
108-
return 0
111+
return min(max(size.height * 0.015, 2), 6)
109112
}
110113
}
111114

@@ -120,6 +123,7 @@ final class PetModel: ObservableObject {
120123
func resetMotion() {
121124
isDragging = false
122125
velocity = CGVector(dx: 42, dy: 0)
126+
resumeWalkingAt = .distantPast
123127
facingLeft = false
124128
mood = .idle
125129
nextMoodChange = Date().addingTimeInterval(TimeInterval.random(in: 10...18))
@@ -132,8 +136,100 @@ final class PetModel: ObservableObject {
132136
skeletonURL = model.skeletonURL
133137
renderScaleControlsWindow = false
134138
visualAspectRatio = nil
139+
resetVisualCrop()
140+
resetMotion()
141+
}
142+
143+
var activeVisualCropRect: CGRect? {
144+
guard hasSpineAssets else { return nil }
145+
let kind = animationKind()
146+
if isStandingKind(kind) {
147+
return standingVisualCropRect
148+
}
149+
if let crop = visualCropRectsByKind[kind] {
150+
if isOneShotKind(kind), let standingCrop = standingVisualCropRect {
151+
return crop.union(standingCrop)
152+
}
153+
return crop
154+
}
155+
return standingVisualCropRect
156+
}
157+
158+
var activeVisualAnchorX: CGFloat? {
159+
guard hasSpineAssets,
160+
let activeCrop = activeVisualCropRect else {
161+
return nil
162+
}
163+
164+
let anchorCrop = standingVisualCropRect ?? activeCrop
165+
let anchorX = anchorCrop.midX - activeCrop.minX
166+
return min(max(anchorX, 0), activeCrop.width)
167+
}
168+
169+
func setVisualCrop(kind: String, rect: CGRect) {
170+
let safeRect = safeVisualCropRect(rect, kind: kind)
171+
let stableRect = visualCropRectsByKind[kind]?.union(safeRect) ?? safeRect
172+
visualCropRectsByKind[kind] = stableRect
173+
visualCropKind = kind
174+
visualCropRect = stableRect
175+
}
176+
177+
func resetVisualCrop() {
178+
visualCropRectsByKind.removeAll()
135179
visualCropRect = nil
136180
visualCropKind = nil
137-
resetMotion()
181+
}
182+
183+
private var standingVisualCropRect: CGRect? {
184+
let standingRects = ["move", "idle"].compactMap { visualCropRectsByKind[$0] }
185+
return union(standingRects)
186+
}
187+
188+
private func isStandingKind(_ kind: String) -> Bool {
189+
kind == "move" || kind == "idle"
190+
}
191+
192+
private func isOneShotKind(_ kind: String) -> Bool {
193+
kind == "interact" || kind == "special"
194+
}
195+
196+
private func union(_ rects: [CGRect]) -> CGRect? {
197+
guard var result = rects.first else { return nil }
198+
for rect in rects.dropFirst() {
199+
result = result.union(rect)
200+
}
201+
return result
202+
}
203+
204+
private func safeVisualCropRect(_ rect: CGRect, kind: String) -> CGRect {
205+
let topPadding: CGFloat
206+
let sidePadding: CGFloat
207+
let bottomPadding: CGFloat
208+
209+
switch kind {
210+
case "move", "idle":
211+
topPadding = max(18, rect.height * 0.08)
212+
sidePadding = max(8, rect.width * 0.025)
213+
bottomPadding = max(3, rect.height * 0.015)
214+
case "rest", "sleep":
215+
topPadding = max(12, rect.height * 0.045)
216+
sidePadding = max(10, rect.width * 0.025)
217+
bottomPadding = max(3, rect.height * 0.015)
218+
default:
219+
topPadding = max(14, rect.height * 0.06)
220+
sidePadding = max(8, rect.width * 0.025)
221+
bottomPadding = max(3, rect.height * 0.015)
222+
}
223+
224+
let left = max(0, rect.minX - sidePadding)
225+
let top = max(0, rect.minY - topPadding)
226+
let right = rect.maxX + sidePadding
227+
let bottom = rect.maxY + bottomPadding
228+
return CGRect(
229+
x: left.rounded(.down),
230+
y: top.rounded(.down),
231+
width: max(1, (right - left).rounded(.up)),
232+
height: max(1, (bottom - top).rounded(.up))
233+
)
138234
}
139235
}

Sources/MacArkPet/Services/PetPhysics.swift

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ struct PetPhysics {
1010
var horizontalSpeed: CGFloat = 42
1111
private var preciseOrigin: CGPoint?
1212
private var currentSupport: Surface?
13+
private var stationaryLockMood: PetModel.Mood?
14+
private var stationaryLockX: CGFloat?
1315

1416
private struct Surface {
1517
enum Kind: Int {
@@ -32,6 +34,9 @@ struct PetPhysics {
3234

3335
mutating func resetOrigin(_ origin: CGPoint, clearSupport: Bool = true) {
3436
preciseOrigin = origin
37+
if stationaryLockMood != nil {
38+
stationaryLockX = origin.x
39+
}
3540
if clearSupport {
3641
currentSupport = nil
3742
}
@@ -65,6 +70,18 @@ struct PetPhysics {
6570
|| model.mood == .resting
6671
|| model.mood == .special
6772
|| model.mood == .happy
73+
let shouldLockHorizontal = isStationaryMood || now < model.resumeWalkingAt
74+
if shouldLockHorizontal {
75+
if stationaryLockMood != model.mood {
76+
stationaryLockMood = model.mood
77+
stationaryLockX = frame.origin.x
78+
}
79+
frame.origin.x = stationaryLockX ?? frame.origin.x
80+
model.velocity.dx = 0
81+
} else {
82+
stationaryLockMood = nil
83+
stationaryLockX = nil
84+
}
6885

6986
if abs(model.velocity.dy) < 1, let support = currentSupport {
7087
if let updatedSupport = updatedSurface(
@@ -79,6 +96,9 @@ struct PetPhysics {
7996
var shiftedFrame = frame
8097
shiftedFrame.origin.x += supportDeltaX
8198
shiftedFrame.origin.y += supportDeltaY
99+
if shouldLockHorizontal, let lockedX = stationaryLockX {
100+
stationaryLockX = lockedX + supportDeltaX
101+
}
82102
if abs(supportDeltaX) < 96,
83103
abs(supportDeltaY) < 96,
84104
supportsFeet(of: shiftedFrame, contactInset: contactInset, on: updatedSupport, screen: screen) {
@@ -93,25 +113,33 @@ struct PetPhysics {
93113
}
94114
}
95115

96-
if isStationaryMood {
97-
model.velocity.dx *= 0.86
98-
if abs(model.velocity.dx) < 1 {
99-
model.velocity.dx = 0
100-
}
101-
} else if abs(model.velocity.dx) < horizontalSpeed * 0.35 {
116+
if shouldLockHorizontal {
117+
model.velocity.dx = 0
118+
frame.origin.x = stationaryLockX ?? frame.origin.x
119+
} else if now >= model.resumeWalkingAt, abs(model.velocity.dx) < horizontalSpeed * 0.35 {
102120
model.velocity.dx = model.facingLeft ? -horizontalSpeed : horizontalSpeed
103121
}
104122

105123
if frame.minX <= bounds.minX + edgeInset {
106124
frame.origin.x = bounds.minX + edgeInset
107-
model.velocity.dx = isStationaryMood ? 0 : abs(horizontalSpeed)
125+
if shouldLockHorizontal {
126+
stationaryLockX = frame.origin.x
127+
}
128+
model.velocity.dx = shouldLockHorizontal ? 0 : abs(horizontalSpeed)
108129
} else if frame.maxX >= bounds.maxX - edgeInset {
109130
frame.origin.x = bounds.maxX - frame.width - edgeInset
110-
model.velocity.dx = isStationaryMood ? 0 : -abs(horizontalSpeed)
131+
if shouldLockHorizontal {
132+
stationaryLockX = frame.origin.x
133+
}
134+
model.velocity.dx = shouldLockHorizontal ? 0 : -abs(horizontalSpeed)
111135
}
112136

113137
model.velocity.dy -= gravity * CGFloat(dt)
114-
frame.origin.x += model.velocity.dx * CGFloat(dt)
138+
if shouldLockHorizontal {
139+
frame.origin.x = stationaryLockX ?? frame.origin.x
140+
} else {
141+
frame.origin.x += model.velocity.dx * CGFloat(dt)
142+
}
115143
frame.origin.y += model.velocity.dy * CGFloat(dt)
116144

117145
let support = landingSurface(
@@ -123,11 +151,21 @@ struct PetPhysics {
123151
)
124152
let contactY = frame.minY + contactInset
125153
if model.velocity.dy <= 0, support.isFloor, contactY <= support.topY {
126-
frame.origin.y = support.topY - contactInset
154+
frame.origin.y = settledY(
155+
currentY: frame.origin.y,
156+
targetY: support.topY - contactInset,
157+
wasSupported: currentSupport != nil,
158+
dt: CGFloat(dt)
159+
)
127160
model.velocity.dy = 0
128161
currentSupport = support
129162
} else if model.velocity.dy <= 0, contactY <= support.topY, previousContactY >= support.topY - 34 {
130-
frame.origin.y = support.topY - contactInset
163+
frame.origin.y = settledY(
164+
currentY: frame.origin.y,
165+
targetY: support.topY - contactInset,
166+
wasSupported: currentSupport != nil,
167+
dt: CGFloat(dt)
168+
)
131169
model.velocity.dy = 0
132170
currentSupport = support
133171
} else if currentSupport?.isFloor == false, support.isFloor {
@@ -138,10 +176,16 @@ struct PetPhysics {
138176

139177
if frame.minX <= bounds.minX + edgeInset {
140178
frame.origin.x = bounds.minX + edgeInset
141-
model.velocity.dx = isStationaryMood ? 0 : abs(horizontalSpeed)
179+
if shouldLockHorizontal {
180+
stationaryLockX = frame.origin.x
181+
}
182+
model.velocity.dx = shouldLockHorizontal ? 0 : abs(horizontalSpeed)
142183
} else if frame.maxX >= bounds.maxX - edgeInset {
143184
frame.origin.x = bounds.maxX - frame.width - edgeInset
144-
model.velocity.dx = isStationaryMood ? 0 : -abs(horizontalSpeed)
185+
if shouldLockHorizontal {
186+
stationaryLockX = frame.origin.x
187+
}
188+
model.velocity.dx = shouldLockHorizontal ? 0 : -abs(horizontalSpeed)
145189
}
146190

147191
if abs(model.velocity.dx) > 1 {
@@ -156,13 +200,22 @@ struct PetPhysics {
156200
window.setFrameOrigin(frame.origin)
157201
}
158202

203+
private func settledY(currentY: CGFloat, targetY: CGFloat, wasSupported: Bool, dt: CGFloat) -> CGFloat {
204+
let delta = targetY - currentY
205+
guard wasSupported, abs(delta) > 0.5, abs(delta) < 90 else {
206+
return targetY
207+
}
208+
209+
let maxStep = max(4, 720 * dt)
210+
return currentY + min(abs(delta), maxStep) * (delta < 0 ? -1 : 1)
211+
}
212+
159213
private func pickNextIdleAction(model: PetModel, onWindowSurface: Bool) {
160214
if model.mood != .idle {
161-
model.mood = .idle
162-
if abs(model.velocity.dx) < horizontalSpeed * 0.35 {
163-
model.velocity.dx = model.facingLeft ? -horizontalSpeed : horizontalSpeed
164-
}
215+
model.resumeWalkingAt = Date().addingTimeInterval(1.6)
216+
model.velocity.dx = 0
165217
model.nextMoodChange = Date().addingTimeInterval(TimeInterval.random(in: 8...14))
218+
model.mood = .idle
166219
return
167220
}
168221

0 commit comments

Comments
 (0)