กรณีศึกษา - Building Racer

เกริ่นนำ

Racer เป็นการทดลอง Chrome บนอุปกรณ์เคลื่อนที่บนเว็บที่พัฒนาโดย Active Theory สามารถเชื่อมต่อโทรศัพท์หรือแท็บเล็ตกับเพื่อนได้สูงสุด 5 คน เพื่อแข่งขันบนหน้าจอทุกชนิด ด้วยแนวคิด การออกแบบ และต้นแบบจาก Google Creative Lab และเสียงจาก Plan8 เราได้ทำซ้ำในสิ่งที่สร้างขึ้นเป็นเวลา 8 สัปดาห์ก่อนเปิดตัวที่ I/O 2013 ตอนนี้เกมเผยแพร่ไปแล้ว 2-3 สัปดาห์ เราจึงมีโอกาสถามคำถามจากชุมชนนักพัฒนาเกมเกี่ยวกับวิธีการทำงานของเกม ด้านล่างนี้เป็นรายละเอียดฟีเจอร์หลักและคำตอบสำหรับคำถามที่เราถูกถามบ่อยที่สุด

สนามแข่ง

ความท้าทายที่ค่อนข้างชัดเจนที่เราประสบอยู่คือ วิธีสร้างเกมมือถือแบบเว็บที่ทำงานได้ดีในอุปกรณ์ชนิดต่างๆ ผู้เล่นต้องสามารถลงแข่งด้วยโทรศัพท์และแท็บเล็ตที่ต่างกัน ผู้เล่นคนหนึ่งอาจมี Nexus 4 และต้องการแข่งขันกับเพื่อนที่ใช้ iPad เราต้องคิดหาวิธีกำหนดขนาดสนามที่ใช้กันทั่วไปสำหรับการแข่งขันแต่ละครั้ง โซลูชันนี้ต้องใช้แทร็กขนาดต่างๆ กัน ขึ้นอยู่กับข้อมูลจำเพาะของอุปกรณ์แต่ละเครื่องที่ใช้ในการแข่งขัน

การคำนวณขนาดแทร็ก

เมื่อผู้เล่นแต่ละคนเข้าร่วม ระบบจะส่งข้อมูลเกี่ยวกับอุปกรณ์ของแต่ละคนไปยังเซิร์ฟเวอร์และแชร์กับผู้เล่นคนอื่นๆ เมื่อมีการสร้างแทร็ก ระบบจะใช้ข้อมูลนี้ในการคำนวณความสูงและความกว้างของแทร็ก เราคำนวณความสูงโดยหาความสูงของหน้าจอที่เล็กที่สุด และความกว้างคือความกว้างรวมของหน้าจอทั้งหมด ดังนั้นในตัวอย่างด้านล่างแทร็กจะมีความกว้าง 1152 พิกเซลและความสูง 519 พิกเซล

พื้นที่สีแดงจะแสดงความกว้างและความสูงทั้งหมดของแทร็กสำหรับตัวอย่างนี้
สำหรับตัวอย่างนี้ พื้นที่สีแดงจะแสดงความกว้างและความสูงทั้งหมดของแทร็ก
this.getDimensions = function () {
  var response = {};
  response.width = 0;
  response.height = _gamePlayers[0].scrn.h; // First screen height
  response.screens = [];
  
  for (var i = 0; i < _gamePlayers.length; i++) {
    var player = _gamePlayers[i];
    response.width += player.scrn.w;

    if (player.scrn.h < response.height) {
      // Find the smallest screen height
      response.height = player.scrn.h;
    }
      
    response.screens.push(player.scrn);
  }
  
  return response;
}

การวาดเส้น

Paper.js เป็นเฟรมเวิร์กการเขียนสคริปต์เวกเตอร์แบบโอเพนซอร์สที่ทำงานบน Canvas ของ HTML5 เราพบว่า Paper.js เป็นเครื่องมือที่ยอดเยี่ยมในการสร้างรูปร่างเวกเตอร์สำหรับแทร็ก เราจึงใช้ความสามารถของเครื่องมือนี้ในการแสดงผลแทร็ก SVG ที่สร้างใน Adobe Illustrator บนองค์ประกอบ <canvas> ในการสร้างแทร็ก คลาส TrackModel จะเพิ่มโค้ด SVG ต่อท้าย DOM และรวบรวมข้อมูลเกี่ยวกับขนาดเดิมและการวางตำแหน่งเพื่อส่งไปยัง TrackPathView ซึ่งจะวาดแทร็กไปยังผืนผ้าใบ

paper.install(window);
_paper = new paper.PaperScope();
_paper.setup('track_canvas');
                    
var svg = document.getElementById('track');
var layer = new _paper.Layer();

_path = layer.importSvg(svg).firstChild.firstChild;
_path.strokeColor = '#14a8df';
_path.strokeWidth = 2;

เมื่อวาดแทร็กแล้ว อุปกรณ์แต่ละเครื่องจะคำนวณระยะห่าง x ตามตำแหน่งในลำดับรายการอุปกรณ์ จากนั้นจึงจัดตำแหน่งแทร็กตามนั้น

var x = 0;

for (var i = 0; i < screens.length; i++) {
  if (i < PLAYER_INDEX) {
    x += screens[i].w;
  }
}
จากนั้นสามารถใช้ออฟเซ็ต x เพื่อแสดงส่วนที่เหมาะสมของแทร็กได้
จากนั้นสามารถใช้ออฟเซ็ต x เพื่อแสดงส่วนที่เหมาะสมของแทร็ก

ภาพเคลื่อนไหว CSS

Paper.js ใช้การประมวลผลของ CPU อย่างมากเพื่อวาดช่องทางการติดตาม และกระบวนการนี้จะใช้เวลามากหรือน้อยลงในอุปกรณ์ต่างๆ ในการจัดการกับปัญหานี้ เราจำเป็นต้องให้ตัวโหลดทำงานวนซ้ำจนกว่าอุปกรณ์ทั้งหมดจะประมวลผลแทร็กให้เสร็จสิ้น ปัญหาคือภาพเคลื่อนไหวที่ใช้ JavaScript จะข้ามเฟรมเนื่องจากข้อกำหนดของ CPU ของ Paper.js ป้อนภาพเคลื่อนไหวของ CSS ซึ่งทำงานในเธรด UI ที่แยกต่างหาก ช่วยให้เราสร้างภาพเคลื่อนไหวแถบสีทั่วทั้งข้อความ "สร้างแทร็ก" ได้อย่างราบรื่น

.glow {
  width: 290px;
  height: 290px;
  background: url('img/track-glow.png') 0 0 no-repeat;
  background-size: 100%;
  top: 0;
  left: -290px;
  z-index: 1;
  -webkit-animation: wipe 1.3s linear 0s infinite;
}

@-webkit-keyframes wipe {
  0% {
    -webkit-transform: translate(-300px, 0);
  }

  25% {
    -webkit-transform: translate(-300px, 0);
  }

  75% {
    -webkit-transform: translate(920px, 0);
  }

  100% {
    -webkit-transform: translate(920px, 0);
  }
}
}

CSS Sprite

CSS ยังมีประโยชน์สำหรับเอฟเฟกต์ในเกมด้วย อุปกรณ์เคลื่อนที่ที่มีพลังงานจำกัดมักจะแสดงภาพเคลื่อนไหวของรถวิ่งบนลู่ ดังนั้นเพื่อเพิ่มความตื่นเต้น เราจึงใช้สไปรท์เป็นวิธีหนึ่งในการใช้ภาพเคลื่อนไหวที่แสดงผลล่วงหน้าลงในเกม ใน CSS Sprite การเปลี่ยนจะใช้ภาพเคลื่อนไหวแบบทีละขั้นตอนซึ่งเปลี่ยนพร็อพเพอร์ตี้ background-position ทำให้เกิดการระเบิดของรถยนต์

#sprite {
  height: 100px; 
  width: 100px;
  background: url('sprite.jpg') 0 0 no-repeat;
  -webkit-animation: play-sprite 0.33s linear 0s steps(9) infinite;
}

@-webkit-keyframes play-sprite {
  0% {
    background-position: 0 0;
  }

  100% {
    background-position: -900px 0;
  }
}

