diff --git a/Privyet/Base.lproj/Main.storyboard b/Privyet/Base.lproj/Main.storyboard index 966bd70..c9c0c3c 100644 --- a/Privyet/Base.lproj/Main.storyboard +++ b/Privyet/Base.lproj/Main.storyboard @@ -15,13 +15,6 @@ - @@ -60,6 +53,20 @@ + + diff --git a/Privyet/Block.swift b/Privyet/Block.swift index aee499e..b66ee74 100644 --- a/Privyet/Block.swift +++ b/Privyet/Block.swift @@ -37,6 +37,45 @@ enum BlockColor: Int, CustomStringConvertible, CaseIterable { } } +// Rectangles in block coordinates +class BlockRect { + var left: Int + var top: Int + var right: Int + var bottom: Int + + init(left: Int, top: Int, right: Int, bottom: Int) { + self.left = left + self.top = top + self.right = right + self.bottom = bottom + } + + convenience init(x: Int, y: Int) { + self.init(left: x, top: y, right: x, bottom: y) + } + + var width: Int { + return right - left + 1 + } + + var height: Int { + return bottom - top + 1 + } + + static func +(lhs: BlockRect, rhs: BlockRect) -> BlockRect { + return BlockRect(left: min(lhs.left, rhs.left), top: min(lhs.top, rhs.top), right: max(lhs.right, rhs.right), bottom: max(lhs.bottom, rhs.bottom)) + } + + static func +=(lhs: BlockRect, rhs: BlockRect) -> BlockRect { + lhs.left = min(lhs.left, rhs.left) + lhs.top = min(lhs.top, rhs.top) + lhs.right = max(lhs.right, rhs.right) + lhs.bottom = max(lhs.bottom, rhs.bottom) + return lhs + } +} + // Represents a single block in the game. class Block: Hashable, CustomStringConvertible { // Constants diff --git a/Privyet/GameScene.swift b/Privyet/GameScene.swift index 87d5ffc..69192f4 100644 --- a/Privyet/GameScene.swift +++ b/Privyet/GameScene.swift @@ -23,6 +23,7 @@ class GameScene: SKScene { var lastTick: NSDate? var scaleFactor: CGFloat! + var holdControlRect: CGRect! var textureCache = Dictionary() var textureAtlas: SKTextureAtlas? @@ -62,6 +63,14 @@ class GameScene: SKScene { shapeLayer.addChild(gameBoard) gameLayer.addChild(shapeLayer) + // Calculate area of "hold" piece for hit-testing + let holdBBox = Shape.boundingBox(baseColumn: HoldColumn, baseRow: HoldRow) + let holdWidth = CGFloat(holdBBox.width) * BlockSize + let holdHeight = CGFloat(holdBBox.height) * BlockSize + let holdX = LayerPosition.x + (CGFloat(HoldColumn) * BlockSize) + let holdY = LayerPosition.y + (CGFloat(HoldRow) * BlockSize) + holdControlRect = CGRect(x: holdX * scaleFactor, y: holdY * scaleFactor, width: holdWidth * scaleFactor, height: holdHeight * scaleFactor) + // Set the theme music to play infinitely. run(SKAction.repeatForever(SKAction.playSoundFileNamed("Sounds/theme.mp3", waitForCompletion: true))) } @@ -163,9 +172,19 @@ class GameScene: SKScene { } // Animate the removed lines "exploding" and the remaining blocks dropping into place. - func animateCollapsingLines(linesToRemove: Array>, fallenBlocks: Array>, completion: @escaping () -> ()) { + func animateCollapsingLines(linesToRemove: Array>, fallenBlocks: Array>, fadingShapes: Array, completion: @escaping () -> ()) { var longestDuration: TimeInterval = 0 + // Animate the shapes fading out + for fadeShape in fadingShapes { + for block in fadeShape.blocks { + let sprite = block.sprite! + let fadeOutAction: SKAction = SKAction.fadeOut(withDuration: TimeInterval(0.1)) + fadeOutAction.timingMode = .easeIn + sprite.run(SKAction.sequence([fadeOutAction, SKAction.removeFromParent()])) + } + } + // Animate the falling blocks dropping into place for (columnIdx, column) in fallenBlocks.enumerated() { for (blockIdx, block) in column.enumerated() { @@ -212,4 +231,28 @@ class GameScene: SKScene { } run(SKAction.sequence([SKAction.wait(forDuration: longestDuration), SKAction.run(completion)])) } + + // Animate the multiple-line clearance banner. + func animateClearanceBanner(bannerName: String?) { + guard let name = bannerName else { + return + } + var texture = textureCache[name] + if texture == nil { + texture = textureAtlas?.textureNamed(name) + textureCache[name] = texture + } + let sprite = SKSpriteNode(texture: texture) + + sprite.position = CGPoint(x: LayerPosition.x + (CGFloat(NumColumns / 2) * BlockSize), y: LayerPosition.y - (CGFloat(NumRows / 2) * BlockSize)) + sprite.zPosition = 20 + sprite.alpha = 0 + shapeLayer.addChild(sprite) + + // Animate a quick fade-in followed by a slow fade out + let fadeInAction: SKAction = SKAction.fadeAlpha(to: 0.9, duration: 0.05) + let fadeOutAction: SKAction = SKAction.fadeOut(withDuration: 1.5) + fadeOutAction.timingMode = .easeIn + sprite.run(SKAction.sequence([fadeInAction, fadeOutAction, SKAction.removeFromParent()])) + } } diff --git a/Privyet/GameViewController.swift b/Privyet/GameViewController.swift index 8c9a0d8..c903ab2 100644 --- a/Privyet/GameViewController.swift +++ b/Privyet/GameViewController.swift @@ -10,6 +10,8 @@ import UIKit import SpriteKit import GameplayKit +let ClearanceAnimation = [nil, "double", "triple", "privyet"] + class GameViewController: UIViewController, PrivyetDelegate, UIGestureRecognizerDelegate { var scene: GameScene! var privyet: Privyet! @@ -31,7 +33,8 @@ class GameViewController: UIViewController, PrivyetDelegate, UIGestureRecognizer scene.scaleMode = .aspectFill bucketRect = scene.rectForBucket() - print("Computed bucket rectangle = \(bucketRect!)") + //print("Computed bucket rectangle = \(bucketRect!)") + //print("Computed hold rectangle = \(scene.holdControlRect!)") scene.tick = didTick @@ -52,6 +55,8 @@ class GameViewController: UIViewController, PrivyetDelegate, UIGestureRecognizer let tapLoc = sender.location(in: view) if bucketRect.contains(tapLoc) { privyet.rotateShape() + } else if scene.holdControlRect.contains(tapLoc) { + privyet.holdShape() } } @@ -136,7 +141,7 @@ class GameViewController: UIViewController, PrivyetDelegate, UIGestureRecognizer view.isUserInteractionEnabled = false scene.stopTicking() scene.playSound(sound: "Sounds/gameover.mp3") - scene.animateCollapsingLines(linesToRemove: privyet.removeAllBlocks(), fallenBlocks: privyet.removeAllBlocks()) { + scene.animateCollapsingLines(linesToRemove: privyet.removeAllBlocks(), fallenBlocks: privyet.removeAllBlocks(), fadingShapes: privyet.removeAllShapes()) { privyet.beginGame() } } @@ -161,20 +166,52 @@ class GameViewController: UIViewController, PrivyetDelegate, UIGestureRecognizer scene.playSound(sound: "Sounds/drop.mp3") } + // Called when the current shape is put "on hold" + func gameShapePutOnHold(privyet: Privyet, firstHold: Bool) { + guard let held = privyet.heldShape else { + return + } + scene.stopTicking() + view.isUserInteractionEnabled = false + scene.redrawShape(shape: held) { + if (firstHold) { + self.nextShape() // act like the previous shape settled + } else { + self.scene.redrawShape(shape: privyet.fallingShape!) { + self.scene.startTicking() + self.view.isUserInteractionEnabled = true + } + } + } + scene.playSound(sound: "Sounds/zap.mp3") + } + + // Internal: called when a shape lands, used to keep from showing the "special" banner more than once + func internalDidLand(privyet: Privyet, showBanner: Bool) { + let removedLines = privyet.removeCompletedLines() + let linesCount = removedLines.linesRemoved.count + if linesCount > 0 { + self.scoreLabel.text = "\(privyet.score)" + if (showBanner) { + scene.animateClearanceBanner(bannerName: ClearanceAnimation[linesCount - 1]) + } + scene.animateCollapsingLines(linesToRemove: removedLines.linesRemoved, fallenBlocks: removedLines.fallenBlocks, fadingShapes: []) { + self.internalDidLand(privyet: privyet, showBanner: (linesCount <= 1) && showBanner) + } + self.scene.playSound(sound: "Sounds/bomb.mp3") + if showBanner && linesCount == 4 { + self.scene.playSound(sound: "Sounds/privyet.mp3") + } + } else { + nextShape() + } + } + // Called when a shape "lands" on top of existing blocks or at the bottom of the bucket func gameShapeDidLand(privyet: Privyet) { scene.stopTicking() self.view.isUserInteractionEnabled = false - let removedLines = privyet.removeCompletedLines() - if removedLines.linesRemoved.count > 0 { - self.scoreLabel.text = "\(privyet.score)" - scene.animateCollapsingLines(linesToRemove: removedLines.linesRemoved, fallenBlocks: removedLines.fallenBlocks) { - self.gameShapeDidLand(privyet: privyet) - } - self.scene.playSound(sound: "Sounds/bomb.mp3") - } else { - nextShape() - } + internalDidLand(privyet: privyet, showBanner: true) } // Called when a shape moves on the screen diff --git a/Privyet/Privyet.swift b/Privyet/Privyet.swift index 216835a..5c7f375 100644 --- a/Privyet/Privyet.swift +++ b/Privyet/Privyet.swift @@ -17,8 +17,12 @@ let StartingRow = 0 let PreviewColumn = 12 let PreviewRow = 3 -let PointsPerLine = 10 -let LevelThreshold = 500 +let HoldColumn = 12 +let HoldRow = 10 + +// number of points for single (1 line), double (2 lines), triple (3 lines), Privyet (4 lines) +let PointsPerLine = [1, 2, 5, 10] +let LevelThreshold = 50 protocol PrivyetDelegate { // Invoked when the current round of Privyet ends @@ -36,6 +40,9 @@ protocol PrivyetDelegate { // invoked when the falling shape has changed its location after being dropped func gameShapeDidDrop(privyet: Privyet) + // invoked when the current shape is put on hold + func gameShapePutOnHold(privyet: Privyet, firstHold: Bool) + // invoked when the game has reached a new level func gameDidLevelUp(privyet: Privyet) } @@ -44,6 +51,7 @@ class Privyet { var bucket: Array2D var nextShape: Shape? var fallingShape: Shape? + var heldShape: Shape? var delegate: PrivyetDelegate? var score = 0 @@ -52,6 +60,7 @@ class Privyet { init() { fallingShape = nil nextShape = nil + heldShape = nil bucket = Array2D(columns: NumColumns, rows: NumRows) } @@ -154,7 +163,7 @@ class Privyet { } // Advance score and game level as appropriate - let pointsEarned = removedLines.count * PointsPerLine * level + let pointsEarned = PointsPerLine[removedLines.count - 1] * max(level / 2, 1) score += pointsEarned if score >= level * LevelThreshold { level += 1 @@ -201,6 +210,21 @@ class Privyet { return allBlocks } + // Removes all stored shapes from their slots (at end of game). Returns all the removed shapes. + func removeAllShapes() -> Array { + var allShapes = Array() + for shape in [fallingShape, nextShape, heldShape] { + guard let s = shape else { + continue + } + allShapes.append(s) + } + fallingShape = nil + nextShape = nil + heldShape = nil + return allShapes + } + // Drop the falling shape down as far as it will go. func dropShape() { guard let shape = fallingShape else { @@ -272,4 +296,22 @@ class Privyet { } delegate?.gameShapeDidMove(privyet: self) } + + // Places current shape "on hold." + func holdShape() { + guard let shape = fallingShape else { + return + } + guard let held = heldShape else { + shape.moveTo(column: HoldColumn, row: HoldRow) + heldShape = shape + fallingShape = nil + delegate?.gameShapePutOnHold(privyet: self, firstHold: true) + return + } + shape.exchangePositions(other: held) + fallingShape = held + heldShape = shape + delegate?.gameShapePutOnHold(privyet: self, firstHold: false) + } } diff --git a/Privyet/Shape.swift b/Privyet/Shape.swift index 77ca24c..9456895 100644 --- a/Privyet/Shape.swift +++ b/Privyet/Shape.swift @@ -131,6 +131,21 @@ class Shape: Hashable, CustomStringConvertible { } } + final func getBoundingBoxForOrientation(baseColumn: Int, baseRow: Int, orientation: Orientation) -> BlockRect { + guard let offsets = blockRowColumnPositions[orientation] else { + return BlockRect(x: baseColumn, y: baseRow) + } + + let blockRects = offsets.map { (offset) -> BlockRect in + return BlockRect(x: baseColumn + offset.columnDiff, y: baseRow + offset.rowDiff) + } + return blockRects[0] + blockRects[1] + blockRects[2] + blockRects[3] + } + + final func getBoundingBox(baseColumn: Int, baseRow: Int) -> BlockRect { + return getBoundingBoxForOrientation(baseColumn: baseColumn, baseRow: baseRow, orientation: .Zero) + getBoundingBoxForOrientation(baseColumn: baseColumn, baseRow: baseRow, orientation: .Ninety) + getBoundingBoxForOrientation(baseColumn: baseColumn, baseRow: baseRow, orientation: .OneEighty) + getBoundingBoxForOrientation(baseColumn: baseColumn, baseRow: baseRow, orientation: .TwoSeventy) + } + // Rotate the blocks in this shape to a new orientation. final func rotateBlocks(orientation: Orientation) { guard let blockRowColumnTranslation: Array<(columnDiff: Int, rowDiff: Int)> = blockRowColumnPositions[orientation] else { @@ -193,6 +208,17 @@ class Shape: Hashable, CustomStringConvertible { rotateBlocks(orientation: orientation) } + final func exchangePositions(other: Shape) { + var tmp = other.column + other.column = self.column + self.column = tmp + tmp = other.row + other.row = self.row + self.row = tmp + other.rotateBlocks(orientation: other.orientation) + rotateBlocks(orientation: orientation) + } + // Generates a random shape. final class func random(startingColumn: Int, startingRow: Int) -> Shape { switch Int.random(in: 0 ..< NumShapeTypes) { @@ -212,4 +238,14 @@ class Shape: Hashable, CustomStringConvertible { return ZShape(column: startingColumn, row: startingRow) } } + + final class func boundingBox(baseColumn: Int, baseRow: Int) -> BlockRect { + return SquareShape(column: 0, row: 0).getBoundingBox(baseColumn: baseColumn, baseRow: baseRow) + + TShape(column: 0, row: 0).getBoundingBox(baseColumn: baseColumn, baseRow: baseRow) + + LineShape(column: 0, row: 0).getBoundingBox(baseColumn: baseColumn, baseRow: baseRow) + + LShape(column: 0, row: 0).getBoundingBox(baseColumn: baseColumn, baseRow: baseRow) + + JShape(column: 0, row: 0).getBoundingBox(baseColumn: baseColumn, baseRow: baseRow) + + SShape(column: 0, row: 0).getBoundingBox(baseColumn: baseColumn, baseRow: baseRow) + + ZShape(column: 0, row: 0).getBoundingBox(baseColumn: baseColumn, baseRow: baseRow) + } } diff --git a/Privyet/Sounds/privyet.mp3 b/Privyet/Sounds/privyet.mp3 new file mode 100755 index 0000000..7797ac4 Binary files /dev/null and b/Privyet/Sounds/privyet.mp3 differ diff --git a/Privyet/Sounds/zap.mp3 b/Privyet/Sounds/zap.mp3 new file mode 100755 index 0000000..06b4115 Binary files /dev/null and b/Privyet/Sounds/zap.mp3 differ diff --git a/Privyet/Sprites.atlas/double.png b/Privyet/Sprites.atlas/double.png new file mode 100644 index 0000000..642023f Binary files /dev/null and b/Privyet/Sprites.atlas/double.png differ diff --git a/Privyet/Sprites.atlas/double@2x.png b/Privyet/Sprites.atlas/double@2x.png new file mode 100644 index 0000000..180d901 Binary files /dev/null and b/Privyet/Sprites.atlas/double@2x.png differ diff --git a/Privyet/Sprites.atlas/privyet.png b/Privyet/Sprites.atlas/privyet.png new file mode 100644 index 0000000..1a77610 Binary files /dev/null and b/Privyet/Sprites.atlas/privyet.png differ diff --git a/Privyet/Sprites.atlas/privyet@2x.png b/Privyet/Sprites.atlas/privyet@2x.png new file mode 100644 index 0000000..fd6c0cf Binary files /dev/null and b/Privyet/Sprites.atlas/privyet@2x.png differ diff --git a/Privyet/Sprites.atlas/triple.png b/Privyet/Sprites.atlas/triple.png new file mode 100644 index 0000000..20a96fd Binary files /dev/null and b/Privyet/Sprites.atlas/triple.png differ diff --git a/Privyet/Sprites.atlas/triple@2x.png b/Privyet/Sprites.atlas/triple@2x.png new file mode 100644 index 0000000..ce4266c Binary files /dev/null and b/Privyet/Sprites.atlas/triple@2x.png differ