studio/src/community/RealtimeTest.jsx
МИН 61fba4e174
Some checks failed
CI / Lint + Format (push) Failing after 32s
CI / Build (push) Failing after 37s
CI / Secret scan (push) Failing after 37s
CI / PR size check (push) Has been skipped
fix: починка билда + studio.rublox.pro инфра
Большой консолидирующий коммит после поднятия studio.rublox.pro (28 мая 2026).
Содержит изменения которые делались в процессе подготовки прод-окружения:

Фиксы импортов после выноса из minecraftia:
- Массовая замена путей ../../components → ../components (40+ файлов в src/community/, src/admin-preview/)
- Замена ../KubikonEditor/ → ../editor/, ../KubikonStudio/ → ../community/, ../AdminPreview/ → ../admin-preview/
- API.js скопирован из минки целиком (было 8 экспортов, стало 312)
- Добавлены PLAYER_URL, MyButton_1, недостающие компоненты
- Заменены require() на статические ES-imports в BabylonScene, PrimitiveManager, GameRuntime (Vite не поддерживает CJS require)

Структура ассетов:
- public/kubikon-templates/ → public/assets/kubikon-templates/
- public/kubikon-learn/ → public/assets/kubikon-learn/
- (код искал в /assets/, файлы лежали без /assets/)

Навигация роутов внутри студии:
- /kubikon-studio/docs → /docs (90+ навигационных вызовов sed-replaced)
- /kubikon-editor/X → /edit/X, /kubikon/play/X → /play/X, /kubikon/gd/X → /gd/X

