12.4 UX, Feedback และ 'Juice'

เฟส 12 · UI / UX Programming · เวลาเรียน: 15–25 h

ความเนี้ยบที่ทำให้ UI น่าใช้ — แอนิเมชัน, เสียง, การสั่น และ feedback ที่ตอบสนอง ทำให้ทุกการกดรู้สึกมีชีวิต

บทที่ 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 (การเข้าถึงสำหรับผู้เล่นทุกกลุ่ม) และความชัดเจนในเกมแข่งขัน -- และวิธีลดมันลงอย่างตั้งใจ

1. "game feel" กับ "juice" คืออะไร และทำไมมันถึงสำคัญ

Juice คือ feedback ทุกชิ้นที่ห่อหุ้มรอบ ๆ การกระทำหนึ่ง ๆ ซึ่งพูดตามตรงแล้วไม่จำเป็นต้องมีเพื่อให้การกระทำนั้นทำงานได้ ถ้าผู้เล่นกดโจมตีแล้วเลขเลือดของศัตรูลดลง เกมก็ทำงานถูกต้องแล้ว ไม่มีอะไรในนั้นที่ต้องการแสงกะพริบ เสียง การสั่นจอ หรือ rumble เลย แต่เวอร์ชันที่ไม่มีสิ่งเหล่านี้เลยจะรู้สึกไร้ชีวิตชีวา ในขณะที่เวอร์ชันที่มีครบจะรู้สึกสะใจ ทั้งที่กฎเบื้องหลัง -- health -= damage -- เหมือนกันทุกไบต์ในทั้งสองแบบ

CORE ACTION: player presses "attack", enemy.health -= 10 bare mechanic (no juice): [ button press ] --> [ health -= 10 ] ...and nothing else happens on screen juiced version (exact same math underneath): [ button press ] --> [ health -= 10 ] | | v v windup pose hit flash on enemy "swoosh" sound small screen shake controller rumble "-10" number pops up + particles

นี่คือไอเดียเดียวกันในรูปแบบโค้ด เป็นตัวอย่างล่วงหน้าว่าแต่ละบรรทัดจะถูกสร้างขึ้นตรงไหนในบทนี้:

// 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 ปรับแต่งมาดีจะรู้สึกมีน้ำหนักและมีผลตามมาจริง ๆ ซึ่งเป็นส่วนสำคัญมากที่ทำให้บางเกม "จับต้องแล้วรู้สึกดี" ในขณะที่บางเกมไม่เป็นแบบนั้น ทั้งที่กลไกคล้ายกันบนกระดาษ

Tip วิธีทดสอบที่มีประโยชน์ตอนสร้างการกระทำใด ๆ: ปิดเสียงเกมและปิดเอฟเฟกต์ภาพทุกอย่างยกเว้นกลไกล้วน ๆ ถ้าคุณบอกไม่ได้จริง ๆ ว่ามีอะไรเกิดขึ้น แปลว่าการกระทำนั้นขาด feedback ไม่ใช่แค่ "รอ polish ทีหลัง" feedback ไม่ใช่ของตกแต่งที่ใส่เพิ่มตอนท้าย -- ให้ถือว่ามันเป็นส่วนหนึ่งของความถูกต้อง (correctness) ของการกระทำนั้นเลย

2. Feedback ทางภาพ: hit flash, particles และ number popups

Feedback ทางภาพเป็นช่องทางที่ผู้เล่นสังเกตเห็นก่อนใคร เพราะสายตาพวกเขาจ้องอยู่ที่หน้าจออยู่แล้ว มีเทคนิคภาพหลัก ๆ 4 อย่างที่ครอบคลุมสถานการณ์ส่วนใหญ่: การกะพริบสีสั้น ๆ บนสิ่งที่โดนตี (hit flash), screen shake (หัวข้อใหญ่พอที่จะมีเป็นของตัวเองในหัวข้อที่ 7), particle ที่ระเบิดออกมา และตัวเลขลอย (number popup)

Hit flash

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 เฟรม):

