PageSourceSearch

https://pupwife.com/scripts/desktop/citrusBirthdayBalloons.js

js pupwife.com collected 2026-09-30 15:04:45 UTC 15,792 bytes, 472 lines download raw bytes

1/**
2 * CitrusOS birthday balloons — Two.js sprites, verlet strings, click-to-pop.
3 */
4(function (global) {
5    'use strict';
6
7    var MAX_BALLOONS = 30;
8    var PERF_MAX = 12;
9    var SRC_PX = 640;
10    var ROPE_SEGMENTS = 10;
11    var ROPE_LENGTH = 88;
12    var ROPE_GRAVITY = 0.28;
13    var RESPAWN_MS = 1400;
14
15    var state = {
16        active: false,
17        entry: null,
18        two: null,
19        layer: null,
20        ropeLayer: null,
21        sparkleLayer: null,
22        balloons: [],
23        fallingRopes: [],
24        container: null,
25        rafId: null,
26        pointerHandler: null,
27        onGoldPop: null
28    };
29
30    function isPerformanceMode() {
31        if (global.PERFORMANCE_MODE_ENABLED) return true;
32        if (global.SettingsMenu && typeof global.SettingsMenu.getSetting === 'function') {
33            return !!global.SettingsMenu.getSetting('performanceMode');
34        }
35        return false;
36    }
37
38    function prefersReducedMotion() {
39        return global.matchMedia && global.matchMedia('(prefers-reduced-motion: reduce)').matches;
40    }
41
42    function maxBalloons() {
43        return isPerformanceMode() ? PERF_MAX : MAX_BALLOONS;
44    }
45
46    function loadTwo() {
47        if (global.Two) return Promise.resolve();
48        if (global.PupwifeScriptLoad && typeof global.PupwifeScriptLoad.once === 'function') {
49            return global.PupwifeScriptLoad.once('https://cdn.jsdelivr.net/npm/[email protected]/build/two.min.js');
50        }
51        return new Promise(function (resolve, reject) {
52            var s = document.createElement('script');
53            s.src = 'https://cdn.jsdelivr.net/npm/[email protected]/build/two.min.js';
54            s.onload = resolve;
55            s.onerror = reject;
56            document.head.appendChild(s);
57        });
58    }
59
60    function createRopePoints(x, y) {
61        var points = [];
62        var segLen = ROPE_LENGTH / ROPE_SEGMENTS;
63        for (var i = 0; i <= ROPE_SEGMENTS; i++) {
64            points.push({
65                x: x,
66                y: y + i * segLen,
67                ox: x,
68                oy: y + i * segLen
69            });
70        }
71        return points;
72    }
73
74    function updateRope(points, anchorX, anchorY, dt) {
75        if (!points.length) return;
76        points[0].x = anchorX;
77        points[0].y = anchorY;
78        points[0].ox = anchorX;
79        points[0].oy = anchorY;
80
81        for (var i = 1; i < points.length; i++) {
82            var p = points[i];
83            var vx = (p.x - p.ox) * 0.96;
84            var vy = (p.y - p.oy) * 0.96;
85            p.ox = p.x;
86            p.oy = p.y;
87            p.x += vx;
88            p.y += vy + ROPE_GRAVITY * dt;
89        }
90
91        var segLen = ROPE_LENGTH / ROPE_SEGMENTS;
92        for (var iter = 0; iter < 3; iter++) {
93            for (var j = 1; j < points.length; j++) {
94                var a = points[j - 1];
95                var b = points[j];
96                var dx = b.x - a.x;
97                var dy = b.y - a.y;
98                var dist = Math.sqrt(dx * dx + dy * dy) || 0.001;
99                var diff = (dist - segLen) / dist;
100                var offX = dx * diff * 0.5;
101                var offY = dy * diff * 0.5;
102                if (j > 1) {
103                    a.x += offX;
104                    a.y += offY;
105                }
106                b.x -= offX;
107                b.y -= offY;
108            }
109        }
110    }
111
112    function updateDetachedRope(rope, dt) {
113        for (var i = 0; i < rope.points.length; i++) {
114            var p = rope.points[i];
115            var vx = (p.x - p.ox) * 0.96;
116            var vy = (p.y - p.oy) * 0.96;
117            p.ox = p.x;
118            p.oy = p.y;
119            p.x += vx;
120            p.y += vy + ROPE_GRAVITY * dt;
121        }
122        var segLen = ROPE_LENGTH / ROPE_SEGMENTS;
123        for (var iter = 0; iter < 3; iter++) {
124            for (var j = 1; j < rope.points.length; j++) {
125                var a = rope.points[j - 1];
126                var b = rope.points[j];
127                var dx = b.x - a.x;
128                var dy = b.y - a.y;
129                var dist = Math.sqrt(dx * dx + dy * dy) || 0.001;
130                var diff = (dist - segLen) / dist;
131                var offX = dx * diff * 0.5;
132                var offY = dy * diff * 0.5;
133                a.x += offX;
134                a.y += offY;
135                b.x -= offX;
136                b.y -= offY;
137            }
138        }
139    }
140
141    function getAnchorCtor(two) {
142        return (two && two.Anchor) || (global.Two && global.Two.Anchor) || null;
143    }
144
145    function createRopeLine(two, points) {
146        var Anchor = getAnchorCtor(two);
147        if (!Anchor || !points.length) return null;
148        var anchors = [];
149        for (var i = 0; i < points.length; i++) {
150            anchors.push(new Anchor(points[i].x, points[i].y));
151        }
152        var line = two.makePath(anchors);
153        line.noFill();
154        return line;
155    }
156
157    function syncRopeLine(line, points) {
158        if (!line || !line.vertices) return;
159        var verts = line.vertices;
160        for (var i = 0; i < points.length; i++) {
161            if (!verts[i]) return;
162            verts[i].x = points[i].x;
163            verts[i].y = points[i].y;
164        }
165    }
166
167    function knotY(b) {
168        return b.y + b.size * 0.46;
169    }
170
171    function spawnBalloon(forceGold) {
172        if (!state.two || !state.entry) return null;
173        var cap = maxBalloons();
174        var living = state.balloons.filter(function (b) { return !b.popped; });
175        if (living.length >= cap) return null;
176
177        var hasGold = living.some(function (b) { return b.isGold; });
178        var isGold = !!forceGold || (!hasGold && Math.random() < 0.08);
179        if (!forceGold && hasGold) isGold = false;
180        if (forceGold) isGold = true;
181
182        var size = global.CitrusBirthdayAssets.pickDisplaySize(state.entry);
183        var x = 40 + Math.random() * (state.two.width - 80);
184        var y = state.two.height + size * 0.5 + Math.random() * 120;
185        var color = global.CitrusBirthdayAssets.pickRandomColor(state.entry);
186        var spriteSource = isGold
187            ? global.CitrusBirthdayAssets.getGoldSpriteSource()
188            : global.CitrusBirthdayAssets.getTintedSpriteSource(color);
189        if (!spriteSource) return null;
190
191        var sprite = state.two.makeSprite(spriteSource, x, y);
192        var scale = size / SRC_PX;
193        sprite.scale = scale;
194        sprite.origin.x = 0.5;
195        sprite.origin.y = 0.42;
196
197        var ropePoints = createRopePoints(x, knotY({ y: y, size: size }));
198        var ropeLine = createRopeLine(state.two, ropePoints);
199        if (!ropeLine) return null;
200        ropeLine.stroke = 'rgba(55, 55, 65, 0.75)';
201        ropeLine.linewidth = 1.4;
202        state.ropeLayer.add(ropeLine);
203
204        var sparkles = [];
205        if (isGold && !isPerformanceMode()) {
206            for (var s = 0; s < 6; s++) {
207                var spark = state.two.makeCircle(x, y, 2 + Math.random() * 2);
208                spark.fill = '#FFE566';
209                spark.noStroke();
210                spark.opacity = 0.85;
211                state.sparkleLayer.add(spark);
212                sparkles.push({
213                    circle: spark,
214                    angle: Math.random() * Math.PI * 2,
215                    radius: size * 0.35 + Math.random() * 14,
216                    speed: 0.02 + Math.random() * 0.03
217                });
218            }
219        }
220
221        var balloon = {
222            sprite: sprite,
223            ropeLine: ropeLine,
224            ropePoints: ropePoints,
225            x: x,
226            y: y,
227            size: size,
228            isGold: isGold,
229            popped: false,
230            vx: (Math.random() - 0.5) * 0.35,
231            vy: -0.55 - Math.random() * 0.35,
232            driftPhase: Math.random() * Math.PI * 2,
233            shimmer: Math.random() * Math.PI * 2,
234            sparkles: sparkles
235        };
236
237        state.layer.add(sprite);
238        state.balloons.push(balloon);
239        return balloon;
240    }
241
242    function popBalloon(balloon) {
243        if (!balloon || balloon.popped) return;
244        balloon.popped = true;
245
246        if (global.PopSound) global.PopSound.playSoft();
247
248        if (balloon.isGold && typeof state.onGoldPop === 'function') {
249            state.onGoldPop();
250        }
251
252        if (balloon.sprite) {
253            state.layer.remove(balloon.sprite);
254        }
255        balloon.sprite = null;
256
257        (balloon.sparkles || []).forEach(function (sp) {
258            if (sp.circle) state.sparkleLayer.remove(sp.circle);
259        });
260        balloon.sparkles = [];
261
262        state.fallingRopes.push({
263            line: balloon.ropeLine,
264            points: balloon.ropePoints,
265            life: 0
266        });
267
268        global.setTimeout(function () {
269            ensureBalloonCount();
270            if (!state.balloons.some(function (b) { return b.isGold && !b.popped; })) {
271                spawnBalloon(true);
272            }
273        }, RESPAWN_MS);
274    }
275
276    function ensureBalloonCount() {
277        var living = state.balloons.filter(function (b) { return !b.popped; });
278        var cap = maxBalloons();
279        var guard = 0;
280        while (living.length < cap && guard < cap + 4) {
281            guard += 1;
282            var b = spawnBalloon(false);
283            if (!b) break;
284            living.push(b);
285        }
286        if (!living.some(function (b) { return b.isGold; })) {
287            spawnBalloon(true);
288        }
289    }
290
291    function hitTest(clientX, clientY) {
292        for (var i = state.balloons.length - 1; i >= 0; i--) {
293            var b = state.balloons[i];
294            if (b.popped || !b.sprite) continue;
295            var dx = clientX - b.sprite.translation.x;
296            var dy = clientY - b.sprite.translation.y;
297            var r = b.size * 0.38;
298            if (dx * dx + dy * dy <= r * r) return b;
299        }
300        return null;
301    }
302
303    function onPointerDown(ev) {
304        if (!state.active) return;
305        if (ev.target && ev.target.closest && ev.target.closest(
306            '#citrusos-windows, #citrusos-taskbar, #citrusos-start-menu, #citrusos-start-btn, .citrus-widget, button, a, input, textarea, select'
307        )) {
308            return;
309        }
310        var hit = hitTest(ev.clientX, ev.clientY);
311        if (hit) {
312            ev.preventDefault();
313            ev.stopPropagation();
314            popBalloon(hit);
315        }
316    }
317
318    function tick() {
319        if (!state.active || !state.two) return;
320        var dt = 1;
321        var now = global.performance.now();
322
323        state.balloons.forEach(function (b) {
324            if (b.popped) return;
325            b.driftPhase += 0.018;
326            b.x = b.sprite.translation.x + b.vx + Math.sin(b.driftPhase) * 0.12;
327            b.y = b.sprite.translation.y + b.vy;
328            b.sprite.translation.x = b.x;
329            b.sprite.translation.y = b.y;
330
331            if (b.isGold) {
332                b.shimmer += 0.08;
333                b.sprite.opacity = 0.92 + Math.sin(b.shimmer) * 0.08;
334                b.sprite.rotation = Math.sin(b.shimmer * 0.5) * 0.04;
335                (b.sparkles || []).forEach(function (sp) {
336                    sp.angle += sp.speed;
337                    sp.circle.translation.x = b.x + Math.cos(sp.angle) * sp.radius;
338                    sp.circle.translation.y = b.y + Math.sin(sp.angle) * sp.radius * 0.8;
339                    sp.circle.opacity = 0.5 + Math.sin(now * 0.01 + sp.angle) * 0.4;
340                });
341            }
342
343            if (b.y < -b.size) {
344                b.y = state.two.height + b.size;
345                b.x = 40 + Math.random() * (state.two.width - 80);
346                b.sprite.translation.x = b.x;
347                b.sprite.translation.y = b.y;
348            }
349
350            updateRope(b.ropePoints, b.x, knotY(b), dt);
351            syncRopeLine(b.ropeLine, b.ropePoints);
352        });
353
354        state.fallingRopes = state.fallingRopes.filter(function (fr) {
355            fr.life += 1;
356            updateDetachedRope(fr, dt);
357            syncRopeLine(fr.line, fr.points);
358            var tail = fr.points[fr.points.length - 1];
359            if (fr.life > 180 || tail.y > state.two.height + 40) {
360                state.ropeLayer.remove(fr.line);
361                return false;
362            }
363            return true;
364        });
365
366        state.balloons = state.balloons.filter(function (b) {
367            return !b.popped || b.sprite;
368        });
369
370        state.two.update();
371    }
372
373    function loop() {
374        tick();
375        state.rafId = global.requestAnimationFrame(loop);
376    }
377
378    function showReducedToast(entry) {
379        var toast = document.createElement('div');
380        toast.className = 'citrus-birthday-toast';
381        toast.textContent = (entry && entry.copy && entry.copy.stickyNote) || 'birthday party on pupwife.com!';
382        document.body.appendChild(toast);
383        global.setTimeout(function () {
384            if (toast.parentNode) toast.remove();
385        }, 4200);
386    }
387
388    function start(entry, opts) {
389        opts = opts || {};
390        if (state.active) return Promise.resolve();
391        state.entry = entry;
392        state.onGoldPop = opts.onGoldPop || null;
393
394        if (prefersReducedMotion()) {
395            showReducedToast(entry);
396            return Promise.resolve();
397        }
398
399        return loadTwo().then(function () {
400            return global.CitrusBirthdayAssets.preload(entry);
401        }).then(function () {
402            state.container = document.createElement('div');
403            state.container.id = 'citrusos-birthday-balloons';
404            state.container.className = 'citrus-birthday-balloons-layer';
405            document.body.appendChild(state.container);
406
407            state.two = new global.Two({
408                type: global.Two.Types.canvas,
409                width: global.innerWidth,
410                height: global.innerHeight,
411                autostart: false
412            }).appendTo(state.container);
413
414            state.ropeLayer = state.two.makeGroup();
415            state.layer = state.two.makeGroup();
416            state.sparkleLayer = state.two.makeGroup();
417
418            for (var i = 0; i < maxBalloons(); i++) {
419                spawnBalloon(i === 0);
420            }
421            ensureBalloonCount();
422
423            state.active = true;
424            state.pointerHandler = onPointerDown;
425            document.addEventListener('pointerdown', state.pointerHandler, true);
426            global.addEventListener('resize', onResize);
427            loop();
428        }).catch(function (err) {
429            if (typeof console !== 'undefined' && console.error) {
430                console.error('CitrusBirthdayBalloons failed to start:', err);
431            }
432        });
433    }
434
435    function onResize() {
436        if (!state.two) return;
437        state.two.width = global.innerWidth;
438        state.two.height = global.innerHeight;
439        state.two.renderer.setSize(global.innerWidth, global.innerHeight);
440    }
441
442    function destroy() {
443        state.active = false;
444        if (state.rafId) {
445            global.cancelAnimationFrame(state.rafId);
446            state.rafId = null;
447        }
448        if (state.pointerHandler) {
449            document.removeEventListener('pointerdown', state.pointerHandler, true);
450            state.pointerHandler = null;
451        }
452        global.removeEventListener('resize', onResize);
453        if (state.two) {
454            if (state.container) state.container.innerHTML = '';
455            state.two = null;
456        }
457        if (state.container && state.container.parentNode) {
458            state.container.parentNode.removeChild(state.container);
459        }
460        state.container = null;
461        state.balloons = [];
462        state.fallingRopes = [];
463        state.entry = null;
464        state.onGoldPop = null;
465    }
466
467    global.CitrusBirthdayBalloons = {
468        start: start,
469        destroy: destroy,
470        isActive: function () { return state.active; }
471    };
472})(typeof window !== 'undefined' ? window : this);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.