Всех приветствую! Когда речь идет о мобильных играх, то сразу все говорят: Unity, Unreal Engine, Godot. Большинство разработчиков тянут эти огромные комбайны просто » по привычке», априори считая что «сделать по другому» или писать «свой движок» это нереально, да что там… просто невозможно. Лично я никогда не встречал игру сделанную на Unity, которая сразу прекрасно работает. У телефона 3 гига оперативки? Пф, умри, несчастный, ты и так тупишь, кладбище вон там, могила вот тут. А почему так происходит? А потому что индустрия всех приучила что это «Абсолютный золотой стандарт». Люди, при этом, выбирают такие подходы, либо из за желания сделать быстрее, либо по незнанке, что можно сделать не так.
Когда, простигоспаде… геймдизайнер, выбирает подобного рода движки он автоматом закрывает глаза на следующие проблемы:
-
Дикий оверхед. Финальный билд намертво зашивает десятки, а иногда и сотни мегабайт мусорного кода, тяжелые графические и физические подсистемы, которые в игре с высокой долей вероятности вовсе не используются.
-
Прожорливость и нагрев. По сути вытекает из предыдущего пункта, но отчасти. Бесконечные слои абстракций готовых «движков» заставляют мобильный процессор работать на пределе. Как итог: телефон сильно греется а батарея «тает»
-
Полное отсутствие оптимизации на слабых устройствах. Чтобы запустить тяжелый движок с нормальным количеством кадров в секунду на движках приходится не чинить сам движок, а тратить ресурсы и время на побочный элемент: ухудшение графики, текстур, хотя виновник сам движок.
Поэтому, я построил свой собственный модуль, с блекджеком и … ну вы сами знаете.
Вот зачем соглашаться на компромиссы и мириться с костылями, если можно просто самому все сделать нормально? (Кстати, насчет «сделать самому я не лукавлю, я пол года назад самостоятельно просто на парковке поменял диск сцепления, кто знает что это и какой геморрой, тот поймет).
Плюс ко всему, в детстве, когда у всех были плойки у меня была денди, когда у меня появилась сега, у всех 3 плойка. и вот на этой сега, была просто сногсшибательная игра — Zero Tolerance. Тогда я, конечно, еще не знал что это «псевдо 3D» хотя объем то есть. А я школьник, откуда я знаю? Кстати, про Doom и Wolfenstein я вообще никогда не слышал в детстве. А еще, как то мне в руки когда то давно попал телефон, с торчащей антенной, siemens c35, — и там была игра, на той же технологии, — лабиринт. просто бродилка.
Короче, смахнув слезу с олдскулы я загорелся проектом — собрать свою игру, на той же технологии — райкаст. И, как ни странно, оно занимает не так и много — около 500 строк. Под капотом нет ни OpenGl, ни Vulkan, только Canvas, Paint и чистая математика. И вот таким образом я написал NodeZero: The Labyrinth.
Архитектура: Скрещиваем Choreographer и Android View.
Где то вычитывал что для игр рекомендуется брать SurfaceView, чтобы рендерить картинку в отдельном потоке. Но, по моему мнению, конкретно в моей игре в этом нет необходимости, можно просто выжать максимум из стандартного графического конвейера. Что бы игровой цикл работал синхронно с частотой обновления экрана я его привязал к системному choreographer:
class RaycastView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) { private var mapWidth = 12 private var mapHeight = 0 private var map = intArrayOf(1,1,1,1,1,1,1,1,1,1,1,1 /* ... */) var playerX = 1.5f var playerY = 1.5f private var playerAngle = 0.0f private val fov = Math.toRadians(60.0).toFloat() private val numRays = 320 private val maxDepth = 16.0f private val gameLoopCallback = object : Choreographer.FrameCallback { override fun doFrame(frameTimeNanos: Long) { if (lastFrameTimeNanos == 0L) { lastFrameTimeNanos = frameTimeNanos Choreographer.getInstance().postFrameCallback(this) return } val deltaTime = (frameTimeNanos - lastFrameTimeNanos) / 1_000_000_000f lastFrameTimeNanos = frameTimeNanos updatePhysics(deltaTime) invalidate() Choreographer.getInstance().postFrameCallback(this) } } override fun onAttachedToWindow() { super.onAttachedToWindow() lastFrameTimeNanos = 0 Choreographer.getInstance().postFrameCallback(gameLoopCallback) }}
такой подход позволяет рассчитывать время с точность дло наносекунд. Скорость игрока жестко привязывается ко времени, поэтому игра работает одинаково плавно и на мощном игровом смартфоне, и на слабом.
Математика Райкастера.
как превратить одномерный массив карты в трехмерное пространство? Выпускаю 320 лучей веером на экране перед игроком. Нужно понять, где, собственно, идет пересечение со стеной, а как? Считать пиксели? Ха, нет конечно. есть для таких целей алгоритм — DDA. Мы шагаем лучами строго по сетке карты, наткнулись на единицу в массиве — стоп, считаем расстояние.
canvas.drawRect(0f, 0f, screenWidth, screenHeight / 2, paintSky) canvas.drawRect(0f, screenHeight / 2, screenWidth, screenHeight, paintFloor) val wallDistances = FloatArray(numRays)for (i in 0 until numRays) { val rayAngle = (playerAngle - fov / 2) + (i.toFloat() / numRays) * fov val rayDirX = Math.cos(rayAngle.toDouble()).toFloat() val rayDirY = Math.sin(rayAngle.toDouble()).toFloat() var mapX = playerX.toInt() var mapY = playerY.toInt() val deltaDistX = if (rayDirX == 0f) Float.MAX_VALUE else Math.abs(1f / rayDirX) val deltaDistY = if (rayDirY == 0f) Float.MAX_VALUE else Math.abs(1f / rayDirY) var hitWall = false var sideHoriz = false while (!hitWall) { if (sideDistX < sideDistY) { sideDistX += deltaDistX mapX += stepX sideHoriz = false } else { sideDistY += deltaDistY mapY += stepY sideHoriz = true } if (map[mapY * mapWidth + mapX] > 0) hitWall = true } val distanceToWall = if (!sideHoriz) { (mapX - playerX + (1f - stepX) / 2f) / rayDirX } else { (mapY - playerY + (1f - stepY) / 2f) / rayDirY } val correctedDistance = (distanceToWall * Math.cos((rayAngle - playerAngle).toDouble()).toFloat()).coerceAtLeast(0.01f) wallDistances[i] = correctedDistance val wallHeight = (screenHeight / correctedDistance).coerceAtMost(screenHeight * 4) val top = screenHeight / 2 - wallHeight / 2 val bottom = screenHeight / 2 + wallHeight / 2 var wallX = if (!sideHoriz) playerY + distanceToWall * rayDirY else playerX + distanceToWall * rayDirX wallX -= Math.floor(wallX.toDouble()).toFloat() var texX = (wallX * tex.width).toInt() wallSrcRect.set(texX, 0, texX + 1, tex.height) wallDstRect.set(screenLeft.toInt(), top.toInt(), screenRight.toInt(), bottom.toInt()) canvas.drawBitmap(tex, wallSrcRect, wallDstRect, paintWallBitmap)}
Спрайты и Z буфер.
Стены есть, но больше ничего нет. необходимо раскидать объекты. Выход и сундуки. По классике, это картинка, которые «следят за нами» прямо как то крокодил из мемчиков. Они всегда смотрят на нас, независимо от того с какой стороны мы на них смотрим. При этом, без Z буфера они будут следить за нами сквозь стены, а мы за ними. Мы пробегаемся по каждой вертикальной полосе спрайта, если расстояние до этой полосы больше, чем значение в Z буфере для этого луча — пиксели не рисуются.
for (sprite in sprites) { if (sprite.isCollected || sprite.texture == null) continue val dx = sprite.x - playerX val dy = sprite.y - playerY val distance = Math.hypot(dx.toDouble(), dy.toDouble()).toFloat() var spriteAngle = Math.atan2(dy.toDouble(), dx.toDouble()).toFloat() var angleDiff = spriteAngle - playerAngle val rotY = (distance * Math.cos(angleDiff.toDouble())).toFloat() if (rotY <= 0.1f) continue val screenX = screenWidth * (0.5f + angleDiff / fov) val spriteHeight = ((screenHeight / rotY) / 2).toInt() val spriteWidth = spriteHeight for (stripe in 0 until spriteWidth) { val stripeX = (screenX - spriteWidth / 2f + stripe).toInt() if (stripeX < 0 || stripeX >= screenWidth) continue val rayIndex = (stripeX / (screenWidth / numRays)).toInt().coerceIn(0, numRays - 1) if (wallDistances[rayIndex] < rotY) continue val texX = (stripe * sprite.texture.width / spriteWidth).coerceIn(0, sprite.texture.width - 1) spriteSrcRect.set(texX, 0, texX + 1, sprite.texture.height) spriteDstRect.set(stripeX, drawStartY, stripeX + 1, drawStartY + spriteHeight) canvas.drawBitmap(sprite.texture, spriteSrcRect, spriteDstRect, paintWallBitmap) }}
Управление.
Вообще, я с ним намучился. и так и этак делал, даже скачивал различные игры, проверял, и ничего не понравилось. одним джойстиком управлять было сильно неудобно, в основном из за отсутствия тактильности. поэтому я их разнес на две стороны. Плюс ко всему, я не стал использовать отдельные вью элементы, это будет лишнем слоем в UI, поэтому они рисуются все в том же движке.
override fun onTouchEvent(event: MotionEvent): Boolean { val action = event.actionMasked val pointerIndex = event.actionIndex val pointerId = event.getPointerId(pointerIndex) val touchX = event.getX(pointerIndex) val touchY = event.getY(pointerIndex) when (action) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { if (btnStrafeLeftRect.contains(touchX, touchY)) { isStrafeLeftPressed = true; strafeLeftPointerId = pointerId return true } val isNearLeft = Math.abs(touchX - leftBaseX) < leftJoyWidth * 1.3f && Math.abs(touchY - leftBaseY) < leftJoyHeight * 1.3f if (isNearLeft && !isLeftPressed) { isLeftPressed = true; leftPointerId = pointerId } } MotionEvent.ACTION_MOVE -> { for (i in 0 until event.pointerCount) { val pId = event.getPointerId(i) val pY = event.getY(i) val pX = event.getX(i) if (pId == leftPointerId && isLeftPressed) { leftStickY = pY.coerceIn(leftBaseY - leftJoyHeight/2, leftBaseY + leftJoyHeight/2) leftValueY = (leftStickY - leftBaseY) / (leftJoyHeight / 2) } } } MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { } } return true}
Благодаря жесткой изоляции по pointerId игрок может одновременно бежать, стрейфить, и плавно крутить «камеру».
Результат:
-
Игра нифига не весит, думаю это еще и не предел, можно, скорее всего, еще что нибудь убрать. например, еще поиграть с ресурсами, хоть они и webp, но их много и вес они в итоге имеют.
-
Производительность. Полный контроль над кодом. Отсутствие тяжелых прослоек. У меня для тестов есть очень дешевый телефон сяоми, с теми самыми 3 гигами оперативки. Я таким образом получил один из самых забавных отзывов: «Каким образом этот телефон, который тупит просто в интерфейсе, не тупит в самой игре???»
-
Энергопотребление: а его нет, игра ничего не потребляет. Процессор делает базовые математические расчеты.
Игра не просто абстрактно собрана и существует «только на моем смартфоне и только с нами в одной комнате», гугл плей ее уже одобрил (4 дня одобрял)
Кстати, защита закручена, база данных зашифрована через SQLCipher, поэтому просто так подкрутить золотишко не выйдет.
Ссылка на google play: https://play.google.com/store/apps/details?id=ru.root.core.nodezero
Буду рад ответить на вопросы по тригонометрии райкаста, оптимизации.
ссылка на оригинал статьи https://habr.com/ru/articles/1057672/