วิธีสร้างเกม SpriteKit ใน Swift 3 (ตอนที่ 3)

เผยแพร่แล้ว: 2022-03-10
สรุปอย่างย่อ ↬ คุณเคยสงสัยหรือไม่ว่าการสร้างเกม SpriteKit ต้องใช้อะไรบ้าง? ปุ่มดูเหมือนเป็นงานที่ใหญ่กว่าที่ควรจะเป็นหรือไม่? เคยสงสัยไหมว่าจะคงการตั้งค่าในเกมไว้ได้อย่างไร? การสร้างเกมไม่เคยง่ายอย่างนี้มาก่อนบน iOS นับตั้งแต่เปิดตัว SpriteKit ในตอนที่สามของซีรีส์สามตอนนี้ เราจะจบเกม RainCat และแนะนำ SpriteKit ให้สมบูรณ์ หากคุณพลาดบทเรียนก่อนหน้านี้ คุณสามารถติดตามได้โดยรับรหัสบน GitHub โปรดจำไว้ว่าบทช่วยสอนนี้ต้องใช้ Xcode 8 และ Swift 3

คุณเคยสงสัยหรือไม่ว่าการสร้างเกม SpriteKit ต้องใช้อะไรบ้าง? ปุ่มดูเหมือนเป็นงานที่ใหญ่กว่าที่ควรจะเป็นหรือไม่? เคยสงสัยไหมว่าจะคงการตั้งค่าในเกมไว้ได้อย่างไร? การสร้างเกมไม่เคยง่ายอย่างนี้มาก่อนบน iOS นับตั้งแต่เปิดตัว SpriteKit ในตอนที่สามของซีรีส์สามตอนนี้ เราจะจบเกม RainCat และแนะนำ SpriteKit ให้สมบูรณ์

หากคุณพลาดบทเรียนก่อนหน้านี้ คุณสามารถติดตามได้โดยรับรหัสบน GitHub โปรดจำไว้ว่าบทช่วยสอนนี้ต้องใช้ Xcode 8 และ Swift 3

อ่านเพิ่มเติม เกี่ยวกับ SmashingMag: Link

  • Gamification และ UX: ตำแหน่งที่ผู้ใช้ชนะหรือแพ้
  • การออกแบบ UX ที่สนุกสนาน: การสร้างเกมที่ดีขึ้น
  • การผสมผสานการออกแบบ UX และจิตวิทยาเพื่อเปลี่ยนพฤติกรรมผู้ใช้
Raincat บทที่ 3
RainCat บทที่ 3
เพิ่มเติมหลังกระโดด! อ่านต่อด้านล่าง↓

นี่คือบทเรียนที่สามในการเดินทาง RainCat ของเรา ในบทเรียนที่แล้ว เรามีวันที่ยาวนานในการใช้แอนิเมชั่นง่ายๆ พฤติกรรมแมว เอฟเฟกต์เสียงแบบรวดเร็ว และเพลงประกอบ

วันนี้เราจะเน้นในเรื่องต่อไปนี้:

  • heads-up display (HUD) สำหรับการให้คะแนน;
  • เมนูหลัก — มีปุ่ม;
  • ตัวเลือกสำหรับปิดเสียง
  • ตัวเลือกการออกจากเกม

ทรัพย์สินมากยิ่งขึ้น

เนื้อหาสำหรับบทเรียนสุดท้ายมีอยู่ใน GitHub ลากรูปภาพไปที่ Assets.xcassets อีกครั้ง เช่นเดียวกับที่เราทำในบทเรียนก่อนหน้านี้

หัวขึ้น!

เราต้องมีวิธีรักษาสกอร์ ในการทำเช่นนี้ เราสามารถสร้าง heads-up display (HUD) สิ่งนี้จะค่อนข้างง่าย มันจะเป็น SKNode ที่มีคะแนนและปุ่มเพื่อออกจากเกม สำหรับตอนนี้เราจะเน้นแต่สกอร์ แบบอักษรที่เราจะใช้คือ Pixel Digivolve ซึ่งคุณสามารถหาได้จาก Dafont.com เช่นเดียวกับการใช้ภาพหรือเสียงที่ไม่ใช่ของคุณ โปรดอ่านใบอนุญาตของฟอนต์ก่อนใช้งาน อันนี้ระบุว่าฟรีสำหรับใช้ส่วนตัว แต่ถ้าคุณชอบฟอนต์จริงๆ คุณสามารถบริจาคให้ผู้เขียนได้จากเพจ คุณไม่สามารถทำทุกอย่างได้ด้วยตัวเอง ดังนั้นการตอบแทนผู้ที่ช่วยเหลือคุณตลอดทางจึงเป็นเรื่องที่ดี

ต่อไป เราต้องเพิ่มแบบอักษรที่กำหนดเองให้กับโครงการ ขั้นตอนนี้อาจยุ่งยากในครั้งแรก

ดาวน์โหลดและย้ายแบบอักษรไปยังโฟลเดอร์โครงการภายใต้โฟลเดอร์ "แบบอักษร" เราได้ทำสิ่งนี้ไปแล้วสองสามครั้งในบทเรียนที่แล้ว ดังนั้นเราจะดำเนินการตามขั้นตอนนี้ให้เร็วขึ้นเล็กน้อย เพิ่มกลุ่มชื่อ Fonts ให้กับโปรเจ็กต์ และเพิ่มไฟล์ Pixel digivolve.otf

ตอนนี้มาถึงส่วนที่ยุ่งยาก หากคุณพลาดส่วนนี้ คุณอาจใช้ฟอนต์ไม่ได้ เราจำเป็นต้องเพิ่มลงในไฟล์ Info.plist ของเรา ไฟล์นี้อยู่ในบานหน้าต่างด้านซ้ายของ Xcode คลิกแล้วคุณจะเห็นรายการคุณสมบัติ (หรือ plist ) คลิกขวาที่รายการแล้วคลิก "เพิ่มแถว"

เพิ่มแถว
เพิ่มแถวใน plist

เมื่อแถวใหม่ปรากฏขึ้น ให้ป้อนข้อมูลต่อไปนี้:

 Fonts provided by application

จากนั้น ภายใต้ Item 0 เราต้องเพิ่มชื่อฟอนต์ของเรา plist ควรมีลักษณะดังนี้:

Pixel digivolve.otf
เพิ่มแบบอักษรลงใน plist เรียบร้อยแล้ว!

