data endpoint for entity 90008 (aka. a website)
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(
182 position.x,
183 targetX,
184 speed * ((self.innerWidth ?? 1600.0) / 1600.0)
185 );
186 position.x += moveByX;
187
188 turnStrideWheel(moveByX);
189
190 flip = moveByX < 0.0;
191 if (moveByX > 0.1 || moveByX < -0.1) {
192 sprite = strideAngle % Math.PI < Math.PI * 0.5 ? '/pet/walk1.webp' : '/pet/walk2.webp';
193 } else {
194 sprite = '/pet/idle.webp';
195 }
196
197 deltaTravelled += Math.abs(moveByX);
198 updateDistanceTravelled();
199 };
200
201 if (browser) setInterval(move, tickRate);
202
203 const shake = (event: DeviceMotionEvent) => {
204 const accel = event.acceleration ?? event.accelerationIncludingGravity;
205 if (accel === null || accel.x === null || accel.y === null) return;
206 if (Math.abs(accel.x) + Math.abs(accel.y) < 40.0) return;
207 // make it so that it amplifies motion proportionally to the window size
208 const windowRatio = (window.innerWidth * 1.0) / (window.innerHeight * 1.0);
209 velocityX += accel.x * windowRatio * 5.0;
210 velocityY += accel.y * (1.0 / windowRatio) * 5.0;
211 sprite = '/pet/pick.webp';
212 };
213
214 if (browser) self.ondevicemotion = shake;
215
216 // this is for ios
217 const askForShakePermission = () => {
218 if (
219 typeof DeviceMotionEvent !== 'undefined' &&
220 // eslint-disable-next-line @typescript-eslint/no-explicit-any
221 typeof (DeviceMotionEvent as any).requestPermission === 'function'
222 ) {
223 // eslint-disable-next-line @typescript-eslint/no-explicit-any
224 (DeviceMotionEvent as any)
225 .requestPermission()
226 .then((permissionState: string) => {
227 if (permissionState === 'granted') {
228 self.ondevicemotion = shake;
229 }
230 })
231 .catch(console.error);
232 }
233 };
234
235 const pickNewTargetX = () => {
236 const viewportWidth = self.innerWidth || null;
237 if (viewportWidth !== null && Math.abs(position.x - targetX) < 5) {
238 targetX = Math.max(
239 Math.min(
240 targetX + (Math.random() - 0.5) * (viewportWidth * 0.5),
241 viewportWidth * 0.9
242 ),
243 viewportWidth * 0.1
244 );
245 }
246 // Set a random interval for the next target update (between 4-10 seconds)
247 const randomDelay = Math.floor(Math.random() * 6000) + 4000;
248 setTimeout(pickNewTargetX, randomDelay);
249 };
250
251 // Start the process
252 if (browser) setTimeout(pickNewTargetX, 1000);
253</script>
254
255<!-- svelte-ignore a11y_missing_attribute -->
256<div
257 use:draggable={{
258 position,
259 applyUserSelectHack: true,
260 handle: 'img',
261 bounds: {
262 bottom: (window.innerHeight / 100) * 5
263 },
264 onDragStart: () => {
265 sprite = '/pet/pick.webp';
266 dragged = true;
267 },
268 onDrag: ({ offsetX, offsetY, event }) => {
269 position.x = offsetX;
270 position.y = offsetY;
271 const mouseXD = event.movementX * delta;
272 const mouseYD = event.movementY * delta;
273 deltaTravelled += Math.sqrt(mouseXD ** 2 + mouseYD ** 2);
274 // reset mouse movement if it's not moving in the same direction so it doesnt accumulate its weird!@!@
275 mouseX = Math.sign(mouseXD) != Math.sign(mouseX) ? mouseXD : mouseX + mouseXD;
276 mouseY = Math.sign(mouseYD) != Math.sign(mouseY) ? mouseYD : mouseY + mouseYD;
277 rotationVelocity += mouseXD + mouseYD;
278 lastDragged = Date.now();
279 },
280 onDragEnd: () => {
281 // reset mouse movement if we stopped for longer than some time
282 if (Date.now() - lastDragged > 50) {
283 mouseX = 0.0;
284 mouseY = 0.0;
285 }
286 // apply velocity based on rotation since we already keep track of that
287 velocityX = mouseX * 70.0;
288 velocityY = mouseY * 50.0;
289 updateDistanceTravelled();
290 // reset mouse movement we dont want it to accumulate
291 mouseX = 0.0;
292 mouseY = 0.0;
293 dragged = false;
294 }
295 }}
296 class="fixed bottom-[4.6vh] z-[1000] hover:animate-squiggle"
297 style="cursor: url('/icons/gaze.webp'), pointer;"
298>
299 <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
300 <!-- svelte-ignore a11y_click_events_have_key_events -->
301 <img
302 draggable="false"
303 onclick={askForShakePermission}
304 style="
305 image-rendering: pixelated !important;
306 transform: rotate({rotation}rad) scaleX({flip ? -1 : 1});
307 filter: invert(100%) drop-shadow(2px 2px 0 black) drop-shadow(-2px -2px 0 black);
308 "
309 src={sprite}
310 />
311</div>