We explain an approach on how to connect your smartphone to a 3D web game. We'll be making a car which you can control by tilting your phone (using the phone's accelerometer). We'll use the JavaScript librarythree.jsfor working with WebGL as well asWebSocketsthrough thesocket.iolibrary and a few other web technologies.
Try it now
Here's the live demo you can play with right now. Note that it works best on WiFi.
Getting Set Up
You are going to need to install Node if you haven't already. We are going to be using Express to set up our server, andsocket.io for the WebSocket communication.
Create a directory for this project, and put thispackage.json at the root:
Now open up your project directory in terminal and install your project dependencies with this command:
npm install
This looks in the package.json file and uses thedevDependencies object to install the correct dependencies and versions of those dependencies. NPM uses Semvar versioning notation, and "*" means "latest".
In order to use socket.io we need to set up a server. This can be done using Express. First, let's serve up an index file. Create a file that will contain our server code. Call itserver.js:
var express =require('express'), http =require('http'), app =express(), server = http.createServer(app), port =8080;
server.listen(port);
app
// Set up index .get('/',function(req, res){
res.sendFile(__dirname +'/index.html');
});
// Log that the servers running console.log("Server running on port: "+ port);
This sets up a server running on port :8080. When the root is requested ("/") it will send the `index.html` file in the response.
This sets up socket.io using the server and logs when new clients connect.
Since server code has changed, we will have to re-run the server. Press "Ctrl + C" in the terminal to cancel the current process. This will have to be done each time server.js is updated.
We should now see the alert notifying us of a successful socket.io connection and the terminal should log "Client connected!" almost instantly.
Connecting the Phone
Now we will connect a browser window on a phone (the controller for the car) to a desktop browser (the game). Here's how that will work:
The game client will tell the server it wants to connect as a game
The server will then store that game socket and then tell the game client it's connected
The game client will then create a URL using its socket ID as a URL parameter
The phone (or any other tab/window) will then go to this link and tell the server it wants to connect as a controller to the game socket with the ID in its URL
The server will then store that controller socket along with the ID of the game socket it's connecting to
The server then assigns that controller socket's ID to the relevant game socket object
The server then tells that game socket it has a controller connected to it and tells the controller socket it has connected
The relevant game socket and controller socket will then alert()
Let's get the game client telling the server it's connecting as a game client. In place of the alert(), add:
io.emit('game_connect');
This emits an event we've named game_connect. The server can then listen for this event and store the socket and send a message back to the client to tell it that it's connected. So, add this as a new global variable:
The controller_id will be populated with the socket ID of the controller connected to this game, when it connects.
Now restart the server and refresh the client. The terminal should now be logging the game connections.
Game connected
Now that the server emits an event called game_connectedspecifically to that socket (which emitted thegame_connect), the client can then listen for this and create the URL:
var game_connected =function(){ var url ="http://x.x.x.x:8080?id="+ io.id; document.body.innerHTML += url; io.removeListener('game_connected', game_connected); };
io.on('game_connected', game_connected);
Replace x.x.x.x with your actual IP. To get this, you can use `ifconfig` from your Mac/Linux terminal or `ipconfig` in the Windows command prompt. This is the IPv4 address.
When you restart the server and go to the client, a URL for your IP on port :8080 with an ID parameter on the end should be present.
Great! When that URL is copied into another tab (or manually entered into a phone) nothing else happens other than creating another URL. That's not what we want. We want it so that when this URL (with the ID parameter) is navigated to, the client should recognize that it has this parameter and tell the server to connect as a controller instead.
So, wrap everything inside of the io.on('connect', function() { in the else of this:
if(window.location.href.indexOf('?id=')>0){
alert("Hey, you're a controller trying to connect to: "+ window.location.href.split('?id=')[1]);
}else{
// In here
}
Load up the client and when you navigate to a created URL, it will alert you that you are trying to connect as a controller, rather than a game. Here we will connect it to the server and link it up with the relevant game socket.
This emits an event called controller_connect and sends the ID, which we have in the URL, to the server. Now the server can listen for this event, store the controller socket, and connect it to the relevant game. First we need a global variable to store the controller sockets:
var controller_sockets ={};
Inside io.sockets.on('connection', function (socket) { } add this:
console.log("Controller attempted to connect but failed");
socket.emit("controller_connected",false); }
});
This checks whether there is a game with that ID and confirms that it doesn't have a controller connected to it already. The server emits an event on the controller socket called controller_connected and is sent a boolean depending on this. Its success is also console.log()'d. If the check is successful then the new controller socket is stored along with the ID of the game it is connecting to. The controller socket ID is also set on the relevant existing game socket item.
Now the terminal should show when a controller is connected to the game. If we attempt to connect a second controller, it will fail (due to the second part of the validation).
Server running on port: 8080 Game connected Controller connected Controller attempted to connect but failed
Also, if we edit the URL and try to connect to a random ID'd game http://x.x.x.x:8080/?id=RANDOMID, it will fail as there isn't a game with that ID (first part of the validation). This will also happen if we fail to start up the game.
The controller client can now listen for this 'controller_connected' event and give a message depending on its success:
io.on('controller_connected',function(connected){
if(connected){
alert("Connected!");
}else{
alert("Not connected!"); }
});
Disconnecting
Now, the check to see if a game exists will work even if the game tab is closed before connecting the controller, since we haven't put in socket disconnection events. So, let's do that by adding this to the server code:
This checks whether the ID of the disconnected socket exists in the game or controller collection. It then uses the connected ID property ("game_id" if the socket is a controller, "controller_id" if the socket is a game) to notify the relevant socket of the disconnection, and removes it from the relevant socket reference. The socket disconnecting is then deleted. This means controllers cannot connect to games that have been closed down.
Now when a tab that is connected as a controller to a game is closed down, this should be in the terminal:
Controller disconnected
Adding a QR Code
If you have been manually typing the controller URL into your phone, you will be glad to know that it's time to put in a QR code generator. We will be using this QR code generator.
Now in the else of where we check if the URL contains a parameter (where we emit game_connect) add this:
var qr = document.createElement('div');
qr.id ="qr";
document.body.appendChild(qr);
This creates an element with an ID of "qr" and appends it to the body. Now where we are currently writing the URL into the body,replace document.body.innerHTML += url; with:
var qr_code =newQRCode("qr"); qr_code.makeCode(url);
This creates a QR Code (using the library) inside using our newly made div with the ID of qr from the provided URL.
Now refresh! Cool, huh?
The QR code is still present, even when the controller has connected. So, let's fix that by adding this in the else(where we are doing the game code):
io.on('controller_connected',function(connected){
if(connected){
qr.style.display ="none";
}else{
qr.style.display ="block";
}
});
This alters the CSS of the QR code element when thecontroller_connected event is received. Now refresh! The QR code should now hide and show depending on the controller's connectivity. Try disconnecting the controller.
Note: Your phone will have to be on the same internet connection as your computer. If you are seeing a 504 error on your phone, try adjusting your firewall settings.
Building the Car and the Floor
Good news. The hard part is all done! Now let's have some fun with 3D.
First, the server must be able to serve up static files as we will be loading in a car model. In server.js add this anywhere in the "global" scope:
Now to get the three scene set up. After where we declare the game_connected function, we have a bunch of config to add:
var renderer =newTHREE.WebGLRenderer({ antialias:true }), scene =newTHREE.Scene(), camera =newTHREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight,1,10000),
// Make the light able to cast shadows directional_light.castShadow =true;
// Rotates the floor 90 degrees, so that it is horizontal floor.rotation.x =-90*(Math.PI /180)
// Make the floor able to recieve shadows floor.receiveShadow =true;
// Add camera, lights and floor to the scene scene.add(camera); scene.add(ambient_light); scene.add(directional_light); scene.add(floor);
// Load the car model loader.load( 'public/car.js',
function( geometry, materials ){
// Create the mesh from loaded geometry and materials var material =newTHREE.MeshFaceMaterial( materials ); car =newTHREE.Mesh( geometry, material );
// Can cast shadows car.castShadow =true;
// Add to the scene scene.add( car ); } )
// Set size of renderer using window dimensions renderer.setSize(window.innerWidth, window.innerHeight);
// Append to DOM document.body.appendChild(renderer.domElement);
// This sets off the render loop render();
This sets up the 3D scene, with a THREE plane mesh as a floor, two lights (one ambient and one directional, to cast shadows) and a loaded car model which rotates everyrequestAnimationFrame.
In detail, this declares the THREE components needed, positions the camera "up" and "back" a bit and rotates the camera using the .lookAt method which accepts a Vector3. The directional light is then positioned and instructed to cast shadows. This will make the light interact with meshes that have their castShadow orreceiveShadow property set to true.
In this case we want the directional light and the car to cast shadows, and the floor to receive shadows.
The floor is rotated -90 degrees to make it "horizontal" to the camera and is set to receive shadows.
The camera, the lights and the floor are added to the scene. The renderer is set to the dimensions of the window and put into the DOM.
The car model is then requested, using the JSONLoader that comes with THREE, from the public directory. The callback function (which is fired once the model file is loaded) returns the geometry and the materials of the model, which is then used to create a mesh. It is set to cast shadows and is added to the scene.
Finally the render() loop is fired which renders (using the render method on the renderer) the scene using the camera, rotates the car if it has loaded (this is so that we know that the render loop is working correctly) and calls itself onrequestAnimationFrame.
Now that the 3D scene is ready and the controller connection is working, it's time to tie the two together. First, let's create the events for the controller instance. In 'controller_connected', after the alert, add this:
document.body.addEventListener('touchstart', touchstart,false);// iOS & Android document.body.addEventListener('MSPointerDown', touchstart,false);// Windows Phone document.body.addEventListener('touchend', touchend,false);// iOS & Android document.body.addEventListener('MSPointerUp', touchend,false);// Windows Phone window.addEventListener('devicemotion', devicemotion,false);
This creates a controller_state object, and attaches events to the document body and the window. Theaccelerate property toggles between true and false ontouchstart and touchend (the Windows Phone equivalent events are MSPointerDown and MSPointerUp). The steer property stores the tilt value of the phone ondevicemotion.
In each of these functions, a custom event (controller_state_change) is emitted containing the current state of the controller.
Now that the controller client is sending its state when changed, the server needs to pass this information onto the relevant game. Add this into `server.js`, where a controller has successfully connected, so after:
// Forward the changes onto the relative game socket socket.on('controller_state_change',function(data){
if(game_sockets[game_socket_id]){
// Notify relevant game socket of controller state change game_sockets[game_socket_id].socket.emit("controller_state_change", data) }
});
Now that the server is forwarding the controller data on to the relevant game socket, it's time to get the game client to listen for this and use the data. First we need acontroller_state variable on the scope of the game instance so that it is accessible in the render (this will act as our gameloop). After where we declare a car placeholder, add this:
var speed =0, controller_state ={};
After the controller_connected listener in the game scope, add this:
// When the server sends a changed controller state update it in the game io.on('controller_state_change',function(state){
controller_state = state;
});
This is the listener for when the server sends a new controller state, it updates the game's controller state when the server sends a new one.
The game now has the controller state changing when the controller connected touches the screen and tilts the phone, but we aren't using this data yet, replace:
if(car) car.rotation.y +=0.01;
with:
if(car){
// Rotate car if(controller_state.steer){
// Gives a number ranging from 0 to 1 var percentage_speed =(speed /2);
// Rotate the car using the steer value // Multiplying it by the percentage speed makes the car not turn // unless accelerating and turns quicker as the speed increases. car.rotateY(controller_state.steer * percentage_speed); }
// If controller is accelerating if(controller_state.accelerate){
// Add to speed until it is 2 if(speed <2){ speed +=0.05; }else{ speed =2; }
// If controller is not accelerating }else{
// Subtract from speed until 0 if(0< speed){ speed -=0.05; }else{ speed =0; } }
// Move car "forward" at speed car.translateZ(speed);
This rotates the car using the (if present) steer property from the controller state, it is multiplied by a speed percentage (current speed/max speed), so that the car doesn't rotate when stationary and the car rotates gradually quicker as the car's speed increases. The car moves forward using speed, which gradually increases or decreases depending on the accelerate property on the controller state.
The last part is for collisions, to stop the car going "off the floor", for the sake of this demo it is hard coded so that the car's x position stays between -150 and 150, the same goes for z.
0 comments:
Post a Comment