游戏中的SpriteKit Shop场景

ccr*_*mer 0 ios sprite-kit swift swift3

我知道如何在我的spriteKit游戏中实现一个商店,用户可以用他们在游戏中获得的硬币购买不同的玩家吗?那里有任何教程吗?

Flu*_*ity 6

这是一个多步项目,花了我大约500 loc(更多没有使用.SKS)这是github完成项目的链接:https://github.com/fluidityt/ShopScene

注意,我使用的是macOS SpriteKit项目,因为它在我的计算机上启动速度更快.只需更改mouseDown()touchesBegan()在iOS上运行即可.

首先编辑你的GameScene.sks看起来像这样:(保存一堆时间编码标签) enter image description here

确保您完全按照我们的需要命名一切来检测触摸:

"enterhop","getcoins","coinlabel","levellabel"

这是主要的"游戏玩法"场景,当您点击币币++时,您可以获得等级并可以四处移动.点击商店将进入商店.

这是我们的GameScene.swift与这个SKS匹配:


import SpriteKit

class GameScene: SKScene {

  let player = Player(costume: Costume.defaultCostume)

  lazy var enterNode:  SKLabelNode = { return (self.childNode(withName: "entershop")  as! SKLabelNode) }()
  lazy var coinNode:   SKLabelNode = { return (self.childNode(withName: "getcoins" )  as! SKLabelNode) }()
  lazy var coinLabel:  SKLabelNode = { return (self.childNode(withName: "coinlabel")  as! SKLabelNode) }()
  lazy var levelLabel: SKLabelNode = { return (self.childNode(withName: "levellabel") as! SKLabelNode) }()

  override func didMove(to view: SKView) {
    player.name = "player"
    if player.scene == nil { addChild(player) }
  }

  override func mouseDown(with event: NSEvent) {

    let location = event.location(in: self)

    if let name = atPoint(location).name {

      switch name {

      case "entershop": view!.presentScene(ShopScene(previousGameScene: self))

      case "getcoins":  player.getCoins(1)

      default: ()
      }
    }

    else {
      player.run(.move(to: location, duration: 1))
    }
  }

  override func update(_ currentTime: TimeInterval) {

    func levelUp(_ level: Int) {
      player.levelsCompleted = level
      levelLabel.text = "Level: \(player.levelsCompleted)"
    }

    switch player.coins {
      case 10: levelUp(2)
      case 20: levelUp(3)
      case 30: levelUp(4)
      default: ()
    }
  }
};
Run Code Online (Sandbox Code Playgroud)

在这里你可以看到我们还有一些其他的东西还没有引入:PlayerCostume

Player是spritenode子类(它兼作数据模型和UI元素).我们的播放器只是一个彩色方块,当您点击屏幕时它会被移动

玩家穿着Costume类型的东西,这只是一个跟踪数据的模型,如价格,名称和玩家要显示的纹理.

这是Costume.swift:


import SpriteKit

/// This is just a test method should be deleted when you have actual texture assets:
private func makeTestTexture() -> (SKTexture, SKTexture, SKTexture, SKTexture) {

  func texit(_ sprite: SKSpriteNode) -> SKTexture { return SKView().texture(from: sprite)! }
  let size = CGSize(width: 50, height: 50)

  return (
    texit(SKSpriteNode(color: .gray,  size: size)),
    texit(SKSpriteNode(color: .red,   size: size)),
    texit(SKSpriteNode(color: .blue,  size: size)),
    texit(SKSpriteNode(color: .green, size: size))
  )
}

/// The items that are for sale in our shop:
struct Costume {

  static var allCostumes: [Costume] = []

  let name:    String
  let texture: SKTexture
  let price:   Int

  init(name: String, texture: SKTexture, price: Int) { self.name = name; self.texture = texture; self.price = price
    // This init simply adds all costumes to a master list for easy sorting later on.
    Costume.allCostumes.append(self)
  }

  private static let (tex1, tex2, tex3, tex4) = makeTestTexture()  // Just a test needed to be deleted when you have actual assets.

