GameKit框架详细解析(三) —— iOS的Game Center:构建基于回合制的游戏(二)

版本记录

版本号 时间
V1.0 2018.12.12 星期三

前言

GameKit框架创造经验,让玩家回到你的游戏。 添加排行榜,成就,匹配,挑战等等。接下来几篇我们就一起看一下这个框架。感兴趣的看下面几篇文章。
1. GameKit框架详细解析(一) —— 基本概览(一)
2. GameKit框架详细解析(二) —— iOS的Game Center:构建基于回合制的游戏(一)

源码

1. Swift

首先看一下工程结构

下面就是一起看一下代码

1. GKTurnBasedMatch+Additions.swift
import GameKit

extension GKTurnBasedMatch {
  var isLocalPlayersTurn: Bool {
    return currentParticipant?.player == GKLocalPlayer.local
  }
  
  var others: [GKTurnBasedParticipant] {
    return participants.filter {
      return $0.player != GKLocalPlayer.local
    }
  }
}
2. SKTexture+Additions.swift
import UIKit
import SpriteKit

extension SKTexture {
  class func recessedBackgroundTexture(of size: CGSize) -> SKTexture {
    return SKTexture(image: UIGraphicsImageRenderer(size: size).image { context in
      let fillColor = UIColor(white: 0, alpha: 0.2)
      let shadowColor = UIColor(white: 0, alpha: 0.3)
      
      let shadow = NSShadow()
      shadow.shadowColor = shadowColor
      shadow.shadowOffset = .zero
      shadow.shadowBlurRadius = 5
      
      let rectanglePath = UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 10)
      fillColor.setFill()
      rectanglePath.fill()
      
      let drawContext = context.cgContext
      
      drawContext.saveGState()
      drawContext.clip(to: rectanglePath.bounds)
      drawContext.setShadow(offset: .zero, blur: 0)
      drawContext.setAlpha((shadow.shadowColor as! UIColor).cgColor.alpha)
      drawContext.beginTransparencyLayer(auxiliaryInfo: nil)
      let rectangleOpaqueShadow = shadowColor.withAlphaComponent(1)
      drawContext.setShadow(offset: shadow.shadowOffset, blur: shadow.shadowBlurRadius, color: rectangleOpaqueShadow.cgColor)
      drawContext.setBlendMode(.sourceOut)
      drawContext.beginTransparencyLayer(auxiliaryInfo: nil)
      
      rectangleOpaqueShadow.setFill()
      rectanglePath.fill()
      
      drawContext.endTransparencyLayer()
      drawContext.endTransparencyLayer()
      drawContext.restoreGState()
    })
  }
  
  class func pillBackgroundTexture(of size: CGSize, color: UIColor?) -> SKTexture {
    return SKTexture(image: UIGraphicsImageRenderer(size: size).image { context in
      let fillColor = color ?? .white
      let shadowColor = UIColor(white: 0, alpha: 0.3)
      
      let shadow = NSShadow()
      shadow.shadowColor = shadowColor
      shadow.shadowOffset = CGSize(width: 0, height: 1)
      shadow.shadowBlurRadius = 5
      
      let drawContext = context.cgContext
      
      let pillRect = CGRect(origin: .zero, size: size).insetBy(dx: 3, dy: 4)
      let rectanglePath = UIBezierPath(roundedRect: pillRect, cornerRadius: size.height / 2)
      
      drawContext.setShadow(offset: shadow.shadowOffset, blur: shadow.shadowBlurRadius, color: shadowColor.cgColor)
      fillColor.setFill()
      rectanglePath.fill()
    })
  }
}
3. UIColor+Additions.swift
import UIKit

extension UIColor {
  static var background: UIColor {
    return UIColor(red: 26/255, green: 26/255, blue: 26/255, alpha: 1)
  }
  
  static var sky: UIColor {
    return UIColor(red: 112/255, green: 196/255, blue: 254/255, alpha: 1)
  }
}
4. GameCenterHelper.swift
import GameKit

final class GameCenterHelper: NSObject {
  typealias CompletionBlock = (Error?) -> Void
    
  static let helper = GameCenterHelper()
  
  static var isAuthenticated: Bool {
    return GKLocalPlayer.local.isAuthenticated
  }

  var viewController: UIViewController?
  var currentMatchmakerVC: GKTurnBasedMatchmakerViewController?
  
  enum GameCenterHelperError: Error {
    case matchNotFound
  }

  var currentMatch: GKTurnBasedMatch?

  var canTakeTurnForCurrentMatch: Bool {
    guard let match = currentMatch else {
      return true
    }
    
    return match.isLocalPlayersTurn
  }
  
  override init() {
    super.init()
    
    GKLocalPlayer.local.authenticateHandler = { gcAuthVC, error in
      NotificationCenter.default.post(name: .authenticationChanged, object: GKLocalPlayer.local.isAuthenticated)

      if GKLocalPlayer.local.isAuthenticated {
        GKLocalPlayer.local.register(self)
      } else if let vc = gcAuthVC {
        self.viewController?.present(vc, animated: true)
      }
      else {
        print("Error authentication to GameCenter: \(error?.localizedDescription ?? "none")")
      }
    }
  }
  
  func presentMatchmaker() {
    guard GKLocalPlayer.local.isAuthenticated else {
      return
    }
    
    let request = GKMatchRequest()
    
    request.minPlayers = 2
    request.maxPlayers = 2
    request.inviteMessage = "Would you like to play Nine Knights?"
    
    let vc = GKTurnBasedMatchmakerViewController(matchRequest: request)
    vc.turnBasedMatchmakerDelegate = self
    
    currentMatchmakerVC = vc
    viewController?.present(vc, animated: true)
  }
  
