Erstellen eines Echtzeit-iOS-Multiplayer-Spiels mit Swift und WebSockets (4)


Bitte beachten Sie: Dies ist der letzte Teil des Tutorials. Teil 3 finden Sie hier.


In den vorherigen Tutorials haben wir eine gemeinsam genutzte Bibliothek zur Verwendung mit den Clients und dem Server und dem Serverprojekt eingerichtet. Der letzte Schritt besteht darin, den iOS-Client zu erstellen.

Richten Sie das iOS-Projekt ein

Das iOS-Projekt wird auch ziemlich einfach sein. Als Ausgangspunkt basiert die App auf der SpriteKit-Spielvorlage für iOS. Das Spiel wird die folgenden Typen und Klassen verwenden:

  • GameScene: Um die Szene zu rendern und Berührungen zu verarbeiten.
  • GameViewController: Zur Verwaltung der GameScene.
  • GameState: Eine Aufzählung, die alle möglichen Client-Zustände enthält.
  • Spiel: Ein Singleton, das den Spielstatus verwaltet.
  • TicTacToeClient: Diese Spielinstanz verwendet diesen Client, um mit dem Server zu interagieren.

Vermögenswerte für das Spiel können sein hier heruntergeladen.

Die Datei Actions.sks können gelöscht werden. Davon werden wir keinen Gebrauch machen. Die Szenendatei GameScene.sks wird so angepasst, dass ein Spielbrett und ein Statuslabel mit den bereitgestellten Assets angezeigt werden.

Die App nutzt die Sternenschrei Bibliothek, da Perfect ist nicht für iOS verfügbar. Das StarScream Bibliothek ist als Cocoapod enthalten.

Die TicTacToeClient-Klasse

Das TicTacToeClient wird die umsetzen WebSocketDelegate von dem StarScream Bibliothek und es müssen wirklich nur drei Delegate-Methoden implementiert werden: für die Verbindung, die Trennung und immer dann, wenn eine Nachricht (JSON-String) empfangen wird. Der Client enthält auch Logik zum Konvertieren einer JSON-Zeichenfolge in eine Message Objekt und umgekehrt.

Der Client stellt einen Delegaten bereit, um mit anderen Klassen zu kommunizieren, die den Client verwenden.

import Foundation
import Starscream
import TicTacToeShared

protocol TicTacToeClientDelegate: class {
    func clientDidConnect()
    func clientDidDisconnect(error: Error?)
    func clientDidReceiveMessage(_ message: Message)
}

class TicTacToeClient: WebSocketDelegate {
    weak var delegate: TicTacToeClientDelegate?
    
    private var socket: WebSocket!
    
    init() {
        let url = URL(string: "http://localhost:8181/game")!
        let request = URLRequest(url: url)
        self.socket = WebSocket(request: request, protocols: ["tictactoe"], stream: FoundationStream())

        self.socket.delegate = self
    }
    
    
    
    func connect() {
        self.socket.connect()
    }
    
    func join(player: Player) {
        let message = Message.join(player: player)
        writeMessageToSocket(message)
    }
    
    func playTurn(updatedBoard board: [Tile], activePlayer: Player) {
        let message = Message.turn(board: board, player: activePlayer)
        writeMessageToSocket(message)
    }
        
    func disconnect() {
        self.socket.disconnect()
    }
    
    
    
    private func writeMessageToSocket(_ message: Message) {
        let jsonEncoder = JSONEncoder()
        
        do {
            let jsonData = try jsonEncoder.encode(message)
            self.socket.write(data: jsonData)
        } catch let error {
            print("error: \(error)")
        }
    }

    
    
    func websocketDidConnect(socket: WebSocketClient) {
        self.delegate?.clientDidConnect()
    }
    
    func websocketDidDisconnect(socket: WebSocketClient, error: Error?) {
        self.delegate?.clientDidDisconnect(error: error)
    }
    
    func websocketDidReceiveMessage(socket: WebSocketClient, text: String) {
        guard let data = text.data(using: .utf8) else {
            print("failed to convert text into data")
            return
        }
    
        do {
            let decoder = JSONDecoder()
            let message = try decoder.decode(Message.self, from: data)
            self.delegate?.clientDidReceiveMessage(message)
        } catch let error {
            print("error: \(error)")
        }
    }
    
    func websocketDidReceiveData(socket: WebSocketClient, data: Data) {
        
    }
}

Der GameState-Typ

Das GameState Enumeration listet alle möglichen Status auf, die wir möglicherweise auf dem Client verwenden möchten. Die Aufzählung enthält auch Zustandsmeldungen zur Anzeige je nach Zustand.

import Foundation