ปัญหาของเทคนิคนี้คือคุณสามารถใช้ได้เฉพาะภาพต่อเรียงที่จัดวางในแถวเดียวเท่านั้น หากต้องการวนซ้ำหลายแถว ภาพเคลื่อนไหวต้องเชนกันผ่านการประกาศคีย์เฟรมหลายรายการ

#sprite {
  height: 100px; 
  width: 100px;
  background: url('sprite.jpg') 0 0 no-repeat;
  -webkit-animation-name: row1, row2, row3;
  -webkit-animation-duration: 0.2s;
  -webkit-animation-delay: 0s, 0.2s, 0.4s;
  -webkit-animation-timing-function: steps(5), steps(5), steps(5);
  -webkit-animation-fill-mode: forwards;
}

@-webkit-keyframes row1 {
  0% {
    background-position: 0 0;
  }

  100% {
    background-position: -500px 0;
  }
}

@-webkit-keyframes row2 {
  0% {
    background-position: 0 -100px;
  }

  100% {
    background-position: -500px -100px;
  }
}

@-webkit-keyframes row3 {
  0% {
    background-position: 0 -200px;
  }

  100% {
    background-position: -500px -200px;
  }
}

การแสดงภาพรถยนต์

อย่างเกมแข่งรถอื่นๆ ที่เรารู้กันดีว่าผู้ใช้ต้องรู้สึกถึงความเร่งและความเร็วขณะรับมือ การใช้แรงดึงในจำนวนที่ต่างกันเป็นสิ่งสำคัญต่อการสร้างสมดุลของเกมและปัจจัยด้านความสนุก ดังนั้นเมื่อผู้เล่นรู้สึกถึงหลักฟิสิกส์ ก็จะรู้สึกถึงความสำเร็จและกลายเป็นนักแข่งรถที่ดีขึ้น

อีกครั้งเราได้เรียกใช้ Paper.js ซึ่งมาพร้อมกับยูทิลิตีทางคณิตศาสตร์มากมาย เราใช้วิธีการบางอย่างเพื่อเคลื่อนรถไปตามเส้นทาง ในขณะเดียวกันก็ปรับตำแหน่งและการหมุนของรถให้ลื่นไหลในแต่ละเฟรม

var trackOffset = _path.length - (_elapsed % _path.length);
var trackPoint = _path.getPointAt(trackOffset);
var trackAngle = _path.getTangentAt(trackOffset).angle;

// Apply the throttle
_velocity.length += _throttle;

if (!_throttle) {
  // Slow down since the throttle is off
  _velocity.length *= FRICTION;
}

if (_velocity.length > MAXVELOCITY) {
  _velocity.length = MAXVELOCITY;
}

_velocity.angle = trackAngle;
trackOffset -= _velocity.length;
_elapsed += _velocity.length;

// Find if a lap has been completed
if (trackOffset < 0) {
  while (trackOffset < 0) trackOffset += _path.length;

  trackPoint = _path.getPointAt(trackOffset);
  console.log('LAP COMPLETE!');
}

if (_velocity.length > 0.1) {
  // Render the car if there is actually velocity
  renderCar(trackPoint);
}

ขณะที่เราเพิ่มประสิทธิภาพการแสดงภาพรถยนต์ เราพบจุดที่น่าสนใจ ส่วนใน iOS ประสิทธิภาพสูงสุดคือการใช้การแปลง translate3d กับรถยนต์ ดังนี้

_car.style.webkitTransform = 'translate3d('+_position.x+'px, '+_position.y+'px, 0px)rotate('+_rotation+'deg)';

ใน Chrome สำหรับ Android ได้ประสิทธิภาพที่ดีที่สุดโดยการคำนวณค่าเมทริกซ์และใช้การเปลี่ยนรูปแบบเมทริกซ์ ดังนี้

var rad = _rotation.rotation * (Math.PI * 2 / 360);
var cos = Math.cos(rad);
var sin = Math.sin(rad);
var a = parseFloat(cos).toFixed(8);
var b = parseFloat(sin).toFixed(8);
var c = parseFloat(-sin).toFixed(8);
var d = a;
_car.style.webkitTransform = 'matrix(' + a + ', ' + b + ', ' + c + ', ' + d + ', ' + _position.x + ', ' + _position.y + ')';