  func endTurn(_ model: GameModel, completion: @escaping CompletionBlock) {
    guard let match = currentMatch else {
      completion(GameCenterHelperError.matchNotFound)
      return
    }
    
    do {
      match.message = model.messageToDisplay
      
      match.endTurn(
        withNextParticipants: match.others,
        turnTimeout: GKExchangeTimeoutDefault,
        match: try JSONEncoder().encode(model),
        completionHandler: completion
      )
    } catch {
      completion(error)
    }
  }

  func win(completion: @escaping CompletionBlock) {
    guard let match = currentMatch else {
      completion(GameCenterHelperError.matchNotFound)
      return
    }
    
    match.currentParticipant?.matchOutcome = .won
    match.others.forEach { other in
      other.matchOutcome = .lost
    }
    
    match.endMatchInTurn(
      withMatch: match.matchData ?? Data(),
      completionHandler: completion
    )
  }
}

extension GameCenterHelper: GKTurnBasedMatchmakerViewControllerDelegate {
  func turnBasedMatchmakerViewControllerWasCancelled(_ viewController: GKTurnBasedMatchmakerViewController) {
    viewController.dismiss(animated: true)
  }
  
  func turnBasedMatchmakerViewController(_ viewController: GKTurnBasedMatchmakerViewController, didFailWithError error: Error) {
    print("Matchmaker vc did fail with error: \(error.localizedDescription).")
  }
}

extension GameCenterHelper: GKLocalPlayerListener {
  func player(_ player: GKPlayer, wantsToQuitMatch match: GKTurnBasedMatch) {
    let activeOthers = match.others.filter { other in
      return other.status == .active
    }
    
    match.currentParticipant?.matchOutcome = .lost
    activeOthers.forEach { participant in
      participant.matchOutcome = .won
    }
    
    match.endMatchInTurn(
      withMatch: match.matchData ?? Data()
    )
  }
  
  func player(_ player: GKPlayer, receivedTurnEventFor match: GKTurnBasedMatch, didBecomeActive: Bool) {
    if let vc = currentMatchmakerVC {
      currentMatchmakerVC = nil
      vc.dismiss(animated: true)
    }
    
    guard didBecomeActive else {
      return
    }
    
    NotificationCenter.default.post(name: .presentGame, object: match)
  }
}

extension Notification.Name {
  static let presentGame = Notification.Name(rawValue: "presentGame")
  static let authenticationChanged = Notification.Name(rawValue: "authenticationChanged")
}
5. GameModel.swift
import GameKit

struct GameModel: Codable {
  var turn: Int
  var state: State
  var lastMove: Move?
  var tokens: [Token]
  var winner: Player?
  var tokensPlaced: Int
  var millTokens: [Token]
  var removedToken: Token?
  var currentMill: [Token]?
  
  var currentPlayer: Player {
    return isKnightTurn ? .knight : .troll
  }
  
  var currentOpponent: Player {
    return isKnightTurn ? .troll : .knight
  }
  
  var messageToDisplay: String {
    let playerName = isKnightTurn ? "Knight" : "Troll"
    
    if isCapturingPiece {
      return "Take an opponent's piece!"
    }
    
    let stateAction: String
    switch state {
    case .placement:
      stateAction = "place"
      
    case .movement:
      if let winner = winner {
        return "\(winner == .knight ? "Knight" : "Troll")'s win!"
      } else {
        stateAction = "move"
      }
    }
    
    return "\(playerName)'s turn to \(stateAction)"
  }
  
  var isCapturingPiece: Bool {
    return currentMill != nil
  }
  
  var emptyCoordinates: [GridCoordinate] {
    let tokenCoords = tokens.map { $0.coord }
    
    return positions.filter { coord in
      return !tokenCoords.contains(coord)
    }
  }
  
  private(set) var isKnightTurn: Bool
  private let positions: [GridCoordinate]
  
  private let maxTokenCount = 18
  private let minPlayerTokenCount = 3
  
  init(isKnightTurn: Bool = true) {
    self.isKnightTurn = isKnightTurn
    
    turn = 0
    tokensPlaced = 0
    state = .placement
    tokens = [Token]()
    millTokens = [Token]()
    
    positions = [
      GridCoordinate(x: .min, y: .max, layer: .outer),
      GridCoordinate(x: .mid, y: .max, layer: .outer),
      GridCoordinate(x: .max, y: .max, layer: .outer),
      GridCoordinate(x: .max, y: .mid, layer: .outer),
      GridCoordinate(x: .max, y: .min, layer: .outer),
      GridCoordinate(x: .mid, y: .min, layer: .outer),
      GridCoordinate(x: .min, y: .min, layer: .outer),
      GridCoordinate(x: .min, y: .mid, layer: .outer),
      GridCoordinate(x: .min, y: .max, layer: .middle),
      GridCoordinate(x: .mid, y: .max, layer: .middle),
      GridCoordinate(x: .max, y: .max, layer: .middle),
      GridCoordinate(x: .max, y: .mid, layer: .middle),
      GridCoordinate(x: .max, y: .min, layer: .middle),
      GridCoordinate(x: .mid, y: .min, layer: .middle),
      GridCoordinate(x: .min, y: .min, layer: .middle),
      GridCoordinate(x: .min, y: .mid, layer: .middle),
      GridCoordinate(x: .min, y: .max, layer: .center),
      GridCoordinate(x: .mid, y: .max, layer: .center),
      GridCoordinate(x: .max, y: .max, layer: .center),
      GridCoordinate(x: .max, y: .mid, layer: .center),
      GridCoordinate(x: .max, y: .min, layer: .center),
      GridCoordinate(x: .mid, y: .min, layer: .center),
      GridCoordinate(x: .min, y: .min, layer: .center),
      GridCoordinate(x: .min, y: .mid, layer: .center),
    ]
  }
  
