// Host and Client (hC) Module // Version 2.1.05 (10:47 PM Thu July 26, 2018) // Written by: James D. Miller // This module is dependent on gwModule.js (referenced here as gW). var hC = (function() { // To insist on tighter code: e.g. globals, etc... "use strict"; // A few globals within hC. ///////////////////////////////////////////////// var socket = null; var nodeServerURL, serverArray; var chatStyleToggle = true; var timer = {}; timer.start = null; timer.end = null; timer.pingArray = []; var clientCanvas, ctx; var clientCanvas_tt, ctx_tt; var videoMirror, videoStream; var chkRequestStream, chkLocalCursor; var chkTwoThumbs, btnTwoThumbs, twoThumbs; var btnFullScreen; var chkPlayer; var myRequest; // Key values. var keyMap = {'49':'1', '50':'2', '51':'3', '52':'4', '53':'5', '54':'6', '55':'7', '56':'8', '57':'9', '70':'f', '65':'a', '83':'s', '68':'d', '87':'w', '74':'j', '75':'k', '76':'l', '73':'i', '16':'sh','32':'sp', //sh:shift, sp:space '191':'cl'}; // cl (short for color), 191 is the question-mark key. // Mouse and keyboard (mK) from non-host clients. var mK = {}; mK.name = null; // Key values, cso (client side only) for use only by the client, not to be sent over network // to the host. var keyMap_cso = {'16':'key_shift', '17':'key_ctrl', '27':'key_esc', '80':'key_p'} var mK_cso = {}; // The client name of this user. This global is only used on the client page and // is some increment of u1, u2, etc for network clients. var newClientName = null; // cl is a global that points at a "Client" or clientlike object. For the host, this will point at the // "Client" object of the client that most recently attempts to connect. On the client page, // this will simply keep this structure and replace the 'notyetnamed' with the name of that // client. var cl = {'name':null, 'previous_name':null}; var rtc_choke = false; var fileName = "hostAndClient.js"; // Pacifier (connecting status) string for connecting... var pacifier = {}; // Switch to enable debugging... var db = {}; // ...of the WebRTC stuff. db.rtc = false; var gameReportCounter = 0; ////////////////////////////////////////////////// // Object prototypes ////////////////////////////////////////////////// function RTC( pars) { this.user1 = setDefault( pars.user1, null); this.user2 = setDefault( pars.user2, null); this.streamRequested = setDefault( pars.streamRequested, null); this.pc = null; this.dataChannel = null; } RTC.prototype.shutdown = function() { //console.log('pc:'+JSON.stringify(this.pc)); //console.log('dataChannel:'+JSON.stringify(this.dataChannel)); // Close then nullify any references to the datachannel and the p2p connection. if (this.dataChannel) { this.dataChannel.close(); } if (this.pc) { var senders = this.pc.getSenders(); if (senders.length >= 1) { //console.log('senders length = ' + senders.length); this.pc.removeTrack( senders[0]); senders = this.pc.getSenders(); //console.log('senders length = ' + senders.length); } this.pc.close(); } if (this.dataChannel) { this.dataChannel = null; } if (this.pc) { this.pc = null; } } // This method works only on the host side of the WebRTC connection. So, that's why there's a check here // to see if user1 is the host. RTC.prototype.turnVideoStreamOff = function() { if (this.pc && (this.user1 == 'host')) { var senders = this.pc.getSenders(); //console.log('senders length (before) = ' + senders.length); if (senders.length >= 1) { this.pc.removeTrack( senders[0]); //senders = this.pc.getSenders(); //console.log('senders length (after) = ' + senders.length); } } } function TwoThumbs( pars) { // Not yet using this adjustment point feature (TBD). this.adjustmentPoint_2d = new gW.Vec2D(0, 0); this.enabled = false; // Grid of rectangles. this.grid = { 'jet_360': {'active':false , 'mK':'w', 'UL':null, 'LR':null, 'dir_2d':null}, 'gun_360': {'active':false , 'mK':'i', 'UL':null, 'LR':null, 'dir_2d':null}, 'shield': {'active':false , 'mK':'sp', 'UL':null, 'LR':null}, 'color': {'active':false , 'mK':'cl', 'UL':null, 'LR':null}, 'alt': {'active':false , 'mK':null, 'UL':null, 'LR':null}, // Controls that are dependent on the alt rectangle being touched. 'esc': {'active':false , 'mK':null, 'UL':null, 'LR':null}, 'demo7': {'active':false , 'mK':'7', 'UL':null, 'LR':null}, 'demo8': {'active':false , 'mK':'8', 'UL':null, 'LR':null}, 'freeze': {'active':false , 'mK':'f', 'UL':null, 'LR':null}, // Secondary control that fires the gun. Changes angle by controlling the rotation rate. 'gun_scope': {'active':false , 'mK':'ScTr','UL':null, 'LR':null} }; // This is the same for both the jet and the gun. this.dirDotRadius_fraction = 0.020; // Control radius in units of screen fraction. The jet has four // strength levels: <1, >1 && <2, >2 && <3, >3. this.grid['jet_360'].cRadius_1_f = 0.090; this.grid['jet_360'].cRadius_2_f = 0.130; this.grid['jet_360'].cRadius_3_f = 0.170; this.jetRadiusColor_3 = "rgb(255, 0, 0)"; this.jetRadiusColor_2 = "rgb(200, 0, 0)"; this.jetRadiusColor_1 = "rgb(140, 0, 0)"; this.jetRadiusColor_0 = "rgb( 50, 0, 0)"; // The gun has zero level, for bluffing. All touches outside that ring // are firing. this.grid['gun_360'].cRadius_0_f = 0.060; this.gunRadiusColor_0 = "rgb(255, 0, 0)"; this.bgColor = 'lightgray'; this.gridColor = '#232323'; // very dark gray // #008080 dark green clientCanvas_tt.style.borderColor = this.gridColor; // 0.10 uses 10% of the rectangle width for the dead spot. this.scopeShootSpot = 0.20; this.updateAndDrawTouchGrid('updateOnly'); } // Calculate point position in canvas coordinates as a function of fractional position. TwoThumbs.prototype.absPos_x_px = function( fraction) { return Math.round(fraction * clientCanvas_tt.width); } TwoThumbs.prototype.absPos_y_px = function( fraction) { return Math.round(fraction * clientCanvas_tt.height); } TwoThumbs.prototype.resetRectangle = function( rectName) { var rect = this.grid[ rectName]; // The alt rectangle cases: if ((rectName =='esc' || rectName =='demo7' || rectName =='demo8' || rectName =='freeze')) { if (this.grid['alt'].active) { this.updateDirectionDot( rectName, this.gridColor); } else { this.updateDirectionDot( rectName, this.bgColor); } // The others... } else { if ((rectName == 'alt') || (rectName == 'shield')) { this.updateDirectionDot( rectName, this.gridColor); } else if (rectName == 'color') { if (cl.name) this.colorClientRect( clientColor( cl.name)); } else if (rectName == 'jet_360') { this.updateDirectionDot( rectName, this.gridColor); mK.jet_d = null; // jet angle in degrees } else if (rectName == 'gun_360') { this.updateDirectionDot( rectName, this.gridColor); mK.gun_d = null; // gun angle in degrees } else if (rectName == 'gun_scope') { this.updateDirectionDot( rectName, this.gridColor); // Rotation rate fraction. mK.ScRrf = 0.00; } } // For all rectangles: deactivate and reset the primary mK attribute for that square. rect.active = false; if (rect.mK) mK[rect.mK] = 'U'; } TwoThumbs.prototype.processMultiTouch = function( touchVectors_2d_px) { for (var rectName in this.grid) { var rect = this.grid[ rectName]; var atLeastOnePointInRect = false; for (var i = 0, len = touchVectors_2d_px.length; i < len; i++) { var p_2d = touchVectors_2d_px[i]; if ( (p_2d.x > rect.UL.x) && (p_2d.x < rect.LR.x) && (p_2d.y > rect.UL.y) && (p_2d.y < rect.LR.y) ) { this.updateRectangle( rectName, p_2d); atLeastOnePointInRect = true; break; } } if (!atLeastOnePointInRect) { this.resetRectangle( rectName); } } handle_sending_mK_data( mK); } TwoThumbs.prototype.processSingleTouchRelease = function( touchVector_2d_px) { for (var rectName in this.grid) { var rect = this.grid[ rectName]; var p_2d = touchVector_2d_px; if ( (p_2d.x > rect.UL.x) && (p_2d.x < rect.LR.x) && (p_2d.y > rect.UL.y) && (p_2d.y < rect.LR.y) ) { this.resetRectangle( rectName); // When you release the alt rectangle, show as sleeping (not listening), // those rectangles that are dependent on the alt rectangle. if (rectName == 'alt') { this.updateDirectionDot('esc', this.bgColor); this.updateDirectionDot('demo7', this.bgColor); this.updateDirectionDot('demo8', this.bgColor); this.updateDirectionDot('freeze',this.bgColor); } break; } } } TwoThumbs.prototype.updateDirectionDot = function( rectName, dirDotColor) { var rect = this.grid[ rectName]; // Draw a square over the prior direction dot. This prevents a jagged edge when the direction // dot is drawn. And is a little more efficient then drawing the whole rectangle for each update. ctx_tt.fillStyle = this.bgColor; // The eraser rectangle is one pixel larger than the dot on each side. // -----------upper left corner------------------------------------------------------------, -----width--------------------, ----height--------------------- ctx_tt.fillRect(rect.center_2d.x - this.dirDotRadius_px - 1, rect.center_2d.y - this.dirDotRadius_px - 1, (this.dirDotRadius_px * 2) + 2, (this.dirDotRadius_px * 2) + 2); if (dirDotColor != this.bgColor) { // Draw the dot. gW.drawCircle( ctx_tt, rect.center_2d, {'radius_px':this.dirDotRadius_px, 'fillColor':dirDotColor} ); } } TwoThumbs.prototype.updateRectangle = function( rectName, point_2d) { var rect = this.grid[ rectName]; var dirDotColor; var relativeToCenter_2d = point_2d.subtract( rect.center_2d); if (rectName == 'jet_360' || rectName == 'gun_360') { var rTC_lengthSquared = relativeToCenter_2d.length_squared(); // Orient dir_2d to match the direction of relativeToCenter_2d // Note the negative sign correction (on the angle result) is necessary because of the // negative orientation of the y axis with the screen (pixels) representation (not world here). var angle_d = -rect.dir_2d.matchAngle( relativeToCenter_2d); // End point for drawing the orientation vector var endPoint_2d = rect.center_2d.add( rect.dir_2d); if (rectName == 'jet_360') { // Check where the point is relative to the control rings. // Always use at least the minimum jet power. dirDotColor = this.jetRadiusColor_0; mK.jet_t = 0.1; // Jet throttle // Stronger jet if (rTC_lengthSquared > Math.pow(this.grid['jet_360'].cRadius_1_px, 2)) { dirDotColor = this.jetRadiusColor_1; mK.jet_t = 0.4; // Even stronger jet if (rTC_lengthSquared > Math.pow(this.grid['jet_360'].cRadius_2_px, 2)) { dirDotColor = this.jetRadiusColor_2; mK.jet_t = 0.7; // Even stronger jet if (rTC_lengthSquared > Math.pow(this.grid['jet_360'].cRadius_3_px, 2)) { dirDotColor = this.jetRadiusColor_3; mK.jet_t = 1.0; } } } // Update mK for sending to the host. mK.w = 'D'; mK.jet_d = angle_d + 0; // + 180 to reverse it... } else if (rectName == 'gun_360') { // Check is the point is outside the control ring... if (rTC_lengthSquared > Math.pow(this.grid['gun_360'].cRadius_0_px, 2)) { dirDotColor = this.gunRadiusColor_0; mK.i = 'D'; } else { dirDotColor = this.gridColor; mK.i = 'U'; } // Update mK for sending to the host. mK.gun_d = angle_d; } this.updateDirectionDot( rectName, dirDotColor); // Draw the direction line. gW.drawLine( ctx_tt, rect.center_2d, endPoint_2d, {'width_px':3, 'color':'white'} ); } else if (rectName == 'shield') { if (rect.mK) mK[rect.mK] = 'D'; this.updateDirectionDot( rectName, 'yellow'); } else if (rectName == 'color') { // mK.cl = 'D' if (rect.mK) mK[rect.mK] = 'D'; this.colorClientRect( this.bgColor); } else if (rectName == 'gun_scope') { // Rotation rate fraction (Rrf) for the scope control (Sc), where x_fraction varies // from -1 to +1; var x_fraction = relativeToCenter_2d.x / ((rect.LR.x - rect.UL.x)/2.0); var x_fraction_abs = Math.abs( x_fraction); if (x_fraction_abs > 0) { var x_fraction_sign = x_fraction / x_fraction_abs; } else { var x_fraction_sign = 1.0; } // Shooting spot in the middle where it will only shoot, not rotate. if (x_fraction_abs < this.scopeShootSpot) { var x_fraction_mapped = 0.00; mK[rect.mK] = 'D'; this.updateDirectionDot( rectName, 'red'); // The outer areas will only rotate, not shoot. } else { // Map the x_fraction value so that near the edge of the dead zone, the rate is small. At the // outer edge of the rectangle, the rate is 1.5 times the normal keyboard rotation rate. var x_fraction_mapped = x_fraction_sign * (x_fraction_abs - this.scopeShootSpot - 0.01) * 1.0; mK[rect.mK] = 'U'; this.updateDirectionDot( rectName, 'yellow'); } mK['ScRrf'] = x_fraction_mapped.toFixed(2); } else if (rectName == 'alt') { this.updateDirectionDot( rectName, 'yellow'); // Show the alt-dependent rectangles as awake (ready to receive a touch). Don't do this // if the alt rectangle is already active. This check is necessary to allow the alt keys // to show yellow after they are touched. Remember, the updateRectangle function fires // twice when using the alt feature. if (!this.grid['alt'].active) { this.updateDirectionDot('esc', this.gridColor); this.updateDirectionDot('demo7', this.gridColor); this.updateDirectionDot('demo8', this.gridColor); this.updateDirectionDot('freeze',this.gridColor); } // Must use the alt button for these: } else if (this.grid['alt'].active && (rectName =='esc' || rectName =='demo7' || rectName =='demo8' || rectName =='freeze')) { if (rectName =='esc') { clientCanvas_tt.width = videoMirror.width; clientCanvas_tt.height = videoMirror.height; // Note: the alt and esc rectangles get "released" in this call to changeDisplay. this.changeDisplay('exit'); return; } if (rect.mK) mK[rect.mK] = 'D'; this.updateDirectionDot( rectName, 'yellow'); } // No matter what, set this rectangle to be active. rect.active = true; } // Color the rectangle that indicates the client color. TwoThumbs.prototype.colorClientRect = function( color) { // Draw this a little smaller than the actual rectangle. var shrink_px = 8; var ULx = this.grid['color'].UL.x + shrink_px; var ULy = this.grid['color'].UL.y + shrink_px; var LRx = this.grid['color'].LR.x - shrink_px; var LRy = this.grid['color'].LR.y - shrink_px; var width_px = LRx - ULx; var height_px = LRy - ULy; ctx_tt.fillStyle = color; ctx_tt.fillRect(ULx, ULy, width_px, height_px); } TwoThumbs.prototype.updateAndDrawTouchGrid = function( mode) { ctx_tt.fillStyle = this.bgColor; ctx_tt.fillRect(0,0, clientCanvas_tt.width, clientCanvas_tt.height); this.adjustmentPoint_2d.x = this.absPos_x_px( 0.47); this.adjustmentPoint_2d.y = this.absPos_y_px( 0.90); this.dirDotRadius_px = this.absPos_x_px(this.dirDotRadius_fraction); this.grid['jet_360'].cRadius_1_px = this.absPos_x_px(this.grid['jet_360'].cRadius_1_f); this.grid['jet_360'].cRadius_2_px = this.absPos_x_px(this.grid['jet_360'].cRadius_2_f); this.grid['jet_360'].cRadius_3_px = this.absPos_x_px(this.grid['jet_360'].cRadius_3_f); this.grid['gun_360'].cRadius_0_px = this.absPos_x_px(this.grid['gun_360'].cRadius_0_f); // x position of the vertical lines (from left to right). var x0 = this.absPos_x_px( 0.00); var x0a = this.absPos_x_px( 0.10); var x0b = this.absPos_x_px( 0.20); var x0c = this.absPos_x_px( 0.30); var x0d = this.absPos_x_px( 0.315); var x0e = this.absPos_x_px( 0.455); var x1 = this.adjustmentPoint_2d.x; var x2 = this.absPos_x_px( 0.60); // ...1.00) - this.adjustmentPoint_2d.x; var x3 = this.absPos_x_px( 1.00); // Center +/- the half width of the scope spot. var x2a = (x3 + x2)/2.0 - ((x3-x2) * this.scopeShootSpot/2.0); var x2b = (x3 + x2)/2.0 + ((x3-x2) * this.scopeShootSpot/2.0); // y position of the horizontal lines (from top to bottom). var y0 = this.absPos_y_px( 0.00); var y0a = this.absPos_y_px( 0.65); var y0b = this.absPos_y_px( 0.85); var y1 = this.adjustmentPoint_2d.y; var y2 = this.absPos_y_px( 1.00); // Define all the rectangles in the grid. UL: upper left, LR: lower right. this.grid['jet_360'].UL = new gW.Vec2D(x0, y0); this.grid['jet_360'].LR = new gW.Vec2D(x1, y1); this.grid['jet_360'].dir_2d = new gW.Vec2D(0, this.dirDotRadius_px); this.grid['gun_360'].UL = new gW.Vec2D(x2, y0); this.grid['gun_360'].LR = new gW.Vec2D(x3, y1); this.grid['gun_360'].dir_2d = new gW.Vec2D(0, this.dirDotRadius_px); this.grid['shield'].UL = new gW.Vec2D(x1, y0); this.grid['shield'].LR = new gW.Vec2D(x2, y0a); this.grid['color'].UL = new gW.Vec2D(x1, y0a); this.grid['color'].LR = new gW.Vec2D(x2, y0b); this.grid['freeze'].UL = new gW.Vec2D(x0, y1); this.grid['freeze'].LR = new gW.Vec2D(x0a, y2); this.grid['demo7'].UL = new gW.Vec2D(x0a, y1); this.grid['demo7'].LR = new gW.Vec2D(x0b, y2); this.grid['demo8'].UL = new gW.Vec2D(x0b, y1); this.grid['demo8'].LR = new gW.Vec2D(x0c, y2); this.grid['esc'].UL = new gW.Vec2D(x0d, y1); this.grid['esc'].LR = new gW.Vec2D(x0e, y2); this.grid['alt'].UL = new gW.Vec2D(x1, y1); this.grid['alt'].LR = new gW.Vec2D(x2, y2); this.grid['gun_scope'].UL = new gW.Vec2D(x2, y0b); this.grid['gun_scope'].LR = new gW.Vec2D(x3, y2); // Calculate the center point of each rectangle. for (var rectName in this.grid) { var rect = this.grid[ rectName]; rect.center_2d = rect.UL.add( rect.LR).scaleBy(1.0/2.0); } if (mode == 'draw') { // Draw grid... // Vertical lines gW.drawLine( ctx_tt, new gW.Vec2D(x0a, y1), new gW.Vec2D(x0a, y2), {'width_px':3, 'color':this.gridColor}); gW.drawLine( ctx_tt, new gW.Vec2D(x0b, y1), new gW.Vec2D(x0b, y2), {'width_px':3, 'color':this.gridColor}); gW.drawLine( ctx_tt, new gW.Vec2D(x0c, y1), new gW.Vec2D(x0c, y2), {'width_px':3, 'color':this.gridColor}); gW.drawLine( ctx_tt, new gW.Vec2D(x0d, y1), new gW.Vec2D(x0d, y2), {'width_px':3, 'color':this.gridColor}); gW.drawLine( ctx_tt, new gW.Vec2D(x0e, y1), new gW.Vec2D(x0e, y2), {'width_px':3, 'color':this.gridColor}); gW.drawLine( ctx_tt, new gW.Vec2D(x1, y0), new gW.Vec2D(x1, y2), {'width_px':5, 'color':this.gridColor}); gW.drawLine( ctx_tt, new gW.Vec2D(x2, y0), new gW.Vec2D(x2, y2), {'width_px':5, 'color':this.gridColor}); // Vertical lines in the scope rectangle gW.drawLine( ctx_tt, new gW.Vec2D(x2a, y0b), new gW.Vec2D(x2a, y2), {'width_px':1, 'color':this.gridColor}); gW.drawLine( ctx_tt, new gW.Vec2D(x2b, y0b), new gW.Vec2D(x2b, y2), {'width_px':1, 'color':this.gridColor}); // Draw the vertical gradient lines in the scope rectangle. var width_px = x2a - x2; var step_px = Math.round(width_px/3); var length_px = Math.round((y2 - y0b)/3); for (var i = step_px; i < width_px; i += step_px) { gW.drawLine( ctx_tt, new gW.Vec2D(x2 + i, y2-length_px), new gW.Vec2D(x2 + i, y2), {'width_px':1, 'color':this.gridColor}); gW.drawLine( ctx_tt, new gW.Vec2D(x3 - i, y2-length_px), new gW.Vec2D(x3 - i, y2), {'width_px':1, 'color':this.gridColor}); step_px *= 0.60; step_px = Math.round( step_px); if (step_px < 3) step_px = 3; } // Horizontal lines // First two run only the width of the shield rectangle. gW.drawLine( ctx_tt, new gW.Vec2D(x1, y0a), new gW.Vec2D(x2, y0a), {'width_px':5, 'color':this.gridColor}); gW.drawLine( ctx_tt, new gW.Vec2D(x1, y0b), new gW.Vec2D(x2, y0b), {'width_px':5, 'color':this.gridColor}); // The next pair do the main bottom line: second segment is at a higher y level for the scope rectangle. gW.drawLine( ctx_tt, new gW.Vec2D(x0, y1), new gW.Vec2D(x2, y1), {'width_px':5, 'color':this.gridColor}); gW.drawLine( ctx_tt, new gW.Vec2D(x2, y0b), new gW.Vec2D(x3, y0b), {'width_px':5, 'color':this.gridColor}); // Adjustment Point //gW.drawCircle( ctx_tt, this.adjustmentPoint_2d, {'fillColor': 'red', 'radius_px':5} ); // Direction dots gW.drawCircle( ctx_tt, this.grid['jet_360'].center_2d, {'fillColor':this.gridColor, 'radius_px':this.dirDotRadius_px} ); gW.drawCircle( ctx_tt, this.grid['gun_360'].center_2d, {'fillColor':this.gridColor, 'radius_px':this.dirDotRadius_px} ); gW.drawCircle( ctx_tt, this.grid['shield'].center_2d, {'fillColor':this.gridColor, 'radius_px':this.dirDotRadius_px} ); gW.drawCircle( ctx_tt, this.grid['alt'].center_2d, {'fillColor':this.gridColor, 'radius_px':this.dirDotRadius_px} ); gW.drawCircle( ctx_tt, this.grid['gun_scope'].center_2d, {'fillColor':this.gridColor, 'radius_px':this.dirDotRadius_px} ); // Control ring gW.drawCircle( ctx_tt, this.grid['jet_360'].center_2d, {'fillColor':'noFill', 'radius_px':this.grid['jet_360'].cRadius_1_px, 'borderWidth_px':3, 'borderColor':this.jetRadiusColor_1} ); gW.drawCircle( ctx_tt, this.grid['jet_360'].center_2d, {'fillColor':'noFill', 'radius_px':this.grid['jet_360'].cRadius_2_px, 'borderWidth_px':3, 'borderColor':this.jetRadiusColor_2} ); gW.drawCircle( ctx_tt, this.grid['jet_360'].center_2d, {'fillColor':'noFill', 'radius_px':this.grid['jet_360'].cRadius_3_px, 'borderWidth_px':3, 'borderColor':this.jetRadiusColor_3} ); gW.drawCircle( ctx_tt, this.grid['gun_360'].center_2d, {'fillColor':'noFill', 'radius_px':this.grid['gun_360'].cRadius_0_px, 'borderWidth_px':3, 'borderColor':this.gunRadiusColor_0} ); // Text labels ctx_tt.font = "25px Arial"; ctx_tt.fillStyle = this.gridColor; // Scale the positioning on this text using the x-axis scaling (not a mix of x and y) and include // a pixel offset on the y coordinate to account for the pixel height of the font. ctx_tt.fillText('jet', this.grid['jet_360'].UL.x + this.absPos_x_px(0.018), this.grid['jet_360'].UL.y + this.absPos_x_px( 0.018) + 20); ctx_tt.fillText('pea shooter', this.grid['gun_360'].UL.x + this.absPos_x_px(0.018), this.grid['gun_360'].UL.y + this.absPos_x_px( 0.018) + 20); ctx_tt.font = "20px Arial"; ctx_tt.fillText('shield', this.grid['shield'].UL.x + this.absPos_x_px(0.018), this.grid['shield'].UL.y + this.absPos_x_px( 0.018) + 20); ctx_tt.font = "15px Arial"; ctx_tt.fillText('esc', this.grid['esc'].UL.x + this.absPos_x_px(0.009), this.grid['esc'].UL.y + this.absPos_x_px( 0.018) + 10); ctx_tt.fillText('7', this.grid['demo7'].UL.x + this.absPos_x_px(0.009), this.grid['demo7'].UL.y + this.absPos_x_px( 0.018) + 10); ctx_tt.fillText('8', this.grid['demo8'].UL.x + this.absPos_x_px(0.009), this.grid['demo8'].UL.y + this.absPos_x_px( 0.018) + 10); ctx_tt.fillText('f', this.grid['freeze'].UL.x + this.absPos_x_px(0.009), this.grid['freeze'].UL.y + this.absPos_x_px( 0.018) + 10); ctx_tt.fillText('alt', this.grid['alt'].UL.x + this.absPos_x_px(0.009), this.grid['alt'].UL.y + this.absPos_x_px( 0.018) + 10); ctx_tt.fillText('ccw', this.grid['gun_scope'].UL.x + this.absPos_x_px(0.009), this.grid['gun_scope'].UL.y + this.absPos_x_px( 0.018) + 10); ctx_tt.fillText('cw', x2b + this.absPos_x_px(0.009), this.grid['gun_scope'].UL.y + this.absPos_x_px( 0.018) + 10); if (cl.name) this.colorClientRect( clientColor( cl.name)); } } // Functions supporting full-screen display mode TwoThumbs.prototype.changeDisplay = function( mode) { if ((mode == 'fullScreen') || (mode == 'normal')) { if (window.innerWidth < window.innerHeight) { var orientationMessage = "The Two-Thumbs client requests that your phone be oriented for landscape viewing. Please turn it sideways, then try touching the Two-Thumbs button again." alert( orientationMessage); displayMessage( orientationMessage); return; } this.enabled = true; // If there's a stream active, shut it down. if (chkRequestStream.checked) { chkRequestStream.click(); } // Reveal the canvas. videoMirror.setAttribute("hidden", null); clientCanvas_tt.removeAttribute("hidden"); // A reference to the HTML root element. //changeFullScreenMode( document.documentElement, 'on'); if (mode == 'fullScreen') { changeFullScreenMode( clientCanvas_tt, 'on'); // Delay is needed with FireFox. window.setTimeout(function() { clientCanvas_tt.width = window.innerWidth - 10; clientCanvas_tt.height = window.innerHeight - 10; }, 500); // Wait a little longer than the canvas-resize delay above. // Notice "this" context is passed in with bind. window.setTimeout(function() { this.updateAndDrawTouchGrid('draw'); }.bind(this), 550); } else if (mode == 'normal') { this.updateAndDrawTouchGrid('draw'); } } else if (mode == 'exit') { changeFullScreenMode( clientCanvas_tt, 'off'); // De-activate the two rectangles that may have gotten you in here (remember, you didn't have to lift // your fingers). This effectively resets these rectangles (like releasing your touch). this.grid['esc'].active = false; this.grid['alt'].active = false; // Reveal the video element (and hide the canvas). videoMirror.removeAttribute("hidden"); clientCanvas_tt.setAttribute("hidden", null); chkTwoThumbs.checked = false; this.enabled = false; } } ////////////////////////////////////////////////// // Functions supporting the socket.io connections ////////////////////////////////////////////////// function disableClientControls( diableMode) { // diableMode: true (disable it) or false if (diableMode) { $('#ConnectButton').html('Wait'); $('#ConnectButton').prop('disabled', true); $('#chkRequestStream').prop('disabled', true); $('#twoThumbsButton').prop('disabled', true); $('#ChatButton').prop('disabled', true); } else { // Change the label from 'Wait' to 'Connect'. $('#ConnectButton').html('Connect'); $('#ConnectButton').prop('disabled', false); // Note: the streaming checkbox opens when the p2p data-channel opens (see cl.rtc.dataChannel.onopen). // the two-thumbs button opens when the room is successfully joined. // the chat button opens when the room is successfully joined. } } function checkForNickName( mode, hostOrClient) { var nickName = {'status':'ok', 'value':null}; //return nickName; // Check the chat input field, e.g. nn:Jimbo (that's how the user inputs it). var chatString = $('#inputField').val(); if (mode =='normal') { // New nick name in the chat input field. if (chatString.includes('nn:') || chatString.includes('Nn:')) { nickName.value = chatString.slice(3, chatString.length); if (nickName.value.length > 9) { nickName.status = "too long"; return nickName; } else { if (hostOrClient == 'client') { // Make the nickName accessible from the client. Remember, this cl object // exists on the client. cl.nickName = nickName.value; } else if (hostOrClient == 'host') { gW.clients['local'].nickName = nickName.value; } // Clear out the input field where the nick name was entered. $('#inputField').val(''); } // Nothing new, so use the current nick name if it's there. } else { if (hostOrClient == 'client') { nickName.value = cl.nickName; } else if (hostOrClient == 'host') { nickName.value = gW.clients['local'].nickName; } } } else if ((mode == 're-connect') && cl.nickName) { nickName.value = cl.nickName; } return nickName; } function connect_and_listen( hostOrClient, mode) { // First, run some checks on the room name. var roomName = $('#roomName').val(); // Gotta have something... if (roomName == "") { displayMessage('Type in a short "Room" name, then click the "Connect" button.'); document.getElementById("roomName").style.borderColor = "red"; return; // the HTML limit is set to 9 (so you can try a little more then 7, but then get some advice to limit it to 7) } else if (roomName.length > 7) { displayMessage('The name should have 7 characters or less.'); document.getElementById("roomName").style.borderColor = "red"; return; } // Check to see if there's a nickname in the chat input field. var nickName = checkForNickName( mode, hostOrClient); if (nickName.status == 'too long') { displayMessage('Nicknames must have fewer than 10 characters. Shorten the name and then try connecting again.'); return; } if (hostOrClient == 'client') { // Disable some of the client controls to keep users from repeatedly // clicking the connect button. disableClientControls(true); refresh_P2P_indicator({'mode':'connecting'}); // Open the connect button after 4 seconds. Sometimes there are network delays. // Note: most of the disabled controls open based on events. For example: the // streaming checkbox opens when the p2p data-channel opens (see cl.rtc.dataChannel.onopen). window.setTimeout(function() { disableClientControls( false); }, 4000); } else { displayMessage('Connecting as host. Please wait up to 20 seconds...'); } var nodeString = $('#nodeServer').val(); if (nodeString == "") { // Use one in the list as a default. nodeString = serverArray[0]; // [0] or [2] $('#nodeServer').val( nodeString); } if (nodeString.includes("heroku")) { var urlPrefix = "https://" } else { var urlPrefix = "http://" } nodeServerURL = urlPrefix + nodeString; //console.log("URL=" + nodeServerURL); // Use jquery to load the socket.io client code. $.getScript( nodeServerURL + "/socket.io/socket.io.js", function() { // This callback function will run after the getScript finishes loading the socket.io client. console.log("socket.io script has loaded."); // If there are already active network connections, close them before making new ones. This is // the case if the client repeatedly clicks the connect button trying to get a preferred color. if (socket) { if (hostOrClient != 'host') { // Send a message to the host (via socket.io server) to shutdown RTC connections. if (newClientName) { if (videoMirror.srcObject) videoMirror.srcObject = null; // Trigger client shutdown at the host. socket.emit('shutDown-p2p-deleteClient', newClientName); } } window.setTimeout( function() { // Close socket.io connection after waiting a bit for the p2p connections to close. socket.disconnect(); }, 500); } // Delay this (connection to the server) even longer than the socket.disconnect() above (to be sure the disconnect is done). window.setTimeout( function() { // When starting a new normal connection, turn off the stream. if (mode =='normal' && (hostOrClient != 'host')) chkRequestStream.checked = false; // Here is where the socket.io client initiates it's connection to the server. The 'query' parameter // shows the form of the query string needed for a multi-parameter example. This is how you pass parameters // to the connection handler in server.js. if (nickName.value) { var nickNameString = '&nickName='+ nickName.value; } else { var nickNameString = ''; } var queryString = 'mode=' + mode + '&currentName=' + cl.name + nickNameString; socket = io.connect( nodeServerURL, {'forceNew':true, 'query':queryString}); init_socket_listeners( roomName, hostOrClient); }, 600); // Use the "fail" method of getScript to report a connection problem. }).fail(function( jqxhr, settings, exception) { displayMessage('The node server is not responding. Try changing to a different server.'); document.getElementById("roomName").style.borderColor = "red"; refresh_P2P_indicator({'mode':'reset'}); }); } function getGameReportCounter() { return gameReportCounter; } function displayMessage( msgText) { if (msgText.includes("Game Summary")) { gameReportCounter += 1; var idString = " id='gR" + gameReportCounter + "'"; } else { var idString = ""; } // Every other line, toggle the background shading. if (chatStyleToggle) { var styleString = "style='background: #efefef;'"; } else { var styleString = "style='background: #d9d9d9;'"; } $("#messages").prepend("<li " + styleString + idString + ">"+ msgText +"</li>"); chatStyleToggle = !chatStyleToggle; } // Used for broadcasting a message to non-host players. function chatToNonHostPlayers( msgTxt) { if (socket) socket.emit('chat message but not me', msgTxt + '</br>'); } function init_chatFeatures( hostOrClient) { serverArray = ['secure-retreat-15768.herokuapp.com', 'localhost:3000', '192.168.1.106:3000', '192.168.1.109:3000', //David's computer '192.168.1.116:3000', //RPi '192.168.1.117:3000']; //Laptop // Use jquery to loop over the serverArray and build the URL datalist. jQuery.each( serverArray, function( i, val ) { $('#nodeServerList').append("<option value='" + val + "'>"); }); var pingTestHelp = "Your ping test has started.<br><br>" + "Please wait about 10 seconds for the results of the 100-ping test to return. Each time you hit enter or click the chat button " + "a new 100-ping test will be queued. Please manually clear out the words 'ping' or 'ping:host' to stop pinging and start chatting."; // Function that emits (if a socket has been established) the text in the form's input field. $('#chatForm').submit(function() { var chatString = $('#inputField').val(); if (socket) { if (chatString == 'ping') { echoTest('server'); displayMessage( pingTestHelp); } else if (chatString == 'ping:host') { echoTest('host'); displayMessage( pingTestHelp); } else { socket.emit('chat message', chatString); $('#inputField').val(''); //clear out the input field. } } else { displayMessage('Type in a short "Room" name, then click the "Connect" button.'); } return false; }); // Prevent typing in the input fields from triggering document level keyboard events. $('#inputField, #nodeServer, #roomName, #jsonCapture').on('keyup keydown keypress', function( e) { e.stopPropagation(); // stops bubbling... }); // A first message in the chat area var helloMessage; if (hostOrClient == 'host') { helloMessage = '' + 'This is the host page for multiplayer.</br></br>'+ 'Click the multiplayer checkbox to toggle between this chat panel and the discussion/help panel. Doing so will not disable connections.</br></br>'+ 'From here you can host a multiplayer room. '+ 'Please notice the links to the client page in the right panel below the multiplayer checkbox. '+ 'You can also get to the client page from the three-line menu icon in the upper left corner.</br></br>'+ 'To get started, type a short room name into the red box, then click the "Connect" button.</br></br>'+ 'Please note, when setting up the room as host, you might not get an immediate response from the server. It can take a little while for the Heroku node application to wake up. '+ 'If waking, give it 10 to 20 seconds before expecting a confimation message in this chat area.</br></br>'+ 'To start over, or disconnect from the server, please reload the page.'; } else { helloMessage = '' + 'This is the client page for multiplayer.</br></br>'+ 'From here you can be a client in a multiplayer room. The room must be started (hosted) from the main www.timetocode.org page. '+ 'Generally, a separate computer is used for hosting. For testing, the host and multiple clients can be run in separate windows on the same computer.</br></br>'+ 'To connect as a client, type, into the red box (here, on this client page), the room name provided to you by the host, then click the "Connect" button.</br></br>' + 'To start over, or disconnect from the server, please reload the page.'; } displayMessage( helloMessage); } function clientColor( clientName) { var colors = {'1':'yellow','2':'blue','3':'green','4':'pink','5':'orange', '6':'brown','7':'greenyellow','8':'cyan','9':'tan','0':'gray'}; var n = clientName.slice(1); var colorIndex = n - Math.trunc(n/10)*10; return colors[ colorIndex]; } function init_socket_listeners( roomName, hostOrClient) { // Listeners needed by both the client and the host. // Listen for chat being forwarded by the server. socket.on('chat message', function(msg) { displayMessage( msg); }); // Change the border color of the roomName input box depending on the // message from the node server. And add additional info to the message. socket.on('room-joining-message', function(msg) { if (msg.includes('You have joined room')) { // Some visual indicators that all is well. document.getElementById("roomName").style.borderColor = "#008080"; //Dark green. if (hostOrClient == 'client') $('#twoThumbsButton').prop('disabled', false); $('#ChatButton').prop('disabled', false); // If the names are the same, it indicates the network client has rejoined with a video stream. if ((hostOrClient == 'client') && (cl.name == cl.previous_name)) { if (cl.nickName) { var nNstring = ' (' + cl.nickName + ').'; } else { var nNstring = '.'; } msg = 'You have reconnected with a video stream. Your name is still ' + cl.name + nNstring; // Additional instructions if this is a non-host client } else if (hostOrClient == 'client') { msg += ''+ "</br></br>"+ "You are in <strong>normal desktop</strong> mode. Your mouse and keyboard events get sent to the host. You must have direct visual access to the host's monitor."+ "</br></br>"+ "Two other options:</br></br>"+ "<strong>Stream:</strong> This is like normal mode, but the host's canvas is rendered in the video element here. "+ "So you can play out-of-sight of the host's monitor, in a separate room, city, country...</br></br>"+ "<strong>Two Thumbs:</strong> touch-screen interface for your phone. Similar to normal mode, this requires line-of-sight to the host's monitor. "+ "However, you can start up a second client (on a second device) and stream to it if you don't have line-of-sight.</br>"; } // Client might get this warning... } else if (msg.includes('Sorry, there is no host')) { document.getElementById("roomName").style.borderColor = "red"; refresh_P2P_indicator({'mode':'reset'}); // A candidate host might get this warning... } else if (msg.includes('Sorry, there is already a host')) { document.getElementById("roomName").style.borderColor = "red"; // Additional instructions for the new host. This room-joining-message event will have to be triggered a second time to get this message to the host after // the "You have joined room" message above. } else if (msg.includes('You are the host')) { var openWindowString = '"' + "window.open('indexClient.html', '_blank', 'width=1320, height=650') " + '"'; msg += ''+ "</br></br>"+ "You can open a test <a href='#' onClick=" + openWindowString + "title='Open a client page in a new window.'>client</a> in a new window. "+ "Enter the same room name on the client page. Then the client mouse and keyboard events will render to the canvas of the host."; } displayMessage( msg); }); // Once your connection succeeds, join a room. socket.on('connect', function() { if (hostOrClient == 'host') { socket.emit('roomJoin', JSON.stringify({'hostOrClient':hostOrClient,'roomName':roomName})); } else if (hostOrClient == 'client') { socket.emit('roomJoin', JSON.stringify({'hostOrClient':hostOrClient,'roomName':roomName, 'player':chkPlayer.checked, 'requestStream':chkRequestStream.checked})); } }); // Listen for echo response from the server. socket.on('echo-from-Server-to-Client', function( msg) { var echoTarget = msg; // Stop timer (measure the round trip). timer.stop = window.performance.now(); var elapsed_time = timer.stop - timer.start; // Add this new timing result to the array. timer.pingArray.push( elapsed_time); // The echo series STOPs here. if (timer.pingArray.length > 99) { var timeAvg = Math.mean( timer.pingArray).toFixed(1); var timeSTD = Math.std( timer.pingArray).toFixed(1); var timeLen = timer.pingArray.length; var timeMax = Math.max( timer.pingArray).toFixed(1); var timeMin = Math.min( timer.pingArray).toFixed(1); displayMessage('Echo test to '+ echoTarget +': '+ timeAvg +' ms '+ '(std='+ timeSTD + ', min='+ timeMin + ', max='+ timeMax + ', n='+ timeLen +')'); timer.pingArray = []; return; } // Ping it again (continue the series). echoTest( echoTarget); // Do this after the timer starts (don't slow it down with a write to the console.) console.log( echoTarget); }); // WebRTC Signaling. // This handles signaling from both sides of the peer-to-peer connection. socket.on('signaling message', function(msg) { // Convert it back to a usable object (parse it). var signal_message = JSON.parse(msg); // Note that signalData needs to be in a stringified form when writing to the console. //console.log("signal message from " + signal_message.from + ", to " + signal_message.to + ": " + JSON.stringify(signal_message.signalData)); // Offers and Answers if (signal_message.signalData.sdp) { //console.log('sdp in signal from host: ' + JSON.stringify(signal_message.signalData)); if (signal_message.signalData.type == 'offer') { //console.log("an offer"); handleOffer( signal_message.signalData); } else if (signal_message.signalData.type == 'answer') { //console.log("an answer"); handleAnswer( signal_message.signalData); } else { console.log("Woooooo-HoHo-Hoooooo, something is screwed up. This can't be good."); } // ICE candidates } else if (signal_message.signalData.candidate) { // handle ICE stuff. cl.rtc.pc.addIceCandidate( signal_message.signalData) .catch( function( reason) { // An error occurred, so... console.log('Error while handling ICE stuff:' + reason); }); //console.log('signaling state after handling ICE = ' + cl.rtc.pc.signalingState); } else { //No WebRTC stuff found in the signaling message. Maybe you are testing... console.log("In final else block of 'signaling message' handler."); } }); socket.on('control message', function( msg) { // General receiver of control messages. This can be used by either the host or a client to // receive messages from anyone. // Convert the raw msg back to a usable object (parse it). var message = JSON.parse( msg); // Control message directed to the host. if (message.to == 'host') { if (message.data.videoStream == 'off') { gW.clients[ message.from].rtc.turnVideoStreamOff(); } else if (message.data.fullScreen == 'off') { console.log('full screen requested off by client'); // Tried to do something here, but browsers fullscreen API requires that the // a change starts with a gesture. The error: '...API can only be initiated by a user gesture.' } // Control message directed to a non-host client. } else { } }); // Listeners needed by the client only. if (hostOrClient == 'client') { socket.on('your name is', function( msg) { var message = JSON.parse( msg); var name = setDefault( message.name, null); // Note: not (yet) doing anything with nickName that comes back from the socket.io server. // cl.nickName gets set for the client on the front end of the connection. Just including it // here for completeness. var nickName = setDefault( message.nickName, null); // Put this name in the mouse and keyboard (mK) global that is used to send // state data from the client. mK.name = name; // Put your name in this global (on the client side) for (possible) use by the WebRTC functions. newClientName = name; // Before updating cl.name with the new client name, store it's current value in previous_name. cl.previous_name = cl.name; cl.name = newClientName; console.log('names: current='+cl.name+ ', previous='+ cl.previous_name +', nick='+nickName); // Initialize this global container for the WebRTC stuff. cl.rtc = new RTC({'user1':newClientName,'user2':'host'}); }); socket.on('disconnectByServer', function(msg) { if (db.rtc) console.log('in client disconnectByServer, msg='+msg); var clientName = msg; displayMessage("This client (" + clientName + ") is being disconnected by the host."); document.getElementById("roomName").style.borderColor = "red"; // When the server gets this one, it will remove the socket. socket.emit('okDisconnectMe', clientName); // Shutdown and delete the client side of the WebRTC p2p connection. cl.rtc.shutdown(); initialize_mK(); //mK = {}; // Delay this so it takes effect after the p2p toggle finishes. window.setTimeout( function() { displayMessage(""); displayMessage("Shutdown of the p2p connection for " + clientName + " has finished."); displayMessage(""); displayMessage(""); displayMessage(""); }, 100); }); socket.on('command-from-host-to-all-clients', function( msg) { // Clients (only) do something based on the message from the host. var command_message = JSON.parse( msg); var type = command_message.type; var command = command_message.command; if (type == 'resize') { gW.adjustSizeOfChatDiv( command); if (command == 'normal') { videoMirror.width = 600, videoMirror.height = 600; } else { videoMirror.width = 1250, videoMirror.height = 950; } } else { console.log("I don't recognize that command; hey, I'm just saying..."); } }); } // Listeners needed by the host only. if (hostOrClient == 'host') { // (Note: this is the one place where calls to gW are made inside of hC.) // Listen for client mouse and keyboard (mk) events broadcast from the server. // StH: Server to Host socket.on('client-mK-StH-event', function(msg) { var msg_parsed = JSON.parse( msg); //console.log('State('+ msg_parsed.name +'):'+ msg_parsed.MD +','+ msg_parsed.bu +'): '+ msg_parsed.mX + "," + msg_parsed.mY); // Send this mouse-and-keyboard state to the engine. gW.updateClientState( msg_parsed.name, msg_parsed); }); // As host, create a new client in gW framework. socket.on('new-game-client', function(msg) { var msgParsed = JSON.parse(msg); var streamRequested = msgParsed.requestStream; var clientName = msgParsed.clientName; var player = msgParsed.player; var nickName = msgParsed.nickName; gW.createNetworkClient({'clientName':clientName, 'player':player, 'nickName':nickName}); // WebRTC. Start the p2p connection here (from the host) when we hear (from the server) // that a client is trying to connect to a room. // Make a global reference to this new (the most recent) client's RTC object. cl = gW.clients[clientName]; cl.rtc.user1 = 'host'; cl.rtc.user2 = clientName; cl.rtc.streamRequested = streamRequested; if (db.rtc) console.log('in new-game-client, cl.rtc.user2 = ' + cl.rtc.user2); // Start the WebRTC signaling exchange with the new client. // Diagnostic tools: chrome://webrtc-internals (in Chrome) and about:webrtc (in Firefox) try { openDataChannel( true); // open as the initiator createOffer(); } catch(e) { console.log("WebRTC startup: " + e); } // Someone just connected. Send the layout state to them (actually to everyone, but that // should, of course, cover the connecting user also). Delay it a bit... window.setTimeout( function() { resizeClients( gW.getChatLayoutState()); }, 300); }); socket.on('client-disconnected', function(msg) { var clientName = msg; if (db.rtc) console.log('in client-disconnected, clientName=' + clientName); // Null out any WebRTC references in c object (most recent connection on the host page) if it happens to be // this client. nullReferences_toRTC_on_c( clientName); // Do corresponding cleanup in gwModule. gW.deleteNetworkClient( clientName); }); socket.on('echo-from-Server-to-Host', function(msg) { // Bounce this back to server. // The msg string is the client id. socket.emit('echo-from-Host-to-Server', msg); }); socket.on('shutDown-p2p-deleteClient', function( msg) { if (db.rtc) console.log('in shutDown-p2p-deleteClient'); var clientName = msg; // First check for the case where the host has reloaded their page and // then a client attempts to reconnect. In that case the clients map will be empty and // this clientName won't be found in there. if (gW.clients[ clientName]) { // Check for a puck controlled by this client. Delete it first. if (gW.clients[ clientName].puck) gW.clients[ clientName].puck.deleteThisOne({}); // Then start shutting down the WebRTC connection. gW.deleteRTC_onClientAndHost( clientName); } }); } } // end of init_socket_listeners // The following two functions are exposed for external use and are called from within gwModule.js. function forceClientDisconnect( clientName) { if (db.rtc) console.log('in forceClientDisconnect'); socket.emit('clientDisconnectByHost', clientName); } function resizeClients( command){ if (socket) { socket.emit('command-from-host-to-all-clients', JSON.stringify({'type':'resize', 'command':command})); } } function echoTest( hostOrServer) { // Start the timer for one echo. timer.start = window.performance.now(); // The echo series STARTs here. socket.emit('echo-from-Client-to-Server', hostOrServer); } //////////////////////////////////////////////// // Functions supporting the WebRTC connections. //////////////////////////////////////////////// var configuration = { 'iceServers': [{'urls': 'stun:stun1.l.google.com:19302'}] }; function openDataChannel( isInitiator) { cl.rtc.pc = new RTCPeerConnection( configuration); // send any ice candidates to the other peer cl.rtc.pc.onicecandidate = function (evt) { if (evt.candidate) { var signal_message = {'from':cl.rtc.user1, 'to':cl.rtc.user2, 'signalData':evt.candidate}; socket.emit('signaling message', JSON.stringify( signal_message)); } }; // Host-side data channel if (isInitiator) { var dc_id = cl.rtc.user2.slice(1); var dc_options = {'id':dc_id, 'ordered':false, 'maxRetransmits':1}; var dc_label = "dc-" + cl.rtc.user2; cl.rtc.dataChannel = cl.rtc.pc.createDataChannel(dc_label, dc_options); cl.rtc.dataChannel.onmessage = function( e) { handle_RTC_message( e); }; cl.rtc.dataChannel.onopen = function( ){console.log("------ RTC DC(H) OPENED ------");}; cl.rtc.dataChannel.onclose = function( ){console.log("------ RTC DC(H) closed ------");}; cl.rtc.dataChannel.onerror = function( ){console.log("RTC DC(H) error.....");}; if (cl.rtc.streamRequested) { startVideoStream(); } // Client-side data channel } else { // This side of the data channel gets established in response to the channel initialization // on the host side. cl.rtc.pc.ondatachannel = function(evt) { cl.rtc.dataChannel = evt.channel; // Must set up an onmessage handler for the clients too. cl.rtc.dataChannel.onmessage = function(e) { console.log("DC (@client) message:" + e.data); }; cl.rtc.dataChannel.onopen = function() { console.log("------ RTC DC(C) OPENED ------"); rtc_choke = false; $('#chkRequestStream').prop('disabled', false); refresh_P2P_indicator({}); }; cl.rtc.dataChannel.onclose = function() { console.log("------ RTC DC(C) closed ------"); rtc_choke = true; }; cl.rtc.dataChannel.onerror = function() { console.log("RTC DC(C) error....."); }; } // Respond to a new track by sending the stream to the video element. cl.rtc.pc.ontrack = function (evt) { videoMirror.srcObject = evt.streams[0]; }; } //console.log('signaling state after openDataChannel = ' + cl.rtc.pc.signalingState); } // This function is used (only) by the host when someone connects and wants a stream. function startVideoStream() { if (!videoStream) { var hostCanvas = document.getElementById('hostCanvas'); videoStream = hostCanvas.captureStream(); //60 } cl.rtc.pc.addTrack( videoStream.getVideoTracks()[0], videoStream); // The chkStream is on the host page only (index.html) document.getElementById("chkStream").checked = true; videoStream.getVideoTracks()[0].enabled = true; } function setCanvasStream( newState) { if (videoStream) { if (newState == 'on') { videoStream.getVideoTracks()[0].enabled = true; } else { videoStream.getVideoTracks()[0].enabled = false; } } } function handle_RTC_message( msg) { //var user2 = Object.assign({}, cl.rtc.user2); /* var user2 = JSON.stringify(cl.rtc.user2); console.log("I am (cl.rtc.user2) = " + user2); console.log("DC ID = " + JSON.stringify(cl.rtc.dataChannel.id)); console.log("DC (@host) message: " + e.data); */ // Process mK events from the client on the other end of this peer-to-peer connection. var mK_string = msg.data; var mK_data = JSON.parse( mK_string); // Send this mouse-and-keyboard state to the engine. gW.updateClientState( mK_data.name, mK_data); } function createOffer() { cl.rtc.pc.createOffer() .then( function( offer) { return cl.rtc.pc.setLocalDescription( offer); }) .then( function() { var signal_message = {'from':cl.rtc.user1, 'to':cl.rtc.user2, 'signalData':cl.rtc.pc.localDescription}; socket.emit('signaling message', JSON.stringify( signal_message)); }) .catch( function(reason) { // An error occurred, so handle the failure to connect console.log('Error while creating offer:' + reason); }); //console.log('signaling state after createOffer = ' + cl.rtc.pc.signalingState); } function handleOffer( msg) { openDataChannel( false); // Open as NOT the initiator cl.rtc.pc.setRemoteDescription( msg) .then(function() { return cl.rtc.pc.createAnswer( ); }) .then(function( answer) { return cl.rtc.pc.setLocalDescription( answer); }) .then(function() { // Send the answer (localDescription) to the remote peer var signal_message = {'from':cl.rtc.user1, 'to':cl.rtc.user2, 'signalData':cl.rtc.pc.localDescription}; socket.emit('signaling message', JSON.stringify( signal_message)); }) .catch( function( reason) { console.log('Error while handling offer:' + reason); }); //console.log('signaling state after handleOffer = ' + cl.rtc.pc.signalingState); } function handleAnswer( answer) { cl.rtc.pc.setRemoteDescription( answer) .catch( function( reason) { console.log('Error while handling answer:' + reason); }); //console.log('signaling state after handleAnswer = ' + cl.rtc.pc.signalingState); } function logError( error) { console.log(error.name + ': ' + error.message); } function nullReferences_toRTC_on_c( clientName) { // Check the global "c" pointer (to the most recently connected client) to see if it happens to // be pointed at this client. //console.log('cl.rtc='+JSON.stringify( cl.rtc) + ", newClientName=" + clientName); if (cl.rtc && (cl.rtc.user2 == clientName)) { cl.rtc = new RTC({}); } } function refresh_P2P_indicator( pars) { var mode = setDefault( pars.mode, 'p2p'); // Stop the pacifier (note: pacifier is a global object) clearInterval( pacifier.intFunction); // If connected, there will be a name (assigned from the server) if ((mode == 'p2p') && cl.name) { // Show (flood/erase the canvas with) the client's color. ctx.fillStyle = clientColor( cl.name); ctx.fillRect(0, 0, clientCanvas.width, clientCanvas.height); ctx.font = "12px Arial"; // Use dark letters for the lighter client colors. var lightColors = ['yellow', 'greenyellow', 'pink', 'cyan', 'tan']; if (lightColors.includes( clientColor( cl.name))) { ctx.fillStyle = 'black'; } else { ctx.fillStyle = 'white'; } // If choke, RTC, data channel, and readyState are ok, display the "P2P" text. if (!rtc_choke && cl.rtc && cl.rtc.dataChannel && (cl.rtc.dataChannel.readyState == 'open')) { ctx.fillText('P2P', 10, 12); } else { ctx.fillText('socket.io', 10, 12); } } else if (mode == 'connecting') { ctx.fillStyle = 'darkgray'; ctx.fillRect(0, 0, clientCanvas.width, clientCanvas.height); ctx.font = "12px Arial"; ctx.fillStyle = 'white'; ctx.fillText('CONNECTING', 10, 12); // Start the pacifier pacifier.string = ''; pacifier.intFunction = setInterval( function() { pacifier.string += '--'; ctx.fillText(pacifier.string, 95, 12); }, 200); } else if (mode == 'reset') { // Light gray fill. ctx.fillStyle = '#EFEFEF'; ctx.fillRect(0, 0, clientCanvas.width, clientCanvas.height); } } //////////////////////////////////////////////////////////////////////////////// // Functions supporting canvas animation //////////////////////////////////////////////////////////////////////////////// // Currently not using this steady animation loop approach. Instead, update the canvas // as input events get fired. Refer to the methods in the TwoThumbs class. /* function canvasLoop( timeStamp_ms) { updateCanvas(); myRequest = window.requestAnimationFrame( canvasLoop); } function updateCanvas() { // Clear the canvas (from one corner to the other) if (ctx_tt.globalCompositeOperation == 'screen') { ctx_tt.clearRect(0,0, clientCanvas_tt.width, clientCanvas_tt.height); } else { ctx_tt.fillStyle = 'blue'; ctx_tt.fillRect(clientCanvas_tt.width/8, clientCanvas_tt.width/8, clientCanvas_tt.width/4, clientCanvas_tt.height/4); } if (twoThumbs.enabled) { // Draw the two-thumb state if (cl.name) { var circleColor = clientColor( cl.name); } else { var circleColor = 'white'; } gW.drawCircle( ctx_tt, {'x':mK.mX, 'y':mK.mY}, {'fillColor': circleColor} ); } } function startAnimation() { // Only start a game loop if there is no game loop running. if (myRequest === null) { // Start the canvas loop. myRequest = window.requestAnimationFrame( canvasLoop); } } function stopAnimation() { window.cancelAnimationFrame( myRequest); myRequest = null; } */ //////////////////////////////////////////////////////////////////////////////// // Misc functions //////////////////////////////////////////////////////////////////////////////// function init_nonHostClients() { init_eventListeners_nonHostClients(); init_chatFeatures('client'); twoThumbs = new TwoThumbs({}); } function setDefault( theValue, theDefault) { // Return the default if the value is undefined. return (typeof theValue !== "undefined") ? theValue : theDefault; } function changeFullScreenMode( targetElement, mode) { if (mode == 'on') { if (targetElement.requestFullscreen) { targetElement.requestFullscreen(); } else if (targetElement.mozRequestFullScreen) { targetElement.mozRequestFullScreen(); } else if (targetElement.webkitRequestFullScreen) { targetElement.webkitRequestFullScreen(); } else if (targetElement.msRequestFullscreen) { targetElement.msRequestFullscreen(); } } else if (mode == 'off') { if (document.exitFullscreen) { document.exitFullscreen(); } else if (document.mozCancelFullScreen) { document.mozCancelFullScreen(); } else if (document.webkitCancelFullScreen) { document.webkitCancelFullScreen(); } else if (document.msExitFullscreen) { document.msExitFullscreen(); } } } function handle_sending_mK_data( mK) { // Use WebRTC datachannel if available if (cl.rtc && cl.rtc.dataChannel && (cl.rtc.dataChannel.readyState == 'open') && (rtc_choke == false)) { cl.rtc.dataChannel.send( JSON.stringify( mK)); // Otherwise use socket.io (WebSocket) } else if (socket) { socket.emit('client-mK-event', JSON.stringify( mK)); } } //////////////////////////////////////////////////////////////////////////////// // Event listeners to capture mouse and keyboard (m & K) state from the non-host // clients. //////////////////////////////////////////////////////////////////////////////// function initialize_mK() { // Initialize the Mouse and Keyboard (mK) state object. // isMouseDown mK.MD = false; // mouse button number (which of the three: 0,1,2) mK.bu = 0; // mouse position in pixels: X_px, Y_px mK.mX = 5; mK.mY = 5; // Use the keyMap to define and initialize all the key states (to UP) in the // mK (mouse and keyboard state) object that is sent to the host. for (var key in keyMap) { mK[keyMap[key]] = 'U'; } for (var key in keyMap_cso) { mK_cso[keyMap_cso[key]] = 'U'; } // Initialize non-keyboard attributes (for the Two Thumbs interface) // Gun scope mK['ScRrf'] = 0.00; mK['ScTr'] = 'U'; // Jet throttle (the full throttle) mK['jet_t'] = 1.0; } function init_eventListeners_nonHostClients() { initialize_mK(); clientCanvas = document.getElementById('connectionCanvas'); ctx = clientCanvas.getContext('2d'); clientCanvas_tt = document.getElementById('twoThumbsCanvas'); ctx_tt = clientCanvas_tt.getContext('2d'); myRequest = null; videoMirror = document.getElementById('videoMirror'); // Event handlers for this network client (user input) // Inhibit the context menu that pops up when right clicking (third button). // Alternatively, could apply this only to the canvas. That way you can still // source the page. document.addEventListener("contextmenu", function(e) { e.preventDefault(); return false; }, {capture: false}); // For the client, keep these listeners on all the time so you can see the client cursor. // To avoid scrolling behavior on the video element, had to set up specific event handlers // for that element (videoMirror) and use preventDefault. This wasn't necessary on the host // side because no video element there (just a canvas). videoMirror.addEventListener("touchmove", function(e) { e.preventDefault(); handleMouseOrTouchMove(e, 'touchmove'); }, {capture: false}); videoMirror.addEventListener("mousemove", function(e) { e.preventDefault(); handleMouseOrTouchMove(e, 'mousemove'); }, {capture: false}); clientCanvas_tt.addEventListener("touchmove", function(e) { e.preventDefault(); handleMouseOrTouchMove(e, 'touchmove'); }, {capture: false}); document.addEventListener("mousedown", function(e) { // Keep mousedown from firing in the TwoThumbs interface. Necessary for Android where this // would fire after the touchstart and screw up the direction dot. This of course prevents // use of the mouse in TwoThumbs, but that's fine, need fingers there... if (twoThumbs.enabled) return; mK.MD = true; // Mouse Down mK.bu = e.button; // Mouse button //Pass this first mouse position to the move handler. handleMouseOrTouchMove(e, 'mousedown'); //if (cl.rtc && cl.rtc.dataChannel) cl.rtc.dataChannel.send( 'mouse-down event, id = ' + cl.rtc.dataChannel.id); }, {capture: false}); document.addEventListener("touchstart", function(e) { // Note: the following canvas style is set: // touch-action: none; // This keep the canvas from sliding when flinging objects. // Prevent the mousedown event from firing. But in the end decided to put the check-and-return // statement in the first line of mousedown. That works in all devices and all browsers. The following // statement problematically blocked touch operations in off-canvas areas of the client when running in // Firefox. //e.preventDefault(); // works great (to prevent mousedown) for laptop but not Android. mK.MD = true; // Mouse Down mK.bu = 0; // Mouse button //Pass this first mouse position to the move handler. handleMouseOrTouchMove( e, 'touchstart'); }, {capture: false}); function handleMouseOrTouchMove( e, fromListener) { if (twoThumbs.enabled) { var touchPoints_2d_px = []; // Determine event type // Mouse (single contact point) if (e.clientX && (mK.MD == true)) { touchPoints_2d_px[0] = gW.screenFromRaw_2d_px( clientCanvas_tt, new gW.Vec2D( e.clientX, e.clientY)); // Touch screen (possibly multiple contact points) } else if (e.touches) { // Tried this but can't. Must start with a gesture on the host. // Use 4-finger touch to toggle fullscreen on the host. if ((e.touches.length == 4) && (fromListener != 'touchmove')) { var control_message = {'from':cl.name, 'to':'host', 'data':{'fullScreen':'off'} }; socket.emit('control message', JSON.stringify( control_message)); } for (var i = 0, len = e.touches.length; i < len; i++) { touchPoints_2d_px[i] = gW.screenFromRaw_2d_px( clientCanvas_tt, new gW.Vec2D( e.touches[i].clientX, e.touches[i].clientY)); } } // Interpret the touch and mouse events using the twoThumbs interface. twoThumbs.processMultiTouch( touchPoints_2d_px); } else { // Determine event type // Mouse if (e.clientX) { var raw_x_px = e.clientX; var raw_y_px = e.clientY; // Touch screen } else if (e.touches) { // Only consider the first touch event. var raw_x_px = e.touches[0].clientX; var raw_y_px = e.touches[0].clientY; } // Convert the raw mouse position into coordinated relative to the corner of the imaging element. var screen_2d_px = gW.screenFromRaw_2d_px( videoMirror, new gW.Vec2D( raw_x_px, raw_y_px)); // Send the state to the server (there it will be relayed to the host client). mK.mX = screen_2d_px.x; mK.mY = screen_2d_px.y; handle_sending_mK_data( mK); } }; document.addEventListener("mouseup", function( e) { if (!mK.MD) return; // Unlike for the host client, DO NOT shut down the mousemove listener. That // way we can see the mouse position even if the buttons are released. resetMouseOrFingerState( e); }, {capture: false}); document.addEventListener("touchend", function( e) { // Don't seem to need this... //if (!mK.MD) return; // Note: e.preventDefault() not needed here if the following canvas style is set // touch-action: none; resetMouseOrFingerState( e); }, {capture: false}); function resetMouseOrFingerState( e) { if (e.changedTouches) { var releasePoint_2d_px = gW.screenFromRaw_2d_px( clientCanvas_tt, new gW.Vec2D( e.changedTouches[0].clientX, e.changedTouches[0].clientY)); twoThumbs.processSingleTouchRelease( releasePoint_2d_px); } mK.MD = false; // Mouse Down mK.bu = null; // Mouse button number handle_sending_mK_data( mK); } document.addEventListener("keydown", function( e) { //console.log("e.keyCode = " + e.keyCode); // This allows the spacebar to be used for the puck shields. if (keyMap[e.keyCode] == 'sp') { // Inhibit page scrolling that results from using the spacebar. e.preventDefault(); // The following is necessary in Firefox to avoid the spacebar from re-clicking // page controls (like the demo buttons) if they have focus. if (document.activeElement != document.body) document.activeElement.blur(); } //console.log(e.keyCode + "(down)=" + String.fromCharCode(e.keyCode)); if (e.keyCode in keyMap_cso) { console.log("keyMap value = " + e.keyCode + ", " + keyMap_cso[e.keyCode]); if (mK_cso[keyMap_cso[e.keyCode]] == 'U') { // Set the key to DOWN. mK_cso[keyMap_cso[e.keyCode]] = 'D'; } } // Toggle the p2p connection if ((mK_cso.key_p == 'D') && (mK_cso.key_shift == 'D')) { rtc_choke = !rtc_choke; refresh_P2P_indicator({}); // Esc out of full-screen mode (only mildly useful if the twothumbs checkbox is not hidden) // If you're in fullscreen mode, this one won't // be the first to fire. The fullscreenchange handler fires first. Then, after // a second esc key press, this block will execute. } else if (keyMap_cso[e.keyCode] == 'key_esc') { //console.log('in key_esc block'); // Reveal the video element (and hide the canvas). videoMirror.removeAttribute("hidden"); clientCanvas_tt.setAttribute("hidden", null); chkTwoThumbs.checked = false; twoThumbs.enabled = false; } if (e.keyCode in keyMap) { //console.log("keyMap value = " + keyMap[e.keyCode]); if (mK[keyMap[e.keyCode]] == 'U') { // Set the key to DOWN. mK[keyMap[e.keyCode]] = 'D'; handle_sending_mK_data( mK); } } }, {capture: false}); //"false" makes this fire in the bubbling phase (not capturing phase). document.addEventListener("keyup", function(e) { //console.log(e.keyCode + "(up)=" + String.fromCharCode(e.keyCode)); if (e.keyCode in keyMap) { // Set the key to UP. mK[keyMap[e.keyCode]] = 'U'; handle_sending_mK_data( mK); } if (e.keyCode in keyMap_cso) { // Set the key to UP. mK_cso[keyMap_cso[e.keyCode]] = 'U'; } }, {capture: false}); //"false" makes this fire in the bubbling phase (not capturing phase). // Video stream checkbox. chkRequestStream = document.getElementById('chkRequestStream'); chkRequestStream.checked = false; chkRequestStream.addEventListener("click", function() { // You checked it. if (chkRequestStream.checked) { $('#FullScreen').prop('disabled', false); if ($('#roomName').val() == "") { displayMessage(''); displayMessage('You must have a room name in the red box. Try again.'); displayMessage(''); chkRequestStream.checked = false; } else { if (chkTwoThumbs.checked) { // Uncheck twoThumbs (but it's probably hidden unless I'm testing) chkTwoThumbs.click(); } // re-negotiate the connection. window.setTimeout(function() { connect_and_listen('client', 're-connect'); }, 100); } // You unchecked it. } else { $('#FullScreen').prop('disabled', true); if (socket) { var control_message = {'from':cl.name, 'to':'host', 'data':{'videoStream':'off'} }; socket.emit('control message', JSON.stringify( control_message)); // Wait a bit for the above message to get to the host. Then clean out the // video element. window.setTimeout(function() { if (videoMirror.srcObject) videoMirror.srcObject = null; }, 200); } else { displayMessage(''); displayMessage("If you haven't already, please connect to the host."); } } }, {capture: false}); // This control can be useful for testing but is normally hidden. Edit indexClient.html // to un-hide it. chkTwoThumbs = document.getElementById('chkTwoThumbs'); chkTwoThumbs.checked = false; chkTwoThumbs.addEventListener("click", function() { if (chkTwoThumbs.checked) { twoThumbs.changeDisplay('normal'); } else { twoThumbs.changeDisplay('exit'); } }, {capture: false}); // Button for entering the mobile client interface btnTwoThumbs = document.getElementById('twoThumbsButton'); btnTwoThumbs.addEventListener("click", function() { twoThumbs.changeDisplay('fullScreen'); }, {capture: false}); // Full screen button (on client) btnFullScreen = document.getElementById('FullScreen'); btnFullScreen.addEventListener('click', function() { changeFullScreenMode(videoMirror, 'on'); }, {capture: false}); // Local cursor is handy if the engine is paused. Also give visual indicator of lag. chkLocalCursor = document.getElementById('chkLocalCursor'); chkLocalCursor.checked = true; chkLocalCursor.addEventListener("click", function() { //console.log("chkLocalCursor.checked=" + chkLocalCursor.checked); //console.log('in chkLocalCursor, cursor=' + videoMirror.style.cursor + '|'); if (chkLocalCursor.checked) { videoMirror.style.cursor = 'default'; clientCanvas_tt.style.cursor = 'default'; } else { videoMirror.style.cursor = 'none'; clientCanvas_tt.style.cursor = 'none'; } }, {capture: false}); // Option for connecting without a puck. chkPlayer = document.getElementById('chkPlayer'); chkPlayer.checked = true; // For handling the first press of the ESC key (exiting fullscreen mode) $(document).on('webkitfullscreenchange mozfullscreenchange fullscreenchange msfullscreenchange', function(e) { // Check the fullscreen state. // Starting fullscreen if (document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement || document.msFullscreenElement) { console.log('fullscreen state: TRUE'); // Exiting fullscreen } else { console.log('fullscreen state: FALSE'); clientCanvas_tt.width = videoMirror.width; clientCanvas_tt.height = videoMirror.height; twoThumbs.changeDisplay('exit'); } }); } // Reveal public pointers to private functions and properties /////////////// return { //nodeServerURL: nodeServerURL, forceClientDisconnect: forceClientDisconnect, resizeClients: resizeClients, init_chatFeatures: init_chatFeatures, init_nonHostClients: init_nonHostClients, connect_and_listen: connect_and_listen, refresh_P2P_indicator: refresh_P2P_indicator, setCanvasStream: setCanvasStream, changeFullScreenMode: changeFullScreenMode, chatToNonHostPlayers: chatToNonHostPlayers, displayMessage: displayMessage, getGameReportCounter: getGameReportCounter, checkForNickName: checkForNickName, RTC: RTC }; })();