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.