  func neighbors(at coord: GridCoordinate) -> [GridCoordinate] {
    var neighbors = [GridCoordinate]()
    
    switch coord.x {
    case .mid:
      neighbors.append(GridCoordinate(x: .min, y: coord.y, layer: coord.layer))
      neighbors.append(GridCoordinate(x: .max, y: coord.y, layer: coord.layer))
      
    case .min, .max:
      if coord.y == .mid {
        switch coord.layer {
        case .middle:
          neighbors.append(GridCoordinate(x: coord.x, y: coord.y, layer: .outer))
          neighbors.append(GridCoordinate(x: coord.x, y: coord.y, layer: .center))
        case .center, .outer:
          neighbors.append(GridCoordinate(x: coord.x, y: coord.y, layer: .middle))
        }
      } else {
        neighbors.append(GridCoordinate(x: .mid, y: coord.y, layer: coord.layer))
      }
    }
    
    switch coord.y {
    case .mid:
      neighbors.append(GridCoordinate(x: coord.x, y: .min, layer: coord.layer))
      neighbors.append(GridCoordinate(x: coord.x, y: .max, layer: coord.layer))
      
    case .min, .max:
      if coord.x == .mid {
        switch coord.layer {
        case .middle:
          neighbors.append(GridCoordinate(x: coord.x, y: coord.y, layer: .outer))
          neighbors.append(GridCoordinate(x: coord.x, y: coord.y, layer: .center))
        case .center, .outer:
          neighbors.append(GridCoordinate(x: coord.x, y: coord.y, layer: .middle))
        }
      } else {
        neighbors.append(GridCoordinate(x: coord.x, y: .mid, layer: coord.layer))
      }
    }
    
    return neighbors
  }
  
  func removableTokens(for player: Player) -> [Token] {
    let playerTokens = tokens.filter { token in
      return token.player == player
    }
    
    if playerTokens.count == 3, state == .movement {
      return playerTokens
    }
    
    return playerTokens.filter { token in
      return !millTokens.contains(token)
    }
  }
  
  func mill(containing token: Token) -> [Token]? {
    var coordsToCheck = [token.coord]
    
    var xPositionsToCheck: [GridPosition] = [.min, .mid, .max]
    xPositionsToCheck.remove(at: token.coord.x.rawValue)
    
    guard let firstXPosition = xPositionsToCheck.first, let lastXPosition = xPositionsToCheck.last else {
      return nil
    }
    
    var yPositionsToCheck: [GridPosition] = [.min, .mid, .max]
    yPositionsToCheck.remove(at: token.coord.y.rawValue)
    
    guard let firstYPosition = yPositionsToCheck.first, let lastYPosition = yPositionsToCheck.last else {
      return nil
    }
    
    var layersToCheck: [GridLayer] = [.outer, .middle, .center]
    layersToCheck.remove(at: token.coord.layer.rawValue)
    
    guard let firstLayer = layersToCheck.first, let lastLayer = layersToCheck.last else {
      return nil
    }
    
    switch token.coord.x {
    case .mid:
      coordsToCheck.append(GridCoordinate(x: token.coord.x, y: token.coord.y, layer: firstLayer))
      coordsToCheck.append(GridCoordinate(x: token.coord.x, y: token.coord.y, layer: lastLayer))
      
    case .min, .max:
      coordsToCheck.append(GridCoordinate(x: token.coord.x, y: firstYPosition, layer: token.coord.layer))
      coordsToCheck.append(GridCoordinate(x: token.coord.x, y: lastYPosition, layer: token.coord.layer))
    }
    
    let validHorizontalMillTokens = tokens.filter {
      return $0.player == token.player && coordsToCheck.contains($0.coord)
    }
    
    if validHorizontalMillTokens.count == 3 {
      return validHorizontalMillTokens
    }
    
    coordsToCheck = [token.coord]
    
    switch token.coord.y {
    case .mid:
      coordsToCheck.append(GridCoordinate(x: token.coord.x, y: token.coord.y, layer: firstLayer))
      coordsToCheck.append(GridCoordinate(x: token.coord.x, y: token.coord.y, layer: lastLayer))
      
    case .min, .max:
      coordsToCheck.append(GridCoordinate(x: firstXPosition, y: token.coord.y, layer: token.coord.layer))
      coordsToCheck.append(GridCoordinate(x: lastXPosition, y: token.coord.y, layer: token.coord.layer))
    }
    
    let validVerticalMillTokens = tokens.filter {
      return $0.player == token.player && coordsToCheck.contains($0.coord)
    }
    
    if validVerticalMillTokens.count == 3 {
      return validVerticalMillTokens
    }
    
    return nil
  }
  
  mutating func placeToken(at coord: GridCoordinate) {
    guard state == .placement else {
      return
    }
    
    let player = isKnightTurn ? Player.knight : Player.troll
    
    let newToken = Token(player: player, coord: coord)
    tokens.append(newToken)
    tokensPlaced += 1
    
    lastMove = Move(placed: coord)
    
    guard let newMill = mill(containing: newToken) else {
      advance()
      return
    }
    
    millTokens.append(contentsOf: newMill)
    currentMill = newMill
  }
  
  mutating func removeToken(at coord: GridCoordinate) -> Bool {
    guard isCapturingPiece else {
      return false
    }
    
    guard let index = tokens.firstIndex(where: { $0.coord == coord }) else {
      return false
    }
    
    let tokenToRemove = tokens[index]
    
    guard tokenCount(for: currentOpponent) == 3 || !millTokens.contains(tokenToRemove) else {
      return false
    }
    
    tokens.remove(at: index)
    lastMove = Move(removed: coord)
    advance()
    
    return true
  }
  
