游戏中的 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: ()
;
在这里您可以看到我们还有一些尚未介绍的其他内容:Player
和 Costume
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 场景中创建带有重复图像的栏 - iOS