1<script module lang="ts">
2 import { get, writable } from 'svelte/store';
3
4 export const localDistanceTravelled = writable(0.0);
5 export const localBounces = writable(0);
6</script>
7
8<script lang="ts">
9 import { draggable } from '@neodrag/svelte';
10 import { browser } from '$app/environment';
11
12 interface Props {
13 apiToken: string;
14 }
15
16 let { apiToken }: Props = $props();
17
18 let lastDragged = 0;
19 let mouseX = 0;
20 let mouseY = 0;
21
22 let position = $state({ x: 0, y: 0 });
23 let rotation = $state(0.0);
24 let sprite = $state('/pet/idle.webp');
25 let flip = $state(false);
26 let dragged = $state(false);
27
28 let targetX = 120;
29 let speed = 10.0;
30 let tickRate = 20;
31 let delta = 1.0 / tickRate;
32
33 let strideRadius = 4.0;
34 let strideAngle = 0;
35
36 const turnStrideWheel = (by: number) => {
37 strideAngle += by / strideRadius;
38 if (strideAngle > Math.PI * 2) {
39 strideAngle -= Math.PI * 2;
40 } else if (strideAngle < 0) {
41 strideAngle += Math.PI * 2;
42 }
43 };
44
45 let targetRotation = 0.0;
46 let rotationVelocity = 0.0;
47 let springStiffness = 20.0; // How quickly rotation returns to target
48 let springDamping = 0.2; // Damping factor to prevent oscillation
49
50 const updateRotationSpring = () => {
51 // Spring physics: calculate force based on distance from target
52 const springForce = (targetRotation - rotation) * springStiffness;
53
54 // Apply damping to velocity
55 rotationVelocity = rotationVelocity * (1 - springDamping) + springForce * delta;
56
57 // Update rotation based on velocity
58 rotation += rotationVelocity * delta;
59
60 // If we're very close to target and barely moving, just snap to target
61 if (Math.abs(rotation - targetRotation) < 0.01 && Math.abs(rotationVelocity) < 0.01) {
62 rotation = targetRotation;
63 rotationVelocity = 0;
64 }
65 };
66
67 // Add spring update to the move function
68 if (browser) setInterval(updateRotationSpring, tickRate);
69
70 const moveTowards = (from: number, to: number, by: number) => {
71 let d = (to - from) * 1.0;
72 let l = Math.abs(d);
73 let s = Math.sign(d);
74 let moveBy = s * Math.min(l, by) * delta;
75 return moveBy;
76 };
77
78 // Physics constants
79 let velocityX = 0;
80 let velocityY = 0;
81 let gravity = 200.0; // Gravity strength (positive because -Y is up)
82 let friction = 0.96; // Air friction
83 let groundFriction = 0.9; // Ground friction
84 let bounciness = 0.8; // How much energy is preserved on bounce
85
86 const sendBounceMetrics = () => {
87 fetch(`/_api/pet/bounce?_token=${apiToken}`);
88 localBounces.set(get(localBounces) + 1);
89 };
90
91 let deltaTravelled = 0.0;
92 let deltaTravelledTotal = 0.0;
93 const updateDistanceTravelled = () => {
94 if (deltaTravelled > 0.1 || deltaTravelled < -0.1) {
95 localDistanceTravelled.update((n) => {
96 n += deltaTravelled;
97 return n;
98 });
99 deltaTravelledTotal += deltaTravelled;
100 }
101 deltaTravelled = 0.0;
102 };
103
104 const sendTotalDistance = () => {
105 fetch(`/_api/pet/distance?_token=${apiToken}`, {
106 method: 'POST',
107 body: deltaTravelledTotal.toString()
108 });
109 deltaTravelledTotal = 0.0;
110 };
111
112 // sending every 5 seconds is probably reliable enough
113 if (browser) setInterval(sendTotalDistance, 1000 * 5);
114
115 const move = () => {
116 if (dragged) return;
117
118 // Apply physics when pet is in motion
119 if (velocityX !== 0 || velocityY !== 0 || position.y !== 0) {
120 // Apply gravity (remember negative Y is upward)
121 velocityY += gravity * delta;
122
123 // Apply friction
124 const fric = position.y === 0 ? groundFriction : friction;
125 velocityX *= fric;
126 velocityY *= fric;
127
128 // Update position
129 const moveX = velocityX * delta;
130 const moveY = velocityY * delta;
131 position.x += moveX;
132 position.y += moveY;
133
134 deltaTravelled += Math.sqrt(moveX ** 2 + moveY ** 2);
135 updateDistanceTravelled();
136
137 // Handle window boundaries
138 const viewportWidth = window.innerWidth;
139
140 // Bounce off sides
141 if (position.x < 0) {
142 position.x = 0;
143 velocityX = -velocityX * bounciness;
144 sendBounceMetrics();
145 } else if (position.x > viewportWidth) {
146 position.x = viewportWidth;
147 velocityX = -velocityX * bounciness;
148 sendBounceMetrics();
149 }
150
151 // Bounce off bottom (floor)
152 if (position.y > 0) {
153 position.y = 0;
154 velocityY = -velocityY * bounciness;
155 // Only bounce if velocity is significant
156 if (Math.abs(velocityY) < 80) {
157 velocityY = 0;
158 position.y = 0;
159 } else {
160 sendBounceMetrics();
161 }
162 }
163
164 // reset velocity
165 if (Math.abs(velocityX) < 5 && Math.abs(velocityY) < 5) {
166 velocityX = 0;
167 velocityY = 0;
168 }
169
170 // Update flip based on velocity
171 if (Math.abs(velocityX) > 0.5) {
172 flip = velocityX < 0;
173 }
174
175 targetRotation = velocityX * 0.02 + velocityY * 0.01;
176
177 return;
178 }
179
180 // Normal movement when not physics-based
181 let moveByX = moveTowards(position.x, targetX, speed * ((self.innerWidth ?? 1600.0) / 1600.0));
182 position.x += moveByX;
183
184 turnStrideWheel(moveByX);
185
186 flip = moveByX < 0.0;
187 if (moveByX > 0.1 || moveByX < -0.1) {
188 sprite = strideAngle % Math.PI < Math.PI * 0.5 ? '/pet/walk1.webp' : '/pet/walk2.webp';
189 } else {
190 sprite = '/pet/idle.webp';
191 }
192
193 deltaTravelled += Math.abs(moveByX);
194 updateDistanceTravelled();
195 };
196
197 if (browser) setInterval(move, tickRate);
198
199 const shake = (event: DeviceMotionEvent) => {
200 const accel = event.acceleration ?? event.accelerationIncludingGravity;
201 if (accel === null || accel.x === null || accel.y === null) return;
202 if (Math.abs(accel.x) + Math.abs(accel.y) < 40.0) return;
203 // make it so that it amplifies motion proportionally to the window size
204 const windowRatio = (window.innerWidth * 1.0) / (window.innerHeight * 1.0);
205 velocityX += accel.x * windowRatio * 5.0;
206 velocityY += accel.y * (1.0 / windowRatio) * 5.0;
207 sprite = '/pet/pick.webp';
208 };
209
210 if (browser) self.ondevicemotion = shake;
211
212 // this is for ios
213 const askForShakePermission = () => {
214 if (
215 typeof DeviceMotionEvent !== 'undefined' &&
216 // eslint-disable-next-line @typescript-eslint/no-explicit-any
217 typeof (DeviceMotionEvent as any).requestPermission === 'function'
218 ) {
219 // eslint-disable-next-line @typescript-eslint/no-explicit-any
220 (DeviceMotionEvent as any)
221 .requestPermission()
222 .then((permissionState: string) => {
223 if (permissionState === 'granted') {
224 self.ondevicemotion = shake;
225 }
226 })
227 .catch(console.error);
228 }
229 };
230
231 const pickNewTargetX = () => {
232 const viewportWidth = self.innerWidth || null;
233 if (viewportWidth !== null && Math.abs(position.x - targetX) < 5) {
234 targetX = Math.max(
235 Math.min(targetX + (Math.random() - 0.5) * (viewportWidth * 0.5), viewportWidth * 0.9),
236 viewportWidth * 0.1
237 );
238 }
239 // Set a random interval for the next target update (between 4-10 seconds)
240 const randomDelay = Math.floor(Math.random() * 6000) + 4000;
241 setTimeout(pickNewTargetX, randomDelay);
242 };
243
244 // Start the process
245 if (browser) setTimeout(pickNewTargetX, 1000);
246</script>
247
248<!-- svelte-ignore a11y_missing_attribute -->
249<div
250 use:draggable={{
251 position,
252 applyUserSelectHack: true,
253 handle: 'img',
254 bounds: {
255 bottom: (window.innerHeight / 100) * 5.5
256 },
257 onDragStart: () => {
258 sprite = '/pet/pick.webp';
259 dragged = true;
260 },
261 onDrag: ({ offsetX, offsetY, event }) => {
262 position.x = offsetX;
263 position.y = offsetY;
264 const mouseXD = event.movementX * delta;
265 const mouseYD = event.movementY * delta;
266 deltaTravelled += Math.sqrt(mouseXD ** 2 + mouseYD ** 2);
267 // reset mouse movement if it's not moving in the same direction so it doesnt accumulate its weird!@!@
268 mouseX = Math.sign(mouseXD) != Math.sign(mouseX) ? mouseXD : mouseX + mouseXD;
269 mouseY = Math.sign(mouseYD) != Math.sign(mouseY) ? mouseYD : mouseY + mouseYD;
270 rotationVelocity += mouseXD + mouseYD;
271 lastDragged = Date.now();
272 },
273 onDragEnd: () => {
274 // reset mouse movement if we stopped for longer than some time
275 if (Date.now() - lastDragged > 50) {
276 mouseX = 0.0;
277 mouseY = 0.0;
278 }
279 // apply velocity based on rotation since we already keep track of that
280 velocityX = mouseX * 70.0;
281 velocityY = mouseY * 50.0;
282 updateDistanceTravelled();
283 // reset mouse movement we dont want it to accumulate
284 mouseX = 0.0;
285 mouseY = 0.0;
286 dragged = false;
287 }
288 }}
289 class="fixed bottom-[5vh] z-[1000] hover:animate-squiggle"
290 style="cursor: url('/icons/gaze.webp'), pointer;"
291>
292 <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
293 <!-- svelte-ignore a11y_click_events_have_key_events -->
294 <img
295 draggable="false"
296 onclick={askForShakePermission}
297 style="
298 image-rendering: pixelated !important;
299 transform: rotate({rotation}rad) scaleX({flip ? -1 : 1});
300 filter: invert(100%) drop-shadow(2px 2px 0 black) drop-shadow(-2px -2px 0 black);
301 "
302 src={sprite}
303 />
304</div>