  mutating func move(from: GridCoordinate, to: GridCoordinate) {
    guard let index = tokens.firstIndex(where: { $0.coord == from }) else {
      return
    }
    
    let previousToken = tokens[index]
    let movedToken = Token(player: previousToken.player, coord: to)
    
    let millToRemove = mill(containing: previousToken) ?? []
    
    if !millToRemove.isEmpty {
      millToRemove.forEach { tokenToRemove in
        guard let index = millTokens.index(of: tokenToRemove) else {
          return
        }
        
        self.millTokens.remove(at: index)
      }
    }
    
    tokens[index] = movedToken
    lastMove = Move(start: from, end: to)
    
    if !millToRemove.isEmpty {
      for removedToken in millToRemove where removedToken != previousToken && mill(containing: removedToken) != nil {
        millTokens.append(removedToken)
      }
    }
    
    guard let newMill = mill(containing: movedToken) else {
      advance()
      return
    }
    
    millTokens.append(contentsOf: newMill)
    currentMill = newMill
  }
  
  mutating func advance() {
    if tokensPlaced == maxTokenCount && state == .placement {
      state = .movement
    }
    
    turn += 1
    currentMill = nil
    
    if state == .movement {
      if tokenCount(for: currentOpponent) == 2 || !canMove(currentOpponent) {
        winner = currentPlayer
      } else {
        isKnightTurn = !isKnightTurn
      }
    } else {
      isKnightTurn = !isKnightTurn
    }
  }
  
  func tokenCount(for player: Player) -> Int {
    return tokens.filter { token in
      return token.player == player
      }.count
  }
  
  func canMove(_ player: Player) -> Bool {
    let playerTokens = tokens.filter { token in
      return token.player == player
    }
    
    for token in playerTokens {
      let emptyNeighbors = neighbors(at: token.coord).filter({ emptyCoordinates.contains($0) })
      if !emptyNeighbors.isEmpty {
        return true
      }
    }
    
    return false
  }
}

// MARK: - Types

extension GameModel {
  enum Player: String, Codable {
    case knight, troll
  }
  
  enum State: Int, Codable {
    case placement
    case movement
  }
  
  enum GridPosition: Int, Codable {
    case min, mid, max
  }
  
  enum GridLayer: Int, Codable {
    case outer, middle, center
  }
  
  struct GridCoordinate: Codable, Equatable {
    let x, y: GridPosition
    let layer: GridLayer
  }
  
  struct Token: Codable, Equatable {
    let player: Player
    let coord: GridCoordinate
  }
  
  struct Move: Codable, Equatable {
    var placed: GridCoordinate?
    var removed: GridCoordinate?
    var start: GridCoordinate?
    var end: GridCoordinate?
    
    init(placed: GridCoordinate?) {
      self.placed = placed
    }
    
    init(removed: GridCoordinate?) {
      self.removed = removed
    }
    
    init(start: GridCoordinate?, end: GridCoordinate?) {
      self.start = start
      self.end = end
    }
  }
}
6. BackgroundNode.swift
import SpriteKit

class BackgroundNode: SKSpriteNode {
  enum Kind {
    case pill
    case recessed
  }
  
  init(kind: Kind, size: CGSize, color: UIColor? = nil) {
    let texture: SKTexture
    
    switch kind {
    case .pill:
      texture = SKTexture.pillBackgroundTexture(of: size, color: color)
    default:
      texture = SKTexture.recessedBackgroundTexture(of: size)
    }
    
    super.init(texture: texture, color: .clear, size: size)
  }
  
  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}
7. BoardNode.swift
import SpriteKit

final class BoardNode: SKNode {
  static let boardPointNodeName = "boardPoint"
  
  private enum NodeLayer: CGFloat {
    case background = 10
    case line = 20
    case point = 30
  }
  
  private let sideLength: CGFloat
  private let innerPadding: CGFloat
  
  init(sideLength: CGFloat, innerPadding: CGFloat = 100) {
    self.sideLength = sideLength
    self.innerPadding = innerPadding
    
    super.init()
    
    let size = CGSize(width: sideLength, height: sideLength)
    
    for index in 0...2 {
      let containerNode = SKSpriteNode(
        color: .clear,
        size: CGSize(
          width: size.width - (innerPadding * CGFloat(index)),
          height: size.height - (innerPadding * CGFloat(index))
        )
      )
      
      containerNode.zPosition = NodeLayer.background.rawValue + CGFloat(index)
      createBoardPoints(on: containerNode, shouldAddCenterLine: index < 2)
      
      addChild(containerNode)
    }
  }
  
  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  func node(at gridCoordinate: GameModel.GridCoordinate, named nodeName: String) -> SKNode? {
    let layerPadding = innerPadding * CGFloat(gridCoordinate.layer.rawValue)
    let halfLayerSide = (sideLength - layerPadding) / 2
    let halfLayerPadding = layerPadding / 2
    let halfSide = sideLength / 2
    
    let adjustedXCoord = halfLayerPadding + (CGFloat(gridCoordinate.x.rawValue) * halfLayerSide)
    let adjustedYCoord = halfLayerPadding + (CGFloat(gridCoordinate.y.rawValue) * halfLayerSide)
    
    let relativeGridPoint = CGPoint(x: adjustedXCoord - halfSide, y: adjustedYCoord - halfSide)
    
    let node = atPoint(relativeGridPoint)
    return node.name == nodeName ? node : nil
  }
  
  func gridCoordinate(for node: SKNode) -> GameModel.GridCoordinate? {
    guard let parentZPosition = node.parent?.zPosition else {
      return nil
    }
    
    let adjustedParentZPosition = parentZPosition - NodeLayer.background.rawValue
    
    guard let layer = GameModel.GridLayer(rawValue: Int(adjustedParentZPosition)) else {
      return nil
    }
    
    let xGridPosition: GameModel.GridPosition
    if node.position.x == 0 {
      xGridPosition = .mid
    } else {
      xGridPosition = node.position.x > 0 ? .max : .min
    }
    
    let yGridPosition: GameModel.GridPosition
    if node.position.y == 0 {
      yGridPosition = .mid
    } else {
      yGridPosition = node.position.y > 0 ? .max : .min
    }
    
    return GameModel.GridCoordinate(x: xGridPosition, y: yGridPosition, layer: layer)
  }
  
