Рубрики
IT - разное

Эксперименты с локальной LLM Gemma 4

На днях появилась новая версия открытой LLM от Google — Gemma 4. Как только прочитал в новостях об этом, решил попробовать, что у них получилось на этот раз. Дело в том, что в третьей версии моделька получилась очень хорошей с оптимальным соотношением размера и качества для локального запуска. Я ее пробовал как для разных своих нужд, так и для одного тестирования по материалам чужой статьи на Хабре — Эксперименты с локальной LLM Gemma 3.

Сегодня хотел провести (и провел) два эксперимента, но в итоге, под впечатлениями, решил описать только второй из них. Как раз повторение эксперимента из статьи о Gemma 3 с созданием игры.

Итак, напомню предысторию. Человек решил создать простую игру на html + css + Javascript через один промпт. Через один, разумеется, не получилось тогда ни у него, ни у меня. Но я тогда, в силу тормознутости модели и постоянных ошибок в предлагаемом ею коде до рабочего состояния игру довести так и не смог, потратив пару часов времени и сделав с десяток итераций исправлений кода. То есть код модель написала, но очень долго (почти полчаса времени) и с кучей ошибок или недоработок. Посмотрим, что нам предложит новая версия модели сегодня.

Эксперимент. Создаем игру

Стартовые условия. Для запуска локальной модели использую простую программу LM Studio, на этот раз версии 0.4.7. Модель — gemma-4-26B-A4B-it-Q4_K_M. Приблизительно того же размера, того же квантования. Отличие лишь в том, что в 4 версии при работе модели используется всего 4 миллиарда параметров, вместо всех 26. Судя по всему, именно это крайне положительно повлияло на скорости работы модели. Но нам важнее качество, а не скорость. Хотя, одно другому не мешает.

Тот же самый промпт, что и в прошлый раз, слово в слово:

Промпт

Напишите HTML-код для игры в Lines.
Суть игры состоит в следующем.
На игровом поле 10 на 10 клеток в случайной клетке появляется первый шарик круглой формы одного из 5 случайных цветов.
Игрок мышкой кликает на этот шарик, выбирая его.
При выборе шарика его яркость и размер плавно циклически изменялись по синусоиде с помощью таймера.
Игрок мышкой указывает свободную клетку, куда он этот шарик желает передвинуть, и шарик передвигается в эту клетку. После этого изменение яркости и размера данного шарика прекращаются.
Выбор шарика и указание клеточки для его перемещения производились только нажатием левой кнопки мыши.
Затем в случайной свободной клетке опять появляется другой шарик одного из случайных 5 цветов.
Игрок снова мышкой кликает на любой имеющийся на поле шарик, выбирая его.
И таким же образом игра продолжается дальше.
Задача игрока состоит в том, чтобы из 5 или более шариков одного цвета собрать непрерывную линию по вертикали или по горизонтали или по диагонали. В этом случае все эти 5 шариков исчезают.
Игра заканчивается, когда все клеточки заполнены шариками. Новый шарик может появиться только на свободной клеточке.
Добавь кнопку, нажав на которую начинается новая игра, а также Label, на котором считается число удаленных линий из шариков. Сделай панель, кнопку и Label более красивыми, с четкими линиями и приятными цветами.

Отправляю и жду. Ответ получился в 3477 токена, со скоростью генерации 10,37 токена в секунду, то есть занял он где-то 336 секунд. Менее 6 минут. В прошлый раз на Gemma 3 понадобилось почти 30 минут. Увеличение скорости реально впечатляет. Конечно, это все зависит от железа, но железо у меня не менялось. Так что выигрыш чисто за счет улучшения работы модели. Но это еще цветочки, ягодки были впереди.

Я получил код (здесь вставлю уже работающий код, про небольшую ошибку и исправление будет дальше):

