Skip to content

Instantly share code, notes, and snippets.

@JohnnyD1776
Created October 7, 2025 07:58
Show Gist options
  • Select an option

  • Save JohnnyD1776/e420400891ea07f2a47e4b4cbfed2037 to your computer and use it in GitHub Desktop.

Select an option

Save JohnnyD1776/e420400891ea07f2a47e4b4cbfed2037 to your computer and use it in GitHub Desktop.
SwiftUI FlappyBird Demo
/*
Filename: FlappyBirdDemo.swift
Project: Demo Project
Created by John Durcan on 06/10/2025.
Copyright © 2025 Itch Studio Ltd.. All rights reserved.
Company No. 14729010. Registered Address: 128, City Road, London, EC1V 2NX
Licensed under the MIT License. You may obtain a copy of the License at
https:opensource.org/licenses/MIT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
👋 Welcome to my SwiftUI demo showcasing advanced animation and layout techniques!
I'm John Durcan, a seasoned iOS and Mac App Developer passionate about creating intuitive and engaging apps.
🌐 Connect with me on LinkedIn: http:linkedin.com/in/john-durcan
🌟 Check out my portfolio at: https:itch.studio
📱 Explore my AI-powered poetry app: https:poeticai.info
💻 View more of my work on GitHub: https:github.com/JohnnyD1776
☕ Support my development journey: https:ko-fi.com/JohnnyD1776
File Description:
*/
import Combine
import SwiftUI
struct FlappyBirdView: View {
let config: BackgroundConfig // Configurable background settings
let builder: GameSegmentBuilder // Builder for segment generation
@State var birdOffsetY: CGFloat = 0
@State var velocity: CGFloat = 0
@State var backgroundOffset: CGFloat = 0
@State var pipeOffset: CGFloat = 0
@State var hillOffset: CGFloat = 0
@State var isGameOver: Bool = false
@State var segmentConfigs: [SegmentConfig] = []
@State var pipeSegmentConfigs: [PipeSegmentConfig] = []
@State var hillSegmentConfigs: [HillSegmentConfig] = []
@State var screenSize: CGSize? = nil
@State var timerPublisher = PassthroughSubject<Void, Never>()
@State var score: Int = 0 // Score state
@State var passedPipeIds: Set<UUID> = [] // Track passed pipes to increment score
@State private var lastPipePositions: [UUID: CGFloat] = [:] // Track last x-position of pipes
let timer: AnyPublisher<Void, Never>
init(config: BackgroundConfig = BackgroundConfig()) {
self.config = config
self.builder = GameSegmentBuilder(config: config)
self.timer = Timer
.publish(every: config.timerInterval, on: .main, in: .common)
.autoconnect()
.map { _ in () }
.eraseToAnyPublisher()
}
var body: some View {
GeometryReader { geometry in
ZStack {
mainContent(geometry: geometry)
.contentShape(Rectangle()) // Makes entire view tappable
.onTapGesture {
if !isGameOver {
velocity = config.jumpVelocity
}
}
scoreOverlay
if isGameOver {
GameOverView(score: score, onReset: resetGame)
}
}
.onAppear {
// Initialize screenSize and segmentConfigs
if screenSize == nil {
screenSize = geometry.size
setupAssets(geometry: geometry.size)
}
}
.onChange(of: geometry.size) { _, newSize in
// Update screenSize and regenerate segments on size change (e.g., rotation)
let oldSize = screenSize
screenSize = newSize
// Only regenerate if the size has actually changed significantly
if oldSize != newSize {
setupAssets(geometry: newSize)
// Reset game state if game over
if isGameOver {
resetGame()
}
}
}
.onReceive(timer) { _ in
update(geometry: geometry)
}
}
.ignoresSafeArea()
}
// MARK: Game Logic Functions
private func setupAssets(geometry newSize: CGSize) {
let segmentWidth = newSize.width * config.segmentWidthMultiplier
let totalWidth = CGFloat(config.segmentCount) * segmentWidth
segmentConfigs = (0..<config.segmentCount).map { _ in
builder.generateNewSegment(width: segmentWidth, height: newSize.height)
}
// First segment has no pipes
pipeSegmentConfigs = [builder.generateEmptyPipeSegment()] + (0..<config.segmentCount-1).map { _ in
builder.generateNewPipeSegment(width: segmentWidth, height: newSize.height, score: score)
}
hillSegmentConfigs = (0..<config.segmentCount).map { _ in
builder.generateNewHillSegment(width: segmentWidth, height: newSize.height)
}
// Align left edge of first segment with screen's left edge, but shift to start midway into the empty segment
let initialShift = newSize.width // Shift by 1 screen width into the empty segment
backgroundOffset = totalWidth / 2 - newSize.width / 2 - initialShift
pipeOffset = totalWidth / 2 - newSize.width / 2 - initialShift
hillOffset = totalWidth / 2 - newSize.width / 2 - initialShift
}
private func update(geometry: GeometryProxy) {
if !isGameOver {
velocity += config.gravity
birdOffsetY += velocity
// Clamp to screen bounds and check ground collision
let halfHeight = geometry.size.height / 2
let birdHalfSize: CGFloat = 25 // Half of bird height/width
let maxY = halfHeight - birdHalfSize
// Ground collision (bottom of screen)
if birdOffsetY > maxY {
birdOffsetY = maxY
velocity = 0
isGameOver = true
timerPublisher.send()
}
// Pipe collision and score update
if let width = screenSize?.width, let height = screenSize?.height {
let birdRect = CGRect(
x: -15, // Center of bird (x=0), collision box 30x30
y: birdOffsetY - 15,
width: 30,
height: 30
)
let segmentWidth = width * config.segmentWidthMultiplier
let totalWidth = CGFloat(config.segmentCount) * segmentWidth
for (index, segment) in pipeSegmentConfigs.enumerated() {
let segmentOffset = CGFloat(index) * segmentWidth - totalWidth / 2 + 0.5 * segmentWidth
for pipe in segment.pipes {
let pipeX = pipe.x + pipeOffset + segmentOffset
// Score: Increment only when pipe crosses from positive to non-positive x
if let lastX = lastPipePositions[pipe.id] {
if lastX > 0 && pipeX <= 0 && !passedPipeIds.contains(pipe.id) {
score += 1
passedPipeIds.insert(pipe.id)
}
}
lastPipePositions[pipe.id] = pipeX // Update last known position
// Only check pipes near the bird for collision
if pipeX > -50 && pipeX < 50 {
// Bottom pipe and cap
if let bh = pipe.bottomHeight {
let pipeRect = CGRect(
x: pipeX - config.pipeWidth / 2,
y: height / 2 - bh,
width: config.pipeWidth,
height: bh
)
let capRect = CGRect(
x: pipeX - config.pipeCapWidth / 2,
y: height / 2 - bh - config.pipeCapHeight,
width: config.pipeCapWidth,
height: config.pipeCapHeight
)
if birdRect.intersects(pipeRect) || birdRect.intersects(capRect) {
isGameOver = true
timerPublisher.send()
break
}
}
// Top pipe and cap
if let th = pipe.topHeight {
let pipeRect = CGRect(
x: pipeX - config.pipeWidth / 2,
y: -height / 2,
width: config.pipeWidth,
height: th
)
let capRect = CGRect(
x: pipeX - config.pipeCapWidth / 2,
y: -height / 2 + th,
width: config.pipeCapWidth,
height: config.pipeCapHeight
)
if birdRect.intersects(pipeRect) || birdRect.intersects(capRect) {
isGameOver = true
timerPublisher.send()
break
}
}
}
}
if isGameOver { break }
}
// Clean up lastPipePositions for pipes no longer in segments
let currentPipeIds = pipeSegmentConfigs.flatMap { $0.pipes }.map { $0.id }
lastPipePositions = lastPipePositions.filter { currentPipeIds.contains($0.key) }
}
}
}
private func resetGame() {
birdOffsetY = 0
velocity = 0
backgroundOffset = 0
pipeOffset = 0
hillOffset = 0
isGameOver = false
score = 0 // Reset score
passedPipeIds = [] // Reset passed pipes
if let screenSize {
setupAssets(geometry: screenSize)
}
timerPublisher = PassthroughSubject<Void, Never>()
}
}
// <-- File: AssetsConfigs.swift
struct BuildingConfig {
let symbol: String
let width: CGFloat
let height: CGFloat
let x: CGFloat
let y: CGFloat
let opacity: Double
}
struct CloudConfig {
let size: CGFloat
let x: CGFloat
let y: CGFloat
let opacity: Double
}
struct SunConfig {
let x: CGFloat
let y: CGFloat
}
struct HillConfig {
let width: CGFloat
let height: CGFloat
let x: CGFloat
let y: CGFloat
let opacity: Double
}
struct SegmentConfig {
let buildings: [BuildingConfig]
let clouds: [CloudConfig]
let sun: SunConfig?
}
struct PipeConfig {
let id: UUID = UUID() // Unique ID for tracking passed pipes
let x: CGFloat
let bottomHeight: CGFloat?
let topHeight: CGFloat?
}
struct PipeSegmentConfig {
let pipes: [PipeConfig]
}
struct HillSegmentConfig {
let hills: [HillConfig]
}
//- FILE: BackgroundConfig.swift //
// Configuration struct for background customization
struct BackgroundConfig {
// Building settings
let buildingSymbols: [String] = ["building.fill", "building.2.fill", "house.fill"]
let buildingsPerSegment: Int = 8
let buildingWidthRange: ClosedRange<CGFloat> = 50...120
let buildingHeightRange: ClosedRange<CGFloat> = 100...250
let buildingYOffsetVariation: ClosedRange<CGFloat> = -30...30
let buildingOpacityRange: ClosedRange<Double> = 0.4...0.7
let buildingZoneOffsetFraction: CGFloat = 0.4 // Fraction of zone width for random offset (0.0 = no randomness, 0.5 = half zone)
// Cloud settings
let cloudsPerSegment: Int = 5
let cloudSizeRange: ClosedRange<CGFloat> = 80...150
let cloudYRangeFraction: (min: CGFloat, max: CGFloat) = (min: 0.25, max: 0.5) // Fractions of height for y-range (higher values = lower in sky)
let cloudOpacityRange: ClosedRange<Double> = 0.5...0.8
let cloudZoneOffsetFraction: CGFloat = 0.3 // Fraction of zone width for random offset
let cloudMinSpacing: CGFloat = 50 // Minimum horizontal spacing between clouds
// Sun settings
let sunProbability: Double = 0.5 // Chance of sun appearing in a segment (0.0 to 1.0)
let sunSize: CGFloat = 60
let sunXRangeFraction: CGFloat = 0.3 // Fraction of width for x-range (± width * fraction)
let sunYFraction: CGFloat = 0.3 // Fraction of height for y-position (from top)
// Pipe settings
let pipeSpeed: CGFloat = 2.0
let pipesPerSegment: Int = 9 // Increased for more pipes per segment
let pipeZoneOffsetFraction: CGFloat = 0.4 // Fraction of zone width for random offset
let pipeMinSpacing: CGFloat = 80 // Minimum horizontal spacing between pipes
let pipeHeightMin: CGFloat = 20
let pipeHeightMaxFraction: CGFloat = 2/3
let pipeWidth: CGFloat = 20
let pipeCapWidth: CGFloat = 30
let pipeCapHeight: CGFloat = 30
let pipeGapRange: ClosedRange<CGFloat> = 220...320 // Range for randomization per pipe
let pipeColor: Color = .orange
// Hill settings
let hillSpeed: CGFloat = 1.5 // Speed for hills layer (between background and pipes)
let hillsPerSegment: Int = 26 // Number of hills per segment
let hillWidthRange: ClosedRange<CGFloat> = 150...300 // Width for oblong hills
let hillHeightRange: ClosedRange<CGFloat> = 80...200 // Height for hills
let hillSpacingRange: ClosedRange<CGFloat> = 20...150 // Spacing between hills
let hillOpacityRange: ClosedRange<Double> = 0.7...0.9 // Opacity for hills
let hillColor: Color = .green // Green color for hills
let hillYFraction: CGFloat = -0.1 // Fraction of height from bottom for hill placement
// General settings
let backgroundSpeed: CGFloat = 1.0
let segmentCount: Int = 4 // Number of segments for scrolling buffer
let segmentWidthMultiplier: CGFloat = 2.0 // Segment width as multiple of screen width
let buildingColor: Color = .gray
let cloudColor: Color = .white
let sunColor: Color = .yellow
let skyGradient: Gradient = Gradient(colors: [.cyan.opacity(0.8), .blue.opacity(0.6)])
let gravity: CGFloat = 0.2
let jumpVelocity: CGFloat = -4.0
let timerInterval: Double = 0.016 // ~60 FPS
}
// <- FILE: FlappyBirdDemo+ViewBuilders
extension FlappyBirdView {
// MARK: ViewBuilders
// Score display
var scoreOverlay: some View {
VStack {
Text("Score: \(score)")
.font(.title)
.fontWeight(.bold)
.foregroundColor(.white)
.padding()
.background(Color.black.opacity(0.5))
.cornerRadius(10)
.padding(.top, 20)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .topLeading)
}
func mainContent(geometry: GeometryProxy) -> some View {
ZStack {
LinearGradient(
gradient: config.skyGradient,
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
hillsLayer(geometry: geometry)
backgroundLayer(geometry: geometry)
pipesLayer(geometry: geometry)
bird(geometry: geometry)
}
.ignoresSafeArea()
.frame(width: geometry.size.width, height: geometry.size.height)
}
// Bird
func bird(geometry: GeometryProxy) -> some View {
Image(systemName: "bird.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 50, height: 50)
.offset(y: birdOffsetY)
.foregroundColor(.blue)
}
func hillsLayer(geometry: GeometryProxy) -> some View {
HStack(spacing: 0) {
ForEach(0..<hillSegmentConfigs.count, id: \.self) { i in
hillContent(segment: hillSegmentConfigs[i], width: geometry.size.width * config.segmentWidthMultiplier, height: geometry.size.height)
}
}
.offset(x: hillOffset)
.onReceive(timer) { _ in
if !isGameOver, let width = screenSize?.width {
hillOffset -= config.hillSpeed
// Seamless looping with recycling
let segmentWidth = width * config.segmentWidthMultiplier
if hillOffset <= -segmentWidth {
hillOffset += segmentWidth
if let height = screenSize?.height {
hillSegmentConfigs.removeFirst()
hillSegmentConfigs.append(builder.generateNewHillSegment(width: segmentWidth, height: height))
}
}
}
}
}
func pipesLayer(geometry: GeometryProxy) -> some View {
HStack(spacing: 0) {
ForEach(0..<pipeSegmentConfigs.count, id: \.self) { i in
pipeContent(segment: pipeSegmentConfigs[i], width: geometry.size.width * config.segmentWidthMultiplier, height: geometry.size.height)
}
}
.offset(x: pipeOffset)
.onReceive(timer) { _ in
if !isGameOver, let width = screenSize?.width {
pipeOffset -= config.pipeSpeed
// Seamless looping with recycling
let segmentWidth = width * config.segmentWidthMultiplier
if pipeOffset <= -segmentWidth {
pipeOffset += segmentWidth
if let height = screenSize?.height {
pipeSegmentConfigs.removeFirst()
pipeSegmentConfigs.append(builder.generateNewPipeSegment(width: segmentWidth, height: height, score: score))
}
}
}
}
}
func backgroundLayer(geometry: GeometryProxy) -> some View {
HStack(spacing: 0) {
ForEach(0..<segmentConfigs.count, id: \.self) { i in
backgroundContent(segment: segmentConfigs[i], width: geometry.size.width * config.segmentWidthMultiplier, height: geometry.size.height)
}
}
.offset(x: backgroundOffset)
.onReceive(timer) { _ in
if !isGameOver, let width = screenSize?.width {
backgroundOffset -= config.backgroundSpeed
// Seamless looping with recycling
let segmentWidth = width * config.segmentWidthMultiplier
if backgroundOffset <= -segmentWidth {
backgroundOffset += segmentWidth
if let height = screenSize?.height {
segmentConfigs.removeFirst()
segmentConfigs.append(builder.generateNewSegment(width: segmentWidth, height: height))
}
}
}
}
}
@ViewBuilder
func backgroundContent(segment: SegmentConfig, width: CGFloat, height: CGFloat) -> some View {
ZStack {
// Buildings
ForEach(0..<segment.buildings.count, id: \.self) { j in
let config = segment.buildings[j]
Image(systemName: config.symbol)
.resizable()
.scaledToFit()
.frame(width: config.width, height: config.height)
.foregroundColor(self.config.buildingColor.opacity(config.opacity))
.offset(x: config.x, y: config.y)
}
// Sun
if let sun = segment.sun {
Image(systemName: "sun.max.fill")
.resizable()
.scaledToFit()
.frame(width: self.config.sunSize, height: self.config.sunSize)
.foregroundColor(self.config.sunColor.opacity(0.8))
.offset(x: sun.x, y: sun.y)
}
// Clouds
ForEach(0..<segment.clouds.count, id: \.self) { j in
let config = segment.clouds[j]
Image(systemName: "cloud.fill")
.resizable()
.scaledToFit()
.frame(width: config.size, height: config.size * 0.6)
.foregroundColor(self.config.cloudColor.opacity(config.opacity))
.offset(x: config.x, y: config.y)
}
}
.frame(width: width, height: height)
}
@ViewBuilder
func pipeContent(segment: PipeSegmentConfig, width: CGFloat, height: CGFloat) -> some View {
ZStack {
ForEach(0..<segment.pipes.count, id: \.self) { j in
let pipe = segment.pipes[j]
if let bh = pipe.bottomHeight {
// Bottom pipe
Rectangle()
.fill(config.pipeColor)
.frame(width: config.pipeWidth, height: bh)
.offset(x: pipe.x, y: height / 2 - bh / 2)
// Cap at top
Rectangle()
.fill(config.pipeColor)
.frame(width: config.pipeCapWidth, height: config.pipeCapHeight)
.offset(x: pipe.x, y: height / 2 - bh - config.pipeCapHeight / 2)
}
if let th = pipe.topHeight {
// Top pipe
Rectangle()
.fill(config.pipeColor)
.frame(width: config.pipeWidth, height: th)
.offset(x: pipe.x, y: -height / 2 + th / 2)
// Cap at bottom
Rectangle()
.fill(config.pipeColor)
.frame(width: config.pipeCapWidth, height: config.pipeCapHeight)
.offset(x: pipe.x, y: -height / 2 + th + config.pipeCapHeight / 2)
}
}
}
.frame(width: width, height: height)
}
@ViewBuilder
func hillContent(segment: HillSegmentConfig, width: CGFloat, height: CGFloat) -> some View {
ZStack {
ForEach(0..<segment.hills.count, id: \.self) { j in
let hill = segment.hills[j]
Ellipse()
.fill(config.hillColor.opacity(hill.opacity))
.frame(width: hill.width, height: hill.height)
.offset(x: hill.x, y: hill.y)
}
}
.frame(width: width, height: height)
}
}
// <-- FILE: GameOverView.swift
struct GameOverView: View {
let score: Int // Added score parameter
let onReset: () -> Void
@State private var scale: CGFloat = 0.5 // Initial scale for animation
@State private var opacity: Double = 0.0 // Initial opacity for animation
var body: some View {
ZStack {
Color.black.opacity(0.5)
.ignoresSafeArea()
VStack {
Text("Game Over")
.font(.largeTitle)
.fontWeight(.bold)
.foregroundColor(.white)
.padding()
Text("Score: \(score)")
.font(.title2)
.foregroundColor(.white)
.padding(.bottom)
Button(action: onReset) {
Text("Try Again")
.font(.title2)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
}
.padding()
.background(Color.gray.opacity(0.8))
.cornerRadius(20)
.scaleEffect(scale) // Apply scale animation
.opacity(opacity) // Apply fade animation
.onAppear {
withAnimation(.spring(response: 0.5, dampingFraction: 0.6, blendDuration: 0)) {
scale = 1.0 // Scale up to normal size
opacity = 1.0 // Fade in
}
}
}
}
}
// <-- GameSegmentBuilder.swift
// Builder class to handle segment generation
struct GameSegmentBuilder {
let config: BackgroundConfig
func generateNewSegment(width: CGFloat, height: CGFloat) -> SegmentConfig {
// Buildings: Distribute across zones with randomness
var buildings: [BuildingConfig] = []
let buildingZoneWidth = width / CGFloat(config.buildingsPerSegment)
for i in 0..<config.buildingsPerSegment {
let symbol = config.buildingSymbols.randomElement() ?? config.buildingSymbols[0]
let bWidth = CGFloat.random(in: config.buildingWidthRange)
let bHeight = CGFloat.random(in: config.buildingHeightRange)
// Center of the i-th zone, with random offset within fraction of the zone width
let zoneCenter = -width / 2 + buildingZoneWidth * (CGFloat(i) + 0.5)
let x = zoneCenter + CGFloat.random(in: -buildingZoneWidth * config.buildingZoneOffsetFraction ... buildingZoneWidth * config.buildingZoneOffsetFraction)
let y = height / 2 - bHeight / 2 + CGFloat.random(in: config.buildingYOffsetVariation) // Slight variation for skyline
let opacity = Double.random(in: config.buildingOpacityRange)
buildings.append(BuildingConfig(symbol: symbol, width: bWidth, height: bHeight, x: x, y: y, opacity: opacity))
}
// Clouds: Distribute across zones with minimum spacing and randomness
var clouds: [CloudConfig] = []
let cloudZoneWidth = width / CGFloat(config.cloudsPerSegment)
var usedXPositions: [CGFloat] = []
for i in 0..<config.cloudsPerSegment {
let size = CGFloat.random(in: config.cloudSizeRange)
let zoneCenter = -width / 2 + cloudZoneWidth * (CGFloat(i) + 0.5)
var x = zoneCenter + CGFloat.random(in: -cloudZoneWidth * config.cloudZoneOffsetFraction ... cloudZoneWidth * config.cloudZoneOffsetFraction)
// Ensure minimum spacing
while usedXPositions.contains(where: { abs($0 - x) < config.cloudMinSpacing }) {
x = zoneCenter + CGFloat.random(in: -cloudZoneWidth * config.cloudZoneOffsetFraction ... cloudZoneWidth * config.cloudZoneOffsetFraction)
}
usedXPositions.append(x)
let y = CGFloat.random(in: -height * config.cloudYRangeFraction.max ... -height * config.cloudYRangeFraction.min)
let opacity = Double.random(in: config.cloudOpacityRange)
clouds.append(CloudConfig(size: size, x: x, y: y, opacity: opacity))
}
// Sun: Random but avoid cloud-heavy zones
let sun: SunConfig? = Double.random(in: 0...1) < config.sunProbability ? {
let sunX = CGFloat.random(in: -width * config.sunXRangeFraction ... width * config.sunXRangeFraction)
return SunConfig(x: sunX, y: -height * config.sunYFraction)
}() : nil
return SegmentConfig(buildings: buildings, clouds: clouds, sun: sun)
}
func generateNewPipeSegment(width: CGFloat, height: CGFloat, score: Int) -> PipeSegmentConfig {
let maxHeight = height * config.pipeHeightMaxFraction
let pipeHeightRange: ClosedRange<CGFloat> = config.pipeHeightMin...maxHeight
// Increasing difficulty: Reduce gap as score increases
let difficultyFactor = min(1.0, CGFloat(score) / 3.0) // Ramp up over 50 points
let minGap = config.pipeGapRange.lowerBound * (1 - 0.5 * difficultyFactor) // Reduce by up to 50%
let maxGap = config.pipeGapRange.upperBound * (1 - 0.5 * difficultyFactor)
let pipeGapRangeAdjusted = minGap...maxGap
// Pipes: Distribute across zones with minimum spacing
var pipes: [PipeConfig] = []
let pipeZoneWidth = width / CGFloat(config.pipesPerSegment)
var usedXPositions: [CGFloat] = []
for i in 0..<config.pipesPerSegment {
let zoneCenter = -width / 2 + pipeZoneWidth * (CGFloat(i) + 0.5)
var x = zoneCenter + CGFloat.random(in: -pipeZoneWidth * config.pipeZoneOffsetFraction ... pipeZoneWidth * config.pipeZoneOffsetFraction)
// Ensure minimum spacing
while usedXPositions.contains(where: { abs($0 - x) < config.pipeMinSpacing }) {
x = zoneCenter + CGFloat.random(in: -pipeZoneWidth * config.pipeZoneOffsetFraction ... pipeZoneWidth * config.pipeZoneOffsetFraction)
}
usedXPositions.append(x)
let style = Int.random(in: 0...2)
switch style {
case 0: // Single from ground
let bottomHeight = CGFloat.random(in: pipeHeightRange)
pipes.append(PipeConfig(x: x, bottomHeight: bottomHeight, topHeight: nil))
case 1: // Single from top
let topHeight = CGFloat.random(in: pipeHeightRange)
pipes.append(PipeConfig(x: x, bottomHeight: nil, topHeight: topHeight))
default: // Double
let pipeGap = CGFloat.random(in: pipeGapRangeAdjusted)
let minH = config.pipeHeightMin
let maxH = maxHeight
let lowerBound = max(minH, height - pipeGap - maxH)
let upperBound = min(maxH, height - pipeGap - minH)
let bottomHeight = CGFloat.random(in: lowerBound ... upperBound)
let topHeight = height - pipeGap - bottomHeight
pipes.append(PipeConfig(x: x, bottomHeight: bottomHeight, topHeight: topHeight))
}
}
return PipeSegmentConfig(pipes: pipes)
}
func generateEmptyPipeSegment() -> PipeSegmentConfig {
return PipeSegmentConfig(pipes: []) // Empty segment with no pipes
}
func generateNewHillSegment(width: CGFloat, height: CGFloat) -> HillSegmentConfig {
var hillPositions: [CGFloat] = []
var currentX = -width / 2 + CGFloat.random(in: 0 ... config.hillSpacingRange.lowerBound / 4)
for _ in 0..<config.hillsPerSegment {
hillPositions.append(currentX)
let spacing = CGFloat.random(in: config.hillSpacingRange)
currentX += spacing
}
var hills: [HillConfig] = []
for pos in hillPositions {
let hWidth = CGFloat.random(in: config.hillWidthRange)
let hHeight = CGFloat.random(in: config.hillHeightRange)
let x = pos
let y = height / 2 - hHeight / 2 - height * config.hillYFraction // Position near bottom
let opacity = Double.random(in: config.hillOpacityRange)
hills.append(HillConfig(width: hWidth, height: hHeight, x: x, y: y, opacity: opacity))
}
return HillSegmentConfig(hills: hills)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment