ארכיטקטורת Player

למה Romergo Player פועל על DOM רגיל

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

סצנה רגילה של רומן חזותי מורכבת מאוסף ברור למדי:

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

חיפשתי מנוע זמן רב ובחרתי בדפדפן

ניסיתי לבנות את הסצנה באמצעות Godot,‏ PixiJS ו־Three.js. הכול עבד ברמות שונות של הצלחה, והמנועים עצמם לא היו אשמים. אבל בדרך שבה אני יוצר quests הם הוסיפו שכבה נוספת שהייתי צריך לתאם בנפרד.

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

בסופו של דבר, הפתרון היעיל ביותר היה הפשוט ביותר: ה־DOM הרגיל של הדפדפן.

הרקע, הדמויות והדיאלוג נשארים שכבות דפדפן מוכרות. רוב הקומפוזיציה והאפקטים נבנים באמצעות CSS. משתמשים ב־Canvas 2D עבור רכיבים חזותיים נקודתיים, ומחברים WebGL canvas רק במקומות שבהם באמת נחוצים מעברי shader בין תמונות.

הצליל פועל דרך HTMLAudioElement של הדפדפן. ה־runtime יוצר אודיו עבור הקליפים הפעילים, שולט בעוצמה ובהשמעה החוזרת ושומר את אותה מוזיקה במהלך מעבר אם ה־asset שלה לא השתנה.

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

סצנה פשוטה ו־runtime חכם

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

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

ה־runtime מקבל את תיאור ה־quest ואת מצב ההתקדמות. הוא יודע מה הפרק, הצומת והסצנה הנוכחיים, מהם ערכי המשתנים, אילו בחירות נעשו, מהם checkpoints ואילו פרקים הושלמו. כשהשחקן לוחץ על הסצנה או בוחר תשובה, ה־runtime מחיל אפקטים, מוצא את השלב הבא ושוב מעביר לסצנה מצב מוכן.

בצורה פשוטה מאוד, המחזור נראה כך:

Quest JSON + התקדמות שמורה
          ↓
runtime קובע את השלב הנוכחי
          ↓
הסצנה מציגה קליפים ומדיה
          ↓
פעולת השחקן חוזרת אל runtime
          ↓
השלב הבא — והמחזור חוזר עד הסיום

במשחק שפורסם, ה־Player טוען snapshot קבוע של ה־runtime עבור ה־quest כולה. ב־Builder, ה־preview בונה runtime תואם מהטיוטה הנוכחית. לכן לא מדובר בשני players דומים שמתחילים להתנהג באופן שונה עם הזמן, אלא ב־RuntimePlayer,‏ SceneStage ו־viewport משותפים בתוך מעטפות שונות.

כיצד נשמרת הרציפות בין הסצנות

במהלך מעבר, ה־runtime מחשב מחדש את מצב הסצנה החדשה. מוזיקה שחוזרת מזוהה לפי asset ID וממשיכה ללא התחלה חדשה או fade-in נוסף. הדפדפן טוען ושומר תמונות ב־cache, ולכן שימוש חוזר ברקע אינו מחייב הורדה נוספת של הקובץ מהרשת.

כלומר, האופטימיזציה אינה נמצאת בתנאי גדול אחד של ״לא לשלוח כלום״, אלא ברמות הנכונות: ה־runtime שומר על רציפות ההשמעה, ה־resolver מחזיר משאבים יציבים ו־cache הדפדפן אינו מוריד שוב מדיה שכבר מוכרת לו.

שני מסכים לסצנה אחת

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

ב־Romergo יש לסצנה שני viewports וירטואליים קבועים: אופקי 1280 × 720 ואנכי 390 × 693. ה־Player בוחר את המצב המתאים לפי המכשיר והכיוון, ואז משנה את קנה המידה של הסצנה המוכנה כיחידה אחת.

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

עבור הרקע, bgX,‏ bgY וקנה המידה משותפים לשני המצבים, וה־viewport האנכי חותך את אותה תמונה בדרכו. לכן לפני הפרסום צריך לבדוק את המסגור בשני הפורמטים ולבחור מוקד משותף. הגדרות רקע נפרדות למובייל אינן חלק מחוזה ה־runtime.

אפשר להעביר התאמות חוזרות של דמויות לסוכן AI דרך MCP, ולאחר מכן לבדוק אותן ב־preview אמיתי של שני ה־viewports. לכן אני מכנה את ההתאמה חצי־אוטומטית ולא אוטומטית לחלוטין.

כיצד לא להציג לשחקן מסך שחור

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

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

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

למשחק offline יש מצב נוסף: המשתמש יכול להוריד מראש את כל ה־quest. אז ה־runtime snapshot, המדיה ומעטפת ה־PWA נשארים ב־cache הדפדפן ונפתחים ללא רשת. טעינה מוקדמת של הסצנה הבאה מספקת משחק online חלק, וההורדה המלאה מספקת משחק offline אמיתי.

Telegram כמעטפת, Discord כמערכת נפרדת

האהבה שלי לדפדפנים השתלמה במיוחד כאשר שילבתי את ה־Player ב־Telegram וב־Discord. בשני המקרים אותו web runtime נפתח בתוך הפלטפורמה, ולכן לא היה צורך לכתוב מחדש את הסצנה ואת כללי ההתקדמות עבור מנוע משחק חדש.

Telegram דרש בעיקר session של הפלטפורמה, הפעלת quest, התקדמות מקומית ומעבר בין Mini App לבין Player רגיל. המשחק עצמו נשאר זהה.

Discord מורכב יותר, משום שכמה אנשים צריכים לראות את אותה נקודה בסיפור. לכל משתתף אכן פועל runtime מקומי משלו, אבל ה־host הוא מקור האמת. ה־Player שלו שולח את המצב כאשר יש שינויים ובערך כל 750 מילישניות. חדר realtime מקבל את הפקודה דרך WebSocket, שומר snapshot ומשדר אותו למשתתפים. ה־runtimes של הצופים מחילים את המצב המרוחק, משחזרים את הסצנה וממשיכים למדוד זמן באופן מקומי בין הסנכרונים.

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

בסיס אחד לכל המצבים

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

אבל ההחלטה הבסיסית עמדה בצמיחה. הסצנה עדיין מטפלת בתמונה ובצליל. ה־runtime עדיין מטפל במצב ובמעברים. Browser API מכסים rendering, מדיה, cache והטמעה, ושכבות מיוחדות מתווספות רק במקומות שבהם הן באמת הכרחיות.

בזכות זאת אפשר לתחזק את אותו Player ב־Builder preview, בבדיקות, במשחק רגיל בדפדפן, ב־Telegram וב־Discord. בעיניי זו דוגמה טובה לכך שהפתרון הפשוט והמעט גס ביותר יכול להיות הגמיש ביותר, אם מגדירים נכון את גבול האחריות.

חזרה לבלוג הפיתוח