-
Notifications
You must be signed in to change notification settings - Fork 4
Space Shooter Tutorial
This tutorial will show you how to use the NEAR SDK in an existing space shooter game.
You can check the final result here.
- Clone the project from here: https://github.com/svntax/near-space-shooter
- Open the project using the C# Mono version of Godot 3.5.1
- Have Godot generate the C# project files by adding a C# script (you can delete it afterwards). See here for more info.
You should now have a simple space shooter game working. The controls for the game are all mouse/touch only. Click or touch to press buttons and to shoot.
The smart contract we'll be using can be found here: https://github.com/svntax/near-high-scores-tracking. You can deploy your own copy of the smart contract and use its name in place of the one being used in this tutorial (see here for more info on deploying contracts). Using dev-deploy
is recommend while the game is still being developed.
The contract has two methods, a view method getScores()
, and a change method submitScore()
. The getScores()
method returns an unsorted array of score objects, each which has a username string, and a score integer. The submitScore()
method is used to submit your score to the contract, and then the contract will check if the score is high enough to enter the top 10 placements.
The project already comes with the NEAR SDK, so the rest of the tutorial will be about implementation.
The game is missing a high scores screen, so let's add one. First, we can duplicate one of the existing buttons in the main menu, rename it, and set up a new function in MainMenu.gd
for its pressed()
signal.
Next, let's create a new scene for the high scores screen, name it HighScoresMenu
, and save it in the root project folder, the same place where the main menu scene is saved. We'll also create a new script for it with the same name, which later on will handle fetching the high scores from NEAR.
Now we can go back to the MainMenu.gd
script, and make the game change to this new scene when the high scores button is pressed. The script should now look something like the following:
extends Node2D
func _on_StartButton_pressed():
get_tree().change_scene("res://Gameplay.tscn")
func _on_ExitButton_pressed():
get_tree().quit()
func _on_HighScoresButton_pressed():
get_tree().change_scene("res://HighScoresMenu.tscn")
We can start setting up the high scores menu by adding backgrounds, text labels, buttons, and other necessary UI elements. At this point, it's up to you how you want to set up the menu. As long as there's a button to go back and a table or container of some sort with rows for the high scores, the setup will work fine.
Here is how I'll be setting up the scene:
I copied the background and header label from the main menu, added a button for going back to the main menu, and then added a new GridContainer
node with 2 columns and two placeholder Label
nodes for the player's name and score. These label nodes have their horizontal size flags set to Fill and Expand in order to make them take up the entire width of the column. The player label is left-aligned, and the score label is right-aligned.
Now we can start working on the high scores script. To start, we need to connect to the NEAR network. In our MainMenu.gd
script, let's check if a connection exists, and if not, connect to the testnet.
extends Node2D
var config = {
"network_id": "testnet",
"node_url": "https://rpc.testnet.near.org",
"wallet_url": "https://wallet.testnet.near.org",
}
func _ready():
if Near.near_connection == null:
Near.start_connection(config)
func _on_StartButton_pressed():
get_tree().change_scene("res://Gameplay.tscn")
func _on_ExitButton_pressed():
get_tree().quit()
func _on_HighScoresButton_pressed():
get_tree().change_scene("res://HighScoresMenu.tscn")
Then in our high scores menu script, we'll call the getScores()
method from our smart contract to retrieve the high scores. The return value will be a JSON string, which when parsed, will be an array of dictionaries, each with a username
and value
field. Our strategy here is to use the placeholder label nodes as templates by hiding them, duplicating them and setting their respective values for each score in the array, and at the end, add them to the grid container node.
The array of scores comes unsorted, so we need to sort them first. For simplicity, and because we're dealing with a very small array, we'll use selection sort.
The HighScoresMenu.gd
script should now look something like the following, including a new function for the back button:
extends Node2D
const CONTRACT_NAME = "name of your smart contract here"
onready var scores_grid = $ScoresGrid
onready var player_name_label = $ScoresGrid/PlayerName
onready var player_score_label = $ScoresGrid/PlayerScore
func _ready():
# First, hide the placeholder labels
player_name_label.hide()
player_score_label.hide()
# Next, fetch the high scores and create new labels for each score
var result = Near.call_view_method(CONTRACT_NAME, "getScores")
if result is GDScriptFunctionState:
result = yield(result, "completed")
if result.has("error"):
pass # Error handling here
else:
var data = result.data
var json_data = JSON.parse(data)
var high_scores: Array = json_data.result
# Selection sort
for i in high_scores.size() - 1:
var indexOfLargest = i
for j in range(i+1, high_scores.size()):
if high_scores[j].value > high_scores[indexOfLargest].value:
indexOfLargest = j
if indexOfLargest != i:
# Swap
var temp = high_scores[i]
high_scores[i] = high_scores[indexOfLargest]
high_scores[indexOfLargest] = temp
for score in high_scores:
var name_label = player_name_label.duplicate()
name_label.set_text(score.username)
scores_grid.add_child(name_label)
name_label.show()
var score_label = player_score_label.duplicate()
score_label.set_text(str(score.value))
scores_grid.add_child(score_label)
score_label.show()
func _on_BackButton_pressed():
get_tree().change_scene("res://MainMenu.tscn")
Now when you run the game and go to the high scores screen, you should see something like this:
If for any reason, the game fails to retrieve the high scores, we can display a message in the middle telling the user. To do so, you can add a new Label
node, place it in the middle, set its text to a message like "Error: Failed to get high scores", and hide it by default. Then, in our script, show this label if an error occurred.
...
if result.has("error"):
$MessageLabel.show()
else:
...
In order to let users sign in with their NEAR wallet, we can add a login button in the main menu with that functionality.
First, let's duplicate one of the existing buttons, drag it outside of the Buttons container, move it to a corner, rename it, and set up a new function for its pressed()
signal. We can also add a new Label
node to display the user's name when logged in.
Next, in our MainMenu.gd
script, after connecting to the NEAR network, we have to create a new WalletConnection
, set up functions for the user_signed_in
and user_signed_out
signals, and make sure the login button and username label have the correct text set if the user is already logged in.
extends Node2D
onready var login_button = $LoginButton
onready var player_name_label = $PlayerNameLabel
var config = {
"network_id": "testnet",
"node_url": "https://rpc.testnet.near.org",
"wallet_url": "https://wallet.testnet.near.org",
}
var wallet_connection
func _ready():
player_name_label.hide()
if Near.near_connection == null:
Near.start_connection(config)
wallet_connection = WalletConnection.new(Near.near_connection)
wallet_connection.connect("user_signed_in", self, "_on_user_signed_in")
wallet_connection.connect("user_signed_out", self, "_on_user_signed_out")
if wallet_connection.is_signed_in():
_on_user_signed_in(wallet_connection)
func _on_user_signed_in(wallet: WalletConnection):
login_button.set_text("Sign Out")
player_name_label.show()
player_name_label.set_text(wallet.get_account_id())
func _on_user_signed_out(wallet: WalletConnection):
login_button.set_text("Sign In")
player_name_label.hide()
func _on_StartButton_pressed():
get_tree().change_scene("res://Gameplay.tscn")
func _on_ExitButton_pressed():
get_tree().quit()
func _on_HighScoresButton_pressed():
get_tree().change_scene("res://HighScoresMenu.tscn")
func _on_LoginButton_pressed():
pass # Replace with function body.
Then, we can crate a new constant at the top of the script for the smart contract's name, and in _on_LoginButton_pressed()
, call either sign_in()
or sign_out()
depending on whether the user is signed in or not.
const CONTRACT_NAME = "name of your smart contract here"
# ... other code ...
func _on_LoginButton_pressed():
if wallet_connection.is_signed_in():
wallet_connection.sign_out()
else:
wallet_connection.sign_in(CONTRACT_NAME)
Now, when you run the game and click on "Sign In", you'll be redirected to the NEAR web wallet, where you can authorize the game to create a new access key, and once you're done, you can close the browser tab and go back to the game.
To submit your score to the smart contract, in the Gameplay.gd
script, we'll need to create another WalletConnection
object here. Note that WalletConnection
is mainly a class to represent a potential connection to the NEAR network through a wallet. What this means is, we can technically create a new WalletConnection
object anywhere in the game, and it will automatically know whether the user is signed in or not because the access key is stored locally in a config file, and WalletConnection
looks for the key there.
Let's update Gameplay.gd
to have a new wallet connection, and when the player has lost all lives, check if the player is logged in, and if so, call the submitScore()
method on the smart contract.
extends Node2D
const CONTRACT_NAME = "name of your smart contract here"
onready var game_ui = $UILayer/GameUI
onready var animation_player = $AnimationPlayer
onready var spawn_system = $SpawnSystem
onready var player = $Player
var wallet_connection
func _ready():
wallet_connection = WalletConnection.new(Near.near_connection)
game_ui.set_lives(3)
game_ui.set_score(0)
animation_player.play("intro")
func _on_Player_life_lost(remaining_lives: int):
if remaining_lives < 0:
animation_player.play("game_over")
spawn_system.stop_spawning()
if wallet_connection.is_signed_in():
var args = {"newScore": game_ui.score}
wallet_connection.call_change_method(CONTRACT_NAME, "submitScore", args)
else:
game_ui.remove_life()
func _on_AnimationPlayer_animation_finished(anim_name):
if anim_name == "intro":
spawn_system.start_spawning()
player.state = player.States.ALIVE
Note that the argument name must match the parameter name on the smart contract, which is "newScore" in this case.
At this point, you should now have a working NEAR space shooter game! You can check out the finished version of the game with NEAR integration here: https://github.com/svntax/near-space-shooter/tree/complete-version (remove the tutorial_images
folder after downloading/cloning).
Now there are a couple of things to point out:
- Instead of having to create a new
WalletConnection
object every time we need one, you can also create a new global singleton, save oneWalletConnection
instance there, and that way you can access it anywhere in your code. - Similarly, you can have a global constant for the smart contract's name to access anywhere as needed.
- When your game is ready to be published officially, you can change the values of the config object passed to
Near.start_connection()
to connect to mainnet instead of testnet. -
(IMPORTANT): If exporting to Android, make sure the
Internet
permission is on. The SDK requires internet permissions because when a user is redirected to the NEAR web wallet, the SDK creates a temporary local HTTP server in order to capture the redirect that happens after you've authorized the game. - Technically, there is no "server-side" verification being done when a user submits a score, so you will have to decide how you want to handle that in your game. Furthermore, the smart contract has a max of 10 entries for the high scores, which you can change if you want, but you'll have to update the high scores screen to account for that.