  private func createBoardPoints(on node: SKSpriteNode, shouldAddCenterLine: Bool) {
    let lineWidth: CGFloat = 3
    let centerLineLength: CGFloat = 50
    let halfBoardWidth = node.size.width / 2
    let halfBoardHeight = node.size.height / 2
    let boardPointSize = CGSize(width: 24, height: 24)
    
    let relativeBoardPositions = [
      CGPoint(x: -halfBoardWidth, y: halfBoardHeight),
      CGPoint(x: 0, y: halfBoardHeight),
      CGPoint(x: halfBoardWidth, y: halfBoardHeight),
      CGPoint(x: halfBoardWidth, y: 0),
      CGPoint(x: halfBoardWidth, y: -halfBoardHeight),
      CGPoint(x: 0, y: -halfBoardHeight),
      CGPoint(x: -halfBoardWidth, y: -halfBoardHeight),
      CGPoint(x: -halfBoardWidth, y: 0),
    ]
    
    for (index, position) in relativeBoardPositions.enumerated() {
      let boardPointNode = SKShapeNode(ellipseOf: boardPointSize)
      
      boardPointNode.zPosition = NodeLayer.point.rawValue
      boardPointNode.name = BoardNode.boardPointNodeName
      boardPointNode.lineWidth = lineWidth
      boardPointNode.position = position
      boardPointNode.fillColor = .background
      boardPointNode.strokeColor = .white
      
      node.addChild(boardPointNode)
      
      if shouldAddCenterLine && (position.x == 0 || position.y == 0) {
        let path = CGMutablePath()
        path.move(to: position)
        
        let nextPosition: CGPoint
        if position.x == 0 {
          let factor = position.y > 0 ? -centerLineLength : centerLineLength
          nextPosition = CGPoint(x: 0, y: position.y + factor)
        } else {
          let factor = position.x > 0 ? -centerLineLength : centerLineLength
          nextPosition = CGPoint(x: position.x + factor, y: 0)
        }
        path.addLine(to: nextPosition)
        
        let lineNode = SKShapeNode(path: path, centered: true)
        lineNode.position = CGPoint(
          x: (position.x + nextPosition.x) / 2,
          y: (position.y + nextPosition.y) / 2
        )
        
        lineNode.strokeColor = boardPointNode.strokeColor
        lineNode.zPosition = NodeLayer.line.rawValue
        lineNode.lineWidth = lineWidth
        
        node.addChild(lineNode)
      }
      
      let lineIndex = index < relativeBoardPositions.count - 1 ? index + 1 : 0
      let nextPosition = relativeBoardPositions[lineIndex]
      
      let path = CGMutablePath()
      path.move(to: position)
      path.addLine(to: nextPosition)
      
      let lineNode = SKShapeNode(path: path, centered: true)
      lineNode.position = CGPoint(
        x: (position.x + nextPosition.x) / 2,
        y: (position.y + nextPosition.y) / 2
      )
      
      lineNode.strokeColor = boardPointNode.strokeColor
      lineNode.zPosition = NodeLayer.line.rawValue
      lineNode.lineWidth = lineWidth
      
      node.addChild(lineNode)
    }
  }
}
8. ButtonNode.swift
import SpriteKit

class ButtonNode: TouchNode {
  private let backgroundNode: BackgroundNode
  private let labelNode: SKLabelNode
  
  init(_ text: String, size: CGSize, actionBlock: ActionBlock?) {
    backgroundNode = BackgroundNode(kind: .recessed, size: size)
    backgroundNode.position = CGPoint(
      x: size.width / 2,
      y: size.height / 2
    )
    
    let buttonFont = UIFont.systemFont(ofSize: 24, weight: .semibold)
    
    labelNode = SKLabelNode(fontNamed: buttonFont.fontName)
    labelNode.fontSize = buttonFont.pointSize
    labelNode.fontColor = .white
    labelNode.text = text
    labelNode.position = CGPoint(
      x: size.width / 2,
      y: size.height / 2 - labelNode.frame.height / 2
    )
    
    let shadowNode = SKLabelNode(fontNamed: buttonFont.fontName)
    shadowNode.fontSize = buttonFont.pointSize
    shadowNode.fontColor = .black
    shadowNode.text = text
    shadowNode.alpha = 0.5
    shadowNode.position = CGPoint(
      x: labelNode.position.x + 2,
      y: labelNode.position.y - 2
    )
    
    super.init()
    
    addChild(backgroundNode)
    addChild(shadowNode)
    addChild(labelNode)
    
    self.actionBlock = actionBlock
  }
  
  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    super.touchesBegan(touches, with: event)
    
    guard isEnabled else {
      return
    }
    
    labelNode.run(SKAction.fadeAlpha(to: 0.8, duration: 0.2))
  }
  
  override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    super.touchesEnded(touches, with: event)
    
    guard isEnabled else {
      return
    }
    
    labelNode.run(SKAction.fadeAlpha(to: 1, duration: 0.2))
  }
}
9. InformationNode.swift
import SpriteKit

final class InformationNode: TouchNode {
  private let backgroundNode: BackgroundNode
  private let labelNode: SKLabelNode
  
  var text: String? {
    get {
      return labelNode.text
    }
    set {
      labelNode.text = newValue
    }
  }
  
