خرائط مسدس في الوحدة: دورة المياه ، التآكل ، المناطق الأحيائية ، خريطة أسطوانية

الأجزاء 1-3: الشبكة والألوان وارتفاعات الخلية

الأجزاء 4-7: المطبات والأنهار والطرق

الأجزاء 8-11: الماء والأشكال الأرضية والأسوار

الأجزاء 12-15: الحفظ والتحميل ، القوام ، المسافات

الأجزاء 16-19: إيجاد الطريق وفرق اللاعبين والرسوم المتحركة

الأجزاء 20-23: ضباب الحرب ، بحث الخرائط ، الجيل الإجرائي

الأجزاء 24-27: دورة الماء ، التآكل ، المناطق الأحيائية ، الخريطة الأسطوانية

الجزء 24: المناطق والتآكل


  • أضف حدًا من المياه حول الخريطة.
  • نقسم الخريطة إلى عدة مناطق.
  • نستخدم التآكل لقطع المنحدرات.
  • ننتقل الأرض لتخفيف الإغاثة.

في الجزء السابق ، وضعنا الأسس لتوليد الخريطة الإجرائية. هذه المرة سنحد من الأماكن التي يمكن أن تحدث فيها الأرض ونعمل عليها بالتآكل.

تم إنشاء هذا البرنامج التعليمي في Unity 2017.1.0.


فصل الأرض وتنعيمها.

حد الخريطة


نظرًا لأننا نرفع مساحات الأراضي بشكل عشوائي ، فقد يحدث أن تلامس حافة الخريطة. قد يكون هذا غير مرغوب فيه. تحتوي الخريطة المحدودة المياه على حاجز طبيعي يمنع اللاعبين من الاقتراب من الحافة. لذلك ، سيكون من الجيد أن نمنع الأرض من الارتفاع فوق مستوى المياه بالقرب من حافة الخريطة.

حجم الحدود


ما مدى قرب الأرض من حافة الخريطة؟ لا توجد إجابة صحيحة لهذا السؤال ، لذلك سنجعل هذه المعلمة قابلة للتخصيص. سنضيف HexMapGenerator مكون HexMapGenerator ، أحدهما للحدود على طول الحواف على طول المحور X ، والآخر للحدود على طول المحور Z. لذا يمكننا استخدام حد أوسع في أحد الأبعاد ، أو حتى إنشاء حد في بُعد واحد فقط. لنستخدم فاصل زمني من 0 إلى 10 بقيمة افتراضية 5.

  [Range(0, 10)] public int mapBorderX = 5; [Range(0, 10)] public int mapBorderZ = 5; 


تعيين أشرطة تمرير الحدود.

نحن نقيد مراكز مناطق الأراضي


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

  int xMin, xMax, zMin, zMax; 

نقوم بتهيئة القيود في GenerateMap قبل إنشاء السوشي. نستخدم هذه القيم كمعلمات للمكالمات Random.Range ، لذا فإن الارتفاعات استثنائية بالفعل. بدون حد ، فهي تساوي عدد خلايا القياس ، وبالتالي ، ليست ناقص 1.

  public void GenerateMap (int x, int z) { … for (int i = 0; i < cellCount; i++) { grid.GetCell(i).WaterLevel = waterLevel; } xMin = mapBorderX; xMax = x - mapBorderX; zMin = mapBorderZ; zMax = z - mapBorderZ; CreateLand(); … } 

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

  HexCell GetRandomCell () { // return grid.GetCell(Random.Range(0, cellCount)); return grid.GetCell(Random.Range(xMin, xMax), Random.Range(zMin, zMax)); } 





حدود الخريطة هي 0 × 0 و 5 × 5 و 10 × 10 و 0 × 10.

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

تعتمد احتمالية عبور الأرض للحدود بأكملها على حجم الحدود والحد الأقصى لحجم الموقع. بدون تردد ، تبقى الأقسام سداسية. مسدس كامل مع نصف القطر rيحتوي على 3r2+3r+1الخلايا. إذا كانت هناك مسدسات نصف قطرها يساوي حجم الحدود ، فيمكنها عبورها. يحتوي السداسي الكامل بنصف قطر 5 على 91 خلية. نظرًا لأن الحد الأقصى افتراضيًا هو 100 خلية لكل قسم ، وهذا يعني أن الأرض ستكون قادرة على مد جسر عبر 5 خلايا ، خاصة إذا كانت هناك اهتزازات. لمنع حدوث ذلك ، يمكنك إما تقليل الحجم الأقصى للقطعة أو زيادة حجم الحدود.

كيف يتم اشتقاق صيغة عدد الخلايا في المنطقة السداسية؟
مع نصف قطر 0 ، نتعامل مع خلية واحدة. جاء من 1. مع نصف قطرها 1 حول المركز ، هناك ست خلايا إضافية ، وهذا هو 6 دولارات + 1 دولار . يمكن اعتبار هذه الخلايا الستة نهايات ستة مثلثات تلامس المركز. مع نصف قطر 2 ، يضاف صف ثان إلى هذه المثلثات ، أي يتم الحصول على خليتين إضافيتين على المثلث ، وإجماليًا 6 دولارات (1 + 2) + 1 دولار . مع نصف قطر 3 ، يتم إضافة صف ثالث ، أي ثلاث خلايا أخرى لكل مثلث ، وإجماليًا 6 دولارات (1 + 2 + 3) + 1 دولار . وهكذا دواليك. أي ، بشكل عام ، تبدو الصيغة 6(sum(i=1)ri)+1=6((r(r+1))/2)+1=3r(r+1)+1=3r2+3r+1.

لرؤية هذا بشكل أكثر وضوحًا ، يمكننا ضبط حجم الحدود على 200. نظرًا لأن السداسي الكامل بنصف قطر 8 يحتوي على 217 خلية ، فمن المرجح أن تلامس الأرض حافة الخريطة. على الأقل إذا كنت تستخدم قيمة حجم الحدود الافتراضية (5). إذا قمت بزيادة الحد إلى 10 ، فسوف ينخفض ​​الاحتمال بشكل كبير.



تبلغ مساحة الأرض 200 قطعة ثابتة ، وحدود الخريطة 5 و 10.

بانجيا


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


40٪ سوشي بحد بطاقة 10.

من أين جاء اسم بانجيا؟
كان هذا اسم آخر قارة عظمى معروفة كانت موجودة على الأرض منذ سنوات عديدة. يتكون الاسم من الكلمات اليونانية عموم و Gaia ، مما يعني شيئا مثل "كل الطبيعة" أو "كل الأرض".


نحمي من بطاقات مستحيلة


نقوم بتوليد المساحة المناسبة من الأرض من خلال الاستمرار في رفع الأرض حتى نصل إلى الكتلة الأرضية المطلوبة. هذا يعمل لأنه عاجلاً أم آجلاً سوف نرفع كل خلية على مستوى الماء. ومع ذلك ، عند استخدام حدود الخريطة ، لا يمكننا الوصول إلى كل خلية. عندما تكون هناك حاجة إلى نسبة عالية جدًا من الأرض ، سيؤدي ذلك إلى "محاولات وإخفاقات" لا نهاية لها للمولد لرفع المزيد من الأراضي ، وسوف يصبح عالقًا في دورة لا نهاية لها. في هذه الحالة ، سيتم تجميد التطبيق ، ولكن هذا لا يجب أن يحدث.

لا يمكننا العثور على تكوينات مستحيلة بشكل موثوق مسبقًا ، ولكن يمكننا حماية أنفسنا من الدورات التي لا نهاية لها. CreateLand ببساطة عدد الدورات التي تم تنفيذها في CreateLand . إذا كان هناك الكثير من التكرارات ، فمن المرجح أننا عالقون ويجب أن نتوقف.

بالنسبة للخريطة الكبيرة ، تبدو آلاف التكرارات مقبولة ، وعشرة آلاف تكرار تبدو سخيفة بالفعل. لذلك دعونا نستخدم هذه القيمة كنقطة إنهاء.

  void CreateLand () { int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f); // while (landBudget > 0) { for (int guard = 0; landBudget > 0 && guard < 10000; guard++) { int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1); … } } 

إذا حصلنا على خريطة تالفة ، فإن إجراء 10000 تكرار لن يستغرق الكثير من الوقت ، لأن العديد من الخلايا ستصل بسرعة إلى أقصى ارتفاع ، مما سيمنع المناطق الجديدة من النمو.

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

  void CreateLand () { … if (landBudget > 0) { Debug.LogWarning("Failed to use up " + landBudget + " land budget."); } } 


95٪ من الأراضي بحد بطاقة 10 لا يمكنها إنفاق المبلغ بالكامل.

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

حزمة الوحدة

تقسيم البطاقة


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

منطقة الخريطة


لنبدأ بوصف منطقة واحدة من الخريطة بالبنية. سيؤدي هذا إلى تبسيط عملنا مع العديد من المناطق. لنقم بإنشاء بنية MapRegion لهذا ، والتي تحتوي ببساطة على الحقول الحدودية للمنطقة. نظرًا لأننا لن نستخدم هذه البنية خارج HexMapGenerator ، فيمكننا تعريفها داخل هذه الفئة HexMapGenerator داخلية خاصة. ثم يمكن استبدال أربعة حقول صحيحة MapRegion واحد.

 // int xMin, xMax, zMin, zMax; struct MapRegion { public int xMin, xMax, zMin, zMax; } MapRegion region; 

لكي يعمل كل شيء ، نحتاج إلى إضافة بادئة region. إلى الحد الأدنى من الحقول في GenerateMap region. .

  region.xMin = mapBorderX; region.xMax = x - mapBorderX; region.zMin = mapBorderZ; region.zMax = z - mapBorderZ; 

وأيضا في GetRandomCell .

  HexCell GetRandomCell () { return grid.GetCell( Random.Range(region.xMin, region.xMax), Random.Range(region.zMin, region.zMax) ); } 

عدة مناطق


لدعم مناطق متعددة ، MapRegion حقل MapRegion بقائمة مناطق.

 // MapRegion region; List<MapRegion> regions; 

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

  void CreateRegions () { if (regions == null) { regions = new List<MapRegion>(); } else { regions.Clear(); } MapRegion region; region.xMin = mapBorderX; region.xMax = grid.cellCountX - mapBorderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); } 

سوف نسمي هذه الطريقة في GenerateMap ، ولن نقوم بإنشاء المنطقة مباشرة.

 // region.xMin = mapBorderX; // region.xMax = x - mapBorderX; // region.zMin = mapBorderZ; // region.zMax = z - mapBorderZ; CreateRegions(); CreateLand(); 

حتى تتمكن GetRandomCell العمل مع منطقة عشوائية ، قم MapRegion المعلمة MapRegion .

  HexCell GetRandomCell (MapRegion region) { return grid.GetCell( Random.Range(region.xMin, region.xMax), Random.Range(region.zMin, region.zMax) ); } 

الآن يجب أن تمرر SinkTerrain و SinkTerrain المنطقة المقابلة إلى GetRandomCell . للقيام بذلك ، يحتاج كل منهم أيضًا إلى معلمة منطقة.

  int RaiseTerrain (int chunkSize, int budget, MapRegion region) { searchFrontierPhase += 1; HexCell firstCell = GetRandomCell(region); … } int SinkTerrain (int chunkSize, int budget, MapRegion region) { searchFrontierPhase += 1; HexCell firstCell = GetRandomCell(region); … } 

يجب أن تحدد طريقة CreateLand لكل منطقة رفع الأقسام أو خفضها. لموازنة الأرض بين المناطق ، سنقوم ببساطة بالتجول في قائمة المناطق في الدورة.

  void CreateLand () { int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f); for (int guard = 0; landBudget > 0 && guard < 10000; guard++) { for (int i = 0; i < regions.Count; i++) { MapRegion region = regions[i]; int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1); if (Random.value < sinkProbability) { landBudget = SinkTerrain(chunkSize, landBudget, region); } else { landBudget = RaiseTerrain(chunkSize, landBudget, region); } } } if (landBudget > 0) { Debug.LogWarning("Failed to use up " + landBudget + " land budget."); } } 

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

  for (int guard = 0; landBudget > 0 && guard < 10000; guard++) { bool sink = Random.value < sinkProbability; for (int i = 0; i < regions.Count; i++) { MapRegion region = regions[i]; int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1); // if (Random.value < sinkProbability) { if (sink) { landBudget = SinkTerrain(chunkSize, landBudget, region); } else { landBudget = RaiseTerrain(chunkSize, landBudget, region); } } } 

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

 // for (int guard = 0; landBudget > 0 && guard < 10000; guard++) { for (int guard = 0; guard < 10000; guard++) { bool sink = Random.value < sinkProbability; for (int i = 0; i < regions.Count; i++) { MapRegion region = regions[i]; int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1); if (sink) { landBudget = SinkTerrain(chunkSize, landBudget, region); } else { landBudget = RaiseTerrain(chunkSize, landBudget, region); if (landBudget == 0) { return; } } } } 

منطقتين


على الرغم من أن لدينا الآن دعم العديد من المناطق ، ما زلنا نطلب منطقة واحدة فقط. دعنا نغير CreateRegions بحيث تقسم الخريطة إلى نصف رأسي. للقيام بذلك ، قمنا بتخفيض قيمة xMax للمنطقة المضافة إلى النصف. ثم نستخدم نفس القيمة لـ xMin مرة أخرى القيمة الأصلية لـ xMax ، xMax كمنطقة ثانية.

  MapRegion region; region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = grid.cellCountX / 2; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region); 

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

  [Range(0, 10)] public int regionBorder = 5; 


شريط تمرير حدود المنطقة.

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

لتطبيق حدود المنطقة هذه ، xMax للمنطقة الأولى وأضف المنطقة الثانية إلى xMin .

  MapRegion region; region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2 - regionBorder; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = grid.cellCountX / 2 + regionBorder; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region); 


