בניית רכיב בתפריט של משחק בתלת-ממד

סקירה כללית בסיסית על יצירת תפריט משחק תלת-ממדי רספונסיבי, מותאם ונגיש.

בפוסט הזה אני רוצה לשתף את הדרך שבה פיתחתי רכיב תפריט של משחק תלת-ממדי. כדאי לנסות את ההדגמה.

דמו

אם אתם מעדיפים סרטון, הנה גרסה של הפוסט הזה ב-YouTube:

סקירה כללית

במשחקי וידאו, לרוב המשתמש מוצג תפריט יצירתי ויוצא דופן, עם אנימציה ובמרחב תלת-ממדי. במשחקי AR/VR חדשים, נהוג ליצור תפריט שנראה כאילו הוא צף בחלל. היום נציג את העיקר של האפקט הזה, אבל עם תוספת של ערכת צבעים דינמית והתאמות למשתמשים שמעדיפים תנועה מופחתת.

HTML

תפריט משחק הוא רשימת לחצנים. הדרך הטובה ביותר לייצג את זה ב-HTML היא:

<ul class="threeD-button-set">
  <li><button>New Game</button></li>
  <li><button>Continue</button></li>
  <li><button>Online</button></li>
  <li><button>Settings</button></li>
  <li><button>Quit</button></li>
</ul>

רשימה של לחצנים תקריא את עצמה בצורה טובה לטכנולוגיות של קוראי מסך, ותעבוד ללא JavaScript או CSS.

רשימת תיבות סימון גנרית מאוד עם לחצנים רגילים כפריטים.

CSS

עיצוב רשימת הלחצנים מחולק לשלבים הבאים ברמה גבוהה:

  1. הגדרת מאפיינים מותאמים אישית.
  2. פריסה של Flexbox.
  3. לחצן בהתאמה אישית עם פסאודו-רכיבים דקורטיביים.
  4. מיקום רכיבים במרחב תלת-ממדי.

סקירה כללית על מאפיינים מותאמים אישית

נכסים מותאמים אישית עוזרים להסיר משמעויות כפולות של ערכים על ידי מתן שמות משמעותיים לערכים שנראים אקראיים, הימנעות מקידוד חוזר ושיתוף ערכים בין הצאצאים.

בהמשך מפורטות שאילתות מדיה שנשמרו כמשתני CSS, שנקראים גם מדיה בהתאמה אישית. הם גלובליים, ושימוש בהם יתבצע בבוררים שונים כדי לשמור על קוד תמציתי וקריא. רכיב תפריט המשחק משתמש בהעדפות התנועה, בערכת הצבעים של המערכת וביכולות של טווח הצבעים של המסך.

@custom-media --motionOK (prefers-reduced-motion: no-preference);
@custom-media --dark (prefers-color-scheme: dark);
@custom-media --HDcolor (dynamic-range: high);

המאפיינים המותאמים אישית הבאים מנהלים את ערכת הצבעים ומכילים ערכים של מיקום העכבר כדי להפוך את תפריט המשחק לאינטראקטיבי כשהוא מרחף. מתן שמות למאפיינים מותאמים אישית עוזר לקריאות הקוד, כי הוא חושף את התרחיש לדוגמה שבו נעשה שימוש בערך, או שם ידידותי לתוצאה של הערך.

.threeD-button-set {
  --y:;
  --x:;
  --distance: 1px;
  --theme: hsl(180 100% 50%);
  --theme-bg: hsl(180 100% 50% / 25%);
  --theme-bg-hover: hsl(180 100% 50% / 40%);
  --theme-text: white;
  --theme-shadow: hsl(180 100% 10% / 25%);

  --_max-rotateY: 10deg;
  --_max-rotateX: 15deg;
  --_btn-bg: var(--theme-bg);
  --_btn-bg-hover: var(--theme-bg-hover);
  --_btn-text: var(--theme-text);
  --_btn-text-shadow: var(--theme-shadow);
  --_bounce-ease: cubic-bezier(.5, 1.75, .75, 1.25);

  @media (--dark) {
    --theme: hsl(255 53% 50%);
    --theme-bg: hsl(255 53% 71% / 25%);
    --theme-bg-hover: hsl(255 53% 50% / 40%);
    --theme-shadow: hsl(255 53% 10% / 25%);
  }

  @media (--HDcolor) {
    @supports (color: color(display-p3 0 0 0)) {
      --theme: color(display-p3 .4 0 .9);
    }
  }
}

רקעים חצי-אליפטיים בעיצוב בהיר ועיצוב כהה

בעיצוב הבהיר יש שיפוע conic תוסס מ-cyan ל-deeppink, ובעיצוב הכהה יש שיפוע conic עדין כהה. מידע נוסף על מה שאפשר לעשות עם מעברי צבע אליפסואידיים זמין במאמר conic.style.

html {
  background: conic-gradient(at -10% 50%, deeppink, cyan);

  @media (--dark) {
    background: conic-gradient(at -10% 50%, #212529, 50%, #495057, #212529);
  }
}
הדגמה של שינוי הרקע בין העדפות צבע בהירות לכהה.

הפעלת פרספקטיבה תלת-ממדית

כדי שרכיבים יהיו קיימים במרחב התלת-ממדי של דף אינטרנט, צריך לאתחל אזור תצוגה עם פרספקטיבה. בחרתי להציב את הפרספקטיבה ברכיב body, והשתמשתי ביחידות של חלון התצוגה כדי ליצור את הסגנון שרציתי.

body {
  perspective: 40vw;
}

זהו סוג ההשפעה שיכולה להיות לנקודת המבט.

עיצוב של רשימת הלחצנים <ul>

האלמנט הזה אחראי על הפריסה הכוללת של רשימת הלחצנים ברמת המאקרו, וגם על כרטיס צף אינטראקטיבי תלת-ממדי. כך עושים את זה.

פריסת קבוצת לחצנים

Flexbox יכול לנהל את הפריסה של הקונטיינר. משנים את כיוון ברירת המחדל של flex משורות לעמודות באמצעות flex-direction, ומוודאים שכל פריט בגודל התוכן שלו על ידי שינוי הערך של align-items מ-stretch ל-start.

.threeD-button-set {
  /* remove <ul> margins */
  margin: 0;

  /* vertical rag-right layout */
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2.5vh;
}

בשלב הבא, מגדירים את הקונטיינר כקונטקסט של מרחב תלת-ממדי ומגדירים פונקציות CSS מסוג clamp() כדי לוודא שהכרטיס לא יתרווח מעבר לתנועות שאפשר לקרוא. שימו לב שהערך האמצעי של הלחיצה הוא מאפיין מותאם אישית. הערכים --x ו---y ייקבעו מ-JavaScript במהלך האינטראקציה עם העכבר בהמשך.

.threeD-button-set {
  

  /* create 3D space context */
  transform-style: preserve-3d;

  /* clamped menu rotation to not be too extreme */
  transform:
    rotateY(
      clamp(
        calc(var(--_max-rotateY) * -1),
        var(--y),
        var(--_max-rotateY)
      )
    )
    rotateX(
      clamp(
        calc(var(--_max-rotateX) * -1),
        var(--x),
        var(--_max-rotateX)
      )
    )
  ;
}

לאחר מכן, אם המשתמש שמבקר באתר מסכים לתנועה, מוסיפים לדפדפן רמז לכך שהטרנספורמציה של הפריט הזה תשתנה כל הזמן באמצעות will-change. בנוסף, מפעילים את האינטרפולציה על ידי הגדרת transition בטרנספורמציות. המעבר הזה יתבצע כשהעכבר יתקשר עם הכרטיס, וכך תהיה אפשרות לבצע מעברים חלקים לשינויי סיבוב. האנימציה פועלת כל הזמן וממחישה את המרחב התלת-ממדי שבו נמצא הכרטיס, גם אם לא ניתן לבצע אינטראקציה עם הרכיב באמצעות העכבר או אם לא מתבצעת אינטראקציה כזו.

@media (--motionOK) {
  .threeD-button-set {
    /* browser hint so it can be prepared and optimized */
    will-change: transform;

    /* transition transform style changes and run an infinite animation */
    transition: transform .1s ease;
    animation: rotate-y 5s ease-in-out infinite;
  }
}

באנימציה rotate-y מוגדרת רק תמונה מפתח באמצע ב-50%, כי הדפדפן מגדיר כברירת מחדל את 0% ואת 100% לסגנון ברירת המחדל של הרכיב. זוהי קיצור דרך לאנימציות שמחליפות זו את זו, והן צריכות להתחיל ולסתיים באותה מיקום. זו דרך מצוינת להציג אנימציות לסירוגין ללא הגבלה.

@keyframes rotate-y {
  50% {
    transform: rotateY(15deg) rotateX(-6deg);
  }
}

עיצוב הרכיבים <li>

כל פריט ברשימה (<li>) מכיל את הלחצן ואת רכיבי המסגרת שלו. הסגנון display משתנה כך שהפריט לא יציג את הסמל ::marker. הסגנון position מוגדר ל-relative כדי שפסאודו-האלמנטים הבאים של הלחצן יוכלו למקם את עצמם בתוך האזור המלא שהלחצן תופס.

.threeD-button-set > li {
  /* change display type from list-item */
  display: inline-flex;

  /* create context for button pseudos */
  position: relative;

  /* create 3D space context */
  transform-style: preserve-3d;
}

צילום מסך של הרשימה שהופכת במרחב תלת-ממדי כדי להציג את הפרספקטיבה, וכל פריט ברשימה כבר לא כולל תבליט.

עיצוב הרכיבים <button>

עיצוב לחצנים יכול להיות משימה קשה, כי יש הרבה מצבים וסוגים של אינטראקציה שצריך להביא בחשבון. הלחצנים האלה הופכים למסובכים במהירות בגלל האיזון בין רכיבי פסאודו, אנימציות ואינטראקציות.

סגנונות <button> ראשוניים

בהמשך מפורטים הסגנונות הבסיסיים שתומכים בסטטוסים האחרים.

.threeD-button-set button {
  /* strip out default button styles */
  appearance: none;
  outline: none;
  border: none;

  /* bring in brand styles via props */
  background-color: var(--_btn-bg);
  color: var(--_btn-text);
  text-shadow: 0 1px 1px var(--_btn-text-shadow);

  /* large text rounded corner and padded*/
  font-size: 5vmin;
  font-family: Audiowide;
  padding-block: .75ch;
  padding-inline: 2ch;
  border-radius: 5px 20px;
}

צילום מסך של רשימת הלחצנים בתצוגת תלת-ממד, הפעם עם לחצנים בסגנון.

פסאודו-רכיבים של לחצן

השוליים של הלחצן הם לא שוליים מסורתיים, אלא פסאודו-אלמנטים עם שוליים במיקום מוחלט.

צילום מסך של חלונית הרכיבים של כלי הפיתוח ל-Chrome, עם לחצן שמוצגים בו רכיבים מסוג ‎::before ו-‎ ::after.

הרכיבים האלה חיוניים להצגת נקודת המבט התלת-ממדית שהוגדרה. אחד מהפסאודו-רכיבים האלה יודחק מהלחצן, והשני יימשך קרוב יותר למשתמש. ההשפעה בולטת במיוחד בלחצנים העליונים והתחתונים.

.threeD-button button {
  

  &::after,
  &::before {
    /* create empty element */
    content: '';
    opacity: .8;

    /* cover the parent (button) */
    position: absolute;
    inset: 0;

    /* style the element for border accents */
    border: 1px solid var(--theme);
    border-radius: 5px 20px;
  }

  /* exceptions for one of the pseudo elements */
  /* this will be pushed back (3x) and have a thicker border */
  &::before {
    border-width: 3px;

    /* in dark mode, it glows! */
    @media (--dark) {
      box-shadow:
        0 0 25px var(--theme),
        inset 0 0 25px var(--theme);
    }
  }
}

סגנונות של טרנספורמציות תלת-ממדיות

מתחת ל-transform-style מוגדר preserve-3d כדי שהילדים יוכלו לפזר את עצמם בציר z. הערך של transform מוגדר למאפיין המותאם אישית --distance, והוא יגדל בהעברת העכבר מעל הרכיב והתמקדות בו.

.threeD-button-set button {
  

  transform: translateZ(var(--distance));
  transform-style: preserve-3d;

  &::after {
    /* pull forward in Z space with a 3x multiplier */
    transform: translateZ(calc(var(--distance) / 3));
  }

  &::before {
    /* push back in Z space with a 3x multiplier */
    transform: translateZ(calc(var(--distance) / 3 * -1));
  }
}

סגנונות אנימציה מותנים

אם המשתמש מסכים לתנועה, הלחצן נותן לגלישה רמוזות לכך שמאפיין הטרנספורמציה צריך להיות מוכן לשינוי, ומוגדר מעבר למאפיינים transform ו-background-color. שימו לב להבדל באורך, לדעתי זה יוצר אפקט מרווח עדין ונעים.

.threeD-button-set button {
  

  @media (--motionOK) {
    will-change: transform;
    transition:
      transform .2s ease,
      background-color .5s ease
    ;

    &::before,
    &::after {
      transition: transform .1s ease-out;
    }

    &::after    { transition-duration: .5s }
    &::before { transition-duration: .3s }
  }
}

סגנונות אינטראקציה של ריחוף ומיקוד

מטרת אנימציית האינטראקציה היא להפיץ את השכבות שמרכיבות את הלחצן שנראה שטוח. כדי לעשות זאת, מגדירים את המשתנה --distance ל-1px. הבורר שמוצג בדוגמת הקוד הבאה בודק אם המכשיר שצריך להציג אינדיקטור של מיקוד מעביר את העכבר מעל הלחצן או מתמקד בו, בלי להפעיל אותו. אם כן, הוא מחיל CSS כדי לבצע את הפעולות הבאות:

  • מחילים את צבע הרקע של מצב ההעברה.
  • מגדילים את המרחק .
  • מוסיפים אפקט קפיצה.
  • מפרקים את המעברים של פסאודו-האלמנטים.
.threeD-button-set button {
  

  &:is(:hover, :focus-visible):not(:active) {
    /* subtle distance plus bg color change on hover/focus */
    --distance: 15px;
    background-color: var(--_btn-bg-hover);

    /* if motion is OK, setup transitions and increase distance */
    @media (--motionOK) {
      --distance: 3vmax;

      transition-timing-function: var(--_bounce-ease);
      transition-duration: .4s;

      &::after  { transition-duration: .5s }
      &::before { transition-duration: .3s }
    }
  }
}

נקודת המבט התלת-ממדית עדיין הייתה נהדרת בהעדפת התנועה reduced. הרכיבים העליונים והתחתונים מציגים את האפקט בצורה עדינה ונעימה.

שיפורים קטנים באמצעות JavaScript

כבר אפשר להשתמש בממשק באמצעות מקלדות, קוראי מסך, משחקי מחשב, מגע ועכבר, אבל אנחנו יכולים להוסיף כמה נגיעות קטנות של JavaScript כדי להקל על כמה תרחישים.

תמיכה במקשי החיצים

מקש Tab הוא דרך מצוינת לנווט בתפריט, אבל הייתי מצפה שהלחצן לכיוון או סטיק ההיגוי יעבירו את המיקוד במכשיר ל-gamepad. הספרייה roving-ux, שמשמשת לעתים קרובות לממשקי GUI של אתגרים, תעזור לנו לטפל במקשות החיצים. הקוד הבא מורה לספרייה לתפוס את המיקוד ב-.threeD-button-set ולהעביר אותו לילדים של הלחצן.

import {rovingIndex} from 'roving-ux'

rovingIndex({
  element: document.querySelector('.threeD-button-set'),
  target: 'button',
})

אינטראקציה של תזוזת העכבר

המעקב אחרי העכבר וההטיה שלו כדי לפתוח את התפריט נועדו לחקות ממשקי משחקי וידאו ב-AR וב-VR, שבהם במקום עכבר יכול להיות סמן וירטואלי. יכול להיות שזה יהיה כיף אם הרכיבים יהיו מודעים מאוד לסמן.

מכיוון שזו תכונה נוספת קטנה, נוסיף את האינטראקציה לשאילתה לגבי העדפות התנועה של המשתמש. כמו כן, כחלק מההגדרה, שומרים את הרכיב של רשימת הלחצנים בזיכרון באמצעות querySelector ומאחסנים את גבולות הרכיב במטמון באמצעות menuRect. משתמשים בגבולות האלה כדי לקבוע את ההיסט של הסיבוב שיוחל על הכרטיס על סמך מיקום העכבר.

const menu = document.querySelector('.threeD-button-set')
const menuRect = menu.getBoundingClientRect()

const { matches:motionOK } = window.matchMedia(
  '(prefers-reduced-motion: no-preference)'
)

בשלב הבא, נצטרך פונקציה שתקבל את המיקומים x ו-y של העכבר ותחזיר ערך שאפשר להשתמש בו כדי לסובב את הכרטיס. הפונקציה הבאה משתמשת במיקום העכבר כדי לקבוע באיזה צד של התיבה הוא נמצא ובכמה. הפונקציה מחזירה את הערך של הדלתה.

const getAngles = (clientX, clientY) => {
  const { x, y, width, height } = menuRect

  const dx = clientX - (x + 0.5 * width)
  const dy = clientY - (y + 0.5 * height)

  return {dx,dy}
}

לבסוף, בודקים את תנועת העכבר, מעבירים את המיקום לפונקציה getAngles() ומשתמשים בערכי הדלתה כסגנונות מותאמים אישית של נכסים. חילקתי ב-20 כדי להגדיל את הערך של הדלתה ולמנוע תנודות חדות, יכול להיות שיש דרך טובה יותר לעשות את זה. אם זכור לכם מההתחלה, שמנו את ה-props‏ --x ו---y באמצע פונקציית clamp(). כך מיקום העכבר לא גורם לסיבוב יתר של הכרטיס למצב שלא ניתן לקרוא אותו.

if (motionOK) {
  window.addEventListener('mousemove', ({target, clientX, clientY}) => {
    const {dx,dy} = getAngles(clientX, clientY)

    menu.attributeStyleMap.set('--x', `${dy / 20}deg`)
    menu.attributeStyleMap.set('--y', `${dx / 20}deg`)
  })
}

תרגומים ומסלולים

גילינו בעיה אחת כשבדקנו את תפריט המשחק בשפות ובמצב כתיבה אחרים.

לרכיבי <button> יש סגנון !important עבור writing-mode בגיליון הסגנונות של סוכן המשתמש. לכן, היה צריך לשנות את ה-HTML של תפריט המשחק כדי להתאים אותו לעיצוב הרצוי. שינוי רשימת הלחצנים לרשימת קישורים מאפשר לנכסים לוגיים לשנות את כיוון התפריט, כי לרכיבי <a> אין סגנון !important שסופק על ידי הדפדפן.

סיכום

עכשיו אחרי שסיפרתי לך איך עשיתי את זה, איך היית עושה את זה? 🙂 אפשר להוסיף לממשק אינטראקציה עם תאוצה למסך, כך ששינוי המצב של הטלפון יביא לתנועה של התפריט? איך אפשר לשפר את חוויית השימוש במצב ללא תנועה?

נרחיב את הגישות שלנו ונלמד את כל הדרכים לפיתוח באינטרנט. אתם יכולים ליצור גרסת דמו, לשלוח לי קישורים בטוויטר ואוסיף אותה לקטע 'רמיקסים של הקהילה' שבהמשך.

רמיקסים של הקהילה

עדיין אין מה לראות כאן