  init(_ text: String, size: CGSize, actionBlock: ActionBlock? = nil) {
    backgroundNode = BackgroundNode(kind: .pill, size: size)
    backgroundNode.position = CGPoint(
      x: size.width / 2,
      y: size.height / 2
    )
    
    let font = UIFont.systemFont(ofSize: 18, weight: .semibold)
    
    labelNode = SKLabelNode(fontNamed: font.fontName)
    labelNode.fontSize = font.pointSize
    labelNode.fontColor = .black
    labelNode.text = text
    labelNode.position = CGPoint(
      x: size.width / 2,
      y: size.height / 2 - labelNode.frame.height / 2 + 2
    )
    
    super.init()
    
    addChild(backgroundNode)
    addChild(labelNode)
    
    self.actionBlock = actionBlock
  }
  
  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}
10. TokenNode.swift
import SpriteKit

final class TokenNode: SKSpriteNode {
  static let tokenNodeName = "token"
  
  private let rotateActionKey = "rotate"
  
  var isIndicated: Bool = false {
    didSet {
      if isIndicated {
        run(SKAction.repeatForever(SKAction.rotate(byAngle: 1, duration: 0.5)), withKey: rotateActionKey)
      } else {
        removeAction(forKey: rotateActionKey)
        run(SKAction.rotate(toAngle: 0, duration: 0.15))
      }
    }
  }
  
  let type: GameModel.Player
  
  init(type: GameModel.Player) {
    self.type = type
    
    let textureName = "\(type.rawValue)-token"
    let texture = SKTexture(imageNamed: textureName)
    
    super.init(
      texture: texture,
      color: .clear,
      size: texture.size()
    )
    
    name = TokenNode.tokenNodeName
  }
  
  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  func remove() {
    run(SKAction.sequence([SKAction.scale(to: 0, duration: 0.15), SKAction.removeFromParent()]))
  }
}
11. TouchNode.swift
import SpriteKit

class TouchNode: SKNode {
  typealias ActionBlock = (() -> Void)
  
  var actionBlock: ActionBlock?
  
  var isEnabled: Bool = true {
    didSet {
      alpha = isEnabled ? 1 : 0.5
    }
  }
  
  override var isUserInteractionEnabled: Bool {
    get {
      return true
    }
    set {
      // intentionally blank
    }
  }
  
  override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    if let block = actionBlock, isEnabled {
      block()
    }
  }
}
12. GameScene.swift
import SpriteKit

final class GameScene: SKScene {
  // MARK: - Enums
  
  private enum NodeLayer: CGFloat {
    case background = 100
    case board = 101
    case token = 102
    case ui = 1000
  }
  
  // MARK: - Properties
  
  private var model: GameModel
  
  private var boardNode: BoardNode!
  private var messageNode: InformationNode!
  private var selectedTokenNode: TokenNode?
  
  private var highlightedTokens = [SKNode]()
  private var removableNodes = [TokenNode]()
  
  private var isSendingTurn = false
  
  private let successGenerator = UINotificationFeedbackGenerator()
  private let feedbackGenerator = UIImpactFeedbackGenerator(style: .light)
  
  // MARK: Computed
  
  private var viewWidth: CGFloat {
    return view?.frame.size.width ?? 0
  }
  
  private var viewHeight: CGFloat {
    return view?.frame.size.height ?? 0
  }
  
  // MARK: - Init
  
  init(model: GameModel) {
    self.model = model
    
    super.init(size: .zero)
    
    scaleMode = .resizeFill
  }
  
  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  override func didMove(to view: SKView) {
    super.didMove(to: view)
    
    successGenerator.prepare()
    feedbackGenerator.prepare()
    
    setUpScene(in: view)
  }
  
  override func didChangeSize(_ oldSize: CGSize) {
    removeAllChildren()
    setUpScene(in: view)
  }
  
  // MARK: - Setup
  
  private func setUpScene(in view: SKView?) {
    guard viewWidth > 0 else {
      return
    }
    
    backgroundColor = .background
    
    var runningYOffset: CGFloat = 0
    
    let sceneMargin: CGFloat = 40
    let safeAreaTopInset = view?.window?.safeAreaInsets.top ?? 0
    let safeAreaBottomInset = view?.window?.safeAreaInsets.bottom ?? 0
    
    let padding: CGFloat = 24
    let boardSideLength = min(viewWidth, viewHeight) - (padding * 2)
    boardNode = BoardNode(sideLength: boardSideLength)
    boardNode.zPosition = NodeLayer.board.rawValue
    runningYOffset += safeAreaBottomInset + sceneMargin + (boardSideLength / 2)
    boardNode.position = CGPoint(
      x: viewWidth / 2,
      y: runningYOffset
    )
    
    addChild(boardNode)
    
    let groundNode = SKSpriteNode(imageNamed: "ground")
    let aspectRatio = groundNode.size.width / groundNode.size.height
    let adjustedGroundWidth = view?.bounds.width ?? 0
    groundNode.size = CGSize(
      width: adjustedGroundWidth,
      height: adjustedGroundWidth / aspectRatio
    )
    groundNode.zPosition = NodeLayer.background.rawValue
    runningYOffset += sceneMargin + (boardSideLength / 2) + (groundNode.size.height / 2)
    groundNode.position = CGPoint(
      x: viewWidth / 2,
      y: runningYOffset
    )
    addChild(groundNode)
    
    messageNode = InformationNode(model.messageToDisplay, size: CGSize(width: viewWidth - (sceneMargin * 2), height: 40))
    messageNode.zPosition = NodeLayer.ui.rawValue
    messageNode.position = CGPoint(
      x: sceneMargin,
      y: runningYOffset - (sceneMargin * 1.25)
    )
    
    addChild(messageNode)
    
    let skySize = CGSize(width: viewWidth, height: viewHeight - groundNode.position.y)
    let skyNode = SKSpriteNode(color: .sky, size: skySize)
    skyNode.zPosition = NodeLayer.background.rawValue - 1
    runningYOffset -= skyNode.size.height / 2
    skyNode.position = CGPoint(
      x: viewWidth / 2,
      y: viewHeight - (skySize.height / 2)
    )
    addChild(skyNode)
    
    let buttonSize = CGSize(width: 125, height: 50)
    let menuButton = ButtonNode("Menu", size: buttonSize) {
      self.returnToMenu()
    }
    menuButton.position = CGPoint(
      x: (viewWidth - buttonSize.width) / 2,
      y: viewHeight - safeAreaTopInset - (sceneMargin * 2)
    )
    menuButton.zPosition = NodeLayer.ui.rawValue
    addChild(menuButton)
    
    loadTokens()
  }
  