تنقسم الخريطة رأسيًا إلى منطقتين.

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

بالطبع ، يمكننا تقسيم الخريطة أفقيًا أيضًا ، وتغيير نهج قياس X و Z. دعنا نختار عشوائيًا أحد اتجاهين محتملين.

  MapRegion region; if (Random.value < 0.5f) { region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2 - regionBorder; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = grid.cellCountX / 2 + regionBorder; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region); } else { region.xMin = mapBorderX; region.xMax = grid.cellCountX - mapBorderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ / 2 - regionBorder; regions.Add(region); region.zMin = grid.cellCountZ / 2 + regionBorder; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); } 


الخريطة مقسمة أفقياً إلى منطقتين.

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

أربع مناطق


دعونا نجعل عدد المناطق قابلاً للتخصيص ، وأنشئ الدعم من 1 إلى 4 مناطق.

  [Range(1, 4)] public int regionCount = 1; 


المنزلق لعدد المناطق.

يمكننا استخدام switch لتحديد تنفيذ رمز المنطقة المقابلة. نبدأ بتكرار رمز منطقة واحدة ، والذي سيتم استخدامه افتراضيًا ، وترك رمز منطقتين للحالة 2.

  MapRegion region; switch (regionCount) { default: region.xMin = mapBorderX; region.xMax = grid.cellCountX - mapBorderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); break; case 2: if (Random.value < 0.5f) { region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2 - regionBorder; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = grid.cellCountX / 2 + regionBorder; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region); } else { region.xMin = mapBorderX; region.xMax = grid.cellCountX - mapBorderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ / 2 - regionBorder; regions.Add(region); region.zMin = grid.cellCountZ / 2 + regionBorder; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); } break; } 

ما هو بيان التبديل؟
هذا بديل لكتابة سلسلة من عبارات if-else-if-else. يتم تطبيق رمز التبديل على المتغير ، ويتم استخدام التسميات للإشارة إلى الرمز الذي يجب تنفيذه. هناك أيضًا تسمية default ، والتي يتم استخدامها ككتلة أخرى أخرى. يجب أن ينتهي كل خيار إما ببيان break أو return .

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

  switch (regionCount) { default: CreateOneRegion(); break; case 2: CreateTwoRegions(); break; case 3: CreateThreeRegions(); break; case 4: CreateFourRegions(); break; } 

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

  switch (regionCount) { default: … break; case 2: … break; case 3: region.xMin = mapBorderX; region.xMax = grid.cellCountX / 3 - regionBorder; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = grid.cellCountX / 3 + regionBorder; region.xMax = grid.cellCountX * 2 / 3 - regionBorder; regions.Add(region); region.xMin = grid.cellCountX * 2 / 3 + regionBorder; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region); break; } 


ثلاث مناطق.

يمكن إنشاء أربع مناطق من خلال الجمع بين الفصل الأفقي والرأسي وإضافة منطقة واحدة إلى كل ركن من أركان الخريطة.

  switch (regionCount) { … case 4: region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2 - regionBorder; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ / 2 - regionBorder; regions.Add(region); region.xMin = grid.cellCountX / 2 + regionBorder; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region); region.zMin = grid.cellCountZ / 2 + regionBorder; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2 - regionBorder; regions.Add(region); break; } } 


أربع مناطق.

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

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

حزمة الوحدة

تآكل


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

  public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); SetTerrainType(); … } … void ErodeLand () {} 

نسبة التآكل


كلما مر الوقت ، كلما زاد التآكل. لذلك ، نريد ألا يكون التآكل دائمًا ، ولكن قابلًا للتخصيص. كحد أدنى ، التآكل هو صفر ، وهو ما يتوافق مع الخرائط التي تم إنشاؤها في وقت سابق. في الحد الأقصى للتآكل شامل ، أي أن التطبيق الإضافي لقوى التآكل لن يغير التضاريس. بمعنى ، يجب أن تكون معلمة التآكل نسبة من 0 إلى 100 ، وبشكل افتراضي سنأخذ 50.

  [Range(0, 100)] public int erosionPercentage = 50; 


منزلق التآكل.

ابحث عن الخلايا المدمرة للتآكل


التآكل يجعل الراحة أكثر سلاسة. في حالتنا ، الأجزاء الحادة الوحيدة هي المنحدرات. لذلك ، سيكونون هدفا لعملية التآكل. في حالة وجود جرف ، يجب أن يقلل التآكل منه حتى يتحول في النهاية إلى منحدر. لن نهدئ المنحدرات ، لأن هذا سيؤدي إلى تضاريس مملة. للقيام بذلك ، نحتاج إلى تحديد الخلايا الموجودة في أعلى المنحدرات ، وخفض ارتفاعها. ستكون هذه الخلايا عرضة للتآكل.

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

  bool IsErodible (HexCell cell) { int erodibleElevation = cell.Elevation - 2; for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (neighbor && neighbor.Elevation <= erodibleElevation) { return true; } } return false; } 

يمكننا استخدام هذه الطريقة ErodeLandللدوران عبر جميع الخلايا وكتابة جميع الخلايا المعرضة للتآكل إلى قائمة مؤقتة.

  void ErodeLand () { List<HexCell> erodibleCells = ListPool<HexCell>.Get(); for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); if (IsErodible(cell)) { erodibleCells.Add(cell); } } ListPool<HexCell>.Add(erodibleCells); } 

بمجرد أن نعرف العدد الإجمالي للخلايا المعرضة للتآكل ، يمكننا استخدام النسبة المئوية للتآكل لتحديد عدد الخلايا المعرضة للتآكل المتبقية. على سبيل المثال ، إذا كانت النسبة 50 ، فيجب علينا تآكل الخلايا حتى يبقى نصف الكمية الأصلية. إذا كانت النسبة المئوية 100 ، فلن نتوقف حتى ندمر جميع الخلايا المعرضة للتآكل.

  void ErodeLand () { List<HexCell> erodibleCells = ListPool<HexCell>.Get(); for (int i = 0; i < cellCount; i++) { … } int targetErodibleCount = (int)(erodibleCells.Count * (100 - erosionPercentage) * 0.01f); ListPool<HexCell>.Add(erodibleCells); } 

ألا يجب أن نعتبر فقط الخلايا المعرضة لتآكل الأرض؟
. , , .

تخفيض الخلية


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

  int targetErodibleCount = (int)(erodibleCells.Count * (100 - erosionPercentage) * 0.01f); while (erodibleCells.Count > targetErodibleCount) { int index = Random.Range(0, erodibleCells.Count); HexCell cell = erodibleCells[index]; cell.Elevation -= 1; erodibleCells.Remove(cell); } ListPool<HexCell>.Add(erodibleCells); 

لمنع البحث المطلوب erodibleCells.Remove، سنقوم بالكتابة فوق الخلية الحالية في القائمة الأخيرة ، ثم نحذف العنصر الأخير. ما زلنا لا نهتم بترتيبهم.

 // erodibleCells.Remove(cell); erodibleCells[index] = erodibleCells[erodibleCells.Count - 1]; erodibleCells.RemoveAt(erodibleCells.Count - 1); 



السذاجة انخفاض 0 ٪ و 100 ٪ من الخلايا المعرضة للتآكل ، خريطة البذور 1957632474.

تتبع التآكل


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

  if (!IsErodible(cell)) { erodibleCells[index] = erodibleCells[erodibleCells.Count - 1]; erodibleCells.RemoveAt(erodibleCells.Count - 1); } 


تآكل 100 ٪ مع الحفاظ على الخلايا المعرضة للتآكل في القائمة.

لذلك نحصل على تآكل أقوى بكثير ، ولكن عند استخدام 100 ٪ ما زلنا لا نتخلص من جميع المنحدرات. والسبب هو أنه بعد تقليل ارتفاع الخلية ، قد يصبح أحد جيرانها عرضة للتآكل. لذلك ، ونتيجة لذلك ، قد يكون لدينا خلايا أكثر عرضة للتآكل مما كانت عليه في الأصل.

بعد خفض الخلية ، نحتاج إلى التحقق من جميع جيرانها. إذا كانوا الآن عرضة للتآكل ، لكنهم ليسوا مدرجين في القائمة بعد ، فأنت بحاجة إلى إضافتهم هناك.

  if (!IsErodible(cell)) { erodibleCells[index] = erodibleCells[erodibleCells.Count - 1]; erodibleCells.RemoveAt(erodibleCells.Count - 1); } for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if ( neighbor && IsErodible(neighbor) && !erodibleCells.Contains(neighbor) ) { erodibleCells.Add(neighbor); } } 


يتم حذف جميع الخلايا المتآكلة.

نحن ننقذ الكثير من الأرض


الآن يمكن أن تستمر عملية التآكل حتى تختفي جميع المنحدرات. هذا يؤثر بشكل كبير على الأرض. اختفت معظم كتلة الأرض وحصلنا على أقل بكثير من النسبة المئوية للأرض المطلوبة. حدث ذلك لأننا نزيل الأرض من الخريطة.

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

لتحقيق ذلك ، نحتاج إلى تحديد مكان نقل منتجات التآكل. سيكون هذا هدفنا للتآكل. لنقم بإنشاء طريقة لتحديد نقطة الهدف للخلية التي سيتم تآكلها. نظرًا لأن هذه الخلية تحتوي على فاصل ، سيكون من المنطقي تحديد الخلية الموجودة تحت هذا الفاصل كهدف. لكن الخلية المعرضة للتآكل يمكن أن يكون لها عدة فواصل ، لذلك سوف نتحقق من جميع الجيران ونضع جميع المرشحين على قائمة مؤقتة ، ثم سنختار واحدة منهم بشكل عشوائي.

  HexCell GetErosionTarget (HexCell cell) { List<HexCell> candidates = ListPool<HexCell>.Get(); int erodibleElevation = cell.Elevation - 2; for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (neighbor && neighbor.Elevation <= erodibleElevation) { candidates.Add(neighbor); } } HexCell target = candidates[Random.Range(0, candidates.Count)]; ListPool<HexCell>.Add(candidates); return target; } 

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

  HexCell cell = erodibleCells[index]; HexCell targetCell = GetErosionTarget(cell); cell.Elevation -= 1; targetCell.Elevation += 1; if (!IsErodible(cell)) { erodibleCells[index] = erodibleCells[erodibleCells.Count - 1]; erodibleCells.RemoveAt(erodibleCells.Count - 1); } 

نظرًا لأننا رفعنا الخلية المستهدفة ، فقد لا يتعرض جزء من جيران هذه الخلية للتآكل. من الضروري الالتفاف حولهم والتحقق مما إذا كانوا عرضة للتآكل. إذا لم يكن كذلك ، لكنهم في القائمة ، فأنت بحاجة إلى إزالتهم منها.

  for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); … } for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = targetCell.GetNeighbor(d); if ( neighbor && !IsErodible(neighbor) && erodibleCells.Contains(neighbor) ) { erodibleCells.Remove(neighbor); } } 


تآكل 100 ٪ مع الحفاظ على كتلة الأرض.

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

تآكل معجل


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

  for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = targetCell.GetNeighbor(d); if ( neighbor && neighbor != cell && !IsErodible(neighbor) && erodibleCells.Contains(neighbor) ) { erodibleCells.Remove(neighbor); } } 

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

  HexCell neighbor = targetCell.GetNeighbor(d); if ( neighbor && neighbor != cell && neighbor.Elevation == targetCell.Elevation + 1 && !IsErodible(neighbor) // && erodibleCells.Contains(neighbor) ) { erodibleCells.Remove(neighbor); } 

ثالثًا ، يمكننا استخدام خدعة مماثلة عند التحقق من جيران الخلية المعرضة للتآكل. إذا كان هناك الآن جرف بينهما ، فإن الجار عرضة للتآكل. لمعرفة ذلك ، لسنا بحاجة للاتصال IsErodible.

  HexCell neighbor = cell.GetNeighbor(d); if ( neighbor && neighbor.Elevation == cell.Elevation + 2 && // IsErodible(neighbor) && !erodibleCells.Contains(neighbor) ) { erodibleCells.Add(neighbor); } 

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

  if (!IsErodible(cell)) { erodibleCells[index] = erodibleCells[erodibleCells.Count - 1]; erodibleCells.RemoveAt(erodibleCells.Count - 1); } for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … } if (IsErodible(targetCell) && !erodibleCells.Contains(targetCell)) { erodibleCells.Add(targetCell); } for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … } 

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





تآكل 25٪ و 50٪ و 75٪ و 100٪.

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


لا تزال أربع مناطق متآكلة تمامًا منفصلة.

حزمة الوحدة

الجزء 25: دورة المياه


  • عرض بيانات الخريطة الأولية.
  • نشكل مناخ الخلايا.
  • إنشاء محاكاة جزئية لدورة المياه.

في هذا الجزء سنضيف الرطوبة على الأرض.

تم إنشاء هذا البرنامج التعليمي في Unity 2017.3.0.


نستخدم دورة المياه لتحديد المناطق الأحيائية.

الغيوم


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

مناخ الأرض نظام معقد للغاية. لحسن الحظ ، لسنا بحاجة إلى إنشاء محاكاة مناخية واقعية. سنحتاج إلى شيء يبدو طبيعيًا بما فيه الكفاية. أهم دورة في المناخ هي دورة المياه ، لأن النباتات والحيوانات تحتاج إلى الماء السائل للبقاء على قيد الحياة. درجة الحرارة مهمة جدًا أيضًا ، ولكن في الوقت الحالي ، نركز على الماء ، ونترك درجة الحرارة العالمية ثابتة وتغير الرطوبة فقط.

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