ฟอนต์น่าจะพร้อมใช้งาน! เราควรทำการทดสอบอย่างรวดเร็วเพื่อให้แน่ใจว่าใช้งานได้ตามที่ตั้งใจไว้ ย้ายไปที่ GameScene.swift และใน sceneDidLoad ให้เพิ่มโค้ดต่อไปนี้ที่ด้านบนของฟังก์ชัน:

 let label = SKLabelNode(fontNamed: "PixelDigivolve") label.text = "Hello World!" label.position = CGPoint(x: size.width / 2, y: size.height / 2) label.zPosition = 1000 addChild(label)

มันทำงาน?

สวัสดีชาวโลก!
การทดสอบ SKLabelNode ของเรา ไม่นะ! ป้าย "Hello World" กลับมาแล้ว

ถ้ามันใช้งานได้ แสดงว่าคุณได้ทำทุกอย่างถูกต้องแล้ว ถ้าไม่เช่นนั้นมีบางอย่างผิดปกติ Code With Chris มีคู่มือการแก้ไขปัญหาเชิงลึกมากขึ้น แต่โปรดทราบว่าสำหรับ Swift เวอร์ชันเก่า ดังนั้นคุณจะต้องปรับแต่งเล็กน้อยเพื่อนำมาใช้กับ Swift 3

ตอนนี้เราสามารถโหลดฟอนต์แบบกำหนดเองได้แล้ว เรามาเริ่มที่ HUD กันเลย ลบป้ายกำกับ "สวัสดีชาวโลก" เนื่องจากเราใช้ป้ายกำกับนี้เพื่อให้แน่ใจว่าฟอนต์ของเราโหลดเท่านั้น HUD จะเป็น SKNode ซึ่งทำหน้าที่เป็นคอนเทนเนอร์สำหรับองค์ประกอบ HUD ของเรา นี่เป็นกระบวนการเดียวกับที่เราดำเนินการเมื่อสร้างโหนดพื้นหลังในบทที่หนึ่ง

