260 lines
9.1 KiB
Nim
260 lines
9.1 KiB
Nim
## # Tron example
|
|
##
|
|
## In this tutorial, we will create a video game based on libp2p, using
|
|
## all of the features we talked about in the last tutorials.
|
|
##
|
|
## We will:
|
|
## - Discover peers using the Discovery Manager
|
|
## - Use GossipSub to find a play mate
|
|
## - Create a custom protocol to play with him
|
|
##
|
|
## While this may look like a daunting project, it's less than 150 lines of code.
|
|
##
|
|
## The game will be a simple Tron. We will use [nico](https://github.com/ftsf/nico)
|
|
## as a game engine. (you need to run `nimble install nico` to have it available)
|
|
##
|
|
## ![multiplay](https://user-images.githubusercontent.com/13471753/198852714-b55048e3-f233-4723-900d-2193ad259fe1.gif)
|
|
##
|
|
## We will start by importing our dependencies and creating our types
|
|
import os
|
|
import nico, chronos, stew/byteutils, stew/endians2
|
|
import libp2p
|
|
import libp2p/protocols/rendezvous
|
|
import libp2p/discovery/rendezvousinterface
|
|
import libp2p/discovery/discoverymngr
|
|
|
|
const
|
|
directions = @[(K_UP, 0, -1), (K_LEFT, -1, 0), (K_DOWN, 0, 1), (K_RIGHT, 1, 0)]
|
|
mapSize = 32
|
|
tickPeriod = 0.2
|
|
|
|
type
|
|
Player = ref object
|
|
x, y: int
|
|
currentDir, nextDir: int
|
|
lost: bool
|
|
color: int
|
|
|
|
Game = ref object
|
|
gameMap: array[mapSize * mapSize, int]
|
|
tickTime: float
|
|
localPlayer, remotePlayer: Player
|
|
peerFound: Future[Connection]
|
|
hasCandidate: bool
|
|
tickFinished: Future[int]
|
|
|
|
GameProto = ref object of LPProtocol
|
|
|
|
proc new(_: type[Game]): Game =
|
|
# Default state of a game
|
|
result = Game(
|
|
tickTime: -3.0, # 3 seconds of "warm-up" time
|
|
localPlayer: Player(x: 4, y: 16, currentDir: 3, nextDir: 3, color: 8),
|
|
remotePlayer: Player(x: 27, y: 16, currentDir: 1, nextDir: 1, color: 12),
|
|
peerFound: newFuture[Connection]()
|
|
)
|
|
for pos in 0 .. result.gameMap.high:
|
|
if pos mod mapSize in [0, mapSize - 1] or pos div mapSize in [0, mapSize - 1]:
|
|
result.gameMap[pos] = 7
|
|
|
|
## ## Game Logic
|
|
## The networking during the game will work like this:
|
|
##
|
|
## * Each player will have `tickPeriod` (0.1) seconds to choose
|
|
## a direction that he wants to go to (default to current direction)
|
|
## * After `tickPeriod`, we will send our choosen direction to the peer,
|
|
## and wait for his direction
|
|
## * Once we have both direction, we will "tick" the game, and restart the
|
|
## loop, as long as both player are alive.
|
|
##
|
|
## This is a very simplistic scheme, but creating proper networking for
|
|
## video games is an [art](https://developer.valvesoftware.com/wiki/Latency_Compensating_Methods_in_Client/Server_In-game_Protocol_Design_and_Optimization)
|
|
##
|
|
## The main drawback of this scheme is that the more ping you have with
|
|
## the peer, the slower the game will run. Or invertedly, the less ping you
|
|
## have, the faster it runs!
|
|
proc update(g: Game, dt: float32) =
|
|
# Will be called at each frame of the game.
|
|
#
|
|
# Because both Nico and Chronos have a main loop,
|
|
# they must share the control of the main thread.
|
|
# This is a hacky way to make this happen
|
|
waitFor(sleepAsync(1.milliseconds))
|
|
# Don't do anything if we are still waiting for an opponent
|
|
if not(g.peerFound.finished()) or isNil(g.tickFinished): return
|
|
g.tickTime += dt
|
|
|
|
# Update the wanted direction, making sure we can't go backward
|
|
for i in 0 .. directions.high:
|
|
if i != (g.localPlayer.currentDir + 2 mod 4) and keyp(directions[i][0]):
|
|
g.localPlayer.nextDir = i
|
|
|
|
if g.tickTime > tickPeriod and not g.tickFinished.finished():
|
|
# We choosen our next direction, let the networking know
|
|
g.localPlayer.currentDir = g.localPlayer.nextDir
|
|
g.tickFinished.complete(g.localPlayer.currentDir)
|
|
|
|
proc tick(g: Game, p: Player) =
|
|
# Move player and check if he lost
|
|
p.x += directions[p.currentDir][1]
|
|
p.y += directions[p.currentDir][2]
|
|
if g.gameMap[p.y * mapSize + p.x] != 0: p.lost = true
|
|
g.gameMap[p.y * mapSize + p.x] = p.color
|
|
|
|
proc mainLoop(g: Game, peer: Connection) {.async.} =
|
|
while not (g.localPlayer.lost or g.remotePlayer.lost):
|
|
if g.tickTime > 0.0:
|
|
g.tickTime = 0
|
|
g.tickFinished = newFuture[int]()
|
|
|
|
# Wait for a choosen direction
|
|
let dir = await g.tickFinished
|
|
# Send it
|
|
await peer.writeLp(toBytes(uint32(dir)))
|
|
|
|
# Get the one from the peer
|
|
g.remotePlayer.currentDir = int uint32.fromBytes(await peer.readLp(8))
|
|
# Tick the players & restart
|
|
g.tick(g.remotePlayer)
|
|
g.tick(g.localPlayer)
|
|
|
|
## We'll draw the map & put some texts when necessary:
|
|
proc draw(g: Game) =
|
|
for pos, color in g.gameMap:
|
|
setColor(color)
|
|
boxFill(pos mod 32 * 4, pos div 32 * 4, 4, 4)
|
|
let text = if not(g.peerFound.finished()): "Matchmaking.."
|
|
elif g.tickTime < -1.5: "Welcome to Etron"
|
|
elif g.tickTime < 0.0: "- " & $(int(abs(g.tickTime) / 0.5) + 1) & " -"
|
|
elif g.remotePlayer.lost and g.localPlayer.lost: "DEUCE"
|
|
elif g.localPlayer.lost: "YOU LOOSE"
|
|
elif g.remotePlayer.lost: "YOU WON"
|
|
else: ""
|
|
printc(text, screenWidth div 2, screenHeight div 2)
|
|
|
|
|
|
## ## Matchmaking
|
|
## To find an opponent, we will broadcast our address on a
|
|
## GossipSub topic, and wait for someone to connect to us.
|
|
## We will also listen to that topic, and connect to anyone
|
|
## broadcasting his address.
|
|
##
|
|
## If we are looking for a game, we'll send `ok` to let the
|
|
## peer know that we are available, check that he is also available,
|
|
## and launch the game.
|
|
proc new(T: typedesc[GameProto], g: Game): T =
|
|
proc handle(conn: Connection, proto: string) {.async, gcsafe.} =
|
|
defer: await conn.closeWithEof()
|
|
if g.peerFound.finished or g.hasCandidate:
|
|
await conn.close()
|
|
return
|
|
g.hasCandidate = true
|
|
await conn.writeLp("ok")
|
|
if "ok" != string.fromBytes(await conn.readLp(1024)):
|
|
g.hasCandidate = false
|
|
return
|
|
g.peerFound.complete(conn)
|
|
# The handler of a protocol must wait for the stream to
|
|
# be finished before returning
|
|
await conn.join()
|
|
return T(codecs: @["/tron/1.0.0"], handler: handle)
|
|
|
|
proc networking(g: Game) {.async.} =
|
|
# Create our switch, similar to the GossipSub example and
|
|
# the Discovery examples combined
|
|
let
|
|
rdv = RendezVous.new()
|
|
switch = SwitchBuilder.new()
|
|
.withRng(newRng())
|
|
.withAddresses(@[ MultiAddress.init("/ip4/0.0.0.0/tcp/0").tryGet() ])
|
|
.withTcpTransport()
|
|
.withYamux()
|
|
.withNoise()
|
|
.withRendezVous(rdv)
|
|
.build()
|
|
dm = DiscoveryManager()
|
|
gameProto = GameProto.new(g)
|
|
gossip = GossipSub.init(
|
|
switch = switch,
|
|
triggerSelf = false)
|
|
dm.add(RendezVousInterface.new(rdv))
|
|
|
|
switch.mount(gossip)
|
|
switch.mount(gameProto)
|
|
|
|
gossip.subscribe(
|
|
"/tron/matchmaking",
|
|
proc (topic: string, data: seq[byte]) {.async.} =
|
|
# If we are still looking for an opponent,
|
|
# try to match anyone broadcasting it's address
|
|
if g.peerFound.finished or g.hasCandidate: return
|
|
g.hasCandidate = true
|
|
|
|
try:
|
|
let
|
|
(peerId, multiAddress) = parseFullAddress(data).tryGet()
|
|
stream = await switch.dial(peerId, @[multiAddress], gameProto.codec)
|
|
|
|
await stream.writeLp("ok")
|
|
if (await stream.readLp(10)) != "ok".toBytes:
|
|
g.hasCandidate = false
|
|
return
|
|
g.peerFound.complete(stream)
|
|
# We are "player 2"
|
|
swap(g.localPlayer, g.remotePlayer)
|
|
except CatchableError as exc:
|
|
discard
|
|
)
|
|
|
|
await switch.start()
|
|
defer: await switch.stop()
|
|
|
|
# As explained in the last tutorial, we need a bootnode to be able
|
|
# to find peers. We could use any libp2p running rendezvous (or any
|
|
# node running tron). We will take it's MultiAddress from the command
|
|
# line parameters
|
|
if paramCount() > 0:
|
|
let (peerId, multiAddress) = paramStr(1).parseFullAddress().tryGet()
|
|
await switch.connect(peerId, @[multiAddress])
|
|
else:
|
|
echo "No bootnode provided, listening on: ", switch.peerInfo.fullAddrs.tryGet()
|
|
|
|
# Discover peers from the bootnode, and connect to them
|
|
dm.advertise(RdvNamespace("tron"))
|
|
let discoveryQuery = dm.request(RdvNamespace("tron"))
|
|
discoveryQuery.forEach:
|
|
try:
|
|
await switch.connect(peer[PeerId], peer.getAll(MultiAddress))
|
|
except CatchableError as exc:
|
|
echo "Failed to dial a peer: ", exc.msg
|
|
|
|
# We will try to publish our address multiple times, in case
|
|
# it takes time to establish connections with other GossipSub peers
|
|
var published = false
|
|
while not published:
|
|
await sleepAsync(500.milliseconds)
|
|
for fullAddr in switch.peerInfo.fullAddrs.tryGet():
|
|
if (await gossip.publish("/tron/matchmaking", fullAddr.bytes)) == 0:
|
|
published = false
|
|
break
|
|
published = true
|
|
|
|
discoveryQuery.stop()
|
|
|
|
# We now wait for someone to connect to us (or for us to connect to someone)
|
|
let peerConn = await g.peerFound
|
|
defer: await peerConn.closeWithEof()
|
|
|
|
await g.mainLoop(peerConn)
|
|
|
|
let
|
|
game = Game.new()
|
|
netFut = networking(game)
|
|
nico.init("Status", "Tron")
|
|
nico.createWindow("Tron", mapSize * 4, mapSize * 4, 4, false)
|
|
nico.run(proc = discard, proc(dt: float32) = game.update(dt), proc = game.draw())
|
|
waitFor(netFut.cancelAndWait())
|
|
|
|
## And that's it! If you want to run this code locally, the simplest way is to use the
|
|
## first node as a boot node for the second one. But you can also use any rendezvous node
|