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 (
{JSON.stringify(healthData, null, 2)}
) : (
ABC123 — друг увидит его рядом с WS-адресом
после подключения.