frame t (s) t / duration Color.Lerp(flashColor, baseColor, t/duration) 1 0.02 0.2 80% flash color, 20% base color 2 0.04 0.4 60% flash color, 40% base color 3 0.06 0.6 40% flash color, 60% base color 4 0.08 0.8 20% flash color, 80% base color 5 0.10 1.0 100% base color (flash finished)

Color.Lerp(a, b, t) เคลื่อน จาก a ไปทาง b เมื่อ t เพิ่มขึ้น -- ดังนั้นที่ t = 0.2 ผลลัพธ์ยังคงเป็น flashColor เป็นส่วนใหญ่ (แฟลชเพิ่งเริ่ม) และที่ t = 1.0 มันไปถึง baseColor เต็มที่แล้ว sprite จะเด้งเป็นสีขาวทันทีในเฟรมที่เรียก Flash() (เพราะมันเริ่ม loop ด้วย coroutine ใหม่) แล้วค่อย ๆ จางกลับไปในช่วง 5 เฟรมถัดไป

Number popups

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 ชุดเดียวกันเป๊ะ ๆ

Particles

การระเบิด particle สั้น ๆ (ประกายไฟ ฝุ่น เลือด หรือแสงเวทมนตร์) ช่วยขายความรู้สึก impact ตรงจุดสัมผัส ในเอนจินส่วนใหญ่นี่แค่บรรทัดเดียวเมื่อตั้งค่า particle system asset เสร็จแล้ว:

hitParticles.transform.position = contactPoint;
hitParticles.Play();

Particles จะอ่านออกได้ดีที่สุดเมื่อรวมกับ hit flash และเสียงในจังหวะเดียวกันเป๊ะ ๆ -- สามช่องทางยืนยันเหตุการณ์เดียวกันมักรู้สึกหนักแน่นกว่าช่องทางใดช่องทางหนึ่งเพียงลำพังมาก ซึ่งเป็นไอเดียหลักของสองหัวข้อถัดไป

3. Feedback ทางเสียง: เสียงสำหรับทุกการกระทำที่มีความหมาย

กฎข้อเดียวที่สำคัญที่สุดสำหรับ 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] เสมอในกรณีนี้):

call 1: pitch = 1.03 call 2: pitch = 0.95 call 3: pitch = 1.07 call 4: pitch = 0.98 close enough that it still sounds like "the same footstep sound", varied enough that four in a row do not sound identical
Tip PlayOneShot (แทนที่จะเป็น Play) ทำให้เสียงชุดเดียวกันหลายชุดเล่นทับกันได้โดยไม่ตัดกันเอง -- สำคัญมากสำหรับการกระทำที่เร็วและซ้ำ ๆ เช่นปืนกลหรือคอมโบโจมตี ที่การโดนตีสองครั้งห่างกันแค่หนึ่งเฟรมควรได้ยินทั้งคู่

4. Feedback ทาง haptic: rumble ในฐานะอีกช่องทางหนึ่ง

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 เครื่องคอนโซล)

5. Easing และ tween: การเคลื่อนไหวที่ไม่เป็นเส้นตรง

บทที่ 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 จริง ๆ แทนที่จะเป็นตัวเลขนามธรรม):

frame t (s) normalized (t/0.1) EaseOut(t) panel.localScale 1 0.02 0.2 0.36 0.36 (36% size) 2 0.04 0.4 0.64 0.64 (64% size) 3 0.06 0.6 0.84 0.84 (84% size) 4 0.08 0.8 0.96 0.96 (96% size) 5 0.10 1.0 1.00 1.00 (full size)
scale 1.0 | * | * 0.8 | * | * 0.6 | * | 0.4 | * | 0.2 | * | 0.0 *----+----+----+----+----+---- t 0.0 0.2 0.4 0.6 0.8 1.0 * = EaseOut curve: big jump early, slows into the landing -- reads as responsive (reacts fast to input) and controlled (no overshoot yet)

