DEV Community

kouana
kouana

Posted on

لعبة ثلاثية الأبعاد داخل صفحة ويب عادية: سبع مصائد لم يذكرها أي درس

قررنا أن تكون صفحة خدمة الألعاب عندنا دليلًا لا وعدًا. بدل صور ثابتة لألعاب صنعناها، وضعنا في الصفحة لعبة تُلعب فعلًا: مشهد صحراوي بمحرك three.js، سلاح مُجهَّز بحركات إطلاق وإعادة تلقيم، أعداء يخرجون من الرمل ويطاردون اللاعب، وحلقة مهمة تنتهي عند خمسين هدفًا.

تشغيل مشهد WebGL داخل صفحة موقع قائمة يختلف عن تشغيله في صفحة فارغة. أغلب الدروس تفترض صفحة نظيفة فيها <canvas> وحده. أما نحن فأدخلناه وسط تخطيط فيه حركات وتحويلات ورأس ثابت ومحتوى تسويقي.

في كل مصيدة من السبع التالية، كانت رسالة الخطأ تشير إلى موضع غير الموضع الذي يجب إصلاحه.

1. position: fixed يتوقف عن كونه fixed

أول ما بنينا زر ملء الشاشة، ظهر المشهد داخل حدود القسم لا فوق الصفحة. راجعنا الـCSS مرارًا وكل شيء سليم ظاهريًا.

السبب ليس في العنصر نفسه بل في أحد آبائه. أي عنصر عليه transform أو filter أو perspective يصنع حاوية احتواء جديدة، فيصير position: fixed نسبيًا إليه لا إلى نافذة المتصفح. وتخطيط موقعنا مليء بعناصر عليها transform بسبب الحركات.

أخرِج المشهد من الشجرة كلها. مطاردة الأب المذنب حل مؤقت، لأن أي تعديل تصميم لاحق يعيد المشكلة:

import { createPortal } from "react-dom";

function FullscreenGame({ children }) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return null;
  return createPortal(
    <div style={{ position: "fixed", inset: 0, zIndex: 9999 }}>{children}</div>,
    document.body
  );
}
Enter fullscreen mode Exit fullscreen mode

ضع أي طبقة تغطي الشاشة كاملة في document.body مباشرة، ولا تعتمد على fixed وحده داخل موقع حقيقي.

2. حاوية بمقاس صفر تُغرق الكونسول

بعد نقل المشهد إلى البورتال، امتلأ الكونسول بسطر واحد يتكرر بالآلاف:

GL_INVALID_FRAMEBUFFER_OPERATION: Framebuffer is incomplete
Enter fullscreen mode Exit fullscreen mode

الرسالة توحي بخلل في إعداد المشهد أو في بطاقة الرسوم. الحقيقة أبسط: الحاوية كانت لا تزال بعرض صفر وارتفاع صفر في اللحظة التي قرأ فيها three.js مقاسها، فأنشأ إطارًا بمساحة صفر. وكل إطار رسم بعدها يفشل ويطبع السطر نفسه.

الحاوية تأخذ مقاسها بعد الرسم الأول لا قبله، ونحن نقرأه قبله. العلاج أن يكون للقراءة بديل مضمون:

const w = wrap.clientWidth || window.innerWidth;
const h = wrap.clientHeight || window.innerHeight;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
Enter fullscreen mode Exit fullscreen mode

ومعها ResizeObserver يعيد الضبط عند أول مقاس حقيقي. أي كود يقرأ مقاس عنصر لحظة التركيب يحتاج قيمة احتياطية، فالمقاس صفر حالة شرعية لا خطأ، وهذا يتجاوز WebGL إلى أي مكتبة رسم أو مخطط بياني.

3. طلب قفل المؤشر قد يُرفض، وأنت لا تتوقع ذلك

التحكم بالفأرة في لعبة منظور أول يحتاج requestPointerLock(). كتبناها كما تُكتب في كل الأمثلة:

canvas.addEventListener("click", () => canvas.requestPointerLock());
Enter fullscreen mode Exit fullscreen mode

