游戏中的 SpriteKit 商店场景

Posted

技术标签:

【中文标题】游戏中的 SpriteKit 商店场景【英文标题】:SpriteKit Shop Scene in game 【发布时间】:2017-06-04 19:05:56 【问题描述】:

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

【问题讨论】:

我正在为您解答,但需要一些时间。 感谢 m8,意义重大 这比我想象的要多得多,我可以看到这将是一个很好的教程。这不是一项疯狂的工作,但这里有许多不同的方法可供选择。我已经写了多达 300 行代码,只需要一页可以穿在角色身上的服装……但还没有完成。 基本上你有你的播放器来记录金钱和清除的等级等,你的商店是一个模型并且没有 UI 元素(它处理玩家和服装之间的逻辑以及用户界面),ShopScene呈现所有按钮和精灵等,然后是你的基础 GameScene,它会在你完成后转换到商店场景并返回。 除了stack以外我可以通过任何方式联系你,我可以给你看我的代码 【参考方案1】:

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

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

首先将您的 GameScene.sks 编辑为如下所示:(节省大量时间编码标签)

确保您准确命名所有内容,因为我们需要它来检测触摸:

“entershop”、“getcoins”、“coinlabel”、“levellabel”

这是主要的“游戏”场景,当您点击硬币++ 时,您会获得关卡并可以四处移动。点击店铺进入店铺。

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


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: ()
    
  
;

在这里您可以看到我们还有一些尚未介绍的其他内容: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 


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

顶部的函数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() 
;

播放器有很多功能,但它们都可以在代码的其他地方处理。我只是做出了这个设计决定,但不觉得你需要用 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") 
;

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

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

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

首先,我使用node.name 来处理触摸/点击。这让我可以快速轻松地使用 .SKS 和常规 SKNode 类型。通常,我喜欢继承 SKNodes,然后重写它们自己的 touchesBegan 方法来处理点击。无论哪种方式都可以。

现在,在 ShopScene 中,您有“购买”、“退出”按钮,我将其用作常规 SKLabelNodes;但是对于显示服装的实际节点,我创建了一个名为 CostumeNode 的子类。

我制作了 CostumeNode,以便它可以处理用于显示服装名称、价格和做一些动画的节点。 CostumeNode 只是一个视觉元素(与 Player 不同)。

这里是 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") 
;

最后我们有了 ShopScene,它是一个庞大的文件。它处理数据和逻辑,不仅用于显示 UI 元素,还用于更新 Shop 和 Player 模型。


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: ()
      
  
;

这里有很多东西要消化,但几乎所有事情都发生在mouseDown()(或 iOS 的 touchesBegan)中。我不需要update() 或其他每帧方法。

那么我是怎么做到的呢?第一步是计划,我知道有几个设计决策需要做出(可能不是最好的)。

我知道我的玩家和商店库存需要一组特定的数据,而且这两件事也需要 UI 元素。

我选择将 Player 的数据 + UI 组合为 Sprite 子类。

对于商店,我知道数据和 UI 元素会非常密集,所以我将它们分开(Shop.swift 处理库存,Costume.swift 是蓝图,CostumeNode.swift 处理大部分 UI)

然后,我需要将数据链接到 UI 元素,这意味着我需要大量的逻辑,所以我决定制作一个全新的场景来处理与进入商店和与商店交互相关的逻辑(它处理一些图形的东西)。

这一切都像这样一起工作:

玩家有服装和硬币 GameScene 是您收集新硬币(和关卡)的地方 ShopScene 处理用于确定要显示哪些 UI 元素的大部分逻辑,而 CostumeNode 具有用于为 UI 设置动画的功能。 ShopScene 还提供了通过 Shop 更新玩家的纹理(服装)和硬币的逻辑。 商店只管理玩家库存,并拥有用于填充更多服装节点的数据 当您完成商店后,您的 GameScene 实例会立即从您进入之前中断的地方恢复

所以你可能有一个问题,“我如何在我的游戏中使用它??”

嗯,你不能只是复制和粘贴它。可能需要进行大量重构。这里的要点是学习创建、呈现和与商店交互所需的不同类型数据、逻辑和操作的基本系统。

这里又是 github: https://github.com/fluidityt/ShopScene

【讨论】:

干得好!你真的超越了这个。

以上是关于游戏中的 SpriteKit 商店场景的主要内容,如果未能解决你的问题,请参考以下文章

Sprite Kit:如何在运行场景之上添加场景

SpriteKit Sprite 无法正确缩放

在 Sprite Kit 场景中创建带有重复图像的栏 - iOS

Sprite Kit:为所有场景只创建一次节点

iOS7 Sprite Kit 我可以存档整个场景并将其用作保存游戏吗?

在 Sprite Kit 中组合多个 SKScene