Удобно так устроился в углу уютного торонтского кафе. Тихий уголок с видом на улицу позволял мне сосредоточиться, но при этом оставаться в курсе того, что происходит снаружи, – эта привычка сильно укрепилась с тех пор, как оказался в этом времени. Передо мной – тёплая булочка и дымящийся чай. Глотнув терпкого чая, вновь взглянул на экран. Да, почти всё заработанное на проекте с Томом потратил на местный вариант ноутбука. Не выдержал. Он не блистал характеристиками, зато давал возможность работать! Не когда было можно, а когда горело. Отрабатывать результат можно и потом, на нормальных машинах, когда к ним будет доступ, а пока так. Да и в целом, сейчас не такие уж и большие требования к графическому окружению программного обеспечения и много чего и на такой машинке можно писать полностью.
Для того чтобы игра могла функционировать на машинах нынешнего времени, придётся отбросить много "фишек" из будущего. Ускорители 3D-графики, как 3D-графические карты, ещё не появились на массовом рынке, и нужно было работать с тем, что есть. Следовательно, всё, что обычно обрабатывалось графическим оборудованием, теперь должно было выполняться центральным процессором со всеми вытекающими из этого последствиями. И основной идеей было задействовать возможности процессоров для построения псевдо-3D графики. Вдохновляясь подходами к рендерингу старых систем, стал думать над алгоритмом, который позволил бы симулировать трёхмерное пространство без полного построения сцены. Пальцы лихо побежали по клавишам, вводя очередные строчки кода:
```c
// Основная функция рендеринга
void RenderScene() {
for (int y = 0; y screenHeight; y++) {
for (int x = 0; x screenWidth; x++) {
// Перевод экрана в псевдо-3D координаты
int mapX = x 3;
int mapY = y 3;
int tileType = map[mapY][mapX];
// Проверка для текстурирования
if (tileType == WALL) {
DrawWall(x, y);
} else {
DrawFloor(x, y);
}
}
}
}
```
Суть этого подхода заключалась в том, чтобы проецировать 2D-карту на экран, таким образом создавая иллюзию 3D-пространства. Для эмуляции высоты использовались заранее подготовленные тайлы – кирпичики, полы, стены – которые накладывались друг на друга, как слои в фотографии. Эффект получался не совсем реальным, но в условиях отсутствия 3D-ускорителя он был весьма неплох. Тем боле пока потребители даже такого не вкусили толком. Ух они у меня устроят мочилово!
Так и продолжал возиться с рендерингом – в каждом шаге приходилось тщательно учитывать ограничения как потенциальных возможностей машин, на которых будут игру запускать, так и того, что мог мой ноут. "Так, без прямой поддержки 3D могу рассчитывать только на простые матричные преобразования и рисовать на основе значений тайлов. Ну, а свет? Ладно, с фальшивым светом подумаю позже...". На этом открыл блокнот, где предварительно набросал идеи для освещения:
1. **Имитировать световые зоны** – в зависимости от расстояния до источника света текстуры становились бы темнее или светлее.
2. **Простая градация** – чем дальше от игрока, тем темнее зона, создавая эффект глубины.
Ещё пара строк кода, и на экране в отдельном окне возникла сцена с характерным серым полом и тёмными стенами. Ощущение присутствия было сырым, но для первого эксперимента неплохо. Да что там, супер, сказал бы даже. Я молодец! Если себя сам не похвалишь, кто это ещё сделает, тем более никто и не знает, какую революцию тут замутил.
```c
void DrawWall(int x, int y) {
// Закрашиваем стену с оттенком серого
int shade = 255 - (distanceToPlayer(x, y) * 10);
shade = max(0, min(shade, 255)); // Ограничиваем значения
SetPixelColor(x, y, RGB(shade, shade, shade));
}
```
"Отлично, - пришла мысль, — это первый шаг. Простейшие стены готовы".
После чего сделал несколько быстрых глотков чая, поглядывая на экран. Несмотря на довольно простую задачу – отрисовку стен и пола, – процесс оказался гораздо более сложным, чем думал внвчале. Даже каждая мелочь, вроде изменения оттенков или расчета расстояний, съедала память и ресурсы процессора. Гадство…. Прибил бы этого Билла Гейтса с его шестнадцатью килобайтами оперативы, которой хватит на всё! Но одна идея крутится в голове, давая новый импульс: вместо полной симуляции каждого шага, можно ведь бы использовать метод **Raycasting**, в прямом переводе звучащий довольно бессмысленно, – метод, которым создавались игры вроде *Wolfenstein 3D* в ранние девяностые годы. Он не требовал 3D-ускорения, но позволял строить иллюзию трехмерного мира на основе двумерной карты.
Правда появился он куда как ранее, ещё в восьмидесятые, но это сейчас не важно, поскольку до нынешнего времени использовать его в практических приложения возможным не было, поскольку банально не хватало мощности компьютеров. Если сделаю игру и выпущу на рынок раньше, то будут упоминать меня, а не Кармака. Да и игра в которой его применили первой станет *DOOM*. В общем, тут можно сказать саму историю переписываю! А потому, сконцентрировавшись, начал накидывать основные принципы метода:
1. **Лучевая трассировка** - при каждом обновлении экрана будет высылаться множество «лучей», начиная с точки игрока и направленных в разные стороны. Эти лучи «натыкались» на стены, вычисляя расстояние до каждой из них.
2. **Ширина и высота стен** зависели от расстояния до них, создавая эффект перспективы.
3. **Свет и тени** – расстояние к стенам также влияло бы на их яркость, что добавляло бы глубину сцене.
Набросав общий план, открыл новый файл и начал писать код для этого подхода:
```c
void RenderRaycasting() {
for (int x = 0; x screenWidth; x++) {
// Рассчитываем угол луча от игрока
float rayAngle = (player.angle - FOV / 2) + ((float)x / screenWidth) * FOV;
float distanceToWall = 0;
bool hitWall = false;
// Переменные для отслеживания попаданий луча
float eyeX = cos(rayAngle);
float eyeY = sin(rayAngle);
while (!hitWall distanceToWall renderDistance) {
distanceToWall += 0.1;
int testX = (int)(player.x + eyeX * distanceToWall);
int testY = (int)(player.y + eyeY * distanceToWall);
// Проверка столкновения луча с границей карты
if (testX 0 || testX = mapWidth || testY 0 || testY = mapHeight) {
hitWall = true;
distanceToWall = renderDistance; // Установка максимального расстояния
} else if (map[testY][testX] == WALL) {
hitWall = true;
}
}
// Расчёт высоты стены на экране
int ceiling = (screenHeight / 2) - (screenHeight / distanceToWall);
int floor = screenHeight - ceiling;
// Закрашивание столбца стены
for (int y = 0; y screenHeight; y++) {
if (y ceiling) {
DrawCeiling(x, y);
} else if (y floor) {
DrawFloor(x, y);
} else {
int shade = 255 - (distanceToWall * 20); // Чем дальше, тем темнее
SetPixelColor(x, y, RGB(shade, shade, shade));
}
}
}
}
```
Остановившись проглядел код, чувствуя тихое удовлетворение. Это был достаточно экономный подход для графики, не требовавшей оборудования доступного в будущем. Кстати об этом, после выпуска игры надо будет попробовать самому принять участие в будущей гонке ускорителей. Может всего и не съем, но чего-нибудь, да надкусаю! Правда, еще не тестировал, как движок поведет себя с большим количеством объектов, но и это уже был ощутимый прогресс. Запустив программу, наблюдать, как на экране проступили низкие серые стены, обрамленные «потолком» и «полом». Простая перспектива, создающая эффект коридора, была достигнута! Наложение света и тени добавляло глубины, делая картинку более выразительной. Почти то, что нужно! Да, наконец удовлетворённо вздохнул, слегка откинулся назад, снова сделал глоток уже остывающего чая и продолжил писать в блокнот идеи, которые планировал реализовать в будущем:
1. **Система врагов и объектов** – каждому врагу нужно выделить отдельное место в памяти и алгоритм для поведения, который бы не замедлял работу движка.
2. **Эффект бликов и освещения** – симулировать отражение света хотя бы частично, используя более светлые оттенки для ближайших объектов и более темные для удаленных.
3. **Открытие дверей и передвижение объектов** – система триггеров, которая позволит игроку взаимодействовать с окружением.
Отложив блокнот, еще раз взглянул на экран и добавил ещё несколько строк кода для отрисовки простейшего интерфейса, чтобы сделать игру более наглядной:
```c
void RenderHUD() {
// Показать простую панель здоровья и боезапаса
char hudText[128];
sprintf(hudText, "Health: %d Ammo: %d", player.health, player.ammo);
DrawText(hudText, 10, screenHeight - 20, RGB(255, 255, 255));
}
```
Простая панель добавляла необходимую «игровую» атмосферу. Сейчас это выглядело минималистично, но я-то знал, что с правильным дизайном и добавлением более сложных механик движок мог бы стать основой для увлекательной игры, в которую, как минимум, полмира рубилась. А второй половине мира просто делать это было не на чем. Всё это ощущалось как начало большой работы. Превьюшка, можно сказать. Но любая дорога начинается с первого шага.
А пока просто убрал чашку в сторону и сосредоточился на следующем этапе. Нужно было добавить возможность передвижения по уровню – а то мог только стоять на месте и смотреть на серые стены. Представил себе, как игрок должен плавно перемещаться по коридорам, с чувством глубины и перспективы, несмотря на ограниченные возможности компьютера. Механика движения, по сути, состояла из трех вещей: изменение координат игрока, поворот камеры и пересчёт лучей с учётом новых координат. И пристально глядя на код, добавил функцию для движения:
```c
void MovePlayer(float deltaX, float deltaY) {
float newX = player.x + deltaX;
float newY = player.y + deltaY;
// Проверяем, не сталкивается ли игрок со стеной
if (map[(int)newY][(int)newX] != WALL) {
player.x = newX;
player.y = newY;
}
}
void RotatePlayer(float angle) {
player.angle += angle;
if (player.angle 0) player.angle += 2 * PI;
if (player.angle 2 * PI) player.angle -= 2 * PI;
}
```
Теперь управление было достаточно простым: стрелки на клавиатуре двигали игрока вперед-назад, а боковые — разворачивали его, позволяя смотреть в разные стороны. Оставалось протестировать, и, предвкушая результат, запустил программу. Эх! На экране игра ожила. Простенький интерфейс отражал каждое движение и разворот, стена мелькала перед глазами, становясь то ближе, то дальше. кликнул стрелкой вправо, и перспектива мгновенно изменилась. Серые стены с разной глубиной то появлялись, то исчезали из виду, и казалось, что он действительно идёт по простым, но реалистичным коридорам. Правда, с цветом был проблема, поскольку всё приходилось додумывать. Монохромный дисплей к красоте не располагал. Ну ничего, запущу на нормальной машине, тогда и посмотрим, как совпадут ожидания и реальность.
Следующей задачей стало разнообразие окружения. Если оставить только серые стены, игра быстро потеряет интерес. Илон добавил несколько простых текстур: пару оттенков для разных видов стен и новую текстуру для пола и потолка, чтобы добавить ощущение разнообразия и глубины. Нет, всё-таки для работы над игрой нужен нормальный комп, а не этот эрзац. Пока решил использовать ASCII-графику для начального наброска:
```c
char wallTexture[2] = { '#', '@' };
char floorTexture = '.';
char ceilingTexture = ' ';
```
И добавил соответствующую логику в код отрисовки, чтобы менять символы текстур в зависимости от расстояния:
```c
void DrawColumn(int x, float distanceToWall) {
int ceiling = (screenHeight / 2) - (screenHeight / distanceToWall);
int floor = screenHeight - ceiling;
for (int y = 0; y screenHeight; y++) {
if (y ceiling) {
SetPixelColor(x, y, ceilingTexture);
} else if (y floor) {
SetPixelColor(x, y, floorTexture);
} else {
char texture = distanceToWall 5 ? wallTexture[0] : wallTexture[1];
SetPixelColor(x, y, texture);
}
}
}
```
Посмотрел на экран, оценив улучшение. Пока не блеск, но разные текстуры действительно помогли – визуальная карта стала больше напоминать место, а не пустую коробку. Оставалось добавить элемент динамики, чего-то живого, движущегося. Тут же пришла идея ввести простого противника – своего рода бота, который будет преследовать игрока, если тот слишком близко. Этот NPC был прост: всего лишь точка на карте, которая медленно приближалась к игроку, если оказывалась в зоне видимости. Ввёл переменные для бота и добавил несколько строк кода, чтобы создать эффект его движения:
```c
void UpdateNPC() {
float dx = player.x - npc.x;
float dy = player.y - npc.y;
float distance = sqrt(dx * dx + dy * dy);
if (distance detectionRange) {
npc.x += dx / distance * npcSpeed;
npc.y += dy / distance * npcSpeed;
}
}
```
Теперь каждый раз при запуске игры бот начинал следовать за игроком, словно его кто-то преследовал по пустым коридорам. Даже представил, что этот противник будет бормотать и издавать звуки – что-то зловещее, чтобы добавить немного напряжения. Однако, звуковая система для будущего была планом максимум, поэтому на время отложил эту задачу.
Идеи текли одна за другой, но с каждой новой функцией ему становилось сложнее поддерживать высокую производительность программы. Мощностей местных компьютеров, а тем более моего ноутбука, для всего задуманного было бы мало, и пришлось обдумывать, как сжать графику и текстуры, чтобы втиснуть всё в лимиты памяти. Периодически экран затухал, процессор перегружался, и часть моих кодовых идей требовала упрощения. Но это меня не останавливало. Какое там, когда чувствовал такой азарт, словно каждый новый шаг приносил меня ближе к созданию по-настоящему революционного проекта!
Пару дней в мастерской, пару ночей за кодом – и вот, наконец, выдался момент, чтобы отдохнуть. Нашёл тихое кафе неподалёку, заказал крепкий чай и свежую булочку, уселся у окна. Открытый ноутбук, хоть и грелся заметно, стойко справлялся с нагрузкой. Сосредоточиться на разработке для "прототипа Дум" в этих условиях было удивительно удобно: пульсирующий шум улицы за окном и гул разговоров вокруг погружали меня в состояние, где мысли шли яснее.
Я понимал: на настоящие 3D-ускорители здесь надеяться не приходится, потому главный вызов был в том, чтобы создать иллюзию трёхмерного пространства с тем, что имелось – в сущности, с теми же вычислительными мощностями, что и в середине восьмидесятых. Запустил компилятор, снова вернулся к коду. Словно бы мелькнуло нечто – магия простых вычислений, которые, при правильном использовании, творят чудеса.
```c
// Код для базовой структуры трассировки лучей
int rayCasting(float playerX, float playerY, float playerAngle) {
for (int x = 0; x screenWidth; x++) {
// Преобразуем угол для каждого пикселя на экране
float rayAngle = (playerAngle - fov / 2.0) + (x / (float)screenWidth) * fov;
float distanceToWall = 0;
// Проверяем пересечение с стенами
float eyeX = cos(rayAngle); // единичный вектор по оси X
float eyeY = sin(rayAngle); // единичный вектор по оси Y
while (!hitWall distanceToWall maxDepth) {
distanceToWall += stepSize;
int testX = (int)(playerX + eyeX * distanceToWall);
int testY = (int)(playerY + eyeY * distanceToWall);
// Проверка выхода за пределы карты
if (testX 0 || testX = mapWidth || testY 0 || testY = mapHeight) {
hitWall = true; // если за пределами — считаем, что есть "стена"
distanceToWall = maxDepth;
} else if (map[testY * mapWidth + testX] == '#') {
hitWall = true;
}
}
// Простое затемнение для глубины
int ceiling = (screenHeight / 2.0) - screenHeight / ((float)distanceToWall);
int floor = screenHeight - ceiling;
for (int y = 0; y screenHeight; y++) {
if (y ceiling) { setPixel(x, y, skyColor); }
else if (y ceiling y = floor) { setPixel(x, y, wallColor); }
else { setPixel(x, y, floorColor); }
}
}
return 0;
}
```
Смысл был прост: пока луч, выпущенный от игрока, не упирается в стену, он продолжает двигаться вперёд, точка за точкой, создавая иллюзию трёхмерного мира. В Raycasting мне нравилось то, что метод работал почти как адаптивный – чем дальше от игрока находился объект, тем меньше подробностей обрабатывалось, и тем менее требовательным к процессору становился код. Пока шёл процесс, сделал глоток чая, тёплая горечь отозвалась бодрящей ясностью. Даже, пожалуй, чувствовалось какое-то детское волнение. Представлялось, как однажды игрок увидит, будто бы на экране он шагает по тёмному, замкнутому коридору, его окружает неизвестность, каждое движение приносит чувство таинственности и лёгкой тревоги – настоящая магия. Да блин, словно вновь переместился в свою юность и мочил всех тих врагов без разбору. А добавить кооперативный режим…. Точно говорю, бомба. Да, потом всё это позабылось, но сейчас-то ничего почти такого нет.
Работа шла почти машинально, по уже заведенному алгоритму. Как будто нащупываешь потайную дверь – улавливаешь, что где-то там, за поворотом, есть свет, и, следуя на ощупь, открываешь его, постигаешь потихоньку механику, будто бы всю жизнь был частью этого мира кодов и пульсирующих пикселей. А ведь мне программирование не давалось. Не хватало усидчивости. Нет, если припирало, то вполне себе сдавал всё что надо, но как только заканчивал, будто взрывался, вырываясь с рабочего места. А сейчас сижу, пишу и не жужжу. И мне даже это всё нравится. Вот и говори потом, что первично, тело или дух. Для меня в этом конкретном случае именно тело Маска подарила возможность делать то, что сейчас творил.
Ну, да ладно, пробую запустить тестовый рендер на небольшом участке карты. Лёгкая анимация показывает – сцена ожила: на экране меняется положение теней и проекций стен, меняется восприятие расстояния. Правда того давнего восторга всё равно не ощущаю. Поскольку всё уже перепробовал, но теперь моя очередь дарить другим радость! А потому снова открыл блокнот и в несколько строк набросал список планов:
1. Добавить базовое освещение для усиления эффекта глубины;
2. Реализовать переходы в зависимости от дальности объектов;
3. Разработать простейшие спрайты для анимации персонажей.
Даже не заметил, как подошла официантка, поставила новую кружку чая. Мелькнула мысль, что, может, это знак, что пора сделать перерыв, – а потом отмахнулся от неё, раз за разом откатываясь в ту же механику кода, в то же воображаемое погружение в мир, который ещё только создаётся. И так за разом то возвращаясь к работе, то вновь выныривая в реальный мир, не заметил, как наступил вечер, а значит, пора бы и честь знать.
***
Вечерний Торонто восемьдесят девятого года был укрытым в тонкую пелену мягких туманов, как будто город был обволакиваем в шёпот старых времён. Улицы были пусты, только разве что тихий шорох листьев на ветвях деревьев, погруженных в серебристый свет лунного диска, прерывал этот молчание. Дороги были покрыты тонким слоем воды от дневного ещё дождя. Воздух был насыщен свежестью осенью, запахами влажной земли и нежным ароматом листвы, которая медленно падала на проулки города. Вдали можно было слышать отдаленные голоса людей, которые уже успели вернуться домой после трудного дня, но большинство жителей уже находились в своих квартирах, готовясь к отдыху.
На фоне этого спокойного города шёл домой медленно, наслаждаясь каждым мгновением. Под ногами слышался приятный хруст листьев, которые упали на асфальт. Вокруг играли тени деревьев, создавая штрихи и пятна на одежде. Свет фонарей мерцал, как звезды, падающие на землю, создавая впечатление, будто весь мир оживает под ногами.
Сквозь туман проносился легкий ветерок, принося с собой прохладу и запах осени. Он очищал воздух города от грязи дня, оставляя только чистоту и свежесть. Вокруг спешили машины, но они казались такими далекими, будто это был другой мир. Автомобили двигались быстро, но я не обращал на них внимания, продолжая свой медленный путь и наслаждался каждым моментом, каждым местом этого города мимо которых проходил. И, наконец, направился к своему дому. Завтра будет новый день, и снова займусь работой, а вечером также стану наслаждаться всеми красотами Торонто, и снова смогу услышать этот шорох листьев и почувствовать мягкий ветерок.
***
Компьютер главного героя. Именно с него и пошло название ноутбук.
Линейка компьютеров NEC UltraLite была представлена в октябре 1988 года, цена стартовала (но не ограничивалась) в зависимости от модели и комплектации с четырех тысяч долларов США. За эти деньги покупатель получал портативный компьютер размером с полпачки бумаги А4 и весом около двух килограммов, который характеризовался современниками, как «notebook» («блокнот») для того, чтобы выделять его на фоне других, более массивных и тяжелых портативных компьютеров того времени. Термин notebook впервые прозвучал именно со страниц журнала PC Magazine 1988 года, на обложке которого был изображен NEC UltraLite.
Компьютер был основан на процессоре NEC V30 частотой 4.92MHz/8.14MHz (по другим данным, 9.83MHz), обладал 640KB памяти, одним мегабайтом RAMDRIVE (PC-17-01 — 1MB, PC-17-02 — 2MB), монитором диагональю 9,5″ (8.25×4.25″) и разрешением 320×200 при четырех цветах или 640×200 при двух (CGA). Так же компьютер имел встроенный модем (2400bs), последовательный порт (через переходник, либо на корпусе внешнего дисковода), EXT порт для подключения дисковода и слот для проприетарных карт расширения.
Набор предустановленного ПО выглядел так: MS-DOS 3.3, Laplink 2, MS-DOS Manager V2.0. Объём содержимого ROM равен 456KB.Опционально, можно было получить внешний 3.5″ дисковод (720KB/1.2MB JAPANIESE, по другим данным, 1.44MB), ROM карты с дополнительным ПО (Lotus 123, Lotus Agenda, Lotus Metro/Express, Wordperfect 5.0, Wordstar, Microsoft Works и возможно другие), SRAM карты (256/512KB и по некоторым данным 1024KB) с возможностью защиты от записи и встроенной литиевой батареей, переходник для параллельного порта. Всё это богатство за дополнительные деньги, естественно.
Его задача – дать возможность читать и писать там, где это необходимо, а также, дать возможность синхронизации с «Большим братом» по телефону или по кабелю тогда, когда это нужно. Таким образом, NEC UltraLite был дорогим, но очень изящным решением проблемы работы в поле, или в дороге, по пути от домашнего ПК к рабочему.
Компьютер комплектовался достаточно крупным по современным меркам источником питания, формирующим несколько напряжений, и рассчитанным только на работу в сети 100V или 110V в зависимости от рынка.