From 9e501cc4e8242422e5980312506a9307c8fa963d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Dec 2025 16:47:49 +0000 Subject: [PATCH] Add RollkofferSimulator iOS SpriteKit arcade game Complete implementation of a 2D top-down arcade collector game where players control a rolling suitcase through an airport, collecting good dogs and green people while avoiding bad dogs and gray people. Features: - Touch & drag controls for suitcase movement - Automatic scrolling airport floor with tile pattern - 4 entity types: good dogs (small/big), bad dogs, green/gray humans - Spawn system with configurable distribution rates - Collision detection with visual feedback effects - Score tracking with high score persistence - 90-second time limit with 10 dogs + 5 humans goal - 3 lives system with invincibility frames - Menu, Game, GameOver, and Victory scenes - German UI text (Created by Ingo K.) Technical: - iOS 15+ with SpriteKit framework - Modular architecture with Nodes, Managers, Scenes - Physics-based collision detection - UserDefaults for score persistence --- RollkofferSimulator/AppDelegate.swift | 42 ++ .../AccentColor.colorset/Contents.json | 20 + .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + .../Base.lproj/LaunchScreen.storyboard | 66 +++ .../Base.lproj/Main.storyboard | 26 + RollkofferSimulator/GameViewController.swift | 79 +++ RollkofferSimulator/Info.plist | 54 ++ .../Managers/CollisionManager.swift | 197 +++++++ .../Managers/ScoreManager.swift | 91 +++ .../Managers/SpawnManager.swift | 104 ++++ RollkofferSimulator/Models/EntityType.swift | 58 ++ RollkofferSimulator/Models/GameState.swift | 103 ++++ RollkofferSimulator/Nodes/DogNode.swift | 162 ++++++ RollkofferSimulator/Nodes/HumanNode.swift | 176 ++++++ RollkofferSimulator/Nodes/PlayerNode.swift | 126 ++++ .../project.pbxproj | 457 +++++++++++++++ .../Scenes/GameOverScene.swift | 255 +++++++++ RollkofferSimulator/Scenes/GameScene.swift | 537 ++++++++++++++++++ RollkofferSimulator/Scenes/MenuScene.swift | 265 +++++++++ RollkofferSimulator/Scenes/VictoryScene.swift | 312 ++++++++++ RollkofferSimulator/Utils/Constants.swift | 79 +++ 22 files changed, 3228 insertions(+) create mode 100644 RollkofferSimulator/AppDelegate.swift create mode 100644 RollkofferSimulator/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 RollkofferSimulator/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 RollkofferSimulator/Assets.xcassets/Contents.json create mode 100644 RollkofferSimulator/Base.lproj/LaunchScreen.storyboard create mode 100644 RollkofferSimulator/Base.lproj/Main.storyboard create mode 100644 RollkofferSimulator/GameViewController.swift create mode 100644 RollkofferSimulator/Info.plist create mode 100644 RollkofferSimulator/Managers/CollisionManager.swift create mode 100644 RollkofferSimulator/Managers/ScoreManager.swift create mode 100644 RollkofferSimulator/Managers/SpawnManager.swift create mode 100644 RollkofferSimulator/Models/EntityType.swift create mode 100644 RollkofferSimulator/Models/GameState.swift create mode 100644 RollkofferSimulator/Nodes/DogNode.swift create mode 100644 RollkofferSimulator/Nodes/HumanNode.swift create mode 100644 RollkofferSimulator/Nodes/PlayerNode.swift create mode 100644 RollkofferSimulator/RollkofferSimulator.xcodeproj/project.pbxproj create mode 100644 RollkofferSimulator/Scenes/GameOverScene.swift create mode 100644 RollkofferSimulator/Scenes/GameScene.swift create mode 100644 RollkofferSimulator/Scenes/MenuScene.swift create mode 100644 RollkofferSimulator/Scenes/VictoryScene.swift create mode 100644 RollkofferSimulator/Utils/Constants.swift diff --git a/RollkofferSimulator/AppDelegate.swift b/RollkofferSimulator/AppDelegate.swift new file mode 100644 index 0000000..5a9cbe4 --- /dev/null +++ b/RollkofferSimulator/AppDelegate.swift @@ -0,0 +1,42 @@ +// +// AppDelegate.swift +// RollkofferSimulator +// +// Created by Ingo K. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Pause the game when app goes to background + NotificationCenter.default.post(name: .pauseGame, object: nil) + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Save game state if needed + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Restore game state if needed + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Resume game if needed + } +} + +// MARK: - Notification Names +extension Notification.Name { + static let pauseGame = Notification.Name("pauseGame") + static let resumeGame = Notification.Name("resumeGame") +} diff --git a/RollkofferSimulator/Assets.xcassets/AccentColor.colorset/Contents.json b/RollkofferSimulator/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..27ea04d --- /dev/null +++ b/RollkofferSimulator/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.600", + "green" : "0.300", + "red" : "0.400" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RollkofferSimulator/Assets.xcassets/AppIcon.appiconset/Contents.json b/RollkofferSimulator/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/RollkofferSimulator/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RollkofferSimulator/Assets.xcassets/Contents.json b/RollkofferSimulator/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/RollkofferSimulator/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RollkofferSimulator/Base.lproj/LaunchScreen.storyboard b/RollkofferSimulator/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..d140726 --- /dev/null +++ b/RollkofferSimulator/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/RollkofferSimulator/Base.lproj/Main.storyboard b/RollkofferSimulator/Base.lproj/Main.storyboard new file mode 100644 index 0000000..29f730e --- /dev/null +++ b/RollkofferSimulator/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/RollkofferSimulator/GameViewController.swift b/RollkofferSimulator/GameViewController.swift new file mode 100644 index 0000000..9d0e5a3 --- /dev/null +++ b/RollkofferSimulator/GameViewController.swift @@ -0,0 +1,79 @@ +// +// GameViewController.swift +// RollkofferSimulator +// +// Created by Ingo K. +// + +import UIKit +import SpriteKit +import GameplayKit + +class GameViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + // Configure the view + guard let skView = self.view as? SKView else { + fatalError("View is not an SKView") + } + + // Create and configure the initial scene + let scene = MenuScene(size: skView.bounds.size) + scene.scaleMode = .aspectFill + + // Configure view options + skView.ignoresSiblingOrder = true + + #if DEBUG + skView.showsFPS = true + skView.showsNodeCount = true + #endif + + // Present the scene + skView.presentScene(scene) + + // Setup notification observers + setupNotificationObservers() + } + + private func setupNotificationObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handlePauseNotification), + name: .pauseGame, + object: nil + ) + } + + @objc private func handlePauseNotification() { + guard let skView = self.view as? SKView, + let gameScene = skView.scene as? GameScene else { + return + } + + // The GameScene should handle pausing internally + // This is just a notification that the app is going to background + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + if UIDevice.current.userInterfaceIdiom == .phone { + return .portrait + } else { + return .all + } + } + + override var prefersStatusBarHidden: Bool { + return true + } + + override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { + return .all + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} diff --git a/RollkofferSimulator/Info.plist b/RollkofferSimulator/Info.plist new file mode 100644 index 0000000..c05f1e7 --- /dev/null +++ b/RollkofferSimulator/Info.plist @@ -0,0 +1,54 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Rollkoffer Simulator + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UIRequiresFullScreen + + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/RollkofferSimulator/Managers/CollisionManager.swift b/RollkofferSimulator/Managers/CollisionManager.swift new file mode 100644 index 0000000..d227bae --- /dev/null +++ b/RollkofferSimulator/Managers/CollisionManager.swift @@ -0,0 +1,197 @@ +// +// CollisionManager.swift +// RollkofferSimulator +// +// Created by Ingo K. +// + +import SpriteKit + +/// Protocol for collision event handling +protocol CollisionManagerDelegate: AnyObject { + func didCollectGoodDog(points: Int) + func didCollectGreenHuman(points: Int) + func didHitHarmfulEntity() +} + +/// Manages collision detection and response +class CollisionManager: NSObject, SKPhysicsContactDelegate { + + // MARK: - Properties + weak var delegate: CollisionManagerDelegate? + + // MARK: - SKPhysicsContactDelegate + func didBegin(_ contact: SKPhysicsContact) { + let collision = contact.bodyA.categoryBitMask | contact.bodyB.categoryBitMask + + // Check if suitcase is involved + guard collision & Constants.PhysicsCategory.suitcase != 0 else { return } + + let otherBody: SKPhysicsBody + if contact.bodyA.categoryBitMask == Constants.PhysicsCategory.suitcase { + otherBody = contact.bodyB + } else { + otherBody = contact.bodyA + } + + handleCollision(with: otherBody) + } + + // MARK: - Private Methods + private func handleCollision(with body: SKPhysicsBody) { + guard let node = body.node else { return } + + switch body.categoryBitMask { + case Constants.PhysicsCategory.goodDog: + handleGoodDogCollision(node: node) + + case Constants.PhysicsCategory.badDog: + handleBadDogCollision(node: node) + + case Constants.PhysicsCategory.greenHuman: + handleGreenHumanCollision(node: node) + + case Constants.PhysicsCategory.grayHuman: + handleGrayHumanCollision(node: node) + + default: + break + } + } + + private func handleGoodDogCollision(node: SKNode) { + guard let dogNode = node as? DogNode else { return } + + let points = dogNode.dogType.points + showCollectEffect(at: node.position, color: .green, text: "+\(points)") + node.removeFromParent() + delegate?.didCollectGoodDog(points: points) + } + + private func handleBadDogCollision(node: SKNode) { + showDamageEffect(at: node.position) + node.removeFromParent() + delegate?.didHitHarmfulEntity() + } + + private func handleGreenHumanCollision(node: SKNode) { + guard let humanNode = node as? HumanNode else { return } + + let points = humanNode.humanType.points + showCollectEffect(at: node.position, color: .green, text: "+\(points)") + node.removeFromParent() + delegate?.didCollectGreenHuman(points: points) + } + + private func handleGrayHumanCollision(node: SKNode) { + showDamageEffect(at: node.position) + node.removeFromParent() + delegate?.didHitHarmfulEntity() + } + + // MARK: - Visual Effects + private func showCollectEffect(at position: CGPoint, color: SKColor, text: String) { + guard let scene = getScene() else { return } + + // Particle burst + let emitter = SKEmitterNode() + emitter.particleTexture = nil + emitter.particleBirthRate = 50 + emitter.numParticlesToEmit = 20 + emitter.particleLifetime = 0.5 + emitter.particleSpeed = 100 + emitter.particleSpeedRange = 50 + emitter.emissionAngleRange = .pi * 2 + emitter.particleScale = 0.3 + emitter.particleScaleRange = 0.2 + emitter.particleColor = color + emitter.particleColorBlendFactor = 1.0 + emitter.position = position + emitter.zPosition = Constants.ZPosition.ui - 1 + + // Create a simple circle shape for particles + let shape = SKShapeNode(circleOfRadius: 5) + shape.fillColor = color + shape.strokeColor = .clear + if let texture = scene.view?.texture(from: shape) { + emitter.particleTexture = texture + } + + scene.addChild(emitter) + + let waitAction = SKAction.wait(forDuration: 1.0) + let removeAction = SKAction.removeFromParent() + emitter.run(SKAction.sequence([waitAction, removeAction])) + + // Floating text + let label = SKLabelNode(text: text) + label.fontName = "AvenirNext-Bold" + label.fontSize = 24 + label.fontColor = color + label.position = position + label.zPosition = Constants.ZPosition.ui + + scene.addChild(label) + + let moveUp = SKAction.moveBy(x: 0, y: 50, duration: 0.5) + let fadeOut = SKAction.fadeOut(withDuration: 0.5) + let group = SKAction.group([moveUp, fadeOut]) + let remove = SKAction.removeFromParent() + label.run(SKAction.sequence([group, remove])) + } + + private func showDamageEffect(at position: CGPoint) { + guard let scene = getScene() else { return } + + // Red flash + let flash = SKShapeNode(circleOfRadius: 30) + flash.fillColor = .red + flash.strokeColor = .clear + flash.alpha = 0.7 + flash.position = position + flash.zPosition = Constants.ZPosition.ui - 1 + + scene.addChild(flash) + + let scaleUp = SKAction.scale(to: 2.0, duration: 0.2) + let fadeOut = SKAction.fadeOut(withDuration: 0.2) + let group = SKAction.group([scaleUp, fadeOut]) + let remove = SKAction.removeFromParent() + flash.run(SKAction.sequence([group, remove])) + + // Floating text + let label = SKLabelNode(text: "-1 โค๏ธ") + label.fontName = "AvenirNext-Bold" + label.fontSize = 24 + label.fontColor = .red + label.position = position + label.zPosition = Constants.ZPosition.ui + + scene.addChild(label) + + let moveUp = SKAction.moveBy(x: 0, y: 50, duration: 0.5) + let labelFadeOut = SKAction.fadeOut(withDuration: 0.5) + let labelGroup = SKAction.group([moveUp, labelFadeOut]) + let labelRemove = SKAction.removeFromParent() + label.run(SKAction.sequence([labelGroup, labelRemove])) + } + + private func getScene() -> SKScene? { + // This would typically be set via dependency injection + // For simplicity, we'll use the notification pattern + return nil + } +} + +// MARK: - Scene Reference Extension +extension CollisionManager { + private static var sceneReference: SKScene? + + func setScene(_ scene: SKScene) { + CollisionManager.sceneReference = scene + } + + private func getSceneFromReference() -> SKScene? { + return CollisionManager.sceneReference + } +} diff --git a/RollkofferSimulator/Managers/ScoreManager.swift b/RollkofferSimulator/Managers/ScoreManager.swift new file mode 100644 index 0000000..4445ae6 --- /dev/null +++ b/RollkofferSimulator/Managers/ScoreManager.swift @@ -0,0 +1,91 @@ +// +// ScoreManager.swift +// RollkofferSimulator +// +// Created by Ingo K. +// + +import Foundation + +/// Manages high scores and game statistics +class ScoreManager { + + // MARK: - Singleton + static let shared = ScoreManager() + + // MARK: - UserDefaults Keys + private let highScoreKey = "RollkofferSimulator.HighScore" + private let gamesPlayedKey = "RollkofferSimulator.GamesPlayed" + private let totalDogsCollectedKey = "RollkofferSimulator.TotalDogsCollected" + private let totalHumansCollectedKey = "RollkofferSimulator.TotalHumansCollected" + private let victoriesKey = "RollkofferSimulator.Victories" + + // MARK: - Properties + private let defaults = UserDefaults.standard + + var highScore: Int { + get { defaults.integer(forKey: highScoreKey) } + set { defaults.set(newValue, forKey: highScoreKey) } + } + + var gamesPlayed: Int { + get { defaults.integer(forKey: gamesPlayedKey) } + set { defaults.set(newValue, forKey: gamesPlayedKey) } + } + + var totalDogsCollected: Int { + get { defaults.integer(forKey: totalDogsCollectedKey) } + set { defaults.set(newValue, forKey: totalDogsCollectedKey) } + } + + var totalHumansCollected: Int { + get { defaults.integer(forKey: totalHumansCollectedKey) } + set { defaults.set(newValue, forKey: totalHumansCollectedKey) } + } + + var victories: Int { + get { defaults.integer(forKey: victoriesKey) } + set { defaults.set(newValue, forKey: victoriesKey) } + } + + // MARK: - Initialization + private init() {} + + // MARK: - Public Methods + func recordGameEnd(score: Int, dogsCollected: Int, humansCollected: Int, didWin: Bool) { + gamesPlayed += 1 + totalDogsCollected += dogsCollected + totalHumansCollected += humansCollected + + if score > highScore { + highScore = score + } + + if didWin { + victories += 1 + } + } + + func isNewHighScore(_ score: Int) -> Bool { + return score > highScore + } + + func resetStatistics() { + highScore = 0 + gamesPlayed = 0 + totalDogsCollected = 0 + totalHumansCollected = 0 + victories = 0 + } + + // MARK: - Formatted Statistics + func getStatisticsText() -> String { + return """ + High Score: \(highScore) + Games Played: \(gamesPlayed) + Victories: \(victories) + Total Dogs: \(totalDogsCollected) + Total Humans: \(totalHumansCollected) + """ + } +} diff --git a/RollkofferSimulator/Managers/SpawnManager.swift b/RollkofferSimulator/Managers/SpawnManager.swift new file mode 100644 index 0000000..ba7515f --- /dev/null +++ b/RollkofferSimulator/Managers/SpawnManager.swift @@ -0,0 +1,104 @@ +// +// SpawnManager.swift +// RollkofferSimulator +// +// Created by Ingo K. +// + +import SpriteKit + +/// Manages spawning of entities at the top of the screen +class SpawnManager { + + // MARK: - Properties + private weak var scene: SKScene? + private var spawnTimer: TimeInterval = 0 + private var nextSpawnInterval: TimeInterval = 0 + private var isSpawning: Bool = false + + // MARK: - Initialization + init(scene: SKScene) { + self.scene = scene + resetSpawnInterval() + } + + // MARK: - Public Methods + func startSpawning() { + isSpawning = true + resetSpawnInterval() + } + + func stopSpawning() { + isSpawning = false + } + + func update(deltaTime: TimeInterval) { + guard isSpawning else { return } + + spawnTimer += deltaTime + + if spawnTimer >= nextSpawnInterval { + spawnEntity() + spawnTimer = 0 + resetSpawnInterval() + } + } + + // MARK: - Private Methods + private func resetSpawnInterval() { + nextSpawnInterval = TimeInterval.random(in: Constants.spawnIntervalMin...Constants.spawnIntervalMax) + } + + private func spawnEntity() { + guard let scene = scene else { return } + + let entityType = determineEntityType() + let entity = createEntity(type: entityType) + + // Random x position + let margin: CGFloat = 60 + let minX = margin + let maxX = scene.frame.width - margin + let randomX = CGFloat.random(in: minX...maxX) + + // Spawn above screen + entity.position = CGPoint(x: randomX, y: scene.frame.height + 50) + + scene.addChild(entity) + + // Move entity down + let moveDistance = scene.frame.height + 200 + let moveDuration = moveDistance / Constants.scrollSpeed + let moveAction = SKAction.moveBy(x: 0, y: -moveDistance, duration: moveDuration) + let removeAction = SKAction.removeFromParent() + entity.run(SKAction.sequence([moveAction, removeAction])) + } + + private func determineEntityType() -> EntityType { + let roll = Int.random(in: 0..<100) + + if roll < Constants.spawnChanceGoodDog { + // 40% good dogs (split between small and big) + let isSmall = Bool.random() + return .dog(isSmall ? .smallGood : .bigGood) + } else if roll < Constants.spawnChanceBadDog { + // 20% bad dogs + return .dog(.bad) + } else if roll < Constants.spawnChanceGreenHuman { + // 25% green humans + return .human(.green) + } else { + // 15% gray humans + return .human(.gray) + } + } + + private func createEntity(type: EntityType) -> SKNode { + switch type { + case .dog(let dogType): + return DogNode(type: dogType) + case .human(let humanType): + return HumanNode(type: humanType) + } + } +} diff --git a/RollkofferSimulator/Models/EntityType.swift b/RollkofferSimulator/Models/EntityType.swift new file mode 100644 index 0000000..cf477ae --- /dev/null +++ b/RollkofferSimulator/Models/EntityType.swift @@ -0,0 +1,58 @@ +// +// EntityType.swift +// RollkofferSimulator +// +// Created by Ingo K. +// + +import Foundation + +/// Types of dogs in the game +enum DogType { + case smallGood // 40x40, gold brown, +10 points + case bigGood // 70x70, gold brown, +25 points + case bad // 55x55, red outlined, -1 life + + var points: Int { + switch self { + case .smallGood: return Constants.pointsSmallGoodDog + case .bigGood: return Constants.pointsBigGoodDog + case .bad: return 0 + } + } + + var isHarmful: Bool { + return self == .bad + } + + var countsTowardGoal: Bool { + return self == .smallGood || self == .bigGood + } +} + +/// Types of humans in the game +enum HumanType { + case green // 50x80, green, +15 points + case gray // 50x80, gray, -1 life + + var points: Int { + switch self { + case .green: return Constants.pointsGreenHuman + case .gray: return 0 + } + } + + var isHarmful: Bool { + return self == .gray + } + + var countsTowardGoal: Bool { + return self == .green + } +} + +/// All entity types for spawn system +enum EntityType { + case dog(DogType) + case human(HumanType) +} diff --git a/RollkofferSimulator/Models/GameState.swift b/RollkofferSimulator/Models/GameState.swift new file mode 100644 index 0000000..9822b18 --- /dev/null +++ b/RollkofferSimulator/Models/GameState.swift @@ -0,0 +1,103 @@ +// +// GameState.swift +// RollkofferSimulator +// +// Created by Ingo K. +// + +import Foundation + +/// Possible game states +enum GameStateType { + case startScreen + case playing + case paused + case gameOver + case victory +} + +/// Manages the current game state and statistics +class GameState { + + // MARK: - Properties + private(set) var currentState: GameStateType = .startScreen + private(set) var score: Int = 0 + private(set) var lives: Int = Constants.startLives + private(set) var dogsCollected: Int = 0 + private(set) var humansCollected: Int = 0 + private(set) var timeRemaining: TimeInterval = Constants.gameTime + + var isInvincible: Bool = false + + // MARK: - Computed Properties + var hasWon: Bool { + return dogsCollected >= Constants.targetDogs && + humansCollected >= Constants.targetHumans + } + + var hasLost: Bool { + return lives <= 0 || (timeRemaining <= 0 && !hasWon) + } + + // MARK: - State Management + func setState(_ state: GameStateType) { + currentState = state + } + + func reset() { + score = 0 + lives = Constants.startLives + dogsCollected = 0 + humansCollected = 0 + timeRemaining = Constants.gameTime + isInvincible = false + currentState = .playing + } + + // MARK: - Score Management + func addPoints(_ points: Int) { + score += points + } + + func collectDog() { + dogsCollected += 1 + } + + func collectHuman() { + humansCollected += 1 + } + + // MARK: - Lives Management + func loseLife() -> Bool { + guard !isInvincible else { return false } + lives -= 1 + return true + } + + // MARK: - Time Management + func updateTime(delta: TimeInterval) { + timeRemaining = max(0, timeRemaining - delta) + } + + // MARK: - Formatted Strings + var formattedTime: String { + let seconds = Int(timeRemaining) + return "\(seconds)s" + } + + var formattedScore: String { + return "SCORE: \(score)" + } + + var formattedDogs: String { + return "๐Ÿ• \(dogsCollected)/\(Constants.targetDogs)" + } + + var formattedHumans: String { + return "๐Ÿ‘ค \(humansCollected)/\(Constants.targetHumans)" + } + + var formattedLives: String { + return String(repeating: "โค๏ธ", count: lives) + } +} diff --git a/RollkofferSimulator/Nodes/DogNode.swift b/RollkofferSimulator/Nodes/DogNode.swift new file mode 100644 index 0000000..be59365 --- /dev/null +++ b/RollkofferSimulator/Nodes/DogNode.swift @@ -0,0 +1,162 @@ +// +// DogNode.swift +// RollkofferSimulator +// +// Created by Ingo K. +// + +import SpriteKit + +/// A dog entity node +class DogNode: SKNode { + + // MARK: - Properties + let dogType: DogType + private let bodyNode: SKShapeNode + + // MARK: - Initialization + init(type: DogType) { + self.dogType = type + + let size: CGSize + let color: SKColor + let hasRedOutline: Bool + + switch type { + case .smallGood: + size = Constants.smallDogSize + color = Constants.Colors.goodDogColor + hasRedOutline = false + case .bigGood: + size = Constants.bigDogSize + color = Constants.Colors.goodDogColor + hasRedOutline = false + case .bad: + size = Constants.badDogSize + color = Constants.Colors.goodDogColor + hasRedOutline = true + } + + // Create dog body (simple oval shape) + let bodyPath = CGMutablePath() + bodyPath.addEllipse(in: CGRect(x: -size.width / 2, y: -size.height / 2, + width: size.width, height: size.height * 0.7)) + bodyNode = SKShapeNode(path: bodyPath) + bodyNode.fillColor = color + bodyNode.strokeColor = hasRedOutline ? Constants.Colors.badDogColor : SKColor(white: 0.3, alpha: 1.0) + bodyNode.lineWidth = hasRedOutline ? 4 : 2 + + super.init() + + addChild(bodyNode) + + // Add head + let headSize = size.width * 0.5 + let head = SKShapeNode(circleOfRadius: headSize / 2) + head.position = CGPoint(x: 0, y: size.height * 0.25) + head.fillColor = color + head.strokeColor = hasRedOutline ? Constants.Colors.badDogColor : SKColor(white: 0.3, alpha: 1.0) + head.lineWidth = hasRedOutline ? 3 : 1.5 + addChild(head) + + // Add ears + let earSize = headSize * 0.4 + for xOffset in [-headSize * 0.4, headSize * 0.4] { + let ear = SKShapeNode(ellipseOf: CGSize(width: earSize, height: earSize * 1.5)) + ear.position = CGPoint(x: xOffset, y: size.height * 0.25 + headSize * 0.35) + ear.fillColor = color.withAlphaComponent(0.8) + ear.strokeColor = hasRedOutline ? Constants.Colors.badDogColor : SKColor(white: 0.3, alpha: 1.0) + ear.lineWidth = hasRedOutline ? 2 : 1 + addChild(ear) + } + + // Add eyes + let eyeSize: CGFloat = headSize * 0.15 + for xOffset in [-headSize * 0.15, headSize * 0.15] { + let eye = SKShapeNode(circleOfRadius: eyeSize) + eye.position = CGPoint(x: xOffset, y: size.height * 0.28) + eye.fillColor = hasRedOutline ? .red : .black + eye.strokeColor = .clear + addChild(eye) + } + + // Add nose + let nose = SKShapeNode(circleOfRadius: headSize * 0.1) + nose.position = CGPoint(x: 0, y: size.height * 0.18) + nose.fillColor = .black + nose.strokeColor = .clear + addChild(nose) + + // Add tail + let tailPath = CGMutablePath() + tailPath.move(to: CGPoint(x: 0, y: -size.height * 0.25)) + tailPath.addQuadCurve(to: CGPoint(x: size.width * 0.3, y: -size.height * 0.1), + control: CGPoint(x: size.width * 0.4, y: -size.height * 0.3)) + let tail = SKShapeNode(path: tailPath) + tail.strokeColor = color + tail.lineWidth = size.width * 0.1 + tail.lineCap = .round + addChild(tail) + + // Add legs + let legWidth = size.width * 0.12 + let legHeight = size.height * 0.25 + let legPositions: [CGFloat] = [-size.width * 0.25, -size.width * 0.1, + size.width * 0.1, size.width * 0.25] + for xPos in legPositions { + let leg = SKShapeNode(rect: CGRect(x: xPos - legWidth / 2, + y: -size.height * 0.35 - legHeight, + width: legWidth, height: legHeight), + cornerRadius: legWidth / 2) + leg.fillColor = color + leg.strokeColor = hasRedOutline ? Constants.Colors.badDogColor : SKColor(white: 0.3, alpha: 1.0) + leg.lineWidth = hasRedOutline ? 2 : 1 + addChild(leg) + } + + // Add angry expression for bad dogs + if hasRedOutline { + let browPath = CGMutablePath() + browPath.move(to: CGPoint(x: -headSize * 0.3, y: size.height * 0.35)) + browPath.addLine(to: CGPoint(x: -headSize * 0.05, y: size.height * 0.32)) + let leftBrow = SKShapeNode(path: browPath) + leftBrow.strokeColor = .black + leftBrow.lineWidth = 2 + addChild(leftBrow) + + let browPath2 = CGMutablePath() + browPath2.move(to: CGPoint(x: headSize * 0.3, y: size.height * 0.35)) + browPath2.addLine(to: CGPoint(x: headSize * 0.05, y: size.height * 0.32)) + let rightBrow = SKShapeNode(path: browPath2) + rightBrow.strokeColor = .black + rightBrow.lineWidth = 2 + addChild(rightBrow) + } + + setupPhysics(size: size) + self.zPosition = Constants.ZPosition.entities + self.name = type.isHarmful ? "badDog" : "goodDog" + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Physics Setup + private func setupPhysics(size: CGSize) { + let radius = min(size.width, size.height) / 2 + physicsBody = SKPhysicsBody(circleOfRadius: radius) + physicsBody?.isDynamic = true + physicsBody?.affectedByGravity = false + physicsBody?.allowsRotation = false + + if dogType.isHarmful { + physicsBody?.categoryBitMask = Constants.PhysicsCategory.badDog + } else { + physicsBody?.categoryBitMask = Constants.PhysicsCategory.goodDog + } + + physicsBody?.contactTestBitMask = Constants.PhysicsCategory.suitcase + physicsBody?.collisionBitMask = Constants.PhysicsCategory.none + } +} diff --git a/RollkofferSimulator/Nodes/HumanNode.swift b/RollkofferSimulator/Nodes/HumanNode.swift new file mode 100644 index 0000000..f00aaff --- /dev/null +++ b/RollkofferSimulator/Nodes/HumanNode.swift @@ -0,0 +1,176 @@ +// +// HumanNode.swift +// RollkofferSimulator +// +// Created by Ingo K. +// + +import SpriteKit + +/// A human entity node +class HumanNode: SKNode { + + // MARK: - Properties + let humanType: HumanType + private let bodyNode: SKShapeNode + + // MARK: - Initialization + init(type: HumanType) { + self.humanType = type + + let size = Constants.humanSize + let color: SKColor + + switch type { + case .green: + color = Constants.Colors.greenHumanColor + case .gray: + color = Constants.Colors.grayHumanColor + } + + // Create body (torso) + let torsoHeight = size.height * 0.4 + let torsoWidth = size.width * 0.6 + let torsoRect = CGRect(x: -torsoWidth / 2, y: -size.height * 0.1, + width: torsoWidth, height: torsoHeight) + bodyNode = SKShapeNode(rect: torsoRect, cornerRadius: 5) + bodyNode.fillColor = color + bodyNode.strokeColor = color.withAlphaComponent(0.7) + bodyNode.lineWidth = 2 + + super.init() + + addChild(bodyNode) + + // Add head + let headRadius = size.width * 0.25 + let head = SKShapeNode(circleOfRadius: headRadius) + head.position = CGPoint(x: 0, y: size.height * 0.35) + head.fillColor = SKColor(red: 1.0, green: 0.87, blue: 0.77, alpha: 1.0) // Skin tone + head.strokeColor = SKColor(red: 0.9, green: 0.75, blue: 0.65, alpha: 1.0) + head.lineWidth = 1 + addChild(head) + + // Add eyes + let eyeSize: CGFloat = 3 + for xOffset in [-headRadius * 0.35, headRadius * 0.35] { + let eye = SKShapeNode(circleOfRadius: eyeSize) + eye.position = CGPoint(x: xOffset, y: size.height * 0.37) + eye.fillColor = .black + eye.strokeColor = .clear + addChild(eye) + } + + // Add mouth/expression + if type == .green { + // Happy smile + let smilePath = CGMutablePath() + smilePath.addArc(center: CGPoint(x: 0, y: size.height * 0.32), + radius: headRadius * 0.3, + startAngle: .pi * 0.2, + endAngle: .pi * 0.8, + clockwise: true) + let smile = SKShapeNode(path: smilePath) + smile.strokeColor = .black + smile.lineWidth = 2 + smile.lineCap = .round + addChild(smile) + } else { + // Neutral/frowning expression + let frownPath = CGMutablePath() + frownPath.move(to: CGPoint(x: -headRadius * 0.25, y: size.height * 0.3)) + frownPath.addLine(to: CGPoint(x: headRadius * 0.25, y: size.height * 0.3)) + let frown = SKShapeNode(path: frownPath) + frown.strokeColor = .black + frown.lineWidth = 2 + addChild(frown) + } + + // Add arms + let armWidth: CGFloat = 6 + let armLength = size.height * 0.3 + for xOffset in [-torsoWidth / 2 - armWidth / 2, torsoWidth / 2 + armWidth / 2] { + let arm = SKShapeNode(rect: CGRect(x: xOffset - armWidth / 2, + y: size.height * 0.05, + width: armWidth, height: armLength), + cornerRadius: armWidth / 2) + arm.fillColor = color + arm.strokeColor = color.withAlphaComponent(0.7) + arm.lineWidth = 1 + addChild(arm) + + // Add hand + let hand = SKShapeNode(circleOfRadius: armWidth * 0.8) + hand.position = CGPoint(x: xOffset, y: size.height * 0.05) + hand.fillColor = SKColor(red: 1.0, green: 0.87, blue: 0.77, alpha: 1.0) + hand.strokeColor = .clear + addChild(hand) + } + + // Add legs + let legWidth: CGFloat = 10 + let legHeight = size.height * 0.35 + for xOffset in [-torsoWidth * 0.25, torsoWidth * 0.25] { + let leg = SKShapeNode(rect: CGRect(x: xOffset - legWidth / 2, + y: -size.height * 0.45, + width: legWidth, height: legHeight), + cornerRadius: legWidth / 2) + leg.fillColor = SKColor(red: 0.2, green: 0.2, blue: 0.4, alpha: 1.0) // Pants color + leg.strokeColor = SKColor(red: 0.15, green: 0.15, blue: 0.3, alpha: 1.0) + leg.lineWidth = 1 + addChild(leg) + + // Add shoe + let shoe = SKShapeNode(rect: CGRect(x: xOffset - legWidth * 0.6, + y: -size.height * 0.48, + width: legWidth * 1.2, height: 6), + cornerRadius: 2) + shoe.fillColor = .black + shoe.strokeColor = .clear + addChild(shoe) + } + + // Add indicator icon for type + if type == .green { + let checkmark = SKLabelNode(text: "โœ“") + checkmark.fontSize = 16 + checkmark.fontColor = .white + checkmark.position = CGPoint(x: 0, y: size.height * 0.1) + checkmark.verticalAlignmentMode = .center + addChild(checkmark) + } else { + let xMark = SKLabelNode(text: "โœ—") + xMark.fontSize = 16 + xMark.fontColor = .white + xMark.position = CGPoint(x: 0, y: size.height * 0.1) + xMark.verticalAlignmentMode = .center + addChild(xMark) + } + + setupPhysics(size: size) + self.zPosition = Constants.ZPosition.entities + self.name = type.isHarmful ? "grayHuman" : "greenHuman" + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Physics Setup + private func setupPhysics(size: CGSize) { + physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: size.width * 0.6, + height: size.height * 0.8)) + physicsBody?.isDynamic = true + physicsBody?.affectedByGravity = false + physicsBody?.allowsRotation = false + + if humanType.isHarmful { + physicsBody?.categoryBitMask = Constants.PhysicsCategory.grayHuman + } else { + physicsBody?.categoryBitMask = Constants.PhysicsCategory.greenHuman + } + + physicsBody?.contactTestBitMask = Constants.PhysicsCategory.suitcase + physicsBody?.collisionBitMask = Constants.PhysicsCategory.none + } +} diff --git a/RollkofferSimulator/Nodes/PlayerNode.swift b/RollkofferSimulator/Nodes/PlayerNode.swift new file mode 100644 index 0000000..7064b49 --- /dev/null +++ b/RollkofferSimulator/Nodes/PlayerNode.swift @@ -0,0 +1,126 @@ +// +// PlayerNode.swift +// RollkofferSimulator +// +// Created by Ingo K. +// + +import SpriteKit + +/// The player-controlled suitcase node +class PlayerNode: SKNode { + + // MARK: - Properties + private let suitcaseBody: SKShapeNode + private let handle: SKShapeNode + private let wheels: [SKShapeNode] + + // MARK: - Initialization + override init() { + let size = Constants.suitcaseSize + + // Create suitcase body (rounded rectangle) + let bodyRect = CGRect(x: -size.width / 2, y: -size.height / 2 + 10, + width: size.width, height: size.height - 15) + suitcaseBody = SKShapeNode(rect: bodyRect, cornerRadius: 8) + suitcaseBody.fillColor = Constants.Colors.suitcaseColor + suitcaseBody.strokeColor = SKColor(white: 0.2, alpha: 1.0) + suitcaseBody.lineWidth = 2 + + // Create handle + let handlePath = CGMutablePath() + handlePath.move(to: CGPoint(x: -10, y: size.height / 2 - 5)) + handlePath.addLine(to: CGPoint(x: -10, y: size.height / 2 + 15)) + handlePath.addLine(to: CGPoint(x: 10, y: size.height / 2 + 15)) + handlePath.addLine(to: CGPoint(x: 10, y: size.height / 2 - 5)) + handle = SKShapeNode(path: handlePath) + handle.strokeColor = SKColor(white: 0.3, alpha: 1.0) + handle.lineWidth = 4 + handle.lineCap = .round + + // Create wheels + var tempWheels: [SKShapeNode] = [] + let wheelPositions = [ + CGPoint(x: -size.width / 2 + 8, y: -size.height / 2 + 5), + CGPoint(x: size.width / 2 - 8, y: -size.height / 2 + 5) + ] + for pos in wheelPositions { + let wheel = SKShapeNode(circleOfRadius: 6) + wheel.position = pos + wheel.fillColor = SKColor.darkGray + wheel.strokeColor = SKColor.black + wheel.lineWidth = 1 + tempWheels.append(wheel) + } + wheels = tempWheels + + super.init() + + // Add decorative stripes + let stripe1 = SKShapeNode(rect: CGRect(x: -size.width / 2 + 5, y: 0, + width: size.width - 10, height: 3)) + stripe1.fillColor = SKColor(white: 0.3, alpha: 0.5) + stripe1.strokeColor = .clear + + let stripe2 = SKShapeNode(rect: CGRect(x: -size.width / 2 + 5, y: -15, + width: size.width - 10, height: 3)) + stripe2.fillColor = SKColor(white: 0.3, alpha: 0.5) + stripe2.strokeColor = .clear + + addChild(suitcaseBody) + addChild(handle) + addChild(stripe1) + addChild(stripe2) + for wheel in wheels { + addChild(wheel) + } + + setupPhysics() + self.zPosition = Constants.ZPosition.player + self.name = "player" + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Physics Setup + private func setupPhysics() { + let size = Constants.suitcaseSize + physicsBody = SKPhysicsBody(rectangleOf: size) + physicsBody?.isDynamic = true + physicsBody?.affectedByGravity = false + physicsBody?.allowsRotation = false + physicsBody?.categoryBitMask = Constants.PhysicsCategory.suitcase + physicsBody?.contactTestBitMask = Constants.PhysicsCategory.all + physicsBody?.collisionBitMask = Constants.PhysicsCategory.none + } + + // MARK: - Visual Effects + func startBlinking() { + let fadeOut = SKAction.fadeAlpha(to: 0.3, duration: Constants.blinkDuration) + let fadeIn = SKAction.fadeAlpha(to: 1.0, duration: Constants.blinkDuration) + let blink = SKAction.sequence([fadeOut, fadeIn]) + let blinkRepeat = SKAction.repeat(blink, count: Constants.blinkCount) + run(blinkRepeat, withKey: "blink") + } + + func stopBlinking() { + removeAction(forKey: "blink") + alpha = 1.0 + } + + // MARK: - Movement + func constrainToScreen(in frame: CGRect) { + let halfWidth = Constants.suitcaseSize.width / 2 + let halfHeight = Constants.suitcaseSize.height / 2 + + let minX = frame.minX + halfWidth + 10 + let maxX = frame.maxX - halfWidth - 10 + let minY = frame.minY + halfHeight + 10 + let maxY = frame.maxY - halfHeight - 100 // Leave space for UI + + position.x = max(minX, min(maxX, position.x)) + position.y = max(minY, min(maxY, position.y)) + } +} diff --git a/RollkofferSimulator/RollkofferSimulator.xcodeproj/project.pbxproj b/RollkofferSimulator/RollkofferSimulator.xcodeproj/project.pbxproj new file mode 100644 index 0000000..0531fdd --- /dev/null +++ b/RollkofferSimulator/RollkofferSimulator.xcodeproj/project.pbxproj @@ -0,0 +1,457 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 001 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101 /* AppDelegate.swift */; }; + 002 /* GameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102 /* GameViewController.swift */; }; + 003 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 103 /* Constants.swift */; }; + 004 /* EntityType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 104 /* EntityType.swift */; }; + 005 /* GameState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 105 /* GameState.swift */; }; + 006 /* PlayerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106 /* PlayerNode.swift */; }; + 007 /* DogNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 107 /* DogNode.swift */; }; + 008 /* HumanNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 108 /* HumanNode.swift */; }; + 009 /* SpawnManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109 /* SpawnManager.swift */; }; + 010 /* CollisionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110 /* CollisionManager.swift */; }; + 011 /* ScoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 111 /* ScoreManager.swift */; }; + 012 /* MenuScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 112 /* MenuScene.swift */; }; + 013 /* GameScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 113 /* GameScene.swift */; }; + 014 /* GameOverScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 114 /* GameOverScene.swift */; }; + 015 /* VictoryScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 115 /* VictoryScene.swift */; }; + 016 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 116 /* Assets.xcassets */; }; + 017 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 117 /* Main.storyboard */; }; + 018 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 118 /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 100 /* RollkofferSimulator.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RollkofferSimulator.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 101 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 102 /* GameViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameViewController.swift; sourceTree = ""; }; + 103 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 104 /* EntityType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityType.swift; sourceTree = ""; }; + 105 /* GameState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameState.swift; sourceTree = ""; }; + 106 /* PlayerNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNode.swift; sourceTree = ""; }; + 107 /* DogNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DogNode.swift; sourceTree = ""; }; + 108 /* HumanNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HumanNode.swift; sourceTree = ""; }; + 109 /* SpawnManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpawnManager.swift; sourceTree = ""; }; + 110 /* CollisionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollisionManager.swift; sourceTree = ""; }; + 111 /* ScoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreManager.swift; sourceTree = ""; }; + 112 /* MenuScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuScene.swift; sourceTree = ""; }; + 113 /* GameScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameScene.swift; sourceTree = ""; }; + 114 /* GameOverScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameOverScene.swift; sourceTree = ""; }; + 115 /* VictoryScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VictoryScene.swift; sourceTree = ""; }; + 116 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 117 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 118 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 119 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 200 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 300 = { + isa = PBXGroup; + children = ( + 301 /* RollkofferSimulator */, + 302 /* Products */, + ); + sourceTree = ""; + }; + 301 /* RollkofferSimulator */ = { + isa = PBXGroup; + children = ( + 101 /* AppDelegate.swift */, + 102 /* GameViewController.swift */, + 303 /* Scenes */, + 304 /* Nodes */, + 305 /* Managers */, + 306 /* Models */, + 307 /* Utils */, + 116 /* Assets.xcassets */, + 117 /* Main.storyboard */, + 118 /* LaunchScreen.storyboard */, + 119 /* Info.plist */, + ); + path = .; + sourceTree = ""; + }; + 302 /* Products */ = { + isa = PBXGroup; + children = ( + 100 /* RollkofferSimulator.app */, + ); + name = Products; + sourceTree = ""; + }; + 303 /* Scenes */ = { + isa = PBXGroup; + children = ( + 112 /* MenuScene.swift */, + 113 /* GameScene.swift */, + 114 /* GameOverScene.swift */, + 115 /* VictoryScene.swift */, + ); + path = Scenes; + sourceTree = ""; + }; + 304 /* Nodes */ = { + isa = PBXGroup; + children = ( + 106 /* PlayerNode.swift */, + 107 /* DogNode.swift */, + 108 /* HumanNode.swift */, + ); + path = Nodes; + sourceTree = ""; + }; + 305 /* Managers */ = { + isa = PBXGroup; + children = ( + 109 /* SpawnManager.swift */, + 110 /* CollisionManager.swift */, + 111 /* ScoreManager.swift */, + ); + path = Managers; + sourceTree = ""; + }; + 306 /* Models */ = { + isa = PBXGroup; + children = ( + 104 /* EntityType.swift */, + 105 /* GameState.swift */, + ); + path = Models; + sourceTree = ""; + }; + 307 /* Utils */ = { + isa = PBXGroup; + children = ( + 103 /* Constants.swift */, + ); + path = Utils; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 400 /* RollkofferSimulator */ = { + isa = PBXNativeTarget; + buildConfigurationList = 501 /* Build configuration list for PBXNativeTarget "RollkofferSimulator" */; + buildPhases = ( + 401 /* Sources */, + 200 /* Frameworks */, + 402 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = RollkofferSimulator; + productName = RollkofferSimulator; + productReference = 100 /* RollkofferSimulator.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 600 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + 400 = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = 500 /* Build configuration list for PBXProject "RollkofferSimulator" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + de, + ); + mainGroup = 300; + productRefGroup = 302 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 400 /* RollkofferSimulator */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 402 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 016 /* Assets.xcassets in Resources */, + 017 /* Main.storyboard in Resources */, + 018 /* LaunchScreen.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 401 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 001 /* AppDelegate.swift in Sources */, + 002 /* GameViewController.swift in Sources */, + 003 /* Constants.swift in Sources */, + 004 /* EntityType.swift in Sources */, + 005 /* GameState.swift in Sources */, + 006 /* PlayerNode.swift in Sources */, + 007 /* DogNode.swift in Sources */, + 008 /* HumanNode.swift in Sources */, + 009 /* SpawnManager.swift in Sources */, + 010 /* CollisionManager.swift in Sources */, + 011 /* ScoreManager.swift in Sources */, + 012 /* MenuScene.swift in Sources */, + 013 /* GameScene.swift in Sources */, + 014 /* GameOverScene.swift in Sources */, + 015 /* VictoryScene.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 117 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 117 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 118 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 118 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 700 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 701 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 702 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UIStatusBarHidden = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 703 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UIStatusBarHidden = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 500 /* Build configuration list for PBXProject "RollkofferSimulator" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 700 /* Debug */, + 701 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 501 /* Build configuration list for PBXNativeTarget "RollkofferSimulator" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 702 /* Debug */, + 703 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 600 /* Project object */; +} diff --git a/RollkofferSimulator/Scenes/GameOverScene.swift b/RollkofferSimulator/Scenes/GameOverScene.swift new file mode 100644 index 0000000..6d66b8f --- /dev/null +++ b/RollkofferSimulator/Scenes/GameOverScene.swift @@ -0,0 +1,255 @@ +// +// GameOverScene.swift +// RollkofferSimulator +// +// Created by Ingo K. +// + +import SpriteKit + +class GameOverScene: SKScene { + + // MARK: - Properties + var finalScore: Int = 0 + var dogsCollected: Int = 0 + var humansCollected: Int = 0 + var isNewHighScore: Bool = false + + private var retryButton: SKShapeNode! + private var menuButton: SKShapeNode! + + // MARK: - Scene Lifecycle + override func didMove(to view: SKView) { + setupBackground() + setupContent() + setupButtons() + startAnimations() + } + + // MARK: - Setup + private func setupBackground() { + backgroundColor = SKColor(red: 0.15, green: 0.1, blue: 0.1, alpha: 1.0) + + // Add dim pattern + for i in 0..<20 { + let x = CGFloat.random(in: 0...frame.width) + let y = CGFloat.random(in: 0...frame.height) + let size = CGFloat.random(in: 20...60) + + let shape = SKShapeNode(circleOfRadius: size) + shape.position = CGPoint(x: x, y: y) + shape.fillColor = SKColor.red.withAlphaComponent(0.05) + shape.strokeColor = .clear + shape.zPosition = 0.1 + addChild(shape) + } + } + + private func setupContent() { + // Game Over title + let titleLabel = SKLabelNode(text: "๐Ÿ’” GAME OVER ๐Ÿ’”") + titleLabel.fontName = "AvenirNext-Heavy" + titleLabel.fontSize = 42 + titleLabel.fontColor = .red + titleLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.8) + titleLabel.zPosition = Constants.ZPosition.ui + addChild(titleLabel) + + // Subtitle based on reason + let subtitleText: String + if dogsCollected < Constants.targetDogs || humansCollected < Constants.targetHumans { + subtitleText = "Zeit abgelaufen!" + } else { + subtitleText = "Keine Leben mehr!" + } + + let subtitleLabel = SKLabelNode(text: subtitleText) + subtitleLabel.fontName = "AvenirNext-Medium" + subtitleLabel.fontSize = 22 + subtitleLabel.fontColor = SKColor(white: 0.8, alpha: 1.0) + subtitleLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.72) + subtitleLabel.zPosition = Constants.ZPosition.ui + addChild(subtitleLabel) + + // Score display + let scoreLabel = SKLabelNode(text: "Punkte: \(finalScore)") + scoreLabel.fontName = "AvenirNext-Bold" + scoreLabel.fontSize = 32 + scoreLabel.fontColor = .white + scoreLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.58) + scoreLabel.zPosition = Constants.ZPosition.ui + addChild(scoreLabel) + + // New high score indicator + if isNewHighScore { + let highScoreLabel = SKLabelNode(text: "๐Ÿ† NEUER HIGHSCORE! ๐Ÿ†") + highScoreLabel.fontName = "AvenirNext-Heavy" + highScoreLabel.fontSize = 24 + highScoreLabel.fontColor = SKColor.yellow + highScoreLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.65) + highScoreLabel.zPosition = Constants.ZPosition.ui + addChild(highScoreLabel) + + // Animate high score + let scale = SKAction.sequence([ + SKAction.scale(to: 1.1, duration: 0.5), + SKAction.scale(to: 1.0, duration: 0.5) + ]) + highScoreLabel.run(SKAction.repeatForever(scale)) + } + + // Stats display + let dogsText = "๐Ÿ• \(dogsCollected)/\(Constants.targetDogs)" + let humansText = "๐Ÿ‘ค \(humansCollected)/\(Constants.targetHumans)" + + let statsLabel = SKLabelNode(text: "\(dogsText) | \(humansText)") + statsLabel.fontName = "AvenirNext-Medium" + statsLabel.fontSize = 24 + statsLabel.fontColor = SKColor(white: 0.7, alpha: 1.0) + statsLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.48) + statsLabel.zPosition = Constants.ZPosition.ui + addChild(statsLabel) + + // Progress indicators + let dogsProgress = min(1.0, CGFloat(dogsCollected) / CGFloat(Constants.targetDogs)) + let humansProgress = min(1.0, CGFloat(humansCollected) / CGFloat(Constants.targetHumans)) + + addProgressBar(at: CGPoint(x: frame.midX - 60, y: frame.height * 0.42), + progress: dogsProgress, color: .orange, label: "Hunde") + addProgressBar(at: CGPoint(x: frame.midX + 60, y: frame.height * 0.42), + progress: humansProgress, color: .green, label: "Menschen") + + // Sad suitcase + let sadSuitcase = PlayerNode() + sadSuitcase.position = CGPoint(x: frame.midX, y: frame.height * 0.25) + sadSuitcase.alpha = 0.6 + sadSuitcase.setScale(0.8) + addChild(sadSuitcase) + + // Sad face on suitcase area + let sadFace = SKLabelNode(text: "๐Ÿ˜ข") + sadFace.fontSize = 30 + sadFace.position = CGPoint(x: frame.midX, y: frame.height * 0.25 + 20) + sadFace.zPosition = Constants.ZPosition.ui + addChild(sadFace) + } + + private func addProgressBar(at position: CGPoint, progress: CGFloat, color: SKColor, label: String) { + let barWidth: CGFloat = 80 + let barHeight: CGFloat = 12 + + // Background + let bg = SKShapeNode(rect: CGRect(x: -barWidth / 2, y: 0, width: barWidth, height: barHeight), + cornerRadius: 6) + bg.position = position + bg.fillColor = SKColor(white: 0.3, alpha: 1.0) + bg.strokeColor = .clear + bg.zPosition = Constants.ZPosition.ui + addChild(bg) + + // Progress fill + let fillWidth = barWidth * progress + if fillWidth > 0 { + let fill = SKShapeNode(rect: CGRect(x: -barWidth / 2, y: 0, width: fillWidth, height: barHeight), + cornerRadius: 6) + fill.position = position + fill.fillColor = progress >= 1.0 ? .green : color + fill.strokeColor = .clear + fill.zPosition = Constants.ZPosition.ui + 0.1 + addChild(fill) + } + } + + private func setupButtons() { + // Retry button + retryButton = createButton(text: "๐Ÿ”„ Nochmal", color: SKColor(red: 0.2, green: 0.6, blue: 0.2, alpha: 1.0)) + retryButton.position = CGPoint(x: frame.midX, y: frame.height * 0.15) + retryButton.name = "retryButton" + addChild(retryButton) + + // Menu button + menuButton = createButton(text: "๐Ÿ  Menรผ", color: SKColor(red: 0.3, green: 0.3, blue: 0.5, alpha: 1.0)) + menuButton.position = CGPoint(x: frame.midX, y: frame.height * 0.08) + menuButton.name = "menuButton" + addChild(menuButton) + } + + private func createButton(text: String, color: SKColor) -> SKShapeNode { + let buttonWidth: CGFloat = 180 + let buttonHeight: CGFloat = 50 + + let button = SKShapeNode(rect: CGRect(x: -buttonWidth / 2, y: -buttonHeight / 2, + width: buttonWidth, height: buttonHeight), + cornerRadius: 12) + button.fillColor = color + button.strokeColor = color.withAlphaComponent(0.5) + button.lineWidth = 2 + button.zPosition = Constants.ZPosition.ui + + let label = SKLabelNode(text: text) + label.fontName = "AvenirNext-Bold" + label.fontSize = 22 + label.fontColor = .white + label.verticalAlignmentMode = .center + label.zPosition = 1 + button.addChild(label) + + return button + } + + private func startAnimations() { + // Fade in effect + alpha = 0 + let fadeIn = SKAction.fadeIn(withDuration: 0.5) + run(fadeIn) + + // Button pulse + let pulse = SKAction.sequence([ + SKAction.scale(to: 1.05, duration: 0.8), + SKAction.scale(to: 1.0, duration: 0.8) + ]) + retryButton.run(SKAction.repeatForever(pulse)) + } + + // MARK: - Touch Handling + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + let location = touch.location(in: self) + + if retryButton.contains(location) { + retryGame() + } else if menuButton.contains(location) { + returnToMenu() + } + } + + private func retryGame() { + let pressDown = SKAction.scale(to: 0.9, duration: 0.1) + let pressUp = SKAction.scale(to: 1.0, duration: 0.1) + + retryButton.run(SKAction.sequence([pressDown, pressUp])) { [weak self] in + guard let self = self else { return } + + let gameScene = GameScene(size: self.size) + gameScene.scaleMode = self.scaleMode + + let transition = SKTransition.fade(withDuration: 0.5) + self.view?.presentScene(gameScene, transition: transition) + } + } + + private func returnToMenu() { + let pressDown = SKAction.scale(to: 0.9, duration: 0.1) + let pressUp = SKAction.scale(to: 1.0, duration: 0.1) + + menuButton.run(SKAction.sequence([pressDown, pressUp])) { [weak self] in + guard let self = self else { return } + + let menuScene = MenuScene(size: self.size) + menuScene.scaleMode = self.scaleMode + + let transition = SKTransition.fade(withDuration: 0.5) + self.view?.presentScene(menuScene, transition: transition) + } + } +} diff --git a/RollkofferSimulator/Scenes/GameScene.swift b/RollkofferSimulator/Scenes/GameScene.swift new file mode 100644 index 0000000..1df64d7 --- /dev/null +++ b/RollkofferSimulator/Scenes/GameScene.swift @@ -0,0 +1,537 @@ +// +// GameScene.swift +// RollkofferSimulator +// +// Created by Ingo K. +// + +import SpriteKit + +class GameScene: SKScene { + + // MARK: - Game Objects + private var player: PlayerNode! + private var gameState: GameState! + + // MARK: - Managers + private var spawnManager: SpawnManager! + private var collisionManager: CollisionManager! + + // MARK: - UI Elements + private var livesLabel: SKLabelNode! + private var scoreLabel: SKLabelNode! + private var dogsLabel: SKLabelNode! + private var humansLabel: SKLabelNode! + private var timerLabel: SKLabelNode! + private var pauseButton: SKShapeNode! + private var pauseOverlay: SKNode? + + // MARK: - Touch Handling + private var touchOffset: CGPoint = .zero + private var isDragging: Bool = false + + // MARK: - Timing + private var lastUpdateTime: TimeInterval = 0 + + // MARK: - Background Scrolling + private var floorTiles: [SKShapeNode] = [] + private let tileSize: CGFloat = 80 + + // MARK: - Scene Lifecycle + override func didMove(to view: SKView) { + setupGame() + } + + // MARK: - Setup + private func setupGame() { + // Initialize game state + gameState = GameState() + gameState.reset() + + // Setup physics + physicsWorld.gravity = .zero + collisionManager = CollisionManager() + collisionManager.delegate = self + collisionManager.setScene(self) + physicsWorld.contactDelegate = collisionManager + + setupBackground() + setupPlayer() + setupUI() + setupSpawnManager() + + spawnManager.startSpawning() + } + + private func setupBackground() { + backgroundColor = Constants.Colors.backgroundColor + + // Create scrolling floor tiles + let rows = Int(frame.height / tileSize) + 3 + let cols = Int(frame.width / tileSize) + 1 + + for row in 0.. SKLabelNode { + let label = SKLabelNode(text: text) + label.fontName = "AvenirNext-Bold" + label.fontSize = fontSize + label.fontColor = .darkGray + label.zPosition = Constants.ZPosition.ui + label.verticalAlignmentMode = .center + return label + } + + private func setupPauseButton() { + let buttonSize: CGFloat = 36 + pauseButton = SKShapeNode(rect: CGRect(x: -buttonSize / 2, y: -buttonSize / 2, + width: buttonSize, height: buttonSize), + cornerRadius: 8) + pauseButton.fillColor = SKColor(white: 0.9, alpha: 1.0) + pauseButton.strokeColor = SKColor.gray + pauseButton.lineWidth = 2 + pauseButton.position = CGPoint(x: 40, y: frame.height - 75) + pauseButton.zPosition = Constants.ZPosition.ui + pauseButton.name = "pauseButton" + addChild(pauseButton) + + // Pause icon (two vertical bars) + let barWidth: CGFloat = 4 + let barHeight: CGFloat = 16 + let barGap: CGFloat = 5 + + let leftBar = SKShapeNode(rect: CGRect(x: -barGap - barWidth / 2, y: -barHeight / 2, + width: barWidth, height: barHeight)) + leftBar.fillColor = .darkGray + leftBar.strokeColor = .clear + pauseButton.addChild(leftBar) + + let rightBar = SKShapeNode(rect: CGRect(x: barGap - barWidth / 2, y: -barHeight / 2, + width: barWidth, height: barHeight)) + rightBar.fillColor = .darkGray + rightBar.strokeColor = .clear + pauseButton.addChild(rightBar) + } + + private func setupSpawnManager() { + spawnManager = SpawnManager(scene: self) + } + + // MARK: - Update Loop + override func update(_ currentTime: TimeInterval) { + guard gameState.currentState == .playing else { return } + + // Calculate delta time + let deltaTime = lastUpdateTime > 0 ? currentTime - lastUpdateTime : 0 + lastUpdateTime = currentTime + + // Update game time + gameState.updateTime(delta: deltaTime) + updateUI() + + // Update spawn manager + spawnManager.update(deltaTime: deltaTime) + + // Update floor scrolling + updateFloorScrolling(deltaTime: deltaTime) + + // Check game end conditions + checkGameEndConditions() + } + + private func updateFloorScrolling(deltaTime: TimeInterval) { + let scrollAmount = Constants.scrollSpeed * CGFloat(deltaTime) + + for tile in floorTiles { + tile.position.y -= scrollAmount + + // Reset tile position when it scrolls off screen + if tile.position.y < -tileSize { + tile.position.y += CGFloat(floorTiles.count / (Int(frame.width / tileSize) + 1)) * tileSize + } + } + + // Also scroll floor markings + enumerateChildNodes(withName: "floorMarking") { node, _ in + node.position.y -= scrollAmount + if node.position.y < -30 { + node.position.y += self.frame.height + 50 + } + } + } + + private func updateUI() { + livesLabel.text = gameState.formattedLives + scoreLabel.text = gameState.formattedScore + dogsLabel.text = gameState.formattedDogs + humansLabel.text = gameState.formattedHumans + timerLabel.text = "โฑ๏ธ \(gameState.formattedTime)" + + // Flash timer when low + if gameState.timeRemaining <= 10 { + timerLabel.fontColor = .red + + if timerLabel.action(forKey: "flash") == nil { + let flash = SKAction.sequence([ + SKAction.scale(to: 1.2, duration: 0.25), + SKAction.scale(to: 1.0, duration: 0.25) + ]) + timerLabel.run(SKAction.repeatForever(flash), withKey: "flash") + } + } + } + + private func checkGameEndConditions() { + if gameState.hasWon { + handleVictory() + } else if gameState.hasLost { + handleGameOver() + } + } + + // MARK: - Touch Handling + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + let location = touch.location(in: self) + + // Check pause button + if pauseButton.contains(location) { + togglePause() + return + } + + // Check if touching player for dragging + if player.contains(location) { + isDragging = true + touchOffset = CGPoint(x: player.position.x - location.x, + y: player.position.y - location.y) + } else { + // Move player to touch location + isDragging = true + touchOffset = .zero + player.position = location + player.constrainToScreen(in: frame) + } + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard isDragging, gameState.currentState == .playing, + let touch = touches.first else { return } + + let location = touch.location(in: self) + player.position = CGPoint(x: location.x + touchOffset.x, + y: location.y + touchOffset.y) + player.constrainToScreen(in: frame) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + isDragging = false + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + isDragging = false + } + + // MARK: - Pause Handling + private func togglePause() { + if gameState.currentState == .playing { + pauseGame() + } else if gameState.currentState == .paused { + resumeGame() + } + } + + private func pauseGame() { + gameState.setState(.paused) + spawnManager.stopSpawning() + isPaused = true + + showPauseOverlay() + } + + private func resumeGame() { + hidePauseOverlay() + + gameState.setState(.playing) + spawnManager.startSpawning() + isPaused = false + lastUpdateTime = 0 + } + + private func showPauseOverlay() { + pauseOverlay = SKNode() + pauseOverlay?.zPosition = Constants.ZPosition.ui + 10 + + // Dimmed background + let dim = SKShapeNode(rect: frame) + dim.fillColor = SKColor.black.withAlphaComponent(0.6) + dim.strokeColor = .clear + pauseOverlay?.addChild(dim) + + // Pause text + let pauseText = SKLabelNode(text: "โธ๏ธ PAUSIERT") + pauseText.fontName = "AvenirNext-Heavy" + pauseText.fontSize = 40 + pauseText.fontColor = .white + pauseText.position = CGPoint(x: frame.midX, y: frame.midY + 40) + pauseOverlay?.addChild(pauseText) + + // Resume button + let resumeButton = createButton(text: "โ–ถ๏ธ Weiter", at: CGPoint(x: frame.midX, y: frame.midY - 20)) + resumeButton.name = "resumeButton" + pauseOverlay?.addChild(resumeButton) + + // Menu button + let menuButton = createButton(text: "๐Ÿ  Menรผ", at: CGPoint(x: frame.midX, y: frame.midY - 80)) + menuButton.name = "menuButton" + pauseOverlay?.addChild(menuButton) + + addChild(pauseOverlay!) + } + + private func createButton(text: String, at position: CGPoint) -> SKNode { + let container = SKNode() + container.position = position + + let bg = SKShapeNode(rect: CGRect(x: -80, y: -25, width: 160, height: 50), + cornerRadius: 10) + bg.fillColor = SKColor(white: 0.2, alpha: 0.9) + bg.strokeColor = .white + bg.lineWidth = 2 + container.addChild(bg) + + let label = SKLabelNode(text: text) + label.fontName = "AvenirNext-Bold" + label.fontSize = 22 + label.fontColor = .white + label.verticalAlignmentMode = .center + container.addChild(label) + + return container + } + + private func hidePauseOverlay() { + pauseOverlay?.removeFromParent() + pauseOverlay = nil + } + + // MARK: - Handle pause overlay touches (override to handle when paused) + func handlePauseOverlayTouch(at location: CGPoint) { + guard let overlay = pauseOverlay else { return } + + if let resumeButton = overlay.childNode(withName: "resumeButton"), + resumeButton.contains(location) { + resumeGame() + } else if let menuButton = overlay.childNode(withName: "menuButton"), + menuButton.contains(location) { + returnToMenu() + } + } + + // Override touchesBegan to handle pause overlay + func handleTouchInPausedState(_ touches: Set) { + guard let touch = touches.first else { return } + let location = touch.location(in: self) + handlePauseOverlayTouch(at: location) + } + + // MARK: - Game End Handling + private func handleDamage() { + guard !gameState.isInvincible else { return } + + if gameState.loseLife() { + // Start invincibility + gameState.isInvincible = true + player.startBlinking() + + // End invincibility after duration + let wait = SKAction.wait(forDuration: Constants.invincibilityDuration) + run(wait) { [weak self] in + self?.gameState.isInvincible = false + self?.player.stopBlinking() + } + } + } + + private func handleVictory() { + gameState.setState(.victory) + spawnManager.stopSpawning() + + // Record score + ScoreManager.shared.recordGameEnd( + score: gameState.score, + dogsCollected: gameState.dogsCollected, + humansCollected: gameState.humansCollected, + didWin: true + ) + + // Transition to victory scene + let victoryScene = VictoryScene(size: size) + victoryScene.scaleMode = scaleMode + victoryScene.finalScore = gameState.score + victoryScene.dogsCollected = gameState.dogsCollected + victoryScene.humansCollected = gameState.humansCollected + victoryScene.timeRemaining = gameState.timeRemaining + + let transition = SKTransition.fade(withDuration: 0.5) + view?.presentScene(victoryScene, transition: transition) + } + + private func handleGameOver() { + gameState.setState(.gameOver) + spawnManager.stopSpawning() + + // Record score + ScoreManager.shared.recordGameEnd( + score: gameState.score, + dogsCollected: gameState.dogsCollected, + humansCollected: gameState.humansCollected, + didWin: false + ) + + // Transition to game over scene + let gameOverScene = GameOverScene(size: size) + gameOverScene.scaleMode = scaleMode + gameOverScene.finalScore = gameState.score + gameOverScene.dogsCollected = gameState.dogsCollected + gameOverScene.humansCollected = gameState.humansCollected + gameOverScene.isNewHighScore = ScoreManager.shared.isNewHighScore(gameState.score) + + let transition = SKTransition.fade(withDuration: 0.5) + view?.presentScene(gameOverScene, transition: transition) + } + + private func returnToMenu() { + let menuScene = MenuScene(size: size) + menuScene.scaleMode = scaleMode + + let transition = SKTransition.fade(withDuration: 0.5) + view?.presentScene(menuScene, transition: transition) + } +} + +// MARK: - CollisionManagerDelegate +extension GameScene: CollisionManagerDelegate { + func didCollectGoodDog(points: Int) { + gameState.addPoints(points) + gameState.collectDog() + + // Show collection effect + showPointsEffect(points: points, at: player.position) + } + + func didCollectGreenHuman(points: Int) { + gameState.addPoints(points) + gameState.collectHuman() + + // Show collection effect + showPointsEffect(points: points, at: player.position) + } + + func didHitHarmfulEntity() { + handleDamage() + } + + private func showPointsEffect(points: Int, at position: CGPoint) { + let label = SKLabelNode(text: "+\(points)") + label.fontName = "AvenirNext-Bold" + label.fontSize = 24 + label.fontColor = .green + label.position = CGPoint(x: position.x, y: position.y + 50) + label.zPosition = Constants.ZPosition.ui + + addChild(label) + + let moveUp = SKAction.moveBy(x: 0, y: 40, duration: 0.5) + let fadeOut = SKAction.fadeOut(withDuration: 0.5) + let group = SKAction.group([moveUp, fadeOut]) + let remove = SKAction.removeFromParent() + label.run(SKAction.sequence([group, remove])) + } +} diff --git a/RollkofferSimulator/Scenes/MenuScene.swift b/RollkofferSimulator/Scenes/MenuScene.swift new file mode 100644 index 0000000..7a650ce --- /dev/null +++ b/RollkofferSimulator/Scenes/MenuScene.swift @@ -0,0 +1,265 @@ +// +// MenuScene.swift +// RollkofferSimulator +// +// Created by Ingo K. +// + +import SpriteKit + +class MenuScene: SKScene { + + // MARK: - Properties + private var startButton: SKShapeNode! + private var titleLabel: SKLabelNode! + private var subtitleLabel: SKLabelNode! + private var highScoreLabel: SKLabelNode! + private var creditsLabel: SKLabelNode! + + // MARK: - Decorative elements + private var decorativeSuitcase: PlayerNode! + private var decorativeDogs: [DogNode] = [] + private var decorativeHumans: [HumanNode] = [] + + // MARK: - Scene Lifecycle + override func didMove(to view: SKView) { + setupBackground() + setupTitle() + setupStartButton() + setupHighScore() + setupDecorations() + setupCredits() + startAnimations() + } + + // MARK: - Setup + private func setupBackground() { + backgroundColor = Constants.Colors.backgroundColor + + // Create floor pattern + let floorHeight = frame.height * 0.6 + let floor = SKShapeNode(rect: CGRect(x: 0, y: 0, + width: frame.width, height: floorHeight)) + floor.fillColor = Constants.Colors.floorColor + floor.strokeColor = .clear + floor.zPosition = Constants.ZPosition.floor + addChild(floor) + + // Add floor tiles pattern + let tileSize: CGFloat = 80 + for x in stride(from: CGFloat(0), to: frame.width, by: tileSize) { + for y in stride(from: CGFloat(0), to: floorHeight, by: tileSize) { + let tile = SKShapeNode(rect: CGRect(x: x, y: y, + width: tileSize - 2, height: tileSize - 2)) + tile.fillColor = (Int((x + y) / tileSize) % 2 == 0) ? + SKColor(white: 0.78, alpha: 1.0) : SKColor(white: 0.72, alpha: 1.0) + tile.strokeColor = SKColor(white: 0.65, alpha: 0.5) + tile.lineWidth = 1 + tile.zPosition = Constants.ZPosition.floor + 0.1 + addChild(tile) + } + } + } + + private func setupTitle() { + // Main title + titleLabel = SKLabelNode(text: "๐Ÿงณ Rollkoffer Simulator ๐Ÿงณ") + titleLabel.fontName = "AvenirNext-Heavy" + titleLabel.fontSize = 36 + titleLabel.fontColor = SKColor(red: 0.3, green: 0.2, blue: 0.5, alpha: 1.0) + titleLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.85) + titleLabel.zPosition = Constants.ZPosition.ui + addChild(titleLabel) + + // Subtitle + subtitleLabel = SKLabelNode(text: "Sammle Hunde & Menschen am Flughafen!") + subtitleLabel.fontName = "AvenirNext-Medium" + subtitleLabel.fontSize = 18 + subtitleLabel.fontColor = SKColor.darkGray + subtitleLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.78) + subtitleLabel.zPosition = Constants.ZPosition.ui + addChild(subtitleLabel) + + // Goal info + let goalLabel = SKLabelNode(text: "Ziel: ๐Ÿ• 10 Hunde + ๐Ÿ‘ค 5 Grรผne Menschen") + goalLabel.fontName = "AvenirNext-Medium" + goalLabel.fontSize = 16 + goalLabel.fontColor = SKColor.gray + goalLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.72) + goalLabel.zPosition = Constants.ZPosition.ui + addChild(goalLabel) + } + + private func setupStartButton() { + // Button background + let buttonWidth: CGFloat = 200 + let buttonHeight: CGFloat = 60 + + startButton = SKShapeNode(rect: CGRect(x: -buttonWidth / 2, y: -buttonHeight / 2, + width: buttonWidth, height: buttonHeight), + cornerRadius: 15) + startButton.fillColor = SKColor(red: 0.2, green: 0.7, blue: 0.3, alpha: 1.0) + startButton.strokeColor = SKColor(red: 0.15, green: 0.5, blue: 0.2, alpha: 1.0) + startButton.lineWidth = 3 + startButton.position = CGPoint(x: frame.midX, y: frame.height * 0.5) + startButton.zPosition = Constants.ZPosition.ui + startButton.name = "startButton" + addChild(startButton) + + // Button label + let buttonLabel = SKLabelNode(text: "โ–ถ START") + buttonLabel.fontName = "AvenirNext-Bold" + buttonLabel.fontSize = 28 + buttonLabel.fontColor = .white + buttonLabel.verticalAlignmentMode = .center + buttonLabel.position = .zero + buttonLabel.zPosition = 1 + startButton.addChild(buttonLabel) + + // Button pulse animation + let scaleUp = SKAction.scale(to: 1.05, duration: 0.8) + let scaleDown = SKAction.scale(to: 1.0, duration: 0.8) + let pulse = SKAction.sequence([scaleUp, scaleDown]) + startButton.run(SKAction.repeatForever(pulse)) + } + + private func setupHighScore() { + let highScore = ScoreManager.shared.highScore + + highScoreLabel = SKLabelNode(text: "๐Ÿ† High Score: \(highScore)") + highScoreLabel.fontName = "AvenirNext-DemiBold" + highScoreLabel.fontSize = 20 + highScoreLabel.fontColor = SKColor(red: 0.8, green: 0.6, blue: 0.1, alpha: 1.0) + highScoreLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.38) + highScoreLabel.zPosition = Constants.ZPosition.ui + addChild(highScoreLabel) + + // Statistics label + let victories = ScoreManager.shared.victories + let gamesPlayed = ScoreManager.shared.gamesPlayed + let statsText = "Siege: \(victories) | Spiele: \(gamesPlayed)" + + let statsLabel = SKLabelNode(text: statsText) + statsLabel.fontName = "AvenirNext-Regular" + statsLabel.fontSize = 14 + statsLabel.fontColor = SKColor.gray + statsLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.33) + statsLabel.zPosition = Constants.ZPosition.ui + addChild(statsLabel) + } + + private func setupDecorations() { + // Decorative suitcase + decorativeSuitcase = PlayerNode() + decorativeSuitcase.position = CGPoint(x: frame.midX, y: frame.height * 0.2) + decorativeSuitcase.zPosition = Constants.ZPosition.entities + addChild(decorativeSuitcase) + + // Add some decorative dogs + let dogTypes: [DogType] = [.smallGood, .bigGood, .bad] + let dogPositions: [CGPoint] = [ + CGPoint(x: frame.width * 0.15, y: frame.height * 0.25), + CGPoint(x: frame.width * 0.85, y: frame.height * 0.22), + CGPoint(x: frame.width * 0.25, y: frame.height * 0.12) + ] + + for (index, type) in dogTypes.enumerated() { + let dog = DogNode(type: type) + dog.position = dogPositions[index] + dog.zPosition = Constants.ZPosition.entities + addChild(dog) + decorativeDogs.append(dog) + } + + // Add decorative humans + let humanTypes: [HumanType] = [.green, .gray] + let humanPositions: [CGPoint] = [ + CGPoint(x: frame.width * 0.75, y: frame.height * 0.15), + CGPoint(x: frame.width * 0.6, y: frame.height * 0.1) + ] + + for (index, type) in humanTypes.enumerated() { + let human = HumanNode(type: type) + human.position = humanPositions[index] + human.zPosition = Constants.ZPosition.entities + addChild(human) + decorativeHumans.append(human) + } + } + + private func setupCredits() { + creditsLabel = SKLabelNode(text: "Created by Ingo K.") + creditsLabel.fontName = "AvenirNext-Italic" + creditsLabel.fontSize = 14 + creditsLabel.fontColor = SKColor.gray + creditsLabel.position = CGPoint(x: frame.midX, y: 30) + creditsLabel.zPosition = Constants.ZPosition.ui + addChild(creditsLabel) + } + + private func startAnimations() { + // Animate decorative suitcase + let moveLeft = SKAction.moveBy(x: -20, y: 0, duration: 1.5) + let moveRight = SKAction.moveBy(x: 40, y: 0, duration: 3.0) + let moveBack = SKAction.moveBy(x: -20, y: 0, duration: 1.5) + let suitcaseSequence = SKAction.sequence([moveLeft, moveRight, moveBack]) + decorativeSuitcase.run(SKAction.repeatForever(suitcaseSequence)) + + // Animate decorative entities + for (index, dog) in decorativeDogs.enumerated() { + let delay = Double(index) * 0.3 + let bounce = SKAction.sequence([ + SKAction.wait(forDuration: delay), + SKAction.moveBy(x: 0, y: 10, duration: 0.4), + SKAction.moveBy(x: 0, y: -10, duration: 0.4) + ]) + dog.run(SKAction.repeatForever(bounce)) + } + + for (index, human) in decorativeHumans.enumerated() { + let delay = Double(index) * 0.4 + 0.2 + let sway = SKAction.sequence([ + SKAction.wait(forDuration: delay), + SKAction.rotate(byAngle: 0.05, duration: 0.5), + SKAction.rotate(byAngle: -0.1, duration: 1.0), + SKAction.rotate(byAngle: 0.05, duration: 0.5) + ]) + human.run(SKAction.repeatForever(sway)) + } + + // Title animation + let titleScale = SKAction.sequence([ + SKAction.scale(to: 1.02, duration: 2.0), + SKAction.scale(to: 1.0, duration: 2.0) + ]) + titleLabel.run(SKAction.repeatForever(titleScale)) + } + + // MARK: - Touch Handling + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + let location = touch.location(in: self) + + if startButton.contains(location) { + startGame() + } + } + + private func startGame() { + // Button press effect + let pressDown = SKAction.scale(to: 0.9, duration: 0.1) + let pressUp = SKAction.scale(to: 1.0, duration: 0.1) + + startButton.run(SKAction.sequence([pressDown, pressUp])) { [weak self] in + self?.transitionToGame() + } + } + + private func transitionToGame() { + let gameScene = GameScene(size: size) + gameScene.scaleMode = scaleMode + + let transition = SKTransition.fade(withDuration: 0.5) + view?.presentScene(gameScene, transition: transition) + } +} diff --git a/RollkofferSimulator/Scenes/VictoryScene.swift b/RollkofferSimulator/Scenes/VictoryScene.swift new file mode 100644 index 0000000..c6f4fa4 --- /dev/null +++ b/RollkofferSimulator/Scenes/VictoryScene.swift @@ -0,0 +1,312 @@ +// +// VictoryScene.swift +// RollkofferSimulator +// +// Created by Ingo K. +// + +import SpriteKit + +class VictoryScene: SKScene { + + // MARK: - Properties + var finalScore: Int = 0 + var dogsCollected: Int = 0 + var humansCollected: Int = 0 + var timeRemaining: TimeInterval = 0 + + private var playAgainButton: SKShapeNode! + private var menuButton: SKShapeNode! + + // MARK: - Scene Lifecycle + override func didMove(to view: SKView) { + setupBackground() + setupContent() + setupButtons() + startCelebration() + } + + // MARK: - Setup + private func setupBackground() { + backgroundColor = SKColor(red: 0.1, green: 0.2, blue: 0.1, alpha: 1.0) + + // Add celebration particles + for _ in 0..<30 { + let confetti = createConfetti() + addChild(confetti) + } + } + + private func createConfetti() -> SKShapeNode { + let size = CGFloat.random(in: 8...15) + let confetti = SKShapeNode(rectOf: CGSize(width: size, height: size * 1.5)) + confetti.fillColor = [SKColor.red, SKColor.yellow, SKColor.green, + SKColor.blue, SKColor.orange, SKColor.purple].randomElement()! + confetti.strokeColor = .clear + confetti.position = CGPoint(x: CGFloat.random(in: 0...frame.width), + y: frame.height + CGFloat.random(in: 50...200)) + confetti.zPosition = Constants.ZPosition.ui - 5 + confetti.zRotation = CGFloat.random(in: 0...(.pi * 2)) + + // Falling animation + let fallDuration = Double.random(in: 3...6) + let fall = SKAction.moveTo(y: -50, duration: fallDuration) + let rotate = SKAction.rotate(byAngle: .pi * 4, duration: fallDuration) + let sway = SKAction.sequence([ + SKAction.moveBy(x: 30, y: 0, duration: 0.5), + SKAction.moveBy(x: -30, y: 0, duration: 0.5) + ]) + let swayRepeat = SKAction.repeat(sway, count: Int(fallDuration)) + + let group = SKAction.group([fall, rotate, swayRepeat]) + let reset = SKAction.run { [weak confetti, weak self] in + confetti?.position = CGPoint(x: CGFloat.random(in: 0...(self?.frame.width ?? 400)), + y: (self?.frame.height ?? 800) + 50) + } + let sequence = SKAction.sequence([group, reset]) + confetti.run(SKAction.repeatForever(sequence)) + + return confetti + } + + private func setupContent() { + // Victory title + let titleLabel = SKLabelNode(text: "๐ŸŽ‰ GEWONNEN! ๐ŸŽ‰") + titleLabel.fontName = "AvenirNext-Heavy" + titleLabel.fontSize = 46 + titleLabel.fontColor = .yellow + titleLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.82) + titleLabel.zPosition = Constants.ZPosition.ui + addChild(titleLabel) + + // Animate title + let titlePulse = SKAction.sequence([ + SKAction.scale(to: 1.1, duration: 0.3), + SKAction.scale(to: 1.0, duration: 0.3) + ]) + titleLabel.run(SKAction.repeatForever(titlePulse)) + + // Subtitle + let subtitleLabel = SKLabelNode(text: "Du hast alle Ziele erreicht!") + subtitleLabel.fontName = "AvenirNext-Medium" + subtitleLabel.fontSize = 20 + subtitleLabel.fontColor = .white + subtitleLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.74) + subtitleLabel.zPosition = Constants.ZPosition.ui + addChild(subtitleLabel) + + // Score display + let scoreLabel = SKLabelNode(text: "Endpunktzahl: \(finalScore)") + scoreLabel.fontName = "AvenirNext-Bold" + scoreLabel.fontSize = 36 + scoreLabel.fontColor = SKColor(red: 1.0, green: 0.85, blue: 0.0, alpha: 1.0) + scoreLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.62) + scoreLabel.zPosition = Constants.ZPosition.ui + addChild(scoreLabel) + + // Time bonus display + let timeBonus = Int(timeRemaining) * 5 + if timeBonus > 0 { + let timeBonusLabel = SKLabelNode(text: "โฑ๏ธ Zeitbonus: +\(timeBonus) (\(Int(timeRemaining))s รผbrig)") + timeBonusLabel.fontName = "AvenirNext-Medium" + timeBonusLabel.fontSize = 18 + timeBonusLabel.fontColor = SKColor.cyan + timeBonusLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.55) + timeBonusLabel.zPosition = Constants.ZPosition.ui + addChild(timeBonusLabel) + } + + // Stats display + let statsY = frame.height * 0.45 + + let dogsLabel = SKLabelNode(text: "๐Ÿ• Hunde: \(dogsCollected)") + dogsLabel.fontName = "AvenirNext-DemiBold" + dogsLabel.fontSize = 22 + dogsLabel.fontColor = .white + dogsLabel.position = CGPoint(x: frame.midX - 80, y: statsY) + dogsLabel.zPosition = Constants.ZPosition.ui + addChild(dogsLabel) + + let humansLabel = SKLabelNode(text: "๐Ÿ‘ค Menschen: \(humansCollected)") + humansLabel.fontName = "AvenirNext-DemiBold" + humansLabel.fontSize = 22 + humansLabel.fontColor = .white + humansLabel.position = CGPoint(x: frame.midX + 80, y: statsY) + humansLabel.zPosition = Constants.ZPosition.ui + addChild(humansLabel) + + // Happy suitcase with collected items + let happySuitcase = PlayerNode() + happySuitcase.position = CGPoint(x: frame.midX, y: frame.height * 0.28) + addChild(happySuitcase) + + // Happy face + let happyFace = SKLabelNode(text: "๐Ÿ˜„") + happyFace.fontSize = 30 + happyFace.position = CGPoint(x: frame.midX, y: frame.height * 0.28 + 20) + happyFace.zPosition = Constants.ZPosition.ui + addChild(happyFace) + + // Add small dogs and humans around suitcase + let collectibles = [ + ("๐Ÿ•", CGPoint(x: -50, y: 0)), + ("๐Ÿ•", CGPoint(x: 50, y: 0)), + ("๐Ÿ‘ค", CGPoint(x: -35, y: 30)), + ("๐Ÿ‘ค", CGPoint(x: 35, y: 30)) + ] + + for (emoji, offset) in collectibles { + let label = SKLabelNode(text: emoji) + label.fontSize = 24 + label.position = CGPoint(x: frame.midX + offset.x, + y: frame.height * 0.28 + offset.y) + label.zPosition = Constants.ZPosition.ui + addChild(label) + + // Bounce animation + let bounce = SKAction.sequence([ + SKAction.moveBy(x: 0, y: 5, duration: 0.3), + SKAction.moveBy(x: 0, y: -5, duration: 0.3) + ]) + label.run(SKAction.repeatForever(bounce)) + } + + // High score check + if ScoreManager.shared.isNewHighScore(finalScore) { + let highScoreLabel = SKLabelNode(text: "๐Ÿ† NEUER HIGHSCORE! ๐Ÿ†") + highScoreLabel.fontName = "AvenirNext-Heavy" + highScoreLabel.fontSize = 26 + highScoreLabel.fontColor = SKColor.yellow + highScoreLabel.position = CGPoint(x: frame.midX, y: frame.height * 0.68) + highScoreLabel.zPosition = Constants.ZPosition.ui + addChild(highScoreLabel) + + let glow = SKAction.sequence([ + SKAction.fadeAlpha(to: 0.6, duration: 0.4), + SKAction.fadeAlpha(to: 1.0, duration: 0.4) + ]) + highScoreLabel.run(SKAction.repeatForever(glow)) + } + } + + private func setupButtons() { + // Play Again button + playAgainButton = createButton(text: "๐ŸŽฎ Nochmal spielen", + color: SKColor(red: 0.2, green: 0.7, blue: 0.3, alpha: 1.0)) + playAgainButton.position = CGPoint(x: frame.midX, y: frame.height * 0.13) + playAgainButton.name = "playAgainButton" + addChild(playAgainButton) + + // Menu button + menuButton = createButton(text: "๐Ÿ  Hauptmenรผ", + color: SKColor(red: 0.3, green: 0.3, blue: 0.6, alpha: 1.0)) + menuButton.position = CGPoint(x: frame.midX, y: frame.height * 0.06) + menuButton.name = "menuButton" + addChild(menuButton) + } + + private func createButton(text: String, color: SKColor) -> SKShapeNode { + let buttonWidth: CGFloat = 220 + let buttonHeight: CGFloat = 50 + + let button = SKShapeNode(rect: CGRect(x: -buttonWidth / 2, y: -buttonHeight / 2, + width: buttonWidth, height: buttonHeight), + cornerRadius: 12) + button.fillColor = color + button.strokeColor = .white.withAlphaComponent(0.5) + button.lineWidth = 2 + button.zPosition = Constants.ZPosition.ui + + let label = SKLabelNode(text: text) + label.fontName = "AvenirNext-Bold" + label.fontSize = 20 + label.fontColor = .white + label.verticalAlignmentMode = .center + label.zPosition = 1 + button.addChild(label) + + return button + } + + private func startCelebration() { + // Screen flash + let flash = SKShapeNode(rect: frame) + flash.fillColor = .white + flash.strokeColor = .clear + flash.zPosition = Constants.ZPosition.ui + 100 + flash.alpha = 0.8 + addChild(flash) + + let fadeOut = SKAction.fadeOut(withDuration: 0.5) + let remove = SKAction.removeFromParent() + flash.run(SKAction.sequence([fadeOut, remove])) + + // Star burst effect + for _ in 0..<12 { + let star = SKLabelNode(text: "โญ") + star.fontSize = CGFloat.random(in: 20...40) + star.position = CGPoint(x: frame.midX, y: frame.height * 0.82) + star.zPosition = Constants.ZPosition.ui - 1 + star.alpha = 0 + addChild(star) + + let angle = CGFloat.random(in: 0...(.pi * 2)) + let distance = CGFloat.random(in: 100...200) + let endPoint = CGPoint(x: frame.midX + cos(angle) * distance, + y: frame.height * 0.82 + sin(angle) * distance) + + let fadeIn = SKAction.fadeIn(withDuration: 0.2) + let move = SKAction.move(to: endPoint, duration: 0.5) + let fadeOutStar = SKAction.fadeOut(withDuration: 0.3) + let removeStar = SKAction.removeFromParent() + + let group = SKAction.group([move, SKAction.sequence([fadeIn, + SKAction.wait(forDuration: 0.2), + fadeOutStar])]) + star.run(SKAction.sequence([SKAction.wait(forDuration: Double.random(in: 0...0.3)), + group, removeStar])) + } + } + + // MARK: - Touch Handling + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + let location = touch.location(in: self) + + if playAgainButton.contains(location) { + playAgain() + } else if menuButton.contains(location) { + returnToMenu() + } + } + + private func playAgain() { + let pressDown = SKAction.scale(to: 0.9, duration: 0.1) + let pressUp = SKAction.scale(to: 1.0, duration: 0.1) + + playAgainButton.run(SKAction.sequence([pressDown, pressUp])) { [weak self] in + guard let self = self else { return } + + let gameScene = GameScene(size: self.size) + gameScene.scaleMode = self.scaleMode + + let transition = SKTransition.fade(withDuration: 0.5) + self.view?.presentScene(gameScene, transition: transition) + } + } + + private func returnToMenu() { + let pressDown = SKAction.scale(to: 0.9, duration: 0.1) + let pressUp = SKAction.scale(to: 1.0, duration: 0.1) + + menuButton.run(SKAction.sequence([pressDown, pressUp])) { [weak self] in + guard let self = self else { return } + + let menuScene = MenuScene(size: self.size) + menuScene.scaleMode = self.scaleMode + + let transition = SKTransition.fade(withDuration: 0.5) + self.view?.presentScene(menuScene, transition: transition) + } + } +} diff --git a/RollkofferSimulator/Utils/Constants.swift b/RollkofferSimulator/Utils/Constants.swift new file mode 100644 index 0000000..4ff63df --- /dev/null +++ b/RollkofferSimulator/Utils/Constants.swift @@ -0,0 +1,79 @@ +// +// Constants.swift +// RollkofferSimulator +// +// Created by Ingo K. +// + +import SpriteKit + +struct Constants { + + // MARK: - Game Settings + static let gameTime: TimeInterval = 90.0 + static let startLives: Int = 3 + static let targetDogs: Int = 10 + static let targetHumans: Int = 5 + + // MARK: - Points + static let pointsSmallGoodDog: Int = 10 + static let pointsBigGoodDog: Int = 25 + static let pointsGreenHuman: Int = 15 + + // MARK: - Spawn Settings + static let spawnIntervalMin: TimeInterval = 0.8 + static let spawnIntervalMax: TimeInterval = 1.5 + static let scrollSpeed: CGFloat = 200.0 + + // MARK: - Spawn Distribution (cumulative percentages) + static let spawnChanceGoodDog: Int = 40 // 0-39: good dogs + static let spawnChanceBadDog: Int = 60 // 40-59: bad dogs (20%) + static let spawnChanceGreenHuman: Int = 85 // 60-84: green humans (25%) + // 85-99: gray humans (15%) + + // MARK: - Sprite Sizes + static let suitcaseSize = CGSize(width: 60, height: 80) + static let smallDogSize = CGSize(width: 40, height: 40) + static let bigDogSize = CGSize(width: 70, height: 70) + static let badDogSize = CGSize(width: 55, height: 55) + static let humanSize = CGSize(width: 50, height: 80) + + // MARK: - Physics Categories (Bitmasks) + struct PhysicsCategory { + static let none: UInt32 = 0 + static let suitcase: UInt32 = 0x1 << 0 + static let goodDog: UInt32 = 0x1 << 1 + static let badDog: UInt32 = 0x1 << 2 + static let greenHuman: UInt32 = 0x1 << 3 + static let grayHuman: UInt32 = 0x1 << 4 + + static let collectible: UInt32 = goodDog | greenHuman + static let harmful: UInt32 = badDog | grayHuman + static let all: UInt32 = goodDog | badDog | greenHuman | grayHuman + } + + // MARK: - Colors + struct Colors { + static let goodDogColor = SKColor(red: 0.85, green: 0.65, blue: 0.13, alpha: 1.0) // Gold brown + static let badDogColor = SKColor.red + static let greenHumanColor = SKColor(red: 0.2, green: 0.8, blue: 0.2, alpha: 1.0) + static let grayHumanColor = SKColor.gray + static let suitcaseColor = SKColor(red: 0.4, green: 0.3, blue: 0.6, alpha: 1.0) + static let backgroundColor = SKColor(red: 0.9, green: 0.9, blue: 0.85, alpha: 1.0) + static let floorColor = SKColor(red: 0.75, green: 0.75, blue: 0.7, alpha: 1.0) + } + + // MARK: - Z-Positions + struct ZPosition { + static let background: CGFloat = 0 + static let floor: CGFloat = 1 + static let entities: CGFloat = 10 + static let player: CGFloat = 20 + static let ui: CGFloat = 100 + } + + // MARK: - Animation + static let blinkDuration: TimeInterval = 0.1 + static let blinkCount: Int = 6 + static let invincibilityDuration: TimeInterval = 1.5 +}