Бесплатный LLM делает игру на Godot

Начитавшись и насмотревшись как люди зарабатывают на яндекс играх, решил попробовать написать игру, совершенно не разбираясь в игроделываниии геймдеве. К тому же мне совершенно случайно попались видео с ютуба о том как использовать мощные LLM совершенно бесплатно. Ноль вложений.

Как я представлял себе процесс разработки: открываешь IDE и пишешь "сделай игровую сцену, бла бла бла...", и через пару часов - готовый результат. Выкладываешь на яндекс игры, люди играют, реклама показывается - ты в плюсе.

Как выяснилось позже, никто не разрабатывает игру с нуля ...за редким исключением. В основном люди покупают в Сonstruct 3 готовые игры/шаблоны, перерисовывают картинки, добавляют уровни т.д.

Тяп, ляп, и в продакшн.

Но мы пойдем тернистым путём проб и ошибок.

На распутье

Итак. Нам нужно сделать игру для яндекс игр. Что такое яндекс игры? Это web страничка на html. Ок. Можно написать игру на html. Но мне хотелось как-то визуально править игровые ресурсы, через какой-нибудь редактор. Не писать же отдельно редактор для html игры?

Есть такая штука как "движок" игры: там можно вставлять свои ресурсы, писать логику, запускать/отлаживать игру. Какие есть игровые движки, без ограничений, бесплатные, с хорошей документацией, стабильные, существующие уже продолжительное время, с возможностью экспортировать игру в html5?

Таким идеальным движком мне показался Godot, с его почти полностью переведенной документацией. Тем более что я когда-то повторял игру по этим урокам на ютуб и экспортировал на android. Даже добавился в телеграмм канал и переписывался. Сейчас там целое сообщество. Но я совершенно забросил это дело, забыл напрочь всё что делал по урокам.

Сэт Ап

Соберем так называемое окружение для разработки.

Бесплатный LLM делает игру на Godot
  1. Во первых скачаем и распакуем куда-нибудь сам Godot (Godot Engine – .NET качать не нужно т.к. насколько я понял, он не поддерживает экспорт в html5). Таким образом языком разработки у нас будет язык GDScript, чем то похожий на python.

  2. Далее устанавливаем Visual Studio Code. Эта IDE будет основным "окном в разработку" игры.

  3. Установим расширение godot-tools: заходим в File - Preferences - Extensions.

  4. Далее в Visual Studio Code установим расширение Kilo Сode: вбиваем в поиск "kilocode.Kilo-Code". Жмем Install.

    Скрытый текст

    И тут важная вещь: необходимо откатиться на определенную версию этого расширения, а именно на 4.142.0 или ниже. Нажимаем на выпадающее меню около Uninstall и выбираем версию. Дело в том, что расширение выше этой версии, сломает нам работу на ИИ Gemini. Но об этом - позже.

    Скрытый текст
  5. Устанавливаем расширение Qwen Code Companion

  6. Устанавливаем расширение Gemini CLI Companion

  7. Устанавливаем NodeJS.

  8. Запускаем командную строку cmd, далее в терминале вводим:
    npm install -g @google/gemini-cli@latest

  9. Там же в терминале вводим:
    npm install -g @qwen-code/qwen-code@latest

  10. Скачиваем и распаковываем Qdrant - он нужен Kilo Сode для работы с кодом. Можно скачать отсюда: qdrant-x86_64-pc-windows-msvc.zip.

  11. Скачиваем LM Studio, скачиваем в нём модель nomic-embed-text-v2-moe-GGUF - она нужна для общения Kilo Сode и Qdrant между собой. Настраиваем LM Studio в качестве сервера. Как настраивать LM Studio в качестве сервера ИИ моделей можно прочитать в статье Открываем RAG и интернет для LM Studio (см. "Включаем сервер моделей в LM Studio").

Важное уточнение

Пока дописывал статью, Kilo Code совсем перестал работать с Gemini CLI, даже старые версии Kilo Code 4.142.0 и ниже перестали работать. Прощай бесплатная Gemini 2.5 Pro... Остается только Qwen3-coder-plus.

Хотя в Gemini CLI можно конечно работать и без Kilo Code, напрямую, в консольной утилите от гугла.

Настройки

Запускаем Godot вручную и создаем новый проект например в папке puzzle. Т.к. мы разрабатываем с последующим экспортом в html5, выбираем "Отрисовщик: Совместимость". После чего можно закрыть Godot.

Скрытый текст

В VSCode выбираем File - Open Folder..., указываем папку где только что создали проект: puzzle.

Godot-tools

Вообще изначально я предполагал что это расширение нужно для разработки, оно позволяет правильно разрисовывать код GDScript, подсвечивает ошибки и прочее в VSCode. Но если вам не нужно вручную ковыряться - можно и не настраивать.

Настройки

