Created
October 7, 2025 07:58
-
-
Save JohnnyD1776/e420400891ea07f2a47e4b4cbfed2037 to your computer and use it in GitHub Desktop.
SwiftUI FlappyBird Demo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| 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