เทียบกับ Linear ที่จะไล่ค่า 0.2, 0.4, 0.6, 0.8, 1.0 ที่เฟรมเดียวกันเป๊ะ -- ความเร็วคงที่ และหยุดกึกทันทีที่ถึง 1.0 การหยุดกึกแบบนั้นแหละที่ทำให้การเคลื่อนไหวเส้นตรงรู้สึก "เหมือนคอมพิวเตอร์": ไม่มีอะไรในโลกจริงหยุดกะทันหันขนาดนั้น EaseOut เคลื่อนที่ไปไกลกว่าในช่วงต้น (ตอนที่อินพุตเพิ่งเกิดขึ้นและความเร็วอ่านออกว่าเป็นความไว) และเคลื่อนน้อยลงในช่วงท้าย (ทำให้การหยุดดูนุ่มนวลแทนที่จะกระชาก)

Tip ในโปรเจกต์ที่ปล่อยจริง คุณจะเห็นแพทเทิร์นนี้ถูกแพ็คเป็นไลบรารีบ่อยมาก ที่พบบ่อยที่สุดคือ DOTween เป็นบรรทัดเดียว: panel.transform.DOScale(1f, 0.1f).SetEase(Ease.OutQuad); บรรทัดนั้นทำสิ่งเดียวกันเป๊ะกับ coroutine ข้างบน -- เคลื่อนค่าหนึ่งไปตามช่วงเวลาผ่าน easing curve -- เพียงแต่มี curve สำเร็จรูปให้เลือกหลายสิบแบบและไม่ต้องเขียน boilerplate เข้าใจเวอร์ชัน coroutine ไว้ก็จะไม่มีวันติดขัด ถ้าวันไหนไม่มีไลบรารี tweening ให้ใช้ หรือต้องการ curve ที่มันไม่มี

6. Anticipation และ overshoot: เด้งเลยเป้าหมายแล้วค่อยนิ่ง

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 ไม่หยุดทันทีที่ถึงเป้าหมาย มันแล่นเลยไปแล้วค่อยนิ่ง ซึ่งรู้สึกเหมือนสิ่งที่มีน้ำหนักและความยืดหยุ่นจริง ๆ แทนที่จะเป็นรูปทรงที่แค่ปรากฏขึ้นมาเฉย ๆ

value (fraction of final size) 1.10 | *** | ** **** 1.00 | - - - - - - -**- - - - - -**- - - - <- target: settles exactly here at t=1.0 | ** | ** 0.50 | * | * | * 0.00 *---*-------------------------------- t 0.0 1.0 the curve overshoots past 100%, then eases back down to exactly 100%
StartCoroutine(Tween.Float(0f, 1f, 0.25f, Easing.EaseOutBack, v =>
{
    button.localScale = Vector3.one * v;
}));

Anticipation ทำงานแบบเดียวกันแต่กลับด้านและเกิดก่อนการกระทำ: สำหรับปุ่มที่ถูกกด อาจหมายถึงการย่อตัวลง 95% ของขนาดในเฟรมเดียวทันทีที่ตรวจจับการกดได้ ก่อนที่แอนิเมชันตอนปล่อยจะค่อย ๆ นิ่งกลับขึ้นไปที่ 100% (หรือเลยไปด้วย EaseOutBack ระหว่างทางขึ้น) ทั้งสองอย่างถูกมาก: แค่โค้ดคณิตศาสตร์ curve เพิ่มไม่กี่บรรทัดบนระบบที่คุณสร้างไว้แล้ว

7. Screen shake ที่ทำถูกวิธี: trauma ไม่ใช่สุ่มดิบ ๆ

วิธีทำ 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 และการหมุนสั่นไปในทิศทางเดียวกันพร้อมกันหมด

Common mistake ใช้ความสุ่มดิบ ๆ ทุกเฟรมแทน noise:
// 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 (s) 0.00 0.20 0.40 0.60 0.80 0.83 trauma 1.00 0.76 0.52 0.28 0.04 0.00 shake=trauma^2 100% 57.8% 27.0% 7.8% 0.2% 0.0% shake=trauma 100% 76.0% 52.0% 28.0% 4.0% 0.0% (for comparison, NOT squared)

การยกกำลังสองทำให้การลดลงรู้สึกเหมือนหมัดที่กระแทกแรงแล้วจางไปเร็ว: ที่ t = 0.2s เพียง 0.2 วินาทีหลังโดนตีครั้งใหญ่ ค่า shake แบบยกกำลังสองลดลงเหลือแค่ 58% แล้ว และยังลดลงต่อเนื่องแรง ๆ ในขณะที่เวอร์ชันไม่ยกกำลังยังอยู่ที่ 76% ซึ่งแรงมาก และยังสั่นสังเกตได้ชัดต่อไปอีกนาน การกระแทกแรงตั้งแต่ต้นแล้วค่อยลดลงเร็วรู้สึกเหมือน impact จริง ส่วนการสั่นที่ค้างอยู่ที่ความแรงสูงนาน ๆ รู้สึกเหมือนกำลังจะทำให้เมารถเมาเรือ

8. Hit-stop: หยุดเวลาตอนกระทบ

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 -> 0.00s 0.02s 0.07s | | | normal speed IMPACT: freeze begins resume Time.timeScale = 0.02 Time.timeScale = 1.0 held for 0.05s of REAL time (measured with WaitForSecondsRealtime)

Time.timeScale คูณกับค่าที่ปรับตามเวลา (scaled time) ทุกตัวในเกมทั้งหมดพร้อมกัน -- Time.deltaTime, physics step, แอนิเมชันส่วนใหญ่ -- ซึ่งเป็นเหตุผลว่าทำไมการตั้งค่ามันใกล้ศูนย์ถึงหยุดเกมได้ถูกมาก: ไม่มีอะไรที่อ่านค่า scaled time ต้องรู้เลยว่ากำลังมีการหยุดเกิดขึ้น

Common mistake รอให้การหยุดจบด้วยตัวจับเวลาที่ผิด:
// 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 -- บาร์เลือดหรือตัวนับคอมโบที่เห็นได้ชัดว่ายังตอบสนอง ระหว่าง การหยุด จะช่วยตอกย้ำว่าการหยุดนั้นตั้งใจ ไม่ใช่ดูเหมือนเกมค้าง

9. ความไว: ตอบสนองภายในหนึ่งเฟรม, buffer อินพุต, coyote time

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

frame N: input read --> immediate feedback fires THIS SAME FRAME | +--> visual: button depresses / windup pose starts +--> audio: click / windup sound plays (the SLOW part -- full animation, a network round-trip, damage resolution -- can still take several more frames, starting AFTER this)

บทที่ 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();
}
Tip รูปแบบนี้เป๊ะ ๆ -- หน้าต่างให้อภัยเล็ก ๆ บวกกับ feedback ทันทีบนตัวอินพุตเอง -- ครอบคลุมสิ่งที่ผู้เล่นส่วนใหญ่บ่นว่า "การควบคุมรู้สึกไม่แฟร์" หรือ "การควบคุมรู้สึกไม่ไว" ไปเกือบหมด แทบไม่ค่อยเป็นบั๊กใหญ่บั๊กเดียว -- ส่วนใหญ่มักเป็น feedback ที่ขาดหายไปหนึ่งเฟรมตรงนี้ หน้าต่าง buffer ที่ขาดหายตรงนั้น สะสมกันไปเรื่อย ๆ

10. Accessibility: juice ที่ผู้เล่นบางคนต้องปิด

เอฟเฟกต์ทุกอย่างในบทนี้ต้องปรับได้ เพราะ juice แบบเดียวกันที่ทำให้เกมรู้สึกดีมากสำหรับผู้เล่นคนหนึ่ง อาจทำให้เกมเล่นไม่ได้เลยสำหรับอีกคนหนึ่ง Accessibility (การเข้าถึงสำหรับผู้เล่นทุกกลุ่ม) ในที่นี้ไม่ใช่ฟีเจอร์แยกต่างหากที่แปะเพิ่มทีหลัง -- มันคือชุดปุ่มปรับที่ทุกระบบในบทนี้ควรจะเปิดให้ใช้ได้อยู่แล้ว

การออกแบบที่ปลอดภัยสำหรับผู้มีปัญหาการมองเห็นสี (colorblind-safe)

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

Common mistake เข้ารหัสข้อมูลสำคัญ -- "นี่คือศัตรู", "การโจมตีนี้บล็อกไม่ได้", "เลือดคุณต่ำแล้ว" -- ด้วยสีเพียงอย่างเดียวและไม่มีอะไรอื่นเลย ทดสอบตัวชี้วัดแบบนั้นด้วยการแปลงภาพหน้าจอเป็นขาวดำ ถ้าความหมายหายไป มันก็จะหายไปสำหรับผู้เล่นจริงบางคนเช่นกัน

คำบรรยาย (subtitles) และสัญญาณเสียง

Subtitles ปกติหมายถึงข้อความบทสนทนา แต่ไอเดียเดียวกันนี้ใช้ได้กับ feedback ในเกมที่มาทางเสียงอย่างเดียวทุกชิ้น: เสียงบี๊บกระสุนใกล้หมด เสียงฝีเท้านอกจอที่เตือนว่ามีศัตรู เสียงเตือนภัย ผู้เล่นที่หูหนวก บกพร่องทางการได้ยิน หรือแค่เล่นแบบปิดเสียงในห้องที่ใช้ร่วมกับคนอื่น ก็ควรได้รับข้อมูลนั้นผ่านสัญญาณบนหน้าจอ (ไอคอน ตัวชี้ทิศทาง หรือคำบรรยายอย่าง "[เสียงฝีเท้า, ด้านหลัง]")

การกำหนดปุ่มควบคุมใหม่ได้ (remappable controls)

ไม่ใช่ผู้เล่นทุกคนจะเอื้อมถึงปุ่มหรือคีย์เริ่มต้นได้สบาย ๆ การอ่านอินพุตผ่านชื่อ action (ระบบอินพุตที่มี action map ที่กำหนดใหม่ได้ แทนที่จะเช็ค KeyCode.Space ตรง ๆ กระจายอยู่ทั่วโค้ด) คือสิ่งที่ทำให้การกำหนดปุ่มใหม่เป็นไปได้ตั้งแต่แรก -- การออกแบบให้รองรับตั้งแต่ต้นถูกกว่าการย้อนกลับมาแก้ทีหลังมาก

ลดการเคลื่อนไหว (reduced motion)

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" ตรง ๆ ที่ทุกจุดที่เรียกใช้

11. อันตรายของการใช้มากเกินไป

ทุกเทคนิคในบทนี้มีปุ่มปิดด้วยเหตุผลที่มากกว่าเรื่อง accessibility: ถ้าใช้มากเกินไป ทุกอย่างจะทำให้เกมแย่ลง ไม่ใช่ดีขึ้น มีรูปแบบความล้มเหลวเฉพาะสามแบบที่เจอบ่อยมาก

Juice fatigue (ความเหนื่อยล้าจาก juice) ถ้าทุกการกระทำ -- หมัดเล็ก ๆ ฝีเท้าหนึ่งก้าว การเปิดเมนู -- ได้รับการสั่นจอ แฟลช และเสียงระดับสูงสุดเท่ากันหมด ไม่มีอะไรรู้สึกพิเศษอีกต่อไป เพราะไม่มีความต่างระหว่างช่วงเวลาเล็ก ๆ กับช่วงเวลาใหญ่ ๆ juice ต้องการช่วงไดนามิก (dynamic range) เหมือนกับที่เพลงต้องการช่วงเงียบ เพื่อให้ช่วงดังโดดเด่นออกมา

hit size trauma added hit-stop length sound tiny (jab) 0.05 0-1 frames quiet tick medium (hit) 0.15 2-4 frames normal thwack big (crit) 0.35 4-8 frames louder crunch huge (kill) 0.70 10-14 frames big impact + brief slow-mo if EVERY hit used the "huge" row, players would lose the ability to tell a jab from a kill blow just by watching and listening to the screen

ข้อมูลถูกบดบัง Screen shake ที่ขยับกล้องรุนแรงระหว่างรูปแบบกระสุนแบบ bullet-hell หรือหน้าต่างจังหวะของเกมแนวจังหวะ (rhythm game) จะบดบังข้อมูลที่ผู้เล่นต้องใช้ตอบสนองให้ถูกต้องโดยตรง นี่คือเหตุผลว่าทำไมเกมแนวแข่งขันและเน้นความแม่นยำ (เกมต่อสู้ เกมยิงระดับสูง เกมจังหวะ) จึงใช้ shake น้อยกว่าเกมแอ็กชันแบบซีนีมาติกอย่างตั้งใจ -- พวกเขาเลือก hit-flash และ hit-stop สั้น ๆ ซึ่งสื่อสาร impact ได้โดยไม่ต้องขยับกล้องที่ผู้เล่นกำลังอ่านอยู่

ความไวลดลง Hit-stop 2 ถึง 6 เฟรมในการโดนตีปกติจะรู้สึกมีน้ำหนัก แต่ hit-stop 30 เฟรมในทุกการโดนตีจะรู้สึกเหมือนหน่วง (lag) -- ตรงข้ามกับกฎ "ตอบสนองภายในหนึ่งเฟรม" จากหัวข้อที่ 9 เก็บการหยุดยาว ๆ ไว้สำหรับช่วงเวลาที่หายากจริง ๆ (การโจมตีปิดฉาก การทำให้บอสเซถัลบเสียหลัก) โดยเฉพาะเพื่อให้มันยังรู้สึกพิเศษเมื่อเกิดขึ้นจริง

Tip นิสัยที่ดีก่อนปล่อยเอฟเฟกต์ juice ใด ๆ ออกไป: ลองปิดมันทั้งหมดแล้วเล่นสถานการณ์เดียวกันทั้งสองแบบ ถ้าปิดแล้วช่วงเวลานั้นอ่านออกได้ชัดขึ้นโดยไม่ทำให้เกมรู้สึกแย่ลง แปลว่าเอฟเฟกต์นั้นกำลังปิดบังความไม่ชัดเจน ไม่ใช่เพิ่ม feel จริง ๆ -- ให้ลดมันลง แทนที่จะเพิ่มมันขึ้นไปอีก

12. ศัพท์สำคัญ (Glossary)

13. แบบฝึกหัด

Exercise 1 ใช้สูตร EaseOutBack จากหัวข้อที่ 6 (s = 1.70158, u = t - 1, result = 1 + u*u*((s+1)*u + s)) คำนวณค่า eased ที่ t = 0.5 ด้วยมือ แสดงวิธีทำ จากนั้นบอกค่าที่ t = 1.0 โดยไม่ต้องคำนวณใหม่ และอธิบายในหนึ่งประโยคว่าทำไมค่านั้นถึงสมเหตุสมผลสำหรับ UI ที่ต้อง "เด้ง" เข้าที่
Show answer
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 เป็นแค่ลูกเล่นระหว่างแอนิเมชัน ไม่ใช่สถานะพักสุดท้าย

Exercise 2 การโดนตีครั้งหนึ่งเพิ่ม 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 จะกลายเป็นศูนย์
Show answer

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% ซึ่งสังเกตได้ชัดมาก นี่คือพฤติกรรม "กระแทกแรงแล้วจางเร็ว" ที่การยกกำลังสองตั้งใจสร้างขึ้นมาพอดี

Exercise 3 เทสเตอร์คนหนึ่งบอกว่าการต่อสู้ระยะประชิดของเกม "รู้สึกอ่อนแอ" ทั้งที่เลขดาเมจและการตรวจจับการโดนตีทำงานถูกต้องอยู่แล้ว และทุกการโดนตีก็สั่นจอแรง ๆ อยู่แล้ว โดยใช้เทคนิคอย่างน้อยสี่อย่างจากบทนี้ (ไม่ใช่แค่ "เพิ่มการสั่นให้มากขึ้น") ให้ระบุการเปลี่ยนแปลงที่เจาะจงที่คุณจะลอง และบอกว่าแต่ละอย่างแก้ปัญหาอะไร
Show answer

ชุดการเปลี่ยนแปลงที่สมเหตุสมผล ซึ่งไม่มีอันไหนแตะเลขดาเมจจริง ๆ เลย:

  • เพิ่ม hit-stop (หัวข้อที่ 8) การหยุด 2-6 เฟรมตรงจังหวะกระทบให้เวลาสายตาสักครู่รับรู้ว่าการโดนตีนี้เป็นเหตุการณ์ที่แยกออกมาชัดเจนและมีน้ำหนัก แทนที่จะเบลนด์เข้ากับเฟรมถัดไปในระหว่างการต่อสู้ที่เร็ว
  • เปลี่ยนไปใช้ shake แบบ trauma-based ที่ปรับตามขนาดของการโดนตี (หัวข้อที่ 7 และ 11) ถ้าทุกการโดนตีใช้ "shake ใหญ่" เท่ากันหมดอยู่แล้ว จะไม่มีช่วงไดนามิกเลย -- หมัดเบา ๆ กับท่าไม้ตายหนัก ๆ รู้สึกเหมือนกันหมด ไม่มีอะไรพิเศษ การปรับ AddTrauma() ตามความแรงของการโดนตี (หมัดเบาเพิ่มนิดหน่อย หมัดหนักเพิ่มเยอะ) จะคืนความต่างนั้นกลับมา และอาจหมายถึงการลด shake ของการโดนตีธรรมดาลงจริง ๆ ด้วยซ้ำ
  • เพิ่ม hit flash และ number popup (หัวข้อที่ 2) การยืนยันทางภาพที่ทันทีและมองเห็นชัดเจนในเฟรมที่โดนตีพอดี ไม่ขึ้นกับว่ากล้องกำลังทำอะไรอยู่
  • เพิ่มเสียงที่มี pitch variance ในทุกการโดนตี (หัวข้อที่ 3) ถ้าตอนนี้การโดนตีเงียบหรือใช้เสียงเดิมซ้ำ ๆ แค่นี้อย่างเดียวก็มักทำให้รู้สึกถึงความหนักหน่วงเพิ่มขึ้นมาก
  • เช็คความไว (หัวข้อที่ 9) ถ้ามีความหน่วงระหว่างการกดโจมตีกับ feedback ชิ้นแรกสุด (แม้แต่แอนิเมชันเหวี่ยงตัวที่เริ่มขึ้น) การโจมตีนั้นจะรู้สึกเชื่องช้าไม่ว่าจะใส่ juice เข้าไปที่จังหวะกระทบมากแค่ไหนก็ตาม -- ท่าเหวี่ยงตัวต้องเริ่มในเฟรมเดียวกับที่อ่านค่าปุ่มได้

รายงานของเทสเตอร์เป็นสัญญาณชัดเจนว่าการเพิ่มเอฟเฟกต์เดิมมากขึ้นเฉย ๆ (สั่นมากขึ้น) จะไม่ช่วยแก้ปัญหา หัวข้อที่ 11 พูดถึงเรื่องนี้พอดี: เมื่อเอฟเฟกต์หนึ่งถูกดันไปสูงสุดแล้ว การเพิ่มมันอีกจะหยุดเพิ่มความรู้สึกน้ำหนัก และเริ่มเพิ่มความรก (noise) แทน ทางแก้ที่ถูกต้องมักเป็นช่องทางอื่นที่ขาดหายไป (hit-stop, flash, เสียง, หรือความไว) ไม่ใช่การเพิ่มปริมาณของช่องทางเดียวที่ใช้อยู่แล้วให้มากขึ้น

← กลับไปหน้ารวมบท