Заходим в File - Preferencesd - Settings, вводим godot в поиске, вводим путь до exe файла godot в Godot tools > Editor Path: Godot 4

Затем в VSCode нажимаем F1, вводим View: Show Run and Debug, далее create a launch.json file, выбираем GDScript Godot Debug.

Создается файл puzzle\.vscode\launch.json:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "GDScript: Launch Project",
            "type": "godot",
            "request": "launch",
            "project": "${workspaceFolder}",
            "debug_collisions": false,
            "debug_paths": false,
            "debug_navigation": false,
            "additional_options": ""      
        }
    ]
}

Теперь можно нажать в правом нижнем углу Open workspace with Godot Editor, откроется Godot, и надпись с Disconnedted сменится на Connected

Qwen CLI

Для работы с бесплатной моделью нам необходимо зарегистрироваться https://chat.qwen.ai/auth?mode=register. Далее запускаем cmd, а в нем команда qwen. Выбираем 1 вариант, вас перекинет в окно где нужно войти под своей учеткой и вуаля. При этом в папке пользователя появляется файл C:\Users\user\.qwen\oauth_creds.json через который будет производится аутентификация при последующих запусках.

Скрытый текст

Gemini CLI

Необходимо зайти https://console.cloud.google.com, скопировать оттуда Project ID, и добавить его в системные или пользовательские переменные среды.

Скрытый текст

Далее запускаем в командной строке gemini, нас перекидывает на страницу авторизации, подтверждаем.

Скрытый текст

При этом в папке пользователя появляется файл C:\Users\user\.gemini\oauth_creds.json.

Kilo Code

Ну а теперь можно подключить наши модели к агенту с которым и будем далее работать.

Скрытый текст

Указываем русский язык.

Добавляем профиль для Qwen CLI

Указываем провайдер Qwen Code и файл аутентификации:

Для Gemini CLI так же создаем новое подключение и указываем настройки:

Так же для gemini я бы установил "Лимит скорости" около 7 секунд.

Зачем это нужно? Gemini при слишком частых обращениях может выдавать ошибку timeout (слишком частые запросы). Лимит скорости между запросами в 7 секунд - устраняет эту проблему.

Затем добавляем MCP серверы для того что бы ИИ агент мог обращаться к свежей документации по GDScript.

Нажимаем на "Редактировать глобальный MCP" и вставляем в файл такое содержимое:

{
   "mcpServers":{
      "context7":{
         "type":"streamable-http",
         "url":"https://mcp.context7.com/mcp",
         "headers":{
            "CONTEXT7_API_KEY":"xxx"
         },
         "alwaysAllow":[
            "query-docs",
            "resolve-library-id"
         ],
         "disabled":false,
         "timeout":15
      },
      "godot-docs":{
         "type":"streamable-http",
         "url":"https://godot-docs-mcp.j2d.workers.dev/mcp",
         "alwaysAllow":[
            "search_docs",
            "get_docs_page_for_term",
            ""
         ],
         "timeout":15,
         "disabled":false
      }
   }
}

Для context7 нужно генерировать свой токен (CONTEXT7_API_KEY) у них на сайте. Хотя можно и без него.

Затем переключаемся на главное окно Kilo Code, нажимаем на значок индексации:

Указываем настройки подключения к Qdrant и к embedding модели обитающей на нашем LM Studio, жмем "Начать" - статус станет зелёным.

Создаем игру

Итак, язык GDScript не такой уж и распространенный в мире, что бы кодовая база попала в gemini и qwen когда их обучали. Поэтому нам желательно установить некие правила, по которым эти модели будут создавать игру. Для этого у Kilo Code есть настройка - мы можем дать моделям правила в markdown разметке с которыми они должны всегда сверятся.

Скрытый текст

Добавим файл rules.md:

Как написать такое содержимое - вопрос. Я попросил GPT 5 на https://arena.ai/ сформировать этот файл. Включил туда общие правила по языку GDScript и по сценам, плюс место откуда модель может запустить игру напрямую (если сказать что-то типа "запусти проект и проанализируй логи"). Правильно это или нет - я не знаю, но вроде получилось.

rules.md
You are an expert game developer specializing in **Godot Engine 4.4** (2D) on **Windows**, with strong knowledge of **typed GDScript**, scalable game architecture, debugging, and performance optimization.

## 0) Version Lock + Documentation Sources (Godot 4.4)
- Target **Godot 4.4** APIs and behavior. Do **not** use Godot 3.x patterns.
- Primary source of truth: official Godot **4.4** manual + class reference: `https://docs.godotengine.org/en/4.4/`
- Always verify:
  - Node/class names,
  - property names,
  - signal names,
  - method signatures,
  - and lifecycle callbacks
  against the **4.4** docs.
- If you must use **stable/latest** docs as a fallback, explicitly say so and re-check compatibility with 4.4.

