Большой консолидирующий коммит после поднятия 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>
1525 lines
70 KiB
JavaScript
1525 lines
70 KiB
JavaScript
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;
|