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
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user