---

## 1) Core Mission
- Give **correct, production-ready** guidance for Godot 4.4 **2D** development.
- Prefer **Godot built-ins** (Nodes/Scenes, Signals, InputMap, Resources, Animation, Physics, UI) over custom frameworks.
- For each solution provide:
  - A short **plan**
  - **Implementation steps** (Editor + code)
  - **Complete runnable code** (or a clear patch when asked)
  - **Pitfalls** and **performance notes**

---

## 2) Writing Style (LLM-friendly)
- Be **clear, technical, and concrete**.
- Use short paragraphs, lists, and fenced code blocks.
- Avoid vague advice; show **exact node names**, **file paths**, and **Godot 4.4 APIs**.
- If multiple approaches exist: explain trade-offs and recommend one.

---

## 3) Godot 4 Architecture Rules (2D)
- Use **Scenes** as reusable prefabs (`PackedScene`) for Player, Enemy, UI widgets, projectiles, pickups.
- Prefer **composition over inheritance** (node/component-style).
- Keep scene trees shallow and readable; name nodes clearly.
- Avoid fragile parent-chains like `get_parent().get_parent()`; prefer signals, groups, or explicit references.

---

## 4) Node Selection (2D defaults)
- `CharacterBody2D` — character movement (player/enemies).
- `RigidBody2D` — physics-driven objects.
- `Area2D` — triggers, hurtboxes/hitboxes, pickups.
- `Control` — UI (don’t build UI on `Node2D`).

---

## 5) GDScript 2.0 (Godot 4.4) — MUST-KNOW LANGUAGE RULES

### 5.1 Indentation is semantic (Python-like)
- Indentation defines blocks. Wrong indentation = wrong program.
- Use **tabs** for indentation (Godot style guide).
- Prefer readable multiline formatting; avoid overly dense one-liners.

### 5.2 Naming conventions (Godot style)
- `PascalCase` — classes/types/nodes.
- `snake_case` — variables, functions, signals.
- `ALL_CAPS` — constants.
- Prefer trailing commas in multiline arrays/dicts/enums for cleaner diffs.

### 5.3 Script lifecycle (Node callbacks — use correctly)
- `_enter_tree()` — node enters the SceneTree (can happen multiple times).
- `_ready()` — node + its children are in the SceneTree; children’s `_ready()` run **before** the parent; usually called only once per node lifetime.
- `_process(delta)` — every rendered frame (variable timestep).
- `_physics_process(delta)` — fixed timestep (physics loop); use for movement/collisions.
- `_exit_tree()` — node leaves the SceneTree.

### 5.4 Input callbacks (priority matters)
- Prefer `_unhandled_input(event)` for gameplay input so UI can consume events first.
- Use `_shortcut_input(event)` for shortcuts; it runs before `_unhandled_key_input()` and `_unhandled_input()`.
- Use polling (`Input.is_action_pressed`, `Input.get_vector`) for continuous movement in `_physics_process()`.

### 5.5 Initialization order (common bug source)
Understand this order for Node-derived scripts:
1) member vars default init,
2) member var assignments top-to-bottom,
3) `_init()` runs (if defined),
4) exported values are assigned (when instancing scenes/resources),
5) `@onready` vars initialize,
6) `_ready()` runs.

### 5.6 `@onready` (defer node lookups safely)
- `@onready` defers member initialization until `_ready()`.
- Do NOT combine `@onready` with `@export` on the same variable (it causes confusing overrides and is treated as an error by default).

Good:
- `@export var speed: float = 300.0`
- `@onready var sprite: Sprite2D = $Sprite2D`

### 5.7 Typed GDScript (required)
- Use typed GDScript by default:
  - `var hp: int = 10`
  - `func take_damage(amount: int) -> void:`
- Always specify return types, including `-> void`.
- Prefer typed arrays/dicts where practical:
  - `var points: Array[Vector2] = []`
  - `var costs: Dictionary[String, int] = {"apple": 5}`
- Use `:=` for type inference only when it’s truly obvious and improves readability.

### 5.8 Properties (setters/getters) — Godot 4 behavior
- Use property syntax:

  var _ms: int = 0
  var seconds: int:
      get:
          return _ms / 1000
      set(value):
          _ms = value * 1000

- In Godot 4, `set`/`get` are called consistently even from inside the same class (with exceptions described in docs).
- Avoid accidental infinite recursion when calling helper methods inside setters/getters.

### 5.9 Signals (decoupling rule)
- Prefer signals for communication between systems (UI ↔ gameplay).
- Signals are first-class values in Godot 4 (like `Callable`).
- Prefer the recommended connection style using `Signal.connect()`:

  button.button_down.connect(_on_button_down)
  player.hit.connect(_on_player_hit.bind("sword", 100))