  static let list = (
    // Hard-code any new costumes you create here (this is a "master list" of costumes)
    // (make sure all of your costumes have a unique name, or the program will not work properly)
    gray:  Costume(name: "Gray Shirt",  texture: tex1 /*SKTexture(imageNamed: "grayshirt")*/,  price:  0),
    red:   Costume(name: "Red Shirt",   texture: tex2 /*SKTexture(imageNamed: "redshirt")*/,   price: 5),
    blue:  Costume(name: "Blue Shirt",  texture: tex3 /*SKTexture(imageNamed: "blueshirt")*/,  price: 25),
    green: Costume(name: "Green Shirt", texture: tex4 /*SKTexture(imageNamed: "greenshirt")*/, price: 50)
  )

  static let defaultCostume = list.gray
};

func == (lhs: Costume, rhs: Costume) -> Bool {
  // The reason why you need unique names:
  if lhs.name == rhs.name { return true }
  else { return false }
}
Run Code Online (Sandbox Code Playgroud)

这个结构的设计是双重的.首先是作为服装对象的蓝图(它包含服装的名称,价格和纹理),然后它作为所有服装的存储库通过硬 - 编码静态主列表属性.

顶部的功能makeTestTextures()只是这个项目的一个例子.我这样做只是为了你可以复制和粘贴,而不必下载图像文件使用.

这是Player.swift,它可以穿着列表中的服装:


final class Player: SKSpriteNode {

  var coins = 0
  var costume: Costume
  var levelsCompleted = 0

  var ownedCostumes: [Costume] = [Costume.list.gray]      // FIXME: This should be a Set, but too lazy to do Hashable.

  init(costume: Costume) {
    self.costume = costume
    super.init(texture: costume.texture, color: .clear, size: costume.texture.size())
  }

  func getCoins(_ amount: Int) {
    guard let scene = self.scene as? GameScene else {     // This is very specific code just for this example.
      fatalError("only call this func after scene has been set up")
    }

    coins += amount
    scene.coinLabel.text = "Coins: \(coins)"
  }

  func loseCoins(_ amount: Int) {
    guard let scene = self.scene as? GameScene else {     // This is very specific code just for this example.
      fatalError("only call this func after scene has been set up")
    }

    coins -= amount
    scene.coinLabel.text = "Coins: \(coins)"
  }

  func hasCostume(_ costume: Costume) -> Bool {
    if ownedCostumes.contains(where: {$0.name == costume.name}) { return true }
    else { return false }
  }

  func getCostume(_ costume: Costume) {
    if hasCostume(costume) { fatalError("trying to get costume already owned") }
    else { ownedCostumes.append(costume) }
  }

  func wearCostume(_ costume: Costume) {
    guard hasCostume(costume) else { fatalError("trying to wear a costume you don't own") }
    self.costume = costume
    self.texture = costume.texture
  }

  required init?(coder aDecoder: NSCoder) { fatalError() }
};
Run Code Online (Sandbox Code Playgroud)

Player有很多功能,但它们都可以在代码的其他地方处理.我只是去做这个设计决定,但不觉得你需要用2行方法加载你的类.

现在我们已经了解了更多细节,因为我们设置了:

  • 基地场景
  • 服装清单
  • 玩家对象

我们真正需要的最后两件事是:1.用于跟踪库存的商店模型2.用于显示库存,UI元素以及处理是否可以购买物品的逻辑的商店场景

这是Shop.swift:


/// Our model class to be used inside of our ShopScene:
final class Shop {

  weak private(set) var scene: ShopScene!     // The scene in which this shop will be called from.

  var player: Player { return scene.player }

  var availableCostumes: [Costume] = [Costume.list.red, Costume.list.blue]   // (The green shirt wont become available until the player has cleared 2 levels).

  // var soldCostumes: [Costume] = [Costume.defaultCostume] // Implement something with this if you want to exclude previously bought items from the store.

  func canSellCostume(_ costume: Costume) -> Bool {
    if player.coins < costume.price                { return false }
    else if player.hasCostume(costume)             { return false }
    else if player.costume == costume              { return false }
    else                                           { return true  }
  }