  // MARK: - Touches
  
  override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    touches.forEach { touch in
      handleTouch(touch)
    }
  }
  
  private func handleTouch(_ touch: UITouch) {
    guard !isSendingTurn && GameCenterHelper.helper.canTakeTurnForCurrentMatch else {
      return
    }
    
    guard model.winner == nil else {
      return
    }
    
    let location = touch.location(in: self)
    
    if model.isCapturingPiece {
      handleRemoval(at: location)
      return
    }
    
    switch model.state {
    case .placement:
      handlePlacement(at: location)
      
    case .movement:
      handleMovement(at: location)
    }
  }
  
  // MARK: - Spawning
  
  private func loadTokens() {
    for token in model.tokens {
      guard let boardPointNode = boardNode.node(at: token.coord, named: BoardNode.boardPointNodeName) else {
        return
      }
      
      spawnToken(at: boardPointNode.position, for: token.player)
    }
  }
  
  private func spawnToken(at point: CGPoint, for player: GameModel.Player) {
    let tokenNode = TokenNode(type: player)
    
    tokenNode.zPosition = NodeLayer.token.rawValue
    tokenNode.position = point
    
    boardNode.addChild(tokenNode)
  }
  
  // MARK: - Helpers
  
  private func returnToMenu() {
    view?.presentScene(MenuScene(), transition: SKTransition.push(with: .down, duration: 0.3))
  }
  
  private func handlePlacement(at location: CGPoint) {
    let node = atPoint(location)
    
    guard node.name == BoardNode.boardPointNodeName else {
      return
    }
    
    guard let coord = boardNode.gridCoordinate(for: node) else {
      return
    }
    
    spawnToken(at: node.position, for: model.currentPlayer)
    model.placeToken(at: coord)
    
    processGameUpdate()
  }
  
  private func handleMovement(at location: CGPoint) {
    let node = atPoint(location)
    
    if let selected = selectedTokenNode {
      if highlightedTokens.contains(node) {
        let selectedSceneLocation = convert(selected.position, from: boardNode)
        
        guard let fromCoord = gridCoordinate(at: selectedSceneLocation), let toCoord = boardNode.gridCoordinate(for: node) else {
          return
        }
        
        model.move(from: fromCoord, to: toCoord)
        processGameUpdate()
        
        selected.run(SKAction.move(to: node.position, duration: 0.175))
      }
      
      deselectCurrentToken()
    } else {
      guard let token = node as? TokenNode, token.type == model.currentPlayer else {
        return
      }
      
      selectedTokenNode = token
      
      if model.tokenCount(for: model.currentPlayer) == 3 {
        highlightTokens(at: model.emptyCoordinates)
        return
      }
      
      guard let coord = gridCoordinate(at: location) else {
        return
      }
      
      highlightTokens(at: model.neighbors(at: coord))
    }
  }
  
  private func handleRemoval(at location: CGPoint) {
    let node = atPoint(location)
    
    guard let tokenNode = node as? TokenNode, tokenNode.type == model.currentOpponent else {
      return
    }
    
    guard let coord = gridCoordinate(at: location) else {
      return
    }
    
    guard model.removeToken(at: coord) else {
      return
    }
    
    tokenNode.remove()
    removableNodes.forEach { node in
      node.isIndicated = false
    }
    
    processGameUpdate()
  }
  
  private func gridCoordinate(at location: CGPoint) -> GameModel.GridCoordinate? {
    guard let boardPointNode = nodes(at: location).first(where: { $0.name == BoardNode.boardPointNodeName }) else {
      return nil
    }
    
    return boardNode.gridCoordinate(for: boardPointNode)
  }
  
  private func highlightTokens(at coords: [GameModel.GridCoordinate]) {
    let tokensFromCoords = coords.compactMap { coord in
      return self.boardNode.node(at: coord, named: BoardNode.boardPointNodeName)
    }
    
    highlightedTokens = tokensFromCoords
    
    for neighborNode in highlightedTokens {
      neighborNode.run(SKAction.scale(to: 1.25, duration: 0.15))
    }
  }
  
  private func deselectCurrentToken() {
    selectedTokenNode = nil
    
    guard !highlightedTokens.isEmpty else {
      return
    }
    
    highlightedTokens.forEach { node in
      node.run(SKAction.scale(to: 1, duration: 0.15))
    }
    
    highlightedTokens.removeAll()
  }
  
  private func processGameUpdate() {
    messageNode.text = model.messageToDisplay
    
    if model.isCapturingPiece {
      successGenerator.notificationOccurred(.success)
      successGenerator.prepare()
      
      let tokens = model.removableTokens(for: model.currentOpponent)
      
      if tokens.isEmpty {
        model.advance()
        processGameUpdate()
        return
      }
      
      let nodes = tokens.compactMap { token in
        boardNode.node(at: token.coord, named: TokenNode.tokenNodeName) as? TokenNode
      }
      
      removableNodes = nodes
      
      nodes.forEach { node in
        node.isIndicated = true
      }
    } else {
      feedbackGenerator.impactOccurred()
      feedbackGenerator.prepare()
      
      isSendingTurn = true

      if model.winner != nil {
        GameCenterHelper.helper.win { error in
          defer {
            self.isSendingTurn = false
          }
          
          if let e = error {
            print("Error winning match: \(e.localizedDescription)")
            return
          }
          
          self.returnToMenu()
        }
      } else {
        GameCenterHelper.helper.endTurn(model) { error in
          defer {
            self.isSendingTurn = false
          }

          if let e = error {
            print("Error ending turn: \(e.localizedDescription)")
            return
          }
          
          self.returnToMenu()
        }
      }
    }
  }
}
13. MenuScene.swift
import GameKit
import SpriteKit