enum GameState {
    case active 
    case waiting 
    case connected 
    case disconnected 
    case stopped 
    case playerWon 
    case playerLost 
    case draw 
    
    var message: String {
        switch self {
        case .active: return "Your turn to play ..."
        case .connected: return "Waiting for player to join"
        case .disconnected: return "Disconnected"
        case .playerWon: return "You won :)"
        case .playerLost: return "You lost :("
        case .draw: return "It's a draw :|"
        case .waiting: return "Waiting for other player ..."
        case .stopped: return "Player left the game"
        }
    }
}

Die Spielklasse

Das können wir dann umsetzen Game Klasse. Das Spiel verfolgt den Status mithilfe von GameState Aufzählung.

Intern nutzt das Spiel die TicTacToeClient um Nachrichten an den Server zu senden. Das TicTacToeClientDelegate ist ebenfalls implementiert, um Nachrichten zu verarbeiten, die vom Server an den Client gesendet werden. Das Spiel erstellt seine eigenen Player Instanz, verfolgt den Board-Status und weist einen Kacheltyp zu (X oder O) zum Player wenn noch kein Kacheltyp zugewiesen wurde.

import Foundation
import TicTacToeShared
import CoreGraphics

class Game {
    static let sharedInstace = Game()

    
    private(set) var board = [Tile]()
    
    
    private (set) var client = TicTacToeClient()

    
    private(set) var state: GameState = .disconnected

    
    private (set) var player = Player()
        
    
    private (set) var playerTile: Tile = .none
    
    
    
    func start() {
        self.client.delegate = self
        self.client.connect()
    }
    
    func stop() {
        self.client.disconnect()
    }
     
    func playTileAtPosition(_ position: CGPoint) {        
        let tilePosition = Int(position.y * 3 + position.x)
        
        let tile = self.board[tilePosition]
        if tile == .none {
            self.board[tilePosition] = self.playerTile
            self.client.playTurn(updatedBoard: self.board, activePlayer: self.player)
            self.state = .waiting
        }
    }

    
    
    private init() {  }

    private func configurePlayerTileIfNeeded(_ playerTile: Tile) {
        let emptyTiles = board.filter({ $0 == .none })
        if emptyTiles.count == 9 {
            self.playerTile = playerTile
        }
    }
}



extension Game: TicTacToeClientDelegate {
    func clientDidDisconnect(error: Error?) {
        self.state = .disconnected
    }
    
    func clientDidConnect() {
        self.client.join(player: self.player)
        self.state = .connected
    }
    
    func clientDidReceiveMessage(_ message: Message) {
        if let board = message.board {
            self.board = board
        }
        
        switch message.type {
        case .finish:
            self.playerTile = .none
            
            if let winningPlayer = message.player {
                self.state = (winningPlayer == self.player) ? .playerWon : .playerLost
            } else {
                self.state = .draw
            }
        case .stop:
            self.board = [Tile]()
            
            self.playerTile = .none

            self.state = .stopped
        case .turn:
            guard let activePlayer = message.player else {
                print("no player found - this should never happen")
                return
            }
            
            if activePlayer == self.player {
                self.state = .active
                configurePlayerTileIfNeeded(.x)
            } else {
                self.state = .waiting
                configurePlayerTileIfNeeded(.o)
            }
        default: break
        }
    }
}

Die GameScene-Klasse

Jetzt müssen wir die implementieren GameScene und passen Sie die an GameScene.sks Datei, damit ein Board und eine Statusbezeichnung angezeigt werden. Zuerst öffnen GameScene.sks und lass es so aussehen:

gamescene-sks.png

Das Spielbrett-Asset sowie die X und O Stück-Assets können meinem GitHub-Projekt entnommen werden. Diese Vermögenswerte sollten dem hinzugefügt werden Assets.xcassets Verzeichnis im Projekt und benannt werden GameBoardBackground, Player_X und Player_O.

Stellen Sie sicher, dass die Tafel 300 x 300 groß ist und sich der Ankerpunkt unten links befindet. Die Positionierung ist wichtig, um die Spielerplättchen richtig zu platzieren. Das Brett sollte auf dem Bildschirm zentriert sein. Der Knotenname sollte lauten GameBoard.

Fügen Sie am unteren Rand der Szene ein Label hinzu. Als Schriftart habe ich Helvetica Neue Light 24.0 verwendet und die Textfarbe ist weiß. Dieser Bezeichnungsknoten sollte benannt werden StatusLabel.

