Изначально я хотел добавить поддержку геймпада только на финальном этапе разработки, но получилось так, что это делается достаточно просто, поэтому засунул сразу.
Пойдём по порядку
function handleKeyDown(e) {
keys[e.code] = true; // аккумулируем все нажатия, чтоб потом обработать
// Предотвращаем прокрутку страницы стрелками и пробелом
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) {
e.preventDefault();
}
}
function handleKeyUp(e) {
keys[e.code] = false; // "забываем" отпущенную кнопку
}
function handleGamepadConnected(e) {
console.log(`Gamepad connected: ${e.gamepad.id}`);
gamepadIndex = e.gamepad.index;
}
function handleGamepadDisconnected(e) {
console.log(`Gamepad disconnected: ${e.gamepad.id}`);
if (gamepadIndex === e.gamepad.index) {
gamepadIndex = null;
}
}
В отличии от клавиатуры, которая отдаёт браузеру события о нажатии на кнопка, с геймпадом такой финт ушами не прокатывает и надо опрашивать его состояние самостоятельно (прям как на привычных движках в старые добрые времена).
Дальше заспавним наш "танк" на игровой сцене
const spawnX = 4 * CELL_SIZE; // 4 клетки от левого края = 64 px
const spawnY = 24 * 8 - 32; // Чуть выше базы = 160 px
player = new Tank(spawnX, spawnY, TANK_PLAYER);
Класс танка достаточно большой, он ко конструкторе принимает значения координат спавна и типа танка (танк игрока или тип врага). Ему устанавливается "здоровье" (сколько раз в него надо попасть, чтобы убить), сколько активных патронов есть и т.д.
constructor(x, y, type = TANK_PLAYER) {
this.x = x;
this.y = y;
// Направление (0=вверх, 1=вправо, 2=вниз, 3=влево)
this.direction = DIR_UP;
this.speed = TANK_SPEED_NORMAL; // в пекселях за кадр
// Тип танка
this.type = type;
this.health = 1;
this.destroyed = false;
// Флаг движения в текущем кадре
this.moving = false; // Пока только задаётся, но нигде не используется. Есть мысли на будущее, но если не понадобится, удалю
// Апгрейды (для игрока)
this.bulletLevel = 0; // 0=обычная, 1=быстрая, 2=усиленная
this.bulletCount = 1; // Макс. пуль на экране
this.hasShield = false; // Временная защита
this.hasBoat = false; // Движение по воде
// Активные пули (для подсчёта лимита)
this.activeBullets = 0;
}
Непосредственно движение выгляди таким образом:
move(direction) {
// Сначала поворачиваем, если нужно
if (this.direction !== direction) {
this.turn(direction);
return; // В оригинале, если повернуть, танк не двигается в этом кадре, может уберу, если плейтесты покажут необходимость
}
// Вычисляем смещение по направлению
let dx = 0;
let dy = 0;
switch (direction) {
case DIR_UP: dy = -this.speed; break;
case DIR_DOWN: dy = this.speed; break;
case DIR_LEFT: dx = -this.speed; break;
case DIR_RIGHT: dx = this.speed; break;
}
// Новая позиция
let newX = this.x + dx;
let newY = this.y + dy;
// Ограничение границами игрового поля
newX = Math.max(0, Math.min(newX, LOGICAL_FIELD_SIZE - TANK_SIZE));
newY = Math.max(0, Math.min(newY, LOGICAL_FIELD_SIZE - TANK_SIZE));
// TODO: Проверка коллизий с тайлами (Этап 4)
// TODO: Проверка коллизий с другими танками (Этап 7)
// Применяем новую позицию
this.x = newX;
this.y = newY;
this.moving = true;
}
Ну и рисование танка на игровом поле
render() {
if (this.destroyed) return;
const ctx = foregroundCtx;
// Конвертация логических координат в физические
const px = GAME_FIELD_X + this.x * GAME_SCALE;
const py = GAME_FIELD_Y + this.y * GAME_SCALE;
const size = TANK_SIZE * GAME_SCALE;
// Цвет танка в зависимости от типа, пока всего два, потом будут новые типы и новые цвета
// Основной квадрат танка
ctx.fillStyle = this.type === TANK_PLAYER ? COLOR_TANK_PLAYER : COLOR_TANK_ENEMY;
ctx.fillRect(px, py, size, size);
// Индикатор направления (треугольник)
this.renderDirectionIndicator(ctx, px, py, size); // понял, что просто квадрато недостаточно, поэтому добавил "морду"
}
Рендер танка вызывается в методе основного рендера после очистки всего динамического слоя
// Foreground canvas — очищаем и рисуем сущности каждый кадр
clearForeground();
// Рендер танка игрока
if (player) {
player.render();
}
// TODO: Здесь будет рендер врагов, пуль, эффектов
Результат всего этого безобразия
Танк ездит сквозь стены, потому что "физического" движка ещё нет. Проверка коллизий как раз на очереди.
От имени своего журнала @VariusSoft выкладывал посты про то, как тетрис запилить и сейчас серия постов о том как воссоздать танчики. В процессе разработки осознал, что в них надо играть вдвоём, а как же это делать, если сесть за одну денди и взять в руки два джойстика нет возможности?
Ну, онлайн, чё тупить. Но как организовать онлайн? В общем покумекал я немного и решил, что для этого необходим сервер. Но писать полноценный сервер для игры в танчики – это, как будто, прям из пушки по воробьям. Поэтому я решил написать универсальный сервер, который одновременно может обслуживать несколько игр, каждая на пару-тройку десятков одновременных игроков.
Выглядит это безобразие так:
Да, вот такая скукотища, просто буковки
Написан он на Шарпе и скомпилирован под 8 дот нет, чтоб была возможность запустить и на маках и на линях. Но ещё, для пущей уверенности, завернул к херам в докер это безобразие.
Каков же алгоритм работы?
Первый игрок, который хочет с кем-то поиграть, нажимает кнопочку «Создать комнату» в интерфейсе игры, игры стучится на сервер и получает идентификатор комнаты. Дальше и эта игра и другие клиенты подключаются к этой же комнате. Первый клиент выступает в роли хоста. То есть вся обработка геймплея и игрового мира происходит на нём. Как это всегда было в КС, Халфе и прочих сессионных зарубах.
Также написал SDK на JS для встраивания работы с сервером в любую HTML5 игру.
Математически при хорошем канале данный сервер способен вытянуть одну игру на 400-500 человек или 10 игр по 40. Как на практике будет работать, посмотрим. Хотя, сэмулировать нагрузку в 500 человек я даже если очень хочу, не смогу)
Но! Если вы вдруг увлекаетесь игронаписанием, я могу вам выдать сервак на растерзание, ну или просто для своей игры себе чтоб подняли и использовали.
И да, я уже предвижу комментарии из цикла: «Нахер изобретать велосипед», но мне насрать, серьёзно. Я хотел написать, я написал.
Следующим этапом сделаем систему отображения тайлов на игровом поле. Мы предполагаем, что у нас есть тайлы разных типов: кирпичи там, бетон, вода, лёд, кусты.
И чтобы эти самые блоки рисовать на игровом поле, нужно ввести сущность карты и самих блоков.
Сущность карты:
class GameMap {
constructor() {
// Сетка 26x26 субтайлов
this.tileGrid = [];
for (let y = 0; y < GRID_SIZE; y++) {
this.tileGrid[y] = new Array(GRID_SIZE).fill(TILE_EMPTY);
}
// Карта для хранения состояния повреждений разрушаемых тайлов
this.damageMask = new Map();
// Флаг необходимости перерисовки фона
this.dirty = true;
}
}
Ну и в нашем основном классе надо карту создать:
/** @type {GameMap} */
let gameMap = null;
...
// в методе init
// Создаём и загружаем карту
gameMap = new GameMap();
gameMap.loadLevel(TEST_LEVEL_1);
// Рисуем фон (карта + рамка)
renderBackground(gameMap);
gameMap.dirty = false;
Не знаю, пока, на сколько такой формат хранения данных корректен и будет удобен в использовании в дальнейшем, но сейчас он по крайне мере очень нагляден и удобен для ручного левел дизайна.
Рисование блоков достаточно просто:
export function renderBackground(gameMap) {
const ctx = backgroundCtx;
// Очищаем весь фон
clearBackground();
// Рамка игрового поля
renderGameFieldBorder();
// Рисуем все непустые тайлы
for (let y = 0; y < GRID_SIZE; y++) {
for (let x = 0; x < GRID_SIZE; x++) {
const tileId = gameMap.getTile(x, y);
if (tileId === TILE_EMPTY) continue;
const tileDef = TILE_DEFS[tileId];
if (!tileDef) continue;
// Логические координаты → физические координаты на canvas
const px = GAME_FIELD_X + x * TILE_SIZE * GAME_SCALE;
const py = GAME_FIELD_Y + y * TILE_SIZE * GAME_SCALE;
const size = TILE_SIZE * GAME_SCALE;
ctx.fillStyle = tileDef.color; // цвет в самих тайлах пока храню
ctx.fillRect(px, py, size, size); // потом буду спрайты рисовать
}
}
}
Хранятся тайлы у меня вот таким образом:
TILE_DEFS = {
[TILE_EMPTY]: {
id: TILE_EMPTY,
name: 'empty',
blocksTank: false,
blocksBullet: false,
destructible: false,
overlay: false,
color: COLOR_EMPTY // эта вся фигня в константах забита
},
...
У сущности карты есть метод для загрузки из вот того текстового бреда, который чуть выше скинут
loadLevel(levelData) {
for (let y = 0; y < GRID_SIZE; y++) {
for (let x = 0; x < GRID_SIZE; x++) {
const char = levelData[y]?.[x] || '.';
this.tileGrid[y][x] = charToTile(char);
}
}
// Инициализируем damageMask для всех разрушаемых тайлов
this.damageMask.clear();
for (let y = 0; y < GRID_SIZE; y++) {
for (let x = 0; x < GRID_SIZE; x++) {
const tileDef = TILE_DEFS[this.tileGrid[y][x]];
if (tileDef && tileDef.destructible) {
this.damageMask.set(`${x},${y}`, DAMAGE_FULL);
}
}
}
this.dirty = true;
console.log(`Level loaded. Destructible tiles: ${this.damageMask.size}`);
}
function charToTile(char) {
switch (char) {
case 'B': return TILE_BRICK;
case 'S': return TILE_STEEL;
case 'W': return TILE_WATER;
case 'F': return TILE_FOREST;
case 'I': return TILE_ICE;
case 'E': return TILE_BASE;
default: return TILE_EMPTY;
}
}
И вот такой вот получается итог
В целом, уже похоже на правду и с этим можно работать.
После прошлого поста я что-то так проникся происходящим, что решил воссоздать ещё одну любовь детства. Но как говорил Маркус Персон: «Если вы не можете написать свой движок, то гавно вы, а не разработчики». Там, скорее всего, было как-о иначе, но суть такая. И да, с этой мыслью я в корне не согласен, но написать свой движок – задача, как минимум, интересная. Я решил, что аркадная игра для этого подходит, как ничто другое. По сути, некий аналог движка уже был реализован в тетрисе, но здесь нужна будет и какая-никакая физическая модель, и интеллект врагов и рендер всего этого безобразия в несколько слоёв и с ФПС побольше, чем 2 :)
Так что, приступим. Что нам нужно:
Просчёт физики
Работа ИИ агентов
Считывание действий игрока
Отрисовка результата работы предыдущих пунктов.
Для одного поста такая задачка звучит жирновато, поэтому, видимо, будет серия.
Я решил разделить рисования окружения (статичных объектов), врагов и игроков (динамических объектов) и UI на три разных канваса, которые просто повещены один поверх другого. Там можно будет проще и меньше перерисовывать.
Начинается наш код с проверки, готова ли страница к явлению миру нашего движка.
Если готова, то давай же скорее всё проинициализируем
Тут всё просто:
function init() {
console.log('Battle City Remake - Initializing...');
// Инициализация рендерера
initRenderer();
// Очищаем все слои
clearBackground();
clearForeground();
clearUI();
// Рисуем границу игрового поля и отладочную сетку
renderGameFieldBorder();
renderDebugGrid();
console.log('Initialization complete. Starting game loop...');
// Запускаем game loop
requestAnimationFrame(gameLoop);
}
Но понятное дело, что кода тут мало, потому что всё вынесено в отдельные методы.
Есть ли среди них хоть что-то интересное?
Ну, renderDebugGrid и renderGameFieldBorder одним названием уже говорят, что там происходит.
Логично, что самое интересное происходит где-то тут requestAnimationFrame(gameLoop);
Но давайте сначала заглянем в инициализацию рендера
Метод update содержит (ну, будет содержать) всю нашу игровую логику. Пока он только считает фпс и выводит его на экран для отладки.
function update(dt) {
// Пока пусто - здесь будет логика игры
// Подсчёт FPS для отладки
frameCount++;
fpsTimer += dt;
if (fpsTimer >= 1000) {
fps = frameCount;
frameCount = 0;
fpsTimer = 0;
// Обновляем FPS на странице
const fpsElement = document.getElementById('fps');
if (fpsElement) {
fpsElement.textContent = fps;
}
}
}
Ну и когда всё закончено, отрисовываем
function render() {
// Background canvas - перерисовывается только при изменении карты
// (пока только отладочная сетка, нарисованная в init)
// Foreground canvas - очищаем и рисуем сущности каждый кадр
clearForeground();
// TODO: Здесь будет рендер танков, пуль, эффектов
// UI canvas - очищаем и рисуем UI каждый кадр
clearUI();
renderUI();
}
То есть, по сути, ничего интересного, просто рисую полосочки и квадратики для UI
Результат выглядит вот так
Следующий пост будет посвящён второму этапу
И да, я упоролся и расписал себе полноценный план реализации на 13 этапов. Без ДизДока последнее время вообще не представляю как работать. А раз уж я в команде один, то сам себе и ТЗ пишу... Но лучше так, чем лепить полную отсебятину.
Вопросы замечания предложения в комментариях жду с нетерпением)
Вчера я остро ощутил, что очень давно не писал ничего простого, но функционального и интересного. А ещё я тут всё про игры да про игры, поэтому решил: пора!
Последнее, что я писал подобного – это змейку, которую можно было наблюдать во время обновлений сайта раньше (помните её?).
Да и ту на половину, а то и на две трети написал за меня чатГПТ. Поэтому я решил, что возьму нового подопытного и реализую всё сам. Мозг не должен забывать, как делать штуки...
В качестве основы решил взять HTML5 и ванильный JS, как технологию с самым дешёвым вариантом рендера, да и повторить, при желании, этот же алгоритм можно на любом другом движке.
Начнём с простого: нам понадобится текстовый редактор любой. Хорошо, если с подсветкой синтаксиса. Для самого простого можно взять Notepad++, для случаев чуть серьёзнее Атом или ВС код.
Создаём html страничку, она будет нашей точкой входа, рядом с ней создаём файлик стилей и основной наш скриптец. Забегая вперёд: стили нам не то, чтобы прям сильно нужны.
Дожидаемся, пока вся страница загрузится, потом инициируем игру
Пока что к самому алгоритму игры мы не приступили, это всё предварительные ласки.
В первую очередь давайте посмотрим на класс GameField.
class GameField {
constructor(width, height) {
this.width = width;
this.height = height;
this.blocks = [];
for (let i = 0; i < this.height; i++) {
let line = [];
for (let j = 0; j < this.width; j++) {
line.push(0);
}
this.blocks.push(line); //Заполняем массив ячеек поля нулями. поле по умолчанию пустое
}
}
checkLines = async () => { // тут мы проверяем, а есть ли линии, которые надо сбросить
for (let y = this.height - 1; y >= 0; y--) {
if (this.currentLineIsFill(this.blocks[y])) {
currentScore++; //плюсуем очки
for (let x = 0; x < this.width; x++) {
this.blocks[y][x] = 0;
redraw();
await sleep(LINE_CLEAR_ANIMATION_DELAY); // это просто для красивой анимации исчезновения
}
}
}
currentScore = getComboPoints(currentScore); // этот метод отдельно, там считаем прирос за комбо
}
currentLineIsEmpty = (line) => {
for (let x = 0; x < line.length; x++) {
if (line[x] === 1) { // если хоть одна ячейка заполнена, то идёт нахер
return false;
}
}
return true;
};
currentLineIsFill = (line) => {
for (let x = 0; x < line.length; x++) {
if (line[x] === 0) { // если хоть одна ячейка пустая, строка идёт нахер
return false;
}
}
return true;
};
moveLines = () => { // сдвигаем после удаления
let notEmptyLines = [];
for (let y = 0; y < this.blocks.length; y++) {
if (!this.currentLineIsEmpty(this.blocks[y])) {
notEmptyLines.push([...this.blocks[y]]); // сначала заполняем массив непустымы строками
}
}
let emptyLine = [];
for (let x = 0; x < this.width; x++) {
emptyLine.push(0); // потом досоздаём массив пустых строк
}
let newLines = []; // запиххиваем их в новый массив
for (let x = 0; x < this.height - notEmptyLines.length; x++) {
newLines.push([...emptyLine]);
}
for (let x = 0; x < notEmptyLines.length; x++) {
newLines.push([...notEmptyLines[x]]);
}
this.blocks = newLines;
}
}
Когда же мы будем всё это дёргать? И почему?
В методе, который описывает игровой цикл. Я постарался программировать так, чтоб даже при современном подходе можно было бы реализовать близкий по сути алгоритм на реальном камне, где, как известно многопоточность лишь выдуманная.
Раз в какое-то количество миллисекунд мы вызываем геймЛуп. «Почему же не сетинтервал», – спросит неокрепших неофит. А всё по тому, что это чревато как раз внезапной неконтролируемой асинхронностью, которая приведёт к гонке за ресурсы и будут беды. Поэтому метод будет вызывать сам себя только после того, как точно закончит.
const gameLoop = async () => {
if (gameMode !== GameMode.PLAYING || isLoopRunning) {
return;
}
isLoopRunning = true;
if (currentFigure === null || typeof currentFigure === 'undefined') { // у нас нет фигуры? Так давай её сделаем из того, что стоит в очереди
currentFigure = new Figure();
currentFigure.cells = nextFigure.cells.map(row => [...row]);
currentFigure.type = nextFigure.type;
currentFigure.states = nextFigure.states.map(state => state.map(row => [...row]));
currentFigure.rotationState = nextFigure.rotationState;
currentFigure.setStartPosition();
nextFigure.fillRandom(); // следующую фигуру херакнули в какую-нибудь новую рандомную
redrawNextFigure();
}
currentFigure.moveDown(); //уронили я одну клеточку
if (currentFigure.checkCollision()) {//пересеклисьс чем-нибудь?
currentFigure.moveUp(); //подняли обратно и зафиксировали с мировом пространстве
const overflow = currentFigure.placeToField();
currentFigure = null;
if (overflow) {
finishGame('top_out');
redraw();
isLoopRunning = false;
return;
}
await field.checkLines(); // все фигуры на своих местах, можно проверить, как там у нас дела
score += currentScore;
scoreEl.innerText = score;
currentScore = 0;
field.moveLines(); // сдвинем, если надо сдвинуть
}
redraw();
isLoopRunning = false;
if (gameMode === GameMode.PLAYING) {
tickTimeout = setTimeout(gameLoop, getCurrentTickDelay()); // снова запускаем всё сначала
}
};
В целом, осталось только понять, что же такое фигура.
Сама геморная часть. Там больше всего буковок.
Фигура - это сущность, в которой хранится информация о том, какой она формы и как её крутить.
class Figure {
constructor() {
this.cells = [
[0],
];
this.x = 2;
this.y = -5;
this.rotationState = 0;
}
moveDown = () => {
this.y++;
};
moveUp = () => {
this.y--;
};
moveLeft = () => {
this.x--;
if (this.checkCollision()) {
this.x++;
}
}
moveRight = () => {
this.x++;
if (this.checkCollision()) {
this.x--;
}
}
fall = () => {
while (!this.checkCollision()) {
this.y++;
}
this.moveUp();
};
checkCollision = () => {
for (let i = 0; i < this.cells.length; i++) {
for (let j = 0; j < this.cells[i].length; j++) {
let cellX = j + this.x;
let cellY = i + this.y;
if (this.cells[i][j] === 0) {
continue;
}
// Проверяем границы по X всегда (независимо от Y)
if (cellX < 0 || cellX >= field.width) {
return true;
}
// Для клеток выше видимой области не проверяем коллизии с полем
if (cellY < 0) {
continue;
}
// Проверяем нижнюю границу и коллизии с заполненными клетками
if (cellY >= field.height) {
return true;
}
if (field.blocks[cellY][cellX] === 1) {
return true;
}
}
}
return false;
};
rotate(withCollisions = true) {
const from = this.rotationState;
const to = (from + 1) % this.states.length;
const key = `${from}>${to}`;
const kickSet = (this.type === "I")
? SRS_KICKS.I[key]
: (this.type === "O" ? SRS_KICKS.O[key] : SRS_KICKS.JLSTZ[key]);
const originalX = this.x;
const originalY = this.y;
let rotated = this.states[to];
if (!withCollisions) {
this.cells = rotated;
this.x = originalX;
this.y = originalY;
this.rotationState = to;
return;
}
for (const [dx, dy] of kickSet) {
this.cells = rotated;
this.x = originalX + dx;
this.y = originalY - dy;
if (!this.checkCollision()) {
this.rotationState = to;
return;
}
}
this.cells = this.states[from];
this.x = originalX;
this.y = originalY;
}
placeToField = () => {
let overflow = false;
for (let i = 0; i < this.cells.length; i++) {
for (let j = 0; j < this.cells[i].length; j++) {
let cellX = j + this.x;
let cellY = i + this.y;
if (this.cells[i][j] === 0) {
continue;
}
if (cellY < 0) {
overflow = true;
continue;
}
if (cellY >= field.height || cellX < 0 || cellX >= field.width) {
overflow = true;
continue;
}
field.blocks[cellY][cellX] = this.cells[i][j];
}
}
return overflow;
};
setStartPosition = () => {
this.x = Math.floor(field.width / 2) - Math.floor(this.cells[0].length / 2);
if (this.type === "I" && this.rotationState === 1) {
this.y = -2;
} else if (this.type === "I") {
this.y = -4;
} else if (this.type === "O") {
this.y = -2;
} else if (this.rotationState === 3) {
this.y = -2;
} else {
this.y = -3;
}
};
fillRandom = () => {
let figure = FIGURES[Math.floor(Math.random() * FIGURES.length)];
this.cells = figure.figure.states[0].map(row => [...row]);
this.states = figure.figure.states.map(state => state.map(row => [...row]));
this.type = figure.type;
this.rotationState = 0;
let rotateSteps = Math.floor(Math.random() * 4);
for (let i = 0; i < rotateSteps; i++) {
this.rotate(false);
}
};
}
Тут, конечно, кода дофига и надо объяснить, что тут происходит. Суть в том, что поворот в тетрисе – это прям отдельная задачка. Я её для себя упростил максимально, создав «спрайты» фигур во всех положениях заранее.
А ещё есть такая штука как SRS – это прям общепринятый стандарт вращения фигур. Специальные таблицы описывают как необходимо проверять смещение фигур в пространстве игрового поля при переходе из одного состояния в другое, на случай столкновения со стенами или существующими блоками в момент вращения. Я, опять-таки, эту часть упростил максимально, вырезав очень много из стандарта, так как у меня, как минимум, нет вращение против часовой стрелки.
Логика такая: после поворота к фигуре применяются смещения по иксу и игрику по очереди из массива, сначала 0-0 (не смещается), потом, к примеру 0-1 и так далее. За идеальное состояние, которое в данный момент всех устраивает, применяется то, после которого проверка коллизии фигуры показывает, что никто ни с кем не столкнулся. Если ни один из вариантов не подошёл, значит поворот не случился.
Классические таблицы подразумевают матрицы фигур одинакового размера и квадратные по своей сути. Я тут тоже отошёл от стандарта,
Как это в итоге играется
По коду там есть у меня есть усложнение с увеличением скорости падения фигур за каждые 30 полученных очков, сохранение рекорда и прочая мишура, которая к основному алгоритму отношения уже не имеет.
Как грицца: понятно, что нифига не понятно, так что спрашивайте, господа и дамы, отвечу на недостающие вопросы)
Не далее как вчера товарищ @ZenitTTLMir1B писал о сложности загрузке видео при нестабильном соединении.
Я ночью всобачил на хостинг поддержку дозагрузки и отслеживания наличия сети.
Вот это видео было залито с принудительным разрывом соединения, переключением между сетями в процессе. Эксперимент считаю удавшимся, но если у вас возникнуть проблемы, дайте знать, будем чинить!
Если вы помните, некоторое время я писал о том, что решил запилить свою веб-версию игры "Алиас" при помощи нейросетей. После предыдущего поста, я доработал игру: добавил функционал присоединения по ссылке, возможность изменять ник в игровой комнате и зрителей как тип участников, сделал возможность менять настройки игры, после окончания сессии её можно перезапустить, ну и, конечно же, пополнил словарь слов (сейчас в нём более 1000 слов). В настоящий момент я не добавил динамические команды (больше двух), так как для этого придётся серьёзно переписывать всю логику игры и не настроил правильную передачу лидерства внутри команды. Однако, эксперимент, могу точно сказать, удался: я успешно создал вполне работающую игру с расширенным функционалом практически полностью полагаясь на нейросети. Да, конечно же, я принимал решения по логике игры и её архитектуре, давал комментарии относительно принимаемых ИИ решений (если видел, что он делает не то, что мне надо) и где-то даже вручную подправлял небольшие детали кода, но в целом это "нейроигра" и "нейрокод".
Сегодня, помимо хвастовства, я бы хотел поделиться с вами своим небольшим опытом. Я считаю, что программирование с помощью ИИ - это уже вполне обыденная реальность. Он гораздо лучше разбирает код, чем человек (по крайней мере, человек с такими навыками, как у меня), знает множество методов решения проблемы от примитивного говнокода (если сам об этом попросишь) до довольно технологичных решений. Как я писал в прошлый раз, знание кода всё же сильно приветствуется - просто для того, чтобы понимать что тебе предлагает нейросеть, особенно если ты вносишь какие-то правки с её помощью, но виртуозно владеть кодингом уже не нужно.
Теперь относительно доступных решений. Я уже ссылался на такой инструмент, как openrouter.ai - интерфейс, позволяющий работать сразу с несколькими ИИ, однако в последнее время я не очень им доволен. Появились ошибки, связанные с историей общения (периодически ИИ перестаёт понимать ход действий и начинает как бы с нуля), некоторые нейросети оказываются недоступными, хотя работают в их родном интерфейсе и т.п. В общем, пришлось временно от него отказаться и перейти к Qwen. Он показался мне более надёжным, чем DeepSeek - меньше ошибается, стабильнее работает. В его интерфейсе есть один неприятный недостаток: для вопроса нельзя загружать файлы, отличные от txt. То есть, все файлы js, php, sql приходится предварительно переименовывать. Openrouter мне в своё время понравился ещё и тем, что в нём такой проблемы нет. Бесплатная версия Qwen обрабатывает довольно большой (для моих задач) объём информации, хотя и в рамках кодинга отвечает с ощутимой задержкой. Также мне нравится Grok, но он без VPN недоступен, а сам VPN я могу подключить далеко не всегда.
Как именно общаться с нейросетью? На самом деле, вопрос без точного ответа. ИИ хорошо понимает семантику запроса, умеет оперировать синонимами и образами. Например, у меня есть иконка короны для отображения статуса лидера - это текстовый символ, но если я называю его "короной", Qwen понимает о чём речь. Если вместо "лидер" (от leader в коде) я напишу "ведущий", она также распознает какую роль я имею в виду. В то же время, нужно очень аккуратно строить запрос: если пытаться разжёвывать сети задачу как человеку, ты скорее запутаешь её, но в то же время примеры логики могут оказаться полезными.
Важный момент: при появлении ошибок, ты можешь описать ему эти ошибки простым языком - что не так в твоём понимании. Да, это не отменяет необходимости скидывать логи, ошибки из консоли, иногда сильно помогает указание на статусы в базе данных - всё это нужно ИИ также, как и человеку-программисту для лучшего понимания ситуации. Также возможна ситуация, когда нейросеть заходит в тупик и не может решить задачу, гоняя один код по кругу - особенно этим раньше страдала DeepSeek. В таком случае приходится самому принимать решение: просить добавить дополнительную отладку, внимательно следить за исполнением логики кода и т.п. И ещё одно наблюдение: утром, часов до 12 по МСК нейросети загружены заметно меньше, потом, видимо, просыпаются китайцы и время ответа сильно увеличивается.
И тут вы, наверное, спросите: неужто всё так идеально? На самом деле, нет. Помните, я написал, что так и не победил проблему с передачей лидерства? Над ней мы с ИИ трудимся уже несколько дней и я так и не могу добиться получения корректного работающего кода. Отчасти это связано с особенностями архитектуры кода - как раз в силу того, что я хотел получить более-менее понятные самому себе скрипты. Отчасти с тем, что это решение требует достаточно комплексной правки, которую за раз нейросеть осилить не может, а при нескольких итерациях она начинает путаться и вся игра в итоге ломается.
Если бы я занимался таким своеобразным кодингом более плотно, то, конечно же, взял бы платную версию - для неё меньше время ожидания в очереди, больше объём запроса и ответа, в некоторых случаях более актуальные базы. Вероятно, и баг с лидерами я бы тогда уже решил. Однако простые задачи можно решать и так. Это довольно интересное занятие, к тому же какой-никакой опыт я всё же получаю даже при таком подходе, ведь у сети всегда можно попросить объяснить конкретное решение.
P.S. Кстати, смену лидеров починил в итоге, так что игра вполне рабочая на данный момент.
Был у меня раньше курс видео по этой теме. В целом оно всё ещё актуально, но я решил чуть обновить некоторые моменты и скомпоновать в виде текста, так как не у всех есть возможность смотреть видео.
План будет следующим:
Препродакш
Прототипирование
Детализация
Оптимизация
Ландшафт
Точки интереса
Свет и цвет
Звук
Всё постараюсь сделать с актуальными на сегодняшних день скринами.
Если есть вопросы, предложения, пожелания, пишите. Если чувствуете, что в моём списке не хватает каких-то важных тем, тоже не забудьте мне об этом сказать.
В последнее время у нас стало слишком много нейросетей, однако причины понятны: их алгоритмы становятся всё более прогрессивными, а функционал доступным. Нет никакого смысла не пользоваться достижениями технологий, особенно если они так просты, да ещё и во многих случаях бесплатны. Помимо вездесущих картинок, большой интерес для меня представляют текстовые сети в части написания кода. Уже достаточно долгое время я скармливаю различные задачи по доработке своего сайта Дипсику и Квену (они оба имеют бесплатную версию и доступны без VPN), но в этот раз решил попробовать что-то большое.
Если вы помните, некоторое время назад я закидывал удочку насчёт совместных сессий в умные игры, в частности Алиас. В принципе, сайт с таким функционалом уже есть, но мне неудержимо захотелось получить собственную версию, не завязанную на чужой хостинг и с тем функционалом, какой хочу лично я. При этом, если PHP я хотя бы немного знаю, а в JS фрагментарно ориентируюсь, в остальных веб-языках вообще ноль. Поэтому задачей было сделать игру максимально простым способом: чтобы я мог хотя бы примерно понимать структуру проекта и в случае чего указывать нейросети где искать ошибки. В итоге, вооружившись openrouter (он позволяет через единый интерфейс давать задачи сразу разным сетям), я приступил к проектированию.
Для "Алиаса" по сути чего-то особо сложного и ненужно: игра просто должна выводить слова на экран для ведущего, а его команда угадывает их. Но команды меняются и ведущие в командах тоже меняются - это первое условие. Время на объяснение нужно ограничить - это вторая деталь. Угаданные слова нужно считать и суммировать очки за них - три. Наконец, 20 очков и более - это победа. Кажется, всё просто, однако далее начинаются хитрые детали.
В базе нас должна быть возможность пропустить слово и перейти к следующему - тут я решил извратиться и сделать две кнопки, чтобы ведущий мог сразу отмечать верно ли угадано слово или пропустить его. Кроме того, логично, что у других игроков должна быть возможность не просто видеть историю слов, но и отмечать их "угадывание" в обратное направление - на случай если ведущий ошибся кнопкой или нарушил правила. Система при этом должна работать параллельно, поэтому над структурой базы данных для хранения пришлось подумать самостоятельно. Я решил использовать две основные таблицы: комнаты и история слов. В таблице комнат содержится основная информация - список участников, количество очков, статус. В словах - собственно, все слова за игровую сессию. Хранить их все до окончания игры (а не только за отдельный раунд) нужно ещё и для того, чтобы контролировать неповторяемость слов.
В ряде моментов нейросеть, помимо обычных багов (вроде аварийной остановки при написании ответа), просто начинала тупить и раз за разом выдавала ошибочное решение. Приходилось самому пытаться понять, что может быть не так, допиливать структуру базы и прямо указывать на возможные пути решения. Благо, ИИ хорошо справляется, если скармливать ему логи ошибок - тогда он сопоставляет их с кодом и находит решение. Также очевидно, что моих навыков архитектора не хватает: определённые вещи нужно планировать сразу, чтобы потом не переписывать большие куски кода, как это, например, было с функцией выбора команды (изначально игра автоматически распределяла игроков). Долго пришлось посидеть над функцией выявления победителя, ведь важно не просто автоматически завершать игру как только одна команда достигнет 20 очков: нужно дать возможность завершить раунд обеим командам (в любом случае, если 20 и более набирает первая, у второй должен быть шанс отыграть свой). Сейчас игра определяет победителя после полного круга и даже умеет учитывать ничью.
Попутно была сделана система автоочистки базы данных при выходе из комнаты всех игроков, добавлена проверка готовности игроков перед началом игры, добавлена отметка с указанием кто в текущем раунде ведущий. С последней есть известный баг: отметка прыгает внутри команды - где-то дублируется функция, хотя сама смена игроков работает корректно. Ну и базу слов постарался наработать, хотя, думаю, она должна быть раза в два больше, чем сейчас, как минимум.
В итоге с перерывами на работу на создание работающего прототипа с большинством хотелок ушло около 3 дней. Результат вот он: https://alias.onlyfox.ru/alias.html
Есть в текущем "билде" места, которые нужно допилить. Пока что не реализована возможность подключаться к уже идущей игре, нужно предусмотреть создание больше трёх команд, дать возможность настраивать параметры комнаты (время на ответ, количество победных очков и т.п.). Но опыт в любом случае интересный: и в плане построения логики игры, и в плане работы с нейросетями. Я использовал их бесплатные версии с ограничением на длину ответа и количеством обрабатываемой информации, поэтому задания нужно выдавать кусками, а это в свою очередь означает, что сразу всё не напишешь, но думаю, в платной версии тоже всю игру за раз сеть не выдаст. В любом случае, результат есть и, что самое главное, на его примере понятно, что уже сейчас ИИ способна реализовывать хотелки даже таких профанов в коддинге, как я.
Сильно "ретро" покрас, не для меня. Я б дизайн попробовал обновить, "поиграл бы со цветами" ) с оттенками. Хм. Надо закинуть в нейронку будет.
Раскатай их, чтоб не это самое)
Вставляй того, кто нравится