عملت على أجهزتنا. ثم بدأت تظهر شاشة خطأ حمراء من Next.js عند بعض المستخدمين وفي بيئات الاختبار الآلي. السبب أن هذه الدالة ترجع Promise في المتصفحات الحديثة، وهذا الوعد يُرفض إذا لم يعتبر المتصفح النقرة إيماءة مستخدم موثوقة، أو إذا كانت الصفحة داخل إطار مقيّد. الرفض غير الملتقَط يصير خطأ غير معالَج، وNext يعرضه فوق الصفحة.

canvas.addEventListener("click", () => {
  const req = canvas.requestPointerLock();
  if (req && typeof req.catch === "function") {
    req.catch(() => showHint("اضغط داخل الشاشة للتحكم"));
  }
});
Enter fullscreen mode Exit fullscreen mode

عامِل كل واجهة برمجية تتعلق بأذونات المتصفح بالطريقة نفسها. افترض أنها سترفض، وحوّل الرفض إلى رسالة مفهومة بدل شاشة خطأ.

4. المؤشر يختفي فوق قائمة تحتاج فأرة

قفل المؤشر يخفيه عن الشاشة، وهذا مطلوب أثناء اللعب. المشكلة تبدأ عند فتح أي طبقة فوق اللعبة: قائمة إيقاف مؤقت، أو شاشة نهاية، أو تحذير خروج. الأزرار ظاهرة والمؤشر غائب، فيظن اللاعب أن الصفحة تجمّدت.

المتصفح لا يفك القفل تلقائيًا عند ظهور عنصر جديد، لأنه لا يعرف شيئًا عن طبقاتك. اربط فك القفل بحالة الواجهة نفسها:

useEffect(() => {
  const overlayOpen = paused || dead || won;
  if (overlayOpen && document.pointerLockElement) {
    document.exitPointerLock();
  }
}, [paused, dead, won]);
Enter fullscreen mode Exit fullscreen mode

وأضفنا cursor: auto صريحًا على الطبقة، وشريحة تلميح تظهر حين يكون المؤشر غير مقفول. كل حالة تفتح واجهة يجب أن تحرر الفأرة، وإلا صار اللاعب أمام شاشة تبدو معطلة.

5. قبة السماء تظهر سوداء بالكامل

استخدمنا نماذج مشاهد جاهزة لبعض العوالم. أغلبها ليس تضاريس بل قبة مرسومة من الداخل: كرة واحدة عليها صورة، والكاميرا تقف بداخلها.

عند تحميلها ظهرت سوداء تمامًا. المشكلة أن الخامة الافتراضية MeshStandardMaterial خامة مضاءة، والوجه الداخلي العلوي للقبة لا يصله ضوء، فيُرسم أسود مهما رفعت شدة الإضاءة.

القبة ليست جسمًا في المشهد، هي لوحة. والحل أن تُعامَل كلوحة:

dome.traverse((o) => {
  if (!o.isMesh) return;
  o.material = new THREE.MeshBasicMaterial({
    map: o.material.map,
    side: THREE.BackSide,   // نرى الوجه الداخلي
    fog: false,             // الضباب لا يمس السماء
    toneMapped: false,      // بألوانها الأصلية
  });
});

// أعد مركز الصندوق المحيط إلى الأصل وإلا وقفت الكاميرا خارج القبة
const box = new THREE.Box3().setFromObject(dome);
dome.position.sub(box.getCenter(new THREE.Vector3()));
Enter fullscreen mode Exit fullscreen mode

وأضفنا خلفية متدرجة على scene.background كضمانة أخيرة، حتى لو فشل تحميل النموذج لا يرى الزائر سوادًا.

6. نموذج بـ87 ميجابايت لا يُرسل إلى متصفح أحد

النموذج الرئيسي للشخصية جاء بحجم 87 ميجابايت. رقم مقبول في محرك ألعاب مثبَّت على الجهاز، ومستحيل في صفحة ويب.

خفّضناه إلى 3.1 ميجابايت بأمر واحد، مع الاحتفاظ بـ92 ألف مثلث وبحركة السكون كاملة و25 خامة:

npx @gltf-transform/cli optimize in.glb out.glb \
  --texture-compress webp \
  --texture-size 1024 \
  --compress draco
Enter fullscreen mode Exit fullscreen mode

أغلب الوزن في الخامات لا في الأشكال. تحويل الصور إلى webp وتحديد حدها الأقصى عند 1024 بكسل يقتطع النصيب الأكبر، وضغط draco يتولى الهندسة. بعد التحويل يفتح المشهد على شبكة جوال عادية.

7. next/dynamic داخل مكوّن سيرفر لا يقسّم شيئًا

هذه لم تظهر في الألعاب وحدها، بل في الموقع كله، ولم ننتبه لها إلا عند قياس الحزمة.

كنا نستورد المحركات الثقيلة عبر next/dynamic ونظن أن كل صفحة تحمّل ما يخصها فقط. الأرقام قالت غير ذلك: أول تحميل لمسار الصفحات الداخلية كان 2.04 ميجابايت.

السبب أن next/dynamic داخل مكوّن سيرفر لا يقسّم إطلاقًا. التقسيم الفعلي يقوم على دلالات React.lazy، وهي لا تعمل إلا على حدود العميل. المكوّن السيرفري يجمع كل شيء في حزمة صفحة واحدة.

الحل أن يكون الموزّع نفسه مكوّن عميل:

"use client";
import dynamic from "next/dynamic";

const PAGES = {
  106: dynamic(() => import("./p106")),
  9443: dynamic(() => import("./p9443")),
};

export default function BespokePage({ id, content }) {
  const C = PAGES[id];
  return C ? <C content={content} /> : null;
}
Enter fullscreen mode Exit fullscreen mode

ويبقى في السيرفر ملف أرقام فقط، لا يستورد أي مكوّن، ليقرر أي فرع يُسلك. النتيجة: أول تحميل نزل من 2.04 ميجابايت إلى 195 كيلوبايت، وعلى صفحة حية قِيس الجافاسكربت المرسل 2,012 كيلوبايت قبل، و209 كيلوبايت بعد.

كيف تتحقق من مشهد لا تستطيع تصويره

أدوات لقطة الشاشة الآلية كانت تفشل على صفحة اللعبة برسالة انتهاء مهلة. حلقة requestAnimationFrame لا تتوقف، فلا تجد الأداة لحظة سكون تلتقط فيها.

المخرج أن نقرأ الإطار من بطاقة الرسوم مباشرة:

const gl = canvas.getContext("webgl2");
const px = new Uint8Array(gl.drawingBufferWidth * gl.drawingBufferHeight * 4);
gl.readPixels(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight,
              gl.RGBA, gl.UNSIGNED_BYTE, px);
Enter fullscreen mode Exit fullscreen mode

ثم نصنّف الصفوف بنسبة البكسلات السوداء ومتوسط اللون. بعد إصلاح القبة صار المشهد صفر بالمئة سوادًا، ومتوسط اللون في الصفوف العليا [116, 174, 210]، أي سماء زرقاء. اختبار رقمي كهذا يعمل داخل خط النشر الآلي.

انتبه أن أصل الإحداثيات في readPixels أسفل اليسار لا أعلاه، فأي منطق يفترض العكس سيقرأ الأرض على أنها سماء.

الخلاصة

المصائد السبع تنبع كلها من موضع المشهد: داخل موقع حقيقي، لا في صفحة تجريبية معزولة. الأب صاحب الـtransform، والحاوية التي لم تأخذ مقاسها بعد، والحزمة التي جمعت كل الصفحات، لا وجود لأي منها في الدرس التعليمي.

بنينا هذا الديمو في شركة تصميم العاب رؤية ليكون جزءًا من صفحة الخدمة نفسها، فوقعنا في المصائد بالترتيب. إن كنت تخطط لشيء مشابه، قِس حزمة الجافاسكربت قبل أن تصدّق أن التقسيم يعمل، وابدأ باختبار المشهد داخل تخطيط موقعك لا في صفحة فارغة.

Top comments (0)