تصور البيانات


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

  Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Terrain Texture Array", 2DArray) = "white" {} _GridTex ("Grid Texture", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Specular ("Specular", Color) = (0.2, 0.2, 0.2) _BackgroundColor ("Background Color", Color) = (0,0,0) [Toggle(SHOW_MAP_DATA)] _ShowMapData ("Show Map Data", Float) = 0 } 


قم بالتبديل لعرض بيانات الخريطة.

أضف وظيفة تظليل لتمكين دعم الكلمات الرئيسية.

  #pragma multi_compile _ GRID_ON #pragma multi_compile _ HEX_MAP_EDIT_MODE #pragma shader_feature SHOW_MAP_DATA 

سنجعلها تعرض تعويم واحد ، كما هو الحال مع بقية بيانات الإغاثة. لتنفيذ ذلك ، سنضيف Inputحقلاً إلى البنية mapDataعندما يتم تحديد الكلمة الرئيسية.

  struct Input { float4 color : COLOR; float3 worldPos; float3 terrain; float4 visibility; #if defined(SHOW_MAP_DATA) float mapData; #endif }; 

في برنامج القمة ، نستخدم القناة Z لهذه الخلايا لملئها mapData، كما هو الحال دائمًا بين الخلايا.

  void vert (inout appdata_full v, out Input data) { … #if defined(SHOW_MAP_DATA) data.mapData = cell0.z * v.color.x + cell1.z * v.color.y + cell2.z * v.color.z; #endif } 

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

  void surf (Input IN, inout SurfaceOutputStandardSpecular o) { … o.Albedo = c.rgb * grid * _Color * explored; #if defined(SHOW_MAP_DATA) o.Albedo = IN.mapData * grid; #endif … } 

في الواقع لنقل البيانات إلى تظليل. نحتاج إلى إضافة إلى HexCellShaderDataالطريقة التي تكتب شيئًا إلى قناة بيانات الملمس الأزرق. البيانات عبارة عن قيمة عائمة واحدة تقتصر على 0-1.

  public void SetMapData (HexCell cell, float data) { cellTextureData[cell.Index].b = data < 0f ? (byte)0 : (data < 1f ? (byte)(data * 255f) : (byte)255); enabled = true; } 

ومع ذلك ، يؤثر هذا القرار على نظام البحث. يتم استخدام قيمة بيانات القناة الزرقاء 255 للإشارة إلى أن رؤية الخلية قيد الانتقال. لكي يستمر هذا النظام في العمل ، نحتاج إلى استخدام قيمة البايت 254 كحد أقصى. لاحظ أن حركة المفرزة ستمحو جميع بيانات البطاقة ، لكن هذا يناسبنا ، لأنها تُستخدم لتصحيح أخطاء إنشاء البطاقة.

  cellTextureData[cell.Index].b = data < 0f ? (byte)0 : (data < 1f ? (byte)(data * 254f) : (byte)254); 

أضف طريقة بنفس الاسم وفي HexCell. ستنقل الطلب إلى بيانات تظليل.

  public void SetMapData (float data) { ShaderData.SetMapData(this, data); } 

للتحقق من تشغيل الكود ، نقوم بتغييره HexMapGenerator.SetTerrainTypeبحيث يقوم بتعيين بيانات كل خلية على الخريطة. دعونا نتصور الارتفاع المحول من عدد صحيح إلى تعويم في الفاصل الزمني 0-1. يتم ذلك عن طريق طرح الحد الأدنى للارتفاع من ارتفاع الخلية ، متبوعًا بقسمة الحد الأقصى للارتفاع مطروحًا منه الحد الأدنى. دعونا نجعل الفاصلة عائمة.

  void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … cell.SetMapData( (cell.Elevation - elevationMinimum) / (float)(elevationMaximum - elevationMinimum) ); } } 

يمكننا الآن التبديل بين التضاريس العادية وتصور البيانات باستخدام مربع الاختيار إظهار بيانات الخريطة لأصل مادة التضاريس .



خريطة 1208905299 ، التضاريس الطبيعية وتصور المرتفعات.

خلق المناخ


لمحاكاة المناخ ، نحتاج إلى تتبع البيانات المناخية. نظرًا لأن الخريطة تتكون من خلايا منفصلة ، لكل منها مناخها المحلي. إنشاء هيكل ClimateDataلتخزين جميع البيانات ذات الصلة. بالطبع ، يمكنك إضافة البيانات إلى الخلايا نفسها ، ولكننا سنستخدمها فقط عند إنشاء الخريطة. لذلك ، سنقوم بحفظها بشكل منفصل. هذا يعني أنه يمكننا تحديد هذا الهيكل داخليًا HexMapGenerator، مثل MapRegion. سنبدأ بتتبع السحب فقط ، والتي يمكن تنفيذها باستخدام حقل عائم واحد.

  struct ClimateData { public float clouds; } 

أضف قائمة لتتبع بيانات المناخ لجميع الخلايا.

  List<ClimateData> climate = new List<ClimateData>(); 

الآن نحن بحاجة إلى طريقة لإنشاء خريطة مناخية. يجب أن تبدأ بمسح قائمة المناطق المناخية ، ثم إضافة عنصر واحد لكل خلية. البيانات المناخية الأولية هي ببساطة صفر ، ويمكن تحقيق ذلك باستخدام مُنشئ قياسي ClimateData.
  void CreateClimate () { climate.Clear(); ClimateData initialData = new ClimateData(); for (int i = 0; i < cellCount; i++) { climate.Add(initialData); } } 

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

  public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); CreateClimate(); SetTerrainType(); … } 

التغيير SetTerrainTypeحتى نتمكن من رؤية بيانات السحابة بدلاً من ارتفاع الخلية. في البداية ، ستبدو كبطاقة سوداء.

  void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … cell.SetMapData(climate[i].clouds); } } 

تغير المناخ


الخطوة الأولى في محاكاة المناخ هي التبخر. ما مقدار الماء الذي يجب أن يتبخر؟ فلنتحكم في هذه القيمة باستخدام شريط التمرير. القيمة 0 تعني عدم التبخر ، 1 - التبخر الأقصى. افتراضيًا ، نستخدم 0.5.

  [Range(0f, 1f)] public float evaporation = 0.5f; 


منزلق التبخر.

لنقم بإنشاء طريقة أخرى خاصة لتشكيل مناخ خلية واحدة. نعطيها مؤشر الخلية كمعلمة ونستخدمها للحصول على الخلية المقابلة وبيانات المناخ الخاصة بها. إذا كانت الخلية تحت الماء ، فنحن نتعامل مع خزان يجب أن يتبخر. نقوم على الفور بتحويل البخار إلى سحب (متجاهلين نقاط التكثف والتكاثف) ، لذلك سنضيف التبخر مباشرة إلى قيمة سحب الخلية. عند الانتهاء من ذلك ، انسخ بيانات المناخ إلى القائمة.

  void EvolveClimate (int cellIndex) { HexCell cell = grid.GetCell(cellIndex); ClimateData cellClimate = climate[cellIndex]; if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } climate[cellIndex] = cellClimate; } 

استدعاء هذه الطريقة لكل خلية في CreateClimate.

  void CreateClimate () { … for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } } 

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

  for (int cycle = 0; cycle < 40; cycle++) { for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } } 

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


التبخر فوق الماء.

تناثر السحب


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

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

  if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } float cloudDispersal = cellClimate.clouds * (1f / 6f); cellClimate.clouds = 0f; climate[cellIndex] = cellClimate; 

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

  float cloudDispersal = cellClimate.clouds * (1f / 6f); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor) { continue; } ClimateData neighborClimate = climate[neighbor.Index]; neighborClimate.clouds += cloudDispersal; climate[neighbor.Index] = neighborClimate; } cellClimate.clouds = 0f; 


تناثر الغيوم.

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

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

يمكنك رؤية فقدان الماء في الجزء العلوي من الخريطة ، خاصة في الخلايا الموجودة في أعلى اليمين. في الزنزانة الأخيرة لا توجد غيوم على الإطلاق ، لأنها تظل الأخيرة التي يتشكل فيها المناخ. لم تتلقَ غيومًا بعد من أحد الجيران.

ألا يجب أن يتشكل مناخ جميع الخلايا بالتوازي؟
, . - , . 40 . - , .

هطول الأمطار


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

  [Range(0f, 1f)] public float precipitationFactor = 0.25f; 


منزلق معامل هطول الأمطار.

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

  if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } float precipitation = cellClimate.clouds * precipitationFactor; cellClimate.clouds -= precipitation; float cloudDispersal = cellClimate.clouds * (1f / 6f); 


الغيوم المختفية.

الآن ، عندما ندمر 25٪ من السحب في كل دورة ، تكون الأرض سوداء مرة أخرى تقريبًا. تمكنت الغيوم من التحرك في الداخل بضع خطوات فقط ، وبعد ذلك تصبح غير مرئية.

حزمة الوحدة

الرطوبة


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

تتبع الرطوبة


سنقوم بتحسين نموذج المناخ من خلال تتبع حالتين للمياه: الغيوم والرطوبة. لتنفيذ ذلك ، أضف في ClimateDataالحقل moisture.

  struct ClimateData { public float clouds, moisture; } 

في شكله الأكثر عمومية ، التبخر هو عملية تحويل الرطوبة إلى سحب ، على الأقل في نموذج المناخ البسيط. وهذا يعني أن التبخر لا يجب أن يكون قيمة ثابتة ، بل عامل آخر. ولذلك، إعادة تسمية refactor- evaporationفي evaporationFactor.

  [Range(0f, 1f)] public float evaporationFactor = 0.5f; 

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

  if (cell.IsUnderwater) { cellClimate.moisture = 1f; cellClimate.clouds += evaporationFactor; } else { float evaporation = cellClimate.moisture * evaporationFactor; cellClimate.moisture -= evaporation; cellClimate.clouds += evaporation; } float precipitation = cellClimate.clouds * precipitationFactor; cellClimate.clouds -= precipitation; cellClimate.moisture += precipitation; 

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


الغيوم مع تبخر الرطوبة.

دعنا نغيرها SetTerrainTypeبحيث تعرض الرطوبة بدلاً من الغيوم ، لأننا سنستخدمها لتحديد أنواع الإغاثة.

  cell.SetMapData(climate[i].moisture); 


عرض الرطوبة.

في هذه المرحلة ، تبدو الرطوبة مشابهة جدًا للسحب (باستثناء أن جميع الخلايا تحت الماء بيضاء) ، لكن هذا سيتغير قريبًا.

جريان الأمطار


التبخر ليس هو الطريقة الوحيدة التي يمكن أن تترك الرطوبة فيها الخلية. تخبرنا دورة الماء أن الجزء الأكبر من الرطوبة المضافة إلى الأرض ينتهي بطريقة ما في الماء. إن العملية الأكثر وضوحًا هي تدفق المياه على الأرض تحت تأثير الجاذبية. لن نقوم بمحاكاة أنهار حقيقية ، ولكننا نستخدم معامل جريان الأمطار المخصص. ستشير إلى النسبة المئوية لتصريف المياه إلى المناطق السفلية. لنفترض أن الأسهم ستكون مساوية لـ 25٪.

  [Range(0f, 1f)] public float runoffFactor = 0.25f; 


منزلق استنزاف.

لن نولد الأنهار؟
.

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

  float cloudDispersal = cellClimate.clouds * (1f / 6f); float runoff = cellClimate.moisture * runoffFactor * (1f / 6f); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor) { continue; } ClimateData neighborClimate = climate[neighbor.Index]; neighborClimate.clouds += cloudDispersal; int elevationDelta = neighbor.Elevation - cell.Elevation; if (elevationDelta < 0) { cellClimate.moisture -= runoff; neighborClimate.moisture += runoff; } climate[neighbor.Index] = neighborClimate; } 


تصريف المياه إلى ارتفاع أقل.

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

  int elevationDelta = neighbor.ViewElevation - cell.ViewElevation; 


استخدم الارتفاع المرئي.

تسرب


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

  [Range(0f, 1f)] public float seepageFactor = 0.125f; 


منزلق التسرب.

يشبه التسرب التصريف ، باستثناء أنه يستخدم عندما يكون للجار نفس الارتفاع المرئي مثل الخلية نفسها.

  float runoff = cellClimate.moisture * runoffFactor * (1f / 6f); float seepage = cellClimate.moisture * seepageFactor * (1f / 6f); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … int elevationDelta = neighbor.ViewElevation - cell.ViewElevation; if (elevationDelta < 0) { cellClimate.moisture -= runoff; neighborClimate.moisture += runoff; } else if (elevationDelta == 0) { cellClimate.moisture -= seepage; neighborClimate.moisture += seepage; } climate[neighbor.Index] = neighborClimate; } 


تمت إضافة القليل من التسرب.

حزمة الوحدة

ظلال المطر


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

الريح


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

  public HexDirection windDirection = HexDirection.NW; [Range(1f, 10f)] public float windStrength = 4f; 


اتجاه وقوة الريح.

يتم التعبير عن قوة الرياح السائدة بالنسبة للتشتت الكلي للسحب. إذا كانت قوة الرياح 1 ، فإن التشتت هو نفسه في جميع الاتجاهات. عندما تكون 2 ، يكون الانتثار أعلى في اتجاه الريح من اتجاهين آخرين ، وهكذا. يمكننا القيام بذلك عن طريق تغيير المقسوم عليه في صيغة مبعثر السحابة. بدلاً من ستة ، ستكون مساوية لخمسة طاقة رياح زائدة.

  float cloudDispersal = cellClimate.clouds * (1f / (5f + windStrength)); 

بالإضافة إلى ذلك ، يحدد اتجاه الريح الاتجاه الذي تهب منه الريح. لذلك ، نحتاج إلى استخدام الاتجاه المعاكس باعتباره الاتجاه الرئيسي للانتثار.

  HexDirection mainDispersalDirection = windDirection.Opposite(); float cloudDispersal = cellClimate.clouds * (1f / (5f + windStrength)); 

