บทที่ 6.2 เคยพูดถึง game feel (ความรู้สึกตอนเล่นเกม) ไว้เล็กน้อย: coyote time กับ jump buffering ทำให้การกระโดดรู้สึก "ดีขึ้น" ทั้งที่คณิตศาสตร์เบื้องหลังแทบไม่เปลี่ยนเลย บทนี้จะขยายไอเดียนั้นให้กว้างขึ้น จังหวะการเคลื่อนไหวเป็นแค่เสี้ยวหนึ่งของ feel เท่านั้น -- การฟันดาบ เปิดเมนู เก็บเหรียญ โดนตี ทุกอย่างพวกนี้ต้องการ feedback (การตอบสนองที่ผู้เล่นรับรู้ได้) เป็นของตัวเองด้วย ไม่งั้นมันจะรู้สึกตายด้านและเป็นเครื่องจักร ทั้งที่โค้ดข้างในถูกต้องสมบูรณ์แบบ
คำแสลงในวงการที่ใช้เรียกเลเยอร์นี้คือ juice (คุณจะเห็นคำว่า "feedback design" หรือ "UX polish" ในงานเขียนที่เป็นทางการกว่านี้ด้วย) บทนี้จะสร้างชิ้นส่วนจริง ๆ ขึ้นมา: การกะพริบหน้าจอ, screen shake ที่ทำถูกวิธี, เสียง, controller rumble, การเด้ง (pop) ที่ขับเคลื่อนด้วย easing curve, และกฎเรื่องความไว (responsiveness) แบบเฟรมต่อเฟรมที่ทำให้อินพุตรู้สึกทันทีทันใด นอกจากนี้ยังพูดถึงจุดที่ juice กลายเป็นปัญหา -- ทั้งเรื่อง accessibility (การเข้าถึงสำหรับผู้เล่นทุกกลุ่ม) และความชัดเจนในเกมแข่งขัน -- และวิธีลดมันลงอย่างตั้งใจ
Juice คือ feedback ทุกชิ้นที่ห่อหุ้มรอบ ๆ การกระทำหนึ่ง ๆ ซึ่งพูดตามตรงแล้วไม่จำเป็นต้องมีเพื่อให้การกระทำนั้นทำงานได้ ถ้าผู้เล่นกดโจมตีแล้วเลขเลือดของศัตรูลดลง เกมก็ทำงานถูกต้องแล้ว ไม่มีอะไรในนั้นที่ต้องการแสงกะพริบ เสียง การสั่นจอ หรือ rumble เลย แต่เวอร์ชันที่ไม่มีสิ่งเหล่านี้เลยจะรู้สึกไร้ชีวิตชีวา ในขณะที่เวอร์ชันที่มีครบจะรู้สึกสะใจ ทั้งที่กฎเบื้องหลัง -- health -= damage -- เหมือนกันทุกไบต์ในทั้งสองแบบ
นี่คือไอเดียเดียวกันในรูปแบบโค้ด เป็นตัวอย่างล่วงหน้าว่าแต่ละบรรทัดจะถูกสร้างขึ้นตรงไหนในบทนี้:
// no feedback -- functionally correct, feels dead
void OnAttackButton()
{
enemy.health -= attackDamage;
}
// juiced -- the exact same rule, wrapped in feedback
void OnAttackButton()
{
enemy.health -= attackDamage;
enemy.FlashHit(); // section 2
audioSource.PlayOneShot(hitClip); // section 3
Gamepad.current?.SetMotorSpeeds(0.3f, 0.3f); // section 4
cameraShake.AddTrauma(0.2f); // section 7
popupSpawner.Spawn(attackDamage, enemy.transform.position); // section 2
}
ทำไมเรื่องนี้ถึงสำคัญมาก เพราะผู้เล่นไม่เคยเห็นข้อมูล (data) ของคุณเลย พวกเขาไม่เห็น health เป็นตัวเลขที่นั่งอยู่ในหน่วยความจำ -- พวกเขาเห็นแค่สิ่งที่เกมแสดงและเล่นย้อนกลับให้ดู feedback คือ ตัวเชื่อมระหว่างระบบจำลอง (simulation) ของคุณกับความเข้าใจของผู้เล่นที่มีต่อมัน กฎที่ไม่มี feedback เลยก็แทบจะเหมือนไม่มีอยู่จริงในสายตาผู้เล่น ส่วนกฎที่มี feedback ปรับแต่งมาดีจะรู้สึกมีน้ำหนักและมีผลตามมาจริง ๆ ซึ่งเป็นส่วนสำคัญมากที่ทำให้บางเกม "จับต้องแล้วรู้สึกดี" ในขณะที่บางเกมไม่เป็นแบบนั้น ทั้งที่กลไกคล้ายกันบนกระดาษ
Feedback ทางภาพเป็นช่องทางที่ผู้เล่นสังเกตเห็นก่อนใคร เพราะสายตาพวกเขาจ้องอยู่ที่หน้าจออยู่แล้ว มีเทคนิคภาพหลัก ๆ 4 อย่างที่ครอบคลุมสถานการณ์ส่วนใหญ่: การกะพริบสีสั้น ๆ บนสิ่งที่โดนตี (hit flash), screen shake (หัวข้อใหญ่พอที่จะมีเป็นของตัวเองในหัวข้อที่ 7), particle ที่ระเบิดออกมา และตัวเลขลอย (number popup)
Hit flash คือการทำให้ sprite หรือโมเดลเปลี่ยนเป็นสีสว่าง (ปกติคือสีขาว) อยู่ไม่กี่เฟรม แล้วค่อย ๆ จางกลับไปเป็นสีปกติ นี่เป็นเอฟเฟกต์ที่ถูกที่สุดแต่ให้ผลคุ้มค่าที่สุดอันหนึ่งในบทนี้ทั้งบท -- แค่ coroutine เดียว ก็ทำให้ทุกการโดนตีอ่านออกได้ทันที
using UnityEngine;
using System.Collections;
public class HitFlash : MonoBehaviour
{
public Color flashColor = Color.white;
public float flashDuration = 0.1f;
SpriteRenderer sr;
Color baseColor;
void Awake()
{
sr = GetComponent<SpriteRenderer>();
baseColor = sr.color;
}
public void Flash()
{
StopAllCoroutines();
StartCoroutine(FlashRoutine());
}
IEnumerator FlashRoutine()
{
float t = 0f;
while (t < flashDuration)
{
t += Time.deltaTime;
sr.color = Color.Lerp(flashColor, baseColor, t / flashDuration);
yield return null;
}
sr.color = baseColor;
}
}
ไล่ค่าจริงที่ 50 FPS (Time.deltaTime = 0.02s ทำให้ flashDuration = 0.1s พอดี 5 เฟรม):
Color.Lerp(a, b, t) เคลื่อน จาก a ไปทาง b เมื่อ t เพิ่มขึ้น -- ดังนั้นที่ t = 0.2 ผลลัพธ์ยังคงเป็น flashColor เป็นส่วนใหญ่ (แฟลชเพิ่งเริ่ม) และที่ t = 1.0 มันไปถึง baseColor เต็มที่แล้ว sprite จะเด้งเป็นสีขาวทันทีในเฟรมที่เรียก Flash() (เพราะมันเริ่ม loop ด้วย coroutine ใหม่) แล้วค่อย ๆ จางกลับไปในช่วง 5 เฟรมถัดไป
Number popup คือข้อความลอยเล็ก ๆ ("-10", "+250 gold") ที่เกิดขึ้นตรงจุดที่โดนตีแล้วลอยขึ้นก่อนจะหายไป นี่คือเวอร์ชันพื้นฐาน ก่อนที่หัวข้อที่ 5 จะทำให้การเคลื่อนไหวของมันดีขึ้น:
public class NumberPopup : MonoBehaviour
{
public TMP_Text label;
public float riseSpeed = 1.5f;
public float lifetime = 0.6f;
float age;
public void Init(int amount)
{
label.text = amount.ToString();
}
void Update()
{
age += Time.deltaTime;
transform.position += Vector3.up * riseSpeed * Time.deltaTime;
if (age >= lifetime)
Destroy(gameObject);
}
}
อันนี้เคลื่อนที่ด้วยความเร็วคงที่แล้วก็หายวับไปตอนจบ -- ใช้งานได้ แต่แข็งทื่อไปนิด ในหัวข้อที่ 5 เราจะแทนที่การลอยขึ้นด้วยความเร็วคงที่ ด้วยการเคลื่อนไหวแบบ eased (เด้งขึ้นเร็ว ๆ แล้วค่อย ๆ นิ่ง จากนั้นค่อยจาง) โดยใช้ระบบ tween ชุดเดียวกันเป๊ะ ๆ
การระเบิด particle สั้น ๆ (ประกายไฟ ฝุ่น เลือด หรือแสงเวทมนตร์) ช่วยขายความรู้สึก impact ตรงจุดสัมผัส ในเอนจินส่วนใหญ่นี่แค่บรรทัดเดียวเมื่อตั้งค่า particle system asset เสร็จแล้ว:
hitParticles.transform.position = contactPoint;
hitParticles.Play();
Particles จะอ่านออกได้ดีที่สุดเมื่อรวมกับ hit flash และเสียงในจังหวะเดียวกันเป๊ะ ๆ -- สามช่องทางยืนยันเหตุการณ์เดียวกันมักรู้สึกหนักแน่นกว่าช่องทางใดช่องทางหนึ่งเพียงลำพังมาก ซึ่งเป็นไอเดียหลักของสองหัวข้อถัดไป
กฎข้อเดียวที่สำคัญที่สุดสำหรับ feedback ทางเสียงคือ ทุกการกระทำที่มีความหมายควรมีเสียง ไม่ใช่แค่การกระทำใหญ่ ๆ เท่านั้น เสียงฝีเท้า คลิกเมนู เก็บไอเทม โดนดาเมจ คูลดาวน์หมดเวลา -- ความเงียบในสิ่งเหล่านี้จะทำให้ผู้เล่นรู้สึกว่า "มันทำงานจริงหรือเปล่า" แม้ว่า feedback ทางภาพจะมีอยู่ก็ตาม เสียงมักไปถึงสมองเร็วกว่าการอ่านภาพแบบเต็ม ๆ ซึ่งเป็นเหตุผลว่าทำไมมันถึงเป็นสัญญาณ "ใช่ มันทำงานแล้ว" ที่ทรงพลังมาก
กฎข้อที่สอง: หลีกเลี่ยงการเล่นเสียงเดิม ๆ ซ้ำ ๆ โดยไม่มีความหลากหลาย ฝีเท้าหรือการโจมตีที่เสียงเหมือนกันเป๊ะทุกเฟรม ๆ จะรู้สึกเหมือนหุ่นยนต์ การสุ่ม pitch เล็กน้อยแก้ปัญหานี้ได้แทบจะฟรี ๆ:
using UnityEngine;
public class SfxOneShot : MonoBehaviour
{
public AudioSource source;
public AudioClip clip;
[Range(0f, 0.2f)] public float pitchVariance = 0.08f;
public void Play()
{
source.pitch = 1f + Random.Range(-pitchVariance, pitchVariance);
source.PlayOneShot(clip);
}
}
ตัวอย่างลำดับการเรียกทั่วไป (ตัวเลขจริงจะต่างกันทุกครั้งที่รัน -- บทที่ 2.6 อธิบายไว้แล้วว่าทำไม Random ที่ไม่ได้ตั้ง seed ถึงให้ลำดับต่างกันทุกครั้ง -- แต่ค่าจะอยู่ในช่วง [0.92, 1.08] เสมอในกรณีนี้):
PlayOneShot (แทนที่จะเป็น Play) ทำให้เสียงชุดเดียวกันหลายชุดเล่นทับกันได้โดยไม่ตัดกันเอง -- สำคัญมากสำหรับการกระทำที่เร็วและซ้ำ ๆ เช่นปืนกลหรือคอมโบโจมตี ที่การโดนตีสองครั้งห่างกันแค่หนึ่งเฟรมควรได้ยินทั้งคู่Haptics หมายถึง feedback ที่ผู้เล่นรู้สึกได้ทางกายภาพ -- controller rumble หรือการสั่นของโทรศัพท์ นี่เป็นช่องทางที่ถูกใช้น้อยที่สุดในสามช่องทาง เพราะไม่ใช่ทุกอุปกรณ์อินพุตจะรองรับ แต่ในอุปกรณ์ที่รองรับ มันมีประสิทธิภาพมากในการขายความรู้สึก "น้ำหนัก": พัลส์สั้น ๆ แรง ๆ จะรู้สึกเหมือน impact ส่วนพัลส์ยาว ๆ เบา ๆ จะรู้สึกเหมือนความตึงเครียดหรือเครื่องยนต์ที่กำลังเดินเบา
using UnityEngine;
using UnityEngine.InputSystem;
using System.Collections;
public class RumbleFeedback : MonoBehaviour
{
public IEnumerator RumblePulse(float lowFreq, float highFreq, float duration)
{
var pad = Gamepad.current;
if (pad == null) yield break; // keyboard/mouse player, or no controller -- do nothing
pad.SetMotorSpeeds(lowFreq, highFreq);
yield return new WaitForSeconds(duration);
pad.SetMotorSpeeds(0f, 0f);
}
}
เงื่อนไข if (pad == null) yield break; สำคัญมาก: ไม่ใช่ผู้เล่นทุกคนจะมี gamepad เสียบอยู่ ดังนั้น haptics ต้องเป็นเลเยอร์เสริมทางเลือกที่ซ้อนอยู่บน feedback ทางภาพและเสียงเสมอ ไม่ใช่สัญญาณเดียวที่บอกว่าเกิดอะไรขึ้น บนมือถือ สิ่งที่เทียบเท่ากันคือเรียกฟังก์ชันเดียว: Handheld.Vibrate(); (สั่นสั้น ๆ แบบตายตัว -- อุปกรณ์มือถือโดยทั่วไปไม่เปิดให้ควบคุม rumble แบบละเอียดเหมือน controller เครื่องคอนโซล)
บทที่ 2.4 พูดถึง lerp(a, b, t) และรูปทรง easing สามแบบไปแล้ว: easeIn(t) = t * t (เริ่มช้า) easeOut(t) = 1 - (1-t) * (1-t) (เริ่มเร็ว จบนุ่มนวล) และ easeInOut (ช้าทั้งสองปลาย) สูตรพวกนั้นตอนนั้นเป็นแค่คณิตศาสตร์ล้วน ๆ แต่ในบทนี้มันจะกลายเป็นกระดูกสันหลังของทุกแอนิเมชัน UI และเอฟเฟกต์เด้งในบทนี้
Tween (ย่อมาจาก "in-betweening" คำที่ยืมมาจากวงการแอนิเมชันดั้งเดิม) คือโค้ดที่เคลื่อนค่าหนึ่งจากจุดเริ่มต้นไปยังจุดปลายทางภายในระยะเวลาหนึ่ง โดยใช้ easing function กำหนดรูปทรงของการเคลื่อนไหวนั้น แทนที่จะเคลื่อนด้วยความเร็วคงที่ นี่คือ tween แบบเล็ก ๆ ที่นำกลับมาใช้ซ้ำได้:
using UnityEngine;
public static class Easing
{
public static float Linear(float t) => t;
public static float EaseIn(float t) => t * t;
public static float EaseOut(float t) => 1f - (1f - t) * (1f - t);
public static float EaseInOut(float t)
{
if (t < 0.5f) return 2f * t * t;
float u = -2f * t + 2f;
return 1f - (u * u) / 2f;
}
}
using UnityEngine;
using System.Collections;
public static class Tween
{
public static IEnumerator Float(float from, float to, float duration,
System.Func<float, float> ease,
System.Action<float> onUpdate)
{
float t = 0f;
while (t < duration)
{
t += Time.deltaTime;
float normalized = Mathf.Clamp01(t / duration);
float eased = ease(normalized);
onUpdate(Mathf.LerpUnclamped(from, to, eased));
yield return null;
}
onUpdate(to);
}
}
ใช้มันเพื่อเด้งขนาด (scale) ของแผง UI จาก 0 ไป 1:
StartCoroutine(Tween.Float(0f, 1f, 0.1f, Easing.EaseOut, v =>
{
panel.localScale = Vector3.one * v;
}));
ไล่ค่าจริงที่ 50 FPS (duration = 0.1s = 5 เฟรม -- ตัวเลขเดียวกันเป๊ะกับตาราง easeOut ในบทที่ 2.4 เพียงแต่คราวนี้เอามาใช้กับค่า scale จริง ๆ แทนที่จะเป็นตัวเลขนามธรรม):
เทียบกับ Linear ที่จะไล่ค่า 0.2, 0.4, 0.6, 0.8, 1.0 ที่เฟรมเดียวกันเป๊ะ -- ความเร็วคงที่ และหยุดกึกทันทีที่ถึง 1.0 การหยุดกึกแบบนั้นแหละที่ทำให้การเคลื่อนไหวเส้นตรงรู้สึก "เหมือนคอมพิวเตอร์": ไม่มีอะไรในโลกจริงหยุดกะทันหันขนาดนั้น EaseOut เคลื่อนที่ไปไกลกว่าในช่วงต้น (ตอนที่อินพุตเพิ่งเกิดขึ้นและความเร็วอ่านออกว่าเป็นความไว) และเคลื่อนน้อยลงในช่วงท้าย (ทำให้การหยุดดูนุ่มนวลแทนที่จะกระชาก)
panel.transform.DOScale(1f, 0.1f).SetEase(Ease.OutQuad); บรรทัดนั้นทำสิ่งเดียวกันเป๊ะกับ coroutine ข้างบน -- เคลื่อนค่าหนึ่งไปตามช่วงเวลาผ่าน easing curve -- เพียงแต่มี curve สำเร็จรูปให้เลือกหลายสิบแบบและไม่ต้องเขียน boilerplate เข้าใจเวอร์ชัน coroutine ไว้ก็จะไม่มีวันติดขัด ถ้าวันไหนไม่มีไลบรารี tweening ให้ใช้ หรือต้องการ curve ที่มันไม่มีAnticipation (การเหวี่ยงตัวก่อน) คือการเคลื่อนไหวเล็ก ๆ ในทิศตรงข้ามกับการกระทำหลัก ก่อนที่การกระทำจริงจะเกิด -- ตัวละครย่อตัวเล็กน้อยก่อนกระโดด คันธนูดึงกลับก่อนยิงออกไป Overshoot (การเด้งเลยเป้า) พร้อมกับการนิ่งกลับที่เรียกว่า follow-through คือปลายอีกด้านของไอเดียเดียวกัน: แทนที่จะหยุดพอดีที่ค่าเป้าหมาย การเคลื่อนไหวจะเลยไปนิดหน่อยก่อนแล้วค่อย ๆ กลับมา ทั้งสองอย่างมาจากแอนิเมชันวาดมือแบบดั้งเดิม และทั้งคู่มีอยู่ด้วยเหตุผลเดียวกัน: สิ่งของจริงทางกายภาพมีมวล และมวลไม่เคยเริ่มหรือหยุดตรงเป๊ะตามจังหวะที่สั่ง -- มันเหวี่ยงตัวก่อนและมันก็เลยเป้าไปด้วย
จุดที่รู้สึกถึง overshoot ได้ชัดที่สุดคือ UI ที่ "เด้ง" ขึ้นมา -- ปุ่มที่ปรากฏขึ้นจากขนาด 0% โตเลย 100% แล้วค่อยนิ่งกลับมาที่ 100% พอดี EaseOut แบบธรรมดาจะหยุดพอดีที่เป้าหมายโดยไม่มี overshoot เลย การจะได้เอฟเฟกต์เด้งต้องใช้รูปทรง curve ที่ต่างออกไป:
public static class Easing
{
// ... EaseIn / EaseOut / EaseInOut from section 5 ...
public static float EaseOutBack(float t)
{
const float s = 1.70158f; // overshoot amount -- bigger s, bigger pop
float u = t - 1f;
return 1f + u * u * ((s + 1f) * u + s);
}
}
อันนี้คุ้มค่าที่จะรันดูจริง ๆ เพราะเป็นคณิตศาสตร์ล้วน ๆ ไม่พึ่ง Unity เลย:
using System;
class EasingDemo
{
static float EaseOutBack(float t)
{
const float s = 1.70158f;
float u = t - 1f;
return 1f + u * u * ((s + 1f) * u + s);
}
static void Main()
{
for (int i = 0; i <= 10; i++)
{
float t = i / 10f;
Console.WriteLine($"t={t:0.0} value={EaseOutBack(t):0.0000}");
}
}
}
ผลลัพธ์:
t=0.0 value=0.0000
t=0.1 value=0.4088
t=0.2 value=0.7058
t=0.3 value=0.9071
t=0.4 value=1.0290
t=0.5 value=1.0877
t=0.6 value=1.0994
t=0.7 value=1.0802
t=0.8 value=1.0465
t=0.9 value=1.0143
t=1.0 value=1.0000
สังเกตว่าค่าตัดผ่าน 1.0000 (100%) ที่ราว ๆ t = 0.35 ขึ้นไปสูงสุดประมาณ 109.9% ที่ราว t = 0.6 แล้วค่อย ๆ นิ่งกลับลงมาที่ เป๊ะ 1.0000 ตอน t = 1.0 นั่นคือเคล็ดลับทั้งหมด: curve ไม่หยุดทันทีที่ถึงเป้าหมาย มันแล่นเลยไปแล้วค่อยนิ่ง ซึ่งรู้สึกเหมือนสิ่งที่มีน้ำหนักและความยืดหยุ่นจริง ๆ แทนที่จะเป็นรูปทรงที่แค่ปรากฏขึ้นมาเฉย ๆ
StartCoroutine(Tween.Float(0f, 1f, 0.25f, Easing.EaseOutBack, v =>
{
button.localScale = Vector3.one * v;
}));
Anticipation ทำงานแบบเดียวกันแต่กลับด้านและเกิดก่อนการกระทำ: สำหรับปุ่มที่ถูกกด อาจหมายถึงการย่อตัวลง 95% ของขนาดในเฟรมเดียวทันทีที่ตรวจจับการกดได้ ก่อนที่แอนิเมชันตอนปล่อยจะค่อย ๆ นิ่งกลับขึ้นไปที่ 100% (หรือเลยไปด้วย EaseOutBack ระหว่างทางขึ้น) ทั้งสองอย่างถูกมาก: แค่โค้ดคณิตศาสตร์ curve เพิ่มไม่กี่บรรทัดบนระบบที่คุณสร้างไว้แล้ว
วิธีทำ screen shake ที่ขี้เกียจที่สุดคือเลื่อนกล้องด้วย Random.insideUnitSphere ทุกเฟรม มันใช้งานได้จริงในทางเทคนิค แต่ดูไม่ดี: ตำแหน่งสุ่มใหม่ทุกเฟรมโดยไม่มีความสัมพันธ์กับเฟรมก่อนหน้าเลยจะรู้สึกเหมือนสัญญาณรบกวน (static) กะพริบ ๆ ไม่ใช่การสั่นทางกายภาพ มีสองวิธีแก้ที่ทำให้ screen shake รู้สึกดีจริง ๆ: ขับเคลื่อนค่า offset ด้วย noise ที่ต่อเนื่องแทนการสุ่มดิบ ๆ และขับเคลื่อนความแรงด้วยค่าที่ค่อย ๆ ลดลงแบบยกกำลังสองจนเป็นศูนย์ แทนที่จะใช้ตัวจับเวลา "สั่น N วินาที" แบบคงที่
ค่าที่ค่อย ๆ ลดลงนี้มักเรียกว่า trauma (คำที่เป็นที่นิยมในวงการพูดคุยเรื่อง game feel เกี่ยวกับการสั่นกล้อง): ตัวเลขตั้งแต่ 0 ถึง 1 ที่พุ่งขึ้นเมื่อมีอะไรที่กระทบมาก ๆ เกิดขึ้น แล้วค่อย ๆ ลดกลับไปทาง 0 ทุกเฟรม ความแรงของการสั่นจริง ๆ คือ trauma * trauma (ยกกำลังสอง) ไม่ใช่ trauma ตรง ๆ:
using UnityEngine;
public class TraumaShake : MonoBehaviour
{
public float maxOffset = 0.6f; // world units at full trauma
public float maxAngle = 8f; // degrees at full trauma
public float decay = 1.2f; // trauma lost per second
public float noiseFrequency = 25f; // how fast the Perlin noise scrolls
float trauma; // 0..1
float seed;
Vector3 basePos;
Quaternion baseRot;
void Awake()
{
seed = Random.value * 100f;
basePos = transform.localPosition;
baseRot = transform.localRotation;
}
public void AddTrauma(float amount)
{
trauma = Mathf.Clamp01(trauma + amount);
}
void Update()
{
trauma = Mathf.Max(0f, trauma - decay * Time.deltaTime);
float shake = trauma * trauma; // squared -- small hits barely move the camera
float t = Time.time * noiseFrequency;
float offsetX = (Mathf.PerlinNoise(seed, t) * 2f - 1f) * maxOffset * shake;
float offsetY = (Mathf.PerlinNoise(seed + 1f, t) * 2f - 1f) * maxOffset * shake;
float angle = (Mathf.PerlinNoise(seed + 2f, t) * 2f - 1f) * maxAngle * shake;
transform.localPosition = basePos + new Vector3(offsetX, offsetY, 0f);
transform.localRotation = baseRot * Quaternion.Euler(0f, 0f, angle);
}
}
Perlin noise (ฟังก์ชันสุ่มเทียมที่ต่อเนื่อง (smooth pseudo-random) ต่างจาก Random ดิบ ๆ ในบทที่ 2.6) ถูกสุ่มตัวอย่างตรงนี้แทนที่จะสุ่มตัวเลขใหม่ทุกเฟรม เพราะมันต่อเนื่อง: ค่าเวลาที่ใกล้กันจะให้ผลลัพธ์ที่ใกล้กัน ทำให้กล้องไหลลื่นระหว่าง offset แทนที่จะกระโดดไปมา แต่ละแกนอ่านค่าจากจุดที่ต่างกันใน noise (seed, seed + 1, seed + 2) เพื่อไม่ให้ X, Y และการหมุนสั่นไปในทิศทางเดียวกันพร้อมกันหมด
// DON'T -- a brand new random offset every single frame looks like static,
// not a shake, because there is no relationship between one frame and the next
transform.localPosition = basePos + Random.insideUnitSphere * shake;
Perlin noise แก้ปัญหานี้เพราะมันเป็นฟังก์ชันต่อเนื่องของเวลา -- เฟรมที่ต่อเนื่องกันจะสุ่มตัวอย่างจากจุดที่ใกล้กันบน curve ที่เรียบ แทนที่จะกระโดดไปยังค่าที่ไม่เกี่ยวข้องกันเลยไล่ค่าจริง: การโดนตีครั้งใหญ่เรียก AddTrauma(1f) แล้วไม่มีอะไรเกิดขึ้นอีก ด้วย decay = 1.2 trauma/วินาที:
การยกกำลังสองทำให้การลดลงรู้สึกเหมือนหมัดที่กระแทกแรงแล้วจางไปเร็ว: ที่ t = 0.2s เพียง 0.2 วินาทีหลังโดนตีครั้งใหญ่ ค่า shake แบบยกกำลังสองลดลงเหลือแค่ 58% แล้ว และยังลดลงต่อเนื่องแรง ๆ ในขณะที่เวอร์ชันไม่ยกกำลังยังอยู่ที่ 76% ซึ่งแรงมาก และยังสั่นสังเกตได้ชัดต่อไปอีกนาน การกระแทกแรงตั้งแต่ต้นแล้วค่อยลดลงเร็วรู้สึกเหมือน impact จริง ส่วนการสั่นที่ค้างอยู่ที่ความแรงสูงนาน ๆ รู้สึกเหมือนกำลังจะทำให้เมารถเมาเรือ
Hit-stop (เรียกอีกอย่างว่า "hit-freeze" หรือ "time freeze") หยุด -- หรือเกือบหยุด -- เกมไว้สักไม่กี่เฟรมตรงจังหวะที่กระทบพอดี ก่อนจะกลับมาเล่นต่อด้วยความเร็วปกติ นี่เป็นเทคนิคที่ถูกที่สุดอันหนึ่งในการทำให้การโดนตีรู้สึกหนักแน่น: การหยุดสั้น ๆ นี้ให้เวลาสายตาสักครู่ในการรับรู้ว่ามีอะไรสำคัญเกิดขึ้น เหมือนกับภาพถ่ายที่หยุดจุดสูงสุดของการเคลื่อนไหวไว้จะดูทรงพลังกว่าวิดีโอที่ลื่นไหลของการเคลื่อนไหวเดียวกัน
using UnityEngine;
using System.Collections;
public class HitStopController : MonoBehaviour
{
public IEnumerator HitStop(float duration, float freezeScale = 0.02f)
{
float previousScale = Time.timeScale;
Time.timeScale = freezeScale;
yield return new WaitForSecondsRealtime(duration);
Time.timeScale = previousScale;
}
}
Time.timeScale คูณกับค่าที่ปรับตามเวลา (scaled time) ทุกตัวในเกมทั้งหมดพร้อมกัน -- Time.deltaTime, physics step, แอนิเมชันส่วนใหญ่ -- ซึ่งเป็นเหตุผลว่าทำไมการตั้งค่ามันใกล้ศูนย์ถึงหยุดเกมได้ถูกมาก: ไม่มีอะไรที่อ่านค่า scaled time ต้องรู้เลยว่ากำลังมีการหยุดเกิดขึ้น
// BUG: WaitForSeconds counts SCALED time. If Time.timeScale is close to
// zero, this wait takes almost forever in real life -- the game appears
// to freeze permanently, because the very thing counting down the freeze
// is itself slowed down by the freeze.
yield return new WaitForSeconds(duration);
WaitForSecondsRealtime เพิกเฉยต่อ Time.timeScale โดยสิ้นเชิง มันจึงนับเวลาจริงตามนาฬิกาบนผนังเสมอ -- ซึ่งเป็นวิธีเดียวที่สมเหตุสมผลในการจับเวลาว่าการหยุดเวลาเองควรกินเวลานานแค่ไหนรายละเอียดที่เกี่ยวข้อง: อะไรก็ตามที่ขับเคลื่อนด้วย Time.deltaTime จะหยุดไปพร้อมกับเกม แต่ UI มักถูกทำแอนิเมชันด้วย Time.unscaledDeltaTime โดยเฉพาะเพื่อให้มันยังคงเคลื่อนไหวลื่น ๆ ต่อไปแม้อยู่ในช่วง hit-stop -- บาร์เลือดหรือตัวนับคอมโบที่เห็นได้ชัดว่ายังตอบสนอง ระหว่าง การหยุด จะช่วยตอกย้ำว่าการหยุดนั้นตั้งใจ ไม่ใช่ดูเหมือนเกมค้าง
ทุกอย่างที่ผ่านมาเป็นเรื่องของการทำให้การตอบสนองรู้สึกดีเมื่อมันเกิดขึ้น หัวข้อนี้เป็นเรื่องของ เมื่อไหร่ มันเกิดขึ้น กฎคือ: ช่วงเวลาระหว่างอินพุตกับ feedback ใด ๆ สำหรับมันควรใกล้เคียงกับหนึ่งเฟรมมากที่สุดเท่าที่เอนจินจะทำได้ แม้ว่าผลลัพธ์ "จริง" -- แอนิเมชันเต็มรูปแบบที่จบลง เซิร์ฟเวอร์ยืนยันการโดนตี การคำนวณดาเมจที่เสร็จสิ้น -- จะใช้เวลานานกว่านั้น ปุ่มควรยุบตัวลงให้เห็นทันทีที่คลิก ตัวละครควรเริ่มท่าเหวี่ยงตัวให้เห็นทันทีที่กดโจมตี แม้ว่าการโดนตีจริง ๆ จะเกิดขึ้นอีกสามเฟรมถัดมาก็ตาม
บทที่ 6.2 สร้างเครื่องมือเฉพาะสองอย่างไว้สำหรับตอนที่จังหวะ "ถูกต้อง" กับปุ่มที่ผู้เล่นกดจริง ๆ ไม่ตรงกันพอดี: coyote time (ให้อภัยการกระโดดที่กดช้าไปนิดหนึ่ง หลังจากเดินตกขอบไปแล้ว) และ jump buffering (ให้อภัยการกระโดดที่กดเร็วไปนิดหนึ่ง ก่อนที่จะแตะพื้น) ทั้งสองอย่างสร้างจากรูปแบบเดียวกัน: ตัวจับเวลานับถอยหลังที่รีเซ็ตเมื่อมีเหตุการณ์กระตุ้น แล้วเช็คว่ามันเทียบกับศูนย์อย่างไร รูปแบบนี้ขยายไปใช้กับอินพุตอะไรก็ได้ที่ต้องการหน้าต่างให้อภัยเล็ก ๆ ไม่ใช่แค่การกระโดดเท่านั้น:
public class BufferedInput
{
public float bufferTime = 0.15f;
float timer;
public void Press()
{
timer = bufferTime;
}
public void Tick(float deltaTime)
{
timer = Mathf.Max(0f, timer - deltaTime);
}
public bool Consume()
{
bool active = timer > 0f;
if (active) timer = 0f;
return active;
}
}
BufferedInput attackInput = new BufferedInput();
void Update()
{
if (Input.GetButtonDown("Attack"))
attackInput.Press();
attackInput.Tick(Time.deltaTime);
if (isReadyForNextHit && attackInput.Consume())
DoAttack();
}
เอฟเฟกต์ทุกอย่างในบทนี้ต้องปรับได้ เพราะ juice แบบเดียวกันที่ทำให้เกมรู้สึกดีมากสำหรับผู้เล่นคนหนึ่ง อาจทำให้เกมเล่นไม่ได้เลยสำหรับอีกคนหนึ่ง Accessibility (การเข้าถึงสำหรับผู้เล่นทุกกลุ่ม) ในที่นี้ไม่ใช่ฟีเจอร์แยกต่างหากที่แปะเพิ่มทีหลัง -- มันคือชุดปุ่มปรับที่ทุกระบบในบทนี้ควรจะเปิดให้ใช้ได้อยู่แล้ว
ผู้เล่นสัดส่วนหนึ่งที่มีนัยสำคัญ ส่วนใหญ่คือผู้ชาย มีภาวะบกพร่องในการมองเห็นสีในระดับใดระดับหนึ่ง ที่พบบ่อยที่สุดคือแยกสีแดงกับเขียวลำบาก feedback ใด ๆ ที่พึ่งพาสี อย่างเดียว -- เส้นขอบสีแดงหมายถึง "ศัตรู" เทียบกับสีเขียวหมายถึง "พวกเดียวกัน" บาร์เลือดสีแดงหมายถึง "อันตราย" -- อาจมองไม่เห็นเลยสำหรับผู้เล่นกลุ่มที่ต้องการมันมากที่สุดพอดี ทางแก้เหมือนเดิมทุกครั้ง: จับคู่สีกับสัญญาณที่สอง (รูปทรง ไอคอน ลวดลายการเติมสีที่ต่างกัน หรือตำแหน่งบนจอ) เพื่อให้ข้อมูลยังคงอยู่แม้ว่าสีจะอ่านไม่ถูกต้อง
Subtitles ปกติหมายถึงข้อความบทสนทนา แต่ไอเดียเดียวกันนี้ใช้ได้กับ feedback ในเกมที่มาทางเสียงอย่างเดียวทุกชิ้น: เสียงบี๊บกระสุนใกล้หมด เสียงฝีเท้านอกจอที่เตือนว่ามีศัตรู เสียงเตือนภัย ผู้เล่นที่หูหนวก บกพร่องทางการได้ยิน หรือแค่เล่นแบบปิดเสียงในห้องที่ใช้ร่วมกับคนอื่น ก็ควรได้รับข้อมูลนั้นผ่านสัญญาณบนหน้าจอ (ไอคอน ตัวชี้ทิศทาง หรือคำบรรยายอย่าง "[เสียงฝีเท้า, ด้านหลัง]")
ไม่ใช่ผู้เล่นทุกคนจะเอื้อมถึงปุ่มหรือคีย์เริ่มต้นได้สบาย ๆ การอ่านอินพุตผ่านชื่อ action (ระบบอินพุตที่มี action map ที่กำหนดใหม่ได้ แทนที่จะเช็ค KeyCode.Space ตรง ๆ กระจายอยู่ทั่วโค้ด) คือสิ่งที่ทำให้การกำหนดปุ่มใหม่เป็นไปได้ตั้งแต่แรก -- การออกแบบให้รองรับตั้งแต่ต้นถูกกว่าการย้อนกลับมาแก้ทีหลังมาก
Screen shake, แสงกะพริบถี่ ๆ และการซูมเร็ว ๆ สามารถกระตุ้นอาการเมารถเมาเรือในผู้เล่นบางคนได้ และการกะพริบเร็ว ๆ โดยเฉพาะ (ประมาณ 3-30 ครั้งต่อวินาที) เป็นตัวกระตุ้นที่รู้จักกันดีสำหรับอาการชักจากแสง (photosensitive seizure) ในคนกลุ่มเล็ก ๆ ตัวเลือก reduced motion ควรลดหรือปิดเอฟเฟกต์เหล่านี้ได้ทั้งหมดโดยไม่ทำลาย feedback ส่วนอื่นของเกม -- ผู้เล่นควรยังรู้อยู่ว่าโดนตีแล้ว แค่ไม่ต้องให้กล้องขยับรุนแรงเพื่อบอกพวกเขา
[System.Serializable]
public class AccessibilitySettings
{
public float shakeIntensity = 1f; // 0 = no camera shake at all
public bool colorblindIcons = true; // pair color cues with shapes/icons
public bool screenFlashReduced = false; // cap hit-flash brightness
public bool subtitlesForSfx = false; // caption non-dialogue sound cues
}
การเชื่อมมันเข้ากับระบบที่สร้างไว้แล้วในบทนี้คือการเปลี่ยนแค่บรรทัดเดียวต่อระบบ:
// in TraumaShake.Update()
float shake = trauma * trauma * settings.shakeIntensity;
// in HitFlash.FlashRoutine()
float peak = settings.screenFlashReduced ? 0.4f : 1f;
sr.color = Color.Lerp(baseColor, flashColor, peak);
เพราะระบบ juice ทุกอันในบทนี้อ่านค่าความแรงของตัวเองจากตัวเลขเดียวอยู่แล้ว (trauma, สี peak ของแฟลช, ระยะเวลา) การเพิ่มตัวคูณ accessibility ทับลงไปแทบจะฟรีเลย -- ซึ่งเป็นเหตุผลว่าทำไมการสร้างระบบแบบนี้ตั้งแต่ต้นถึงคุ้มค่า แทนที่จะ hardcode "สั่นด้วยค่า 0.6" ตรง ๆ ที่ทุกจุดที่เรียกใช้
ทุกเทคนิคในบทนี้มีปุ่มปิดด้วยเหตุผลที่มากกว่าเรื่อง accessibility: ถ้าใช้มากเกินไป ทุกอย่างจะทำให้เกมแย่ลง ไม่ใช่ดีขึ้น มีรูปแบบความล้มเหลวเฉพาะสามแบบที่เจอบ่อยมาก
Juice fatigue (ความเหนื่อยล้าจาก juice) ถ้าทุกการกระทำ -- หมัดเล็ก ๆ ฝีเท้าหนึ่งก้าว การเปิดเมนู -- ได้รับการสั่นจอ แฟลช และเสียงระดับสูงสุดเท่ากันหมด ไม่มีอะไรรู้สึกพิเศษอีกต่อไป เพราะไม่มีความต่างระหว่างช่วงเวลาเล็ก ๆ กับช่วงเวลาใหญ่ ๆ juice ต้องการช่วงไดนามิก (dynamic range) เหมือนกับที่เพลงต้องการช่วงเงียบ เพื่อให้ช่วงดังโดดเด่นออกมา
ข้อมูลถูกบดบัง Screen shake ที่ขยับกล้องรุนแรงระหว่างรูปแบบกระสุนแบบ bullet-hell หรือหน้าต่างจังหวะของเกมแนวจังหวะ (rhythm game) จะบดบังข้อมูลที่ผู้เล่นต้องใช้ตอบสนองให้ถูกต้องโดยตรง นี่คือเหตุผลว่าทำไมเกมแนวแข่งขันและเน้นความแม่นยำ (เกมต่อสู้ เกมยิงระดับสูง เกมจังหวะ) จึงใช้ shake น้อยกว่าเกมแอ็กชันแบบซีนีมาติกอย่างตั้งใจ -- พวกเขาเลือก hit-flash และ hit-stop สั้น ๆ ซึ่งสื่อสาร impact ได้โดยไม่ต้องขยับกล้องที่ผู้เล่นกำลังอ่านอยู่
ความไวลดลง Hit-stop 2 ถึง 6 เฟรมในการโดนตีปกติจะรู้สึกมีน้ำหนัก แต่ hit-stop 30 เฟรมในทุกการโดนตีจะรู้สึกเหมือนหน่วง (lag) -- ตรงข้ามกับกฎ "ตอบสนองภายในหนึ่งเฟรม" จากหัวข้อที่ 9 เก็บการหยุดยาว ๆ ไว้สำหรับช่วงเวลาที่หายากจริง ๆ (การโจมตีปิดฉาก การทำให้บอสเซถัลบเสียหลัก) โดยเฉพาะเพื่อให้มันยังรู้สึกพิเศษเมื่อเกิดขึ้นจริง
0..1 แบบเส้นตรง ให้กลายเป็น curve การเคลื่อนไหวที่ไม่คงที่Time.timeScale ใช้เพื่อให้การรอเวลาจริงและแอนิเมชัน UI ยังทำงานต่อได้ระหว่างการหยุดEaseOutBack จากหัวข้อที่ 6 (s = 1.70158, u = t - 1, result = 1 + u*u*((s+1)*u + s)) คำนวณค่า eased ที่ t = 0.5 ด้วยมือ แสดงวิธีทำ จากนั้นบอกค่าที่ t = 1.0 โดยไม่ต้องคำนวณใหม่ และอธิบายในหนึ่งประโยคว่าทำไมค่านั้นถึงสมเหตุสมผลสำหรับ UI ที่ต้อง "เด้ง" เข้าที่t = 0.5
u = 0.5 - 1 = -0.5
u*u = 0.25
(s+1)*u = 2.70158 * (-0.5) = -1.35079
(s+1)*u + s = -1.35079 + 1.70158 = 0.35079
u*u * 0.35079 = 0.25 * 0.35079 = 0.0876975
result = 1 + 0.0876975 = 1.0877
ดังนั้นที่จุดกึ่งกลางของเวลา องค์ประกอบนี้อยู่ที่ประมาณ 108.8% ของขนาดสุดท้ายแล้ว -- เลยเป้าหมายไปแล้ว อยู่กลาง overshoot พอดี ที่ t = 1.0 ค่าจะเป็น 1.0000 (100%) เป๊ะ เพราะ curve ถูกสร้างมาโดยเฉพาะให้ลงที่เป้าหมายพอดีเสมอตอนจบ ไม่ว่าระหว่างทางจะเลยไปไกลแค่ไหนก็ตาม เรื่องนี้สำคัญสำหรับ UI ที่เด้ง: องค์ประกอบนั้นต้องนิ่งลงที่ขนาดจริงสุดท้ายให้เห็นชัด ไม่ใช่ค้างอยู่ที่ขนาดใหญ่หรือเล็กเกินไปนิดหน่อย -- overshoot เป็นแค่ลูกเล่นระหว่างแอนิเมชัน ไม่ใช่สถานะพักสุดท้าย
trauma = 0.5 (เริ่มจาก 0 ถูกจำกัดในช่วง 0..1) decay = 1.5 trauma ต่อวินาที และไม่มีการโดนตีเพิ่มเติมอีก โดยใช้ shake = trauma * trauma คำนวณ trauma และ shake ที่ t = 0.1s, t = 0.2s, และ t = 0.3s จากนั้นคำนวณเวลาพอดีที่ trauma จะกลายเป็นศูนย์trauma(t) = max(0, 0.5 - 1.5*t):
t=0.1s: trauma = 0.5 - 1.5*0.1 = 0.35 shake = 0.35*0.35 = 0.1225 (12.25%)
t=0.2s: trauma = 0.5 - 1.5*0.2 = 0.20 shake = 0.20*0.20 = 0.0400 (4.00%)
t=0.3s: trauma = 0.5 - 1.5*0.3 = 0.05 shake = 0.05*0.05 = 0.0025 (0.25%)
Trauma จะเป็นศูนย์พอดีเมื่อ 0.5 - 1.5*t = 0 ดังนั้น t = 0.5 / 1.5 = 0.333s (ประมาณหนึ่งในสามวินาที) สังเกตว่าค่า shake ที่ยกกำลังสองยุบตัวเร็วแค่ไหน: ที่ t = 0.2s ซึ่งเป็นแค่ 60% ของอายุการสั่นทั้งหมด มันลดลงเหลือแค่ 4% ของความแรงแล้ว -- แทบสังเกตไม่ได้ -- ทั้งที่ trauma เองยังอยู่ที่ 20% ซึ่งสังเกตได้ชัดมาก นี่คือพฤติกรรม "กระแทกแรงแล้วจางเร็ว" ที่การยกกำลังสองตั้งใจสร้างขึ้นมาพอดี
ชุดการเปลี่ยนแปลงที่สมเหตุสมผล ซึ่งไม่มีอันไหนแตะเลขดาเมจจริง ๆ เลย:
AddTrauma() ตามความแรงของการโดนตี (หมัดเบาเพิ่มนิดหน่อย หมัดหนักเพิ่มเยอะ) จะคืนความต่างนั้นกลับมา และอาจหมายถึงการลด shake ของการโดนตีธรรมดาลงจริง ๆ ด้วยซ้ำรายงานของเทสเตอร์เป็นสัญญาณชัดเจนว่าการเพิ่มเอฟเฟกต์เดิมมากขึ้นเฉย ๆ (สั่นมากขึ้น) จะไม่ช่วยแก้ปัญหา หัวข้อที่ 11 พูดถึงเรื่องนี้พอดี: เมื่อเอฟเฟกต์หนึ่งถูกดันไปสูงสุดแล้ว การเพิ่มมันอีกจะหยุดเพิ่มความรู้สึกน้ำหนัก และเริ่มเพิ่มความรก (noise) แทน ทางแก้ที่ถูกต้องมักเป็นช่องทางอื่นที่ขาดหายไป (hit-stop, flash, เสียง, หรือความไว) ไม่ใช่การเพิ่มปริมาณของช่องทางเดียวที่ใช้อยู่แล้วให้มากขึ้น