-- Red Balloons: Float up, collect at top, tap to pop! local OVAL_CONSTANT: number = 0.5519150244935105707435627 type PopParticle = { x: number, y: number, vx: number, vy: number, rotation: number, rotationSpeed: number, size: number, path: Path, } type Balloon = { x: number, y: number, radius: number, vx: number, vy: number, wobblePhase: number, wobbleSpeed: number, age: number, popping: boolean, popProgress: number, popParticles: { PopParticle }, color: Color, colorDark: Color, colorLight: Color, colorShadow: Color, path: Path, knotPath: Path, stringPath: Path, highlightPath: Path, specularPath: Path, shadowPath: Path, } type Balloons = { -- Inputs for customization boundsWidth: Input, boundsHeight: Input, maxBalloons: Input, minRadius: Input, maxRadius: Input, floatSpeed: Input, spawnRate: Input, -- Output trigger for pop event popTrigger: Output, -- Internal state balloons: { Balloon }, balloonPaint: Paint, knotPaint: Paint, shadowPaint: Paint, highlightPaint: Paint, specularPaint: Paint, stringPaint: Paint, particlePaint: Paint, time: number, spawnTimer: number, balloonIndex: number, } -- Draw balloon body (teardrop shape, more realistic) local function drawBalloonBody(path: Path, x: number, y: number, radius: number, wobble: number) -- Balloon is taller than wide with teardrop shape local rx = radius * (1 + wobble * 0.02) local ryTop = radius * 1.1 -- rounder at top local ryBottom = radius * 1.3 -- elongated at bottom -- Start at right middle path:moveTo(Vector.xy(x + rx, y - ryTop * 0.1)) -- Bottom right curve (fuller) path:cubicTo( Vector.xy(x + rx * 1.0, y + ryBottom * 0.5), Vector.xy(x + rx * 0.5, y + ryBottom * 0.9), Vector.xy(x, y + ryBottom) ) -- Bottom left curve (fuller) path:cubicTo( Vector.xy(x - rx * 0.5, y + ryBottom * 0.9), Vector.xy(x - rx * 1.0, y + ryBottom * 0.5), Vector.xy(x - rx, y - ryTop * 0.1) ) -- Top left curve (rounder) path:cubicTo( Vector.xy(x - rx, y - ryTop * 0.6), Vector.xy(x - rx * 0.7, y - ryTop), Vector.xy(x, y - ryTop) ) -- Top right curve (rounder) path:cubicTo( Vector.xy(x + rx * 0.7, y - ryTop), Vector.xy(x + rx, y - ryTop * 0.6), Vector.xy(x + rx, y - ryTop * 0.1) ) path:close() end -- Draw balloon knot separately local function drawBalloonKnot(path: Path, x: number, y: number, radius: number) local ryBottom = radius * 1.3 local knotY = y + ryBottom local knotW = radius * 0.12 local knotH = radius * 0.18 -- Small triangular knot path:moveTo(Vector.xy(x - knotW, knotY - knotH * 0.3)) path:cubicTo( Vector.xy(x - knotW * 0.5, knotY + knotH * 0.5), Vector.xy(x + knotW * 0.5, knotY + knotH * 0.5), Vector.xy(x + knotW, knotY - knotH * 0.3) ) path:cubicTo( Vector.xy(x + knotW * 0.3, knotY), Vector.xy(x - knotW * 0.3, knotY), Vector.xy(x - knotW, knotY - knotH * 0.3) ) path:close() end -- Draw balloon string local function drawString(path: Path, x: number, y: number, radius: number, wobble: number, time: number) local ryBottom = radius * 1.3 local knotH = radius * 0.18 local startY = y + ryBottom + knotH * 0.5 local stringLength = radius * 1.2 -- Subtle sway based on wobble phase (very gentle, not frantic) local sway = math.sin(time * 0.5 + wobble) * 2 path:moveTo(Vector.xy(x, startY)) -- Smooth realistic hanging string using cubic beziers -- First curve - gentle outward curve local mid1Y = startY + stringLength * 0.35 local mid2Y = startY + stringLength * 0.7 local endY = startY + stringLength path:cubicTo( Vector.xy(x + sway * 0.3, startY + stringLength * 0.15), Vector.xy(x + sway * 0.8 + 4, mid1Y), Vector.xy(x + sway + 3, mid1Y) ) -- Second curve - flows back slightly path:cubicTo( Vector.xy(x + sway + 2, mid1Y + stringLength * 0.1), Vector.xy(x + sway * 0.6 - 2, mid2Y - stringLength * 0.1), Vector.xy(x + sway * 0.5 - 1, mid2Y) ) -- Final curve - hangs down naturally path:cubicTo( Vector.xy(x + sway * 0.3, mid2Y + stringLength * 0.1), Vector.xy(x + sway * 0.1, endY - stringLength * 0.05), Vector.xy(x, endY) ) end -- Draw main highlight/shine on balloon (large soft glow) local function drawHighlight(path: Path, x: number, y: number, radius: number) -- Main highlight - oval shaped, positioned upper left (moved more to the left) local hx = x - radius * 0.45 local hy = y - radius * 0.4 local hrx = radius * 0.28 local hry = radius * 0.4 local c: number = OVAL_CONSTANT path:moveTo(Vector.xy(hx + hrx, hy)) path:cubicTo( Vector.xy(hx + hrx, hy + hry * c), Vector.xy(hx + hrx * c, hy + hry), Vector.xy(hx, hy + hry) ) path:cubicTo( Vector.xy(hx - hrx * c, hy + hry), Vector.xy(hx - hrx, hy + hry * c), Vector.xy(hx - hrx, hy) ) path:cubicTo( Vector.xy(hx - hrx, hy - hry * c), Vector.xy(hx - hrx * c, hy - hry), Vector.xy(hx, hy - hry) ) path:cubicTo( Vector.xy(hx + hrx * c, hy - hry), Vector.xy(hx + hrx, hy - hry * c), Vector.xy(hx + hrx, hy) ) path:close() end -- Draw small bright specular highlight local function drawSpecular(path: Path, x: number, y: number, radius: number) local sx = x - radius * 0.5 local sy = y - radius * 0.5 local sr = radius * 0.1 local c: number = OVAL_CONSTANT path:moveTo(Vector.xy(sx + sr, sy)) path:cubicTo( Vector.xy(sx + sr, sy + sr * c), Vector.xy(sx + sr * c, sy + sr), Vector.xy(sx, sy + sr) ) path:cubicTo( Vector.xy(sx - sr * c, sy + sr), Vector.xy(sx - sr, sy + sr * c), Vector.xy(sx - sr, sy) ) path:cubicTo( Vector.xy(sx - sr, sy - sr * c), Vector.xy(sx - sr * c, sy - sr), Vector.xy(sx, sy - sr) ) path:cubicTo( Vector.xy(sx + sr * c, sy - sr), Vector.xy(sx + sr, sy - sr * c), Vector.xy(sx + sr, sy) ) path:close() end -- Draw edge shading on balloon (opposite to light source) -- Light is at upper-left, shadow curves from bottom up to top-right -- Now clipped to balloon shape for clean edges local function drawShadow(path: Path, x: number, y: number, radius: number) local ry = radius * 1.2 -- Crescent that curves from bottom, up along the right edge to top-right -- Start at bottom path:moveTo(Vector.xy(x - radius * 0.2, y + ry * 0.8)) -- Outer edge - follows balloon contour from bottom, curving up to top-right path:cubicTo( Vector.xy(x + radius * 0.3, y + ry * 0.85), Vector.xy(x + radius * 0.75, y + ry * 0.7), Vector.xy(x + radius * 1.0, y + ry * 0.4) ) path:cubicTo( Vector.xy(x + radius * 1.15, y + ry * 0.1), Vector.xy(x + radius * 1.15, y - ry * 0.3), Vector.xy(x + radius * 0.9, y - ry * 0.6) ) -- Inner edge - curves back down to start path:cubicTo( Vector.xy(x + radius * 0.7, y - ry * 0.4), Vector.xy(x + radius * 0.6, y - ry * 0.1), Vector.xy(x + radius * 0.55, y + ry * 0.2) ) path:cubicTo( Vector.xy(x + radius * 0.45, y + ry * 0.5), Vector.xy(x + radius * 0.2, y + ry * 0.65), Vector.xy(x - radius * 0.2, y + ry * 0.8) ) path:close() end -- Generate a pseudo-random number local function pseudoRandom(seed: number, index: number): number local x = math.sin(seed * 12.9898 + index * 78.233) * 43758.5453 return x - math.floor(x) end -- Create pop particles when balloon pops local function createPopParticles(balloon: Balloon, seed: number): { PopParticle } local particles: { PopParticle } = {} local numParticles = 8 for i = 1, numParticles do local angle = (i / numParticles) * math.pi * 2 + pseudoRandom(seed, i * 10) * 0.5 local speed = 150 + pseudoRandom(seed, i * 11) * 100 local size = balloon.radius * (0.15 + pseudoRandom(seed, i * 12) * 0.2) local particle: PopParticle = { x = balloon.x + math.cos(angle) * balloon.radius * 0.3, y = balloon.y + math.sin(angle) * balloon.radius * 0.3, vx = math.cos(angle) * speed, vy = math.sin(angle) * speed, rotation = pseudoRandom(seed, i * 13) * math.pi * 2, rotationSpeed = (pseudoRandom(seed, i * 14) - 0.5) * 15, size = size, path = Path.new(), } table.insert(particles, particle) end return particles end -- Draw a balloon fragment (irregular curved shape) local function drawFragment(path: Path, x: number, y: number, size: number, rotation: number) -- Irregular blob shape local points = { { dx = 1.0, dy = 0.0 }, { dx = 0.7, dy = 0.8 }, { dx = -0.3, dy = 0.9 }, { dx = -0.8, dy = 0.2 }, { dx = -0.6, dy = -0.7 }, { dx = 0.2, dy = -0.9 }, } local cosR = math.cos(rotation) local sinR = math.sin(rotation) local function transform(dx: number, dy: number): Vector local rx = dx * cosR - dy * sinR local ry = dx * sinR + dy * cosR return Vector.xy(x + rx * size, y + ry * size) end path:moveTo(transform(points[1].dx, points[1].dy)) for i = 1, #points do local curr = points[i] local next = points[(i % #points) + 1] local midX = (curr.dx + next.dx) / 2 local midY = (curr.dy + next.dy) / 2 path:quadTo(transform(curr.dx * 1.1, curr.dy * 1.1), transform(midX, midY)) end path:close() end -- Vibrant balloon color palette local balloonColors = { { r = 215, g = 45, b = 45 }, -- Red { r = 45, g = 135, b = 215 }, -- Blue { r = 45, g = 185, b = 85 }, -- Green { r = 245, g = 180, b = 50 }, -- Yellow/Orange { r = 180, g = 60, b = 200 }, -- Purple { r = 255, g = 105, b = 145 }, -- Pink { r = 50, g = 200, b = 200 }, -- Cyan { r = 255, g = 130, b = 50 }, -- Orange } -- Create a new balloon local function createBalloon(self: Balloons): Balloon self.balloonIndex = self.balloonIndex + 1 local seed = self.balloonIndex * 17.31 + self.time * 3.7 local radius = self.minRadius + pseudoRandom(seed, 1) * (self.maxRadius - self.minRadius) -- Spawn from bottom local x = radius + pseudoRandom(seed, 2) * (self.boundsWidth - radius * 2) local y = self.boundsHeight + radius * 2 -- Slight horizontal drift local vx = (pseudoRandom(seed, 3) - 0.5) * 0.3 -- Pick a random color from palette local colorIndex = math.floor(pseudoRandom(seed, 7) * #balloonColors) + 1 local baseColor = balloonColors[colorIndex] local r, g, b = baseColor.r, baseColor.g, baseColor.b -- Create color variants for different parts local mainColor = Color.rgb(r, g, b) local darkColor = Color.rgb( math.floor(r * 0.7), math.floor(g * 0.7), math.floor(b * 0.7) ) local lightColor = Color.rgba( math.min(255, r + 80), math.min(255, g + 80), math.min(255, b + 80), 70 ) local shadowColor = Color.rgba( math.floor(r * 0.5), math.floor(g * 0.5), math.floor(b * 0.5), 30 ) return { x = x, y = y, radius = radius, vx = vx, vy = -self.floatSpeed * (0.8 + pseudoRandom(seed, 4) * 0.4), wobblePhase = pseudoRandom(seed, 5) * math.pi * 2, wobbleSpeed = 1.5 + pseudoRandom(seed, 6) * 2, age = 0, popping = false, popProgress = 0, popParticles = {}, color = mainColor, colorDark = darkColor, colorLight = lightColor, colorShadow = shadowColor, path = Path.new(), knotPath = Path.new(), stringPath = Path.new(), highlightPath = Path.new(), specularPath = Path.new(), shadowPath = Path.new(), } end -- Handle collision between two balloons local function resolveBalloonCollision(b1: Balloon, b2: Balloon) if b1.popping or b2.popping then return end local dx = b2.x - b1.x local dy = b2.y - b1.y local dist = math.sqrt(dx * dx + dy * dy) local minDist = b1.radius + b2.radius if dist < minDist and dist > 0.001 then local nx = dx / dist local ny = dy / dist -- Push apart local overlap = (minDist - dist) / 2 b1.x = b1.x - nx * overlap b1.y = b1.y - ny * overlap b2.x = b2.x + nx * overlap b2.y = b2.y + ny * overlap -- Soft bounce (balloons are gentle) local dvx = b1.vx - b2.vx local dvy = b1.vy - b2.vy local dvn = dvx * nx + dvy * ny if dvn > 0 then local bounce = dvn * 0.3 b1.vx = b1.vx - bounce * nx b2.vx = b2.vx + bounce * nx end end end -- Check if point is inside balloon local function isPointInBalloon(balloon: Balloon, px: number, py: number): boolean local dx = px - balloon.x local dy = py - balloon.y local rx = balloon.radius -- Use average of top and bottom for hit test local ry = balloon.radius * 1.2 -- Ellipse hit test (slightly generous) return (dx * dx) / (rx * rx * 1.1) + (dy * dy) / (ry * ry * 1.1) <= 1 end function init(self: Balloons, context: Context): boolean self.balloons = {} self.balloonIndex = 0 self.spawnTimer = 0 self.time = 0 return true end function advance(self: Balloons, seconds: number): boolean self.time = self.time + seconds -- Count non-popping balloons local activeBalloons = 0 for _, balloon in ipairs(self.balloons) do if not balloon.popping then activeBalloons = activeBalloons + 1 end end -- Only spawn new balloons if we have room for active ones -- Spawn one at a time with a small delay self.spawnTimer = self.spawnTimer + seconds local spawnInterval = 1 / self.spawnRate if self.spawnTimer >= spawnInterval and activeBalloons < self.maxBalloons then self.spawnTimer = self.spawnTimer - spawnInterval table.insert(self.balloons, createBalloon(self)) end -- Update each balloon for _, balloon in ipairs(self.balloons) do balloon.age = balloon.age + seconds if balloon.popping then -- Animate pop balloon.popProgress = balloon.popProgress + seconds * 3 -- Update particles for _, particle in ipairs(balloon.popParticles) do -- Apply gravity particle.vy = particle.vy + 400 * seconds -- Apply air resistance particle.vx = particle.vx * (1 - 2 * seconds) particle.vy = particle.vy * (1 - 1.5 * seconds) -- Move particle particle.x = particle.x + particle.vx * seconds particle.y = particle.y + particle.vy * seconds -- Rotate particle.rotation = particle.rotation + particle.rotationSpeed * seconds end else -- Float upward balloon.x = balloon.x + balloon.vx * seconds * 60 balloon.y = balloon.y + balloon.vy * seconds * 60 -- Gentle sway balloon.vx = balloon.vx + math.sin(self.time * 2 + balloon.wobblePhase) * 0.001 -- Dampen horizontal movement balloon.vx = balloon.vx * 0.99 -- Bounce off left/right edges if balloon.x - balloon.radius < 0 then balloon.x = balloon.radius balloon.vx = math.abs(balloon.vx) * 0.5 elseif balloon.x + balloon.radius > self.boundsWidth then balloon.x = self.boundsWidth - balloon.radius balloon.vx = -math.abs(balloon.vx) * 0.5 end -- Stop at top (collect at ceiling) local topLimit = balloon.radius * 1.2 if balloon.y < topLimit then balloon.y = topLimit balloon.vy = 0 -- Gentle bob at top balloon.y = topLimit + math.sin(self.time * 1.5 + balloon.wobblePhase) * 3 end end end -- Balloon-to-balloon collision for i = 1, #self.balloons do for j = i + 1, #self.balloons do resolveBalloonCollision(self.balloons[i], self.balloons[j]) end end -- Remove fully popped balloons and spawn replacements local toRemove: { number } = {} for i, balloon in ipairs(self.balloons) do if balloon.popProgress >= 1 then table.insert(toRemove, i) end end for i = #toRemove, 1, -1 do table.remove(self.balloons, toRemove[i]) end return true end function update(self: Balloons) end -- Handle tap to pop balloons function pointerDown(self: Balloons, event: PointerEvent) local px = event.position.x local py = event.position.y -- Check balloons in reverse order (top-most first) for i = #self.balloons, 1, -1 do local balloon = self.balloons[i] if not balloon.popping and isPointInBalloon(balloon, px, py) then -- Start pop animation balloon.popping = true balloon.popProgress = 0 balloon.popParticles = createPopParticles(balloon, self.time * 100 + i) -- Fire the pop trigger for sound self.popTrigger() event:hit() return end end end function draw(self: Balloons, renderer: Renderer) for _, balloon in ipairs(self.balloons) do if balloon.popping then -- Draw popping animation with particles flying outward local alpha = math.floor(255 * math.max(0, 1 - balloon.popProgress * 1.5)) -- Get balloon color components for burst local r = Color.red(balloon.color) local g = Color.green(balloon.color) local b = Color.blue(balloon.color) -- Draw expanding burst ring (quick flash at start) if balloon.popProgress < 0.3 then local burstScale = 1 + balloon.popProgress * 3 local burstAlpha = math.floor(150 * (1 - balloon.popProgress / 0.3)) balloon.path:reset() drawBalloonBody(balloon.path, balloon.x, balloon.y, balloon.radius * burstScale, 0) self.particlePaint.color = Color.rgba( math.min(255, r + 40), math.min(255, g + 40), math.min(255, b + 40), burstAlpha ) renderer:drawPath(balloon.path, self.particlePaint) end -- Draw flying fragments if alpha > 0 then self.particlePaint.color = Color.rgba(r, g, b, alpha) for _, particle in ipairs(balloon.popParticles) do particle.path:reset() drawFragment(particle.path, particle.x, particle.y, particle.size, particle.rotation) renderer:drawPath(particle.path, self.particlePaint) end end else -- Calculate wobble local wobble = math.sin(self.time * balloon.wobbleSpeed + balloon.wobblePhase) -- Draw string first (behind balloon) balloon.stringPath:reset() drawString(balloon.stringPath, balloon.x, balloon.y, balloon.radius, wobble, self.time) renderer:drawPath(balloon.stringPath, self.stringPaint) -- Draw balloon body with balloon's color balloon.path:reset() drawBalloonBody(balloon.path, balloon.x, balloon.y, balloon.radius, wobble) self.balloonPaint.color = balloon.color renderer:drawPath(balloon.path, self.balloonPaint) -- Draw shadow (darker area) - clipped to balloon shape renderer:save() renderer:clipPath(balloon.path) balloon.shadowPath:reset() drawShadow(balloon.shadowPath, balloon.x, balloon.y, balloon.radius) self.shadowPaint.color = balloon.colorShadow renderer:drawPath(balloon.shadowPath, self.shadowPaint) renderer:restore() -- Draw knot with darker color balloon.knotPath:reset() drawBalloonKnot(balloon.knotPath, balloon.x, balloon.y, balloon.radius) self.knotPaint.color = balloon.colorDark renderer:drawPath(balloon.knotPath, self.knotPaint) -- Draw soft highlight (large glow) with balloon's light color balloon.highlightPath:reset() drawHighlight(balloon.highlightPath, balloon.x, balloon.y, balloon.radius) self.highlightPaint.color = balloon.colorLight renderer:drawPath(balloon.highlightPath, self.highlightPaint) -- Draw specular highlight (bright spot) - white for all balloons balloon.specularPath:reset() drawSpecular(balloon.specularPath, balloon.x, balloon.y, balloon.radius) renderer:drawPath(balloon.specularPath, self.specularPaint) end end end return function(): Node return { -- Configurable inputs boundsWidth = 393, boundsHeight = 892, maxBalloons = 20, minRadius = 30, maxRadius = 50, floatSpeed = 1.5, spawnRate = 1.0, -- Output trigger for pop event popTrigger = function() end, -- Internal state balloons = {}, balloonPaint = Paint.with({ style = 'fill', color = Color.rgb(215, 45, 45) }), knotPaint = Paint.with({ style = 'fill', color = Color.rgb(170, 30, 30) }), shadowPaint = Paint.with({ style = 'fill', color = Color.rgba(120, 20, 20, 30) }), highlightPaint = Paint.with({ style = 'fill', color = Color.rgba(255, 150, 150, 70) }), specularPaint = Paint.with({ style = 'fill', color = Color.rgba(255, 255, 255, 200) }), stringPaint = Paint.with({ style = 'stroke', color = Color.rgb(120, 120, 120), thickness = 1.2 }), particlePaint = Paint.with({ style = 'fill', color = Color.rgb(215, 45, 45) }), time = 0, spawnTimer = 0, balloonIndex = 0, -- Callbacks init = init, advance = advance, update = update, draw = draw, pointerDown = pointerDown, } end