สร้างไฟล์ HudNode.swift โดยใช้วิธีการปกติ และป้อนรหัสต่อไปนี้:

 import SpriteKit class HudNode : SKNode { private let scoreKey = "RAINCAT_HIGHSCORE" private let scoreNode = SKLabelNode(fontNamed: "PixelDigivolve") private(set) var score : Int = 0 private var highScore : Int = 0 private var showingHighScore = false /// Set up HUD here. public func setup(size: CGSize) { let defaults = UserDefaults.standard highScore = defaults.integer(forKey: scoreKey) scoreNode.text = "\(score)" scoreNode.fontSize = 70 scoreNode.position = CGPoint(x: size.width / 2, y: size.height - 100) scoreNode.zPosition = 1 addChild(scoreNode) } /// Add point. /// - Increments the score. /// - Saves to user defaults. /// - If a high score is achieved, then enlarge the scoreNode and update the color. public func addPoint() { score += 1 updateScoreboard() if score > highScore { let defaults = UserDefaults.standard defaults.set(score, forKey: scoreKey) if !showingHighScore { showingHighScore = true scoreNode.run(SKAction.scale(to: 1.5, duration: 0.25)) scoreNode.fontColor = SKColor(red:0.99, green:0.92, blue:0.55, alpha:1.0) } } } /// Reset points. /// - Sets score to zero. /// - Updates score label. /// - Resets color and size to default values. public func resetPoints() { score = 0 updateScoreboard() if showingHighScore { showingHighScore = false scoreNode.run(SKAction.scale(to: 1.0, duration: 0.25)) scoreNode.fontColor = SKColor.white } } /// Updates the score label to show the current score. private func updateScoreboard() { scoreNode.text = "\(score)" } }

ก่อนที่เราจะทำอย่างอื่น ให้เปิด Constants.swift และเพิ่มบรรทัดต่อไปนี้ที่ด้านล่างของไฟล์ - เราจะใช้มันเพื่อดึงและรักษาคะแนนสูง:

 let ScoreKey = "RAINCAT_HIGHSCORE"

ในโค้ดนี้ เรามีตัวแปร 5 ตัวที่เกี่ยวข้องกับกระดานคะแนน ตัวแปรแรกคือ SKLabelNode จริง ซึ่งเราใช้เพื่อแสดงป้ายกำกับ ต่อไปเป็นตัวแปรของเราที่จะเก็บคะแนนปัจจุบัน แล้วตัวแปรที่มีคะแนนดีที่สุด ตัวแปรสุดท้ายคือบูลีนที่บอกเราว่าขณะนี้เรากำลังนำเสนอคะแนนสูงหรือไม่ (เราใช้สิ่งนี้เพื่อกำหนดว่าเราจำเป็นต้องเรียกใช้ SKAction เพื่อเพิ่มขนาดของกระดานคะแนนและทำให้เป็นสีเหลืองของพื้น)

ฟังก์ชันแรก setup(size:) มีไว้เพื่อตั้งค่าทุกอย่าง เราตั้งค่า SKLabelNode แบบเดียวกับที่เราทำก่อนหน้านี้ คลาส SKNode ไม่มีคุณสมบัติของขนาดโดยค่าเริ่มต้น ดังนั้นเราจึงจำเป็นต้องสร้างวิธีกำหนดขนาดเพื่อวางตำแหน่งป้ายกำกับ scoreNode ของเรา เรากำลังดึงคะแนนสูงสุดในปัจจุบันจาก UserDefaults นี่เป็นวิธีที่รวดเร็วและง่ายดายในการบันทึกข้อมูลขนาดเล็ก แต่ไม่ปลอดภัย เนื่องจากเราไม่กังวลเกี่ยวกับความปลอดภัยสำหรับตัวอย่างนี้ UserDefaults นั้นใช้ได้อย่างสมบูรณ์

ใน addPoint() ของเรา เรากำลังเพิ่มตัวแปร score ปัจจุบันและตรวจสอบว่าผู้ใช้ได้คะแนนสูงหรือไม่ หากคะแนนสูง เราจะบันทึกคะแนนนั้นใน UserDefaults และตรวจสอบว่าขณะนี้เรากำลังแสดงคะแนนที่ดีที่สุดหรือไม่ หากผู้ใช้ทำคะแนนได้สูง เราก็สามารถทำให้ขนาดและสีของ scoreNode ได้

ใน resetPoints() เราตั้งค่าคะแนนปัจจุบันเป็น 0 จากนั้นเราต้องตรวจสอบว่าเราแสดงคะแนนสูงหรือไม่ และรีเซ็ตขนาดและสีเป็นค่าเริ่มต้นหากจำเป็น

สุดท้าย เรามีฟังก์ชั่นเล็กๆ ชื่อว่า updateScoreboard นี่คือฟังก์ชันภายในเพื่อตั้งค่าคะแนนเป็นข้อความของ scoreNode สิ่งนี้ถูกเรียกทั้งใน addPoint() และ resetPoints()

เชื่อมต่อ HUD

เราจำเป็นต้องทดสอบว่า HUD ของเราทำงานอย่างถูกต้องหรือไม่ ย้ายไปที่ GameScene.swift และเพิ่มบรรทัดต่อไปนี้ใต้ตัวแปร foodNode ที่ด้านบนของไฟล์:

 private let hudNode = HudNode()

เพิ่มสองบรรทัดต่อไปนี้ใน sceneDidLoad() ใกล้ด้านบน:

 hudNode.setup(size: size) addChild(hudNode)

จากนั้นใน spawnCat() ให้รีเซ็ตจุดในกรณีที่แมวหลุดออกจากหน้าจอ เพิ่มบรรทัดต่อไปนี้หลังจากเพิ่ม cat sprite ในฉาก:

 hudNode.resetPoints()

ต่อไป ใน handleCatCollision(contact:) เราจำเป็นต้องรีเซ็ตคะแนนอีกครั้งเมื่อแมวโดนฝน ในคำสั่ง switch ที่ส่วนท้ายของฟังก์ชัน — เมื่ออีกส่วนเป็น RainDropCategory — ให้เพิ่มบรรทัดต่อไปนี้:

 hudNode.resetPoints()

สุดท้าย เราต้องบอกกระดานคะแนนเมื่อผู้ใช้ได้รับคะแนน ที่ท้ายไฟล์ใน handleFoodHit(contact:) ให้ค้นหาบรรทัดต่อไปนี้:

 //TODO increment points print("fed cat")

และแทนที่ด้วยสิ่งนี้:

 hudNode.addPoint()

โว้ว!

ปลดล็อค HUD แล้ว!
ปลดล็อค HUD แล้ว!

คุณควรเห็นการทำงานของ HUD วิ่งไปเก็บอาหาร ครั้งแรกที่คุณเก็บอาหาร คุณควรเห็นคะแนนเปลี่ยนเป็นสีเหลืองและเพิ่มขึ้นตามขนาด เมื่อคุณเห็นสิ่งนี้เกิดขึ้น ปล่อยให้แมวโดนตี หากคะแนนถูกรีเซ็ต คุณจะรู้ว่าคุณมาถูกทางแล้ว!

คะแนนสูง!
คะแนนสูงสุดที่เคยมีมา (… ในขณะที่เขียน)!

ฉากต่อไป

ถูกต้อง เรากำลังเคลื่อนไปสู่อีกฉากหนึ่ง! อันที่จริงเมื่อเสร็จแล้ว นี่จะเป็นหน้าจอแรกของแอพของเรา ก่อนที่คุณจะดำเนินการใดๆ ให้เปิด Constants.swift และเพิ่มบรรทัดต่อไปนี้ที่ด้านล่างของไฟล์ เราจะใช้มันเพื่อดึงข้อมูลและคงคะแนนสูงสุดไว้:

 let ScoreKey = "RAINCAT_HIGHSCORE"

สร้างฉากใหม่ วางไว้ใต้โฟลเดอร์ "ฉาก" และเรียกมันว่า MenuScene.swift ป้อนรหัสต่อไปนี้ในไฟล์ MenuScene.swift :

 import SpriteKit class MenuScene : SKScene { let startButtonTexture = SKTexture(imageNamed: "button_start") let startButtonPressedTexture = SKTexture(imageNamed: "button_start_pressed") let soundButtonTexture = SKTexture(imageNamed: "speaker_on") let soundButtonTextureOff = SKTexture(imageNamed: "speaker_off") let logoSprite = SKSpriteNode(imageNamed: "logo") var startButton : SKSpriteNode! = nil var soundButton : SKSpriteNode! = nil let highScoreNode = SKLabelNode(fontNamed: "PixelDigivolve") var selectedButton : SKSpriteNode? override func sceneDidLoad() { backgroundColor = SKColor(red:0.30, green:0.81, blue:0.89, alpha:1.0) //Set up logo - sprite initialized earlier logoSprite.position = CGPoint(x: size.width / 2, y: size.height / 2 + 100) addChild(logoSprite) //Set up start button startButton = SKSpriteNode(texture: startButtonTexture) startButton.position = CGPoint(x: size.width / 2, y: size.height / 2 - startButton.size.height / 2) addChild(startButton) let edgeMargin : CGFloat = 25 //Set up sound button soundButton = SKSpriteNode(texture: soundButtonTexture) soundButton.position = CGPoint(x: size.width - soundButton.size.width / 2 - edgeMargin, y: soundButton.size.height / 2 + edgeMargin) addChild(soundButton) //Set up high-score node let defaults = UserDefaults.standard let highScore = defaults.integer(forKey: ScoreKey) highScoreNode.text = "\(highScore)" highScoreNode.fontSize = 90 highScoreNode.verticalAlignmentMode = .top highScoreNode.position = CGPoint(x: size.width / 2, y: startButton.position.y - startButton.size.height / 2 - 50) highScoreNode.zPosition = 1 addChild(highScoreNode) } }

เนื่องจากฉากนี้ค่อนข้างเรียบง่าย เราจะไม่สร้างคลาสพิเศษใดๆ ฉากของเราจะประกอบด้วยสองปุ่ม สิ่งเหล่านี้อาจเป็น (และสมควรที่จะเป็น) คลาส SKSpriteNodes ของพวกเขาเอง แต่เนื่องจากมันแตกต่างกันมากพอ เราจึงไม่จำเป็นต้องสร้างคลาสใหม่สำหรับพวกเขา นี่เป็นเคล็ดลับสำคัญเมื่อคุณสร้างเกมของคุณเอง: คุณต้องสามารถกำหนดตำแหน่งที่จะหยุดและจัดองค์ประกอบโค้ดใหม่ได้เมื่อสิ่งต่างๆ ซับซ้อนขึ้น เมื่อคุณเพิ่มปุ่มมากกว่าสามหรือสี่ปุ่มลงในเกมแล้ว อาจถึงเวลาที่ต้องหยุดและจัดองค์ประกอบโค้ดของปุ่มเมนูใหม่ให้เป็นคลาสของตัวเอง

รหัสด้านบนไม่ได้ทำอะไรเป็นพิเศษ มันคือการกำหนดตำแหน่งของสไปรท์สี่ตัว เรากำลังตั้งค่าสีพื้นหลังของฉาก เพื่อให้พื้นหลังทั้งหมดเป็นค่าที่ถูกต้อง เครื่องมือที่ดีในการสร้างรหัสสีจากสตริง HEX สำหรับ Xcode คือ UI Color โค้ดด้านบนยังเป็นการตั้งค่าพื้นผิวสำหรับสถานะปุ่มของเราอีกด้วย ปุ่มสำหรับเริ่มเกมมีสถานะปกติและสถานะกด ในขณะที่ปุ่มเสียงเป็นปุ่มสลับ เพื่อลดความซับซ้อนของสิ่งต่าง ๆ สำหรับการสลับ เราจะเปลี่ยนค่าอัลฟาของปุ่มเสียงเมื่อผู้ใช้กด เรากำลังดึงและตั้งค่า SKLabelNode ที่มีคะแนนสูง

MenuScene ของเราดูดีมาก ตอนนี้เราต้องแสดงฉากเมื่อแอปโหลด ย้ายไปที่ GameViewController.swift และค้นหาบรรทัดต่อไปนี้:

 let sceneNode = GameScene(size: view.frame.size)

แทนที่ด้วยสิ่งนี้:

 let sceneNode = MenuScene(size: view.frame.size)

การเปลี่ยนแปลงเล็กน้อยนี้จะโหลด MenuScene ตามค่าเริ่มต้น แทนที่จะเป็น GameScene

ฉากใหม่ของเรา!
ฉากใหม่ของเรา! หมายเหตุ 1.0 เฟรมต่อวินาที: ไม่มีอะไรเคลื่อนไหว ดังนั้นจึงไม่จำเป็นต้องอัปเดตอะไรเลย

สถานะปุ่ม

ปุ่มต่างๆ อาจทำได้ยากใน SpriteKit มีตัวเลือกของบุคคลที่สามมากมาย (ฉันทำเองด้วย) แต่ในทางทฤษฎีแล้วคุณจำเป็นต้องรู้วิธีการสัมผัสสามวิธีเท่านั้น:

  • touchesBegan(_ touches: with event:)
  • touchesMoved(_ touches: with event:)
  • touchesEnded(_ touches: with event:)

เราได้กล่าวถึงสิ่งนี้โดยสังเขปเมื่ออัปเดตร่ม แต่ตอนนี้ เราต้องรู้สิ่งต่อไปนี้: ปุ่มใดถูกแตะ ผู้ใช้ปล่อยการแตะหรือคลิกปุ่มนั้น และผู้ใช้ยังคงแตะปุ่มนั้นอยู่หรือไม่ นี่คือจุดที่ตัวแปรที่ selectedButton ปุ่มของเราเข้ามาเล่น เมื่อสัมผัสเริ่มต้น เราสามารถจับภาพปุ่มที่ผู้ใช้เริ่มคลิกด้วยตัวแปรนั้น หากพวกมันลากออกไปนอกปุ่ม เราสามารถจัดการสิ่งนี้และให้พื้นผิวที่เหมาะสมกับปุ่มนั้น เมื่อพวกเขาปล่อยสัมผัส เราจะสามารถเห็นได้ว่าพวกเขายังแตะอยู่ในปุ่มหรือไม่ หากเป็นเช่นนั้น เราก็สามารถใช้การกระทำที่เกี่ยวข้องได้ เพิ่มบรรทัดต่อไปนี้ที่ด้านล่างของ MenuScene.swift :

 override func touchesBegan(_ touches: Set, with event: UIEvent?) { if let touch = touches.first { if selectedButton != nil { handleStartButtonHover(isHovering: false) handleSoundButtonHover(isHovering: false) } // Check which button was clicked (if any) if startButton.contains(touch.location(in: self)) { selectedButton = startButton handleStartButtonHover(isHovering: true) } else if soundButton.contains(touch.location(in: self)) { selectedButton = soundButton handleSoundButtonHover(isHovering: true) } } } override func touchesMoved(_ touches: Set, with event: UIEvent?) { if let touch = touches.first { // Check which button was clicked (if any) if selectedButton == startButton { handleStartButtonHover(isHovering: (startButton.contains(touch.location(in: self)))) } else if selectedButton == soundButton { handleSoundButtonHover(isHovering: (soundButton.contains(touch.location(in: self)))) } } } override func touchesEnded(_ touches: Set, with event: UIEvent?) { if let touch = touches.first { if selectedButton == startButton { // Start button clicked handleStartButtonHover(isHovering: false) if (startButton.contains(touch.location(in: self))) { handleStartButtonClick() } } else if selectedButton == soundButton { // Sound button clicked handleSoundButtonHover(isHovering: false) if (soundButton.contains(touch.location(in: self))) { handleSoundButtonClick() } } } selectedButton = nil } /// Handles start button hover behavior func handleStartButtonHover(isHovering : Bool) { if isHovering { startButton.texture = startButtonPressedTexture } else { startButton.texture = startButtonTexture } } /// Handles sound button hover behavior func handleSoundButtonHover(isHovering : Bool) { if isHovering { soundButton.alpha = 0.5 } else { soundButton.alpha = 1.0 } } /// Stubbed out start button on click method func handleStartButtonClick() { print("start clicked") } /// Stubbed out sound button on click method func handleSoundButtonClick() { print("sound clicked") }

นี่คือการจัดการปุ่มอย่างง่ายสำหรับสองปุ่มของเรา ใน touchesBegan(_ touches: with events:) เราเริ่มต้นด้วยการตรวจสอบว่าเรามีปุ่มที่เลือกอยู่หรือไม่ หากเป็นเช่นนั้น เราจำเป็นต้องรีเซ็ตสถานะของปุ่มเป็นปุ่มที่ไม่ได้กด จากนั้นเราต้องตรวจสอบว่ามีการกดปุ่มใดบ้าง หากกดหนึ่งปุ่มจะแสดงสถานะที่ไฮไลต์สำหรับปุ่มนั้น จากนั้นเราตั้งค่า selectedButton เป็นปุ่มสำหรับใช้ในอีกสองวิธี

ใน touchesMoved(_ touches: with events:) เราตรวจสอบว่าปุ่มใดถูกแตะในตอนแรก จากนั้น เราตรวจสอบว่าการสัมผัสปัจจุบันยังอยู่ภายในขอบเขตของ selectedButton หรือไม่ และเราอัปเดตสถานะที่ไฮไลต์จากที่นั่น สถานะที่ไฮไลต์ของ startButton จะเปลี่ยนพื้นผิวเป็นพื้นผิวของสถานะกด โดยที่สถานะที่ไฮไลต์ของ soundButton มีค่าอัลฟาของสไปรต์ที่ตั้งค่าเป็น 50%

สุดท้าย ใน touchesEnded(_ touches: with event:) เราจะตรวจสอบอีกครั้งว่าปุ่มใดถูกเลือก หากมี แล้วแตะว่ายังอยู่ภายในขอบเขตของปุ่มหรือไม่ หากทุกกรณีเป็นที่พอใจ เราจะเรียก handleStartButtonClick() หรือ handleSoundButtonClick() สำหรับปุ่มที่ถูกต้อง

เวลาสำหรับการกระทำ

ตอนนี้เรามีการทำงานของปุ่มพื้นฐานแล้ว เราจำเป็นต้องมีเหตุการณ์ที่จะทริกเกอร์เมื่อมีการคลิก ปุ่มที่ง่ายต่อการใช้งานคือ startButton เมื่อคลิก เราจำเป็นต้องนำเสนอ GameScene เท่านั้น อัปเดต handleStartButtonClick() ในฟังก์ชัน MenuScene.swift เป็นโค้ดต่อไปนี้:

 func handleStartButtonClick() { let transition = SKTransition.reveal(with: .down, duration: 0.75) let gameScene = GameScene(size: size) gameScene.scaleMode = scaleMode view?.presentScene(gameScene, transition: transition) }

หากคุณเปิดแอปตอนนี้และกดปุ่ม เกมจะเริ่มขึ้น!

ตอนนี้เราจำเป็นต้องใช้การสลับปิดเสียง เรามีตัวจัดการเสียงอยู่แล้ว แต่เราต้องสามารถบอกได้ว่าการปิดเสียงเปิดหรือปิดอยู่ ใน Constants.swift เราจำเป็นต้องเพิ่มคีย์เพื่อยืนยันเมื่อเปิดการปิดเสียง เพิ่มบรรทัดต่อไปนี้:

 let MuteKey = "RAINCAT_MUTED"

เราจะใช้สิ่งนี้เพื่อบันทึกค่าบูลีนเป็น UserDefaults เมื่อตั้งค่าเรียบร้อยแล้ว เราก็ย้ายไป SoundManager.swift ได้ นี่คือที่ที่เราจะตรวจสอบและตั้งค่า UserDefaults เพื่อดูว่าการปิดเสียงเปิดหรือปิดอยู่ ที่ด้านบนของไฟล์ ภายใต้ตัวแปร trackPosition ให้เพิ่มบรรทัดต่อไปนี้:

 private(set) var isMuted = false

นี่คือตัวแปรที่เมนูหลัก (และอย่างอื่นที่จะเล่นเสียง) ตรวจสอบว่าอนุญาตให้ใช้เสียงหรือไม่ เราเริ่มต้นเป็น false แต่ตอนนี้เราต้องตรวจสอบ UserDefaults เพื่อดูว่าผู้ใช้ต้องการอะไร แทนที่ฟังก์ชัน init() ด้วยสิ่งต่อไปนี้:

 private override init() { //This is private, so you can only have one Sound Manager ever. trackPosition = Int(arc4random_uniform(UInt32(SoundManager.tracks.count))) let defaults = UserDefaults.standard isMuted = defaults.bool(forKey: MuteKey) }

ตอนนี้เรามีค่าเริ่มต้นสำหรับ isMuted แล้ว เราต้องการความสามารถในการเปลี่ยนแปลง เพิ่มรหัสต่อไปนี้ที่ด้านล่างของ SoundManager.swift :

 func toggleMute() -> Bool { isMuted = !isMuted let defaults = UserDefaults.standard defaults.set(isMuted, forKey: MuteKey) defaults.synchronize() if isMuted { audioPlayer?.stop() } else { startPlaying() } return isMuted }

วิธีนี้จะสลับตัวแปรปิดเสียงของเรา รวมทั้งอัปเดต UserDefaults หากไม่ได้ปิดเสียงค่าใหม่ การเล่นเพลงจะเริ่มขึ้น หากปิดเสียงค่าใหม่ การเล่นจะไม่เริ่มต้นขึ้น มิฉะนั้น เราจะหยุดเล่นเพลงปัจจุบัน หลังจากนี้ เราต้องแก้ไขคำสั่ง if ใน startPlaying()

ค้นหาบรรทัดต่อไปนี้:

 if audioPlayer == nil || audioPlayer?.isPlaying == false {

และแทนที่ด้วยสิ่งนี้:

 if !isMuted && (audioPlayer == nil || audioPlayer?.isPlaying == false) {

ตอนนี้ หากการปิดเสียงปิดอยู่และไม่ได้ตั้งค่าเครื่องเล่นเสียงหรือเครื่องเล่นเสียงปัจจุบันไม่ได้เล่นอีกต่อไป เราจะเล่นแทร็กถัดไป

จากที่นี่ เราสามารถย้ายกลับไปที่ MenuScene.swift เพื่อสิ้นสุดปุ่มปิดเสียงของเรา แทนที่ handleSoundbuttonClick() ด้วยรหัสต่อไปนี้:

 func handleSoundButtonClick() { if SoundManager.sharedInstance.toggleMute() { //Is muted soundButton.texture = soundButtonTextureOff } else { //Is not muted soundButton.texture = soundButtonTexture } }

วิธีนี้จะสลับเสียงใน SoundManager ตรวจสอบผลลัพธ์ จากนั้นตั้งค่าพื้นผิวให้เหมาะสมเพื่อแสดงให้ผู้ใช้เห็นว่าปิดเสียงอยู่หรือไม่ เราใกล้จะเสร็จแล้ว! เราต้องตั้งค่าพื้นผิวเริ่มต้นของปุ่มเมื่อเปิดตัวเท่านั้น ใน sceneDidLoad() ให้ค้นหาบรรทัดต่อไปนี้:

 soundButton = SKSpriteNode(texture: soundButtonTexture)

และแทนที่ด้วยสิ่งนี้:

 soundButton = SKSpriteNode(texture: SoundManager.sharedInstance.isMuted ? soundButtonTextureOff : soundButtonTexture)

ตัวอย่างข้างต้นใช้ตัวดำเนินการ ternary เพื่อกำหนดพื้นผิวที่ถูกต้อง

เมื่อเชื่อมต่อเพลงแล้ว เราสามารถย้ายไปที่ CatSprite.swift เพื่อปิดเสียงแมวเหมียวเมื่อเปิดการปิดเสียง ใน hitByRain() เราสามารถเพิ่มคำสั่ง if ต่อไปนี้หลังจากลบการดำเนินการเดิน:

 if SoundManager.sharedInstance.isMuted { return }

คำสั่งนี้จะส่งคืนว่าผู้ใช้ปิดเสียงแอปหรือไม่ ด้วยเหตุนี้ เราจะเพิกเฉยต่อ currentRainHits , maxRainHits และเอฟเฟกต์เสียง meowing ของเราโดยสิ้นเชิง

หลังจากนั้น ก็ถึงเวลาลองใช้ปุ่มปิดเสียงของเรา เรียกใช้แอพและตรวจสอบว่ากำลังเล่นและปิดเสียงอย่างเหมาะสมหรือไม่ ปิดเสียง ปิดแอป แล้วเปิดใหม่อีกครั้ง ตรวจสอบให้แน่ใจว่าการตั้งค่าปิดเสียงยังคงอยู่ โปรดทราบว่าหากคุณเพียงแค่ปิดเสียงและเรียกใช้แอปจาก Xcode อีกครั้ง คุณอาจไม่มีเวลาเพียงพอสำหรับ UserDefaults ในการบันทึก เล่นเกมและตรวจดูให้แน่ใจว่าแมวจะไม่ส่งเสียงร้องเมื่อคุณปิดเสียง

ทดสอบการทำงานของปุ่ม

ออกจากเกม

ตอนนี้เรามีปุ่มประเภทแรกสำหรับเมนูหลักแล้ว เราสามารถเข้าสู่ธุรกิจที่ยุ่งยากได้โดยการเพิ่มปุ่มออกในฉากเกมของเรา การโต้ตอบที่น่าสนใจบางอย่างสามารถเกิดขึ้นได้กับสไตล์เกมของเรา ในปัจจุบัน ร่มจะเคลื่อนไปยังทุกที่ที่ผู้ใช้สัมผัสหรือขยับสัมผัส เห็นได้ชัดว่าร่มที่ย้ายไปที่ปุ่มออกเมื่อผู้ใช้พยายามออกจากเกมนั้นเป็นประสบการณ์การใช้งานที่ค่อนข้างแย่ ดังนั้นเราจะพยายามหยุดไม่ให้สิ่งนี้เกิดขึ้น

ปุ่มออกที่เราใช้จะเลียนแบบปุ่มเริ่มเกมที่เราเพิ่มไว้ก่อนหน้านี้ โดยกระบวนการส่วนใหญ่ยังคงเหมือนเดิม การเปลี่ยนแปลงจะอยู่ที่วิธีที่เราจัดการกับการสัมผัส รับสินทรัพย์ quit_button และ quit_button_pressed ของคุณลงในไฟล์ Assets.xcassets และเพิ่มโค้ดต่อไปนี้ในไฟล์ HudNode.swift :

 private var quitButton : SKSpriteNode! private let quitButtonTexture = SKTexture(imageNamed: "quit_button") private let quitButtonPressedTexture = SKTexture(imageNamed: "quit_button_pressed")

สิ่งนี้จะจัดการการอ้างอิง quitButton ของเราพร้อมกับพื้นผิวที่เราจะตั้งค่าสำหรับสถานะของปุ่ม เพื่อให้แน่ใจว่าเราไม่ได้อัปเดตร่มโดยไม่ได้ตั้งใจในขณะที่พยายามเลิก เราต้องการตัวแปรที่บอก HUD (และฉากในเกม) ว่าเรากำลังโต้ตอบกับปุ่มออก ไม่ใช่ที่ร่ม เพิ่มโค้ดต่อไปนี้ด้านล่างตัวแปร showingHighScore boolean:

 private(set) var quitButtonPressed = false

อีกครั้ง นี่เป็นตัวแปรที่มีเพียง HudNode เท่านั้นที่สามารถตั้งค่าได้ แต่คลาสอื่นๆ สามารถตรวจสอบได้ เมื่อตั้งค่าตัวแปรของเราแล้ว เราก็สามารถเพิ่มปุ่มลงใน HUD ได้ เพิ่มรหัสต่อไปนี้ในฟังก์ชัน setup(size:)

 quitButton = SKSpriteNode(texture: quitButtonTexture) let margin : CGFloat = 15 quitButton.position = CGPoint(x: size.width - quitButton.size.width - margin, y: size.height - quitButton.size.height - margin) quitButton.zPosition = 1000 addChild(quitButton)

รหัสด้านบนจะตั้งค่าปุ่มออกด้วยพื้นผิวของสถานะที่ไม่ได้กดของเรา เรายังกำหนดตำแหน่งไว้ที่มุมขวาบนและตั้งค่า zPosition เป็นตัวเลขที่สูงเพื่อบังคับให้วางตำแหน่งด้านบนเสมอ หากคุณเปิดเกมตอนนี้ เกมนั้นจะแสดงขึ้นใน GameScene แต่จะยังไม่สามารถคลิกได้

ปุ่มออก
สังเกตปุ่มออกใหม่ใน HUD ของเรา

เมื่อวางตำแหน่งปุ่มแล้ว เราต้องสามารถโต้ตอบกับปุ่มได้ ตอนนี้ ที่เดียวที่เรามีปฏิสัมพันธ์ใน GameScene คือเมื่อเราโต้ตอบกับ umbrellaSprite ในตัวอย่างของเรา HUD จะมีลำดับความสำคัญเหนือร่ม ดังนั้นผู้ใช้จึงไม่ต้องย้ายร่มออกไปให้พ้นทางเพื่อที่จะออกไป เราสามารถสร้างฟังก์ชันเดียวกันใน HudNode.swift เพื่อเลียนแบบฟังก์ชันการสัมผัสใน GameScene.swift เพิ่มรหัสต่อไปนี้ใน HudNode.swift :

 func touchBeganAtPoint(point: CGPoint) { let containsPoint = quitButton.contains(point) if quitButtonPressed && !containsPoint { //Cancel the last click quitButtonPressed = false quitButton.texture = quitButtonTexture } else if containsPoint { quitButton.texture = quitButtonPressedTexture quitButtonPressed = true } } func touchMovedToPoint(point: CGPoint) { if quitButtonPressed { if quitButton.contains(point) { quitButton.texture = quitButtonPressedTexture } else { quitButton.texture = quitButtonTexture } } } func touchEndedAtPoint(point: CGPoint) { if quitButton.contains(point) { //TODO tell the gamescene to quit the game } quitButton.texture = quitButtonTexture }

โค้ดด้านบนนี้เหมือนกับโค้ดที่เราสร้างขึ้นสำหรับ MenuScene ความแตกต่างคือมีเพียงปุ่มเดียวให้ติดตาม ดังนั้นเราจึงสามารถจัดการทุกอย่างด้วยวิธีการสัมผัสเหล่านี้ นอกจากนี้ เนื่องจากเราจะทราบตำแหน่งของการสัมผัสใน GameScene เราจึงสามารถตรวจสอบได้ว่าปุ่มของเรามีจุดสัมผัสหรือไม่

ย้ายไปที่ GameScene.swift และแทนที่ touchesBegan(_ touches with event:) และ touchesMoved(_ touches: with event:) ด้วยรหัสต่อไปนี้:

 override func touchesBegan(_ touches: Set, with event: UIEvent?) { let touchPoint = touches.first?.location(in: self) if let point = touchPoint { hudNode.touchBeganAtPoint(point: point) if !hudNode.quitButtonPressed { umbrellaNode.setDestination(destination: point) } } } override func touchesMoved(_ touches: Set, with event: UIEvent?) { let touchPoint = touches.first?.location(in: self) if let point = touchPoint { hudNode.touchMovedToPoint(point: point) if !hudNode.quitButtonPressed { umbrellaNode.setDestination(destination: point) } } } override func touchesEnded(_ touches: Set, with event: UIEvent?) { let touchPoint = touches.first?.location(in: self) if let point = touchPoint { hudNode.touchEndedAtPoint(point: point) } }

ที่นี่แต่ละวิธีจัดการทุกอย่างในลักษณะเดียวกัน เรากำลังบอก HUD ว่าผู้ใช้โต้ตอบกับฉากนั้น จากนั้น เราตรวจสอบว่าปุ่มออกกำลังจับการสัมผัสหรือไม่ ถ้าไม่เช่นนั้นเราจะย้ายร่ม เรายังได้เพิ่ม touchesEnded(_ touches: with event:) เพื่อจัดการการสิ้นสุดการคลิกปุ่มสำหรับปุ่มออก แต่เรายังไม่ได้ใช้งานสำหรับ umbrellaSprite

การคลิกปุ่มออกจะไม่ย้ายร่ม แต่การคลิกที่อื่นจะมีผล

ตอนนี้เรามีปุ่มแล้ว เราต้องการวิธีที่จะส่งผลต่อ GameScene เพิ่มบรรทัดต่อไปนี้ที่ด้านบนของ HudeNode.swift :

 var quitButtonAction : (() -> ())?

นี่คือการปิดทั่วไปที่ไม่มีอินพุตและเอาต์พุต เราจะตั้งค่านี้ด้วยรหัสในไฟล์ GameScene.swift และเรียกใช้เมื่อเราคลิกปุ่มใน HudNode.swift จากนั้น เราสามารถแทนที่ TODO ในโค้ดที่เราสร้างไว้ก่อนหน้านี้ใน touchEndedAtPoint(point:) ด้วยสิ่งนี้:

 if quitButton.contains(point) && quitButtonAction != nil { quitButtonAction!() }

ตอนนี้ ถ้าเราตั้งค่าการปิด quitButtonAction มันจะถูกเรียกจากจุดนี้

ในการตั้งค่าการปิด quitButtonAction เราจำเป็นต้องย้ายไปที่ GameScene.swift ใน sceneDidLoad() เราสามารถแทนที่การตั้งค่า HUD ของเราด้วยรหัสต่อไปนี้:

 hudNode.setup(size: size) hudNode.quitButtonAction = { let transition = SKTransition.reveal(with: .up, duration: 0.75) let gameScene = MenuScene(size: self.size) gameScene.scaleMode = self.scaleMode self.view?.presentScene(gameScene, transition: transition) self.hudNode.quitButtonAction = nil } addChild(hudNode)

เรียกใช้แอป กดเล่น แล้วกดออก หากคุณกลับมาที่เมนูหลัก แสดงว่าปุ่มออกของคุณทำงานตามที่ตั้งใจไว้ ในการปิดที่เราสร้างขึ้น เราได้เริ่มต้นการเปลี่ยนไปใช้ MenuScene และเราตั้งค่าการปิดนี้เป็นโหนด HUD เพื่อให้ทำงานเมื่อมีการคลิกปุ่มออก บรรทัดสำคัญอีกบรรทัดหนึ่งคือเมื่อเราตั้งค่า quitButtonAction เป็น nil เหตุผลก็คือวงจรการรักษากำลังเกิดขึ้น ฉากนี้มีการอ้างอิงถึง HUD โดยที่ HUD กำลังอ้างอิงถึงที่เกิดเหตุ เนื่องจากมีการอ้างอิงถึงวัตถุทั้งสอง จึงไม่ถูกกำจัดเมื่อถึงเวลาสำหรับการรวบรวมขยะ ในกรณีนี้ ทุกครั้งที่เราเข้าและออกจาก GameScene จะมีการสร้างอินสแตนซ์อื่นและจะไม่เผยแพร่ สิ่งนี้ไม่ดีต่อประสิทธิภาพ และในที่สุดแอพก็จะมีหน่วยความจำไม่เพียงพอ มีหลายวิธีที่จะหลีกเลี่ยงสิ่งนี้ แต่ในกรณีของเรา เราสามารถลบการอ้างอิงถึง GameScene ออกจาก HUD และฉากและ HUD จะถูกยกเลิกเมื่อเรากลับไปที่ MenuScene Krakendev มีคำอธิบายที่ลึกซึ้งยิ่งขึ้นเกี่ยวกับประเภทการอ้างอิงและวิธีหลีกเลี่ยงวงจรเหล่านี้

ตอนนี้ ย้ายไปที่ GameViewController.swift และลบหรือใส่ความคิดเห็นในโค้ดสามบรรทัดต่อไปนี้:

 view.showsPhysics = true view.showsFPS = true view.showsNodeCount = true

ด้วยข้อมูลการดีบักเกมจึงออกมาดี! ขอแสดงความยินดี: ขณะนี้เรากำลังเข้าสู่เบต้า! ตรวจสอบรหัสสุดท้ายตั้งแต่วันนี้บน GitHub

ความคิดสุดท้าย

นี่เป็นบทเรียนสุดท้ายของบทช่วยสอนสามส่วน และหากคุณทำมาไกลถึงขนาดนี้ แสดงว่าคุณทำงานหนักมากกับเกมของคุณ ในบทช่วยสอนนี้ คุณเปลี่ยนจากฉากที่ไม่มีอะไรเลย ไปสู่เกมที่สมบูรณ์ ยินดีด้วย! ในบทที่หนึ่ง เราได้เพิ่มพื้น เม็ดฝน พื้นหลัง และสไปรท์ร่ม เรายังเล่นฟิสิกส์และทำให้แน่ใจว่าเม็ดฝนของเราจะไม่กองพะเนิน เราเริ่มต้นด้วยการตรวจจับการชนกันและทำงานกับโหนดที่คัดแยกเพื่อไม่ให้หน่วยความจำไม่เพียงพอ นอกจากนี้เรายังเพิ่มการโต้ตอบกับผู้ใช้โดยอนุญาตให้ร่มเคลื่อนไปยังตำแหน่งที่ผู้ใช้สัมผัสบนหน้าจอ

ในบทที่ 2 เราได้เพิ่มแมวและอาหาร พร้อมด้วยวิธีการวางไข่แบบกำหนดเองสำหรับแมวแต่ละตัว เราอัปเดตการตรวจจับการชนกันเพื่อให้มีสไปรท์ของแมวและอาหาร เรายังทำงานเกี่ยวกับการเคลื่อนไหวของแมว แมวมีจุดประสงค์: กินอาหารที่มีให้ครบทุกมื้อ เราได้เพิ่มแอนิเมชั่นอย่างง่ายสำหรับแมวและเพิ่มการโต้ตอบที่กำหนดเองระหว่างแมวกับฝน สุดท้าย เราได้เพิ่มเอฟเฟกต์เสียงและเพลงเพื่อให้รู้สึกเหมือนเป็นเกมที่สมบูรณ์

ในบทเรียนสุดท้ายนี้ เราได้สร้างการแสดงข้อมูลล่วงหน้าเพื่อเก็บป้ายกำกับคะแนนของเราไว้ เช่นเดียวกับปุ่มออก เราจัดการการกระทำข้ามโหนดและเปิดใช้งานให้ผู้ใช้สามารถออกด้วยการโทรกลับจากโหนด HUD เรายังเพิ่มฉากอื่นที่ผู้ใช้สามารถเปิดเข้าไปและกลับมาได้หลังจากคลิกปุ่มออก เราจัดการกระบวนการในการเริ่มเกมและเพื่อควบคุมเสียงในเกม

จะไปจากที่นี่ที่ไหน

เราทุ่มเทเวลาอย่างมากเพื่อไปให้ไกลถึงขนาดนี้ แต่ก็ยังมีงานอีกมากที่สามารถเข้าสู่เกมนี้ได้ RainCat ยังคงพัฒนาต่อไปและพร้อมให้บริการใน App Store ด้านล่างเป็นรายการความต้องการและจำเป็นต้องเพิ่ม เพิ่มบางรายการแล้ว ในขณะที่บางรายการยังรอดำเนินการ:

  • เพิ่มในไอคอนและหน้าจอเริ่มต้น
  • จบเมนูหลัก (แบบง่ายสำหรับบทช่วยสอน)
  • แก้ไขข้อบกพร่อง รวมทั้งเม็ดฝนอันธพาลและการวางไข่ของอาหารหลายตัว
  • Refactor และเพิ่มประสิทธิภาพโค้ด
  • เปลี่ยนจานสีของเกมตามคะแนน
  • อัปเดตความยากตามคะแนน
  • ทำให้แมวเคลื่อนไหวเมื่อมีอาหารอยู่ด้านบน
  • รวมศูนย์เกม
  • ให้เครดิต (รวมถึงเครดิตที่เหมาะสมสำหรับแทร็กเพลง)

ติดตาม GitHub เพราะการเปลี่ยนแปลงเหล่านี้จะเกิดขึ้นในอนาคต หากคุณมีคำถามใด ๆ เกี่ยวกับรหัส โปรดส่งอีเมลหาเราที่ [email protected] แล้วเราจะสามารถพูดคุยกันได้ หากบางหัวข้อได้รับความสนใจเพียงพอ เราอาจเขียนบทความอื่นที่กล่าวถึงหัวข้อนั้นได้

ขอบคุณ!

ฉันอยากจะขอบคุณทุกคนที่ช่วยในกระบวนการสร้างเกมและพัฒนาบทความที่เกี่ยวข้อง

  • Cathryn Rowe สำหรับงานศิลปะเบื้องต้น การออกแบบและการแก้ไข และสำหรับการเผยแพร่บทความในโรงรถของเรา
  • Morgan Wheaton สำหรับการออกแบบเมนูขั้นสุดท้ายและจานสี (ซึ่งจะดูดีมากเมื่อฉันใช้คุณสมบัติเหล่านี้จริงๆ – คอยติดตาม)
  • Nikki Clark สำหรับส่วนหัวและตัวแบ่งที่ยอดเยี่ยมในบทความและสำหรับความช่วยเหลือในการแก้ไขบทความ
  • Laura Levisay สำหรับ GIF ที่ยอดเยี่ยมทั้งหมดในบทความและสำหรับการส่ง GIF แมวน่ารักสำหรับการสนับสนุนทางศีลธรรม
  • ทอม ฮัดสัน สำหรับความช่วยเหลือในการแก้ไขบทความและหากไม่มีใครที่ซีรีส์นี้จะไม่มีการสร้างเลย
  • Lani DeGuire สำหรับความช่วยเหลือในการแก้ไขบทความซึ่งเป็นงานมากมาย
  • Jeff Moon สำหรับความช่วยเหลือในการแก้ไขบทที่ 3 และปิงปอง ปิงปองมากมาย
  • ทอม เนลสัน สำหรับช่วยให้แน่ใจว่าบทช่วยสอนทำงานตามที่ควรจะเป็น

อย่างจริงจัง ต้องใช้ผู้คนมากมายในการเตรียมทุกอย่างให้พร้อมสำหรับบทความนี้และเผยแพร่ไปยังร้านค้า

ขอบคุณทุกคนที่อ่านประโยคนี้เช่นกัน