การทำให้อุปกรณ์ซิงค์อยู่เสมอ

ส่วนที่สำคัญที่สุด (และยากที่สุด) ของการพัฒนาคือการตรวจสอบว่าเกมซิงค์กันในอุปกรณ์ต่างๆ เราคิดว่าผู้ใช้ต้องให้อภัยหากบางครั้งรถข้ามเฟรมหลายเฟรมเพราะการเชื่อมต่อช้า แต่คงไม่สนุกนักหากรถของคุณกระโดดไปมาและปรากฏในหลายหน้าจอพร้อมกัน การแก้ไขปัญหานี้ต้องอาศัยการลองผิดลองถูกมากมาย แต่ในที่สุดเราก็ได้กลเม็ดเคล็ดลับสัก 2-3 อย่างที่ได้ผล

กำลังคำนวณเวลาในการตอบสนอง

จุดเริ่มต้นของการซิงค์อุปกรณ์คือทราบระยะเวลาที่จะได้รับข้อความจากการส่งต่อของ Compute Engine ส่วนที่ยากก็คือนาฬิกาในอุปกรณ์แต่ละเครื่องจะไม่ซิงค์กันโดยสมบูรณ์ ในการแก้ปัญหานี้ เราต้องหาความแตกต่างของระยะเวลาระหว่างอุปกรณ์กับเซิร์ฟเวอร์

หากต้องการค้นหาการชดเชยเวลาระหว่างอุปกรณ์กับเซิร์ฟเวอร์หลัก เราจะส่งข้อความที่มีการประทับเวลาปัจจุบันของอุปกรณ์ จากนั้นเซิร์ฟเวอร์จะตอบกลับด้วยการประทับเวลาเดิมพร้อมกับการประทับเวลาของเซิร์ฟเวอร์ เราจะใช้ผลตอบกลับดังกล่าวเพื่อคำนวณส่วนต่างจริงของเวลา

var currentTime = Date.now();
var latency = Math.round((currentTime - e.time) * .5);
var serverTime = e.serverTime;
currentTime -= latency;
var difference = currentTime - serverTime;

การดำเนินการเช่นนี้เพียงครั้งเดียวนั้นไม่เพียงพอ เนื่องจากการเดินทางแบบไป-กลับไปยังเซิร์ฟเวอร์อาจมีความไม่สมมาตรเสมอไป ซึ่งหมายความว่า อาจใช้เวลานานขึ้นกว่าที่การตอบกลับไปยังเซิร์ฟเวอร์จะใช้เวลานานกว่าที่เซิร์ฟเวอร์จะส่งกลับ ในการแก้ปัญหานี้ เราจะสอบถามเซิร์ฟเวอร์หลายครั้ง โดยใช้ผลลัพธ์ตามค่ามัธยฐาน ซึ่งทำให้เราภายใน 10 มิลลิวินาทีของความแตกต่างจริงระหว่างอุปกรณ์กับเซิร์ฟเวอร์

ความเร่ง/ลดความเร็ว

เมื่อผู้เล่น 1 กดหรือปล่อยหน้าจอ ระบบจะส่งเหตุการณ์การเร่งความเร็วไปยังเซิร์ฟเวอร์ เมื่อได้รับแล้ว เซิร์ฟเวอร์จะเพิ่มการประทับเวลาปัจจุบันของตน แล้วส่งผ่านข้อมูลนั้นไปยังโปรแกรมเล่นอื่นทั้งหมด

เมื่ออุปกรณ์ได้รับเหตุการณ์ "เร่งความเร็ว" หรือ "เร่งปิด" เราจะสามารถใช้ออฟเซ็ตของเซิร์ฟเวอร์ (ตามการคำนวณด้านบน) เพื่อดูระยะเวลาที่ใช้ในการรับข้อความ ซึ่งจะเป็นประโยชน์ เนื่องจากผู้เล่น 1 อาจได้รับข้อความภายใน 20 มิลลิวินาที แต่ผู้เล่น 2 อาจใช้เวลา 50 มิลลิวินาทีในการรับข้อความ ซึ่งจะส่งผลให้รถอยู่ใน 2 ตำแหน่งที่แตกต่างกัน เนื่องจากอุปกรณ์ 1 จะเริ่มเร่งความเร็วให้เร็วขึ้น

