SpriteKit游戏中的店铺场景
这是一个多步骤的项目,我花了大约500 LOC(更不用.SKS)这里是GitHub上完成的项目链接:https://github.com/fluidityt/ShopScene
请注意,我用的是MacOS的SpriteKit项目,因为它启动在我的电脑上快得多。只需将mouseDown()
更改为touchesBegan()
即可使其在iOS上运行。
首先编写GameScene.sks看起来像这样:(节省了一堆时间编码标签)
确保你的名字的一切正是因为我们需要这个来检测触摸:
“ “buycoins”,“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:()
}
}
};
在这里你可以看到,我们有一些其他的东西怎么回事尚未出台:Player
和Costume
播放器是一个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行方法加载你的类。
现在我们正在向更多的基本事实的东西,因为我们已经建立了我们:
- 基地现场
- 服装名单
- Player对象
最后两我们真正需要的东西是: 1.跟踪库存的商店模型 2.显示库存,UI元素和处理逻辑的商店场景是否或n加时赛,你可以买到的物品
这里是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中,您可以使用“buy”,“exit”按钮,我只用它作为常规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()
(或的touchesBegan iOS设备)。我不需要update()
或其他每帧方法。
那么我是怎么做到的呢?第一步是规划,我知道有几个设计决策要做(这可能不是最好的)。
我知道我需要一些用于播放器和商店库存的数据,而且这两件事情也需要UI元素。
我选择将Player +的数据+用户界面组合成一个Sprite子类。我知道数据和UI元素会非常强烈,所以我把它们分开了(Shop.swift处理库存,Costume.swift是一个蓝图,CostumeNode.swift处理大部分UI)然后,我需要将数据链接到UI元素,这意味着我需要很多逻辑,所以我决定创建一个全新的场景来处理仅仅进入和与店铺互动的逻辑(它处理一些图形的东西)。
这一切工作在一起,就像这样:
- 播放器有一个服装和硬币
- GameScene是你收集新币(和级别)
- ShopScene处理大部分的逻辑决定哪些UI元素来显示,而CostumeNode具有动画UI的功能。
- ShopScene还提供了通过Shop更新玩家纹理(服装)和硬币的逻辑。
- 店铺只能勉强播放器库存,并具有用于填充更多CostumeNodes
- 当你与店做,你GameScene实例立即恢复,你进入
之前离开该数据所以你可能会遇到的问题是,“我怎么在我的游戏中使用它?”
那么,你不能复制和粘贴它。很可能需要很多重构。这里的要点是要学习您需要创建,呈现和与商店互动所需的不同类型的数据,逻辑和操作的基本系统。
这里是github上再次: https://github.com/fluidityt/ShopScene
我正在给你一个答案,但它会带我一段时间。 – Fluidity
感谢m8,意味着很多 – ccronheimer
这是比我想象的要多得多的代码,我可以看到这将是一个很好的教程,供人们使用。这不是一个疯狂的工作量,但有很多不同的方法可以采取。我已经达到了300行代码,只有一页服饰可以放在你的角色上......还没有完成。 – Fluidity