Код
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <title>Lines Game - Стратегия</title>
    <style>
        :root {
            --bg-color: #2c3e50;
            --panel-color: #34495e;
            --text-color: #ecf0f1;
            --accent-color: #27ae60;
            --cell-size: 50px;
            --grid-border: #7f8c8d;
        }

        body {
            background-color: var(--bg-color);
            color: var(--text-color);
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            height: 100vh;
            margin: 0;
        }

        .header-panel {
            background-color: var(--panel-color);
            padding: 20px 40px;
            border-radius: 15px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.3);
            margin-bottom: 20px;
            display: flex;
            gap: 30px;
            align-items: center;
            border: 2px solid #465e75;
        }

        .score-label {
            font-size: 24px;
            font-weight: bold;
            color: var(--accent-color);
        }

        .btn-new-game {
            background-color: var(--accent-color);
            color: white;
            border: none;
            padding: 10px 25px;
            font-size: 18px;
            border-radius: 8px;
            cursor: pointer;
            transition: all 0.3s ease;
            text-transform: uppercase;
            letter-spacing: 1px;
            box-shadow: 0 4px 0 #1e8449;
        }

        .btn-new-game:hover {
            background-color: #2ecc71;
            transform: translateY(-2px);
        }

        .btn-new-game:active {
            transform: translateY(2px);
            box-shadow: 0 0 0 transparent;
        }

        #game-board {
            display: grid;
            grid-template-columns: repeat(10, var(--cell-size));
            grid-template-rows: repeat(10, var(--cell-size));
            background-color: var(--panel-color);
            border: 5px solid var(--grid-border);
            border-radius: 8px;
            box-shadow: 0 15px 35px rgba(0,0,0,0.4);
        }

        .cell {
            width: var(--cell-size);
            height: var(--cell-size);
            border: 1px solid #465e75;
            display: flex;
            align-items: center;
            justify-content: center;
            box-sizing: border-box;
        }

        .ball {
            width: 30px;
            height: 30px;
            border-radius: 50%;
            cursor: pointer;
            transition: transform 0.1s ease;
            /* Тень для объема */
            box-shadow: inset -4px -4px 8px rgba(0,0,0,0.3), 2px 2px 5px rgba(0,0,0,0.2);
        }

        .game-over {
            position: absolute;
            top: 0; left: 0; right: 0; bottom: 0;
            background: rgba(0,0,0,0.7);
            display: none;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            z-index: 10;
            border-radius: 15px;
        }

        .game-over h2 { font-size: 48px; color: #e74c3c; margin: 0; }
    </style>
</head>
<body>

    <div class="header-panel">
        <div id="score" class="score-label">Линий удалено: 0</div>
        <button class="btn-new-game" onclick="initGame()">Новая игра</button>
    </div>

    <div style="position: relative;">
        <div id="game-board"></div>
        <div id="game-over-screen" class="game-over">
            <h2>КОНЕЦ ИГРЫ</h2>
        </div>
    </div>

    <script>
        const BOARD_SIZE = 10;
        const COLORS = ['#e74c3c', '#f1c40f', '#3498db', '#9b59b6', '#2ecc71']; // Красный, Желтый, Синий, Фиолето  вый, Зеленый
        let board = []; // Массив объектов {color: string} или null
        let selectedCell = null; // {r, c}
        let score = 0;
        let animationId = null;

        const boardElement = document.getElementById('game-board');
        const scoreElement = document.getElementById('score');
        const gameOverScreen = document.getElementById('game-over-screen');

        // Инициализация игры
        function initGame() {
            cancelAnimationFrame(animationId);
            board = Array(BOARD_SIZE).fill(null).map(() => Array(BOARD_SIZE).fill(null));
            score = 0;
            selectedCell = null;
            scoreElement.innerText = `Линий удалено: 0`;
            gameOverScreen.style.display = 'none';
            
            createBoardDOM();
            spawnBall();
            animate();
        }

        // Создание DOM элементов сетки
        function createBoardDOM() {
            boardElement.innerHTML = '';
            for (let r = 0; r < BOARD_SIZE; r++) {
                for (let c = 0; c < BOARD_SIZE; c++) {
                    const cell = document.createElement('div');
                    cell.className = 'cell';
                    cell.dataset.r = r;
                    cell.dataset.c = c;
                    cell.onclick = () => handleCellClick(r, c);
                    boardElement.appendChild(cell);
                }
            }
        }

        // Появление нового шарика в случайной пустой клетке
        function spawnBall() {
            let emptyCells = [];
            for (let r = 0; r < BOARD_SIZE; r++) {
                for (let c = 0; c < BOARD_SIZE; c++) {
                    if (!board[r][c]) emptyCells.push({r, c});
                }
            }

            if (emptyCells.length === 0) {
                gameOverScreen.style.display = 'flex';
                return;
            }

            const randomPos = emptyCells[Math.floor(Math.random() * emptyCells.length)];
            const randomColor = COLORS[Math.floor(Math.random() * COLORS.length)];
            board[randomPos.r][randomPos.c] = randomColor;
            renderBalls();
        }

        // Перерисовка всех шариков на поле
        function renderBalls() {
            const cells = document.querySelectorAll('.cell');
            cells.forEach(cell => {
                const r = parseInt(cell.dataset.r);
                const c = parseInt(cell.dataset.c);
                cell.innerHTML = '';
                if (board[r][c]) {
                    const ball = document.createElement('div');
                    ball.className = 'ball';
                    ball.style.backgroundColor = board[r][c];
                    // Если клетка выбрана, добавляем класс или пометку для анимации
                    if (selectedCell && selectedCell.r === r && selectedCell.c === c) {
                        ball.dataset.selected = "true";
                    }
                    cell.appendChild(ball);
                }
            });
        }

        // Логика клика
        function handleCellClick(r, c) {
            if (board[r][c]) {
                // Выбор существующего шарика
                selectedCell = {r, c};
                renderBalls();
            } else if (selectedCell) {
                // Перемещение выбранного шарика в пустую клетку
                board[r][c] = board[selectedCell.r][selectedCell.c];
                board[selectedCell.r][selectedCell.c] = null;
                selectedCell = null;
                
                renderBalls();
                checkLines(); // Проверка на совпадение линий после хода
                
                // После хода всегда пытаемся добавить новый шарик (если есть место)
                // Но по правилам Lines, новый шар появляется только если мы закончили ход.
                // В этой версии: переместили -> проверили линии -> если линий нет, добавили шар.
                // Чтобы не перегружать поле, добавляем шар после каждого успешного хода.
                setTimeout(spawnBall, 300); 
            }
        }

        // Проверка линий (5 и более)
        function checkLines() {
            let linesFound = [];

            const directions = [
                {dr: 0, dc: 1},  // Горизонт
                {dr: 1, dc: 0},  // Вертикаль
                {dr: 1, dc: 1},  // Диагональ \
                {dr: 1, dc: -1}  // Диагональ /
            ];

            for (let r = 0; r < BOARD_SIZE; r++) {
                for (let c = 0; c < BOARD_SIZE; c++) {
                    if (!board[r][c]) continue;

                    const color = board[r][c];

                    directions.forEach(dir => {
                        let line = [{r, c}];
                        // Проверяем в обе стороны от текущей точки (чтобы не дублировать)
                        // Но для простоты проверим только "вперед" и будем считать длину всей цепочки
                        let nr = r + dir.dr;
                        let nc = c + dir.dc;

                        while (nr >= 0 && nr < BOARD_SIZE && nc >= 0 && nc < BOARD_SIZE && board[nr][nc] === color) {
                            line.push({r: nr, c: nc});
                            nr += dir.dr;
                            nc += dir.dc;
                        }

                        if (line.length >= 5) {
                            // Проверяем, не является ли эта линия частью уже найденной
                            // Сохраняем массив координат
                            linesFound.push(line);
                        }
                    });
                }
            }

            if (linesFound.length > 0) {
                // Удаляем все шарики, входящие в найденные линии
                let cellsToRemove = new Set();
                linesFound.forEach(line => {
                    line.forEach(pos => cellsToRemove.add(`${pos.r},${pos.c}`));
                });

                cellsToRemove.forEach(coord => {
                    const [tr, tc] = coord.split(',').map(Number);
                    board[tr][tc] = null;
                });

                score++;
                scoreElement.innerText = `Линий удалено: ${score}`;
                renderBalls();
                // Рекурсивно проверяем, не образовались ли новые линии после удаления
                setTimeout(checkLines, 400);
            }
        }

        // Анимация синусоиды для выбранного шарика
        function animate() {
            const time = Date.now() * 0.005; // Скорость анимации
            const sineValue = Math.sin(time); // Значение от -1 до 1

            const balls = document.querySelectorAll('.ball');
            balls.forEach(ball => {
                if (ball.dataset.selected === "true") {
                    // Изменение размера: 30px + колебание
                    const newSize = 30 + (sineValue * 5);
                    ball.style.width = `${newSize}px`;
                    ball.style.height = `${newSize}px`;
                    
                    // Изменение яркости (через фильтр brightness)
                    // 1.0 - стандарт, 1.5 - ярко
                    const brightness = 1 + (Math.abs(sineValue) * 0.5);
                    ball.style.filter = `brightness(${brightness})`;
                }
            });

            animationId = requestAnimationFrame(animate);
        }

        // Запуск при загрузке страницы
        initGame();

     </script>
</body>
</html>

### Особенности реализации:

1.  **Графика и Анимация**: 
    *   Использован `requestAnimationFrame` для плавного изменения размера (от 30 до 35 пикселей) и яркости выбранного шарика по закону синуса.
    *   Шарики имеют эффект объема (`box-shadow: inset...`), что делает их похожими на настоящие сферы.
2.  **Игровая логика**:
    *   **Выбор**: Клик по существующему шарику "подсвечивает" его (активирует анимацию).
    *   **Перемещение**: Клик по пустой клетке перемещает шар.
    *   **Поиск линий**: Алгоритм сканирует все направления (горизонталь, вертикаль и обе диагонали) на предмет цепочек длиной 5 и более одинаковых цветов.
    *   **Удаление**: Все шарики, принадлежащие найденным линиям, удаляются, а счетчик увеличивается.
3.  **Интерфейс**:
    *   Панель управления выполнена в современном стиле (темно-синие тона, четкие границы).
    *   Кнопка "Новая игра" имеет эффект нажатия и приятный зеленый цвет.
    *   При заполнении поля появляется экран "КОНЕЦ ИГРЫ".

**Как играть**:
1. Нажмите на шарик, который хотите переместить (он начнет пульсировать).
2. Нажмите на пустую клетку, куда хотите его поставить.
3. Собирайте линии из 5 или более шариков одного цвета.

Сохраняю в html файле, запускаю и получаю вот такой результат:

Симпатишно, но ничего не работает и поля для игры не видно. Как и шариков. Беру этот скриншот и скармливаю его модели, вопрошая, что ж не так то, исправляй:

Оказывается он просто вместо трех закрывающих тегов поставил </empty>. И правда, что могло пойти не так:

Дальше уже я начал тупить. Вместо того, чтобы посмотреть на исправленный код, который он мне выдал, я, как только увидел в ответе про закрывающий тег </html>, побежал его вставлять. Бегло глянул код, увидел, что кроме него он забыл и про тег </body>. Вставил и его. Запускаю. Не работает. Да как же так? Посмотрел внимательнее на структуру и увидел, что забыл он закрыть и тег скрипта — </script>. Добавляю и его и… вуаля, все работает:

Только потом уже увидел, что в ответе на вопрос по поводу исправления он мне все правильно написал, это я раньше времени побежал править. Сам дурак и сам виноват.

Выводы

Итого. Gemma 4 написала код в 4-5 раз быстрее, чем предыдущая версия. И правильно практически с первого раза. Ошибка реально малозначительная и очень заметная, можно было и самому исправить. Я бы даже сказал — описка. Но со второго ответа она сама все идеально исправила. Поэтому с двух промптов мы получили полностью работающую и симпатичную игру. Как по мне — это успех. Мощно. Результат развития за год — налицо.

PS: желающие могут посмотреть на результат здесь.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *