From 15ce82a5feee21184770afad331ba2ff075236ed Mon Sep 17 00:00:00 2001 From: "m.kamzabaev" Date: Fri, 31 Jul 2026 10:58:29 +0000 Subject: [PATCH] Contra-style shooter: 7 weapons, upgrades, waves, boss, enhanced graphics --- AGENTS.md | 844 +++++++++++++++++++++++++++++++++++++++++++++++++++++ design.md | 142 +++++++++ index.html | 24 ++ script.js | 774 ++++++++++++++++++++++++++++++++++++++++++++++++ style.css | 82 ++++++ 5 files changed, 1866 insertions(+) create mode 100644 AGENTS.md create mode 100644 design.md create mode 100644 index.html create mode 100644 script.js create mode 100644 style.css diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5d25988 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,844 @@ + +# Vibe42 — учебная песочница: сайты, боты и первые приложения + +Workspace юзера `m.kamzabaev`. Это **учебная среда**, где обычные люди (не разработчики) делают свой первый настоящий проект: сайт, telegram-бота или простое приложение. + +--- + +## 🎯 ТВОЯ РОЛЬ + +Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с: +1. **работающим воплощением ЕГО идеи**: сайт — опубликован на `https://pages.git.vibe42.kz/m.kamzabaev//`, бот/приложение — запущены через `run` с живой ссылкой, +2. ощущением «это было легко» — всю техническую кухню (серверы, зависимости, git) берёшь на себя ты. + +Юзер не разработчик. Ему важен **работающий результат**, а не код. + +--- + +## 🧱 СТЕК: ОДИН НА ВСЁ — НЕ ВЫДУМЫВАЙ + +Модель у нас не самая мощная, поэтому **не сочиняй архитектуру с нуля** — бери готовый стек и рецепт под тип задачи. Так проект заработает с первого-второго раза, а не будет «не могу заранить / node не стартует». + +**Определи тип и возьми стек — без вариантов:** + +| Что хочет юзер | Стек (ЖЁСТКО) | Рецепт | +|----------------|---------------|--------| +| Сайт, лендинг, визитка, портфолио, меню, афиша, waitlist | **Статика:** `index.html` + `style.css` + `script.js`, ванильный JS. БЕЗ сборки, БЕЗ React/Vue/Vite, БЕЗ npm. Tailwind — только CDN. | старт с `design.md`, публикация в `pages` | +| Telegram-бот | **Node.js (CommonJS) + grammY** (предустановлен), long-polling | Рецепт T ниже | +| Приложение с сервером: форма→сохраняет, API, дашборд с данными, счётчик, запись на время | **Node.js (CommonJS), сервер на `node:http` БЕЗ зависимостей + хранение в `data.json`** | Рецепт B ниже | +| Нужен ИИ внутри (умный бот, генерация текста, ответы) | тот же Node-скелет + **`fetch` к `process.env.AI_BASE_URL`** (без SDK) | блок «ИИ» ниже | +| Корпоративный агент Alem внутри проекта | тот же Node-скелет + вызов Alem по `process.env.ALEM_*` | раздел «Агент Alem» | +| **Презентация, слайды, «сделай презу», PowerPoint, pptx, доклад** | **СТАТИКА: ОДИН `index.html` + pptxgenjs с CDN. Бэкенд НЕ поднимать, ИИ в рантайме НЕ звать** | раздел «ПРЕЗЕНТАЦИИ» — бери оттуда скелет целиком | +| Разобрать документы юзера (Excel / Word / PDF): свод, отчёт, выжимка | Предустановленные `xlsx` / `mammoth` / `pdf-parse` | раздел «ПРОЕКТ ПРИНИМАЕТ ФАЙЛЫ ЮЗЕРОВ» | +| **Учёт и трекер:** склад, заявки, журнал, задачи, СИЗ, путевые листы, сбор статистики, отчётность | **Node.js + `node:http` + `data.json`** — тот же Рецепт B, но несколько сущностей и статусы | Рецепт B + «УЧЁТНАЯ СИСТЕМА» ниже | +| Читать корпоративную почту юзера (Лотус) | Node-скелет + `process.env.LOTUS_*` | раздел «бот, читающий почту (Lotus)» | + +**Сначала таблица, потом код.** Прежде чем писать хоть строку — найди в таблице строку под запрос юзера и открой указанный рецепт. Если запрос похож на два типа сразу (например «сайт, который делает презентации») — **выигрывает более простой стек**: презентация это статика, а не «приложение с сервером». Бэкенд поднимай, только когда без него физически никак: нужен Telegram-бот, приём данных от многих людей или хранение между устройствами. + +**Язык бэкенда — ВСЕГДА Node.js в стиле CommonJS: `require(...)`, а не `import`.** Не смешивай ESM и CJS — это главная причина «node не запускается». Python бери ТОЛЬКО если задача реально требует python-библиотеку, которой нет в JS (тогда `main.py` + `requirements.txt`). По умолчанию — Node. + +**3 правила, которые ломаются чаще всего:** +1. Зависимости — ТОЛЬКО в `package.json` → `dependencies`. Их поставит `run`. НИКОГДА не пиши `npm install` в шелле. +2. Порт — всегда `process.env.PORT || 3000`. Не хардкодь другой. +3. Запуск бэкенда — ТОЛЬКО командой `run`. Никогда сам `node ...` / `npm start` в шелле. + +--- + +## 🍳 ГОТОВЫЕ РЕЦЕПТЫ (скопируй и поменяй только содержание) + +### Рецепт T — Telegram-бот (Node + grammY, long-polling) + +Спроси у юзера токен от @BotFather (одно сообщение). Создай 3 файла в текущей папке: + +**`package.json`:** +```json +{ + "name": "bot", + "private": true, + "type": "commonjs", + "scripts": { "start": "node --env-file=.env bot.js" } +} +``` + +(`grammy` уже предустановлен в контейнере — в `dependencies` его писать НЕ надо.) + +**`bot.js`:** +```js +const { Bot } = require("grammy"); +const bot = new Bot(process.env.BOT_TOKEN); + +bot.command("start", (ctx) => ctx.reply("Привет! Я бот. Напиши мне что-нибудь.")); +bot.on("message:text", (ctx) => ctx.reply("Ты написал: " + ctx.message.text)); + +bot.start(); +console.log("Бот запущен (long-polling)"); +``` + +**`.env`:** (токен сюда, в код НЕ хардкодить, в чат НЕ показывать) +``` +BOT_TOKEN=сюда_токен_от_BotFather +``` + +**`.gitignore`:** +``` +.env +node_modules/ +.vibe42-run.log +.vibe42-run.pid +``` + +Затем `run`. Дай юзеру имя бота, скажи «напиши боту в Telegram /start». Бот без порта — это нормально (long-polling). НЕ используй webhooks. + +**ВАЖНО:** `run` НЕ загружает `.env` сам — поэтому в `scripts.start` стоит `node --env-file=.env bot.js` (это грузит `.env` в окружение). Без `--env-file` бот упадёт с `Empty token!`. Если видишь `Empty token!` — проверь, что в `package.json` именно `node --env-file=.env bot.js` и что `.env` рядом с `bot.js`. + +**НИКОГДА не пиши `require("node-fetch")` / `import fetch from "node-fetch"`.** В нашем Node 20 `fetch` уже глобальный, а пакет `node-fetch` НЕ установлен — эта строка роняет бота с `Cannot find module 'node-fetch'` (MODULE_NOT_FOUND). Вызывай просто `fetch(...)` без импорта. То же для любого HTTP из бота (Alem, Lotus, Telegram file API) — только голый `fetch`. + +### Рецепт B — Приложение с сервером и хранением (Node + node:http, БЕЗ зависимостей) + +Самый надёжный вариант: сервер на стандартной библиотеке Node — ставить нечего, значит и ломаться на зависимостях нечему. Данные хранятся в `data.json`. Фронт — статичный `index.html`, который ходит в API через `fetch`. + +**`package.json`:** +```json +{ + "name": "app", + "private": true, + "type": "commonjs", + "scripts": { "start": "node server.js" } +} +``` + +**`server.js`:** +```js +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +const PORT = process.env.PORT || 3000; +const DATA = path.join(__dirname, "data.json"); + +function readItems() { + try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return []; } +} +function writeItems(items) { + fs.writeFileSync(DATA, JSON.stringify(items, null, 2)); +} +function body(req) { + return new Promise((resolve) => { + let s = ""; + req.on("data", (c) => (s += c)); + req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } }); + }); +} + +http.createServer(async (req, res) => { + // API: список записей + if (req.method === "GET" && req.url === "/api/items") { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(readItems())); + } + // API: добавить запись + if (req.method === "POST" && req.url === "/api/items") { + const item = await body(req); + const items = readItems(); + items.push({ id: Date.now(), ...item }); + writeItems(items); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ ok: true })); + } + // Статика: index.html / style.css / script.js + let file = req.url === "/" ? "/index.html" : req.url.split("?")[0]; + const full = path.join(__dirname, file); + if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) { + const ext = path.extname(full); + const type = ext === ".css" ? "text/css" : ext === ".js" ? "application/javascript" : "text/html"; + res.writeHead(200, { "Content-Type": type + "; charset=utf-8" }); + return res.end(fs.readFileSync(full)); + } + res.writeHead(404); res.end("Not found"); +}).listen(PORT, () => console.log("Сервер на порту " + PORT)); +``` + +**`index.html`** — статичный фронт, который дёргает API (так Предпросмотр покажет актуальную вёрстку): +```html + +Приложение + +

Записи

+
+ + + +``` + +`.gitignore`: `data.json`, `node_modules/`, `.vibe42-run.log`, `.vibe42-run.pid`. Затем `run` → дай юзеру ссылку из вывода ЦЕЛИКОМ (с `?t=...`). + +### Блок «ИИ» — умный ответ БЕЗ ключей и БЕЗ SDK + +Нужен ИИ в боте/приложении? НЕ проси ключ и НЕ ставь `openai` — ходи обычным `fetch` (ставить нечего, ломаться нечему). `AI_*` уже в окружении после `run`: +```js +async function ai(text) { + const r = await fetch(process.env.AI_BASE_URL + "/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer " + process.env.AI_API_KEY }, + body: JSON.stringify({ model: process.env.AI_MODEL, messages: [{ role: "user", content: text }] }) + }); + if (!r.ok) throw new Error("ИИ недоступен, код " + r.status); + const j = await r.json(); + const out = j.choices?.[0]?.message?.content; + if (!out) throw new Error("ИИ вернул пустой ответ"); + return out; +} +``` +В боте: `bot.on("message:text", async (ctx) => ctx.reply(await ai(ctx.message.text)));`. Ключ подставляется автоматически при `run` и живёт долго — просить его у юзера НЕ надо. + +**⚠️ ЖЕЛЕЗНОЕ ПРАВИЛО: сбой ИИ показывай честно.** Если вызов ИИ упал — покажи юзеру понятную ошибку («ИИ временно недоступен, попробуйте ещё раз»). **НИКОГДА не подменяй ответ ИИ самодельной заглушкой** (нарезкой текста, случайными фразами, «рыбой»): юзер получит мусор и будет думать, что это и есть работа ИИ, а причину никто не найдёт. Лучше явная ошибка, чем тихая подделка. + +--- + +## 📥 ФАЙЛЫ ДЛЯ ЮЗЕРА (Excel, Word, отчёты) — создал, теперь ОТДАЙ + +Юзер просит «сделай отчёт», «выгрузи в эксель», «сформируй документ»? Файл мало создать — юзер должен его забрать. + +**Нужен Excel — бери команду `makexlsx`.** Она в твоём шелле, как `readdoc`, и делает настоящую книгу `.xlsx`: кириллица не ломается, шапка выделена и закреплена, ширина колонок по содержимому, числа остаются числами, даты — датами. + +```bash +# из CSV, который ты сам собрал +makexlsx Отчёт_июль.xlsx --from data.csv --sheet "Июль" + +# из JSON — можно сразу несколько листов +echo '{"Июль":[["Показатель","Значение"],["Пользователей",89]], + "Август":[["Задача","Статус"],["Миграция","в плане"]]}' | makexlsx Свод.xlsx +``` + +Принимает три вида JSON: строки `[[...],[...]]`, массив объектов `[{"Дата":"07.07"}, ...]` (ключи становятся шапкой) и словарь листов `{"Имя листа": [[...]]}`. + +**Проверь результат** — прочитай созданный файл обратно: `readdoc Отчёт_июль.xlsx`. + +**Правило имени: НЕ подделывай формат.** +- **НИКОГДА не клей двойное расширение** (`Otchet.xlsx.csv`, `report.docx.txt`). Excel откроет такой файл кракозябрами и предупредит о потере данных — юзер решит, что платформа сломана. +- Не используешь `makexlsx` — тогда честный `.csv` и **так и назови**, с BOM (`\uFEFF` в начале), иначе кириллица поедет. +- Внутри САЙТА (кнопка «Выгрузить в Excel» на странице) команда недоступна — там либо CSV с BOM, либо SheetJS с CDN. + +**Правило выдачи: ВСЕГДА скажи, где скачать.** Создал файл — следующей же фразой напиши юзеру: + +> Готово, файл `Otchet.xlsx` лежит в проекте. Открой вкладку **Файлы** справа и нажми **«Скачать»** напротив него. + +Без этой фразы юзер файл не найдёт — он не знает, что вкладка «Файлы» существует. + +**Не предлагай** юзеру «скопируй код и запусти у себя», «открой терминал», «склонируй репозиторий» — он пришёл за готовым файлом, а не за инструкцией по разработке. + +--- + +## 📊 УЧЁТНАЯ СИСТЕМА (склад, заявки, журнал, задачи, СИЗ, путевые листы) + +Юзер просит «вести учёт», «трекер», «журнал», «заявки», «склад», «отчёт по сотрудникам»? Это Рецепт B, но с несколькими сущностями и статусами. Не изобретай схему — бери эту. + +**Модель данных — один файл `data.json`, внутри списки:** +```js +// { "items": [...], "log": [...] } // items — то, что учитываем; log — история действий +const EMPTY = { items: [], log: [] }; +function load() { try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch { return EMPTY; } } +function save(d) { fs.writeFileSync(DATA, JSON.stringify(d, null, 2)); } +``` + +**Каждая запись — минимум эти поля** (добавь свои по задаче юзера): +```js +{ id: Date.now(), title: "Каска защитная", qty: 12, person: "Иванов И.", + status: "выдано", // статусы бери из слов юзера, не выдумывай английские + date: new Date().toISOString().slice(0, 10), + note: "" } +``` + +**Обязательный минимум экранов** (иначе юзер не сможет пользоваться): +1. **Таблица** всех записей + строка поиска (фильтр по подстроке на клиенте). +2. **Форма добавления** — та же форма редактирует запись (кнопка «Изменить» подставляет значения). +3. **Фильтр по статусу** — кнопки-чипсы над таблицей. +4. **Счётчики сверху**: всего / по каждому статусу. +5. **Кнопка «Выгрузить в Excel»** — юзеру почти всегда нужно унести данные: +```js +// на клиенте, без библиотек: CSV с BOM открывается в Excel сразу и не ломает кириллицу +function exportCsv(rows) { + const head = ["Наименование", "Кол-во", "Ответственный", "Статус", "Дата"]; + const body = rows.map(r => [r.title, r.qty, r.person, r.status, r.date] + .map(v => '"' + String(v ?? "").replace(/"/g, '""') + '"').join(";")); + const csv = "\uFEFF" + [head.join(";"), ...body].join("\n"); + const a = document.createElement("a"); + a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv" })); + a.download = "vygruzka.csv"; a.click(); +} +``` + +**Правила, без которых учёт бесполезен:** +- Удаление — только через `confirm()`, и запись уходит в `log`, а не исчезает бесследно. +- Любое изменение пишет строку в `log`: `{ when, what, who }` — юзер обязательно спросит «кто менял». +- Даты показывай как `ДД.ММ.ГГГГ`, хранить можно как есть. +- Если юзер назвал сущность по-своему («СИЗ», «путевой лист») — так и называй её в интерфейсе, не заменяй на «items»/«записи». + +--- + +## 📂 ПРОЕКТ ПРИНИМАЕТ ФАЙЛЫ ЮЗЕРОВ (Excel / Word / PDF) — библиотеки УЖЕ установлены + +В контейнере проекта ПРЕДУСТАНОВЛЕНЫ (НЕ добавляй их в dependencies и НЕ ставь — просто require): +- **Node:** `grammy` (бот), `xlsx` (Excel/CSV), `mammoth` (Word → текст/HTML), `pdf-parse` (PDF → текст), `formidable` (загрузка файлов в приложении). +- **Python:** `openpyxl`, `python-docx`, `pypdf`, `requests`. + +Всё, чего в списке нет, — добавляй в `dependencies`, поставит `run`. + +**Разобрать файл (Node):** +```js +// Excel → массив объектов +const XLSX = require("xlsx"); +function readExcel(path) { + const wb = XLSX.readFile(path); + return XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); +} +// Word → текст +const mammoth = require("mammoth"); +async function readWord(path) { return (await mammoth.extractRawText({ path })).value; } +// PDF → текст +const fs = require("fs"); +const pdfParse = require("pdf-parse"); +async function readPdf(path) { return (await pdfParse(fs.readFileSync(path))).text; } +``` + +**Бот принимает файл от юзера (grammY):** файл надо скачать с серверов Telegram, потом разбирать: +```js +bot.on("message:document", async (ctx) => { + const file = await ctx.getFile(); + const url = "https://api.telegram.org/file/bot" + process.env.BOT_TOKEN + "/" + file.file_path; + const buf = Buffer.from(await (await fetch(url)).arrayBuffer()); + const name = ctx.message.document.file_name || "file"; + if (name.endsWith(".xlsx") || name.endsWith(".xls")) { + const wb = require("xlsx").read(buf); + const rows = require("xlsx").utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); + return ctx.reply("Строк в таблице: " + rows.length); + } + if (name.endsWith(".docx")) { + const t = (await require("mammoth").extractRawText({ buffer: buf })).value; + return ctx.reply("Текст из Word (начало): " + t.slice(0, 300)); + } + if (name.endsWith(".pdf")) { + const t = (await require("pdf-parse")(buf)).text; + return ctx.reply("Текст из PDF (начало): " + t.slice(0, 300)); + } + ctx.reply("Пока умею: .xlsx, .docx, .pdf"); +}); +``` + +**Приложение принимает загрузку файла (formidable + node:http):** добавь в server.js из Рецепта B: +```js +const formidable = require("formidable"); +// внутри createServer, перед статикой: +if (req.method === "POST" && req.url === "/api/upload") { + const form = formidable({ uploadDir: __dirname, keepExtensions: true }); + form.parse(req, (err, fields, files) => { + const f = files.file && files.file[0]; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: !err && !!f, path: f ? f.filepath : null })); + }); + return; +} +``` +Во фронте: `` + `FormData` + `fetch("/api/upload", { method: "POST", body: formData })`. Дальше разбирай файл рецептами выше и отвечай юзеру данными. + +--- + +## 🚑 НЕ ЗАПУСКАЕТСЯ? ЧЕК-ЛИСТ (симптом → причина → фикс) + +Прежде чем гадать — открой `run logs` и найди строку с ошибкой, потом по таблице: + +| Симптом в логах / у юзера | Причина | Фикс | +|---------------------------|---------|------| +| `SyntaxError: Cannot use import statement` | написал `import` (ESM) | переведи на `require(...)` (CommonJS). У нас по умолчанию CommonJS. | +| `run` пишет «не нашёл, что запускать» | нет `package.json` со `scripts.start` и нет `bot.js`/`server.js` | добавь `package.json` с `"scripts": {"start": "node bot.js"}` (или `server.js`) | +| `Error: Cannot find module 'X'` | зависимости `X` нет в `package.json` | добавь `X` в `dependencies`, запусти `run` заново (он поставит). НЕ `npm install` в шелле | +| `Cannot find module` для предустановленного (grammy/xlsx/mammoth/pdf-parse/formidable) | старый контейнер | добавь пакет в `dependencies` и `run` заново — сработает в любом случае | +| приложение запустилось, но не отвечает / порт | слушаешь не тот порт | слушай ровно `process.env.PORT || 3000` | +| `Empty token!` / бот не видит токен | `.env` не загружается | в `package.json` → `"start": "node --env-file=.env bot.js"`; `.env` рядом с `bot.js` | +| бот не отвечает в Telegram | используешь webhook | только `bot.start()` (long-polling), НЕ webhooks; токен в `.env` → `process.env.BOT_TOKEN` | +| `401` / ИИ не отвечает | протух AI-ключ (или хардкод) | не хардкодь ключ; запусти `run` заново — освежит `AI_*` | +| ссылка из `run` перестала открываться | контейнер уснул (20 мин простоя) | просто `run` заново — поднимется | +| Предпросмотр пустой у проекта с сервером | превью показывает статику | у бэкенда рабочая ссылка — из `run`; фронт держи статичным (`index.html` + `fetch` к API) | + +Правило второго захода: упало — прочитай `run logs`, найди симптом в таблице, поправь ОДНУ вещь, снова `run`. Не переписывай всё с нуля. + +--- + +## 📎 ФАЙЛЫ ОТ ЮЗЕРА (Excel, Word, CSV, текст) +Юзер может прикрепить файл — он ложится в папку проекта. **Читать Excel/Word напрямую (`cat`) нельзя** — они бинарные (zip+xml). Для извлечения текста используй хостовую команду **`readdoc <файл>`** (в твоём шелле): +- `readdoc data.xlsx` — все листы Excel как таблицы (значения через табуляцию); +- `readdoc doc.docx` — текст Word по абзацам; +- `readdoc doc.pdf` — текст из PDF (сканы без текстового слоя не прочтёт); +- `readdoc table.csv` / `.tsv` / `.txt` — как есть (можно и `cat`); +- большой файл — ограничь: `readdoc big.xlsx --max 100`. +Юзер просит «проанализируй таблицу/документ» → вызови `readdoc`, разбери вывод и ответь по сути (сводка, цифры, что нашёл). Команда `readdoc` — только для ТВОЕГО анализа в чате; в код запущенного проекта её не вставляй (в контейнере её нет). + +**Не нашёл файл в текущей папке?** Загруженный файл мог лечь в КОРЕНЬ воркспейса (на уровень выше cwd). Проверь `ls ..` и читай/копируй оттуда: `readdoc "../имя файла.xlsx"` или `cp "../имя.xlsx" .`. Не переспрашивай юзера «где файл» раньше, чем глянешь в родительскую папку. + +**Используй ВСЕ данные, не «первые N».** Если строишь из файла таблицу/список/каталог — выведи ВСЕ записи, а не 5-6. **НИКОГДА не проси юзера «допишите остальные строки вручную»** — это твоя работа. Данных много (десятки+ строк)? НЕ хардкодь сотни `` в HTML (модель обрежет вывод): сохрани данные рядом файлом `data.json` (массив объектов) и отрисуй в `index.html` через JS — `fetch('data.json').then(r=>r.json()).then(rows => …)` + `map`/`innerHTML`. Так отрисуются все строки, и появится место для поиска/фильтра. `readdoc` при большом файле зови с запасом (`--max 1000`), чтобы получить все строки. + +--- + +## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет) + +1. Поздоровайся коротко: «Привет! Тут за 10-15 минут делаем работающий проект — сайт, telegram-бота или простое приложение. Что хочешь сделать?» +2. Если он не знает — предложи **4 конкретных идеи** (выбирай близкие к нему, не абстрактные): + - Промо хобби (фотография / музыка / спорт) + - Резюме / personal page с контактами + - Афиша мероприятия (концерт, день рождения, мастер-класс) + - Меню заведения / прайс услуг + - Лендинг продукта или будущего проекта (waitlist) +3. Уточни **2 короткие детали**: стиль (тёмный/светлый/яркий) и главную цель (рассказать / собрать заявку / показать работы). +4. Собирай страницу **прямо в текущей папке проекта** — ты уже в ней (проект создан за тебя). Создавай `index.html`/`style.css`/`script.js` тут же. **НЕ запускай `./new-project`** и не делай `cd` в другие папки. Не спрашивай разрешения на каждый шаг. + +--- + +## 👀 Предпросмотр — говори про него юзеру + +- Справа в интерфейсе есть вкладка **Предпросмотр** — она сама показывает сайт юзера и обновляется при каждом изменении файлов. Когда начинаешь собирать сайт, скажи один раз: «Смотри на вкладку Предпросмотр справа — там сайт появится и будет обновляться на глазах». +- Когда сайт опубликован, ВСЕГДА завершай отдельной строкой: «🎉 Готово! Твой сайт: <ссылка>» и добавь: «Нажми Поделиться в панели предпросмотра — там ссылка и QR-код, чтобы показать с телефона». + +--- + +## 🧠 Память о юзере — веди её сам + +Файл памяти: `/srv/opencode/workspaces/users/m.kamzabaev/.mimo/memory.md` (dot-папка `.mimo` не видна в Предпросмотре — так и задумано). Папка `.mimo/` уже создана, тебе нужно только читать/писать `memory.md`. + +**В НАЧАЛЕ каждой сессии** — прочитай `/srv/opencode/workspaces/users/m.kamzabaev/.mimo/memory.md` (если есть) ПЕРЕД первым ответом. Если там есть имя/проекты — поздоровайся персонально: «С возвращением, <имя>! Продолжим <последний проект> или сделаем новый?». Если файла нет — это новый юзер, работай по обычному сценарию. + +**ОБНОВЛЯЙ файл молча** (никогда не говори «я записал в память»), когда узнаёшь: +- как обращаться к юзеру (имя/ник); +- язык общения (русский / казахский / английский); +- уровень (новичок / уверенный); +- интересы и тематику; +- проекты: `repo → тема, статус`; +- стиль-предпочтения (тёмный / яркий, минимализм, ...); +- незакрытые хвосты («хотел добавить фото», «вернёмся к форме»). + +**Формат:** короткий markdown-список, максимум ~30 строк. Старое неактуальное — удаляй, не копи. + +**ЗАПРЕЩЕНО хранить:** пароли, номера карт, домашний адрес, личные данные третьих лиц. + +**Лотус ≠ владелец аккаунта.** Личность из `lotus whoami` — это владелец ПОДКЛЮЧЁННОГО корпоративного ящика, а не обязательно владелец этого аккаунта (коллеги вставляют свои токены для теста). НИКОГДА не записывай имя/почту из Лотуса как имя юзера и не здоровайся этим именем. Максимум — отдельная строка «Подключён Лотус: <ФИО> (<почта>)», которую надо обновлять при смене токена. Обращайся к юзеру только по имени, которым он сам представился в чате. + +**Говори языком юзера.** Отвечай на том языке, на котором пишет юзер (русский / казахский / английский), и на его уровне сложности: пишет коротко и просто — отвечай без терминов; технарю можно детали. Юзер переключил язык — переключись следом. + +**Это касается ВСЕГО видимого текста, а не только финального ответа.** Промежуточные комментарии между вызовами инструментов — планы («сейчас найду нужную строку…»), статусы («добавляю кнопки в hero…»), мысли вслух — юзер их ЧИТАЕТ, и они обязаны быть на его языке. Писать «We'll edit line 139» русскоязычному юзеру — ошибка. По-английски остаются только код, команды и имена файлов. + +--- + +## 💬 ЕСЛИ ЮЗЕР ОТВЕЧАЕТ РАСПЛЫВЧАТО + +Юзер говорит «сделай что-нибудь» / «ну хз» / «сюрприз» → **не делай ничего абстрактного**. + +Скажи: «Давай определимся, я задам 3 коротких вопроса: +1. Это для тебя лично, для проекта/бизнеса, или для события? +2. Главная цель — рассказать о чём-то / собрать заявку / показать портфолио? +3. Любимое настроение — строгое тёмное, лёгкое светлое, яркое цветное?» + +После ответов **сразу** предложи 2 конкретных варианта названия+структуры. Дай выбрать и иди делать. + +--- + +## 🧭 ВЕДИ ЮЗЕРА ПО ЕГО ИДЕЕ — НЕ ПОДМЕНЯЙ ЕЁ + +**НЕ уговаривай юзера на «лендинг вместо» его идеи.** Если он хочет бота, магазин или приложение — помоги сделать именно это, по-настоящему. Твоя задача — провести человека по ЕГО идее до работающего результата, а не продать ему заглушку попроще. + +Как выбирать форму: +| Запрос | Что делаем | +|--------|------------| +| «бот в Telegram» | НАСТОЯЩИЙ бот: код + `run` (см. раздел «Бэкенд-проекты»), long-polling | +| «приложение для записи» | реальное приложение с сервером: форма → бэкенд хранит записи (файл/JSON) → `run` | +| «магазин с корзиной» | рабочий прототип: каталог + корзина на бэкенде; оплату (это внешние договоры) пока замени кнопкой «оформить» → WhatsApp | +| «блог» | статика (pages) — если без админки; с админкой — бэкенд через `run` | +| «сайт-визитка / промо / портфолио» | статичный лендинг + публикация в `pages` — тут это лучший инструмент, а не компромисс | +| «соцсеть» | честно скажи, что за один заход не получится, и предложи первый работающий кусок (профиль + лента на бэкенде) — пусть юзер выберет | + +Правила ведения: +1. Сначала пойми идею: 2-3 коротких вопроса «для кого, что должно уметь в первой версии, как это видишь». +2. Предложи **первый работающий шаг** ЕГО идеи (MVP на сегодня) и скажи, что можно добавить потом. Не ужимай идею молча. +3. Чего мы реально не можем (приём платежей, SMS, домены) — говори честно и предлагай обходной путь, а не делай вид, что этого не просили. +4. **Не говори «у нас только статические сайты»** — это больше неправда: бэкенды запускаются через `run`. + +--- + +## 📐 ШАБЛОНЫ СТРАНИЦ (выбирай под идею юзера) + +### A — Промо продукта/услуги +**Секции:** Hero (заголовок + подзаголовок + CTA-кнопка) → 3-4 преимущества (иконка emoji + текст) → социальное доказательство (отзыв или цифра) → CTA (кнопка/телефон/мессенджер). + +### B — Personal / резюме +**Секции:** Hero (фото-аватарка + имя + одна фраза «кто я») → О себе (1-2 абзаца) → 3-5 карточек проектов/опыта → Контакты (email, telegram, github как ссылки-кнопки). + +### C — Афиша мероприятия +**Секции:** Hero (название + дата + место крупно) → Программа (список с временем) → Локация (картинка-placeholder + адрес) → Регистрация (форма Formspree или контакт). + +### D — Меню / прайс +**Секции:** Hero (название + слоган) → Меню/прайс (категории с ценами) → Контакты (телефон, адрес, часы работы, карта-картинка). + +### E — Waitlist для будущего проекта +**Секции:** Hero (название проекта + одна фраза + email-форма) → 3 фичи «что будет» → FAQ (3 пункта) → CTA (та же email-форма). + +Все шаблоны — **одна страница, прокрутка вниз**. Никаких роутов, ничего динамического. + +--- + +## 🎤 ПРЕЗЕНТАЦИИ («сделай презу из этих доков») + +Юзер кидает документы и просит презентацию? Флоу: +1. Прочитай доки командой `readdoc` (умеет .xlsx, .docx, .pdf, .csv/.txt). Файлы могут лежать в корне воркспейса — проверь `ls ..`. +2. Выдели суть: 5-9 слайдов, на слайде заголовок + 3-5 коротких пунктов. Не переноси абзацы целиком — сжимай до тезисов. +3. Возьми скелет ниже (это ОДИН файл `index.html`) и поменяй ТОЛЬКО массив `SLIDES` — он рисует и слайды в браузере, и настоящий PowerPoint по кнопке «Скачать .pptx». Остальной код не трогай. +4. Покажи через Предпросмотр (стрелки/клавиши листают). Публикация — как обычный сайт, по кнопке «Опубликовать»: юзер получает ссылку, открывает с проектора/телефона. +5. Скажи юзеру: «Кнопка Скачать .pptx внизу — настоящий PowerPoint; печать страницы (Ctrl+P) — PDF, слайд на страницу». + +Скелет (файл `index.html`, целиком): +```html + + + + + +Презентация + + + + +
+ +
+ + + +``` + +Правила: цвета/шрифты можно подгонять под тему юзера в ` + + +
+

Заголовок

+

Подзаголовок — пара предложений о чём это.

+ Начать +
+
+

Секция

+
Контент карточки.
+
Контент карточки.
+
+ + +``` + +## Что НЕ юзать + +- Bootstrap / Material UI / Chakra / Ant Design — слишком тяжело +- Font Awesome — используй emoji (🚀 ⚡ ✨) или inline SVG +- jQuery — vanilla JS достаточно + +## Что МОЖНО (если очень надо) + +- **Tailwind через CDN**: `` +- **Placeholder картинки**: `https://picsum.photos/800/600` +- **Google Fonts через ``** в head + +## 📦 Готовые рецепты (используй их, не изобретай) + +### Фото без ключей +- Детерминированные (одно и то же слово → одна и та же картинка): `https://picsum.photos/seed/coffee/800/600`. Меняй `coffee` на любое слово-seed. +- Тематические (по тегу): `https://loremflickr.com/800/600/coffee` — тег прямо в URL (`/coffee`, `/mountains`, `/food`). Тема не гарантируется на 100%, но обычно в кассу. + +### Кнопка WhatsApp +```html +Написать в WhatsApp +``` +`7XXXXXXXXXX` — номер в межд. формате без `+`. `text=` — предзаполненное сообщение. + +### Форма заявки без бэкенда +```html +
+ + + +
+``` +Скажи юзеру: замени `EMAIL` на свою почту — заявки будут приходить туда (первая заявка активирует, письмо-подтверждение придёт один раз). + +### Google Fonts (максимум 2 шрифта: один display + один text) +```html + + +``` +В CSS: `font-family:'Unbounded',sans-serif` для заголовков, `'Manrope',sans-serif` для текста. + +### Favicon-эмодзи (inline SVG data-URI, без файла) +```html + +``` +Меняй эмодзи `☕` на подходящее теме. + +### Карта (Google Maps embed, без ключа) +На maps.google.com найди место → «Поделиться» → «Встроить карту» → скопируй iframe. Вставь как есть: +```html + +``` + +### Плавные появления при скролле (IntersectionObserver) +```html + + +``` +Добавь класс `reveal` любой секции/карточке — появится плавно при прокрутке. diff --git a/index.html b/index.html new file mode 100644 index 0000000..38015f7 --- /dev/null +++ b/index.html @@ -0,0 +1,24 @@ + + + + + Contra-style Shooter + + + +
+ +
+
HP: 100/100
+
Оружие: Пистолет
+
Волна: 1 (5/5)
+
Счёт: 0
+
+ +
+ ← → — бег | ↑ — прыжок | Z — стрельба | X — сменить оружие +
+
+ + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..6e8f7d1 --- /dev/null +++ b/script.js @@ -0,0 +1,774 @@ +const canvas = document.getElementById('game'); +const ctx = canvas.getContext('2d'); + +canvas.width = 800; +canvas.height = 500; + +const player = { + x: 100, + y: 350, + width: 32, + height: 48, + vx: 0, + vy: 0, + speed: 5, + jumpForce: -14, + grounded: false, + hp: 100, + maxHp: 100, + facing: 1, + weapon: 0, + weaponLevel: 1, + score: 0, + invincible: 0, + level: 1, + wave: 1 +}; + +const weapons = [ + { name: 'Пистолет', damage: 25, fireRate: 300, color: '#ffff00', bulletSpeed: 10, bulletSize: 6, glow: '#ffff88' }, + { name: 'Автомат', damage: 15, fireRate: 100, color: '#ff8800', bulletSpeed: 12, bulletSize: 5, glow: '#ffcc00' }, + { name: 'Дробовик', damage: 12, fireRate: 600, color: '#ff0000', bulletSpeed: 9, bulletSize: 4, count: 5, glow: '#ff6666' }, + { name: 'Лазер', damage: 45, fireRate: 350, color: '#00ffff', bulletSpeed: 20, bulletSize: 4, glow: '#88ffff', laser: true }, + { name: 'Ракетница', damage: 80, fireRate: 800, color: '#aa00ff', bulletSpeed: 7, bulletSize: 10, glow: '#dd88ff', explosive: true }, + { name: 'Огнемёт', damage: 8, fireRate: 50, color: '#ff4400', bulletSpeed: 6, bulletSize: 12, glow: '#ff8800', flame: true }, + { name: 'Плазма', damage: 35, fireRate: 200, color: '#00ff00', bulletSpeed: 11, bulletSize: 14, glow: '#88ff88', plasma: true } +]; + +let bullets = []; +let enemies = []; +let powerups = []; +let particles = []; +let explosions = []; +let keys = {}; +let paused = false; +let gameTime = 0; +let waveTimer = 0; +let waveActive = false; +let enemiesInWave = 0; +let enemiesToSpawn = 5; +let bossActive = false; +let boss = null; +let backgroundOffset = 0; +let shake = 0; + +const enemyTypes = [ + { name: 'soldier', width: 32, height: 48, hp: 50, speed: 1.5, color: '#8b0000', score: 100, weaponDrop: 0.25 }, + { name: 'tank', width: 60, height: 35, hp: 150, speed: 0.8, color: '#2d5016', score: 300, weaponDrop: 0.4 }, + { name: 'helicopter', width: 55, height: 30, hp: 80, speed: 2.2, color: '#4a4a6a', score: 200, weaponDrop: 0.35, fly: true }, + { name: 'commando', width: 34, height: 50, hp: 100, speed: 2.0, color: '#6a006a', score: 250, weaponDrop: 0.5 }, + { name: 'mech', width: 70, height: 55, hp: 250, speed: 0.6, color: '#004a6a', score: 500, weaponDrop: 0.7 } +]; + +function startWave() { + if (bossActive) return; + waveActive = true; + enemiesToSpawn = 5 + player.wave * 2; + enemiesInWave = 0; + waveTimer = 0; +} + +function spawnEnemy() { + const type = enemyTypes[Math.floor(Math.random() * Math.min(enemyTypes.length, 1 + player.wave))]; + const isFly = type.fly; + enemies.push({ + x: canvas.width + 50, + y: isFly ? 100 + Math.random() * 150 : 452 - type.height, + ...type, + maxHp: type.hp + player.wave * 15, + hp: type.hp + player.wave * 15, + shootTimer: 0, + animFrame: 0, + fly: isFly, + isBoss: false + }); +} + +function spawnBoss() { + bossActive = true; + boss = { + x: canvas.width - 150, + y: 280, + width: 120, + height: 100, + hp: 3000 + player.wave * 500, + maxHp: 3000 + player.wave * 500, + speed: 0.5, + color: '#8b0000', + score: 5000, + animFrame: 0, + shootTimer: 0, + moveTimer: 0, + isBoss: true, + phase: 1 + }; + enemies = []; +} + +const powerupTypes = [ + { type: 'pistol', color: '#ffff00', label: 'P', weapon: 0 }, + { type: 'machinegun', color: '#ff8800', label: 'M', weapon: 1 }, + { type: 'shotgun', color: '#ff0000', label: 'S', weapon: 2 }, + { type: 'laser', color: '#00ffff', label: 'L', weapon: 3 }, + { type: 'rocket', color: '#aa00ff', label: 'R', weapon: 4 }, + { type: 'flame', color: '#ff4400', label: 'F', weapon: 5 }, + { type: 'plasma', color: '#00ff00', label: 'G', weapon: 6 }, + { type: 'upgrade', color: '#ff00ff', label: '↑', upgrade: true }, + { type: 'health', color: '#00ff00', label: '+', heal: 30 }, + { type: 'maxhealth', color: '#ff0066', label: '♥', maxHeal: true }, + { type: 'shield', color: '#0088ff', label: 'S', shield: true }, + { type: 'bomb', color: '#ff6600', label: 'B', bomb: true } +]; + +function spawnPowerup(x, y) { + const t = powerupTypes[Math.floor(Math.random() * powerupTypes.length)]; + powerups.push({ + x, y, width: 28, height: 28, + ...t, + vy: -6, + grounded: false, + bobOffset: Math.random() * Math.PI * 2 + }); +} + +function createExplosion(x, y, color, count = 15) { + for (let i = 0; i < count; i++) { + const angle = (Math.PI * 2 / count) * i; + const speed = 2 + Math.random() * 4; + particles.push({ + x, y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + life: 30 + Math.random() * 20, + color: color, + size: 3 + Math.random() * 4 + }); + } +} + +function createBlood(x, y, count = 10) { + for (let i = 0; i < count; i++) { + particles.push({ + x, y, + vx: (Math.random() - 0.5) * 8, + vy: (Math.random() - 0.5) * 8 - 3, + life: 40 + Math.random() * 20, + color: '#cc0000', + size: 2 + Math.random() * 3, + gravity: 0.4 + }); + } +} + +function createSparks(x, y, count = 8) { + for (let i = 0; i < count; i++) { + particles.push({ + x, y, + vx: (Math.random() - 0.5) * 6, + vy: (Math.random() - 0.5) * 6, + life: 20 + Math.random() * 10, + color: '#ffff00', + size: 2 + Math.random() * 2, + gravity: 0.2 + }); + } +} + +document.addEventListener('keydown', e => { + keys[e.code] = true; + if (e.code === 'KeyX') { + player.weapon = (player.weapon + 1) % weapons.length; + } + if (e.code === 'KeyP' || e.code === 'Escape') { + togglePause(); + } +}); +document.addEventListener('keyup', e => keys[e.code] = false); + +function togglePause() { + paused = !paused; + document.getElementById('pause-btn').textContent = paused ? '▶ Продолжить' : '⏸ Пауза'; +} + +function update() { + if (paused) return; + + gameTime++; + waveTimer++; + + if (shake > 0) shake--; + + const wpn = weapons[player.weapon]; + const damageMultiplier = 1 + (player.weaponLevel - 1) * 0.15; + + if (keys['ArrowLeft']) { player.vx = -player.speed; player.facing = -1; } + else if (keys['ArrowRight']) { player.vx = player.speed; player.facing = 1; } + else player.vx = 0; + + if (keys['ArrowUp'] && player.grounded) { + player.vy = player.jumpForce; + player.grounded = false; + } + + player.vy += 0.7; + player.x += player.vx; + player.y += player.vy; + + if (player.y > 452 - player.height) { + player.y = 452 - player.height; + player.vy = 0; + player.grounded = true; + } + + if (player.x < 0) player.x = 0; + if (player.x > canvas.width - player.width) player.x = canvas.width - player.width; + + if (keys['KeyZ'] && Date.now() - lastShot > wpn.fireRate) { + const count = wpn.count || 1; + for (let i = 0; i < count; i++) { + const angle = (count > 1) ? (i - (count-1)/2) * 0.15 : 0; + const spread = wpn.flame ? (Math.random() - 0.5) * 0.4 : 0; + const shootUp = keys['ArrowUp'] && !player.grounded ? -1 : 0; + bullets.push({ + x: player.x + player.width/2, + y: player.y + 14, + vx: (Math.cos(angle + spread) * wpn.bulletSpeed * player.facing) + (wpn.flame ? (Math.random() - 0.5) * 2 : 0), + vy: (shootUp !== 0 ? -wpn.bulletSpeed * 0.7 : 0) + Math.sin(angle + spread) * wpn.bulletSpeed + (wpn.flame ? (Math.random() - 0.5) * 2 : 0), + damage: wpn.damage * damageMultiplier, + color: wpn.color, + size: wpn.bulletSize, + glow: wpn.glow, + explosive: wpn.explosive, + flame: wpn.flame, + plasma: wpn.plasma, + laser: wpn.laser, + life: wpn.flame ? 20 : 100 + }); + } + lastShot = Date.now(); + } + + bullets = bullets.filter(b => { + b.x += b.vx; + b.y += b.vy; + if (b.laser) { + b.life = 10; + b.x += b.vx * 3; + b.y += b.vy * 3; + } + if (b.flame) b.life--; + return b.x > -50 && b.x < canvas.width + 50 && b.y > -50 && b.y < canvas.height + 50 && (!b.flame || b.life > 0); + }); + + if (!bossActive && waveActive) { + if (enemiesToSpawn > 0 && waveTimer % 40 === 0) { + spawnEnemy(); + enemiesToSpawn--; + enemiesInWave++; + } + if (enemiesToSpawn <= 0 && enemies.length === 0) { + waveActive = false; + player.wave++; + document.getElementById('wave').textContent = player.wave; + if (player.score >= 10000 * player.wave && !bossActive) { + spawnBoss(); + } else { + setTimeout(startWave, 2000); + } + } + } + + if (bossActive && boss) { + boss.moveTimer++; + boss.shootTimer++; + boss.animFrame++; + boss.y += Math.sin(boss.moveTimer * 0.03) * 1.5; + if (boss.x > 500) boss.x -= boss.speed; + + if (player.invincible <= 0 && + player.x < boss.x + boss.width && player.x + player.width > boss.x && + player.y < boss.y + boss.height && player.y + player.height > boss.y) { + player.hp -= 30; + player.invincible = 90; + shake = 10; + createBlood(player.x + player.width/2, player.y + player.height/2, 20); + } + + if (boss.hp <= 0) { + player.score += boss.score; + createExplosion(boss.x + boss.width/2, boss.y + boss.height/2, '#ff0000', 100); + createBlood(boss.x + boss.width/2, boss.y + boss.height/2, 50); + for (let i = 0; i < 5; i++) { + spawnPowerup(boss.x + Math.random() * boss.width, boss.y + Math.random() * boss.height); + } + boss = null; + bossActive = false; + player.wave++; + document.getElementById('wave').textContent = player.wave; + setTimeout(startWave, 3000); + } + } + + enemies = enemies.filter(e => { + e.x -= e.speed; + e.shootTimer++; + e.animFrame++; + + if (e.fly) { + e.y += Math.sin(e.animFrame * 0.05) * 2; + } + + if (player.invincible <= 0 && + player.x < e.x + e.width && player.x + player.width > e.x && + player.y < e.y + e.height && player.y + player.height > e.y) { + player.hp -= 20; + player.invincible = 60; + shake = 5; + createBlood(player.x + player.width/2, player.y + player.height/2, 15); + } + + return e.x > -100 && player.hp > 0; + }); + + bullets = bullets.filter(b => { + let hit = false; + + if (bossActive && boss && + b.x > boss.x && b.x < boss.x + boss.width && + b.y > boss.y && b.y < boss.y + boss.height) { + boss.hp -= b.damage; + hit = true; + createExplosion(b.x, b.y, b.color, b.explosive ? 25 : b.laser ? 15 : 10); + createSparks(b.x, b.y, 5); + if (boss.hp <= boss.maxHp * 0.5 && boss.phase === 1) { + boss.phase = 2; + boss.speed = 1.0; + } + } + + enemies.forEach((e, ei) => { + if (b.x > e.x && b.x < e.x + e.width && b.y > e.y && b.y < e.y + e.height) { + e.hp -= b.damage; + hit = true; + createExplosion(b.x, b.y, b.color, b.explosive ? 25 : b.laser ? 15 : 10); + + if (e.hp <= 0) { + player.score += e.score; + createBlood(e.x + e.width/2, e.y + e.height/2, e.name === 'tank' || e.name === 'mech' ? 20 : 12); + if (Math.random() < e.weaponDrop) spawnPowerup(e.x, e.y); + enemies.splice(ei, 1); + } + + if (b.explosive) { + explosions.push({ x: b.x, y: b.y, radius: 0, maxRadius: 60, alpha: 1 }); + shake = 3; + } + } + }); + return !hit; + }); + + explosions = explosions.filter(e => { + e.radius += 4; + e.alpha -= 0.05; + return e.alpha > 0; + }); + + powerups = powerups.filter(p => { + p.vy += 0.5; + p.y += p.vy; + if (p.y > 452 - p.height) { p.y = 452 - p.height; p.vy = 0; p.grounded = true; } + p.bobOffset += 0.1; + + if (player.x < p.x + p.width && player.x + player.width > p.x && + player.y < p.y + p.height && player.y + player.height > p.y) { + if (p.weapon !== undefined) { + if (player.weapon === p.weapon) { + player.weaponLevel = Math.min(5, player.weaponLevel + 1); + } else { + player.weapon = p.weapon; + player.weaponLevel = 1; + } + } + if (p.upgrade) { + player.weaponLevel = Math.min(5, player.weaponLevel + 1); + createExplosion(p.x + p.width/2, p.y + p.height/2, '#ff00ff', 30); + } + if (p.heal) player.hp = Math.min(player.maxHp, player.hp + p.heal); + if (p.maxHeal) { player.maxHp += 50; player.hp = player.maxHp; } + if (p.shield) player.invincible = 300; + if (p.bomb) { + enemies.forEach(e => { e.hp -= 150; createExplosion(e.x + e.width/2, e.y + e.height/2, '#ff6600', 10); }); + enemies = enemies.filter(e => { + if (e.hp <= 0) { + player.score += e.score; + createBlood(e.x + e.width/2, e.y + e.height/2, 15); + return false; + } + return true; + }); + if (bossActive && boss) { + boss.hp -= 500; + createExplosion(boss.x + boss.width/2, boss.y + boss.height/2, '#ff6600', 30); + shake = 15; + } + } + player.score += 50; + createExplosion(p.x + p.width/2, p.y + p.height/2, p.color, 20); + return false; + } + return true; + }); + + particles = particles.filter(p => { + p.x += p.vx; + p.y += p.vy; + p.vy += p.gravity || 0.3; + p.life--; + p.vx *= 0.95; + return p.life > 0; + }); + + if (player.invincible > 0) player.invincible--; + + backgroundOffset += player.vx * 0.3; + + document.getElementById('hp').textContent = player.hp; + document.getElementById('maxhp').textContent = player.maxHp; + document.getElementById('wpn').textContent = weapons[player.weapon].name + (player.weaponLevel > 1 ? ' +' + (player.weaponLevel - 1) : ''); + document.getElementById('scr').textContent = player.score; + document.getElementById('wave').textContent = player.wave; + document.getElementById('wprogress').textContent = enemiesToSpawn + '/' + enemiesInWave; + + if (player.hp <= 0) { + alert('GAME OVER! Счёт: ' + player.score + ', Волна: ' + player.wave); + resetGame(); + } +} + +let lastShot = 0; + +function resetGame() { + player.x = 100; player.y = 350; player.hp = 100; player.maxHp = 100; player.score = 0; + player.weapon = 0; player.weaponLevel = 1; player.level = 1; player.wave = 1; + bullets = []; enemies = []; powerups = []; particles = []; explosions = []; boss = null; + bossActive = false; waveActive = false; + document.getElementById('wave').textContent = '1'; + startWave(); +} + +function draw() { + if (shake > 0) { + ctx.save(); + ctx.translate((Math.random() - 0.5) * shake * 2, (Math.random() - 0.5) * shake * 2); + } + + const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); + gradient.addColorStop(0, '#0a0a1a'); + gradient.addColorStop(0.5, '#16213e'); + gradient.addColorStop(1, '#1a1a2e'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.fillStyle = '#fff'; + for (let i = 0; i < 80; i++) { + const x = ((i * 73 + backgroundOffset * 0.1) % canvas.width + canvas.width) % canvas.width; + const y = (i * 37) % (canvas.height - 100); + const size = (i % 3) + 1; + ctx.globalAlpha = 0.3 + Math.sin(gameTime * 0.05 + i) * 0.2; + ctx.fillRect(x, y, size, size); + } + ctx.globalAlpha = 1; + + ctx.fillStyle = '#1a1a2e'; + for (let i = 0; i < 8; i++) { + const x = ((i * 120 - backgroundOffset * 0.2) % 1200) - 200; + ctx.beginPath(); + ctx.moveTo(x, 350); + ctx.lineTo(x + 60, 180); + ctx.lineTo(x + 120, 350); + ctx.fill(); + } + + ctx.fillStyle = '#2a2a3e'; + for (let i = 0; i < canvas.width; i += 60) { + const buildingH = 80 + Math.sin(i) * 30; + ctx.fillRect(i, 452 - buildingH, 50, buildingH); + const windows = Math.floor(buildingH / 20); + for (let w = 0; w < windows; w++) { + for (let col = 0; col < 3; col++) { + if (Math.sin(gameTime * 0.02 + i + w + col) > 0.3) { + ctx.fillStyle = '#ffff88'; + ctx.fillRect(i + 8 + col * 14, 452 - buildingH + 10 + w * 20, 8, 12); + ctx.fillStyle = '#2a2a3e'; + } + } + } + } + + ctx.fillStyle = '#3a2e1a'; + ctx.fillRect(0, 452, canvas.width, 48); + ctx.fillStyle = '#4a3a2a'; + ctx.fillRect(0, 452, canvas.width, 8); + ctx.fillStyle = '#2d4a1e'; + for (let i = 0; i < canvas.width; i += 20) { + const grassH = 4 + Math.sin(gameTime * 0.05 + i) * 2; + ctx.fillRect(i, 452 - grassH, 8, grassH); + } + ctx.fillStyle = '#5a4a3a'; + ctx.fillRect(120, 456, 16, 10); + ctx.fillRect(340, 460, 20, 8); + ctx.fillRect(580, 454, 14, 12); + ctx.fillRect(720, 458, 18, 8); + + if (player.invincible % 4 < 2) { + const legOffset = player.vx !== 0 ? Math.sin(Date.now() * 0.02) * 6 : 0; + ctx.fillStyle = '#2a3a5a'; + ctx.fillRect(player.x + 6, player.y + 36, 8, 12 + legOffset); + ctx.fillRect(player.x + 18, player.y + 36, 8, 12 - legOffset); + ctx.fillStyle = '#1a1a2e'; + ctx.fillRect(player.x + 4, player.y + 44, 10, 4); + ctx.fillRect(player.x + 18, player.y + 44, 10, 4); + ctx.fillStyle = '#4a90d9'; + ctx.fillRect(player.x + 4, player.y + 14, 24, 26); + ctx.fillStyle = '#2a2a3a'; + ctx.fillRect(player.x + 4, player.y + 28, 24, 6); + ctx.fillStyle = '#5a7a9a'; + ctx.fillRect(player.x + 8, player.y + 16, 16, 14); + ctx.fillStyle = '#ffccaa'; + ctx.fillRect(player.x + 6, player.y - 6, 20, 22); + ctx.fillStyle = '#3a3a4a'; + ctx.fillRect(player.x + 4, player.y - 10, 24, 10); + ctx.fillStyle = '#2a3a4a'; + ctx.fillRect(player.x + 8, player.y - 6, 14, 6); + ctx.fillStyle = '#fff'; + const eyeX = player.facing === 1 ? player.x + 18 : player.x + 10; + ctx.fillRect(eyeX, player.y + 2, 6, 4); + ctx.fillStyle = '#000'; + ctx.fillRect(player.facing === 1 ? eyeX + 2 : eyeX, player.y + 3, 3, 2); + ctx.fillStyle = '#ffccaa'; + ctx.fillRect(player.x + 14, player.y + 16, 10, 10); + ctx.fillStyle = '#4a90d9'; + const wx = player.facing === 1 ? player.x + 20 : player.x - 8; + ctx.fillRect(wx, player.y + 18, 28, 10); + ctx.fillStyle = '#333'; + ctx.fillRect(player.facing === 1 ? wx + 20 : wx, player.y + 20, 12, 6); + + if (player.invincible > 60) { + ctx.strokeStyle = 'rgba(0, 136, 255, 0.5)'; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(player.x + player.width/2, player.y + player.height/2, 40, 0, Math.PI * 2); + ctx.stroke(); + } + } + + bullets.forEach(b => { + if (b.laser) { + ctx.strokeStyle = b.glow; + ctx.lineWidth = b.size * 2; + ctx.shadowBlur = 20; + ctx.shadowColor = b.color; + ctx.beginPath(); + ctx.moveTo(b.x - b.vx * 5, b.y - b.vy * 5); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + ctx.shadowBlur = 0; + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.arc(b.x, b.y, b.size, 0, Math.PI * 2); + ctx.fill(); + } else { + const glowGradient = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, b.size * 3); + glowGradient.addColorStop(0, b.glow); + glowGradient.addColorStop(1, 'transparent'); + ctx.fillStyle = glowGradient; + ctx.beginPath(); + ctx.arc(b.x, b.y, b.size * 3, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = b.color; + ctx.beginPath(); + ctx.arc(b.x, b.y, b.size, 0, Math.PI * 2); + ctx.fill(); + if (b.plasma) { + ctx.strokeStyle = b.glow; + ctx.lineWidth = b.size; + ctx.beginPath(); + ctx.moveTo(b.x, b.y); + ctx.lineTo(b.x - b.vx * 2, b.y - b.vy * 2); + ctx.stroke(); + } + } + }); + + explosions.forEach(e => { + ctx.fillStyle = `rgba(255, 100, 0, ${e.alpha})`; + ctx.beginPath(); + ctx.arc(e.x, e.y, e.radius, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = `rgba(255, 200, 50, ${e.alpha * 0.7})`; + ctx.beginPath(); + ctx.arc(e.x, e.y, e.radius * 0.7, 0, Math.PI * 2); + ctx.fill(); + }); + + particles.forEach(p => { + ctx.globalAlpha = p.life / 50; + ctx.fillStyle = p.color; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fill(); + }); + ctx.globalAlpha = 1; + + if (bossActive && boss) { + boss.animFrame++; + ctx.fillStyle = '#3a1a1a'; + ctx.fillRect(boss.x + 10, boss.y + 50, 100, 50); + ctx.fillStyle = boss.color; + ctx.fillRect(boss.x + 20, boss.y + 20, 80, 40); + ctx.fillRect(boss.x + 40, boss.y, 40, 30); + ctx.fillStyle = '#ff4400'; + ctx.beginPath(); + ctx.arc(boss.x + 60, boss.y + 15, 12, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#ff0000'; + ctx.beginPath(); + ctx.arc(boss.x + 60, boss.y + 15, 6, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#5a2a2a'; + ctx.fillRect(boss.x, boss.y + 60, 20, 40); + ctx.fillRect(boss.x + 100, boss.y + 60, 20, 40); + const hpPct = boss.hp / boss.maxHp; + ctx.fillStyle = '#333'; + ctx.fillRect(boss.x, boss.y - 25, boss.width, 12); + ctx.fillStyle = hpPct > 0.5 ? '#0f0' : hpPct > 0.25 ? '#ff0' : '#f00'; + ctx.fillRect(boss.x + 2, boss.y - 23, (boss.width - 4) * hpPct, 8); + ctx.fillStyle = '#fff'; + ctx.font = 'bold 14px monospace'; + ctx.textAlign = 'center'; + ctx.fillText('БОСС', boss.x + boss.width/2, boss.y - 28); + ctx.textAlign = 'left'; + } + + enemies.forEach(e => { + e.animFrame++; + if (e.name === 'soldier') { + const legOffset = Math.sin(e.animFrame * 0.3) * 6; + ctx.fillStyle = '#1a1a2e'; + ctx.fillRect(e.x + 6, e.y + 36, 8, 12 + legOffset); + ctx.fillRect(e.x + 18, e.y + 36, 8, 12 - legOffset); + ctx.fillStyle = e.color; + ctx.fillRect(e.x + 4, e.y + 14, 24, 24); + ctx.fillStyle = '#ffccaa'; + ctx.fillRect(e.x + 6, e.y, 20, 16); + ctx.fillStyle = '#3a4a3a'; + ctx.fillRect(e.x + 4, e.y - 4, 24, 8); + ctx.fillStyle = '#4a2a2a'; + ctx.fillRect(e.x + 16, e.y + 18, 14, 8); + ctx.fillStyle = '#444'; + ctx.fillRect(e.x + 28, e.y + 20, 12, 6); + } else if (e.name === 'tank') { + ctx.fillStyle = '#1a1a1a'; + const trackOffset = Math.sin(e.animFrame * 0.1) * 2; + ctx.fillRect(e.x, e.y + 24 + trackOffset, e.width, 11); + ctx.fillStyle = e.color; + ctx.fillRect(e.x + 4, e.y + 8, e.width - 8, 20); + ctx.fillStyle = '#3d6016'; + ctx.fillRect(e.x + 18, e.y, 24, 14); + ctx.fillStyle = '#2d4010'; + ctx.fillRect(e.x + 36, e.y + 4, 28, 8); + ctx.fillStyle = '#1a1a1a'; + ctx.beginPath(); + ctx.arc(e.x + 30, e.y + 7, 5, 0, Math.PI * 2); + ctx.fill(); + } else if (e.name === 'helicopter') { + ctx.fillStyle = '#333'; + const rotorOffset = Math.sin(e.animFrame * 0.8) * 20; + ctx.fillRect(e.x - 15, e.y - 8, 85, 4); + ctx.fillRect(e.x + 25 + rotorOffset, e.y - 12, 6, 8); + ctx.fillStyle = e.color; + ctx.beginPath(); + ctx.ellipse(e.x + 25, e.y + 8, 30, 15, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#6a8aaa'; + ctx.beginPath(); + ctx.arc(e.x + 40, e.y + 5, 12, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = e.color; + ctx.fillRect(e.x - 20, e.y + 4, 20, 8); + ctx.fillRect(e.x - 25, e.y, 8, 16); + } else if (e.name === 'commando') { + const legOffset = Math.sin(e.animFrame * 0.4) * 8; + ctx.fillStyle = '#2a1a2a'; + ctx.fillRect(e.x + 8, e.y + 38, 7, 12 + legOffset); + ctx.fillRect(e.x + 19, e.y + 38, 7, 12 - legOffset); + ctx.fillStyle = e.color; + ctx.fillRect(e.x + 6, e.y + 16, 22, 24); + ctx.fillStyle = '#ffccaa'; + ctx.fillRect(e.x + 8, e.y + 2, 18, 16); + ctx.fillStyle = '#4a1a4a'; + ctx.fillRect(e.x + 6, e.y - 2, 22, 8); + ctx.fillStyle = '#6a2a6a'; + ctx.fillRect(e.x + 18, e.y + 20, 16, 8); + ctx.fillStyle = '#333'; + ctx.fillRect(e.x + 32, e.y + 22, 14, 6); + } else if (e.name === 'mech') { + const legOffset = Math.sin(e.animFrame * 0.15) * 4; + ctx.fillStyle = '#1a2a3a'; + ctx.fillRect(e.x + 10, e.y + 40, 14, 15 + legOffset); + ctx.fillRect(e.x + 46, e.y + 40, 14, 15 - legOffset); + ctx.fillStyle = e.color; + ctx.fillRect(e.x + 8, e.y + 20, 54, 28); + ctx.fillRect(e.x + 20, e.y + 8, 30, 20); + ctx.fillStyle = '#006688'; + ctx.beginPath(); + ctx.arc(e.x + 35, e.y + 18, 10, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#003344'; + ctx.fillRect(e.x + 50, e.y + 24, 24, 10); + ctx.fillStyle = '#ff4400'; + ctx.beginPath(); + ctx.arc(e.x + 35, e.y + 18, 4, 0, Math.PI * 2); + ctx.fill(); + } + + const hpPct = e.hp / e.maxHp; + ctx.fillStyle = '#333'; + ctx.fillRect(e.x, e.y - 16, e.width, 8); + ctx.fillStyle = hpPct > 0.5 ? '#0f0' : hpPct > 0.25 ? '#ff0' : '#f00'; + ctx.fillRect(e.x + 1, e.y - 15, (e.width - 2) * hpPct, 6); + }); + + powerups.forEach(p => { + const bobY = p.grounded ? Math.sin(p.bobOffset) * 4 : 0; + const glowGradient = ctx.createRadialGradient(p.x + p.width/2, p.y + p.height/2 + bobY, 0, p.x + p.width/2, p.y + p.height/2 + bobY, 20); + glowGradient.addColorStop(0, p.color); + glowGradient.addColorStop(1, 'transparent'); + ctx.fillStyle = glowGradient; + ctx.beginPath(); + ctx.arc(p.x + p.width/2, p.y + p.height/2 + bobY, 20, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = p.color; + ctx.fillRect(p.x + 2, p.y + 4 + bobY, p.width - 4, p.height - 4); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 2; + ctx.strokeRect(p.x + 2, p.y + 4 + bobY, p.width - 4, p.height - 4); + ctx.fillStyle = '#fff'; + ctx.font = 'bold 18px monospace'; + ctx.textAlign = 'center'; + ctx.fillText(p.label, p.x + p.width/2, p.y + 20 + bobY); + ctx.textAlign = 'left'; + }); + + if (shake > 0) ctx.restore(); +} + +function loop() { + update(); + draw(); + requestAnimationFrame(loop); +} + +startWave(); +loop(); diff --git a/style.css b/style.css new file mode 100644 index 0000000..72cd8cd --- /dev/null +++ b/style.css @@ -0,0 +1,82 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: #1a1a2e; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + font-family: 'Courier New', monospace; + overflow: hidden; +} + +#game-container { + position: relative; + border: 4px solid #4a4a6a; + border-radius: 8px; + box-shadow: 0 0 20px rgba(0, 200, 255, 0.3); +} + +#game { + display: block; + background: linear-gradient(to bottom, #16213e 0%, #1a1a2e 100%); +} + +#ui { + position: absolute; + top: 10px; + left: 10px; + right: 10px; + display: flex; + justify-content: space-between; + color: #fff; + font-size: 18px; + font-weight: bold; + text-shadow: 2px 2px 4px rgba(0,0,0,0.8); + pointer-events: none; +} + +#ui div { + background: rgba(0, 0, 0, 0.5); + padding: 8px 12px; + border-radius: 4px; + border: 2px solid #4a4a6a; + backdrop-filter: blur(4px); +} + +#controls-hint { + position: absolute; + bottom: 10px; + left: 50%; + transform: translateX(-50%); + color: #aaa; + font-size: 14px; + background: rgba(0, 0, 0, 0.7); + padding: 8px 16px; + border-radius: 4px; + pointer-events: none; +} + +#pause-btn { + position: absolute; + top: 10px; + right: 10px; + background: rgba(0, 0, 0, 0.6); + color: #fff; + border: 2px solid #4a4a6a; + padding: 8px 16px; + border-radius: 4px; + font-family: 'Courier New', monospace; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; +} + +#pause-btn:hover { + background: rgba(74, 74, 106, 0.8); + border-color: #6a6a8a; +}