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

บทนำ

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

แทร็ก

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

การคำนวณมิติข้อมูลแทร็ก

เมื่อผู้เล่นแต่ละคนเข้าร่วม ระบบจะส่งข้อมูลเกี่ยวกับอุปกรณ์ของผู้เล่นไปยังเซิร์ฟเวอร์และแชร์กับผู้เล่นคนอื่นๆ เมื่อสร้างแทร็ก ระบบจะใช้ข้อมูลนี้เพื่อคำนวณความสูงและความกว้างของแทร็ก เราจะคำนวณความสูงโดยหาความสูงของหน้าจอที่เล็กที่สุด และความกว้างคือความกว้างทั้งหมดของหน้าจอทั้งหมด ดังนั้นในตัวอย่างด้านล่าง แทร็กจะมีความกว้าง 1,152 พิกเซลและความสูง 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 เพื่อแสดงส่วนของแทร็กที่เหมาะสมได้
จากนั้นจะใช้การเลื่อนแนวนอนเพื่อแสดงส่วนของแทร็กที่เหมาะสมได้

ภาพเคลื่อนไหว 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 การเปลี่ยนจะใช้ภาพเคลื่อนไหวแบบทีละขั้นตอนซึ่งจะเปลี่ยนพร็อพเพอร์ตี้ 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 Sheet ที่วางแนวเป็นแถวเดียวเท่านั้น หากต้องการวนซ้ำหลายแถว คุณต้องเชื่อมโยงภาพเคลื่อนไหวผ่านการประกาศคีย์เฟรมหลายรายการ

#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 นั้นช่วยขยายขีดความสามารถของเทคโนโลยีเบราว์เซอร์ได้อย่างสนุกสนาน และในฐานะนักพัฒนาซอฟต์แวร์ เราไม่มีอะไรจะขอมากไปกว่านี้