الآن يمكننا التحقق مما إذا كان الجار في الاتجاه الرئيسي للانتثار. إذا كان الأمر كذلك ، فيجب علينا مضاعفة تشتت الغيوم بقوة الرياح.

  ClimateData neighborClimate = climate[neighbor.Index]; if (d == mainDispersalDirection) { neighborClimate.clouds += cloudDispersal * windStrength; } else { neighborClimate.clouds += cloudDispersal; } 


الرياح الشمالية الغربية ، القوة 4.

تضيف الرياح السائدة الاتجاه إلى توزيع الرطوبة على الأرض. كلما كانت الرياح أقوى ، أصبح التأثير أقوى.

ارتفاع مطلق


العنصر الثاني في الحصول على ظلال المطر هي الجبال. ليس لدينا تصنيف صارم لما هو جبل ، تمامًا كما لا يوجد في الطبيعة. فقط الارتفاع المطلق هو المهم. في الواقع ، عندما يتحرك الهواء فوق الجبل ، يضطر إلى الارتفاع ، ويتم تبريده وقد يحتوي على كمية أقل من الماء ، مما يؤدي إلى هطول الأمطار قبل أن يمر الهواء فوق الجبل. نتيجة لذلك ، على الجانب الآخر نحصل على الهواء الجاف ، أي ظل المطر.

والأهم من ذلك ، أنه كلما ارتفع الهواء ، قل الماء الذي يحتويه. في محاكاة لدينا ، يمكننا أن نتخيل هذا على أنه تقييد مفروض على الحد الأقصى لقيمة السحابة لكل خلية. كلما زاد ارتفاع الخلية المرئية ، يجب أن يكون هذا الحد الأدنى أقل. أسهل طريقة للقيام بذلك هي تعيين الحد الأقصى إلى 1 ناقص الارتفاع الظاهري مقسومًا على الحد الأقصى للارتفاع. ولكن في الواقع ، دعونا نقسم على الحد الأقصى ناقص 1. سيسمح هذا لجزء صغير من السحب بالمرور حتى أعلى الخلايا. نحدد هذا الحد الأقصى بعد حساب هطول الأمطار وقبل التناثر.

  float precipitation = cellClimate.clouds * precipitationFactor; cellClimate.clouds -= precipitation; cellClimate.moisture += precipitation; float cloudMaximum = 1f - cell.ViewElevation / (elevationMaximum + 1f); HexDirection mainDispersalDirection = windDirection.Opposite(); 

إذا حصلنا على نتيجة سحب أكثر من المقبول ، فإننا ببساطة نقوم بتحويل السحب الزائدة إلى رطوبة. في الواقع ، هذه هي الطريقة التي نضيف بها هطولًا إضافيًا ، كما يحدث في الجبال الحقيقية.

  float cloudMaximum = 1f - cell.ViewElevation / (elevationMaximum + 1f); if (cellClimate.clouds > cloudMaximum) { cellClimate.moisture += cellClimate.clouds - cloudMaximum; cellClimate.clouds = cloudMaximum; } 


الظلال المطيرة الناجمة عن الارتفاعات العالية.

حزمة الوحدة

نكمل المحاكاة


في هذه المرحلة ، لدينا بالفعل محاكاة جزئية عالية الجودة لدورة المياه. دعنا نرتبها قليلاً ، ثم نطبقها لتحديد نوع راحة الخلايا.

الحوسبة المتوازية


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

  List<ClimateData> climate = new List<ClimateData>(); List<ClimateData> nextClimate = new List<ClimateData>(); 

امسح هذه القائمة وقم بتهيئتها ، مثل أي شخص آخر. ثم سنقوم بتبادل القوائم في كل دورة. في هذه الحالة ، ستستخدم المحاكاة بالتناوب القائمتين وتطبق بيانات المناخ الحالية والقادمة.

  void CreateClimate () { climate.Clear(); nextClimate.Clear(); ClimateData initialData = new ClimateData(); for (int i = 0; i < cellCount; i++) { climate.Add(initialData); nextClimate.Add(initialData); } for (int cycle = 0; cycle < 40; cycle++) { for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } List<ClimateData> swap = climate; climate = nextClimate; nextClimate = swap; } } 

عندما تؤثر الخلية على مناخ جيرانها ، يجب علينا تغيير بيانات المناخ التالية ، وليس البيانات الحالية.

  for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor) { continue; } ClimateData neighborClimate = nextClimate[neighbor.Index]; … nextClimate[neighbor.Index] = neighborClimate; } 

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

 // cellClimate.clouds = 0f; ClimateData nextCellClimate = nextClimate[cellIndex]; nextCellClimate.moisture += cellClimate.moisture; nextClimate[cellIndex] = nextCellClimate; climate[cellIndex] = new ClimateData(); 

وأثناء قيامنا بذلك ، فلنقم أيضًا بتعيين مستوى الرطوبة إلى 1 كحد أقصى حتى لا تكون خلايا الأرض أكثر رطوبة من تحت الماء.

  nextCellClimate.moisture += cellClimate.moisture; if (nextCellClimate.moisture > 1f) { nextCellClimate.moisture = 1f; } nextClimate[cellIndex] = nextCellClimate; 


الحوسبة المتوازية.

الرطوبة الأصلية


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

  [Range(0f, 1f)] public float startingMoisture = 0.1f; 


أعلاه هو منزلق الرطوبة الأصلية.

نستخدم هذه القيمة للرطوبة في قائمة المناخ الأولية ، ولكن ليس لما يلي.

  ClimateData initialData = new ClimateData(); initialData.moisture = startingMoisture; ClimateData clearData = new ClimateData(); for (int i = 0; i < cellCount; i++) { climate.Add(initialData); nextClimate.Add(clearData); } 


مع الرطوبة الأصلية.

تحديد المناطق الأحيائية


نستنتج باستخدام الرطوبة بدلاً من الارتفاع لتحديد نوع راحة الخلية. دعونا نستخدم الثلج في الأراضي الجافة تمامًا ، وفي المناطق القاحلة نستخدم الثلج ، ثم هناك الحجر والعشب للرطب بما فيه الكفاية والأرض للخلايا المشبعة بالماء وتحت الماء. أسهل طريقة هي استخدام خمس فواصل بزيادات قدرها 0.2.

  void SetTerrainType () { for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); float moisture = climate[i].moisture; if (!cell.IsUnderwater) { if (moisture < 0.2f) { cell.TerrainTypeIndex = 4; } else if (moisture < 0.4f) { cell.TerrainTypeIndex = 0; } else if (moisture < 0.6f) { cell.TerrainTypeIndex = 3; } else if (moisture < 0.8f) { cell.TerrainTypeIndex = 1; } else { cell.TerrainTypeIndex = 2; } } else { cell.TerrainTypeIndex = 2; } cell.SetMapData(moisture); } } 


المناطق الأحيائية.

عند استخدام التوزيع المنتظم ، تكون النتيجة ليست جيدة جدًا ، وتبدو غير طبيعية. من الأفضل استخدام عتبات أخرى ، على سبيل المثال 0.05 و 0.12 و 0.28 و 0.85.

  if (moisture < 0.05f) { cell.TerrainTypeIndex = 4; } else if (moisture < 0.12f) { cell.TerrainTypeIndex = 0; } else if (moisture < 0.28f) { cell.TerrainTypeIndex = 3; } else if (moisture < 0.85f) { cell.TerrainTypeIndex = 1; } 


المناطق الحيوية المعدلة.

حزمة الوحدة

الجزء 26: المناطق الحيوية والأنهار


  • نخلق الأنهار القادمة من خلايا عالية مع رطوبة.
  • نقوم بإنشاء نموذج بسيط لدرجة الحرارة.
  • نستخدم مصفوفة biome للخلايا ، ثم نغيرها.

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

تم إنشاء البرنامج التعليمي باستخدام Unity 2017.3.0p3.


عززت الحرارة والماء الخريطة.

توليد النهر


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

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

لماذا يكون تدفق النهر خاطئًا في بعض الأحيان؟
TriangulateWaterShore , . , . , , . , . , , . («»).

  void TriangulateWaterShore ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { … if (cell.HasRiverThroughEdge(direction)) { TriangulateEstuary( e1, e2, cell.HasIncomingRiver && cell.IncomingRiver == direction, indices ); } … } 

خلايا رطوبة عالية


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

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

  void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … float data = (float)(cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); cell.SetMapData(data); } } 



الرطوبة والارتفاع. رقم الخريطة الكبيرة 1208905299 مع الإعدادات الافتراضية.

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

  float data = moisture * (cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); cell.SetMapData(data); 


أوزان لمصادر الأنهار.

من الناحية المثالية ، نستخدم هذه الأوزان لرفض التحديد العشوائي للخلية المصدر. على الرغم من أنه يمكننا إنشاء قائمة بالأوزان الصحيحة والاختيار من بينها ، إلا أن هذا نهج غير تافه ويبطئ عملية التوليد. تصنيف أبسط للأهمية مقسم إلى أربعة مستويات سيكون كافياً بالنسبة لنا. سيكون المرشحون الأوائل أوزانًا بقيم أعلى من 0.75. المرشحون الجيدون لديهم أوزان من 0.5. المرشحين المؤهلين أكبر من 0.25. يتم تجاهل جميع الخلايا الأخرى. دعونا نظهر كيف يبدو بيانيا.

  float data = moisture * (cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); if (data > 0.75f) { cell.SetMapData(1f); } else if (data > 0.5f) { cell.SetMapData(0.5f); } else if (data > 0.25f) { cell.SetMapData(0.25f); } // cell.SetMapData(data); 


فئات أوزان مصادر الأنهار.

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

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

  void CreateRivers () { List<HexCell> riverOrigins = ListPool<HexCell>.Get(); for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); if (cell.IsUnderwater) { continue; } ClimateData data = climate[i]; float weight = data.moisture * (cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); if (weight > 0.75f) { riverOrigins.Add(cell); riverOrigins.Add(cell); } if (weight > 0.5f) { riverOrigins.Add(cell); } if (weight > 0.25f) { riverOrigins.Add(cell); } } ListPool<HexCell>.Add(riverOrigins); } 

يجب استدعاء هذه الطريقة بعد CreateClimateذلك حتى تتوفر لنا بيانات الرطوبة.

  public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); CreateClimate(); CreateRivers(); SetTerrainType(); … } 

بعد الانتهاء من التصنيف ، يمكنك التخلص من تصور بياناته على الخريطة.

  void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … // float data = // moisture * (cell.Elevation - waterLevel) / // (elevationMaximum - waterLevel); // if (data > 0.6f) { // cell.SetMapData(1f); // } // else if (data > 0.4f) { // cell.SetMapData(0.5f); // } // else if (data > 0.2f) { // cell.SetMapData(0.25f); // } } } 

نقاط النهر


كم عدد الأنهار التي نحتاجها؟ يجب أن تكون هذه المعلمة قابلة للتخصيص. نظرًا لأن طول الأنهار يختلف ، سيكون من المنطقي التحكم فيه بمساعدة نقاط النهر ، التي تحدد عدد خلايا الأرض التي يجب أن تحتوي فيها الأنهار. دعونا نعبر عنها كنسبة مئوية بحد أقصى 20٪ وقيمة افتراضية 10٪. مثل نسبة السوشي ، هذه قيمة مستهدفة وليست مضمونة. نتيجة لذلك ، قد يكون لدينا عدد قليل جدًا من المرشحين أو الأنهار التي تكون أقصر من أن تغطي المساحة المطلوبة من الأرض. هذا هو السبب في أن النسبة المئوية القصوى يجب ألا تكون كبيرة جدًا.

  [Range(0, 20)] public int riverPercentage = 10; 


منزلق الأنهار في المئة.

لتحديد نقاط النهر ، معبرًا عنها بعدد الخلايا ، نحتاج إلى تذكر عدد خلايا الأرض التي تم إنشاؤها فيها CreateLand.

  int cellCount, landCells; … void CreateLand () { int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f); landCells = landBudget; for (int guard = 0; guard < 10000; guard++) { … } if (landBudget > 0) { Debug.LogWarning("Failed to use up " + landBudget + " land budget."); landCells -= landBudget; } } 

في الداخل ، CreateRiversيمكن الآن حساب عدد نقاط النهر بنفس الطريقة التي نقوم بها CreateLand.

  void CreateRivers () { List<HexCell> riverOrigins = ListPool<HexCell>.Get(); for (int i = 0; i < cellCount; i++) { … } int riverBudget = Mathf.RoundToInt(landCells * riverPercentage * 0.01f); ListPool<HexCell>.Add(riverOrigins); } 

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

  int riverBudget = Mathf.RoundToInt(landCells * riverPercentage * 0.01f); while (riverBudget > 0 && riverOrigins.Count > 0) { int index = Random.Range(0, riverOrigins.Count); int lastIndex = riverOrigins.Count - 1; HexCell origin = riverOrigins[index]; riverOrigins[index] = riverOrigins[lastIndex]; riverOrigins.RemoveAt(lastIndex); } if (riverBudget > 0) { Debug.LogWarning("Failed to use up river budget."); } 

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

  int CreateRiver (HexCell origin) { int length = 0; return length; } 

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

  while (riverBudget > 0 && riverOrigins.Count > 0) { … if (!origin.HasRiver) { riverBudget -= CreateRiver(origin); } } 

الأنهار الحالية


من المنطقي إنشاء أنهار تتدفق إلى البحر أو مسطح آخر من المياه. عندما نبدأ من المصدر ، نحصل على الطول 1. فورًا ، نختار جارًا عشوائيًا ونزيد الطول. نواصل التحرك حتى نصل إلى الخلية تحت الماء.

  int CreateRiver (HexCell origin) { int length = 1; HexCell cell = origin; while (!cell.IsUnderwater) { HexDirection direction = (HexDirection)Random.Range(0, 6); cell.SetOutgoingRiver(direction); length += 1; cell = cell.GetNeighbor(direction); } return length; } 