เราสามารถใช้เวลาในการรับเหตุการณ์และแปลงเป็นเฟรม ที่ 60 FPS แต่ละเฟรมจะมีระยะเวลา 16.67 มิลลิวินาที เราจึงเพิ่มอัตราความเร็ว (การเร่งความเร็ว) หรือความเสียดทาน (ลดความเร็ว) ให้รถเพื่อชดเชยเฟรมที่พลาดไปได้

var frames = time / 16.67;
var onScreen = this.isOnScreen() && time < 75;

for (var i = 0; i < frames; i++) {
  if (onScreen) {
    _velocity.length += _throttle * Math.round(frames * .215);
  } else {
    _this.render();
  }
}}

ในตัวอย่างด้านบน หากผู้เล่น 1 มีรถบนหน้าจอและเวลาที่ใช้ในการรับข้อความน้อยกว่า 75 มิลลิวินาที ระบบจะปรับความเร็วของรถ และเร่งความเร็วเพื่อสร้างความแตกต่าง หากอุปกรณ์ไม่ได้อยู่บนหน้าจอหรือข้อความใช้เวลานานเกินไป ระบบจะเรียกใช้ฟังก์ชันการแสดงภาพและทำให้รถข้ามไปยังตำแหน่งที่ต้องการจริงๆ

ซิงค์รถยนต์

แม้จะต้องคำนึงถึงเวลาในการตอบสนองของความเร่งแล้ว รถก็ยังคงซิงค์ไม่ได้และปรากฏบนหน้าจอหลายหน้าจอพร้อมกัน โดยเฉพาะเมื่อเปลี่ยนจากอุปกรณ์หนึ่งไปยังอีกอุปกรณ์หนึ่ง เพื่อป้องกันไม่ให้เหตุการณ์ดังกล่าวเกิดขึ้น ระบบจะส่งเหตุการณ์การอัปเดตบ่อยๆ เพื่อให้รถอยู่ในตำแหน่งเดิมในแทร็กบนทุกหน้าจอ

ตรรกะคือทุกๆ 4 เฟรม หากรถแสดงบนหน้าจอ อุปกรณ์จะส่งค่าของตนไปยังอุปกรณ์อื่นๆ แต่ละเครื่อง หากไม่เห็นรถ แอปจะอัปเดตค่าด้วยค่าที่ได้รับ แล้วเลื่อนรถยนต์ไปข้างหน้าตามเวลาที่ใช้เพื่อรับเหตุการณ์การอัปเดต

this.getValues = function () {
  _values.p = _position.clone();
  _values.r = _rotation;
  _values.e = _elapsed;
  _values.v = _velocity.length;
  _values.pos = _this.position;

  return _values;
}

this.setValues = function (val, time) {
  _position.x = val.p.x;
  _position.y = val.p.y;
  _rotation = val.r;
  _elapsed = val.e;
  _velocity.length = val.v;

  var frames = time / 16.67;

  for (var i = 0; i < frames; i++) {
    _this.render();
  }
}

บทสรุป

พอได้ทราบแนวคิดสำหรับ Racer เราก็รู้ว่าโครงการนี้มีโอกาสที่จะเป็นโปรเจ็กต์ที่พิเศษมากๆ เราได้สร้างต้นแบบอย่างรวดเร็วซึ่งทำให้เรามีแนวคิดคร่าวๆ เกี่ยวกับวิธีเอาชนะเวลาในการตอบสนองและประสิทธิภาพของเครือข่าย เป็นโครงการที่ท้าทายซึ่งทำให้เรายุ่งทั้งในช่วงกลางดึกและช่วงวันหยุดยาว แต่เกมก็เริ่มเป็นรูปเป็นร่างเป็นความคิดที่ดี ในท้ายที่สุด เราพอใจกับผลลัพธ์ที่ได้ แนวคิดของ Google Creative Lab ได้ขยายขีดจำกัดของเทคโนโลยีเบราว์เซอร์ในรูปแบบที่สนุกสนาน และในฐานะนักพัฒนาซอฟต์แวร์ที่เราไม่สามารถขออะไรเพิ่มเติมได้อีก