Wie für die GameScene Klasse selbst, müssen wir eine Funktion hinzufügen, um Berührungsereignisse und eine Renderfunktion zu verarbeiten. Was das Rendern betrifft, werden wir grundsätzlich alle Knoten in jedem Frame entfernen und dann Knoten basierend auf dem Spielbrett wieder hinzufügen. Das ist natürlich nicht sehr effizient, aber für diese einfache App funktioniert es gut genug. Wenn ein Spieler die Ansicht berührt, können wir je nach aktuellem Zustand ein neues Spiel beginnen, nichts tun (z. B. ist der andere Spieler an der Reihe) oder ein Plättchen spielen.

import SpriteKit
import GameplayKit
import TicTacToeShared

class GameScene: SKScene {
    var entities = [GKEntity]()
    var graphs = [String : GKGraph]()
    
    private var gameBoard: SKSpriteNode!
    private var statusLabel: SKLabelNode!
    
    lazy var tileSize: CGSize = {
        let tileWidth = self.gameBoard.size.width / 3
        let tileHeight = self.gameBoard.size.height / 3
        return CGSize(width: tileWidth, height: tileHeight)
    }()
    
    override func sceneDidLoad() {
        Game.sharedInstace.start()
    }
    
    override func didMove(to view: SKView) {
        self.gameBoard = self.childNode(withName: "GameBoard") as! SKSpriteNode
        self.statusLabel = self.childNode(withName: "StatusLabel") as! SKLabelNode
    }
    
    func touchUp(atPoint pos : CGPoint) {
        
        
        switch Game.sharedInstace.state {
        case .active:
            if let tilePosition = tilePositionOnGameBoardForPoint(pos) {
                Game.sharedInstace.playTileAtPosition(tilePosition)
            }
        case .connected, .waiting: break
        default: Game.sharedInstace.start()
        }
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        for t in touches { self.touchUp(atPoint: t.location(in: self)) }
    }
    
    override func update(_ currentTime: TimeInterval) {
        self.statusLabel.text = Game.sharedInstace.state.message
        
        drawTiles(Game.sharedInstace.board)
    }
    
    func tilePositionOnGameBoardForPoint(_ point: CGPoint) -> CGPoint? {
        if self.gameBoard.frame.contains(point) == false {
            return nil
        }
        
        let positionOnBoard = self.convert(point, to: self.gameBoard)
        
        let xPos = Int(positionOnBoard.x / self.tileSize.width)
        let yPos = Int(positionOnBoard.y / self.tileSize.height)
        
        return CGPoint(x: xPos, y: yPos)
    }
    
    func drawTiles(_ tiles: [Tile]) {
        self.gameBoard.removeAllChildren()
        
        for tileIdx in 0 ..< tiles.count {
            let tile = tiles[tileIdx]
            
            if tile == .none {
                continue
            }
            
            let row = tileIdx / 3
            let col = tileIdx % 3
            
            let x = CGFloat(col) * self.tileSize.width + self.tileSize.width / 2
            let y = CGFloat(row) * self.tileSize.height + self.tileSize.height / 2
            
            if tile == .x {
                let sprite = SKSpriteNode(imageNamed: "Player_X")
                sprite.position = CGPoint(x: x, y: y)
                self.gameBoard.addChild(sprite)
            } else if tile == .o {
                let sprite = SKSpriteNode(imageNamed: "Player_O")
                sprite.position = CGPoint(x: x, y: y)
                self.gameBoard.addChild(sprite)
            }
        }
    }
}

GameViewController aktualisieren

Endlich können wir einige Anpassungen an der vornehmen GameViewController. Beispielsweise bietet unsere App nur die Ausrichtung im Hochformat.

import UIKit
import SpriteKit
import GameplayKit

class GameViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        if let scene = GKScene(fileNamed: "GameScene") {
            
            if let sceneNode = scene.rootNode as! GameScene? {
                
                
                sceneNode.entities = scene.entities
                sceneNode.graphs = scene.graphs
                
                
                sceneNode.scaleMode = .aspectFill
                
                
                if let view = self.view as! SKView? {
                    view.presentScene(sceneNode)
                    
                    view.ignoresSiblingOrder = true
                    view.showsFPS = true
                    view.showsNodeCount = true
                }
            }
        }
    }

    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return .portrait
    }
    
    override var preferredStatusBarStyle: UIStatusBarStyle {
        return .lightContent
    }
}

Fazit

Während es ein bisschen mühsam sein kann, ein Projekt für ein Echtzeit-Multiplayer-Spiel mit Swift und WebSockets einzurichten, ist es am Ende ziemlich trivial, das Spiel selbst mit dieser Technologie zu schreiben. Obwohl ich nicht sicher bin, ob WebSockets das beste Werkzeug für diesen Zweck sind (ich bin kein Spieleprogrammierer), sollte es definitiv gut genug für das Prototyping sein.

Similar Posts

Leave a Reply

Your email address will not be published.