الأنهار العشوائية.

نتيجة لمثل هذا النهج الساذج ، نحصل على شظايا نهر متناثرة عشوائيًا ، ويرجع ذلك أساسًا إلى استبدال الأنهار التي تم إنشاؤها سابقًا. يمكن أن يؤدي هذا إلى أخطاء ، لأننا لا نتحقق مما إذا كان الجار موجودًا بالفعل. نحتاج إلى التحقق من جميع الاتجاهات في الحلقة والتأكد من وجود جار هناك. إذا كان الأمر كذلك ، فإننا نضيف هذا الاتجاه إلى قائمة اتجاهات التدفق المحتملة ، ولكن فقط إذا لم يتدفق النهر عبر هذا الجار حتى الآن. ثم حدد قيمة عشوائية من هذه القائمة.

  List<HexDirection> flowDirections = new List<HexDirection>(); … int CreateRiver (HexCell origin) { int length = 1; HexCell cell = origin; while (!cell.IsUnderwater) { flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor || neighbor.HasRiver) { continue; } flowDirections.Add(d); } HexDirection direction = // (HexDirection)Random.Range(0, 6); flowDirections[Random.Range(0, flowDirections.Count)]; cell.SetOutgoingRiver(direction); length += 1; cell = cell.GetNeighbor(direction); } return length; } 

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

  flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … } if (flowDirections.Count == 0) { return length > 1 ? length : 0; } 


الأنهار المحفوظة.

اصمت


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

  if (!neighbor || neighbor.HasRiver) { continue; } int delta = neighbor.Elevation - cell.Elevation; if (delta > 0) { continue; } flowDirections.Add(d); 


الأنهار تتدفق.

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

  if (delta > 0) { continue; } if (delta < 0) { flowDirections.Add(d); flowDirections.Add(d); flowDirections.Add(d); } flowDirections.Add(d); 

تجنب المنعطفات الحادة


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

  int CreateRiver (HexCell origin) { int length = 1; HexCell cell = origin; HexDirection direction = HexDirection.NE; while (!cell.IsUnderwater) { flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … if (delta < 0) { flowDirections.Add(d); flowDirections.Add(d); flowDirections.Add(d); } if ( length == 1 || (d != direction.Next2() && d != direction.Previous2()) ) { flowDirections.Add(d); } flowDirections.Add(d); } if (flowDirections.Count == 0) { return length > 1 ? length : 0; } // HexDirection direction = direction = flowDirections[Random.Range(0, flowDirections.Count)]; cell.SetOutgoingRiver(direction); length += 1; cell = cell.GetNeighbor(direction); } return length; } 

هذا يقلل بشكل كبير من احتمال أن تكون الأنهار متعرجة تبدو قبيحة.


المنعطفات الحادة أقل.

التقاء النهر


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

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

  HexCell neighbor = cell.GetNeighbor(d); // if (!neighbor || neighbor.HasRiver) { // continue; // } if (!neighbor || neighbor == origin || neighbor.HasIncomingRiver) { continue; } int delta = neighbor.Elevation - cell.Elevation; if (delta > 0) { continue; } if (neighbor.HasOutgoingRiver) { cell.SetOutgoingRiver(d); return length; } 



الأنهار قبل وبعد التجميع.

ابق على مسافة


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

  while (riverBudget > 0 && riverOrigins.Count > 0) { int index = Random.Range(0, riverOrigins.Count); int lastIndex = riverOrigins.Count - 1; HexCell origin = riverOrigins[index]; riverOrigins[index] = riverOrigins[lastIndex]; riverOrigins.RemoveAt(lastIndex); if (!origin.HasRiver) { bool isValidOrigin = true; for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = origin.GetNeighbor(d); if (neighbor && (neighbor.HasRiver || neighbor.IsUnderwater)) { isValidOrigin = false; break; } } if (isValidOrigin) { riverBudget -= CreateRiver(origin); } } 

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



بدون مسافة ومعها.

ننهي النهر ببحيرة


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

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

  while (!cell.IsUnderwater) { int minNeighborElevation = int.MaxValue; flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); // if (!neighbor || neighbor == origin || neighbor.HasIncomingRiver) { // continue; // } if (!neighbor) { continue; } if (neighbor.Elevation < minNeighborElevation) { minNeighborElevation = neighbor.Elevation; } if (neighbor == origin || neighbor.HasIncomingRiver) { continue; } int delta = neighbor.Elevation - cell.Elevation; if (delta > 0) { continue; } … } … } 

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

  if (flowDirections.Count == 0) { // return length > 1 ? length : 0; if (length == 1) { return 0; } if (minNeighborElevation >= cell.Elevation) { cell.WaterLevel = minNeighborElevation; if (minNeighborElevation == cell.Elevation) { cell.Elevation = minNeighborElevation - 1; } } break; } 



نهايات الأنهار بدون بحيرات وبحيرات. في هذه الحالة ، النسبة المئوية للأنهار هي 20.

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

بحيرات إضافية


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

  while (!cell.IsUnderwater) { … if (minNeighborElevation >= cell.Elevation) { cell.WaterLevel = cell.Elevation; cell.Elevation -= 1; } cell = cell.GetNeighbor(direction); } 



بدون بحيرات إضافية ومعهم.

العديد من البحيرات جميلة ، لكن بلا حدود يمكننا إنشاء الكثير من البحيرات. لذلك ، دعنا نضيف احتمالية مخصصة لبحيرات إضافية ، مع قيمة افتراضية 0.25.

  [Range(0f, 1f)] public float extraLakeProbability = 0.25f; 

سوف تتحكم في احتمالية توليد بحيرة إضافية ، إن أمكن.

  if ( minNeighborElevation >= cell.Elevation && Random.value < extraLakeProbability ) { cell.WaterLevel = cell.Elevation; cell.Elevation -= 1; } 



بحيرات إضافية.

ماذا عن إنشاء بحيرات تحتوي على أكثر من خلية واحدة؟
, , , . . : . , . , , , .

حزمة الوحدة

درجة الحرارة


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

درجة الحرارة وخط العرض


التأثير الأكبر على درجة الحرارة هو خط العرض. إنه حار عند خط الاستواء ، بارد عند القطبين ، وهناك انتقال سلس بينهما. دعونا ننشئ طريقة DetermineTemperatureتعيد درجة حرارة خلية معينة. للبدء ، نستخدم ببساطة إحداثيات Z للخلية مقسومة على البعد Z كخط عرض ، ثم نستخدم هذه القيمة كدرجة حرارة.

  float DetermineTemperature (HexCell cell) { float latitude = (float)cell.coordinates.Z / grid.cellCountZ; return latitude; } 

نحدد درجة الحرارة SetTerrainTypeونستخدمها كبيانات خرائط.

  void SetTerrainType () { for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); float temperature = DetermineTemperature(cell); cell.SetMapData(temperature); float moisture = climate[i].moisture; … } } 


خط العرض كدرجة حرارة ، نصف الكرة الجنوبي.

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

  [Range(0f, 1f)] public float lowTemperature = 0f; [Range(0f, 1f)] public float highTemperature = 1f; 


منزلقات درجة الحرارة.

نطبق نطاق درجة الحرارة باستخدام الاستيفاء الخطي ، باستخدام خط العرض كمقلب. نظرًا لأننا نعبر عن خط العرض كقيمة من 0 إلى 1 ، يمكننا استخدامه Mathf.LerpUnclamped.

  float DetermineTemperature (HexCell cell) { float latitude = (float)cell.coordinates.Z / grid.cellCountZ; float temperature = Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude); return temperature; } 

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

نصف الكرة الأرضية


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

  public enum HemisphereMode { Both, North, South } public HemisphereMode hemisphere; 


اختيار نصف الكرة الأرضية.

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

  float DetermineTemperature (HexCell cell) { float latitude = (float)cell.coordinates.Z / grid.cellCountZ; if (hemisphere == HemisphereMode.Both) { latitude *= 2f; if (latitude > 1f) { latitude = 2f - latitude; } } else if (hemisphere == HemisphereMode.North) { latitude = 1f - latitude; } float temperature = Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude); return temperature; } 


كلا نصفي الكرة الأرضية.

تجدر الإشارة إلى أن هذا يخلق إمكانية إنشاء خريطة غريبة يكون فيها خط الاستواء باردًا وتكون الأعمدة دافئة.

كلما زاد البرودة


بالإضافة إلى خط العرض ، تتأثر درجة الحرارة أيضًا بشكل كبير بالارتفاع. في المتوسط ​​، كلما صعدنا أعلى ، كلما ازداد برودة. يمكننا تحويل هذا إلى عامل ، كما فعلنا مع مرشحي الأنهار. في هذه الحالة ، نستخدم ارتفاع الخلية. بالإضافة إلى ذلك ، ينخفض ​​هذا المؤشر مع الارتفاع ، أي يساوي 1 ناقص الارتفاع مقسومًا على الحد الأقصى بالنسبة لمستوى الماء. حتى لا ينخفض ​​المؤشر على أعلى مستوى إلى الصفر ، نضيف إلى المقسوم عليه. ثم استخدم هذا المؤشر لقياس درجة الحرارة.

  float temperature = Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude); temperature *= 1f - (cell.ViewElevation - waterLevel) / (elevationMaximum - waterLevel + 1f); return temperature; 


يؤثر الارتفاع على درجة الحرارة.

تقلبات درجات الحرارة


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

  [Range(0f, 1f)] public float temperatureJitter = 0.1f; 


منزلق تقلبات درجة الحرارة.

يجب أن تكون هذه التقلبات سلسة مع تغييرات محلية طفيفة. يمكنك استخدام نسيج الضجيج لدينا لهذا الغرض. سنستدعي HexMetrics.SampleNoiseونستخدم كحجة موضع الخلية ، مقيسة بمقدار 0.1. لنأخذ القناة W ونركزها ونوسعها بمعامل التذبذب. ثم نضيف هذه القيمة إلى درجة الحرارة المحسوبة مسبقًا.

  temperature *= 1f - (cell.ViewElevation - waterLevel) / (elevationMaximum - waterLevel + 1f); temperature += (HexMetrics.SampleNoise(cell.Position * 0.1f).w * 2f - 1f) * temperatureJitter; return temperature; 



تقلبات درجات الحرارة مع قيم 0.1 و 1.

يمكننا إضافة تغير طفيف في التقلبات في كل خريطة ، واختيار عشوائي من قنوات الضوضاء الأربعة. اضبط القناة مرة واحدة SetTerrainType، ثم قم بفهرسة قنوات الألوان فيها DetermineTemperature.

  int temperatureJitterChannel; … void SetTerrainType () { temperatureJitterChannel = Random.Range(0, 4); for (int i = 0; i < cellCount; i++) { … } } float DetermineTemperature (HexCell cell) { … float jitter = HexMetrics.SampleNoise(cell.Position * 0.1f)[temperatureJitterChannel]; temperature += (jitter * 2f - 1f) * temperatureJitter; return temperature; } 


تقلبات مختلفة في درجات الحرارة بأقصى قوة.

حزمة الوحدة

المناطق الأحيائية


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

مصفوفة بيوم


هناك العديد من النماذج المناخية ، لكننا لن نستخدم أيًا منها. سنجعل الأمر بسيطًا للغاية ، نحن مهتمون فقط بالمنطق. جاف يعني صحراء (باردة أو ساخنة) ، لأننا نستخدم الرمل. البرد والرطب يعني الثلج. الحارة والرطبة تعني الكثير من النباتات ، أي العشب. فيما بينها سيكون لدينا التايغا أو التندرا ، والتي سنطلق عليها اسم مادة رمادية للأرض. مصفوفة 4 × 4 ستكون كافية لإنشاء انتقالات بين هذه المناطق الأحيائية.

في السابق ، قمنا بتعيين أنواع الارتفاعات استنادًا إلى خمسة فترات رطوبة. نخفض ببساطة الشريط الأكثر جفافاً إلى 0.05 ، ونوفر الباقي. بالنسبة لنطاقات درجة الحرارة ، نستخدم 0.1 و 0.3 و 0.6 وأعلى. للراحة ، سنقوم بتعيين هذه القيم في صفائف ثابتة.

  static float[] temperatureBands = { 0.1f, 0.3f, 0.6f }; static float[] moistureBands = { 0.12f, 0.28f, 0.85f }; 

على الرغم من أننا نحدد فقط نوع الإغاثة على أساس biome ، يمكننا استخدامه لتحديد المعلمات الأخرى. لذلك ، دعنا نحدد في HexMapGeneratorهيكل Biomeيصف تكوين مجال حيوي فردي. حتى الآن ، يحتوي فقط على مؤشر النتوء بالإضافة إلى طريقة المنشئ المقابلة.

  struct Biome { public int terrain; public Biome (int terrain) { this.terrain = terrain; } } 

نستخدم هذه البنية لإنشاء صفيف ثابت يحتوي على بيانات المصفوفة. نستخدم الرطوبة مثل إحداثيات X ، ودرجة الحرارة مثل Y. نملأ الخط بأقل درجة حرارة بالثلج ، والخط الثاني بالتندرا ، والاثنان الآخران بالعشب. ثم نستبدل العمود الأكثر جفافاً بالصحراء ، ونعيد تحديد خيار درجة الحرارة.

  static Biome[] biomes = { new Biome(0), new Biome(4), new Biome(4), new Biome(4), new Biome(0), new Biome(2), new Biome(2), new Biome(2), new Biome(0), new Biome(1), new Biome(1), new Biome(1), new Biome(0), new Biome(1), new Biome(1), new Biome(1) }; 


مصفوفة المناطق الأحيائية بمؤشرات صفيف أحادي البعد.