- Declare custom signals with `signal`, emit with `.emit(...)`.

### 5.10 `await` (coroutines by awaiting signals)
- `await` is used to wait for signals (or other awaitables).
- Canonical delay:

  await get_tree().create_timer(1.0).timeout

- `SceneTree.create_timer()` returns a `SceneTreeTimer` that emits `timeout` and is auto-freed.
- Use the `create_timer(..., process_in_physics=..., ignore_time_scale=...)` flags when you need precise timing behavior.

### 5.11 Tool scripts
- Use `@tool` at the top to run script code in the editor.
- Be careful with `queue_free()`/`free()` in tool scripts (can crash the editor).

### 5.12 Memory management (must be correct)
- `RefCounted`-based objects (including `Resource`) free automatically when unreferenced.
- `Node` is not ref-counted: free with `queue_free()` (preferred) or `free()`.

---

## 6) Exported Properties & Data
- Use `@export` for tunables in Inspector.
- Use export grouping when helpful:
  - `@export_group("Movement")`
  - `@export_subgroup("Air")`
- Prefer **Resources** for data assets, not hard-coded dictionaries.
- Use Autoload singletons sparingly and keep them thin:
  - `GameState`, `AudioManager`, `SceneLoader`, `SaveSystem`

---

## 7) Input (Godot Way)
- Use **InputMap actions** (Project Settings → Input Map).
- Prefer `StringName` literals for action names:
  - `Input.is_action_pressed(&"move_left")`
  - `Input.is_action_just_pressed(&"jump")`
- Always list required InputMap actions in setup instructions.

---

## 8) UI Rules
- Use `Control` + Containers (`VBoxContainer`, `HBoxContainer`, `MarginContainer`) for layout.
- Avoid hard-coded pixel positioning when containers can solve it.
- Prefer Themes for consistent UI styling.

---

## 9) Animation Rules
- Use `AnimationPlayer` for timelines and simple animation control.
- Use `AnimationTree` (state machine) for complex character animation logic.
- Keep animation state changes explicit and debuggable.

---

## 10) Audio Rules
- Use `AudioStreamPlayer`, `AudioStreamPlayer2D`
- Use buses for volume groups (SFX/Music/UI)
- Don’t recreate streams every time; reuse players or pool when needed.

---

## 11) Error Handling & Debugging (Windows)
- Logging: `print()`, `push_warning()`, `push_error()`, `assert()`
- Use Godot tools: Debugger, Remote Inspector, Profiler, Monitors
- When errors are likely, include:
  - symptom
  - reproduction steps
  - fix
  - how to verify

---

## 12) Performance Rules (Godot 4.x)
- Avoid per-frame allocations in hot paths.
- Pool frequently spawned nodes (bullets/VFX).
- Use collision layers/masks to reduce physics checks.
- Use `queue_free()` responsibly; avoid mass churn every frame.
- Profile first (Profiler + Monitors), then optimize.

---

## 13) Code Organization
- Keep code well-organized with meaningful names.
- Use doc comments `##` for class/module documentation and Inspector tooltips.
- Keep functions small; comment only tricky logic.

---

## 14) Output Format (Hard Requirements)
When you provide code, always include:
- **File path**, e.g. `res://player/player.gd`
- **Node tree expectations**
- **Inspector settings** (`@export` values to set)
- **Signal connections** (who emits, who listens)
- **InputMap actions** needed
- A short **"how to test"** checklist

---

## 15) Programming / Environment Specific (Windows)
- Godot executable path (current): `C:\Users\user\Downloads\godot\Godot_v4.5.1-stable_win64_console.exe`
  - This ruleset targets **Godot 4.4** docs/APIs. Avoid using features introduced after 4.4 unless you verify compatibility.
- Create project with Godot.
- Do not close Godot during debugging.
- Environment is Windows 11; cmd tools available for file/folder operations.
- For basic puzzle mechanics (image slicing + snapping), you can use the project structure at:
  `C:\Users\user\Desktop\Projects\puzzle`

Режимы ИИ агента

У Kilo Code есть несколько режимов работы.

Скрытый текст
  1. Архитектор - создает план по которому будет производить написание кода. Сам может переключиться в режим "Код".

  2. Код - здесь у модели есть права на написание кода, чем собственно она и занимается.

  3. Вопросы - тут можно задавать вопросы без изменения кода.

  4. Отладка - в этом режиме можно почти бесконечно исправлять то, что отказывается работать.

  5. Оркестратор - менеджер проекта, разбивает сложный запрос на подзадачи и распределяет их между предыдущими режимами.

Для любого режима можно выбрать модель. Обычно для Архитектора я выбираю Gemini, а для кода - Qwen.

Каждый режим можно отредактировать: поправить промпт, указать модель. По кнопке "Предпросмотр системного промпта" можно увидеть как Kilo Code внедряет в результативный промпт текст из rules.md.