final class MenuScene: SKScene {
  private let transition = SKTransition.push(with: .up, duration: 0.3)
  private let feedbackGenerator = UIImpactFeedbackGenerator(style: .light)
  
  private var viewWidth: CGFloat {
    return view?.frame.size.width ?? 0
  }
  
  private var viewHeight: CGFloat {
    return view?.frame.size.height ?? 0
  }
  
  private var localButton: ButtonNode!
  private var onlineButton: ButtonNode!
  
  // MARK: - Init
  
  override init() {
    super.init(size: .zero)
    
    scaleMode = .resizeFill
  }
  
  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  override func didMove(to view: SKView) {
    super.didMove(to: view)
    
    feedbackGenerator.prepare()
    GameCenterHelper.helper.currentMatch = nil
    
    NotificationCenter.default.addObserver(
      self,
      selector: #selector(authenticationChanged(_:)),
      name: .authenticationChanged,
      object: nil
    )
    
    NotificationCenter.default.addObserver(
      self,
      selector: #selector(presentGame(_:)),
      name: .presentGame,
      object: nil
    )
    
    setUpScene(in: view)
  }
  
  override func didChangeSize(_ oldSize: CGSize) {
    removeAllChildren()
    setUpScene(in: view)
  }
  
  private func setUpScene(in view: SKView?) {
    guard viewWidth > 0 else {
      return
    }
    
    backgroundColor = .sky
    
    var runningYOffset = viewHeight
    
    let sceneMargin: CGFloat = 40
    let buttonWidth: CGFloat = viewWidth - (sceneMargin * 2)
    let safeAreaTopInset = view?.window?.safeAreaInsets.top ?? 0
    let buttonSize = CGSize(width: buttonWidth, height: buttonWidth * 3 / 11)
    
    runningYOffset -= safeAreaTopInset + (sceneMargin * 3)
    
    let logoNode = SKSpriteNode(imageNamed: "title-logo")
    logoNode.position = CGPoint(
      x: viewWidth / 2,
      y: runningYOffset
    )
    addChild(logoNode)
    
    let groundNode = SKSpriteNode(imageNamed: "ground")
    let aspectRatio = groundNode.size.width / groundNode.size.height
    let adjustedGroundWidth = view?.bounds.width ?? 0
    groundNode.size = CGSize(
        width: adjustedGroundWidth,
        height: adjustedGroundWidth / aspectRatio
    )
    groundNode.position = CGPoint(
      x: viewWidth / 2,
      y: (groundNode.size.height / 2) - (sceneMargin * 1.375)
    )
    addChild(groundNode)
    
    let sunNode = SKSpriteNode(imageNamed: "sun")
    sunNode.position = CGPoint(
      x: viewWidth - (sceneMargin * 1.3),
      y: viewHeight - safeAreaTopInset - (sceneMargin * 1.25)
    )
    addChild(sunNode)
    
    localButton = ButtonNode("Local Game", size: buttonSize) {
      self.view?.presentScene(GameScene(model: GameModel()), transition: self.transition)
    }
    
    runningYOffset -= sceneMargin + logoNode.size.height
    localButton.position = CGPoint(x: sceneMargin, y: runningYOffset)
    addChild(localButton)
    
    onlineButton = ButtonNode("Online Game", size: buttonSize) {
      GameCenterHelper.helper.presentMatchmaker()
    }
    onlineButton.isEnabled = GameCenterHelper.isAuthenticated
    runningYOffset -= sceneMargin + buttonSize.height
    onlineButton.position = CGPoint(x: sceneMargin, y: runningYOffset)
    addChild(onlineButton)
  }
  
  // MARK: - Notifications

  @objc private func authenticationChanged(_ notification: Notification) {
    onlineButton.isEnabled = notification.object as? Bool ?? false
  }
  
  @objc private func presentGame(_ notification: Notification) {
    // 1
    guard let match = notification.object as? GKTurnBasedMatch else {
      return
    }
    
    loadAndDisplay(match: match)
  }

  // MARK: - Helpers

  private func loadAndDisplay(match: GKTurnBasedMatch) {
    // 2
    match.loadMatchData { data, error in
      let model: GameModel
      
      if let data = data {
        do {
          // 3
          model = try JSONDecoder().decode(GameModel.self, from: data)
        } catch {
          model = GameModel()
        }
      } else {
        model = GameModel()
      }
      
      GameCenterHelper.helper.currentMatch = match
      // 4
      self.view?.presentScene(GameScene(model: model), transition: self.transition)
    }
  }
}
14. AppDelegate.swift
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
  var window: UIWindow?
  
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    window = UIWindow(frame: UIScreen.main.bounds)
    
    window?.rootViewController = GameViewController()
    window?.makeKeyAndVisible()
    
    return true
  }
}
15. GameViewController.swift
import UIKit
import SpriteKit

final class GameViewController: UIViewController {
  private var skView: SKView {
    return view as! SKView
  }
  
  override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
    return .portrait
  }
  
  override var shouldAutorotate: Bool {
    return false
  }
  
  override func loadView() {
    view = SKView()
  }
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    skView.presentScene(MenuScene())
    
    GameCenterHelper.helper.viewController = self
  }
}

后记

本篇主要讲述了iOS的Game Center:构建基于回合制的游戏,感兴趣的给个赞或者关注~~~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容