تعريف المناطق الأحيائية


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

  void SetTerrainType () { temperatureJitterChannel = Random.Range(0, 4); for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); float temperature = DetermineTemperature(cell); // cell.SetMapData(temperature); float moisture = climate[i].moisture; if (!cell.IsUnderwater) { // if (moisture < 0.05f) { // cell.TerrainTypeIndex = 4; // } // … // else { // cell.TerrainTypeIndex = 2; // } int t = 0; for (; t < temperatureBands.Length; t++) { if (temperature < temperatureBands[t]) { break; } } int m = 0; for (; m < moistureBands.Length; m++) { if (moisture < moistureBands[m]) { break; } } Biome cellBiome = biomes[t * 4 + m]; cell.TerrainTypeIndex = cellBiome.terrain; } else { cell.TerrainTypeIndex = 2; } } } 


الإغاثة على أساس مصفوفة المناطق الحيوية.

إعداد Biome


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

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

  void SetTerrainType () { temperatureJitterChannel = Random.Range(0, 4); int rockDesertElevation = elevationMaximum - (elevationMaximum - waterLevel) / 2; for (int i = 0; i < cellCount; i++) { … if (!cell.IsUnderwater) { … Biome cellBiome = biomes[t * 4 + m]; if (cellBiome.terrain == 0) { if (cell.Elevation >= rockDesertElevation) { cellBiome.terrain = 3; } } cell.TerrainTypeIndex = cellBiome.terrain; } else { cell.TerrainTypeIndex = 2; } } } 


الصحاري الرملية والصخرية.

تغيير آخر يعتمد على الارتفاع هو إجبار الخلايا على أقصى ارتفاع على التحول إلى قمم ثلجية ، بغض النظر عن درجة حرارتها ، فقط إذا لم تكن جافة جدًا. سيؤدي ذلك إلى زيادة احتمالية وجود قمم ثلجية بالقرب من خط الاستواء الحار والرطب.

  if (cellBiome.terrain == 0) { if (cell.Elevation >= rockDesertElevation) { cellBiome.terrain = 3; } } else if (cell.Elevation == elevationMaximum) { cellBiome.terrain = 4; } 


أغطية ثلوج على أقصى ارتفاع.

النباتات


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

  struct Biome { public int terrain, plant; public Biome (int terrain, int plant) { this.terrain = terrain; this.plant = plant; } } 

لن يكون هناك نباتات على الإطلاق في المناطق الأبرد والأكثر برودة. في جميع النواحي الأخرى ، كلما كان المناخ أكثر دفئًا ورطوبة ، زاد عدد النباتات. يتلقى العمود الثاني من الرطوبة المستوى الأول فقط من النباتات للصف الأكثر سخونة ، وبالتالي [0 ، 0 ، 0 ، 1]. يقوم العمود الثالث بزيادة المستويات بمقدار واحد ، باستثناء الثلج ، أي [0 ، 1 ، 1 ، 2]. ويزيدها العمود المبلل مرة أخرى ، أي أنه يتبين [0 ، 2 ، 2 ، 3]. قم بتغيير الصفيف biomesعن طريق إضافة تكوين المصنع إليه.

  static Biome[] biomes = { new Biome(0, 0), new Biome(4, 0), new Biome(4, 0), new Biome(4, 0), new Biome(0, 0), new Biome(2, 0), new Biome(2, 1), new Biome(2, 2), new Biome(0, 0), new Biome(1, 0), new Biome(1, 1), new Biome(1, 2), new Biome(0, 0), new Biome(1, 1), new Biome(1, 2), new Biome(1, 3) }; 


مصفوفة المناطق الأحيائية بمستويات نباتية.

الآن يمكننا تحديد مستوى النباتات للخلية.

  cell.TerrainTypeIndex = cellBiome.terrain; cell.PlantLevel = cellBiome.plant; 


البيوم مع النباتات.

هل تبدو النباتات الآن مختلفة؟
, . (1, 2, 1) (0.75, 1, 0.75). (1.5, 3, 1.5) (2, 1.5, 2). — (2, 4.5, 2) (2.5, 3, 2.5).

, : (13, 114, 0).

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

  if (cellBiome.terrain == 4) { cellBiome.plant = 0; } else if (cellBiome.plant < 3 && cell.HasRiver) { cellBiome.plant += 1; } cell.TerrainTypeIndex = cellBiome.terrain; cell.PlantLevel = cellBiome.plant; 


نباتات معدلة.

المناطق الأحيائية تحت الماء


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

  void SetTerrainType () { … if (!cell.IsUnderwater) { … } else { int terrain; if (cell.Elevation == waterLevel - 1) { terrain = 1; } else if (cell.Elevation >= waterLevel) { terrain = 1; } else if (cell.Elevation < 0) { terrain = 3; } else { terrain = 2; } cell.TerrainTypeIndex = terrain; } } } 


تقلبات تحت الماء.

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

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

  if (cell.Elevation == waterLevel - 1) { int cliffs = 0, slopes = 0; for ( HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++ ) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor) { continue; } int delta = neighbor.Elevation - cell.WaterLevel; if (delta == 0) { slopes += 1; } else if (delta > 0) { cliffs += 1; } } terrain = 1; } 

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

  if (cell.Elevation == waterLevel - 1) { int cliffs = 0, slopes = 0; for ( HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++ ) { … } if (cliffs + slopes > 3) { terrain = 1; } else if (cliffs > 0) { terrain = 3; } else if (slopes > 0) { terrain = 0; } else { terrain = 1; } } 



تقلب الساحل.

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

  if (terrain == 1 && temperature < temperatureBands[0]) { terrain = 2; } cell.TerrainTypeIndex = terrain; 

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

حزمة الوحدة

الجزء 27: طي البطاقة


  • نقسم البطاقات إلى أعمدة يمكن نقلها.
  • قم بتوسيط البطاقة في الكاميرا.
  • ننهار كل شيء.

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

تم إنشاء البرنامج التعليمي باستخدام Unity 2017.3.0p3.


الطي يجعل العالم يدور.

بطاقات قابلة للطي


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

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

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

ماذا عن الشمال والجنوب؟
, . , , . -, -. .

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

طي اختياري


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


قائمة الخريطة الجديدة مع خيار الانهيار.

أضف إلى NewMapMenuالحقل لتتبع التحديد ، بالإضافة إلى طريقة لتغييره. فلنقم باستدعاء هذه الطريقة عندما تتغير حالة المفتاح.

  bool wrapping = true; … public void ToggleWrapping (bool toggle) { wrapping = toggle; } 

عند طلب خريطة جديدة ، فإننا نمرر قيمة خيار التصغير.

  void CreateMap (int x, int z) { if (generateMaps) { mapGenerator.GenerateMap(x, z, wrapping); } else { hexGrid.CreateMap(x, z, wrapping); } HexMapCamera.ValidatePosition(); Close(); } 

قم بتغييره HexMapGenerator.GenerateMapبحيث يقبل هذه الحجة الجديدة ثم يمررها إلى HexGrid.CreateMap.

  public void GenerateMap (int x, int z, bool wrapping) { … grid.CreateMap(x, z, wrapping); … } 

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

  public int cellCountX = 20, cellCountZ = 15; public bool wrapping; … public bool CreateMap (int x, int z, bool wrapping) { … cellCountX = x; cellCountZ = z; this.wrapping = wrapping; … } 

HexGridالمكالمات الخاصة CreateMapفي مكانين. يمكننا ببساطة استخدام مجاله الخاص لحجة الانهيار.

  void Awake () { … CreateMap(cellCountX, cellCountZ, wrapping); } … public void Load (BinaryReader reader, int header) { … if (x != cellCountX || z != cellCountZ) { if (!CreateMap(x, z, wrapping)) { return; } } … } 


يتم تشغيل مفتاح طي الشبكة افتراضيًا.

حفظ وتحميل


نظرًا لأنه يتم ضبط الطي لكل بطاقة ، يجب حفظها وتحميلها. هذا يعني أنك بحاجة إلى تغيير تنسيق حفظ الملف ، لذا قم بزيادة ثابت الإصدار في SaveLoadMenu.

  const int mapFileVersion = 5; 

عند الحفظ ، دعه HexGridيكتب فقط قيمة الطي المنطقي بعد حجم الخريطة.

  public void Save (BinaryWriter writer) { writer.Write(cellCountX); writer.Write(cellCountZ); writer.Write(wrapping); … } 

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

  public void Load (BinaryReader reader, int header) { ClearPath(); ClearUnits(); int x = 20, z = 15; if (header >= 1) { x = reader.ReadInt32(); z = reader.ReadInt32(); } bool wrapping = header >= 5 ? reader.ReadBoolean() : false; if (x != cellCountX || z != cellCountZ || this.wrapping != wrapping) { if (!CreateMap(x, z, wrapping)) { return; } } … } 

مقاييس قابلة للطي


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

  public static int wrapSize; public static bool Wrapping { get { return wrapSize > 0; } } 

نحتاج إلى تعيين حجم الطي لكل مكالمة HexGrid.CreateMap.

  public bool CreateMap (int x, int z, bool wrapping) { … this.wrapping = wrapping; HexMetrics.wrapSize = wrapping ? cellCountX : 0; … } 

نظرًا لأن هذه البيانات لن تنجو من إعادة التجميع في وضع التشغيل ، فسوف نقوم بإعدادها OnEnable.

  void OnEnable () { if (!HexMetrics.noiseSource) { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexUnit.unitPrefab = unitPrefab; HexMetrics.wrapSize = wrapping ? cellCountX : 0; ResetVisibility(); } } 

عرض الخلية


عند العمل باستخدام بطاقات قابلة للطي ، غالبًا ما يتعين علينا التعامل مع المواضع على طول المحور X ، مقاسة بعرض الخلايا. على الرغم من أنه يمكن استخدامه لهذا HexMetrics.innerRadius * 2f، فإنه سيكون أكثر ملاءمة إذا لم نقم بإضافة الضرب في كل مرة. لذا دعنا نضيف ثابت HexMetrics.innerDiameter.

  public const float innerRadius = outerRadius * outerToInner; public const float innerDiameter = innerRadius * 2f; 

يمكننا بالفعل استخدام القطر في ثلاثة أماكن. أولاً ، HexGrid.CreateCellعند وضع خلية جديدة.

  void CreateCell (int x, int z, int i) { Vector3 position; position.x = (x + z * 0.5f - z / 2) * HexMetrics.innerDiameter; … } 

ثانيًا ، في HexMapCameraتحديد موضع الكاميرا.

  Vector3 ClampPosition (Vector3 position) { float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter; position.x = Mathf.Clamp(position.x, 0f, xMax); … } 

وكذلك في HexCoordinatesالتحويل من الموقع إلى الإحداثيات.

  public static HexCoordinates FromPosition (Vector3 position) { float x = position.x / HexMetrics.innerDiameter; … } 

حزمة الوحدة

توسيط البطاقة


عندما لا تنهار الخريطة ، فقد حددت بوضوح الحواف الشرقية والغربية ، وبالتالي مركز أفقي واضح. ولكن في حالة البطاقة القابلة للطي ، كل شيء مختلف. ليس لها الحافة الشرقية ولا الغربية ولا المركز. كبديل ، يمكننا أن نفترض أن المركز هو مكان الكاميرا. سيكون هذا مفيدًا لأننا نريد أن تركز الخريطة دائمًا على وجهة نظرنا. ثم ، أينما كنا ، لن نرى الحواف الشرقية أو الغربية للخريطة.

أعمدة جزء الخريطة


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

من الناحية المثالية ، بمجرد أن تنتقل الكاميرا إلى عمود الخلايا المجاور ، يجب أن ننقل على الفور العمود الأبعد للخلايا إلى الجانب الآخر. ومع ذلك ، لسنا بحاجة إلى أن نكون دقيقين للغاية. بدلاً من ذلك ، يمكننا نقل أجزاء الخريطة بالكامل. هذا يسمح لنا بتحريك أجزاء من الخريطة دون الحاجة إلى تعديل الشبكات.

نظرًا لأننا نقوم بنقل أعمدة كاملة من الأجزاء في نفس الوقت ، فلنقم بتجميعها عن طريق إنشاء كائن عمود رئيسي لكل مجموعة. أضف مصفوفة لهذه الكائنات HexGrid، وسنقوم بتهيئتها فيه CreateChunks. سوف نستخدمها فقط كحاويات ، لذلك نحن بحاجة فقط لتتبع الرابط إلى مكوناتها Transform. كما هو الحال في الأجزاء ، تقع مواقعها الأولية في الأصل المحلي لإحداثيات الشبكة.

  Transform[] columns; … void CreateChunks () { columns = new Transform[chunkCountX]; for (int x = 0; x < chunkCountX; x++) { columns[x] = new GameObject("Column").transform; columns[x].SetParent(transform, false); } … } 

الآن يجب أن يصبح الجزء تابعًا للعمود المقابل ، وليس الشبكة.

  void CreateChunks () { … chunks = new HexGridChunk[chunkCountX * chunkCountZ]; for (int z = 0, i = 0; z < chunkCountZ; z++) { for (int x = 0; x < chunkCountX; x++) { HexGridChunk chunk = chunks[i++] = Instantiate(chunkPrefab); chunk.transform.SetParent(columns[x], false); } } } 


تم تجميع الأجزاء في أعمدة.

نظرًا لأن جميع الأجزاء أصبحت الآن أعمدة من الأعمدة ، CreateMapيكفي أن ندمر جميع الأعمدة مباشرةً ، وليس الأجزاء. لذلك سوف نتخلص من شظايا الابنة.

  public bool CreateMap (int x, int z, bool wrapping) { … if (columns != null) { for (int i = 0; i < columns.Length; i++) { Destroy(columns[i].gameObject); } } … } 

أعمدة النقل الفوري