UI:
- Новый компонент StudioHeader (61px, как в минке) + копия favicon
- WithHeader wrapper в App.jsx для всех страниц кроме fullscreen-редактора/плеера
- SSO ticket-flow в AuthContext (auto-redeem #ticket= при загрузке)
- Тёмная тема карточек игр в ВИКИ (фон #1c2231 вместо #fff, картинка впритык)

Документация:
- docs/ONBOARDING.md — путь нового контрибьютора от нуля до PR
- docs/TUTORIAL_ADD_SCRIPT_API.md — как добавить game.* API
- API_USAGE.md — список эндпоинтов backend
- README в подпапках engine/, engine/terrain/, engine/voxel/, engine/robloxterrain/, engine/types/

.gitignore:
- public/wiki/ исключён (73МБ PNG, будут на CDN отдельной задачей)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 05:01:13 +03:00

1525 lines
70 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Client, getStateCallbacks } from 'colyseus.js';
import { Link } from 'react-router-dom';
import { REALTIME_WS, REALTIME_HTTP } from '../api/API';
import { KT, KUBIKON_KEYFRAMES } from '../utils/kubikonTheme';
import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
import Icon from '../editor/Icon';
/**
* RealtimeTest — отладочная страница для подэтапа 4.1 мультиплеера.
* URL: /kubikon-realtime-test
*
* Что делает:
* 1. Кнопка «Подключиться» — создаёт colyseus.js Client и
* делает joinOrCreate('battle', { projectId, username }).
* 2. Пишет в лог всё что прилетает: state.players add/remove,
* messages 'welcome', serverTime tick, errors.
* 3. Кнопка «Отправить hello» шлёт сообщение на сервер,
* получаем 'welcome' обратно.
* 4. Кнопка «Health» проверяет HTTP /health.
*
* Цель: убедиться что вся цепочка
* браузер → NPM (когда настроим) → VM 1598 → docker → Colyseus
* работает.
*/
const RealtimeTest = () => {
const [logs, setLogs] = useState([]);
const [status, setStatus] = useState('disconnected'); // 'disconnected' | 'connecting' | 'connected'
const [room, setRoom] = useState(null);
const [players, setPlayers] = useState([]);
const [serverTime, setServerTime] = useState(null);
const [projectId, setProjectId] = useState('1');
const [healthData, setHealthData] = useState(null);
// Токен берём из localStorage — тот же что и для всего API школы.
// Если ты залогинен — там валидный JWT с {id, exp}. Если гость — токен
// с {guest: true}, и сервер откажет с 403 'guests_not_allowed' (это нормально).
const tokenInfo = (() => {
try {
const raw = localStorage.getItem('Authorization');
if (!raw) return { hasToken: false, isGuest: false, userId: null };
const t = raw.startsWith('Bearer ') ? raw.slice(7) : raw;
const p = JSON.parse(atob(t.split('.')[1]));
const isGuest = p?.guest === true || p?.type === 'guest'
|| (typeof p?.sub === 'string' && p.sub.startsWith('guest_'));
return { hasToken: true, isGuest, userId: p?.id || null, raw };
} catch (e) {
return { hasToken: false, isGuest: false, userId: null };
}
})();
const [moving, setMoving] = useState(false);
/** WASD-режим — клиент сам управляет своим игроком клавишами. */
const [wasdMode, setWasdMode] = useState(false);
/** Модалка «Куда подключиться?». 'closed' | 'list' | 'by-code'. */
const [joinModal, setJoinModal] = useState('closed');
/** Список доступных публичных комнат для projectId. */
const [availableRooms, setAvailableRooms] = useState([]);
const [roomsLoading, setRoomsLoading] = useState(false);
/** Введённый код приватной комнаты. */
const [joinCode, setJoinCode] = useState('');
/** Если мы вошли по коду — храним roomId для UI «ты в комнате XYZ». */
const [currentRoomCode, setCurrentRoomCode] = useState('');
const clientRef = useRef(null);
const logRef = useRef(null);
const moveTimerRef = useRef(null);
const inputTimerRef = useRef(null);
const wasdLoopRef = useRef(null);
const canvasRef = useRef(null);
/** Какие клавиши сейчас нажаты (управление WASD). */
const keysRef = useRef({ w: false, a: false, s: false, d: false });
/** Локальная позиция этого клиента — то что мы шлём серверу. */
const myPosRef = useRef({ x: 0, y: 0, z: 0, yaw: 0 });
/** Список игроков для рендера canvas (живой ref, не state).
* setPlayers триггерит ре-рендер всего React-дерева, а нам нужен
* только canvas — поэтому держим параллельный ref. */
const playersRef = useRef([]);
/** Список выстрелов-визуальных (трейсеров) для отрисовки. */
const shotsRef = useRef([]);
/** Список вспышек попадания для отрисовки. */
const hitsRef = useRef([]);
/** Добавить строку в лог. Авто-скролл к концу. */
const log = useCallback((level, ...parts) => {
const text = parts.map(p =>
typeof p === 'string' ? p : JSON.stringify(p)
).join(' ');
setLogs(prev => [
...prev,
{ level, text, ts: Date.now() },
].slice(-200)); // храним только последние 200 строк
}, []);
// Авто-скролл лога вниз
useEffect(() => {
const el = logRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [logs]);
/**
* Универсальное подключение. mode:
* 'public' — joinOrCreate (попадаем в первую свободную / создаём новую публичную)
* 'private' — create с private:true (только мы и тот кому скажем код)
* 'by-code' — joinById по конкретному roomId
* 'specific' — joinById по конкретной комнате из списка
*/
const connectAs = async (mode, opts = {}) => {
if (status === 'connected' || status === 'connecting') return;
if (!tokenInfo.hasToken) {
log('error', 'В localStorage нет токена. Сначала войди в аккаунт.');
return;
}
if (tokenInfo.isGuest) {
log('error', 'Гостевые токены в realtime не пускаются. Зарегистрируйся.');
return;
}
setJoinModal('closed');
setStatus('connecting');
log('info', `Подключаемся к ${REALTIME_WS} (${mode})…`);
try {
const client = new Client(REALTIME_WS);
clientRef.current = client;
const baseOptions = {
projectId: Number(projectId) || 0,
token: tokenInfo.raw,
};
let r;
if (mode === 'public') {
r = await client.joinOrCreate('battle', baseOptions);
} else if (mode === 'private') {
r = await client.create('battle', { ...baseOptions, private: true });
} else if (mode === 'specific') {
// Прямой joinById с конкретным roomId из getAvailableRooms.
if (!opts.roomId) throw new Error('roomId required');
r = await client.joinById(opts.roomId, baseOptions);
} else if (mode === 'by-code') {
// Сначала ищем roomId по короткому коду через REST-endpoint
// /find-room/:code (он ходит в matchMaker.query и матчит metadata.roomCode).
const code = String(opts.roomId || '').toUpperCase();
if (!code) throw new Error('code required');
log('info', `Ищем комнату по коду ${code}`);
const lookupRes = await fetch(`${REALTIME_HTTP}/find-room/${encodeURIComponent(code)}`);
if (lookupRes.status === 404) {
throw new Error(`Комната с кодом «${code}» не найдена`);
}
if (!lookupRes.ok) {
throw new Error(`Lookup error HTTP ${lookupRes.status}`);
}
const lookup = await lookupRes.json();
log('info', `Нашли roomId=${lookup.roomId}, входим…`);
r = await client.joinById(lookup.roomId, baseOptions);
} else {
throw new Error(`unknown mode: ${mode}`);
}
setRoom(r);
setStatus('connected');
const code = (r.roomId || '').slice(0, 6).toUpperCase();
setCurrentRoomCode(code);
log('ok', `✓ Joined room ${r.roomId} (code=${code}), sessionId=${r.sessionId}`);
if (mode === 'private') {
log('info', `🔑 Код приватной комнаты: ${code} — поделись им с другом`);
}
// === Подписка на state ===
// В Colyseus 0.16 state-колбэки прокидываются через getStateCallbacks(room).
// На сервере (@colyseus/schema 3.x) поля стали "lazy", и .onAdd на MapSchema
// напрямую больше не работает — нужен фасад $.
const $ = getStateCallbacks(r);
$(r.state).players.onAdd((player, sessionId) => {
log('info', `+ player ${sessionId} (${player.username})`);
const entry = {
sessionId,
username: player.username,
x: player.x, y: player.y, z: player.z, yaw: player.yaw || 0,
hp: player.hp ?? 100,
maxHp: player.maxHp ?? 100,
isDead: !!player.isDead,
kills: player.kills || 0,
deaths: player.deaths || 0,
};
playersRef.current = [
...playersRef.current.filter(p => p.sessionId !== sessionId),
entry,
];
setPlayers([...playersRef.current]);
// Подписка на изменения полей этого Player'а — координаты приходят
// из state-snapshot'ов сервера (после нашего onMessage 'input').
// Без этого state-callbacks мы бы не знали, что player.x изменился.
$(player).onChange(() => {
const idx = playersRef.current.findIndex(p => p.sessionId === sessionId);
if (idx === -1) return;
playersRef.current[idx] = {
...playersRef.current[idx],
x: player.x, y: player.y, z: player.z, yaw: player.yaw || 0,
hp: player.hp,
maxHp: player.maxHp,
isDead: !!player.isDead,
kills: player.kills || 0,
deaths: player.deaths || 0,
};
// Не вызываем setPlayers на каждый input — слишком часто
// (40+ раз/сек = тормозит React). Список перерисуем позже,
// canvas же рисует напрямую из playersRef в своём 60-FPS-loop'е.
});
}, true); // immediate=true — обработать уже существующих игроков
$(r.state).players.onRemove((player, sessionId) => {
log('warn', `- player ${sessionId} (${player.username})`);
playersRef.current = playersRef.current.filter(p => p.sessionId !== sessionId);
setPlayers([...playersRef.current]);
});
// serverTime обновляется 20 раз/сек — пишем не каждый,
// только раз в 2 сек, чтобы не спамить лог.
let lastLoggedSecond = 0;
$(r.state).listen('serverTime', (t) => {
setServerTime(t);
const sec = Math.floor(t / 2000);
if (sec !== lastLoggedSecond) {
lastLoggedSecond = sec;
log('debug', `tick serverTime=${new Date(t).toISOString().slice(11, 23)}`);
}
});
// === Сообщения ===
r.onMessage('welcome', (m) => {
log('ok', '← welcome', m);
});
// Выстрел кого-то (включая нас) — рисуем трейсер на 200мс
r.onMessage('shot', (m) => {
shotsRef.current.push({
originX: m.originX, originZ: m.originZ,
dirX: m.dirX, dirZ: m.dirZ,
distance: m.hitDistance || 30,
hit: !!m.hit,
expiresAt: Date.now() + 200,
});
});
// Попадание — короткая красная вспышка вокруг victim'а
r.onMessage('hit', (m) => {
hitsRef.current.push({
sessionId: m.victimSessionId,
expiresAt: Date.now() + 250,
});
if (m.victimSessionId === r.sessionId) {
log('warn', `🩸 Получил урон ${m.damage}! HP=${m.victimHp}/${m.victimMaxHp}`);
} else if (m.shooterSessionId === r.sessionId) {
log('ok', `🎯 Попал в ${m.victimSessionId}, нанёс ${m.damage} урона`);
}
});
r.onMessage('kill', (m) => {
if (m.killerSessionId === r.sessionId) {
log('ok', `💀 Убил ${m.victimName}!`);
} else if (m.victimSessionId === r.sessionId) {
log('warn', `☠️ Тебя убил ${m.killerName}. Респаун через 2с`);
} else {
log('info', `${m.killerName} убил ${m.victimName}`);
}
});
r.onMessage('respawn', (m) => {
if (m.sessionId === r.sessionId) {
log('ok', '✨ Респаун! Вернулся в (0,0,0) с полным HP');
// Сбросим локальную позицию — иначе клиент сразу зашлёт
// input со старой и сервер опять увидит «телепорт».
myPosRef.current = { x: 0, y: 0, z: 0, yaw: 0 };
}
});
r.onLeave((code) => {
log('warn', `Disconnected (code=${code})`);
stopMoving();
stopWasd();
setStatus('disconnected');
setRoom(null);
playersRef.current = [];
shotsRef.current = [];
hitsRef.current = [];
setPlayers([]);
});
r.onError((code, message) => {
log('error', `Room error: code=${code}, message=${message}`);
});
} catch (e) {
setStatus('disconnected');
// ServerError из onAuth прилетает сюда: e.code и e.message
// (например, 403 + 'Мультиплеер доступен только зарегистрированным…').
const code = e?.code ?? '';
const msg = e?.message || String(e);
if (code) {
log('error', `Сервер отказал (code=${code}): ${msg}`);
} else {
log('error', 'Не удалось подключиться:', msg);
}
console.error('[RealtimeTest] connect error:', e);
}
};
/** Получить список ПУБЛИЧНЫХ комнат для текущего projectId через
* собственный realtime-endpoint /list-rooms/:projectId.
* Стандартный /matchmake/battle в Colyseus 0.16 не работает как listing
* (он только для joinOrCreate/create/join/joinById/reconnect). */
const fetchAvailableRooms = useCallback(async () => {
setRoomsLoading(true);
try {
const pid = encodeURIComponent(String(Number(projectId) || 0));
const res = await fetch(`${REALTIME_HTTP}/list-rooms/${pid}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const rooms = await res.json(); // массив room-info
setAvailableRooms(Array.isArray(rooms) ? rooms : []);
} catch (err) {
log('error', 'Не удалось получить список комнат:', err?.message || err);
setAvailableRooms([]);
} finally {
setRoomsLoading(false);
}
}, [projectId, log]);
/** Послать текущую позицию серверу. */
const sendInput = useCallback(() => {
if (!room) return;
const p = myPosRef.current;
room.send('input', { x: p.x, y: p.y, z: p.z, yaw: p.yaw });
}, [room]);
/** Выстрел в направлении (worldX, worldZ) от текущей позиции игрока. */
const shootAt = useCallback((worldX, worldZ) => {
if (!room) return;
const p = myPosRef.current;
const dx = worldX - p.x;
const dz = worldZ - p.z;
const len = Math.hypot(dx, dz);
if (len < 0.001) return;
room.send('shoot', {
originX: p.x,
originZ: p.z,
dirX: dx / len,
dirZ: dz / len,
});
}, [room]);
/** Включить WASD-режим. Слушаем клавиши, в локальном loop'е считаем
* позицию, шлём её серверу 20 раз/сек. */
const startWasd = useCallback(() => {
if (!room || wasdLoopRef.current) return;
log('info', '⌨️ WASD-режим включён (5 м/с). Кликни на canvas.');
setWasdMode(true);
const SPEED = 5; // метров в секунду
let lastTs = Date.now();
wasdLoopRef.current = setInterval(() => {
const now = Date.now();
const dt = (now - lastTs) / 1000; // секунды
lastTs = now;
const k = keysRef.current;
let vx = 0, vz = 0;
if (k.w) vz -= 1;
if (k.s) vz += 1;
if (k.a) vx -= 1;
if (k.d) vx += 1;
// Нормализуем диагональ — без этого вход (1,1) даёт скорость √2
const len = Math.hypot(vx, vz);
if (len > 0) { vx /= len; vz /= len; }
const p = myPosRef.current;
myPosRef.current = {
x: p.x + vx * SPEED * dt,
y: 0,
z: p.z + vz * SPEED * dt,
yaw: (vx === 0 && vz === 0) ? p.yaw : Math.atan2(vx, -vz),
};
}, 16);
// Шлём серверу 20 раз/сек
if (!inputTimerRef.current) {
inputTimerRef.current = setInterval(sendInput, 50);
}
}, [room, sendInput, log]);
const stopWasd = useCallback(() => {
if (wasdLoopRef.current) {
clearInterval(wasdLoopRef.current);
wasdLoopRef.current = null;
}
if (inputTimerRef.current && !moveTimerRef.current) {
clearInterval(inputTimerRef.current);
inputTimerRef.current = null;
}
keysRef.current = { w: false, a: false, s: false, d: false };
setWasdMode(false);
log('info', '⌨️ WASD-режим выключен');
}, [log]);
// Слушаем WASD на window. Активны только в WASD-режиме.
useEffect(() => {
if (!wasdMode) return;
const map = { KeyW: 'w', KeyA: 'a', KeyS: 's', KeyD: 'd' };
const onDown = (e) => {
const k = map[e.code];
if (k) { keysRef.current[k] = true; e.preventDefault(); }
};
const onUp = (e) => {
const k = map[e.code];
if (k) { keysRef.current[k] = false; }
};
window.addEventListener('keydown', onDown);
window.addEventListener('keyup', onUp);
return () => {
window.removeEventListener('keydown', onDown);
window.removeEventListener('keyup', onUp);
};
}, [wasdMode]);
/** Симулировать движение по кругу: меняем myPos.x/z по синусу/косинусу.
* Радиус наращивается плавно за первые 2 секунды, чтобы не было
* телепорта в (5,0,0) и сервер не отбил sanity-check'ом. */
const startMoving = useCallback(() => {
if (!room) return;
if (moveTimerRef.current) return;
log('info', '🎮 Симуляция движения запущена (круг радиусом 5м, плавный старт)');
setMoving(true);
const startTs = Date.now();
const TARGET_R = 5; // целевой радиус
const RAMP_SEC = 2; // время наращивания радиуса
const omega = (Math.PI * 2) / 6; // период 6 сек
// 60 FPS симуляция позиции — обновляем myPosRef
moveTimerRef.current = setInterval(() => {
const t = (Date.now() - startTs) / 1000;
// Текущий радиус: от 0 до TARGET_R за RAMP_SEC, потом константа.
const r = t < RAMP_SEC ? TARGET_R * (t / RAMP_SEC) : TARGET_R;
myPosRef.current = {
x: r * Math.cos(omega * t),
y: 0,
z: r * Math.sin(omega * t),
// yaw — направление движения (касательная к кругу)
yaw: Math.atan2(Math.cos(omega * t), -Math.sin(omega * t)),
};
}, 16);
// Шлём input серверу 20 раз/сек (внутри лимита 30/сек)
inputTimerRef.current = setInterval(sendInput, 50);
}, [room, sendInput, log]);
const stopMoving = useCallback(() => {
if (moveTimerRef.current) {
clearInterval(moveTimerRef.current);
moveTimerRef.current = null;
}
if (inputTimerRef.current) {
clearInterval(inputTimerRef.current);
inputTimerRef.current = null;
}
setMoving(false);
log('info', '⏸ Симуляция движения остановлена');
}, [log]);
/** Уйти из комнаты. */
const disconnect = async () => {
stopMoving();
stopWasd();
if (room) {
try { await room.leave(true); } catch (e) {}
setRoom(null);
}
setStatus('disconnected');
setPlayers([]);
setCurrentRoomCode('');
log('info', 'Disconnected manually');
};
/** Отправить тестовое 'hello'. */
const sendHello = () => {
if (!room) return;
const payload = { foo: 'bar', t: Date.now() };
room.send('hello', payload);
log('info', '→ hello', payload);
};
/** Проверить /health. */
const checkHealth = async () => {
log('info', `GET ${REALTIME_HTTP}/health`);
try {
const res = await fetch(`${REALTIME_HTTP}/health`);
const data = await res.json();
setHealthData(data);
log('ok', '/health →', data);
} catch (e) {
setHealthData({ ok: false, error: e?.message });
log('error', '/health failed:', e?.message);
}
};
// Когда открыли модалку «Список комнат» — сразу подгружаем
// и обновляем каждые 3 секунды, пока модалка открыта.
useEffect(() => {
if (joinModal !== 'list') return;
fetchAvailableRooms();
const t = setInterval(fetchAvailableRooms, 3000);
return () => clearInterval(t);
}, [joinModal, fetchAvailableRooms]);
// Раз в 250мс синхронизируем state players ← playersRef. Это нужно потому что
// мы НЕ вызываем setPlayers в onChange (40+ раз/сек = тормозит React),
// и без этого таймера правый список «Игроки в комнате» был бы статичным.
useEffect(() => {
if (status !== 'connected') return;
const t = setInterval(() => {
setPlayers([...playersRef.current]);
}, 250);
return () => clearInterval(t);
}, [status]);
// === Canvas: top-down 2D-сцена ===
// Рисуем 60 раз/сек: пол-сетка, всех игроков, ник над аватаркой.
// Координаты: 1 м = 8 px, центр canvas = (0, 0) в мире.
useEffect(() => {
if (status !== 'connected') return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
const SCALE = 8; // пикселей на метр
const cx = W / 2;
const cy = H / 2;
// Клик по canvas = выстрел в это место мира.
// Работает только в WASD-режиме, чтобы случайно не настрелять при простой инспекции.
const onClick = (e) => {
if (!wasdMode) return;
const rect = canvas.getBoundingClientRect();
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
// px/py → world coordinates
const worldX = (px - cx) / SCALE;
const worldZ = (py - cy) / SCALE;
shootAt(worldX, worldZ);
};
canvas.addEventListener('click', onClick);
let raf = 0;
const draw = () => {
// Фон
ctx.fillStyle = '#0a0e1a';
ctx.fillRect(0, 0, W, H);
// Сетка (1 м)
ctx.strokeStyle = 'rgba(51, 87, 255, 0.08)';
ctx.lineWidth = 1;
for (let x = -30; x <= 30; x++) {
const sx = cx + x * SCALE;
ctx.beginPath();
ctx.moveTo(sx, 0);
ctx.lineTo(sx, H);
ctx.stroke();
}
for (let z = -30; z <= 30; z++) {
const sy = cy + z * SCALE;
ctx.beginPath();
ctx.moveTo(0, sy);
ctx.lineTo(W, sy);
ctx.stroke();
}
// Оси (через центр)
ctx.strokeStyle = 'rgba(51, 87, 255, 0.30)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(cx, 0); ctx.lineTo(cx, H);
ctx.moveTo(0, cy); ctx.lineTo(W, cy);
ctx.stroke();
// Чистим устаревшие выстрелы и попадания
const now = Date.now();
shotsRef.current = shotsRef.current.filter(s => s.expiresAt > now);
hitsRef.current = hitsRef.current.filter(h => h.expiresAt > now);
// Трейсеры выстрелов (под игроками)
for (const s of shotsRef.current) {
const t = (s.expiresAt - now) / 200; // 1 → 0
const sx = cx + s.originX * SCALE;
const sy = cy + s.originZ * SCALE;
const ex = cx + (s.originX + s.dirX * s.distance) * SCALE;
const ez = cy + (s.originZ + s.dirZ * s.distance) * SCALE;
ctx.strokeStyle = s.hit
? `rgba(255, 100, 100, ${t})`
: `rgba(255, 230, 100, ${t * 0.7})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(ex, ez);
ctx.stroke();
}
// Игроки
const myId = room?.sessionId;
for (const p of playersRef.current) {
const sx = cx + p.x * SCALE;
const sy = cy + p.z * SCALE;
const isMe = p.sessionId === myId;
const isDead = !!p.isDead;
// Вспышка попадания (красное кольцо вокруг)
const hit = hitsRef.current.find(h => h.sessionId === p.sessionId);
if (hit) {
const t = (hit.expiresAt - now) / 250;
ctx.strokeStyle = `rgba(255, 60, 60, ${t})`;
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(sx, sy, 14 + (1 - t) * 6, 0, Math.PI * 2);
ctx.stroke();
}
// Кружок-игрок
ctx.fillStyle = isDead
? '#475569'
: (isMe ? '#3357ff' : '#94a3b8');
ctx.globalAlpha = isDead ? 0.45 : 1;
ctx.beginPath();
ctx.arc(sx, sy, 10, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
if (!isDead) {
// Стрелка-yaw
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(
sx + Math.sin(p.yaw) * 14,
sy - Math.cos(p.yaw) * 14
);
ctx.stroke();
}
// HP-бар над игроком
if (!isDead) {
const hpPct = (p.hp || 0) / (p.maxHp || 100);
const barW = 28;
const barH = 4;
const barY = sy - 22;
// Фон
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(sx - barW / 2 - 1, barY - 1, barW + 2, barH + 2);
// Заливка
ctx.fillStyle = hpPct > 0.5 ? '#22d97a'
: hpPct > 0.25 ? '#f59e0b'
: '#ef4444';
ctx.fillRect(sx - barW / 2, barY, barW * hpPct, barH);
}
// Ник
ctx.font = 'bold 11px "Roboto Condensed", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
const label = isDead ? `💀 ${p.username}` : p.username;
const textW = ctx.measureText(label).width;
ctx.fillStyle = isDead
? 'rgba(70, 80, 100, 0.85)'
: (isMe ? 'rgba(51, 87, 255, 0.85)' : 'rgba(20, 24, 45, 0.85)');
ctx.fillRect(sx - textW / 2 - 4, sy - 38, textW + 8, 14);
ctx.fillStyle = '#fff';
ctx.fillText(label, sx, sy - 26);
}
raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(raf);
canvas.removeEventListener('click', onClick);
};
}, [status, room, wasdMode, shootAt]);
// При размонтировании — гарантированно отключаемся и стопаем таймеры
useEffect(() => () => {
if (moveTimerRef.current) clearInterval(moveTimerRef.current);
if (inputTimerRef.current) clearInterval(inputTimerRef.current);
if (wasdLoopRef.current) clearInterval(wasdLoopRef.current);
if (room) {
try { room.leave(true); } catch (e) {}
}
}, [room]);
return (
<div style={{
minHeight: '100vh',
background: KT.bg,
color: KT.text,
fontFamily: KT.font,
}}>
<style>{KUBIKON_KEYFRAMES}</style>
{/* Header */}
<div style={{
background: KT.glassDark,
backdropFilter: 'blur(20px)',
borderBottom: `1px solid ${KT.borderSoft}`,
padding: '14px 24px',
position: 'sticky', top: 0, zIndex: 50,
}}>
<div style={{
maxWidth: 1240, margin: '0 auto',
display: 'flex', alignItems: 'center', gap: 16,
}}>
<Link to="/" style={{
color: KT.text, textDecoration: 'none',
fontSize: 14, fontWeight: 700,
padding: '8px 14px',
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: 999,
boxShadow: KT.shadowSm,
}}><Icon name="arrow-left" size={13} /> В Studio</Link>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 10,
}}>
<RublocsLogo size={32} />
<span style={{
fontSize: 22, fontWeight: 900, color: KT.text,
letterSpacing: -0.5,
}}>Realtime Test</span>
<StatusPill status={status} />
</div>
<div style={{ marginLeft: 'auto', fontSize: 12, color: KT.textMuted }}>
Подэтап 4.1 Hello-room
</div>
</div>
</div>
<div style={{ maxWidth: 1240, margin: '0 auto', padding: '32px 24px 64px' }}>
{/* Контролы */}
<div style={{
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: KT.radiusXl,
padding: 24,
marginBottom: 20,
boxShadow: KT.shadowMd,
}}>
<h2 style={sectionTitleStyle}><Icon name="settings" size={13} /> Параметры подключения</h2>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 16,
marginBottom: 18,
}}>
<Field
label="Project ID"
value={projectId}
onChange={setProjectId}
placeholder="1"
disabled={status !== 'disconnected'}
/>
<TokenInfo info={tokenInfo} />
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<Btn
primary
disabled={status !== 'disconnected'
|| !tokenInfo.hasToken
|| tokenInfo.isGuest}
onClick={() => setJoinModal('list')}
>
<Icon name="link" size={13} /> Подключиться
</Btn>
<Btn
disabled={status !== 'connected'}
onClick={disconnect}
>
<Icon name="stop" size={13} /> Отключиться
</Btn>
<Btn
disabled={status !== 'connected'}
onClick={sendHello}
>
<Icon name="send" size={13} /> Отправить hello
</Btn>
{!moving ? (
<Btn
disabled={status !== 'connected' || wasdMode}
onClick={startMoving}
>
<Icon name="cycle" size={13} /> По кругу
</Btn>
) : (
<Btn
disabled={status !== 'connected'}
onClick={stopMoving}
>
<Icon name="pause" size={13} /> Стоп круг
</Btn>
)}
{!wasdMode ? (
<Btn
disabled={status !== 'connected' || moving}
onClick={startWasd}
>
<Icon name="keyboard" size={13} /> WASD-режим
</Btn>
) : (
<Btn
disabled={status !== 'connected'}
onClick={stopWasd}
>
<Icon name="pause" size={13} /> Стоп WASD
</Btn>
)}
<Btn onClick={checkHealth}><Icon name="stethoscope" size={13} /> GET /health</Btn>
<Btn onClick={() => setLogs([])}><Icon name="delete" size={13} /> Очистить лог</Btn>
</div>
<div style={{
marginTop: 14, fontSize: 12, color: KT.textMuted,
fontFamily: 'monospace',
}}>
WS: {REALTIME_WS} · HTTP: {REALTIME_HTTP}
{currentRoomCode && status === 'connected' && (
<span style={{
marginLeft: 16,
background: KT.accent, color: '#fff',
padding: '3px 10px', borderRadius: 999,
fontWeight: 800, letterSpacing: 1,
}}>
<Icon name="tag" size={12} /> {currentRoomCode}
</span>
)}
</div>
</div>
{/* === 2D-сцена === */}
<div style={{ ...cardStyle, marginBottom: 20 }}>
<h3 style={sectionTitleStyle}>
<Icon name="map" size={15} /> Сцена (top-down)
<span style={{
fontSize: 11, fontWeight: 600, color: KT.textMuted,
marginLeft: 12, letterSpacing: 0,
textTransform: 'none',
}}>
1 м = 8 px · ось X , ось Z · кружок = игрок, стрелка = yaw
</span>
</h3>
<div style={{
display: 'flex', justifyContent: 'center',
background: '#0a0e1a',
borderRadius: KT.radius,
border: `1px solid ${KT.border}`,
padding: 8,
}}>
<canvas
ref={canvasRef}
width={600}
height={400}
style={{
display: 'block',
borderRadius: 8,
outline: wasdMode ? `2px solid ${KT.accent}` : 'none',
cursor: wasdMode ? 'crosshair' : 'default',
}}
/>
</div>
{wasdMode && (
<div style={{
marginTop: 12,
padding: '10px 14px',
background: KT.accentSoft,
border: `1px solid ${KT.accent}`,
borderRadius: KT.radius,
fontSize: 13, fontWeight: 600,
color: KT.accentDeep,
}}><Icon name="keyboard" size={14} /><b>WASD</b> двигаешься (5 м/с). 🔫 <b>Клик по сцене</b>
выстрел в ту точку (25 урона, дальность 30м, до 5 выстрелов/сек).
При HP=0 респаун через 2 секунды.
</div>
)}
</div>
{/* Health + game state */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 20,
marginBottom: 20,
}}>
{/* Health */}
<div style={cardStyle}>
<h3 style={sectionTitleStyle}><Icon name="stethoscope" size={13} /> Health</h3>
{healthData ? (
<pre style={preStyle}>
{JSON.stringify(healthData, null, 2)}
</pre>
) : (
<div style={{ color: KT.textMuted, fontSize: 13 }}>
Нажми «GET /health» проверим, что HTTP-сервер жив.
</div>
)}
</div>
{/* Players */}
<div style={cardStyle}>
<h3 style={sectionTitleStyle}>
<Icon name="users" size={15} /> Игроки в комнате
<span style={pillStyle}>{players.length}</span>
</h3>
{players.length === 0 ? (
<div style={{ color: KT.textMuted, fontSize: 13 }}>
Подключись и зайди в комнату здесь появишься ты,
а потом и другие игроки (если откроешь страницу
в другой вкладке).
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{players.map(p => {
const isMe = p.sessionId === room?.sessionId;
return (
<div key={p.sessionId} style={{
background: isMe ? KT.accentSoft : KT.bgSubtle,
border: `1px solid ${isMe ? KT.accent : KT.borderSoft}`,
borderRadius: KT.radius,
padding: '10px 14px',
display: 'flex', alignItems: 'center', gap: 12,
}}>
<div style={{
width: 32, height: 32, borderRadius: '50%',
background: KT.gradientBrand,
color: '#fff', display: 'flex',
alignItems: 'center', justifyContent: 'center',
fontSize: 14, fontWeight: 800,
}}>
{p.username.slice(0, 1).toUpperCase()}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 14, fontWeight: 800, color: KT.text,
display: 'flex', alignItems: 'center', gap: 6,
flexWrap: 'wrap',
}}>
{p.isDead && <span><Icon name="skull" size={14} /></span>}
{p.username}
{isMe && (
<span style={{
fontSize: 10,
fontWeight: 800, color: KT.accent,
textTransform: 'uppercase', letterSpacing: 0.6,
}}>(ты)</span>
)}
<span style={{
fontSize: 11, fontWeight: 800,
color: KT.success,
background: KT.successLight || '#ecfdf5',
padding: '1px 6px', borderRadius: 999,
}}>K {p.kills || 0}</span>
<span style={{
fontSize: 11, fontWeight: 800,
color: KT.danger,
background: '#fef2f2',
padding: '1px 6px', borderRadius: 999,
}}>D {p.deaths || 0}</span>
</div>
{/* HP-бар */}
<div style={{
marginTop: 4, height: 6,
background: '#e5e7eb',
borderRadius: 999,
overflow: 'hidden',
}}>
<div style={{
height: '100%',
width: `${((p.hp || 0) / (p.maxHp || 100)) * 100}%`,
background: (p.hp || 0) > 50 ? '#22d97a'
: (p.hp || 0) > 25 ? '#f59e0b'
: '#ef4444',
transition: 'width 200ms ease, background 200ms ease',
}} />
</div>
<div style={{
fontSize: 11, color: KT.textMuted,
fontFamily: 'monospace',
fontVariantNumeric: 'tabular-nums',
marginTop: 3,
}}>
HP {Math.round(p.hp || 0)}/{p.maxHp || 100} · ({p.x.toFixed(1)}, {p.z.toFixed(1)})
</div>
</div>
</div>
);
})}
</div>
)}
{serverTime && (
<div style={{
marginTop: 12, fontSize: 11, color: KT.textMuted,
fontFamily: 'monospace',
}}>
Server time: {new Date(serverTime).toISOString()}
</div>
)}
</div>
</div>
{/* Лог */}
<div style={cardStyle}>
<h3 style={sectionTitleStyle}><Icon name="duplicate" size={13} /> Лог ({logs.length})</h3>
<div ref={logRef} style={{
background: '#0f1326',
border: `1px solid ${KT.border}`,
borderRadius: KT.radius,
padding: 14,
maxHeight: 360,
overflowY: 'auto',
fontFamily: 'Consolas, Menlo, monospace',
fontSize: 12.5,
lineHeight: 1.5,
}}>
{logs.length === 0 ? (
<div style={{ color: '#94a3b8', fontStyle: 'italic' }}>
События появятся здесь после подключения.
</div>
) : logs.map((l, i) => (
<div key={i} style={{
color: LEVEL_COLORS[l.level] || '#f1f5fb',
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
<span style={{ color: '#64748b', marginRight: 10 }}>
{new Date(l.ts).toISOString().slice(11, 23)}
</span>
{l.text}
</div>
))}
</div>
</div>
</div>
{/* === Модалка «Куда подключиться?» === */}
{joinModal !== 'closed' && (
<JoinModal
mode={joinModal}
setMode={setJoinModal}
rooms={availableRooms}
roomsLoading={roomsLoading}
onRefresh={fetchAvailableRooms}
joinCode={joinCode}
setJoinCode={setJoinCode}
onConnect={connectAs}
/>
)}
</div>
);
};
// =============================================================
// === Модалка «Куда подключиться?» ===
// =============================================================
const JoinModal = ({ mode, setMode, rooms, roomsLoading, onRefresh,
joinCode, setJoinCode, onConnect }) => {
return (
<div
onClick={() => setMode('closed')}
style={{
position: 'fixed', inset: 0, zIndex: 100,
background: 'rgba(15, 23, 42, 0.55)',
backdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 20, animation: 'kubikonFadeIn 200ms ease',
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: '#fff',
borderRadius: 22,
width: '100%', maxWidth: 540,
color: KT.text,
fontFamily: KT.font,
boxShadow: KT.shadowLg,
overflow: 'hidden',
animation: 'kubikonFadeInScale 280ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}}
>
{/* Header с табами */}
<div style={{
background: KT.gradientBrand,
padding: '20px 24px',
color: '#fff',
display: 'flex', alignItems: 'center', gap: 12,
}}>
<div style={{
width: 40, height: 40, borderRadius: 12,
background: 'rgba(255,255,255,0.18)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20,
}}><Icon name="globe" size={14} /></div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 800, letterSpacing: -0.3 }}>
Подключиться к мультиплееру
</div>
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.78)', marginTop: 2 }}>
Выбери комнату или создай новую
</div>
</div>
<button
onClick={() => setMode('closed')}
style={{
width: 32, height: 32,
background: 'rgba(255,255,255,0.16)',
border: '1px solid rgba(255,255,255,0.25)',
color: '#fff', borderRadius: 10,
fontSize: 20, cursor: 'pointer',
fontFamily: 'inherit',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>×</button>
</div>
{/* Tabs */}
<div style={{
display: 'flex', gap: 0,
borderBottom: `1px solid ${KT.borderSoft}`,
background: KT.bgSubtle,
}}>
<ModalTab active={mode === 'list'} onClick={() => setMode('list')}>
<Icon name="globe" size={13} /> Публичные
</ModalTab>
<ModalTab active={mode === 'by-code'} onClick={() => setMode('by-code')}>
<Icon name="tag" size={13} /> По коду
</ModalTab>
</div>
<div style={{ padding: 24 }}>
{mode === 'list' && (
<>
<div style={{
display: 'flex', alignItems: 'center',
marginBottom: 12,
}}>
<span style={{ fontSize: 13, color: KT.textSecondary, fontWeight: 600 }}>
Активные комнаты этой игры:
</span>
<button
onClick={onRefresh}
disabled={roomsLoading}
style={{
marginLeft: 'auto',
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: 999,
padding: '4px 12px',
fontSize: 12, fontWeight: 700,
color: KT.text, cursor: 'pointer',
fontFamily: 'inherit',
}}
>
{roomsLoading ? <><Icon name="hourglass" size={13} /> загрузка</> : <><Icon name="refresh" size={13} /> обновить</>}
</button>
</div>
<div style={{
maxHeight: 240, overflowY: 'auto',
display: 'flex', flexDirection: 'column', gap: 6,
marginBottom: 16,
}}>
{rooms.length === 0 && !roomsLoading && (
<div style={{
padding: '24px 16px', textAlign: 'center',
background: KT.bgSubtle,
border: `1px dashed ${KT.borderStrong}`,
borderRadius: 12,
color: KT.textSecondary, fontSize: 13,
}}><Icon name="sprout" size={13} /> Активных публичных комнат нет.<br />
Создай новую нажми «Создать публичную».
</div>
)}
{rooms.map(r => (
<button
key={r.roomId}
onClick={() => onConnect('specific', { roomId: r.roomId })}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 14px',
background: KT.bgSubtle,
border: `1px solid ${KT.border}`,
borderRadius: 12,
cursor: 'pointer', fontFamily: 'inherit',
textAlign: 'left',
transition: 'all 150ms ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = KT.accent;
e.currentTarget.style.background = KT.accentSoft;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = KT.border;
e.currentTarget.style.background = KT.bgSubtle;
}}
>
<div style={{
width: 36, height: 36, borderRadius: 10,
background: KT.gradientBrand,
color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12, fontWeight: 800,
fontFamily: 'monospace',
}}>
{(r.metadata?.roomCode || r.roomId.slice(0, 6)).toUpperCase()}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 800, color: KT.text }}>
{r.metadata?.roomName || `Комната #${r.roomId.slice(0, 6)}`}
</div>
<div style={{ fontSize: 11, color: KT.textMuted, fontWeight: 600 }}>
Хост: {r.metadata?.hostUsername || '—'}
</div>
</div>
<div style={{
fontSize: 13, fontWeight: 800,
color: r.clients >= r.maxClients ? KT.danger : KT.accent,
fontVariantNumeric: 'tabular-nums',
}}>
{r.clients}/{r.maxClients}
</div>
</button>
))}
</div>
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10,
}}>
<button
onClick={() => onConnect('public')}
style={btnPrimaryModal}
>
<Icon name="globe" size={14} /> Создать публичную
</button>
<button
onClick={() => onConnect('private')}
style={btnSecondaryModal}
>
<Icon name="tag" size={14} /> Создать приватную
</button>
</div>
<div style={{
fontSize: 11, color: KT.textMuted, marginTop: 12,
textAlign: 'center', fontWeight: 600,
}}>
Публичная видна всем игрокам этой игры.
Приватная только по коду, который ты получишь.
</div>
</>
)}
{mode === 'by-code' && (
<>
<div style={{
fontSize: 13, color: KT.textSecondary, fontWeight: 600,
marginBottom: 10,
}}>
Введи 6-символьный код, который тебе дал друг:
</div>
<input
type="text"
value={joinCode}
onChange={(e) => setJoinCode(
e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, '').slice(0, 12)
)}
placeholder="ABC123"
style={{
width: '100%', boxSizing: 'border-box',
background: KT.bgSubtle,
border: `2px solid ${KT.border}`,
borderRadius: 12,
padding: '14px 18px',
fontSize: 22, fontWeight: 900,
fontFamily: 'monospace',
color: KT.text,
textAlign: 'center',
letterSpacing: 4,
outline: 'none',
}}
onFocus={(e) => { e.target.style.borderColor = KT.accent; }}
onBlur={(e) => { e.target.style.borderColor = KT.border; }}
/>
<div style={{
fontSize: 11, color: KT.textMuted, marginTop: 8,
fontWeight: 500,
}}>
Код выглядит как <code style={{
background: KT.bgMuted, padding: '1px 6px',
borderRadius: 4, fontFamily: 'monospace', fontWeight: 700,
}}>ABC123</code> друг увидит его рядом с WS-адресом
после подключения.
</div>
<button
onClick={() => onConnect('by-code', { roomId: joinCode })}
disabled={joinCode.length < 4}
style={{
...btnPrimaryModal,
width: '100%', marginTop: 18,
opacity: joinCode.length < 4 ? 0.5 : 1,
cursor: joinCode.length < 4 ? 'not-allowed' : 'pointer',
}}
>
<Icon name="rocket" size={14} /> Войти в комнату
</button>
</>
)}
</div>
</div>
</div>
);
};
const ModalTab = ({ active, onClick, children }) => (
<button
onClick={onClick}
style={{
flex: 1, padding: '12px 16px',
background: active ? '#fff' : 'transparent',
border: 'none',
borderBottom: `2px solid ${active ? KT.accent : 'transparent'}`,
color: active ? KT.accent : KT.textSecondary,
fontSize: 13, fontWeight: 800,
cursor: 'pointer', fontFamily: 'inherit',
transition: 'all 150ms ease',
}}
>
{children}
</button>
);
const btnPrimaryModal = {
padding: '11px 20px',
background: 'linear-gradient(135deg, #3357ff 0%, #1e2da5 100%)',
color: '#fff',
border: '1px solid transparent',
borderRadius: 10,
fontSize: 13, fontWeight: 800,
cursor: 'pointer',
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
boxShadow: '0 6px 16px rgba(51, 87, 255, 0.32)',
};
const btnSecondaryModal = {
padding: '11px 20px',
background: '#fafbfd',
color: '#0f172a',
border: '1.5px solid #e5e7eb',
borderRadius: 10,
fontSize: 13, fontWeight: 800,
cursor: 'pointer',
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
};
const LEVEL_COLORS = {
info: '#5470ff',
debug: '#94a3b8',
ok: '#22d97a',
warn: '#f59e0b',
error: '#ff6f7a',
};
const StatusPill = ({ status }) => {
const meta = {
disconnected: { color: '#94a3b8', bg: '#f1f5f9', label: '○ disconnected' },
connecting: { color: '#f59e0b', bg: '#fffbeb', label: '◌ connecting…' },
connected: { color: '#10b981', bg: '#ecfdf5', label: '● connected' },
}[status];
return (
<span style={{
background: meta.bg, color: meta.color,
padding: '4px 12px', borderRadius: 999,
fontSize: 12, fontWeight: 800,
border: `1px solid ${meta.color}33`,
marginLeft: 6,
}}>{meta.label}</span>
);
};
const TokenInfo = ({ info }) => {
let badge, color;
if (!info.hasToken) {
badge = 'Нет токена';
color = '#ef4444';
} else if (info.isGuest) {
badge = 'Гость (не пустят)';
color = '#f59e0b';
} else {
badge = `Юзер #${info.userId}`;
color = '#10b981';
}
return (
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{
fontSize: 11, color: KT.textSecondary,
fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0.8,
}}>
Токен (из localStorage)
</span>
<div style={{
background: KT.bgSubtle,
border: `1.5px solid ${KT.border}`,
borderRadius: KT.radius,
padding: '10px 14px',
color: color,
fontSize: 14, fontWeight: 800,
fontFamily: KT.font,
display: 'flex', alignItems: 'center', gap: 10,
}}>
<span style={{
width: 8, height: 8, borderRadius: '50%', background: color,
}} />
{badge}
</div>
</label>
);
};
const Field = ({ label, value, onChange, placeholder, disabled }) => (
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{
fontSize: 11, color: KT.textSecondary,
fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0.8,
}}>
{label}
</span>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
style={{
background: KT.bgSubtle,
border: `1.5px solid ${KT.border}`,
borderRadius: KT.radius,
padding: '10px 14px',
color: KT.text,
fontSize: 14, fontFamily: KT.font,
outline: 'none',
opacity: disabled ? 0.55 : 1,
cursor: disabled ? 'not-allowed' : 'text',
}}
/>
</label>
);
const Btn = ({ children, onClick, disabled, primary }) => (
<button
onClick={onClick}
disabled={disabled}
style={{
padding: '10px 18px',
background: primary && !disabled
? KT.gradientBrand
: (disabled ? KT.bgMuted : KT.bgPage),
color: primary && !disabled ? '#fff' : KT.text,
border: `1px solid ${primary && !disabled ? 'transparent' : KT.border}`,
borderRadius: KT.radius,
fontSize: 13, fontWeight: 800,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.55 : 1,
fontFamily: KT.font,
boxShadow: primary && !disabled ? KT.shadowAccent : 'none',
transition: 'all 200ms cubic-bezier(0.34, 1.56, 0.64, 1)',
letterSpacing: 0.2,
}}
>
{children}
</button>
);
const sectionTitleStyle = {
margin: '0 0 14px', fontSize: 16, fontWeight: 800,
color: KT.text, letterSpacing: -0.2,
display: 'inline-flex', alignItems: 'center', gap: 8,
};
const cardStyle = {
background: KT.bgPage,
border: `1px solid ${KT.border}`,
borderRadius: KT.radiusXl,
padding: 24,
boxShadow: KT.shadowMd,
};
const preStyle = {
margin: 0,
background: KT.bgSubtle,
border: `1px solid ${KT.borderSoft}`,
borderRadius: KT.radius,
padding: 14,
fontSize: 12.5,
color: KT.text,
fontFamily: 'Consolas, Menlo, monospace',
overflowX: 'auto',
};
const pillStyle = {
background: KT.gradientBrand,
color: '#fff',
padding: '2px 10px',
borderRadius: 999,
fontSize: 12,
fontWeight: 800,
marginLeft: 8,
};
export default RealtimeTest;