Полезные вещи

Есть такая очень полезная вещь как "улучшение промпта" с учетом текущего проекта. Например вы пишете "добавь сцену с меню: уровень сложности, выход". Эта кнопка обогатит ваш запрос в более конкретный промпт с учетом текущей реализации.

Скрытый текст

Однако иногда надо вчитываться в то, как вам "улучшили" ваш запрос. Бывает что он может не правильно вас понять и вписать ненужную вам логику.

Очень удобная вещь в Kilo Code - это возможность восстановить произведенные изменения в проекте. Эта штука будет спасать вас не раз.

Скрытый текст

В начале было Слово

Как вы уже догадались, решил я "разработать" мозгами ИИ игру "Пазл". Вот прямо так ему и написал: "создай игру пазл. есть главное меню, и игровое поле с картинкой".

...спустя несколько минут часов это уже было похоже на:

Стартовый промпт

Создайте с нуля новую 2D-игру на Godot 4 под названием "Собрание Паззлов", предназначенную для Windows 11. Игра должна динамически загружать изображения из внешних источников, с первоначальной реализацией, использующей публичный API музея Метрополитен: https://collectionapi.metmuseum.org/public/collection/v1, выбирая случайный объект, имеющий доступное поле primaryImage. Стартовое меню игры должно предоставлять выбор источника изображений (по умолчанию Метрополитен) и три уровня сложности: Легкий (например, 3x3 кусочка), Средний (например, 5x5 кусочков) и Сложный (например, 7x7 кусочков), где количество кусочков увеличивается с возрастанием сложности. После нажатия кнопки "Старт" открывается основное игровое поле: в левом верхнем углу отображается полноразмерное превью выбранного изображения с его описанием (например, название, автор, дата), полученным из API; в центральной части экрана находится область, где пользователь собирает разбросанные кусочки паззла, которые должны иметь функцию перетаскивания и автоматического притягивания (снаппинга) к соседним правильным позициям; в правом верхнем углу отображается счетчик "Собрано: X" (количество корректно соединенных кусочков), и кнопка "Обновить", которая загружает новое изображение и генерирует новый паззл. Во время загрузки нового изображения должна отображаться анимированная индикация загрузки, а кнопка "Обновить" должна быть временно недоступна. Предусмотрите кнопку "Выйти" или возможность выхода по нажатию клавиши ESC; если паззл не завершен, перед выходом должно появиться диалоговое окно подтверждения "Действительно выйти?". Для реализации ��азовых механик паззла, таких как нарезка изображений и логика снаппинга, можно ориентироваться на структуру проекта по пути C:\Users\user\Documents\Jigsaw-Puzzle-2D-master\Jigsaw-Puzzle-2D-master.

Да пришлось найти где то пример пазла, который брал картинки из бесплатного публичного API музея "Метрополитен" из Нью-Йорка.

В этом примере не хватало главного - не было реализации фигурных вырезов каждого кусочка пазла. На реализацию которого у меня ушло примерно ...четыре дня. Тут уже пришлось напрячь извилины и придумать моему помощнику идею: на основе шаблона с прозрачными линиями - вырезать эти самые кусочки. Шаблон генерировал с помощью chat-gpt5 и других моделей на lmarena.ai. С генерацией тоже было много проблем. Пришлось самому править шаблоны вручную.

Шаблон

Какая же эта игра без фоновой музыки? ИИ справился с добавлением фоновой музыки достаточно быстро.

Фальшстарт

И тут я решил запилить этот шедевр на яндекс игры. Зашел в консоль, на добавлял скриншотов с описанием игры. Ну и собственно загрузил игру экспортированную из godot в html5.

Игра запустилась, но ничего не загружается. Оказывается для доступа игры к серверу музея, необходимо добавлять url в консоль и ждать когда его одобрят. Проходит день, доступ к сайту дают. Игра не может загрузить картинку для создания пазла. Смотрю в консоль - ошибки CORS.

Сделал некий CORS ретранслятор на https://dash.cloudflare.com/, так же добавил его.