  /// Only call this after checking canBuyCostume(), or you likely will have errors:
  func sellCostume(_ costume: Costume) {
    player.loseCoins(costume.price)
    player.getCostume(costume)
    player.wearCostume(costume)
  }

  func newCostumeBecomesAvailable(_ costume: Costume) {
    if availableCostumes.contains(where: {$0.name == costume.name}) /*|| soldCostumes.contains(costume)*/ {
      fatalError("trying to add a costume that is already available (or sold!)")
    }
    else { availableCostumes.append(costume) }
  }

  init(shopScene: ShopScene) {
    self.scene = shopScene
  }

  deinit { print("shop: if you don't see this message when exiting shop then you have a retain cycle") }
};
Run Code Online (Sandbox Code Playgroud)

我的想法是让第四套服装只在某个级别上可用,但我没有时间来实现这个功能,但大多数支持方法都在那里(你只需要实现逻辑).

另外,Shop几乎可以只是一个结构,但我觉得它现在作为一个类更灵活.

现在,在进入我们最大的文件ShopScene之前,让我告诉您一些设计决策.

首先,我正在使用node.name触摸/点击.这让我可以SKNode快速轻松地使用.SKS和常规类型.通常,我喜欢子类化SKNodes,然后覆盖自己的touchesBegan方法来处理点击.你可以这样做.

Now, in ShopScene you have buttons for "buy", "exit" which I have used as just regular SKLabelNodes; but for the actual nodes that display the costume, I have created a subclass called CostumeNode.

I made CostumeNode so that way it could handle nodes for displaying the costume's name, price, and doing some animations. CostumeNode is just a visual element (unlike Player).

Here is CostumeNode.swift:


/// Just a UI representation, does not manipulate any models.
final class CostumeNode: SKSpriteNode {

  let costume:   Costume

  weak private(set) var player: Player!

  private(set) var
  backgroundNode = SKSpriteNode(),
  nameNode       = SKLabelNode(),
  priceNode      = SKLabelNode()

  private func label(text: String, size: CGSize) -> SKLabelNode {
    let label = SKLabelNode(text: text)
    label.fontName = "Chalkduster"
    // FIXME: deform label to fit size and offset
    return label
  }

  init(costume: Costume, player: Player) {

     func setupNodes(with size: CGSize) {

      let circle = SKShapeNode(circleOfRadius: size.width)
      circle.fillColor = .yellow
      let bkg = SKSpriteNode(texture: SKView().texture(from: circle))
      bkg.zPosition -= 1

      let name = label(text: "\(costume.name)", size: size)
      name.position.y = frame.maxY + name.frame.size.height

      let price = label(text: "\(costume.price)", size: size)
      price.position.y = frame.minY - price.frame.size.height

      addChildrenBehind([bkg, name, price])
      (backgroundNode, nameNode, priceNode) = (bkg, name, price)
    }

    self.player = player
    self.costume = costume

    let size = costume.texture.size()
    super.init(texture: costume.texture, color: .clear, size: size)

    name = costume.name   // Name is needed for sorting and detecting touches.

    setupNodes(with: size)
    becomesUnselected()
  }

  private func setPriceText() { // Updates the color and text of price labels

    func playerCanAfford() {
      priceNode.text = "\(costume.price)"
      priceNode.fontColor = .white
    }

    func playerCantAfford() {
      priceNode.text = "\(costume.price)"
      priceNode.fontColor = .red
    }

    func playerOwns() {
      priceNode.text = ""
      priceNode.fontColor = .white
    }

    if player.hasCostume(self.costume)         { playerOwns()       }
    else if player.coins < self.costume.price  { playerCantAfford() }
    else if player.coins >= self.costume.price { playerCanAfford()  }
    else                                       { fatalError()       }
  }

  func becomesSelected() {    // For animation / sound purposes (could also just be handled by the ShopScene).
    backgroundNode.run(.fadeAlpha(to: 0.75, duration: 0.25))
    setPriceText()
    // insert sound if desired.
  }