أضف إلى HexGridالطريقة الجديدة CenterMapمع الموضع X كمعلمة. قم بتحويل الموضع إلى فهرس العمود ، وتقسيمه على عرض الجزء في وحدات الوحدة. سيكون هذا هو فهرس العمود الذي توجد فيه الكاميرا حاليًا ، أي أنه سيكون العمود الأوسط للخريطة.

  public void CenterMap (float xPosition) { int centerColumnIndex = (int) (xPosition / (HexMetrics.innerDiameter * HexMetrics.chunkSizeX)); } 

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

  int currentCenterColumnIndex = -1; … public bool CreateMap (int x, int z, bool wrapping) { … this.wrapping = wrapping; currentCenterColumnIndex = -1; … } … public void CenterMap (float xPosition) { int centerColumnIndex = (int) (xPosition / (HexMetrics.innerDiameter * HexMetrics.chunkSizeX)); if (centerColumnIndex == currentCenterColumnIndex) { return; } currentCenterColumnIndex = centerColumnIndex; } 

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

  currentCenterColumnIndex = centerColumnIndex; int minColumnIndex = centerColumnIndex - chunkCountX / 2; int maxColumnIndex = centerColumnIndex + chunkCountX / 2; 

لاحظ أن هذه المؤشرات قد تكون سالبة أو أكبر من فهرس العمود الأقصى الطبيعي. الحد الأدنى هو صفر فقط عندما تكون الكاميرا بالقرب من المركز الطبيعي للخريطة. مهمتنا هي تحريك الأعمدة بحيث تتوافق مع هذه المؤشرات النسبية. يمكن القيام بذلك عن طريق تغيير إحداثيات X المحلية لكل عمود في الحلقة.

  int minColumnIndex = centerColumnIndex - chunkCountX / 2; int maxColumnIndex = centerColumnIndex + chunkCountX / 2; Vector3 position; position.y = position.z = 0f; for (int i = 0; i < columns.Length; i++) { position.x = 0f; columns[i].localPosition = position; } 

لكل عمود ، نتحقق مما إذا كان فهرس الحد الأدنى للفهرس أقل. إذا كان الأمر كذلك ، فهو بعيد جدًا إلى يسار المركز. يجب أن ينتقل إلى الجانب الآخر من الخريطة. يمكن القيام بذلك بجعل إحداثيات X مساوية لعرض الخريطة. وبالمثل ، إذا كان فهرس العمود أكبر من الحد الأقصى للفهرس ، فهو بعيد جدًا إلى يمين الوسط ، ويجب أن ينتقل إلى الجانب الآخر.

  for (int i = 0; i < columns.Length; i++) { if (i < minColumnIndex) { position.x = chunkCountX * (HexMetrics.innerDiameter * HexMetrics.chunkSizeX); } else if (i > maxColumnIndex) { position.x = chunkCountX * -(HexMetrics.innerDiameter * HexMetrics.chunkSizeX); } else { position.x = 0f; } columns[i].localPosition = position; } 

تحريك الكاميرا


التغيير HexMapCamera.AdjustPositionبحيث أنه عند العمل مع بطاقة قابلة للطي ، فإنه بدلاً من ذلك ClampPositionيتصل WrapPosition. أولاً ، اجعل الطريقة الجديدة WrapPositionمكررة ClampPosition، ولكن مع الاختلاف الوحيد: في النهاية ، سوف تستدعي CenterMap.

  void AdjustPosition (float xDelta, float zDelta) { … transform.localPosition = grid.wrapping ? WrapPosition(position) : ClampPosition(position); } … Vector3 WrapPosition (Vector3 position) { float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter; position.x = Mathf.Clamp(position.x, 0f, xMax); float zMax = (grid.cellCountZ - 1) * (1.5f * HexMetrics.outerRadius); position.z = Mathf.Clamp(position.z, 0f, zMax); grid.CenterMap(position.x); return position; } 

حتى تتم توسيط البطاقة على الفور ، نسميها OnEnableالطريقة ValidatePosition.

  void OnEnable () { instance = this; ValidatePosition(); } 


تحرك إلى اليسار واليمين عند توسيط الكاميرا.

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

لطي الكاميرا ، نزيل قيود إحداثيات X الخاصة بها WrapPosition. بدلاً من ذلك ، سنستمر في زيادة إحداثيات X بعرض الخريطة عندما تكون أقل من الصفر ، وتقليلها بينما تكون أكبر من عرض الخريطة.

  Vector3 WrapPosition (Vector3 position) { // float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter; // position.x = Mathf.Clamp(position.x, 0f, xMax); float width = grid.cellCountX * HexMetrics.innerDiameter; while (position.x < 0f) { position.x += width; } while (position.x > width) { position.x -= width; } float zMax = (grid.cellCountZ - 1) * (1.5f * HexMetrics.outerRadius); position.z = Mathf.Clamp(position.z, 0f, zMax); grid.CenterMap(position.x); return position; } 


تتحرك الكاميرا الملتفة على طول الخريطة.

مواد تظليل قابلة للطي


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

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

 #define OUTER_TO_INNER 0.866025404 #define OUTER_RADIUS 10 #define CHUNK_SIZE_X 5 #define TILING_SCALE (1 / (CHUNK_SIZE_X * 2 * OUTER_RADIUS / OUTER_TO_INNER)) 

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

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

  #include "../HexMetrics.cginc" #include "../HexCellData.cginc" … float4 GetTerrainColor (Input IN, int index) { float3 uvw = float3( IN.worldPos.xz * (2 * TILING_SCALE), IN.terrain[index] ); … } 

في تظليل الطرق للضوضاء فوق البنفسجية ، استخدمنا مقياس 0.025. بدلاً من ذلك ، يمكنك استخدام مقياس التبليط الثلاثي. هذا يعطينا 0.02598076212 ، وهي قريبة جدًا.

  #include "HexMetrics.cginc" #include "HexCellData.cginc" … void surf (Input IN, inout SurfaceOutputStandardSpecular o) { float4 noise = tex2D(_MainTex, IN.worldPos.xz * (3 * TILING_SCALE)); … } 

أخيرًا ، استخدمنا في Water.cginc 0.015 للرغوة و 0.025 للموجات. هنا يمكننا استبدال هذه القيم مرة أخرى بمقياس تجانب مزدوج ومضاعف ثلاث مرات.

 #include "HexMetrics.cginc" float Foam (float shore, float2 worldXZ, sampler2D noiseTex) { shore = sqrt(shore) * 0.9; float2 noiseUV = worldXZ + _Time.y * 0.25; float4 noise = tex2D(noiseTex, noiseUV * (2 * TILING_SCALE)); … } … float Waves (float2 worldXZ, sampler2D noiseTex) { float2 uv1 = worldXZ; uv1.y += _Time.y; float4 noise1 = tex2D(noiseTex, uv1 * (3 * TILING_SCALE)); float2 uv2 = worldXZ; uv2.x += _Time.y; float4 noise2 = tex2D(noiseTex, uv2 * (3 * TILING_SCALE)); … } 

حزمة الوحدة

اتحاد الشرق والغرب


في هذه المرحلة ، الدليل المرئي الوحيد لتقليل الخريطة هو فجوة صغيرة بين الأعمدة الواقعة في أقصى الشرق وأقصى الغرب. تحدث هذه الفجوة لأننا لم نقم بعد بتثليث اتصالات الحواف والزوايا بين الخلايا على جانبي الخريطة المتقابلين دون طيها.


مساحة على الحافة.

الجيران للطي


لتثليث الاتصال بين الشرق والغرب ، نحتاج إلى جعل الخلايا على الجانبين المتقابلين متجاورين. حتى الآن لا نقوم بذلك ، لأن HexGrid.CreateCellاتصال E - W يتم إنشاؤه مع الخلية السابقة فقط إذا كان فهرسه في X أكبر من الصفر. لطي هذا الاتصال ، نحتاج إلى ربط الخلية الأخيرة من الصف بالخلية الأولى في الصف نفسه عند طي الخريطة.

  void CreateCell (int x, int z, int i) { … if (x > 0) { cell.SetNeighbor(HexDirection.W, cells[i - 1]); if (wrapping && x == cellCountX - 1) { cell.SetNeighbor(HexDirection.E, cells[i - x]); } } … } 

بعد أن ثبت اتصال الجيران E - W ، نحصل على تثليث جزئي للفجوة. اتصال الحواف ليس مثاليًا ، لأن التشويه مخفي بشكل غير صحيح. سنتعامل مع هذا لاحقًا.


المركبات E - W.

نحتاج أيضًا إلى طي روابط NE - SW. يمكن القيام بذلك عن طريق ربط الخلية الأولى من كل صف زوجي بالخلايا الأخيرة من الصف السابق. ستكون الخلية السابقة فقط.

  if (z > 0) { if ((z & 1) == 0) { cell.SetNeighbor(HexDirection.SE, cells[i - cellCountX]); if (x > 0) { cell.SetNeighbor(HexDirection.SW, cells[i - cellCountX - 1]); } else if (wrapping) { cell.SetNeighbor(HexDirection.SW, cells[i - 1]); } } else { … } } 


اتصالات NE - SW.

أخيرًا ، يتم إنشاء اتصالات SE - NW في نهاية كل خط فردي أسفل الأول. يجب أن تكون هذه الخلايا متصلة بالخلية الأولى من الصف السابق.

  if (z > 0) { if ((z & 1) == 0) { … } else { cell.SetNeighbor(HexDirection.SW, cells[i - cellCountX]); if (x < cellCountX - 1) { cell.SetNeighbor(HexDirection.SE, cells[i - cellCountX + 1]); } else if (wrapping) { cell.SetNeighbor( HexDirection.SE, cells[i - cellCountX * 2 + 1] ); } } } 


مركبات SE - NW.

طي الضوضاء


لإخفاء الفجوة تمامًا ، نحتاج إلى التأكد من أن الحواف الشرقية والغربية للخريطة تتوافق مع الضوضاء المستخدمة بشكل مثالي لتشويه مواقع القمم. يمكننا استخدام نفس الحيلة التي تم استخدامها للتظليل ، ولكن تم استخدام مقياس ضوضاء 0.003 للتشويه. لضمان التجانب ، تحتاج إلى زيادة الحجم بشكل كبير ، مما سيؤدي إلى تشويه الفوضى أكثر من القمم.

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

ماذا عن تقلبات درجة الحرارة؟
. , . , . , .

إذا لم نسحب البطاقة ، فيمكننا الحصول على HexMetrics.SampleNoiseعينة واحدة. ولكن عند الطي ، من الضروري إضافة التوهين. لذلك ، قبل إرجاع العينة ، احفظها في متغير.

  public static Vector4 SampleNoise (Vector3 position) { Vector4 sample = noiseSource.GetPixelBilinear( position.x * noiseScale, position.z * noiseScale ); return sample; } 

عند التقليل ، نحتاج إلى المزج مع العينة الثانية. سنقوم بالانتقال في الجزء الشرقي من الخريطة ، لذا يجب نقل العينة الثانية إلى الغرب.

  Vector4 sample = noiseSource.GetPixelBilinear( position.x * noiseScale, position.z * noiseScale ); if (Wrapping && position.x < innerDiameter) { Vector4 sample2 = noiseSource.GetPixelBilinear( (position.x + wrapSize * innerDiameter) * noiseScale, position.z * noiseScale ); } 

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

  if (Wrapping && position.x < innerDiameter) { Vector4 sample2 = noiseSource.GetPixelBilinear( (position.x + wrapSize * innerDiameter) * noiseScale, position.z * noiseScale ); sample = Vector4.Lerp( sample2, sample, position.x * (1f / innerDiameter) ); } 


خلط الضوضاء ، حل غير كامل

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

  if (Wrapping && position.x < innerDiameter * 1.5f) { Vector4 sample2 = noiseSource.GetPixelBilinear( (position.x + wrapSize * innerDiameter) * noiseScale, position.z * noiseScale ); sample = Vector4.Lerp( sample2, sample, position.x * (1f / innerDiameter) - 0.5f ); } 


التوهين الصحيح.

تحرير الخلية


الآن بعد أن يبدو التثليث صحيحًا ، دعنا نتأكد من أنه يمكننا تعديل كل شيء على الخريطة وعلى خط الطي. كما اتضح ، في الأجزاء المنقولة عن بعد ، تكون الإحداثيات خاطئة ويتم قطع الفرش الكبيرة بواسطة خط التماس.


الفرشاة مشذبة.

لإصلاح ذلك ، نحتاج إلى الإبلاغ عن HexCoordinatesالطي. يمكننا القيام بذلك عن طريق مطابقة إحداثيات X في طريقة المُنشئ. نحن نعلم أن الإحداثيات المحورية X يتم الحصول عليها من الإحداثيات X للإزاحة بطرح نصف الإحداثي Z. يمكنك استخدام هذه المعلومات لإجراء التحويل العكسي والتحقق مما إذا كان الإحداثيات الصفرية أقل من الصفر. إذا كان الأمر كذلك ، فلدينا الإحداثيات خارج الجانب الشرقي من الخريطة غير المكشوفة. نظرًا لأننا نقوم في كل اتجاه بنقل ما لا يزيد عن نصف الخريطة ، فسيكون كافياً بالنسبة لنا أن نضيف حجم الطي إلى X مرة واحدة. وعندما تكون إحداثيات الإزاحة أكبر من حجم الطي ، نحتاج إلى إجراء الطرح.

  public HexCoordinates (int x, int z) { if (HexMetrics.Wrapping) { int oX = x + z / 2; if (oX < 0) { x += HexMetrics.wrapSize; } else if (oX >= HexMetrics.wrapSize) { x -= HexMetrics.wrapSize; } } this.x = x; this.z = z; } 

أحيانًا عند تحرير الجزء السفلي أو العلوي من الخريطة ، أتلقى أخطاء
يحدث هذا عندما يظهر المؤشر في صف الخلايا خارج الخريطة بسبب تشوه القمم. هذا خطأ يحدث لأننا لا نطابق الإحداثيات HexGrid.GetCellمع معلمة المتجه. يمكن إصلاح ذلك من خلال تطبيق طريقة GetCellبإحداثيات كمعلمات ستقوم بإجراء الفحوصات اللازمة.

  public HexCell GetCell (Vector3 position) { position = transform.InverseTransformPoint(position); HexCoordinates coordinates = HexCoordinates.FromPosition(position); // int index = // coordinates.X + coordinates.Z * cellCountX + coordinates.Z / 2; // return cells[index]; return GetCell(coordinates); } 

للطي الساحلي


يعمل التثليث بشكل جيد للتضاريس ، ولكن على طول خط التماس بين الشرق والغرب لا توجد حواف لساحل الماء. في الواقع ، هم فقط لا ينهارون. يتم قلبها وتمددها إلى الجانب الآخر من الخريطة.


حافة المياه المفقودة.

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

  public int ColumnIndex { get; set; } 

تعيين هذا الفهرس إلى HexGrid.CreateCell. وهي تساوي ببساطة إحداثيات الإزاحة X مقسومة على حجم القطعة.

  void CreateCell (int x, int z, int i) { … cell.Index = i; cell.ColumnIndex = x / HexMetrics.chunkSizeX; … } 

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

  Vector3 center2 = neighbor.Position; if (neighbor.ColumnIndex < cell.ColumnIndex - 1) { center2.x += HexMetrics.wrapSize * HexMetrics.innerDiameter; } else if (neighbor.ColumnIndex > cell.ColumnIndex + 1) { center2.x -= HexMetrics.wrapSize * HexMetrics.innerDiameter; } 


أضلاع الساحل ، ولكن لا زوايا.

لذلك اعتنينا بأضلاع الساحل ، لكن حتى الآن لم نتعامل مع الزوايا. نحتاج أن نفعل نفس الشيء مع الجار التالي.

  if (nextNeighbor != null) { Vector3 center3 = nextNeighbor.Position; if (nextNeighbor.ColumnIndex < cell.ColumnIndex - 1) { center3.x += HexMetrics.wrapSize * HexMetrics.innerDiameter; } else if (nextNeighbor.ColumnIndex > cell.ColumnIndex + 1) { center3.x -= HexMetrics.wrapSize * HexMetrics.innerDiameter; } Vector3 v3 = center3 + (nextNeighbor.IsUnderwater ? HexMetrics.GetFirstWaterCorner(direction.Previous()) : HexMetrics.GetFirstSolidCorner(direction.Previous())); … } 


تقليص الساحل بشكل صحيح.

توليد البطاقة


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



خريطة كبيرة 1208905299 مع الإعدادات الافتراضية. مع للطي وبدون ذلك.

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

نغير HexMapGenerator.CreateRegions، نستبدل في جميع الحالات mapBorderXبـ borderX. سيكون هذا المتغير الجديد مساويًا أو regionBorder، أو mapBorderXحسب قيمة خيار الانهيار. أدناه عرضت التغييرات فقط للحالة الأولى.

  int borderX = grid.wrapping ? regionBorder : mapBorderX; MapRegion region; switch (regionCount) { default: region.xMin = borderX; region.xMax = grid.cellCountX - borderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); break; … } 

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

  switch (regionCount) { default: if (grid.wrapping) { borderX = 0; } region.xMin = borderX; region.xMax = grid.cellCountX - borderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); break; case 2: if (Random.value < 0.5f) { … } else { if (grid.wrapping) { borderX = 0; } region.xMin = borderX; region.xMax = grid.cellCountX - borderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ / 2 - regionBorder; regions.Add(region); region.zMin = grid.cellCountZ / 2 + regionBorder; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); } break; … } 