Код ретранслятора, если кому интересно
export default {
  async fetch(request, env) {
    // Обработка OPTIONS запросов (CORS preflight)
    if (request.method === 'OPTIONS') {
      return new Response(null, {
        headers: {
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Methods': 'GET, OPTIONS',
          'Access-Control-Allow-Headers': '*',
          'Access-Control-Max-Age': '86400',
          'Accept-Ranges': 'bytes'
        }
      });
    }

    try {
      const url = new URL(request.url);
      
      // ПРАВИЛЬНО извлекаем параметр url с помощью URLSearchParams
      const rawUrl = request.url.split('url=')[1]; 
      var targetUrlParam = decodeURIComponent(rawUrl);      

      /* test
      return new Response(targetUrlParam, { 
        status: 200,
        headers: { 'Content-Type': 'text/plain' }
      });
      */
      
      // АЛЬТЕРНАТИВНЫЙ СПОСОБ (если URLSearchParams не сработал):
      if (!targetUrlParam) {
        // Ручной парсинг для случаев, когда параметры сложные
        const queryString = url.search.substring(1); // Убираем '?'
        const params = queryString.split('&');
        let extractedUrl = null;
        
        for (const param of params) {
          if (param.startsWith('url=')) {
            extractedUrl = decodeURIComponent(param.substring(4));
            break;
          }
        }
        
        if (!extractedUrl) {
          return new Response('No URL parameter provided', { 
            status: 400,
            headers: { 'Content-Type': 'text/plain' }
          });
        }
        
        // Если нашли URL ручным способом, используем его
        targetUrlParam = extractedUrl;
      }

      if (!targetUrlParam) {
        return new Response('No URL parameter provided', { 
          status: 400,
          headers: { 'Content-Type': 'text/plain' }
        });
      }

      // Исправляем URL с пробелами и неправильным форматированием
      let cleanedUrl = targetUrlParam.trim()
        .replace(/\s*:\s*\/\s*\//g, '://')  // Исправляем "https  ://", "http  :  //", и т.д.
        .replace(/\s+/g, '%20');            // Заменяем пробелы на %20

      // Дополнительная очистка от лишних параметров proxy
      // Убираем все после первого &, если это не часть целевого URL
      // Но сначала проверяем, есть ли в URL уже параметры (?)
      let finalUrl;
      
      try {
        // Пытаемся создать URL объект для валидации
        finalUrl = new URL(cleanedUrl);
      } catch (e) {
        // Если не удалось, пробуем дополнительные методы очистки
        if (cleanedUrl.includes('?') && cleanedUrl.includes('&')) {
          // Для URL с параметрами оставляем всё как есть
          finalUrl = cleanedUrl;
        } else {
          // Пытаемся найти начало реального URL
          const possibleProtocols = ['http://', 'https://'];
          let startIndex = -1;
          
          for (const protocol of possibleProtocols) {
            const index = cleanedUrl.toLowerCase().indexOf(protocol);
            if (index !== -1) {
              startIndex = index;
              break;
            }
          }
          
          if (startIndex !== -1) {
            finalUrl = cleanedUrl.substring(startIndex);
          } else {
            finalUrl = cleanedUrl;
          }
        }
        
        try {
          finalUrl = new URL(finalUrl);
        } catch (e2) {
          return new Response('Invalid URL format after cleaning: ' + e2.message, { 
            status: 400,
            headers: { 'Content-Type': 'text/plain' }
          });
        }
      }

      // Создаем заголовки для запроса
      const headers = new Headers();
      
      // Передаем Range заголовок если есть
      const rangeHeader = request.headers.get('Range');
      if (rangeHeader) {
        headers.set('Range', rangeHeader);
      }
      
      // Копируем важные заголовки
      const forwardedHeaders = ['User-Agent', 'Accept', 'Accept-Language', 'Referer'];
      forwardedHeaders.forEach(header => {
        const value = request.headers.get(header);
        if (value) headers.set(header, value);
      });

      // Выполняем запрос к целевому URL
      const upstreamResponse = await fetch(finalUrl.toString(), {
        headers: headers,
        redirect: 'follow'
      });

      // Создаем заголовки ответа
      const responseHeaders = new Headers(upstreamResponse.headers);
      
      // Сохраняем критические заголовки
      const preserveHeaders = ['Content-Range', 'Accept-Ranges', 'Content-Length', 'Content-Type', 'Cache-Control'];
      preserveHeaders.forEach(header => {
        const value = upstreamResponse.headers.get(header);
        if (value) responseHeaders.set(header, value);
      });

      // Добавляем CORS заголовки
      responseHeaders.set('Access-Control-Allow-Origin', '*');
      responseHeaders.set('Access-Control-Expose-Headers', '*');
      responseHeaders.set('Accept-Ranges', 'bytes');
      responseHeaders.set('Cross-Origin-Opener-Policy', 'same-origin');
      responseHeaders.set('Cross-Origin-Embedder-Policy', 'require-corp');
   

      // Обработка 206 Partial Content
      if (upstreamResponse.status === 206) {
        return new Response(upstreamResponse.body, {
          status: 206,
          statusText: 'Partial Content',
          headers: responseHeaders
        });
      }

      // Для бинарных данных используем потоковую передачу
      const contentType = (responseHeaders.get('content-type') || '').toLowerCase();
      /*
      const isBinary = contentType.startsWith('image/') || 
                       contentType.startsWith('video/') || 
                       contentType.startsWith('audio/') || 
                       contentType.includes('application/octet-stream');
      */
      return new Response(upstreamResponse.body, {
        status: upstreamResponse.status,
        headers: responseHeaders
      });

    } catch (error) {
      console.error('Proxy error:', error);
      
      if (error.name === 'TypeError' && error.message.includes('fetch')) {
        return new Response('Failed to fetch target URL. Check if the URL is valid and accessible.', {
          status: 502,
          headers: { 
            'Content-Type': 'text/plain',
            'Access-Control-Allow-Origin': '*'
          }
        });
      }
      
      return new Response('Proxy Error: ' + error.message, {
        status: 500,
        headers: { 
          'Content-Type': 'text/plain',
          'Access-Control-Allow-Origin': '*'
        }
      });
    }
  }
};

Проблему это не решило: были страшные тормоза при загрузке из игры. Хотя браузер прекрасно загружал картинки. ИИ гугла сказал что это может быть проблема с тем как godot скачивает данные: не умеет он качать сжатые куски как это делает браузер.

Появилась еще одна проблема: фоновая музыка прерывалась при работе с сетью.

Локализация

Ладно, если Магомет не идет к горе... то сделаем проще. Картинки будем хранить на яндекс диске.

Идем на https://oauth.yandex.ru/ добавляем новое "приложение" MuzeumPuzzle, запрашиваемые права - Яндекс.Диск REST API • Доступ к папке приложения на Диске.

Получаем токен: заходим через https://oauth.yandex.ru/authorize?response_type=token&client_id=xxx и сохраняем токен, этот токен нам нужен будет для работы с яндекс диском. Токен работает ровно 1 год с момента получения.

У нас есть целый полигон от яндекса для тестирования api. Можно создать например папку, и она появится по пути: https://disk.yandex.ru/client/disk/Приложения/MuzeumPuzzle.

Теперь легким движением руки закидываем в папку игры MuzeumPuzzle фото и их описание.

Сначала думал поискать какой нибудь клиент яндекс диска на js, но в итоге написал свой плагин для godot:

yandex_disk_service.gd
extends Node

const BASE_URL = "https://cloud-api.yandex.net"

# готовый токен на 1 год с доступом к папке приложения
var token: String = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

var headers = ["User-Agent: Godot-Puzzle-Game/1.0", "Accept: */*"]

# регистрация связана с появлением окна
# при этом должно быть не больше 30 токенов
# поэтому обойдемся готовым токеном на 1 год
#func auth(client_id: String) -> void:
#	var response = await _make_request("https://oauth.yandex.ru/authorize?response_type=token&client_id=" + client_id)
#	push_error("YandexDisk: Ошибка получения ссылки (Code %d)" % response.code)

func check_auth() -> bool:
	var response = await _yandex_make_request(BASE_URL + "/v1/disk/resources?path=app:/")
	if response.code == 200:
		return true
	else:
		return false

## Получение списка файлов и папок
func get_files(path: String = "app:/") -> Array:
	var url = BASE_URL + "/v1/disk/resources?path=" + path.uri_encode()
	var response = await _yandex_make_request(url)
	
	if response.code == 200:
		var data = response.data
		if data is Dictionary and data.has("_embedded"):
			return data["_embedded"].get("items", [])
	
	push_error("YandexDisk: Ошибка получения списка файлов (Code %d)" % response.code)
	return []

## Получение временной ссылки на скачивание файла
func get_download_link(path: String = "app:/") -> String:
	var url = BASE_URL + "/v1/disk/resources/download?path=" + path.uri_encode()
	var response = await _yandex_make_request(url)
	
	if response.code == 200:
		return response.data.get("href", "")
	
	push_error("YandexDisk: Ошибка получения ссылки (Code %d)" % response.code)
	return ""
	
## Получение изображения
func get_image(path: String = "app:/") -> Image:
	# получаем ссылку
	var link = await get_download_link(path)
	
	# скачиваем по ссылке
	var image_result = await _make_request(link, headers)
	
	# преобразуем в картинку
	if image_result.result == HTTPRequest.RESULT_SUCCESS:
		var image = Image.new()
		var err = image.load_jpg_from_buffer(image_result.body)
		if err != OK:
			err = image.load_png_from_buffer(image_result.body)
		
		if err == OK:
			return image
			
	print("WARNING: Не удалось загрузить файл: %s" % path)
	return null
	
## Получение изображения
func get_text(path: String = "app:/") -> String:
	# получаем ссылку
	var link = await get_download_link(path)
	
	# скачиваем по ссылке
	var result = await _make_request(link, headers)
	
	# преобразуем в картинку
	if result.result == HTTPRequest.RESULT_SUCCESS:
		return result.body.get_string_from_utf8()
			
	print("WARNING: Не удалось загрузить или декодировать изображение: %s" % path)
	return ""

## Внутренний метод для выполнения HTTP-запросов
func _yandex_make_request(url: String) -> Dictionary:
	var http = HTTPRequest.new()
	http.max_redirects = 5
	add_child(http)
	
	var headers = [
		"Authorization: OAuth " + token,
		"Accept: application/json"
	]
	
	var err = http.request(url, headers, HTTPClient.METHOD_GET)
	if err != OK:
		http.queue_free()
		return {"code": 0, "data": {}}

	var result = await http.request_completed
	# result[0] - result (int), result[1] - response_code (int), 
	# result[2] - headers (PackedStringArray), result[3] - body (PackedByteArray)
	
	var response_code = result[1]
	var body = result[3].get_string_from_utf8()
	
	var json = JSON.new()
	var parse_err = json.parse(body)
	var data = json.get_data() if parse_err == OK else {}
	
	http.queue_free()
	return {"code": response_code, "data": data}

## обычный запрос
func _make_request(url: String, headers: PackedStringArray) -> Dictionary:
	# Вместо использования одного глобального http_request, 
	# создаем временный для этого конкретного вызова. 
	# Это исключит ошибку 5 (ERR_ALREADY_IN_USE).
	var http = HTTPRequest.new()
	add_child(http)
	
	# Настраиваем TLS ДО вызова request
	#var tls_settings = TLSOptions.client_unsafe()
	#http.set_tls_options(tls_settings)

	http.timeout = 15  # Установим таймаут 15 секунд для предотвращения зависания
	
	http.max_redirects = 5
	
	# Увеличиваем лимиты для бинарных данных
	http.body_size_limit = 26214400 # 25 МБ (чтобы точно влезли любые фото)
	
	var error = http.request(url, headers, HTTPClient.METHOD_GET, "")
	if error != OK:
		print("ERROR: %s" % error)
		http.queue_free()
		return {"result": -1}

	var result = await http.request_completed
	
	# Удаляем временный узел
	http.queue_free()
	
	return {
		"result": result[0],
		"code": result[1],
		"headers": result[2],
		"body": result[3]
	}

Звук

Для звука так же пытался найти а затем сделать отдельный worker, но так ничего не получилось. Сейчас точно не помню, но проблема была решена изменениями в двух местах: в экспорте в html5 нужно было убрать галку "Поддержка потоков". А в коде нужно было указать такое:

Фрагмент MusicService.gd
audio_player = AudioStreamPlayer.new()
if OS.has_feature("web"):
	audio_player.playback_type = AudioServer.PLAYBACK_TYPE_SAMPLE
else:
	audio_player.playback_type = AudioServer.PLAYBACK_TYPE_STREAM

Фоновая музыка для игры - с бесплатной лицензией для использования в коммерческих целях.

Допиливание

Прежде чем выкладывать игру, надо в неё поиграть.

Ни у кого не получится очень точно, пиксель в пиксель разместить кусок пазла в нужное место на шаблоне с первого раза. Поэтому добавил авто-притягивание куска пазла если его положили близко к своему месту.

Новая проблема: все картинки разные, а шаблон - квадратный, поэтому при формировании пазла, картинка растягивается, что выглядит не очень. Нашел единственный вариант - дорисовывать по краям белый фон.

Белый фон по краям картинки

Было не удобно когда сгенерированные кусочки раскидывались по всему игровому полю. Поэтому решил сделать некий "конвейер" с кусочками, откуда можно их доставать, не захламляя игровое поле.

При запуске игры на компьютере все идеально, но при запуске на телефоне - всё мелкое и шаблон очень мелкий. После долгих препираний с ИИ, удалось таки сделать динамическое масштабирование при изменении размеров игрового поля.

Разное масштабирование

Пиши пропало

И тут случилось то, чего я не ожидал. Kilo code перестал работать с gemini. Возврат агента на старую версию не намного отсрочил проблему. Через какое то время gemini перестала работать даже со старой версией агента.

Попробовал последние правки игры делать сразу в Qwen CLI, без VSCode. Общие впечатления: отвечает намного шустрее. Но работа через Kilo code намного нагляднее и проще, особенно с возвратом кода.

Итого

Это увлекательный был аттракцион...

Вайб прости господи кодинг по началу вызывает ощущение безграничных возможностей. Но эти возможности кардинально ограничены тем, кто решил нанять ИИ в качестве джуна. После многочисленных "да вы правы, сейчас исправим", понимаешь, что без собственных знаний в предмете, такое "программирование" начинает очень сильно утомлять.

История...

Какова ценность того, что сделал ИИ для вас, если вы сами в этом ничего не понимаете? Как оценить то, что написал для вас ИИ? Что с этим делать? Как это дальше использовать?

Вопрос - открытый.

Раньше все ругали "индусский код". Теперь на смену ему пришел куда более опасный ИИ-слоп.

А на сегодня, всё...


Внимание!

Официальный сайт бота по ссылке ниже.

Официальный сайт