  func becomesUnselected() {
    backgroundNode.run(.fadeAlpha(to: 0, duration: 0.10))
    setPriceText()
    // insert sound if desired.
  }

  required init?(coder aDecoder: NSCoder) { fatalError() }

  deinit { print("costumenode: if you don't see this then you have a retain cycle") }
};
Run Code Online (Sandbox Code Playgroud)

Finally we have ShopScene, which is the behemoth file. It handles the data and logic for not only showing UI elements, but also for updating the Shop and Player models.


import SpriteKit

// Helpers:
extension SKNode {
  func addChildren(_ nodes: [SKNode]) { for node in nodes { addChild(node) } }

  func addChildrenBehind(_ nodes: [SKNode]) { for node in nodes {
    node.zPosition -= 2
    addChild(node)
    }
  }
}
 func halfHeight(_ node: SKNode) -> CGFloat { return node.frame.size.height/2 }
 func halfWidth (_ node: SKNode) -> CGFloat { return node.frame.size.width/2 }


// MARK: -
/// The scene in which we can interact with our shop and player:
class ShopScene: SKScene {

  lazy private(set) var shop: Shop = { return Shop(shopScene: self) }()

  let previousGameScene: GameScene

  var player: Player { return self.previousGameScene.player }    // The player is actually still in the other scene, not this one.

  private var costumeNodes = [CostumeNode]()                   // All costume textures will be node-ified here.

  lazy private(set) var selectedNode: CostumeNode? = {
    return self.costumeNodes.first!
  }()

  private let
  buyNode  = SKLabelNode(fontNamed: "Chalkduster"),
  coinNode = SKLabelNode(fontNamed: "Chalkduster"),
  exitNode = SKLabelNode(fontNamed: "Chalkduster")

  // MARK: - Node setup:
  private func setUpNodes() {

    buyNode.text = "Buy Costume"
    buyNode.name = "buynode"
    buyNode.position.y = frame.minY + halfHeight(buyNode)

    coinNode.text = "Coins: \(player.coins)"
    coinNode.name = "coinnode"
    coinNode.position = CGPoint(x: frame.minX + halfWidth(coinNode), y: frame.minY + halfHeight(coinNode))

    exitNode.text = "Leave Shop"
    exitNode.name = "exitnode"
    exitNode.position.y = frame.maxY - buyNode.frame.height

    setupCostumeNodes: do {
      guard Costume.allCostumes.count > 1 else {
        fatalError("must have at least two costumes (for while loop)")
      }
      for costume in Costume.allCostumes {
        costumeNodes.append(CostumeNode(costume: costume, player: player))
      }
      guard costumeNodes.count == Costume.allCostumes.count else {
        fatalError("duplicate nodes found, or nodes are missing")
      }

      let offset = CGFloat(150)

      func findStartingPosition(offset: CGFloat, yPos: CGFloat) -> CGPoint {   // Find the correct position to have all costumes centered on screen.
        let
        count = CGFloat(costumeNodes.count),
        totalOffsets = (count - 1) * offset,
        textureWidth = Costume.list.gray.texture.size().width,                 // All textures must be same width for centering to work.
        totalWidth = (textureWidth * count) + totalOffsets

        let measurementNode = SKShapeNode(rectOf: CGSize(width: totalWidth, height: 0))

        return CGPoint(x: measurementNode.frame.minX + textureWidth/2, y: yPos)
      }

      costumeNodes.first!.position = findStartingPosition(offset: offset, yPos: self.frame.midY)

      var counter = 1
      let finalIndex = costumeNodes.count - 1
      // Place nodes from left to right:
      while counter <= finalIndex {
        let thisNode = costumeNodes[counter]
        let prevNode = costumeNodes[counter - 1]

        thisNode.position.x = prevNode.frame.maxX + halfWidth(thisNode) + offset
        counter += 1
      }
    }

    addChildren(costumeNodes)
    addChildren([buyNode, coinNode, exitNode])
  }

  // MARK: - Init:
  init(previousGameScene: GameScene) {
    self.previousGameScene = previousGameScene
    super.init(size: previousGameScene.size)
  }

  required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented")}

  deinit { print("shopscene: if you don't see this message when exiting shop then you have a retain cycle") }

  // MARK: - Game loop:
  override func didMove(to view: SKView) {
    anchorPoint = CGPoint(x: 0.5, y: 0.5)
    setUpNodes()

    select(costumeNodes.first!)                           // Default selection.
    for node in costumeNodes {
      if node.costume == player.costume { select(node) }
    }
  }

  // MARK: - Touch / Click handling:
  private func unselect(_ costumeNode: CostumeNode) {
    selectedNode = nil
    costumeNode.becomesUnselected()
  }

  private func select(_ costumeNode: CostumeNode) {
    unselect(selectedNode!)
    selectedNode = costumeNode
    costumeNode.becomesSelected()

    if player.hasCostume(costumeNode.costume) {      // Wear selected costume if owned.
      player.costume = costumeNode.costume
      buyNode.text = "Bought Costume"
      buyNode.alpha = 1
    }

    else if player.coins < costumeNode.costume.price { // Can't afford costume.
      buyNode.text = "Buy Costume"
      buyNode.alpha = 0.5
    }

    else {                                            // Player can buy costume.
      buyNode.text = "Buy Costume"
      buyNode.alpha = 1
      }
  }

  // I'm choosing to have the buttons activated by searching for name here. You can also
  // subclass a node and have them do actions on their own when clicked.
  override func mouseDown(with event: NSEvent) {

    guard let selectedNode = selectedNode else { fatalError() }
    let location    = event.location(in: self)
    let clickedNode = atPoint(location)

    switch clickedNode {

      // Clicked empty space:
      case is ShopScene:
        return

      // Clicked Buy / Leave:
      case is SKLabelNode:
        if clickedNode.name == "exitnode" { view!.presentScene(previousGameScene) }

        if clickedNode.name == "buynode"  {
          // guard let shop = shop else { fatalError("where did the shop go?") }
          if shop.canSellCostume(selectedNode.costume) {
            shop.sellCostume(selectedNode.costume)
            coinNode.text = "Coins: \(player.coins)"
            buyNode.text = "Bought"
          }
        }

      // Clicked a costume:
      case let clickedCostume as CostumeNode:
        for node in costumeNodes {
          if node.name == clickedCostume.name {
            select(clickedCostume)
          }
        }

      default: ()
      }
  }
};
Run Code Online (Sandbox Code Playgroud)

There's a lot to digest here, but pretty much everything happens in mouseDown() (or touchesBegan for iOS). I had no need for update() or other every-frame methods.

So how did I make this? The first step was planning, and I knew there were several design decisions to make (which may not have been the best ones).

I knew that I needed a certain set of data for my player and shop inventory, and that those two things would also need UI elements.

I chose to combine the data + UI for Player by making it a Sprite subclass.

For the shop, I knew that the data and UI elements would be pretty intense, so I separated them (Shop.swift handling the inventory, Costume.swift being a blueprint, and CostumeNode.swift handling most of the UI)

Then, I needed to link the data to the UI elements, which meant that I needed a lot of logic, so I decided to make a whole new scene to handle logic pertaining just to entering and interacting with the shop (it handles some graphics stuff too).

This all works together like this:

  • Player has a costume and coins
  • GameScene is where you collect new coins (and levels)
  • ShopScene handles most of the logic for determining which UI elements to display, while CostumeNode has the functions for animating the UI.
  • ShopScene also provides the logic for updating the Player's texture (costume) and coins through Shop.
  • Shop just manages the player inventory, and has the data with which to populate more CostumeNodes
  • When you are done with the shop, your GameScene instance is immediately resumed where you left off prior to entering

So the question you may have is, "how do I use this in my game??"

Well, you aren't going to be able to just copy and paste it. A lot of refactoring will likely be needed. The takeaway here is to learn the basic system of the different types of data, logic, and actions that you will need to create, present, and interact with a shop.

Here is the github again: https://github.com/fluidityt/ShopScene