منطقة واحدة تنهار.

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



عندما يتم تعطيل التآكل ، يصبح خط التماس على الإغاثة ملحوظًا.

تحدث الفجوة لأن التماس يمنع نمو شظايا الإغاثة. لتحديد ما يتم إضافته أولاً ، يتم استخدام المسافة من الخلية إلى مركز الجزء ، ويمكن أن تكون الخلايا الموجودة على الجانب الآخر من الخريطة بعيدة جدًا ، لذلك لا يتم تشغيلها أبدًا تقريبًا. بالطبع ، هذا خطأ. نحتاج إلى التأكد من HexCoordinates.DistanceToمعرفتنا بالخريطة المصغرة.

نحسب المسافة بين HexCoordinates، ونجمع المسافات المطلقة على طول كل من المحاور الثلاثة وخفض النتيجة إلى النصف. تكون المسافة على طول Z صحيحة دائمًا ، ولكن الطي على طولها قد يؤثر على مسافات X و Y. لذا فلنبدأ بحساب منفصل لـ X + Y.

  public int DistanceTo (HexCoordinates other) { // return // ((x < other.x ? other.x - x : x - other.x) + // (Y < other.Y ? other.Y - Y : Y - other.Y) + // (z < other.z ? other.z - z : z - other.z)) / 2; int xy = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); return (xy + (z < other.z ? other.z - z : z - other.z)) / 2; } 

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

  int xy = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); if (HexMetrics.Wrapping) { other.x += HexMetrics.wrapSize; int xyWrapped = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); if (xyWrapped < xy) { xy = xyWrapped; } } 

إذا لم يؤد ذلك إلى مسافة أقصر ، فمن الممكن أن تتحول إلى مسافة أقصر في الاتجاه الآخر ، لذلك سوف نتحقق منها.

  if (HexMetrics.Wrapping) { other.x += HexMetrics.wrapSize; int xyWrapped = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); if (xyWrapped < xy) { xy = xyWrapped; } else { other.x -= 2 * HexMetrics.wrapSize; xyWrapped = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); if (xyWrapped < xy) { xy = xyWrapped; } } } 

الآن نحصل دائمًا على أقصر مسافة على الخريطة القابلة للطي. لم يعد يتم سد أجزاء التضاريس بواسطة خط التماس ، مما يسمح للكتل الأرضية بالالتفاف.



راحة قابلة للطي بشكل صحيح دون التآكل والتآكل.

حزمة الوحدة

السفر حول العالم


بعد التفكير في إنشاء الخرائط والتثليث ، دعنا ننتقل الآن إلى فحص الفرق والاستكشاف والرؤية.

اختبار التماس


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


لا يمكن فحص خط التماس للبطاقة.

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

  if (wrapping) { cell.Explorable = z > 0 && z < cellCountZ - 1; } else { cell.Explorable = x > 0 && z > 0 && x < cellCountX - 1 && z < cellCountZ - 1; } 

وضوح ميزات الإغاثة


الآن دعونا نتحقق مما إذا كانت الرؤية تعمل على طول خط التماس. يعمل على التضاريس ، ولكن ليس مع كائنات التضاريس. يبدو أن الكائنات المنهارة تحصل على رؤية الخلية الأخيرة التي لم تنهار.


رؤية غير صحيحة للأشياء.

يحدث هذا لأنه HexCellShaderDataتم تعيين وضع المشبك لوضع طي النسيج المستخدم . لحل المشكلة ، ما عليك سوى تغيير وضع المشبك للتكرار. ولكن يتعين علينا القيام به هو فقط لتنسيق U، علينا أن Initializeنسأل wrapModeUو wrapModeVحدة.

  public void Initialize (int x, int z) { if (cellTexture) { cellTexture.Resize(x, z); } else { cellTexture = new Texture2D( x, z, TextureFormat.RGBA32, false, true ); cellTexture.filterMode = FilterMode.Point; // cellTexture.wrapMode = TextureWrapMode.Clamp; cellTexture.wrapModeU = TextureWrapMode.Repeat; cellTexture.wrapModeV = TextureWrapMode.Clamp; Shader.SetGlobalTexture("_HexCellData", cellTexture); } … } 

فرق وأعمدة


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


لا يتم نقل الوحدة وهي في الجانب الخطأ.

يمكن حل هذه المشكلة عن طريق جعل الفرق عناصر فرعية من الأعمدة ، كما فعلنا مع الأجزاء. أولاً ، لن نجعلهم الأطفال المباشرين للشبكة HexGrid.AddUnit.

  public void AddUnit (HexUnit unit, HexCell location, float orientation) { units.Add(unit); unit.Grid = this; // unit.transform.SetParent(transform, false); unit.Location = location; unit.Orientation = orientation; } 

نظرًا لأن الوحدات تتحرك ، فقد تظهر في عمود آخر ، أي أنه سيكون من الضروري تغيير الأصل. لجعل هذا ممكنًا ، نضيف إلى HexGridالطريقة العامة MakeChildOfColumn، وبصفتنا معلمات ، نمرره مكون Transformالعنصر الفرعي وفهرس العمود.

  public void MakeChildOfColumn (Transform child, int columnIndex) { child.SetParent(columns[columnIndex], false); } 

سنقوم باستدعاء هذه الطريقة عند تعيين الخاصية HexUnit.Location.

  public HexCell Location { … set { … Grid.MakeChildOfColumn(transform, value.ColumnIndex); } } 

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

  IEnumerator TravelPath () { Vector3 a, b, c = pathToTravel[0].Position; yield return LookAt(pathToTravel[1].Position); // Grid.DecreaseVisibility( // currentTravelLocation ? currentTravelLocation : pathToTravel[0], // VisionRange // ); if (!currentTravelLocation) { currentTravelLocation = pathToTravel[0]; } Grid.DecreaseVisibility(currentTravelLocation, VisionRange); int currentColumn = currentTravelLocation.ColumnIndex; … } 

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

  int currentColumn = currentTravelLocation.ColumnIndex; float t = Time.deltaTime * travelSpeed; for (int i = 1; i < pathToTravel.Count; i++) { … Grid.IncreaseVisibility(pathToTravel[i], VisionRange); int nextColumn = currentTravelLocation.ColumnIndex; if (currentColumn != nextColumn) { Grid.MakeChildOfColumn(transform, nextColumn); currentColumn = nextColumn; } … } 

سيسمح هذا للوحدات بالتحرك بشكل مماثل للأجزاء. ومع ذلك ، عند التحرك عبر خط التماس للبطاقة ، لا تنهار الوحدات بعد. وبدلاً من ذلك ، يبدأون فجأة في التحرك في الاتجاه الخاطئ. يحدث هذا بغض النظر عن موقع خط التماس ، ولكن بشكل ملحوظ عندما يقفزون عبر الخريطة بأكملها.


سباق الخيل عبر الخريطة.

هنا يمكننا استخدام نفس النهج الذي تم استخدامه للساحل ، ولكن هذه المرة فقط سنحول المنحنى الذي تتحرك فيه الانفصال. إذا تم تحويل العمود التالي إلى الشرق ، فسننقل المنحنى أيضًا شرقًا ، وبالمثل للاتجاه الآخر. فمن الضروري تغيير نقاط السيطرة على منحنى aو bالتي من شأنها أن تؤثر أيضا على نقطة مراقبة c.

  for (int i = 1; i < pathToTravel.Count; i++) { currentTravelLocation = pathToTravel[i]; a = c; b = pathToTravel[i - 1].Position; // c = (b + currentTravelLocation.Position) * 0.5f; // Grid.IncreaseVisibility(pathToTravel[i], VisionRange); int nextColumn = currentTravelLocation.ColumnIndex; if (currentColumn != nextColumn) { if (nextColumn < currentColumn - 1) { ax -= HexMetrics.innerDiameter * HexMetrics.wrapSize; bx -= HexMetrics.innerDiameter * HexMetrics.wrapSize; } else if (nextColumn > currentColumn + 1) { ax += HexMetrics.innerDiameter * HexMetrics.wrapSize; bx += HexMetrics.innerDiameter * HexMetrics.wrapSize; } Grid.MakeChildOfColumn(transform, nextColumn); currentColumn = nextColumn; } c = (b + currentTravelLocation.Position) * 0.5f; Grid.IncreaseVisibility(pathToTravel[i], VisionRange); … } 


حركة قابلة للطي.

آخر شيء فعله هو تغيير المنعطف الأولي للفريق عندما ينظر إلى الخلية الأولى التي سينتقل إليها. إذا كانت هذه الزنزانة على الجانب الآخر من خط التماس بين الشرق والغرب ، فستبدو الوحدة في الاتجاه الخاطئ.

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

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

  IEnumerator LookAt (Vector3 point) { if (HexMetrics.Wrapping) { float xDistance = point.x - transform.localPosition.x; if (xDistance < -HexMetrics.innerRadius * HexMetrics.wrapSize) { point.x += HexMetrics.innerDiameter * HexMetrics.wrapSize; } else if (xDistance > HexMetrics.innerRadius * HexMetrics.wrapSize) { point.x -= HexMetrics.innerDiameter * HexMetrics.wrapSize; } } … } 

لذا ، لدينا خريطة مصغرة تعمل بكامل طاقتها. وبهذا نختتم سلسلة الدروس على الخرائط السداسية. كما ذكر في الأقسام السابقة ، يمكن النظر في مواضيع أخرى ، لكنها ليست خاصة بالخرائط السداسية. ربما سأدرسها في سلسلة من الدروس التعليمية في المستقبل.

لقد قمت بتنزيل الحزمة الأخيرة وتلقيت أخطاء في وضع التشغيل
, Rotation . . . 5.

لقد قمت بتنزيل الحزمة الأخيرة والرسومات ليست جميلة كما في لقطات الشاشة
. - .

لقد قمت بتنزيل الحزمة الأخيرة ويقوم باستمرار بإنشاء نفس البطاقة
seed (1208905299), . , Use Fixed Seed .

حزمة الوحدة

Source: https://habr.com/ru/post/ar427567/


All Articles