diff --git a/README.md b/README.md index d6274a7..232c96f 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,26 @@ 一个支持多节点中继的 Minecraft 联机平台,让玩家无需公网IP即可轻松联机。 -本仓库包含 **两个独立项目**,可分别独立开发、部署和运行。 +本仓库包含 **三个独立项目**,覆盖全平台客户端和服务端。 + +## 支持平台 + +| 平台 | 类型 | 项目 | +|------|------|------| +| **Windows** | 桌面客户端 (NSIS 安装包) | `client/` | +| **macOS** | 桌面客户端 (DMG, x64/arm64) | `client/` | +| **Linux** | 桌面客户端 (AppImage/deb) | `client/` | +| **iOS** | 移动客户端 | `mobile/` | +| **Android** | 移动客户端 (APK/AAB) | `mobile/` | +| **Ubuntu** | 中继服务器 + Web 管理面板 | `server/` | ## 项目结构 ``` FunConnect/ ├── server/ # 服务端(中继服务器 + Web 管理面板 + 部署脚本) -└── client/ # 客户端(Electron 桌面应用) +├── client/ # 桌面客户端(Electron: Windows / macOS / Linux) +└── mobile/ # 移动客户端(React Native + Expo: iOS / Android) ``` ## 服务端 (`server/`) @@ -25,46 +37,63 @@ FunConnect/ - **一键部署** - Ubuntu 自动安装脚本 + systemd 服务 ```bash -cd server -npm install -cp .env.example .env -npm run dev +cd server && npm install && cp .env.example .env && npm run dev ``` -详细文档见 [server/README.md](server/README.md) +📖 [server/README.md](server/README.md) · 📦 [部署教程 DEPLOY.md](server/DEPLOY.md) -## 客户端 (`client/`) +## 桌面客户端 (`client/`) Electron 跨平台桌面客户端,支持 Windows / macOS / Linux。 -- **连接服务器** - 输入中继地址一键连接 -- **房间管理** - 浏览/创建/加入联机房间 -- **本地代理** - 自动建立本地代理,MC 添加 `127.0.0.1:25566` 即可联机 +- **本地代理** - 自动建立 TCP 代理,MC 添加 `127.0.0.1:25566` 即可联机 - **设置持久化** - 记住服务器地址、玩家名等偏好 - **系统托盘** - 最小化到托盘后台运行 ```bash -cd client -npm install -npm run dev +cd client && npm install && npm run dev # 开发 +npm run dist:win # 打包 Windows +npm run dist:mac # 打包 macOS +npm run dist:linux # 打包 Linux ``` -详细文档见 [client/README.md](client/README.md) +📖 [client/README.md](client/README.md) + +## 移动客户端 (`mobile/`) + +React Native + Expo 移动客户端,支持 iOS / Android。 + +- **房间管理** - 浏览/搜索/创建/加入联机房间 +- **设置持久化** - 记住服务器地址和玩家名 +- **深色 UI** - Minecraft 风格暗色主题 + +```bash +cd mobile && npm install && npm start # 开发(Expo) +eas build --platform android --profile preview # 构建 Android APK +eas build --platform ios --profile production # 构建 iOS +``` + +📖 [mobile/README.md](mobile/README.md) ## 架构 ``` -┌───────────────┐ ┌──────────────────────────┐ -│ FunConnect │ TCP │ 中继服务器 (Ubuntu) │ -│ 桌面客户端 │◄─────►│ server/ 项目独立部署 │ -│ client/ │ │ ┌─────────┐ ┌──────────┐ │ -└───────────────┘ │ │ TCP中继 │ │ REST API │ │ - │ └─────────┘ └──────────┘ │ -┌───────────────┐ │ ┌──────────────────────┐ │ -│ Minecraft │ TCP │ │ Web 管理面板 │ │ -│ 游戏客户端 │◄─────►│ │ (React + Vite) │ │ -└───────────────┘ │ └──────────────────────┘ │ - └──────────────────────────┘ +┌──────────────────┐ +│ 桌面客户端 │ Windows / macOS / Linux +│ Electron │ TCP 本地代理 +│ client/ │─────────┐ +└──────────────────┘ │ + ▼ +┌──────────────────┐ ┌──────────────────────────┐ +│ 移动客户端 │ │ 中继服务器 (Ubuntu) │ +│ React Native │──►│ TCP 中继 + REST API │ +│ mobile/ │ │ Web 管理面板 (React) │ +└──────────────────┘ └──────────────────────────┘ + ▲ +┌──────────────────┐ │ +│ Minecraft │─────────┘ +│ 游戏客户端 │ TCP 直连中继 +└──────────────────┘ ``` ## License diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 0000000..39148b6 --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +.expo/ +dist/ +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* +web-build/ +ios/ +android/ diff --git a/mobile/App.tsx b/mobile/App.tsx new file mode 100644 index 0000000..1bc66f0 --- /dev/null +++ b/mobile/App.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { StatusBar } from 'expo-status-bar'; +import { NavigationContainer, DefaultTheme } from '@react-navigation/native'; +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { Text, View } from 'react-native'; +import ConnectScreen from './src/screens/ConnectScreen'; +import RoomsScreen from './src/screens/RoomsScreen'; +import CreateScreen from './src/screens/CreateScreen'; +import JoinScreen from './src/screens/JoinScreen'; +import SettingsScreen from './src/screens/SettingsScreen'; + +const Tab = createBottomTabNavigator(); + +const DarkTheme = { + ...DefaultTheme, + colors: { + ...DefaultTheme.colors, + primary: '#4CAF50', + background: '#1a1a2e', + card: '#16213e', + text: '#e0e0e0', + border: 'rgba(15, 52, 96, 0.6)', + notification: '#e94560', + }, +}; + +const icons: Record = { + Connect: '🔗', + Rooms: '🎮', + Create: '🏠', + Join: '🚀', + Settings: '⚙️', +}; + +export default function App() { + return ( + + + ({ + headerStyle: { backgroundColor: '#16213e', elevation: 0, shadowOpacity: 0 }, + headerTintColor: '#e0e0e0', + headerTitleStyle: { fontWeight: '700' }, + tabBarStyle: { + backgroundColor: '#16213e', + borderTopColor: 'rgba(15, 52, 96, 0.6)', + height: 60, + paddingBottom: 8, + }, + tabBarActiveTintColor: '#4CAF50', + tabBarInactiveTintColor: '#8a8a9a', + tabBarIcon: ({ color }) => ( + {icons[route.name] || '📱'} + ), + })} + > + + + + + + + + ); +} diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..3f345cb --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,131 @@ +# FunConnect Mobile + +Minecraft 联机移动客户端,基于 **React Native + Expo** 构建,支持 **iOS** 和 **Android**。 + +## 功能 + +- **连接服务器** - 输入中继服务器地址连接 +- **浏览房间** - 实时查看在线联机房间(自动刷新) +- **搜索筛选** - 按名称、房间号、房主搜索,按版本类型筛选 +- **创建房间** - 创建联机房间并分享房间号 +- **加入房间** - 输入房间号加入,支持密码验证 +- **设置持久化** - 记住服务器地址、玩家名 +- **深色主题** - Minecraft 风格暗色 UI + +## 快速开始 + +### 前置要求 + +- Node.js 18+ +- Expo CLI: `npm install -g expo-cli` +- iOS: Xcode 15+(仅 macOS) +- Android: Android Studio + SDK + +### 安装依赖 + +```bash +cd mobile +npm install +``` + +### 开发模式 + +```bash +# 启动 Expo 开发服务器 +npm start + +# 在 iOS 模拟器中运行 +npm run ios + +# 在 Android 模拟器中运行 +npm run android +``` + +> 也可以用 **Expo Go** 手机 App 扫描二维码直接在真机上运行。 + +## 编译发布 + +### 方式一:EAS Build(推荐,云端构建) + +```bash +# 安装 EAS CLI +npm install -g eas-cli + +# 登录 Expo 账号 +eas login + +# 构建 Android APK(预览版) +eas build --platform android --profile preview + +# 构建 Android AAB(生产版,用于上架 Google Play) +eas build --platform android --profile production + +# 构建 iOS(需要 Apple Developer 账号) +eas build --platform ios --profile production + +# 同时构建两个平台 +eas build --platform all --profile production +``` + +### 方式二:本地构建 + +```bash +# 生成原生项目 +npx expo prebuild + +# Android +cd android && ./gradlew assembleRelease +# 输出: android/app/build/outputs/apk/release/app-release.apk + +# iOS(需要 macOS + Xcode) +cd ios && xcodebuild -workspace FunConnect.xcworkspace -scheme FunConnect archive +``` + +## 项目结构 + +``` +mobile/ +├── App.tsx # 入口 + Tab 导航 +├── index.js # 注册根组件 +├── src/ +│ ├── screens/ +│ │ ├── ConnectScreen.tsx # 连接服务器 +│ │ ├── RoomsScreen.tsx # 房间列表 +│ │ ├── CreateScreen.tsx # 创建房间 +│ │ ├── JoinScreen.tsx # 加入房间 +│ │ └── SettingsScreen.tsx # 设置 +│ ├── lib/ +│ │ ├── api.ts # API 客户端 + 存储 +│ │ └── theme.ts # 主题色彩 +│ └── components/ # 共享组件 +├── app.json # Expo 配置 +├── eas.json # EAS Build 配置 +├── package.json +└── tsconfig.json +``` + +## 联机流程 + +### 房主 + +1. 在「连接」页连接中继服务器 +2. 在「创建」页创建房间,复制房间号 +3. 将房间号分享给好友 +4. 启动本地 Minecraft 服务器 + +### 玩家 + +1. 在「连接」页连接同一个中继服务器 +2. 在「加入」页输入房间号 +3. 验证通过后获取服务器地址 +4. 在 Minecraft 中添加该服务器地址 +5. 开始联机! + +## 技术栈 + +- **React Native 0.73** + **Expo 50** +- **TypeScript** +- **React Navigation** - Tab 导航 +- **AsyncStorage** - 本地持久化 +- **Axios** - HTTP 请求 +- **EAS Build** - 云端编译发布 diff --git a/mobile/app.json b/mobile/app.json new file mode 100644 index 0000000..87e777c --- /dev/null +++ b/mobile/app.json @@ -0,0 +1,26 @@ +{ + "expo": { + "name": "FunConnect", + "slug": "funconnect", + "version": "1.1.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "dark", + "splash": { + "backgroundColor": "#1a1a2e" + }, + "assetBundlePatterns": ["**/*"], + "ios": { + "supportsTablet": true, + "bundleIdentifier": "cn.funmc.connect", + "buildNumber": "1" + }, + "android": { + "adaptiveIcon": { + "backgroundColor": "#1a1a2e" + }, + "package": "cn.funmc.connect", + "versionCode": 1 + } + } +} diff --git a/mobile/babel.config.js b/mobile/babel.config.js new file mode 100644 index 0000000..9d89e13 --- /dev/null +++ b/mobile/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/mobile/eas.json b/mobile/eas.json new file mode 100644 index 0000000..54abc93 --- /dev/null +++ b/mobile/eas.json @@ -0,0 +1,37 @@ +{ + "cli": { + "version": ">= 5.0.0" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal", + "android": { + "buildType": "apk" + } + }, + "production": { + "android": { + "buildType": "app-bundle" + }, + "ios": { + "autoIncrement": true + } + } + }, + "submit": { + "production": { + "android": { + "serviceAccountKeyPath": "./google-services.json", + "track": "internal" + }, + "ios": { + "appleId": "your-apple-id@example.com", + "ascAppId": "your-asc-app-id" + } + } + } +} diff --git a/mobile/index.js b/mobile/index.js new file mode 100644 index 0000000..5fd059f --- /dev/null +++ b/mobile/index.js @@ -0,0 +1,4 @@ +import { registerRootComponent } from 'expo'; +import App from './App'; + +registerRootComponent(App); diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 0000000..5a0e346 --- /dev/null +++ b/mobile/package.json @@ -0,0 +1,34 @@ +{ + "name": "funconnect-mobile", + "version": "1.1.0", + "description": "FunConnect - Minecraft 联机客户端 (iOS & Android)", + "main": "index.js", + "scripts": { + "start": "expo start", + "android": "expo run:android", + "ios": "expo run:ios", + "build:android": "eas build --platform android", + "build:ios": "eas build --platform ios", + "build:all": "eas build --platform all" + }, + "dependencies": { + "expo": "~50.0.0", + "expo-status-bar": "~1.11.1", + "expo-clipboard": "~5.0.1", + "expo-constants": "~15.4.5", + "react": "18.2.0", + "react-native": "0.73.4", + "react-native-safe-area-context": "4.8.2", + "react-native-screens": "~3.29.0", + "@react-navigation/native": "^6.1.9", + "@react-navigation/bottom-tabs": "^6.5.11", + "@react-native-async-storage/async-storage": "1.21.0", + "axios": "^1.6.2" + }, + "devDependencies": { + "@babel/core": "^7.20.0", + "@types/react": "~18.2.45", + "typescript": "^5.3.0" + }, + "private": true +} diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts new file mode 100644 index 0000000..f155e61 --- /dev/null +++ b/mobile/src/lib/api.ts @@ -0,0 +1,114 @@ +import axios, { AxiosInstance } from 'axios'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export interface RoomInfo { + id: string; + name: string; + hostName: string; + hostPort: number; + gameVersion: string; + gameEdition: 'java' | 'bedrock'; + maxPlayers: number; + currentPlayers: number; + nodeId: string; + createdAt: string; + password?: string; +} + +export interface ServerHealth { + status: string; + nodeId: string; + nodeName: string; + isMaster: boolean; + uptime: number; +} + +export interface ServerStats { + node: { id: string; name: string; rooms: number; players: number; maxRooms: number }; + cluster: { totalNodes: number; onlineNodes: number; totalRooms: number; totalPlayers: number } | null; +} + +class ApiClient { + private http: AxiosInstance | null = null; + private baseUrl: string = ''; + + configure(url: string) { + this.baseUrl = url.replace(/\/+$/, ''); + this.http = axios.create({ baseURL: `${this.baseUrl}/api`, timeout: 10000 }); + } + + get isConfigured() { return !!this.http; } + get serverUrl() { return this.baseUrl; } + + async getHealth(): Promise { + const res = await this.http!.get('/health'); + return res.data; + } + + async getStats(): Promise { + const res = await this.http!.get('/stats'); + return res.data; + } + + async getRooms(): Promise<{ rooms: RoomInfo[]; total: number }> { + const res = await this.http!.get('/rooms'); + return res.data; + } + + async createRoom(data: { + name: string; hostName: string; hostPort: number; + gameVersion: string; gameEdition: string; maxPlayers: number; password?: string; + }) { + const res = await this.http!.post('/rooms', data); + return res.data; + } + + async joinRoom(roomId: string, password?: string) { + const res = await this.http!.post(`/rooms/${roomId}/join`, { password }); + return res.data; + } + + async deleteRoom(roomId: string) { + const res = await this.http!.delete(`/rooms/${roomId}`); + return res.data; + } + + async getTraffic() { + const res = await this.http!.get('/traffic'); + return res.data; + } +} + +export const apiClient = new ApiClient(); + +// Settings storage +const STORAGE_KEYS = { + SERVER_URL: 'funmc_server_url', + PLAYER_NAME: 'funmc_player_name', + RECENT_SERVERS: 'funmc_recent_servers', +}; + +export const storage = { + async getServerUrl(): Promise { + return (await AsyncStorage.getItem(STORAGE_KEYS.SERVER_URL)) || ''; + }, + async setServerUrl(url: string) { + await AsyncStorage.setItem(STORAGE_KEYS.SERVER_URL, url); + }, + async getPlayerName(): Promise { + return (await AsyncStorage.getItem(STORAGE_KEYS.PLAYER_NAME)) || 'Player'; + }, + async setPlayerName(name: string) { + await AsyncStorage.setItem(STORAGE_KEYS.PLAYER_NAME, name); + }, + async getRecentServers(): Promise { + const raw = await AsyncStorage.getItem(STORAGE_KEYS.RECENT_SERVERS); + return raw ? JSON.parse(raw) : []; + }, + async addRecentServer(url: string) { + const recent = await this.getRecentServers(); + const filtered = recent.filter(s => s !== url); + filtered.unshift(url); + await AsyncStorage.setItem(STORAGE_KEYS.RECENT_SERVERS, JSON.stringify(filtered.slice(0, 10))); + }, +}; diff --git a/mobile/src/lib/theme.ts b/mobile/src/lib/theme.ts new file mode 100644 index 0000000..7ed1561 --- /dev/null +++ b/mobile/src/lib/theme.ts @@ -0,0 +1,24 @@ +export const colors = { + bg: '#1a1a2e', + bgDarker: '#16213e', + bgAccent: '#0f3460', + green: '#4CAF50', + greenDark: '#388E3C', + red: '#e94560', + gold: '#FFD700', + blue: '#4fc3f7', + purple: '#b388ff', + text: '#e0e0e0', + textDim: '#8a8a9a', + border: 'rgba(15, 52, 96, 0.6)', + card: '#16213e', + input: '#1a1a2e', +}; + +export const spacing = { + xs: 4, + sm: 8, + md: 16, + lg: 24, + xl: 32, +}; diff --git a/mobile/src/screens/ConnectScreen.tsx b/mobile/src/screens/ConnectScreen.tsx new file mode 100644 index 0000000..c76d659 --- /dev/null +++ b/mobile/src/screens/ConnectScreen.tsx @@ -0,0 +1,158 @@ +import React, { useState, useEffect } from 'react'; +import { + View, Text, TextInput, TouchableOpacity, StyleSheet, + ScrollView, Alert, ActivityIndicator, +} from 'react-native'; +import { colors, spacing } from '../lib/theme'; +import { apiClient, storage, ServerHealth } from '../lib/api'; + +export default function ConnectScreen() { + const [url, setUrl] = useState(''); + const [loading, setLoading] = useState(false); + const [connected, setConnected] = useState(false); + const [health, setHealth] = useState(null); + const [recentServers, setRecentServers] = useState([]); + + useEffect(() => { + loadSaved(); + }, []); + + async function loadSaved() { + const savedUrl = await storage.getServerUrl(); + if (savedUrl) setUrl(savedUrl); + const recent = await storage.getRecentServers(); + setRecentServers(recent); + } + + async function handleConnect() { + const trimmed = url.trim(); + if (!trimmed) { Alert.alert('提示', '请输入服务器地址'); return; } + + setLoading(true); + try { + apiClient.configure(trimmed); + const data = await apiClient.getHealth(); + setHealth(data); + setConnected(true); + await storage.setServerUrl(trimmed); + await storage.addRecentServer(trimmed); + setRecentServers(await storage.getRecentServers()); + } catch (err: any) { + Alert.alert('连接失败', err.message || '无法连接到服务器'); + setConnected(false); + } finally { + setLoading(false); + } + } + + return ( + + 连接服务器 + 输入 FunConnect 中继服务器地址 + + + 服务器地址 + + + {recentServers.length > 0 && ( + + 最近连接 + {recentServers.map((s, i) => ( + setUrl(s)} style={styles.recentItem}> + {s} + + ))} + + )} + + + {loading ? ( + + ) : ( + + {connected ? '重新连接' : '连接服务器'} + + )} + + + + {connected && health && ( + + ✅ 服务器已连接 + + + + + + + + )} + + ); +} + +function InfoItem({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function formatUptime(s: number): string { + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + if (h > 0) return `${h}时${m}分`; + return `${m}分`; +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bg }, + content: { padding: spacing.lg }, + title: { fontSize: 24, fontWeight: '700', color: colors.text, marginBottom: 4 }, + desc: { fontSize: 13, color: colors.textDim, marginBottom: 20 }, + card: { + backgroundColor: colors.card, borderRadius: 12, + borderWidth: 1, borderColor: colors.border, + padding: spacing.lg, marginBottom: spacing.md, + }, + cardTitle: { fontSize: 16, fontWeight: '700', color: colors.green, marginBottom: 12 }, + label: { fontSize: 12, color: colors.textDim, marginBottom: 6, fontWeight: '500' }, + input: { + backgroundColor: colors.input, borderWidth: 1, borderColor: colors.border, + borderRadius: 8, padding: 12, color: colors.text, fontSize: 14, + }, + btn: { + backgroundColor: colors.green, borderRadius: 8, + padding: 14, alignItems: 'center', marginTop: 16, + }, + btnDisabled: { opacity: 0.6 }, + btnText: { color: '#fff', fontWeight: '600', fontSize: 14 }, + recentBox: { marginTop: 12 }, + recentTitle: { fontSize: 11, color: colors.textDim, marginBottom: 6 }, + recentItem: { + backgroundColor: colors.bg, borderRadius: 6, padding: 10, + marginBottom: 4, + }, + recentText: { fontSize: 12, color: colors.textDim }, + infoGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, + infoItem: { + backgroundColor: colors.bg, borderRadius: 8, + padding: 10, width: '48%', + }, + infoLabel: { fontSize: 11, color: colors.textDim, marginBottom: 4 }, + infoValue: { fontSize: 14, fontWeight: '600', color: colors.text }, +}); diff --git a/mobile/src/screens/CreateScreen.tsx b/mobile/src/screens/CreateScreen.tsx new file mode 100644 index 0000000..e8f0161 --- /dev/null +++ b/mobile/src/screens/CreateScreen.tsx @@ -0,0 +1,170 @@ +import React, { useState } from 'react'; +import { + View, Text, TextInput, TouchableOpacity, StyleSheet, + ScrollView, Alert, ActivityIndicator, +} from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { colors, spacing } from '../lib/theme'; +import { apiClient, storage } from '../lib/api'; + +export default function CreateScreen() { + const [name, setName] = useState(''); + const [port, setPort] = useState('25565'); + const [version, setVersion] = useState('1.20.4'); + const [edition, setEdition] = useState<'java' | 'bedrock'>('java'); + const [maxPlayers, setMaxPlayers] = useState('10'); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [roomId, setRoomId] = useState(''); + + async function handleCreate() { + if (!apiClient.isConfigured) { Alert.alert('提示', '请先连接服务器'); return; } + if (!name.trim()) { Alert.alert('提示', '请输入房间名称'); return; } + + setLoading(true); + try { + const playerName = await storage.getPlayerName(); + const result = await apiClient.createRoom({ + name: name.trim(), + hostName: playerName, + hostPort: parseInt(port) || 25565, + gameVersion: version, + gameEdition: edition, + maxPlayers: parseInt(maxPlayers) || 10, + password: password || undefined, + }); + setRoomId(result.room.id); + } catch (err: any) { + Alert.alert('创建失败', err.response?.data?.error || err.message); + } finally { + setLoading(false); + } + } + + async function copyRoomId() { + await Clipboard.setStringAsync(roomId); + Alert.alert('已复制', '房间号已复制到剪贴板,分享给你的好友吧!'); + } + + if (roomId) { + return ( + + + 🎉 + 房间创建成功! + {roomId} + 将房间号分享给好友即可联机 + + 复制房间号 + + setRoomId('')}> + 创建新房间 + + + + ); + } + + return ( + + 创建房间 + 创建联机房间并分享给好友 + + + 房间名称 * + + + + + 本地端口 + + + + 最大玩家 + + + + + 游戏版本 + + + 版本类型 + + {(['java', 'bedrock'] as const).map(e => ( + setEdition(e)} + > + + {e === 'java' ? 'Java 版' : '基岩版'} + + + ))} + + + 房间密码(可选) + + + + {loading ? : + 创建房间} + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bg }, + content: { padding: spacing.lg }, + title: { fontSize: 24, fontWeight: '700', color: colors.text, marginBottom: 4 }, + desc: { fontSize: 13, color: colors.textDim, marginBottom: 20 }, + card: { + backgroundColor: colors.card, borderRadius: 12, + borderWidth: 1, borderColor: colors.border, padding: spacing.lg, + }, + label: { fontSize: 12, color: colors.textDim, marginBottom: 6, fontWeight: '500', marginTop: 12 }, + input: { + backgroundColor: colors.input, borderWidth: 1, borderColor: colors.border, + borderRadius: 8, padding: 12, color: colors.text, fontSize: 14, + }, + row: { flexDirection: 'row', gap: 12 }, + editionRow: { flexDirection: 'row', gap: 8 }, + editionBtn: { + flex: 1, padding: 12, borderRadius: 8, alignItems: 'center', + backgroundColor: colors.bgAccent, + }, + editionActive: { backgroundColor: colors.green }, + editionText: { color: colors.textDim, fontSize: 13, fontWeight: '500' }, + editionTextActive: { color: '#fff' }, + btn: { + backgroundColor: colors.green, borderRadius: 8, + padding: 14, alignItems: 'center', marginTop: 20, + }, + btnText: { color: '#fff', fontWeight: '600', fontSize: 14 }, + btnSecondary: { + borderWidth: 1, borderColor: colors.border, borderRadius: 8, + padding: 14, alignItems: 'center', marginTop: 10, + }, + btnSecondaryText: { color: colors.textDim, fontWeight: '500', fontSize: 14 }, + resultCard: { + backgroundColor: colors.card, borderRadius: 12, + borderWidth: 1, borderColor: colors.border, padding: spacing.xl, + alignItems: 'center', + }, + resultTitle: { fontSize: 20, fontWeight: '700', color: colors.green, marginTop: 12 }, + resultId: { + fontSize: 28, fontWeight: '700', color: colors.gold, + letterSpacing: 3, marginVertical: 16, + }, + resultHint: { fontSize: 13, color: colors.textDim, marginBottom: 16 }, +}); diff --git a/mobile/src/screens/JoinScreen.tsx b/mobile/src/screens/JoinScreen.tsx new file mode 100644 index 0000000..1d09e7e --- /dev/null +++ b/mobile/src/screens/JoinScreen.tsx @@ -0,0 +1,159 @@ +import React, { useState } from 'react'; +import { + View, Text, TextInput, TouchableOpacity, StyleSheet, + ScrollView, Alert, ActivityIndicator, +} from 'react-native'; +import { colors, spacing } from '../lib/theme'; +import { apiClient } from '../lib/api'; + +export default function JoinScreen() { + const [roomId, setRoomId] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [joined, setJoined] = useState(false); + const [connectInfo, setConnectInfo] = useState(null); + + async function handleJoin() { + if (!apiClient.isConfigured) { Alert.alert('提示', '请先连接服务器'); return; } + if (!roomId.trim()) { Alert.alert('提示', '请输入房间号'); return; } + + setLoading(true); + try { + const result = await apiClient.joinRoom(roomId.trim(), password || undefined); + setConnectInfo(result); + setJoined(true); + } catch (err: any) { + Alert.alert('加入失败', err.response?.data?.error || err.message); + } finally { + setLoading(false); + } + } + + if (joined && connectInfo) { + return ( + + + + 验证通过! + 请在 Minecraft 中添加以下服务器地址: + + + 服务器地址 + {apiClient.serverUrl.replace(/https?:\/\//, '').replace(/:3000$/, '')}:25565 + + + + 连接说明 + 1. 打开 Minecraft + 2. 选择「多人游戏」/「添加服务器」 + 3. 输入上方服务器地址 + 4. 连接即可开始联机! + + + { setJoined(false); setRoomId(''); }}> + 加入其他房间 + + + + ); + } + + return ( + + 加入房间 + 输入房间号加入好友的联机世界 + + + 房间号 * + + + 房间密码(如需要) + + + + {loading ? : + 加入房间} + + + + + 💡 如何联机? + + 1. 在「连接服务器」页面连接到中继服务器{'\n'} + 2. 输入房主分享的房间号{'\n'} + 3. 验证通过后,在 Minecraft 中添加服务器地址{'\n'} + 4. 连接即可开始游戏! + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bg }, + content: { padding: spacing.lg }, + title: { fontSize: 24, fontWeight: '700', color: colors.text, marginBottom: 4 }, + desc: { fontSize: 13, color: colors.textDim, marginBottom: 20 }, + card: { + backgroundColor: colors.card, borderRadius: 12, + borderWidth: 1, borderColor: colors.border, padding: spacing.lg, + }, + label: { fontSize: 12, color: colors.textDim, marginBottom: 6, fontWeight: '500', marginTop: 12 }, + input: { + backgroundColor: colors.input, borderWidth: 1, borderColor: colors.border, + borderRadius: 8, padding: 12, color: colors.text, fontSize: 14, + }, + btn: { + backgroundColor: colors.green, borderRadius: 8, + padding: 14, alignItems: 'center', marginTop: 20, + }, + btnText: { color: '#fff', fontWeight: '600', fontSize: 14 }, + btnSecondary: { + borderWidth: 1, borderColor: colors.border, borderRadius: 8, + padding: 14, alignItems: 'center', marginTop: 16, + }, + btnSecondaryText: { color: colors.textDim, fontWeight: '500', fontSize: 14 }, + successCard: { + backgroundColor: colors.card, borderRadius: 12, + borderWidth: 1, borderColor: colors.border, padding: spacing.xl, + alignItems: 'center', + }, + successTitle: { fontSize: 20, fontWeight: '700', color: colors.green, marginTop: 12 }, + successDesc: { fontSize: 13, color: colors.textDim, marginTop: 8, marginBottom: 16 }, + addressBox: { + backgroundColor: colors.bg, borderRadius: 10, padding: 16, + width: '100%', alignItems: 'center', marginBottom: 16, + }, + addressLabel: { fontSize: 11, color: colors.textDim, marginBottom: 6 }, + addressValue: { fontSize: 18, fontWeight: '700', color: colors.gold }, + infoBox: { + backgroundColor: colors.bg, borderRadius: 10, padding: 16, width: '100%', + }, + infoTitle: { fontSize: 13, fontWeight: '600', color: colors.green, marginBottom: 8 }, + infoStep: { fontSize: 12, color: colors.textDim, marginBottom: 4 }, + helpCard: { + backgroundColor: colors.card, borderRadius: 12, + borderWidth: 1, borderColor: colors.border, + padding: spacing.lg, marginTop: spacing.md, + }, + helpTitle: { fontSize: 14, fontWeight: '600', color: colors.green, marginBottom: 8 }, + helpText: { fontSize: 12, color: colors.textDim, lineHeight: 20 }, +}); diff --git a/mobile/src/screens/RoomsScreen.tsx b/mobile/src/screens/RoomsScreen.tsx new file mode 100644 index 0000000..ff27b1b --- /dev/null +++ b/mobile/src/screens/RoomsScreen.tsx @@ -0,0 +1,186 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + View, Text, TouchableOpacity, StyleSheet, FlatList, + RefreshControl, Alert, TextInput, +} from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { colors, spacing } from '../lib/theme'; +import { apiClient, RoomInfo } from '../lib/api'; + +export default function RoomsScreen() { + const [rooms, setRooms] = useState([]); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState('all'); + + const loadRooms = useCallback(async () => { + if (!apiClient.isConfigured) return; + setLoading(true); + try { + const data = await apiClient.getRooms(); + setRooms(data.rooms); + } catch { + Alert.alert('错误', '加载房间列表失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadRooms(); + const timer = setInterval(loadRooms, 15000); + return () => clearInterval(timer); + }, [loadRooms]); + + const filtered = rooms.filter(r => { + const matchSearch = !search || + r.name.toLowerCase().includes(search.toLowerCase()) || + r.id.toLowerCase().includes(search.toLowerCase()) || + r.hostName.toLowerCase().includes(search.toLowerCase()); + const matchFilter = filter === 'all' || r.gameEdition === filter; + return matchSearch && matchFilter; + }); + + async function copyId(id: string) { + await Clipboard.setStringAsync(id); + Alert.alert('已复制', `房间号 ${id} 已复制到剪贴板`); + } + + function renderRoom({ item }: { item: RoomInfo }) { + return ( + + + 🎮 + + + {item.name} {item.password ? '🔒' : ''} + + + 房间号: {item.id} · 房主: {item.hostName} + + + + + + + 👥 {item.currentPlayers}/{item.maxPlayers} + + + + + {item.gameEdition === 'java' ? 'Java' : '基岩'} {item.gameVersion} + + + copyId(item.id)}> + 复制号 + + + + ); + } + + if (!apiClient.isConfigured) { + return ( + + 🔗 + 请先连接服务器 + + ); + } + + return ( + + + 房间列表 + + {rooms.length} 个房间在线 · 自动刷新 + + + + + + + {['all', 'java', 'bedrock'].map(f => ( + setFilter(f)} + > + + {f === 'all' ? '全部' : f === 'java' ? 'Java' : '基岩'} + + + ))} + + + + item.id} + refreshControl={} + contentContainerStyle={{ padding: spacing.md }} + ListEmptyComponent={ + + 🎮 + + {search ? '无匹配房间' : '暂无在线房间'} + + + } + /> + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bg }, + header: { paddingHorizontal: spacing.lg, paddingTop: spacing.lg }, + title: { fontSize: 24, fontWeight: '700', color: colors.text }, + desc: { fontSize: 12, color: colors.textDim, marginTop: 4 }, + filterRow: { paddingHorizontal: spacing.md, paddingTop: spacing.md }, + searchInput: { + backgroundColor: colors.input, borderWidth: 1, borderColor: colors.border, + borderRadius: 8, padding: 10, color: colors.text, fontSize: 13, marginBottom: 8, + }, + filterBtns: { flexDirection: 'row', gap: 8, marginBottom: 4 }, + filterBtn: { + paddingHorizontal: 14, paddingVertical: 6, borderRadius: 16, + backgroundColor: colors.bgAccent, + }, + filterActive: { backgroundColor: colors.green }, + filterText: { fontSize: 12, color: colors.textDim }, + filterTextActive: { color: '#fff', fontWeight: '600' }, + roomCard: { + backgroundColor: colors.card, borderRadius: 12, + borderWidth: 1, borderColor: colors.border, + padding: spacing.md, marginBottom: spacing.sm, + }, + roomTop: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + roomIcon: { + width: 42, height: 42, borderRadius: 10, + backgroundColor: 'rgba(76,175,80,0.15)', + alignItems: 'center', justifyContent: 'center', + }, + roomName: { fontSize: 14, fontWeight: '600', color: colors.text }, + roomMeta: { fontSize: 11, color: colors.textDim, marginTop: 2 }, + roomTags: { + flexDirection: 'row', alignItems: 'center', gap: 8, + marginTop: 12, + }, + tag: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12 }, + tagText: { fontSize: 11 }, + copyBtn: { + marginLeft: 'auto', backgroundColor: colors.green, + paddingHorizontal: 12, paddingVertical: 5, borderRadius: 6, + }, + copyText: { color: '#fff', fontSize: 11, fontWeight: '600' }, + empty: { alignItems: 'center', paddingTop: 60 }, + emptyText: { fontSize: 14, color: colors.textDim, marginTop: 12 }, +}); diff --git a/mobile/src/screens/SettingsScreen.tsx b/mobile/src/screens/SettingsScreen.tsx new file mode 100644 index 0000000..69d8285 --- /dev/null +++ b/mobile/src/screens/SettingsScreen.tsx @@ -0,0 +1,113 @@ +import React, { useState, useEffect } from 'react'; +import { + View, Text, TextInput, TouchableOpacity, StyleSheet, + ScrollView, Alert, +} from 'react-native'; +import { colors, spacing } from '../lib/theme'; +import { storage } from '../lib/api'; + +export default function SettingsScreen() { + const [playerName, setPlayerName] = useState(''); + const [recentServers, setRecentServers] = useState([]); + + useEffect(() => { + loadSettings(); + }, []); + + async function loadSettings() { + setPlayerName(await storage.getPlayerName()); + setRecentServers(await storage.getRecentServers()); + } + + async function handleSave() { + await storage.setPlayerName(playerName.trim() || 'Player'); + Alert.alert('已保存', '设置已保存'); + } + + return ( + + 设置 + 客户端偏好设置 + + + 玩家名称 + + + + 保存设置 + + + + + 最近连接的服务器 + {recentServers.length === 0 ? ( + 暂无记录 + ) : ( + recentServers.map((s, i) => ( + + {s} + + )) + )} + + + + 关于 FunConnect + + 版本 + v1.1.0 + + + 平台 + iOS / Android + + + 框架 + React Native + Expo + + © 2024 FunMC Team + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bg }, + content: { padding: spacing.lg }, + title: { fontSize: 24, fontWeight: '700', color: colors.text, marginBottom: 4 }, + desc: { fontSize: 13, color: colors.textDim, marginBottom: 20 }, + card: { + backgroundColor: colors.card, borderRadius: 12, + borderWidth: 1, borderColor: colors.border, + padding: spacing.lg, marginBottom: spacing.md, + }, + cardTitle: { fontSize: 15, fontWeight: '600', color: colors.green, marginBottom: 12 }, + label: { fontSize: 12, color: colors.textDim, marginBottom: 6, fontWeight: '500' }, + input: { + backgroundColor: colors.input, borderWidth: 1, borderColor: colors.border, + borderRadius: 8, padding: 12, color: colors.text, fontSize: 14, + }, + btn: { + backgroundColor: colors.green, borderRadius: 8, + padding: 14, alignItems: 'center', marginTop: 16, + }, + btnText: { color: '#fff', fontWeight: '600', fontSize: 14 }, + emptyText: { fontSize: 12, color: '#555' }, + recentItem: { + backgroundColor: colors.bg, borderRadius: 6, padding: 10, marginBottom: 4, + }, + recentText: { fontSize: 12, color: colors.textDim }, + aboutRow: { + flexDirection: 'row', justifyContent: 'space-between', + paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.border, + }, + aboutLabel: { fontSize: 13, color: colors.textDim }, + aboutValue: { fontSize: 13, color: colors.text, fontWeight: '500' }, + copyright: { fontSize: 11, color: '#555', textAlign: 'center', marginTop: 16 }, +}); diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json new file mode 100644 index 0000000..6b574f4 --- /dev/null +++ b/mobile/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + } +}