كهف واحد، كل لاعب
كيف يبني TUNL مستوى يوميا من مجرد تاريخ، والخطآن اللذان أفسداه بصمت لأشهر.
TUNL لعبة طيران في الكهوف بدفع مستمر عند الضغط. تضغط لتصعد، وتفلت لتسقط، وتحاول الوصول أبعد في نفق يضيق مما وصلت أمس. كل لاعب يطير في الكهف نفسه في اليوم نفسه، وعند حد منتصف الليل بتوقيت UTC يصبح كهفا مختلفا.
لا يوجد ملف مستوى. لا يوزع أي خادم خريطة، ولا يُنزَّل شيء، واللعبة لا تخزن النفق الذي ولّدته للتو. الكهف دالة خالصة للتاريخ، و"العالم" كله نحو أربعين سطرا من الحساب.
يمنح هذا التصميم قدرا مدهشا: لوحة صدارة عالمية عادلة، وإعادة شبح تكلف بضع مئات من البايتات، وصورة قابلة للمشاركة لجولتك. كما يفرض قيدا أقسى بكثير مما يبدو أول الأمر، وقد كسرته مرتين دون أن أنتبه. يتناول هذا المقال النصفين معا.
مستوى هو دالة، لا ملف
تولّد معظم الألعاب الإجرائية عالما عند بدء الجولة وتحتفظ به في الذاكرة. أما TUNL فلا تحتفظ به إطلاقا. يُحسب مركز الممر ونصف عرضه عند أي موضع في العالم wx عند الطلب من موجتين جيبيتين متراكبتين:
function boundsBase(wx) {
const p = Math.min(Math.sqrt(wx / 14000), 1); // wave frequency ramp, sqrt eased
const g = Math.min(Math.max(wx - 3000, 0) / 90000, 1); // corridor ramp, slow and linear
const wA1 = lerp(H * 0.07, H * 0.12, g); // slow wide arc
const wA2 = lerp(H * 0.035, H * 0.055, g); // faster shallow ripple
const wF1 = lerp(0.0025, 0.0048, p);
const wF2 = lerp(0.0060, 0.0115, p);
const hg = lerp(H * 0.34, H * 0.163, g); // half-gap: 68% -> 33% of the height
const raw = H / 2
+ wA1 * Math.sin(wx * wF1 + _wavePhase1)
+ wA2 * Math.sin(wx * wF2 + 1.57 + _wavePhase2);
const cy = clamp(raw, hg + WALL_PAD, H - hg - WALL_PAD);
return { top: cy - hg, bot: cy + hg };
}
هذا هو الهيكل العظمي. تضيف الدالة الفعلية بضعة حدود أخرى، مثل تحول الشكل للجولات العميقة الموصوف أدناه، لكن أيا منها لا يأخذ شيئا سوى wx واليوم.
موجتان لا موجة واحدة، لأن الجيب الواحد يُقرأ فورا كإيقاع ويكف عن أن يكون مثيرا بعد نحو عشر ثوان. قوس بطيء عريض مع تموج أسرع وأضحل بنسبة تردد شبه لا نسبية يعطي امتدادات طويلة لا تتكرر تماما أبدا. يضيق نصف الفجوة من نحو 68% من ارتفاع الشاشة إلى 33% كلما تعمقت، ويفعل ذلك ببطء: لا شيء إطلاقا في الامتداد الأول، وهو قاعة مفتوحة، ثم خط مستقيم لا يبلغ قاعه إلا بعد نحو النقاط 1,500.
لم يكن الأمر هكذا دائما. كان الممر يضيق أولا على منحنى الجذر التربيعي الذي يبلغ أضيق حالاته عند النقاط 233، وهو منحدر سريع مركّز في البداية، وجاءت ملاحظات تقول إن اللعبة صعبة جدا. لذا حصل الممر وسعة موجته على ساعتهما البطيئة الخاصة، أي g أعلاه. أما ترددات الموجات فبقيت عمدا على منحنى الجذر التربيعي القديم: sin(wx * f) عند wx كبيرة شديد الحساسية لـf، لذا فإعادة ضبط إيقاع التردد في منتصف الجولة تُربك طور كل ما هو خلفك بالفعل. السعة والعرض يمكن إبطاؤهما بأمان، أما التردد فلا.
الخاصية المهمة ليست الشكل. بل أن boundsBase(wx) لا تأخذ إلا wx. اسألها عن موضع العالم 40,000 في الإطار الأول من جولة فتجيب فورا، بشكل متطابق، على أي جهاز، دون أن تولّد شيئا في الوسط.
أين يدخل اليوم
يدخل التاريخ عبر متغيرين اثنين بالضبط في ذلك المقطع: _wavePhase1 و_wavePhase2. يصبح تاريخ UTC عددا صحيحا مثل 20260915، يمر عبر سلسلة تجزئة:
function seedDailyVariety(dayInt) {
let h = Math.imul(dayInt ^ 0x9e3779b9, 0x45d9f3b) >>> 0;
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0;
h = (h ^ (h >>> 16)) >>> 0;
_wavePhase1 = (h % 6283) / 1000; // 0 .. 2pi
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0;
_wavePhase2 = (h % 6283) / 1000;
_waveJitterA = 1 + (draw() * 2 - 1) * 0.08; // +/- 8% amplitude
_waveJitterF = 1 + (draw() * 2 - 1) * 0.08; // +/- 8% frequency
_dayArchetype = Math.floor(draw() * DAY_ARCHETYPES.length);
}
التواريخ المتتالية تختلف بواحد، لذا يجب أن تبعثر التجزئة بقوة وإلا بدا اليوم وغد كجارين قريبين. سلسلة multiply-xorshift تفعل ذلك: 20260915 و20260916 يولدان أطوارا غير مترابطة تماما.
التقلبات صغيرة عمدا. الطور وحده يغير مواضع المنعطفات لكنه لا يغير إحساس اليوم، وأردت أن يكون لكل يوم طابع مختلف قليلا دون أن ينحرف أي يوم عن خط أساس استغرق ضبطه وقتا طويلا. ثمانية بالمئة كافية للملاحظة على مدى أسبوع وليست كافية لجعل يوم يبدو خاطئا.
أربعة تدفقات عشوائية، لا واحد
العوائق مشكلة منفصلة عن شكل الممر. لكل من الصواعد والعملات والألغام والمدافع مولّدها المستقل الخاص، وكلها مشتقة من اليوم نفسه:
rngStal = makeRngStream(Math.imul(dayInt ^ 0x5741, 0x2545F491));
rngCoin = makeRngStream(Math.imul(dayInt ^ 0xC01D, 0x9E3779B1));
rngMine = makeRngStream(Math.imul(dayInt ^ 0x4D19, 0x85EBCA6B));
rngCannon = makeRngStream(Math.imul(dayInt ^ 0xCA77, 0xC2B2AE35));
كان تدفق مشترك واحد سيكون أبسط، وكان سيكون خطأ. يمكن لوضع العملات أن يرفض موضعا مرشحا، مثلا عندما يقع قريبا جدا من صاعدة. في تدفق مشترك يستهلك كل رفض رقما ويزيح كل ما يُسحب بعده، لذا فعملة يفشل وضعها ستحرك اللغم التالي بصمت. الأملاح المنفصلة تجعل الأنظمة الأربعة مستقلة حقا بدل أن تكون نسخا متزحزحة الطور من تسلسل واحد، وتعني أنني أستطيع تغيير طريقة وضع العملات دون تحريك لغم واحد.
أنماط الأيام
الطور والتقلب يغيران شكل اليوم. ولتغيير نكهته، تختار البذرة أيضا واحدا من أربعة أنماط، تعدّل مقياس منحنيات التباعد الموجودة في اللعبة أصلا:
| النمط | الصواعد | العملات | الألغام | الممرات المتعرجة |
|---|---|---|---|---|
| كلاسيكي | 1.00 | 1.00 | 1.00 | 1.00 |
| يوم الممرات المتعرجة | 0.85 | 1.00 | 1.00 | 1.35 |
| اجتياز الألغام | 1.00 | 1.00 | 0.75 | 1.00 |
| اندفاع العملات | 1.15 | 0.72 | 1.15 | 0.80 |
هذه مضاعفات للتباعد، لذا فالرقم الأقل من واحد يعني أقرب معا. القاعدة التي وضعتها للجدول أنه لا يُسمح لأي صف أن يكون أصعب باطراد أو أسهل باطراد من الكلاسيكي. كل نمط يدفع مقبضا ويسحب آخر إلى الوراء. اندفاع العملات يعطيك عملات أكثر بكثير، وهي في TUNL توسّع الممر، لكنه يدفع ثمن ذلك بصواعد أضيق وألغام أكثر. يجب أن يُقرأ اليوم كيوم مختلف، لا كرمية حظ جيدة أو سيئة.
القيد الكامن تحت كل هذا
هذا هو الجزء الذي تبين أنه أهم من المولّد نفسه.
لأن الكهف مشترك ولوحة الصدارة عالمية، يجب أن يكون كل ما يقرر مكان وضع أي جسم دالة خالصة لموضع العالم واليوم. لا يُسمح لأي شيء آخر بالتسرب. لا حجم شاشة اللاعب، ولا نقاطه، ولا ما إذا كان لديه تعزيز فعال بالصدفة، ولا مدة لعبه.
يبدو هذا بديهيا عند كتابته. أما عمليا فهو يمنع قائمة طويلة من الأشياء التي تبدو معقولة تماما أثناء كتابتها، والانتهاكات لا تعلن عن نفسها. لا شيء ينهار. اللعبة تبدو صحيحة. الكهف ببساطة، وبصمت، توقف عن أن يكون الكهف نفسه.
الخطأ الأول: ستة أجهزة، ست كهوف
دوال التباعد التي تقرر مدى بعد العوائق عن بعضها هي منحنيات صعوبة، لذا فمن الطبيعي أن تقرأ الصعوبة الحالية. كانت النسخ الأصلية تقرأ المتغير العام _prog، وهو الصعوبة عند موضع اللاعب الحالي.
تبدو حلقات التوليد هكذا:
while (nextStalWx < scrollX + W + margin) { ... }
تضع العوائق بعد الحافة اليمنى للشاشة بقليل. لذا فإن اللحظة التي يُسحب فيها تباعد موضع عالم معين تعتمد على W، عرض الشاشة. الجهاز الأعرض يمتد أبعد للأمام، ويسحب ذلك الموضع أبكر في الجولة، وبالتالي يأخذ عينة من منحنى الصعوبة عند قيمة أدنى لـ_prog مما يفعل جهاز ضيق لنفس القطعة تماما من العالم.
تباعد مختلف. كهف مختلف. قسته على ستة أحجام أجهزة وحصلت على ست كهوف مختلفة، تتباعد من موضع العالم 938، أي نحو النقاط 15. كل ما بعد تلك النقطة على لوحة الصدارة اليومية المشتركة كان يُقارَن عبر أنفاق لم تكن النفق نفسه.
كان الإصلاح تمرير الموضع الذي يجري وضعه إلى كل منحنى، لتجيب "ما مدى صعوبته هناك" بدلا من "ما مدى صعوبته هنا، الآن":
function progAt(wx) { return Math.min(Math.sqrt(Math.max(wx, 0) / 14000), 1); }
function prog2At(wx) { return Math.max(wx - 14000, 0) / 40000; }
كان هناك مثال ثان أصغر من الفئة نفسها من الأخطاء مختبئا في تحديد الخط المركزي. كان WALL_PAD، الهامش الذي يبقي الممر بعيدا عن حافة الشاشة، ثابتا عند 8 بكسلات بينما كان كل ما حوله يتناسب مع ارتفاع الشاشة. على شاشة أطول يكون ذلك الحشو الثابت أصغر نسبيا، مما أزاح نقطة التحديد قليلا، وكان ذلك كافيا لقلب رفض وضع واحد، مما شقّ الكهف. صار الآن جزءا من H مثل كل شيء آخر.
يشترك الخطآن في شكل يستحق التسمية: قيمة هي تقريبا دالة خالصة لموضع العالم، مع ملوِّث واحد لا يهم إلا على عتاد لا تملكه. لم يكن أي منهما قابلا للاكتشاف باللعب. كلاهما مقفل الآن باختبارات تولّد الكهف عند عدة أحجام شاشة وتؤكد أن النتائج متطابقة بايتا ببايت.
الخطأ الثاني: محتوى لم يره أحد قط
لم يكن للفشل الثاني علاقة بالحتمية وكان كل علاقته بعدم فحص افتراضاتي مقابل الواقع.
بعد عمق معين تبلغ كل مقابض هندسة الممر سقوفها، لأن دفعها أكثر يجعل النفق غير قابل للعبور فعلا. بعد تلك النقطة لا يتغير شيء سوى سرعة التمرير، لذا كانت الجولة الطويلة جدا متغيرا واحدا يزداد عصبية ببطء في مقابل ممر مجمّد. ولإصلاح ذلك بنيت مجموعة ميزات للجولات العميقة: تحول شكل يعيد توزيع سعتي الموجتين عبر تسلسل مبذور من الطبائع (اكتساحات كبيرة نظيفة، تقطيع نابض، شبه مستقيم)، ونبضة سرعة تجعل الإيقاع يتنفس، وغرف واسعة تأتي كاستراحة، وصخور كمشاهد نادرة بارزة.
فتحت كل ذلك عند موضع العالم 54,000، وهو نحو النقاط 900. جاء ذلك الرقم من منحنى الصعوبة، حيث تبدأ الهضبة رياضيا. كان الرقم الصحيح للهندسة والرقم الخاطئ للاعبين.
عندما أعدت أخيرا تشغيل بيانات لوحة الصدارة الحقيقية مقابل المولّد، كانت الإجابة صريحة: عمليا لم يكن أحد يقترب منه. كنت قد أمضيت أسابيع في بناء محتوى لم يره لاعب واحد قط، بما فيهم أنا.
لذا بنيت محاكاة فريق أحمر: 2,400 جولة بلا واجهة عبر أربع فئات مهارة معايَرة، تقيس مدى ما تصله كل فئة فعلا. الفئة المطابقة للاعبين الحقيقيين بلغت موضع العالم 30,000 في 0% من 600 جولة. لا "نادرا". أبدا. حتى بعد أن خفضت البوابة مرة إلى 30,000، بقي المحتوى بعيد المنال. كان الخطأ نفسه، بحجم أصغر بمرتبة واحدة.
تقع البوابة الآن عند 9,000، نحو النقاط 150، حيث تقول المحاكاة إن نسبة حقيقية من الجولات تصل فعلا:
| فئة المهارة | الجولات التي تبلغ البوابة |
|---|---|
| متوسط | 3.3% |
| جيد | 24.7% |
| خبير | 63.5% |
لم يكن ذلك آمنا إلا لأن كل ميزة في الجولات العميقة مقيَّدة نسبة إلى الموضع نفسه غير المتحول. توزيعات سعة التحول مسقوفة نسبة إلى سرعة الممر، لذا فامتداد متحول لا يحمل أبدا أكثر من نحو 3% طاقة اهتزاز زائدة عن غير المتحول، والغرف لا تفعل سوى توسيع الممر. لا شيء من هذا يمكنه جعل موضع معين أصعب من خط الأساس المضبوط يدويا، عند أي عمق، ولهذا فتحريك البوابة قرار توريد لا تغيير في الصعوبة.
قطعة واحدة لم تتحرك عمدا. الألغام المنحازة للذروة، التي تنجذب نحو الخط المركزي عند ذروة المنعطف حيث يجبرك الممر بالفعل، تحمل خطرا حقيقيا بأن تُقرأ كغش من اللعبة. تبقى تلك عند العمق القديم حتى يطير بها أحد فعلا على جهاز. سحب خطر عدالة غير مثبت 750 نقطة أبكر ليس جزءا من إصلاح الإيقاع.
ما تشتريه الحتمية
كل هذا القيد يدفع ثمن ثلاثة أشياء كانت ستكون أصعب بكثير لولاه.
شبح لا يكلف شيئا تقريبا
التسابق مع إعادة لأفضل جولة لك يعني عادة تسجيل المدخلات أو حالة العوائق. هنا الكهف قابل لإعادة الإنتاج من تاريخ، لذا لا تحتاج الإعادة إلى شيء سوى موضع السفينة الرأسي عبر الزمن. بايت واحد لكل 60 بكسلا من العالم، مكمّى على ارتفاع الشاشة، مما يجعل الشبح الكامل بضع مئات من البايتات ويتيح لشبح مسجل على هاتف أن يُعاد بشكل صحيح على جهاز لوحي.
يُفهرس بالمسافة المقطوعة لا بالزمن المنقضي، وهذا مهم لأن أحد أنواع العملات يبطئ التمرير ويعيده على مدى عدة ثوان. شبح مفهرس بالزمن سيفقد تزامنه مع النفق لحظة التقاط أي أحد لواحدة.
بطاقة مشاركة هي صورة للجولة
لأن الممر دالة خالصة للموضع، يمكن إعادة رسم النفق المقطوع كله بعد الحدث، مضغوطا في شريط، مع تعليم نقطة الموت والرقم القياسي الشخصي. البطاقة صورة للجولة لا شارة نقاط، وتوليدها لا يحتاج إلى أي تسجيل، فقط المسافة المقطوعة.
لوحة صدارة لها معنى
طيران الجميع في كهف متطابق هو الأساس الكامل لمقارنة النقاط. وهو أيضا سبب تحديد عرض العرض بدلا من ملء الشاشة: تباعد العوائق ثابت في موضع العالم، لذا فجهاز أعرض بكثير سيرى الكهف المشترك يمر بسرعة أكبر نسبيا ويواجه العوائق نفسها بوقت رد فعل أقل. يُطبَّع الإحساس الرأسي بالطريقة نفسها، إذ تُقاس الجاذبية والدفع بارتفاع الشاشة مقابل مرجع، بحيث يغطي المسار النسبة نفسها من الممر في الثانية على أي جهاز.
لو بدأت من جديد
المولّد كان الجزء السهل. موجتان وتجزئة هما عصر يوم واحد. كل ما كان مكلفا جاء من الوعد الملفوف حوله، بأن الكهف هو نفسه، مما يحول كل ميزة مستقبلية إلى سؤال عما إذا كانت تسرب اللاحتمية إلى الوضع.
الدرس الذي أحتفظ به هو شكل الاختبار. أخطاء الحتمية غير مرئية باللعب وبسيطة بالتأكيد البرمجي. توليد أول عدة آلاف من المواضع عند ستة أحجام شاشة ومقارنتها يستغرق ميلي ثوان وكان سيلتقط خطأي الجهازين في اليوم الذي كُتبا فيه. أما خطأ بوابة المحتوى فاحتاج أداة مختلفة تماما، محاكاة لمدى ما يبلغه اللاعبون فعلا، والدرس الحقيقي الوحيد هناك أن رقما مشتقا من منحنى صعوبتك ليس رقما عن لاعبيك.
كهف اليوم متاح ويُعاد ضبطه عند منتصف الليل بتوقيت UTC. يعمل في المتصفح، دون تثبيت: