Underground 2 Revived
Play Need for Speed: Underground 2 (2004) in Chrome and Firefox with WebGPU graphics, Web Audio, up to 120 fps widescreen, controller support, free roam and Online races with friends. Fan project, not
翻譯準備中
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證正在載入完整 README。 以下為已儲存的摘錄。
Интерактивный редактор тир-листа по Blox Fruits в стиле макета Nexus Tier List (сакура). Чистый HTML/CSS/JS, без сборки. Просто дважды кликните start.bat — он поднимет локальный сервер и откроет редактор в браузере (нужен установленный Python; адрес: http://127.0.0.1:8780/). Можно открыть и index.html напрямую двойным кликом — редактор работает, но кнопка «Скачать PNG» при открытии файлом (file://) не сработает: браузер блокирует экспорт картинок. Поэтому для скачивания используйте start.bat.檢視完整 README ↗
Интерактивный редактор тир-листа по Blox Fruits в стиле макета Nexus Tier List (сакура). Чистый HTML/CSS/JS, без сборки.
Просто дважды кликните start.bat — он поднимет локальный сервер и откроет редактор в браузере
(нужен установленный Python; адрес: http://127.0.0.1:8780/).
Можно открыть и
index.htmlнапрямую двойным кликом — редактор работает, но кнопка «Скачать PNG» при открытии файлом (file://) не сработает: браузер блокирует экспорт картинок. Поэтому для скачивания используйтеstart.bat.
Альтернатива без .bat (в папке проекта):
python -m http.server 8780
и открыть http://127.0.0.1:8780/
+ Тир), переименовать (клик по названию), свой логотип тира (🖼),
переместить ▲▼, удалить ✕.+ в тире или + Предмет), перетаскивать между тирами (drag-and-drop).js/i18n.js, узлы помечены data-i18n* в разметке./api/promo.php, таблица promo) и настраиваются на странице
/admin/promo. Подробности — в разделе «Реклама» ниже.js/protect.js плюс класс
body.protected в css/base.css; админу защита снимается целиком, иначе
не работали бы ни перенос ячеек, ни редактор поста. Это заслон от обычного
посетителя, а не от исходников: разметка видна через «Просмотр кода», а
экран можно сфотографировать..json.Отдельная лента на /news (адрес чистый — редиректит на news.html через
.htaccess, ссылки на неё есть в тулбаре обеих страниц). Правится на
/admin/news — см. раздел «Админка».
Таблицу
newsнужно завести на боевой БД вручную (phpMyAdmin илиmysql < schema.sql). Автоматически это не делается:.cpanel.ymlкопирует в веб-рут толькоpublic_html/, аschema.sqlлежит уровнем выше.Порядок при этом свободный: без таблицы
api/news.phpотвечает пустой лентой, а не пятисоткой — тот же приём, что и вpromo_load(). Страница открывается и честно говорит, что новостей нет. Таблица нужна к моменту, когда захочется опубликовать первый пост:news_save.phpбез неё не запишет.
/newsнамеренно закомментирован вsitemap.xml, пока лента пустая: приглашать поисковик на страницу без содержимого незачем. Раскомментировать вместе с первым постом.
js/news.js, проверяется на сервере api/news_save.php),
поэтому они не могут разойтись в том, что считают допустимой категорией.api/news_save.php сам переводит
пустые EN-поля — заголовок, абзацы, цитаты, пункты списков, подписи к
картинкам — через бесплатный эндпоинт Google (api/lib/translate.php),
сохраняя жирный, ссылки и прочую разметку. Написанный вручную английский не
трогается; английский, оставшийся от прежней версии поправленного русского
абзаца, переводится заново. Код не переводится.pickLang (js/news.js) откатывается на русский
заголовок/текст там, где английского нет — лента не показывает пустые
карточки. Эндпоинт неофициальный, без ключа: Google вправе его ограничить,
и тогда вместо перевода остаётся этот откат.NEWS_FEED_LIMIT в api/news.php — единственное место, где
меняется это число.news (schema.sql), отдельно от tierlist. Публикация
поста не трогает JSON и rev тирлиста, поэтому не сбрасывает кеш и не
вызывает у посетителей тирлиста лишнюю перезагрузку данных.index.html разметка (тулбар, шапка, тиры, легенда, футер, команда, модалка)
news.html разметка ленты новостей (тулбар, сцена, карточки, редактор админа)
css/base.css общий хром обеих страниц: тулбар, кнопки, чипы, переключатель языка, сцена, лепестки, модалка
css/styles.css только тирлист: тема, верстка, дорожки, ячейки
css/news.css только лента: карточки, фильтры, редактор поста (префиксы nw-/ne-)
admin.php /admin — редактор тирлиста: отдаёт index.html с шапкой админки
admin-news.php /admin/news — лента новостей в режиме редактирования
admin-promo.php /admin/promo — панель рекламы
admin-logout.php /admin/logout — выход (POST из шапки)
api/lib/admin_page.php проверка роли, шапка админки, страница входа
css/admin-shell.css шапка админки и экран входа
css/styles.css тема, верстка, дорожки, ячейки
js/app.js вся логика редактора
js/news.js категории, выбор языка и дата поста — общее для ленты и её редактора (без DOM — тестируется в node)
js/news-page.js загрузка ленты, рендер карточек, фильтры и редактор админа на news.html
js/i18n.js строки интерфейса RU/EN (без DOM — тестируется в node)
js/tiers.js марки полос тиров и перевод старых сохранений (без DOM — тестируется в node)
js/protect.js запрет копирования текста и картинок для гостя — общее для тирлиста и ленты (тестируется в node)
js/html2canvas.min.js экспорт в PNG (локально, офлайн)
assets/ картинки макета + шрифты
poster/ всё, что вырезано из PSD «макнеми тир под сайт» (актуальный вид)
bg.webp / bg-m.webp / bg-export.jpg фон (сакура/храм/водопад), уже с цветокором
petals.webp / petals-m.webp / petals-export.png лепестки и декор
band.webp / band.png плашка тира
logo-bf.png логотип Blox Fruits
marks.png марки шапки (молния + GLH + MK)
dot-*.png / trend-*.png точки спроса и тренды легенды
trend-new.png / trend-wip.png они же на карточках предметов (NEW и «?»)
design/legend/ значки типов (FR, CS, CM, MS, PM, GP, CR, VH) и тренды из
редизайна — вектор, один набор на легенду и на карточки
bg.png / petals.png / bg-stage*.webp фон прошлого макета (не подключён)
dots-band.png полутоновые точки плашки тира прошлого макета (не подключён)
dots-tile.png полутоновая текстура дорожки
brand-logos.png логотип шапки прошлого макета (не подключён)
logo-bolt.png / logo-glh.png / logo-mk.png логотипы тиров, те же марки, что в шапке
logo-flame.png марка тира прошлого макета (не подключён — старые сохранения переводятся на logo-bolt.png)
bf-logo-trim.png логотип Blox Fruits прошлого макета (не подключён)
dot-*.png / trend-*.png точки спроса и тренды на карточках предметов и в модалке
dot-neon.png точка «оверпрайс» — единственная с переливом, кадр из неонового градиента
dot-{red,orange,yellow,green}.png остальные точки, 72x72 как неон; рисует tools/make-demand-dots.py
icon-sample.png стандартная иконка предмета
fonts/Bootshaus/Bootshaus-Regular.ttf шрифт легенды и подвала
fonts/ProtoSans56.otf шрифт заголовка и даты
Файлы прошлого макета оставлены намеренно: деплой копирует дерево поверх, ничего не удаляя, так что удаление из репозитория всё равно не убрало бы их с сервера — зато сломало бы откат, если новый вид не зайдёт.
Всё редактирование живёт на отдельных адресах, под общей шапкой
«Тирлист · Новости · Реклама · Обращения». Ссылку на панель видят только админы
и модераторы — пунктом в меню аватара, — а ни app.js, ни news-page.js не
спрашивают роль у сервера.
| Адрес | Что это |
|---|---|
/admin | редактор тирлиста — тот же постер, что на сайте, плюс тулбар |
/admin/news | лента новостей с кнопкой «+ Новость» и ✎/✕ на карточках |
/admin/promo | панель рекламы |
/admin/support | обращения из /support |
/admin/logout | выход, только POST из шапки |
Пароля у панели нет. Админы и модераторы входят через Roblox, как все, а
права дают два списка Roblox id в config.php:
| Ключ | Что открыто |
|---|---|
admin_ids | вся панель: тирлист, новости, реклама, обращения, баны |
moderator_ids | только /admin/support и /admin/bans (и уведомления в Telegram) |
Роль проверяет сервер, до отдачи разметки (admin_page_guard()). Всем, кого
нет в списках, — и гостю, и вошедшему игроку — панели как будто нет: ответ
байт в байт тот же 404, что LiteSpeed отдаёт на несуществующий адрес, без
кнопки входа и без «нет доступа»; в robots.txt её тоже нет. Админ и модератор
входят на сайт через Roblox из шапки, как все, и открывают панель пунктом меню
аватара. Модератор на странице админа уходит на /admin/support. Роль в
сессии не хранится и сверяется с конфигом на каждом запросе: вычеркнутый id
теряет доступ сразу. /api/login.php оставлен заглушкой с 410 — деплой не удаляет
файлы, и прежний на сервере продолжал бы принимать пароль.
Бан — третий список, banned_ids, в том же config.php. Roblox id
забаненного берётся из адреса его профиля (/profile?id=…). Со следующего
запроса он выходит из аккаунта на всех устройствах: сессия пустеет, ключи
«запомнить вход» гаснут. Войти заново не может: шапка показывает «Этот аккаунт
заблокирован». Его объявления пропадают из ленты и профиля, переписка у
собеседников остаётся. Бан сильнее роли. Чтобы снять бан, id убирают из
списка, и неистёкшие объявления возвращаются.
Бан из панели. Модераторы и админы банят и без доступа к серверу: кнопкой
в шапке переписки на /chat или на /admin/bans, где игрока ищут по нику,
логину Roblox или id. Срок — 1, 3, 7, 30 дней или навсегда, причина по
желанию. Модератор банит только обычных игроков, админ — ещё и модераторов,
админа из панели не забанит никто (иначе модератор мог бы закрыть владельцу
вход в панель). Такие баны лежат в таблице user_bans (миграция
docs/migrations/2026-10-10-user-bans.sql) и действуют так же, как список в
конфиге: проверка одна, site_banned(). Бан из config.php панель показывает,
но не снимает. Пока миграции нет, работает только список в конфиге.
Бан бывает временным: вместо id пишут id => срок по Москве,
'2222222222' => '2026-10-20' (до 00:00 этого дня) или
'2222222222' => '2026-10-20 18:30'. Когда срок выходит, бан снимается сам и
трогать конфиг не нужно, но войти придётся заново. Шапка называет дату конца
во времени того, кто смотрит. Если дата похожа на настоящую, но такой нет
(2026-02-30), бан считается бессрочным.
Разметка не продублирована: admin.php читает index.html, admin-news.php —
news.html, и оба дописывают шапку, window.NX_ADMIN_PAGE и свою таблицу
стилей (редактор тирлиста ещё и вырезает счётчик статистики). Копия разъехалась бы с
оригиналом на первой же правке вёрстки.
Разница между двумя редакторами есть, и она вынужденная. У тирлиста тулбар
перемешан с публичными кнопками — фильтры, переключатель языка, — поэтому он
остаётся в index.html, а посетителю просто скрыт. У новостей редактор
самостоятельный: модалка на восемь полей и кнопка «+ Новость» вставляются
только admin-news.php, и публичная лента не несёт админской разметки вовсе.
Адрес /admin без завершающего слэша (его снимает 301 в .htaccess) — это
не косметика. Относительные пути внутри index.html и app.js
(assets/dot-green.png, js/html2canvas.min.js) считаются от адреса документа:
на /admin они разрешаются от корня, на /admin/ уехали бы в /admin/assets/
и вернули бы 404.
Прежний адрес панели рекламы /promo-admin.html редиректит на /admin/promo.
Правило стоит в .htaccess до отдачи файлов, поэтому работает и на боевом
сервере, где старый файл ещё лежит: деплой копирует поверх и ничего не удаляет.
Посетитель входит на сайт своим аккаунтом Roblox — по официальному OAuth 2.0
/ OpenID Connect самого Roblox. Логин, пароль и 2FA он вводит на roblox.com,
обратно приходит одноразовый код, и сервер сам забирает по нему ник и аватар.
Своей регистрации с паролем на сайте нет: хранить чужие пароли ради тирлиста
незачем, а «войти через Roblox» здесь и честнее — аккаунт в игре и есть та
личность, которая нужна для трейдов.
| Адрес | Что делает |
|---|---|
/api/roblox_start.php | кладёт в сессию state + PKCE-verifier и уводит на страницу согласия Roblox |
/api/roblox_callback.php | сверяет state, меняет код на токен, забирает профиль, запоминает вход |
/api/session.php | {admin, moderator, user, roblox, roblox_public} — по нему шапка решает, что показать |
/api/logout.php | выход, POST (тот же, что у админки: сессия одна) |
Запрашиваются только openid profile — ник, отображаемое имя и аватар.
Инвентарь и трейды OAuth не отдаёт вовсе: какие у игрока фрукты, узнать
отсюда нельзя, и на это рассчитывать не стоит. Токены Roblox нигде не
сохраняются — access token живёт один запрос userinfo и выбрасывается вместе с
refresh-токеном: нечего хранить — нечего и утекать.
create.roblox.com/dashboard/credentials (на аккаунте
должна быть включена 2FA). Взять Client ID и Client Secret.https://maknemy.com/api/roblox_callback.php.
Roblox сверяет строку целиком: лишний слэш или www. — и кода не будет.
Разрешение: openid, profile.schema.sql (появилась таблица users).config.php (тот, что лежит выше public_html):'roblox_client_id' => '...',
'roblox_client_secret' => '...',
'roblox_redirect_uri' => 'https://maknemy.com/api/roblox_callback.php',
'roblox_login_public' => false,
Пока этих ключей нет, вход выключен целиком: /api/roblox_start.php
отвечает 503, а кнопка профиля в шапке остаётся прежней заглушкой «В активной
разработке». Ничего выкатывать заново для включения не надо — правки конфига
достаточно.
Пока приложение в Roblox не опубликовано, войти могут только 10 разных
аккаунтов — 11-й получит отказ от самого Roblox. Поэтому при
'roblox_login_public' => false кнопку входа видят не все, а только открывшие
сайт по ссылке с ?signin:
https://maknemy.com/?signin
Остальные видят прежнюю заглушку «В активной разработке», и места не
разбирают случайные посетители. js/topbar.js запоминает приглашение в
localStorage (nexus-signin-v1) и убирает signin из адреса: дальше по
сайту параметр не нужен, а скопированный адрес не разнесёт приглашение. Это
ограничение показа кнопки, а не доступа: /api/roblox_start.php приглашения
не проверяет.
После одобрения приложения в config.php ставится
'roblox_login_public' => true — и кнопку видят все. Выкатывать ничего не
нужно. Проверка в браузере: node tools/check-signin-invite-ui.mjs.
state — одноразовый, живёт в серверной сессии и сверяется через
hash_equals. Заявка гасится до всех проверок, поэтому один код нельзя
принести дважды.client_secret перехваченный из адресной
строки код бесполезен без verifier, а тот по сети не ходит.?return=https://зло.example
превращается в / — иначе ссылку «вход на maknemy» можно было бы разослать как
переадресацию на чужой сайт.picture из userinfo
отбрасывается, а не попадает в <img src>.sub — строка из одних цифр. (int)"12abc" дал бы юзера 12, то есть
чужой аккаунт.session_regenerate_id) — подсунутый заранее
PHPSESSID не станет идентификатором авторизованного посетителя./privacy и /terms — политика конфиденциальности и условия использования.
Обе собирает api/lib/legal_page.php: своей вёрстки у них нет, только текст.
Каждая страница несёт обе языковые версии сразу — русскую и следом
английскую с lang="en". Переключатель языка сюда не подключён намеренно:
юридический текст не должен зависеть от того, что лежит в localStorage, а
ревьюер Roblox англоязычный и обязан увидеть английскую версию, ничего не
нажимая.
Шапка на этих страницах урезана до логотипа и двух пилюль назад на сайт —
пятая и шестая копии полного хрома ничего бы не дали, кроме двух новых мест,
где он может разъехаться. Ссылки на обе страницы стоят в общем подвале сайта
(.mk-foot-legal), это требование Roblox: политику должно быть видно с сайта,
а не только по прямой ссылке из карточки приложения.
tests/legal_pages_test.php держит текст в согласии с кодом: если из политики
пропадёт упоминание Google Analytics и его cookie или фраза о том, что
паролей мы не храним, тест упадёт. Документ, разошедшийся с поведением сайта, — это не
устаревший текст, а неправда.
Свежее OAuth-приложение живёт в private mode с потолком 10 уникальных
пользователей. Чтобы потолок сняли, в карточке приложения заполняются
Description, Entry Link (https://maknemy.com/?signin — см. «Вход по
приглашению»), Privacy Policy URL (https://maknemy.com/privacy) и Terms of
Service URL (https://maknemy.com/terms), после чего Edit and Publish →
Review and Publish → Submit for Review.
Форма ревью требует ещё две вещи:
openid и
profile, то есть всё, что просит сайт.Пока ревью идёт, приложение редактировать нельзя, и оно остаётся приватным.
После одобрения вернуть его в private уже не получится, а в config.php
ставится 'roblox_login_public' => true.
Описание приложения проходит через модерацию Roblox, и она глухая: отвечает «contained inappropriate text», не называя слова. Подбирать приходится короткими правками — на формулировке про trading она уже срабатывала.
Разметка на всех четырёх страницах не менялась: кнопка профиля приезжает той же
заглушкой data-soon, а js/topbar.js по ответу /api/session.php делает из
неё аватар с меню («Профиль в Roblox», «Выйти») для вошедшего, ссылку «Войти
через Roblox» — если вход открыт всем или посетитель пришёл по приглашению, а
иначе оставляет заглушкой. Поэтому в PHP-страницах нет ни одного ветвления по
входу — и четвёртой копии условия тоже.
Меню лежит в <body> с position: fixed, а не внутри шапки: у .mk-top стоит
overflow-x: clip с откатом на hidden, и в движках без clip выпадающий
список обрезало бы по краю шапки.
Три места: карусель в середине постера (strip), боковые борта (rail,
только десктоп от 1460×760) и всплывающее окно через 12 секунд после захода
(popup). Статика и анимация (WebP/GIF) смешиваются свободно.
| Файл | Что делает |
|---|---|
js/promo.js | расписание, взвешенная ротация, частотный лимит окна. Без DOM — тестируется в node |
api/promo.php | хранение кампаний, отдельная таблица promo, свой rev |
admin-promo.php + js/promo-admin.js | панель /admin/promo: список кампаний, загрузка макетов, предпросмотр |
docs/mediakit/ | коммерческое предложение рекламодателю: места, пакеты, спецификация, собранный PDF. Вне public_html — на сайт не публикуется, файл отдаётся клиенту напрямую. Цены — заглушки, правятся в блоке PRICES в конце mediakit.html |
Кампании принципиально не лежат в state. Причины записаны в шапке
api/promo.php: всё, что попадает в блоб тирлиста, проходит через
save_image_bytes() и ужимается до 256 px; сохранение блоба работает по
принципу «последний записал — тот и прав»; а сам блоб отдаётся с годовым
immutable-кэшем, и смена баннера заставляла бы каждого посетителя качать
тирлист заново.
Файл называется promo.js, а не ads.js, классы — ptn-*, а не ad-*:
##.ad-block и /js/ads.js — стандартные правила блокировщиков рекламы.
Новая таблица заводится тем же schema.sql. Пока он не прогнан, сайт работает:
promo_load() и state.php читают отсутствие таблицы как «кампаний нет».
Локальный показ без PHP (файлы в .gitignore, на боевой сайт не уезжают):
__seed.local.html кладёт снимок боевой базы в localStorage
__promo.local.html кладёт демо-кампании и открывает сайт
Иконки предметов хранятся с длинной стороной не больше 256 px
(ICON_MAX_SIDE в api/lib/images.php). Порог не косметический: на экране
иконка занимает максимум ~130 device-px, а Safari держит в памяти
распакованный битмап целиком. Исходники по 800×800 давали 63 МБ битмапов на
полный тирлист — вкладка начинала выбрасывать декодированные картинки, и
иконки пропадали прямо во время прокрутки.
Сжатие применяется при загрузке автоматически. Картинки, загруженные раньше, переводятся разово:
php tools/downscale-images.php --dry-run # только отчёт
php tools/downscale-images.php # сжать и перепривязать
На боевом хостинге config.php лежит над веб-рутом, вне клона репозитория,
поэтому туда нужно указать путь явно:
php tools/downscale-images.php --config=/home/maknemyt/config.php --dry-run
Сайт работает на PHP 7.4 — писать код нужно под неё.
Локально почти наверняка стоит восьмёрка, и это ловушка: php -l проверяет
только синтаксис, а функции 8.0+ синтаксически безупречны на любой версии.
str_contains() спокойно линтуется, проходит весь прогон тестов на машине
разработчика — и падает Call to undefined function уже на бою.
Держит это tests/php74_compat_test.php: он разбирает все .php в
public_html/ токенайзером и роняет прогон, если встретит функцию или
синтаксис новее 7.4. Список запрещённого лежит в самом тесте — если однажды
на бою вылезет что-то мимо него, допишите туда вместе с починкой.
Из-под ограничения выведены tests/ и .claude/: по .cpanel.yml
публикуется только public_html/, на сервер они не уезжают. Тесты всё же
проверяются тем же стражем — прогон могут запустить и на 7.4.
.htaccess отдаёт на все ответы заголовок CSP. Главное в нём — script-src 'self' https://www.googletagmanager.com
без 'unsafe-inline':
если в чат, объявление или профиль пролезет разметка со скриптом или
onerror=, браузер её не исполнит, и чужой код не сможет действовать от имени
вошедшего админа.
Из этого правила для кода:
<script>…</script> и обработчиков on*= в
разметке — их браузер молча не исполнит. Данные для скриптов передаются
через <meta> (nx-rev в тирлисте, nx-post в новостях) или отдельным
файлом (js/admin-flag.js). <script type="application/ld+json"> —
данные, их можно.metrika_counter_html() печатает его
блок (js/ga.js, номер — GA_ID в api/lib/google_analytics.php; пустой
номер выключает GA). Цели по кликам на рекламу шлёт PROMO.goal() из
js/promo.js. Яндекс Метрику сняли 2026-10-20; имя функции осталось от неё.Всё это держит tests/csp_test.php. Если после деплоя что-то сломалось,
полный откат — закомментировать строку Header always set Content-Security-Policy в .htaccess.
bash tests/run_all.sh
PHP-тесты покрывают API, node-тесты (tests/*_test.mjs) — словарь интерфейса,
язык описаний, марки полос тиров, логику показа рекламы и защиту от
копирования. Если node не установлен, его часть пропускается с явным
предупреждением.
tests/images_test.php и tests/creatives_test.php требуют GD. В XAMPP
расширение выключено в php.ini, и без него эти тесты дают ложные падения.
run_all.sh берёт бинарник из $PHP одним словом, поэтому флаг в
переменную не запихнуть — нужна обёртка:
printf '#!/usr/bin/env bash\nexec /c/xampp/php/php.exe -d extension=gd "$@"\n' > /tmp/php-gd.sh
chmod +x /tmp/php-gd.sh
PHP=/tmp/php-gd.sh bash tests/run_all.sh