feat: 白色主题UI + 签到接口修复 + 物理/地形Bug修复 + WPF启动器重构
CI / Go Backend (push) Canceled after 0s

This commit is contained in:
xyou
2026-08-09 21:32:55 +08:00
parent f70b061d1a
commit abd4548dff
20 changed files with 3559 additions and 449 deletions
+1
View File
@@ -79,6 +79,7 @@ desktop.ini
.dotnet/ .dotnet/
.nuget/ .nuget/
.userdata/ .userdata/
dotnet-sdk/
# ===================== Docker ===================== # ===================== Docker =====================
docker-compose.override.yml docker-compose.override.yml
BIN
View File
Binary file not shown.
+7
View File
@@ -4,4 +4,11 @@ namespace MRCC.Launcher;
public partial class App : Application public partial class App : Application
{ {
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
DispatcherUnhandledException += (s, a) => Program.Log($"UI异常: {a.Exception}");
AppDomain.CurrentDomain.UnhandledException += (s, a) => Program.Log($"未处理异常: {a.ExceptionObject}");
TaskScheduler.UnobservedTaskException += (s, a) => Program.Log($"任务异常: {a.Exception}");
}
} }
+5
View File
@@ -0,0 +1,5 @@
<Window x:Class="MRCC.Launcher.GameWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Game" Height="450" Width="800">
</Window>
+11
View File
@@ -0,0 +1,11 @@
using System.Windows;
namespace MRCC.Launcher;
public partial class GameWindow : Window
{
public GameWindow()
{
InitializeComponent();
}
}
+8 -14
View File
@@ -1,31 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework> <TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<EnableWindowsTargeting>true</EnableWindowsTargeting> <EnableWindowsTargeting>true</EnableWindowsTargeting>
<RootNamespace>MRCC.Launcher</RootNamespace> <RootNamespace>MRCC.Launcher</RootNamespace>
<AssemblyName>MRCC.Launcher</AssemblyName> <AssemblyName>RedCircuit</AssemblyName>
<PublishDir>..\..\build\</PublishDir>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>14.0</LangVersion> <LangVersion>14.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<StartupObject>MRCC.Launcher.Program</StartupObject> <StartupObject>Program</StartupObject>
<SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4129.50" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PCL-CE\PCL.Core\PCL.Core.csproj" /> <Content Include="..\..\prototype\**" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" LinkBase="game\" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<EmbeddedResource Include="metadata.json">
<LogicalName>PCL.metadata.json</LogicalName>
</EmbeddedResource>
</ItemGroup>
</Project> </Project>
+6 -123
View File
@@ -1,128 +1,11 @@
<Window x:Class="MRCC.Launcher.MainWindow" <Window x:Class="MRCC.Launcher.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MRCC Launcher" Title="RedCircuit Launcher"
Height="600" Width="900" Height="720" Width="1100"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
Background="#0E1116" Background="#0A0A14"
WindowStyle="SingleBorderWindow" ResizeMode="CanResizeWithGrip"
ResizeMode="CanResize" MinHeight="400" MinWidth="600"
MinHeight="500" MinWidth="700"> Loaded="OnWindowLoaded">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 侧边栏 -->
<Border Grid.Column="0" Background="#161B22" BorderBrush="#2A3441" BorderThickness="0,0,1,0">
<DockPanel Margin="0,20,0,0">
<!-- Logo -->
<StackPanel DockPanel.Dock="Top" Margin="20,0,20,20">
<TextBlock Text="MRCC" FontSize="20" FontWeight="Bold" Foreground="#E83229"
FontFamily="Consolas"/>
<TextBlock Text="MineRedCircuitcraft" FontSize="10" Foreground="#6B7280"
Margin="0,2,0,0"/>
</StackPanel>
<!-- 导航菜单 -->
<StackPanel DockPanel.Dock="Top" Margin="10,0">
<RadioButton x:Name="NavHome" Content="首页" IsChecked="True"
Style="{StaticResource NavRadioButtonStyle}"
Checked="OnNavChecked" Tag="home"/>
<RadioButton Content="下载"
Style="{StaticResource NavRadioButtonStyle}"
Checked="OnNavChecked" Tag="download"/>
<RadioButton Content="设置"
Style="{StaticResource NavRadioButtonStyle}"
Checked="OnNavChecked" Tag="settings"/>
<RadioButton Content="关于"
Style="{StaticResource NavRadioButtonStyle}"
Checked="OnNavChecked" Tag="about"/>
</StackPanel>
<!-- 底部版本信息 -->
<TextBlock DockPanel.Dock="Bottom" Text="v1.0.0-dev" Foreground="#4B5563"
FontSize="10" Margin="20,0,20,10" VerticalAlignment="Bottom"/>
</DockPanel>
</Border>
<!-- 主内容区 -->
<Grid Grid.Column="1">
<!-- 首页 -->
<StackPanel x:Name="PageHome" Margin="32,28" Visibility="Visible">
<TextBlock Text="欢迎来到 MRCC" FontSize="24" FontWeight="Bold" Foreground="#E8EAED"/>
<TextBlock Text="红石逻辑 x 像素方块 x 模拟电路" FontSize="13" Foreground="#6B7280"
Margin="0,6,0,24"/>
<Border Background="#161B22" CornerRadius="8" Padding="20" Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="开始游戏" FontSize="14" FontWeight="Bold" Foreground="#E8EAED"/>
<TextBlock Text="点击下方按钮启动 MRCC 客户端" FontSize="11" Foreground="#6B7280"
Margin="0,4,0,12"/>
<Button Content="启动游戏" Width="120" Height="36" HorizontalAlignment="Left"
Background="#E83229" Foreground="White" BorderThickness="0"
FontSize="13" FontWeight="Bold" Click="OnLaunchGame"/>
</StackPanel>
</Border>
<Border Background="#161B22" CornerRadius="8" Padding="20" Margin="0,0,0,12">
<StackPanel>
<TextBlock Text="最新动态" FontSize="14" FontWeight="Bold" Foreground="#E8EAED"/>
<TextBlock Text="项目初始化中..." FontSize="11" Foreground="#6B7280" Margin="0,4,0,0"/>
</StackPanel>
</Border>
</StackPanel>
<!-- 下载页 -->
<StackPanel x:Name="PageDownload" Margin="32,28" Visibility="Collapsed">
<TextBlock Text="下载管理" FontSize="24" FontWeight="Bold" Foreground="#E8EAED"/>
<TextBlock Text="暂无下载任务" FontSize="13" Foreground="#6B7280" Margin="0,12,0,0"/>
</StackPanel>
<!-- 设置页 -->
<StackPanel x:Name="PageSettings" Margin="32,28" Visibility="Collapsed">
<TextBlock Text="设置" FontSize="24" FontWeight="Bold" Foreground="#E8EAED"/>
<TextBlock Text="设置项开发中..." FontSize="13" Foreground="#6B7280" Margin="0,12,0,0"/>
</StackPanel>
<!-- 关于页 -->
<StackPanel x:Name="PageAbout" Margin="32,28" Visibility="Collapsed">
<TextBlock Text="关于 MRCC" FontSize="24" FontWeight="Bold" Foreground="#E8EAED"/>
<TextBlock Text="MineRedCircuitcraft - 我的世界红石衍生版" FontSize="13" Foreground="#6B7280"
Margin="0,6,0,20"/>
<TextBlock Text="电路仿真游戏,红石逻辑 x 像素方块 x 模拟电路" FontSize="12" Foreground="#9BA3AE"/>
<TextBlock Text="核心库: PCL.Core (Apache 2.0)" FontSize="11" Foreground="#4B5563"
Margin="0,12,0,0"/>
</StackPanel>
</Grid>
</Grid>
<Window.Resources>
<Style x:Key="NavRadioButtonStyle" TargetType="RadioButton">
<Setter Property="Foreground" Value="#6B7280"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="16,10"/>
<Setter Property="Margin" Value="0,2"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<Border x:Name="bd" Background="Transparent" CornerRadius="6" Padding="{TemplateBinding Padding}">
<ContentPresenter VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="#1C2230"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="bd" Property="Background" Value="#1C2230"/>
<Setter Property="Foreground" Value="#E83229"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
</Window> </Window>
+136 -10
View File
@@ -1,29 +1,155 @@
using System;
using System.IO;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.Wpf;
namespace MRCC.Launcher; namespace MRCC.Launcher;
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
private WebView2? _wv;
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
} }
private void OnNavChecked(object sender, RoutedEventArgs e) private async void OnWindowLoaded(object sender, RoutedEventArgs e)
{ {
if (sender is not RadioButton rb) return; try
var tag = rb.Tag?.ToString() ?? "home"; {
var gameDir = FindGamePath();
if (gameDir == null)
{
Content = new TextBlock { Text = "游戏文件未找到", Foreground = System.Windows.Media.Brushes.White, FontSize = 14, Margin = new Thickness(20) };
return;
}
PageHome.Visibility = tag == "home" ? Visibility.Visible : Visibility.Collapsed; var port = GameServer.Start(gameDir);
PageDownload.Visibility = tag == "download" ? Visibility.Visible : Visibility.Collapsed; var url = $"http://localhost:{port}/voxel-world.html";
PageSettings.Visibility = tag == "settings" ? Visibility.Visible : Visibility.Collapsed;
PageAbout.Visibility = tag == "about" ? Visibility.Visible : Visibility.Collapsed; _wv = new WebView2();
Content = _wv;
await _wv.EnsureCoreWebView2Async();
_wv.CoreWebView2.Settings.IsWebMessageEnabled = true;
_wv.CoreWebView2.WebMessageReceived += OnWebMessage;
_wv.CoreWebView2.Navigate(url);
Program.Log($"Launcher UI loaded: {url}");
}
catch (Exception ex)
{
Program.Log($"Launcher load failed: {ex}");
}
} }
private void OnLaunchGame(object sender, RoutedEventArgs e) private void OnWebMessage(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
{ {
// TODO: 启动 MRCC Unity 客户端 try
MessageBox.Show("游戏启动功能开发中", "MRCC Launcher", MessageBoxButton.OK, MessageBoxImage.Information); {
// WebView2 的 postMessage 会 JSON 编码,需先解一层
var rawJson = e.TryGetWebMessageAsString();
if (string.IsNullOrEmpty(rawJson)) return;
using var doc = JsonDocument.Parse(rawJson);
var root = doc.RootElement;
if (!root.TryGetProperty("action", out var action) || action.GetString() != "launch_game") return;
var username = "Player";
if (root.TryGetProperty("username", out var u) && u.ValueKind == JsonValueKind.String)
username = u.GetString()!;
// 不再启动 Luanti —— HTML5 世界直接在 WebView2 内运行
Program.Log($"Game launch requested by: {username}");
}
catch (Exception ex) { Program.Log($"WebMessage error: {ex}"); }
}
public static string? FindGamePath()
{
var exeDir = AppContext.BaseDirectory;
foreach (var p in new[] { "game", "../game", "../../../prototype", "../../prototype", "../prototype" })
{
var full = Path.GetFullPath(Path.Combine(exeDir, p));
if (Directory.Exists(full) && File.Exists(Path.Combine(full, "voxel-world.html")))
return full;
}
return null;
}
}
// HTTP server for static files
public static class GameServer
{
private static HttpListener? _listener;
private static CancellationTokenSource? _cts;
private static string _gameDir = "";
public static int Start(string gameDir)
{
_gameDir = gameDir;
var port = FindFreePort();
_cts = new CancellationTokenSource();
_listener = new HttpListener();
_listener.Prefixes.Add($"http://localhost:{port}/");
_listener.Start();
Task.Run(() => ListenLoop(_cts.Token));
return port;
}
public static void Stop()
{
_cts?.Cancel();
try { _listener?.Stop(); } catch { }
try { _listener?.Close(); } catch { }
}
private static async Task ListenLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try { var ctx = await _listener!.GetContextAsync().WaitAsync(ct); _ = Task.Run(() => Handle(ctx)); }
catch (OperationCanceledException) { break; }
catch (HttpListenerException) { break; }
}
}
private static async Task Handle(HttpListenerContext ctx)
{
try
{
var path = ctx.Request.Url!.AbsolutePath.TrimStart('/');
if (string.IsNullOrEmpty(path)) path = "voxel-world.html";
var fp = Path.GetFullPath(Path.Combine(_gameDir, path));
if (!fp.StartsWith(_gameDir, StringComparison.OrdinalIgnoreCase) || !File.Exists(fp))
{ ctx.Response.StatusCode = 404; ctx.Response.Close(); return; }
var ct = Path.GetExtension(fp).ToLowerInvariant() switch
{
".html" => "text/html; charset=utf-8", ".css" => "text/css",
".js" => "application/javascript", ".json" => "application/json",
".png" => "image/png", ".jpg" or ".jpeg" => "image/jpeg",
".svg" => "image/svg+xml", _ => "application/octet-stream"
};
var bytes = await File.ReadAllBytesAsync(fp);
ctx.Response.ContentType = ct;
ctx.Response.ContentLength64 = bytes.Length;
ctx.Response.Headers.Add("Access-Control-Allow-Origin", "*");
ctx.Response.Headers.Add("Cache-Control", "no-cache");
await ctx.Response.OutputStream.WriteAsync(bytes);
ctx.Response.Close();
}
catch { try { ctx.Response.StatusCode = 500; ctx.Response.Close(); } catch { } }
}
private static int FindFreePort()
{
var l = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
l.Start(); var port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port; l.Stop();
return port;
} }
} }
+38 -14
View File
@@ -1,26 +1,50 @@
using System; using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Windows; using System.Windows;
using PCL.Core.App.Essentials; using MRCC.Launcher;
using PCL.Core.App.IoC;
namespace MRCC.Launcher; internal static class Program
public static class Program
{ {
public static bool IsDevMode;
private static readonly object _logLock = new();
private static readonly string LogPath = "redcircuit.log";
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AllocConsole();
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
// 设置 WPF Application 加载委托 IsDevMode = args.Any(a => string.Equals(a, "dev", StringComparison.OrdinalIgnoreCase));
ApplicationService.Loading = () =>
if (IsDevMode)
{ {
var app = new App(); AllocConsole();
return app; Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
}; Log("=== RedCircuit 开发模式 ===");
Log($"工作目录: {Environment.CurrentDirectory}");
Log($"EXE目录: {AppContext.BaseDirectory}");
Log("HTML5 世界直接运行于 WebView2 内,控制台用于查看日志。");
}
// 设置主窗口加载委托 // 启动器 UIWebView2 加载 HTML5 世界
MainWindowService.Loading = () => new MainWindow(); var app = new App();
app.Run(new MainWindow());
}
// 启动生命周期 public static void Log(string message)
Lifecycle.OnInitialize(); {
var line = $"[{DateTime.Now:HH:mm:ss.fff}] {message}";
lock (_logLock)
{
if (IsDevMode) Console.WriteLine(line);
Debug.WriteLine(line);
try { File.AppendAllText(LogPath, line + Environment.NewLine, Encoding.UTF8); } catch { }
}
} }
} }
@@ -1,29 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace MRCC.Launcher.ViewModels;
/// <summary>
/// 主窗口 ViewModel,管理导航状态与页面数据。
/// </summary>
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
private string _versionText = "v1.0.0-dev";
[ObservableProperty]
private string _statusText = "就绪";
[ObservableProperty]
private bool _isGameInstalled;
[ObservableProperty]
private int _downloadProgress;
/// <summary>
/// 启动游戏命令(后续实现具体逻辑)
/// </summary>
public void LaunchGame()
{
// TODO: 检查游戏安装状态 -> 启动 Unity 客户端
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 904 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+2528 -252
View File
@@ -2,69 +2,682 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>RedCircuit · 方块世界 v0.3</title> <title>RedCircuit · 方块世界 v0.4</title>
<style> <style>
/* ============================================================
RedCircuit · 方块世界 — 启动器视觉层 v3(红蓝白 · 方形科技风)
品牌红 #E83229 | 辅助蓝 #58A6FF | 白 #F5F7FB
============================================================ */
:root{
color-scheme:light;
--red:#E83229;
--red-deep:#C4201E;
--red-bright:#E83229;
--blue:#2D7FF0;
--blue-deep:#1D5FBF;
--white:#FFFFFF;
--bg-0:#f2f4f8;
--panel-grad:linear-gradient(180deg,rgba(255,255,255,0.94),rgba(242,244,248,0.97));
--line:rgba(10,15,30,0.10);
--line-hi:rgba(10,15,30,0.20);
--t1:#1b1d26;
--t2:#4a5160;
--t3:#8b93a3;
--r-lg:4px;
--r-md:3px;
--r-sm:2px;
--font:'Segoe UI','Microsoft YaHei',sans-serif;
--shadow-deep:0 20px 50px rgba(15,20,40,0.18),0 0 30px rgba(232,50,41,0.04);
}
/* ---------- 基础 ---------- */
*{margin:0;padding:0;box-sizing:border-box} *{margin:0;padding:0;box-sizing:border-box}
body{overflow:hidden;background:#000;font-family:'Segoe UI',sans-serif} html,body{height:100%}
body{
overflow:hidden;
background:var(--bg-0);
color:var(--t1);
font-family:var(--font);
font-size:14px;
line-height:1.55;
text-rendering:optimizeLegibility;
-webkit-font-smoothing:antialiased;
}
canvas{display:block} canvas{display:block}
/* 背景画布:压暗增饱和,作为优雅底色 */
#bgCanvas{
position:fixed;inset:0;z-index:0;width:100%;height:100%;object-fit:cover;
filter:brightness(0.7) saturate(1.06);
}
/* ---------- 游戏内 HUD ---------- */
#crosshair{position:fixed;top:50%;left:50%;width:20px;height:20px;transform:translate(-50%,-50%);pointer-events:none;z-index:10} #crosshair{position:fixed;top:50%;left:50%;width:20px;height:20px;transform:translate(-50%,-50%);pointer-events:none;z-index:10}
#crosshair::before,#crosshair::after{content:'';position:absolute;background:rgba(255,255,255,0.7)} #crosshair::before,#crosshair::after{content:'';position:absolute;background:rgba(255,255,255,0.75);animation:crosshair-pulse 2s ease-in-out infinite}
#crosshair::before{width:2px;height:12px;left:9px;top:4px} #crosshair::before{width:2px;height:12px;left:9px;top:4px;box-shadow:0 0 4px rgba(232,50,41,0.35)}
#crosshair::after{width:12px;height:2px;left:4px;top:9px} #crosshair::after{width:12px;height:2px;left:4px;top:9px;box-shadow:0 0 4px rgba(232,50,41,0.35)}
#hotbar{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);display:flex;gap:4px;background:rgba(0,0,0,0.5);padding:4px;border-radius:6px;z-index:10}
.slot{width:56px;height:64px;border:2px solid rgba(255,255,255,0.2);border-radius:4px;display:flex;flex-direction:column;align-items:center;justify-content:flex-start;cursor:pointer;position:relative;background:rgba(0,0,0,0.3);padding-top:2px} #hotbar{
.slot.active{border-color:#E83229;box-shadow:0 0 8px rgba(232,50,41,0.5)} position:fixed;bottom:12px;left:50%;transform:translateX(-50%);
.slot-icon{width:32px;height:32px;border-radius:3px;image-rendering:pixelated} display:flex;gap:3px;padding:5px;
.slot-key{position:absolute;top:1px;left:3px;font-size:9px;color:rgba(255,255,255,0.6)} background:linear-gradient(180deg,rgba(255,255,255,0.88),rgba(244,246,250,0.94));
.slot-name{font-size:9px;color:rgba(255,255,255,0.7);margin-top:2px;text-align:center;line-height:1.1;white-space:nowrap} backdrop-filter:blur(8px);
#info{position:fixed;top:12px;left:12px;color:#aaa;font-size:12px;z-index:10;line-height:1.6;background:rgba(0,0,0,0.4);padding:8px 12px;border-radius:6px} -webkit-backdrop-filter:blur(8px);
#info b{color:#E83229} border:1px solid var(--line);
#overlay{position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;flex-direction:column;z-index:100;color:#fff} border-radius:2px;z-index:10;
#overlay h1{font-size:32px;color:#E83229;margin-bottom:8px} box-shadow:0 8px 26px rgba(15,20,40,0.18),inset 0 1px 0 rgba(255,255,255,0.9);
#overlay p{color:#888;margin:4px 0;font-size:14px} }
#overlay .start-btn{margin-top:20px;padding:12px 40px;background:#E83229;border:none;color:#fff;font-size:16px;border-radius:6px;cursor:pointer} .slot{
#overlay .start-btn:hover{background:#B91C1C} width:52px;height:58px;
#overlay .keys{margin-top:16px;font-size:12px;color:#666;line-height:1.8;text-align:center} border:2px solid;
#toast{position:fixed;top:50%;left:50%;transform:translate(-50%,-60px);background:rgba(0,0,0,0.7);color:#fff;padding:6px 16px;border-radius:4px;font-size:13px;z-index:20;opacity:0;transition:opacity .3s;pointer-events:none} border-color:#dfe3ec #c6ccda #c6ccda #dfe3ec;
border-radius:2px;
display:flex;flex-direction:column;align-items:center;justify-content:flex-start;
cursor:pointer;position:relative;
background:linear-gradient(180deg,#ffffff,#f1f3f8);
padding-top:4px;
transition:border-color .15s,box-shadow .15s,transform .15s;
}
.slot:hover{border-color:#b6bdcd #9aa3b8 #9aa3b8 #b6bdcd;transform:translateY(-1px)}
.slot.active{
border-color:#ff6a5e !important;
box-shadow:0 0 0 1px rgba(232,50,41,0.6),0 0 12px rgba(232,50,41,0.35) inset,0 0 8px rgba(232,50,41,0.3);
}
.slot-icon{width:30px;height:30px;image-rendering:pixelated}
.slot-key{position:absolute;top:2px;left:3px;font-size:8px;color:rgba(27,29,38,0.45)}
.slot-name{font-size:8px;color:rgba(27,29,38,0.68);margin-top:3px;text-align:center;line-height:1.1;white-space:nowrap;max-width:46px;overflow:hidden;text-overflow:ellipsis}
#info{
position:fixed;top:14px;left:14px;
color:var(--t2);font-size:12px;z-index:10;line-height:1.7;
background:linear-gradient(180deg,rgba(255,255,255,0.88),rgba(244,246,250,0.94));
backdrop-filter:blur(6px);
-webkit-backdrop-filter:blur(6px);
padding:9px 14px;border-radius:2px;
border:1px solid var(--line);
box-shadow:0 6px 20px rgba(15,20,40,0.14);
}
#info b{color:var(--red);text-shadow:0 0 10px rgba(232,50,41,0.35);animation:breathe 3.2s ease-in-out infinite}
/* ---------- 全屏遮罩(浅色红调渐变,白色主题) ---------- */
#overlay{
position:fixed;inset:0;
background:
radial-gradient(120% 85% at 50% 0%,rgba(232,50,41,0.08),transparent 60%),
linear-gradient(168deg,rgba(246,248,252,0.82) 0%,rgba(238,241,247,0.92) 100%);
backdrop-filter:blur(2px);
-webkit-backdrop-filter:blur(2px);
display:flex;align-items:center;justify-content:center;
z-index:100;color:var(--t1);
}
/* ---------- 菜单面板(玻璃 · 左对齐按钮) ---------- */
.menu-panel{
display:flex;flex-direction:column;align-items:stretch;
text-align:left;
width:min(92vw,400px);min-width:300px;max-width:400px;
max-height:88vh;overflow-y:auto;
padding:36px 38px;
background:var(--panel-grad);
backdrop-filter:blur(20px) saturate(1.25);
-webkit-backdrop-filter:blur(20px) saturate(1.25);
border:1px solid var(--line);
border-top-color:rgba(255,255,255,0.9);
border-radius:var(--r-lg);
box-shadow:var(--shadow-deep),inset 0 1px 0 rgba(255,255,255,0.9);
}
.menu-panel h1{
font-size:30px;font-weight:700;letter-spacing:2px;
color:var(--red);text-align:center;
margin-bottom:6px;
text-shadow:0 2px 14px rgba(232,50,41,0.18);
}
.menu-panel h2{
font-size:19px;font-weight:600;color:var(--t1);
text-align:center;margin-bottom:16px;letter-spacing:1px;
}
.menu-panel p{
color:var(--t2);margin:2px 0 22px;font-size:13px;text-align:center;letter-spacing:2px;
}
.menu-panel .keys{margin-top:18px;font-size:12px;color:var(--t3);line-height:2;text-align:center}
.menu-panel .keys b{color:var(--red);font-weight:600}
/* 面板内:按钮列 / 表单 / 状态 统一左对齐(280px 内容列) */
.menu-panel > .menu-btn,
.menu-panel > .form-group,
.menu-panel > .form-error,
.menu-panel > .menu-status{
align-self:flex-start;width:100%;max-width:280px;
}
.menu-panel > .menu-btn{justify-content:flex-start;text-align:left}
/* ---------- 按钮 ---------- */
.menu-btn{
display:inline-flex;align-items:center;justify-content:center;gap:6px;
padding:11px 22px;margin:6px 0;
background:rgba(30,40,70,0.07);
border:1px solid var(--line);
color:var(--t1);font-size:14px;font-family:inherit;
border-radius:var(--r-md);cursor:pointer;
transition:background .18s,border-color .18s,color .18s,transform .18s,box-shadow .18s;
user-select:none;
}
.menu-btn:hover{
background:rgba(30,40,70,0.13);
border-color:var(--line-hi);
color:var(--t1);
transform:translateY(-1px);
box-shadow:0 6px 16px rgba(20,30,60,0.14);
}
.menu-btn:active{transform:translateY(0) scale(0.98)}
.menu-btn.primary{
position:relative;overflow:hidden;
background:linear-gradient(180deg,#f0473a,#d8231b);
border-color:rgba(232,50,41,0.6);
color:#fff;font-weight:600;letter-spacing:0.5px;
box-shadow:0 8px 22px rgba(232,50,41,0.28),inset 0 1px 0 rgba(255,255,255,0.20);
}
.menu-btn.primary:hover{
background:linear-gradient(180deg,#ff5749,#e32b21);
box-shadow:0 10px 28px rgba(232,50,41,0.36),inset 0 1px 0 rgba(255,255,255,0.22);
}
.menu-btn.primary::after{
content:'';position:absolute;inset:0;pointer-events:none;
background:linear-gradient(90deg,transparent,rgba(255,255,255,0.07),transparent);
background-size:200% 100%;
animation:shimmer 3.5s ease-in-out infinite;
}
.menu-btn.ghost{background:transparent;border-color:transparent;color:var(--t2)}
.menu-btn.ghost:hover{background:rgba(30,40,70,0.06);color:var(--t1);box-shadow:none}
.menu-btn.start-btn{margin-top:18px;padding:14px 40px;font-size:16px;font-weight:600}
/* ---------- 表单 ---------- */
.form-group{width:100%;max-width:280px;margin:6px 0}
.form-group input{
width:100%;padding:12px 14px;
background:rgba(255,255,255,0.85);
border:1px solid var(--line);
color:var(--t1);font-size:13px;font-family:inherit;
border-radius:var(--r-sm);
outline:none;
transition:border-color .18s,box-shadow .18s,background .18s;
}
.form-group input::placeholder{color:#98a0b0}
.form-group input:focus{
border-color:rgba(232,50,41,0.65);
background:#fff;
box-shadow:0 0 0 3px rgba(232,50,41,0.12),0 0 16px rgba(232,50,41,0.10);
}
.form-error{width:100%;max-width:280px;min-height:20px;font-size:12px;color:var(--red);margin:3px 0;text-align:left}
.menu-status{font-size:11px;color:var(--t3);margin-top:18px;text-align:left}
/* ---------- Hub 主页 ---------- */
.hub-page{position:absolute;inset:0;display:flex}
.hub-profile{
position:absolute;top:24px;left:24px;
display:flex;align-items:center;flex-wrap:wrap;gap:12px;
background:linear-gradient(180deg,rgba(255,255,255,0.94),rgba(244,246,250,0.97));
backdrop-filter:blur(16px);
-webkit-backdrop-filter:blur(16px);
padding:12px 18px;border-radius:var(--r-md);
border:1px solid var(--line);
box-shadow:0 12px 34px rgba(15,20,40,0.14),inset 0 1px 0 rgba(255,255,255,0.9);
z-index:2;
max-width:min(92vw,740px);
}
.hub-profile canvas{border-radius:2px;image-rendering:pixelated}
#hubAvatar{border:2px solid rgba(232,50,41,0.35);box-shadow:0 0 14px rgba(232,50,41,0.20)}
.hub-profile-info{display:flex;flex-direction:column}
.hub-nickname{color:var(--t1);font-size:15px;font-weight:600}
.hub-level{color:var(--red);font-size:11px;font-weight:600;animation:breathe 2.4s ease-in-out infinite}
.hub-coins{display:flex;align-items:center;gap:2px;font-size:12px;color:var(--t2);margin-top:2px}
.coin-icon{font-size:14px}
/* 用户信息卡内的横向小按钮 */
.hub-profile .menu-btn{
width:auto;margin:0;padding:6px 13px;
font-size:11px;border-radius:2px;
background:rgba(30,40,70,0.06);border-color:var(--line);
}
.hub-profile .menu-btn:hover{background:rgba(30,40,70,0.12);border-color:var(--line-hi)}
.hub-start-btn{
position:absolute;bottom:36px;right:36px;
padding:17px 38px;
background:linear-gradient(180deg,#f0473a,#d8231b);
border:1px solid rgba(232,50,41,0.6);
color:#fff;font-size:19px;font-weight:600;letter-spacing:1px;
border-radius:2px;cursor:pointer;
display:flex;align-items:center;gap:10px;
box-shadow:0 12px 36px rgba(232,50,41,0.34),inset 0 1px 0 rgba(255,255,255,0.22);
transition:transform .2s,box-shadow .2s,background .2s;
overflow:hidden;
}
.hub-start-btn::before{
content:'';position:absolute;inset:0;pointer-events:none;
background:linear-gradient(90deg,transparent,rgba(255,255,255,0.08),transparent);
background-size:200% 100%;
animation:shimmer 4s ease-in-out infinite;
}
.hub-start-btn:hover{
transform:translateY(-3px);
background:linear-gradient(180deg,#ff5749,#e32b21);
box-shadow:0 16px 44px rgba(232,50,41,0.46),inset 0 1px 0 rgba(255,255,255,0.24);
}
.btn-arrow{font-size:16px;display:inline-block;animation:float 2.4s ease-in-out infinite}
/* ---------- 游戏模式选择 ---------- */
.modes-page{
display:flex;flex-direction:column;align-items:center;
min-width:0;width:min(92vw,660px);max-width:92vw;
padding:32px 34px;
background:var(--panel-grad);
backdrop-filter:blur(20px) saturate(1.25);
-webkit-backdrop-filter:blur(20px) saturate(1.25);
border:1px solid var(--line);
border-radius:2px;
box-shadow:var(--shadow-deep),inset 0 1px 0 rgba(255,255,255,0.9);
max-height:88vh;overflow-y:auto;
}
.modes-title{
font-size:20px;font-weight:600;color:var(--t1);
margin-bottom:24px;letter-spacing:2px;text-align:center;
}
.modes-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;width:100%;margin-bottom:20px;min-width:0}
.mode-card{
background:linear-gradient(180deg,rgba(255,255,255,0.9),rgba(244,246,250,0.92));
border:1px solid var(--line);
border-radius:2px;padding:18px 14px;text-align:center;cursor:pointer;
min-width:0;
transition:transform .2s,border-color .2s,box-shadow .2s,background .2s;
}
.mode-card:hover{
transform:translateY(-4px);
border-color:rgba(232,50,41,0.45);
background:linear-gradient(180deg,#fff,#fdf2f1);
box-shadow:0 12px 26px rgba(15,20,40,0.12),0 0 18px rgba(232,50,41,0.08);
}
.mode-card.active{
border-color:rgba(232,50,41,0.85);
background:linear-gradient(180deg,#fff5f4,#fdebe9);
box-shadow:0 0 0 1px rgba(232,50,41,0.55),0 0 22px rgba(232,50,41,0.14);
}
.mode-icon{font-size:28px;margin-bottom:10px;transition:transform .25s}
.mode-card:hover .mode-icon{transform:scale(1.12)}
.mode-name{color:var(--t1);font-size:14px;font-weight:600;margin-bottom:6px}
.mode-card.active .mode-name{color:var(--red);}
.mode-desc{color:var(--t3);font-size:10px;line-height:1.6}
.modes-page .menu-btn{width:100%;max-width:340px}
/* ---------- 方块破坏进度 / 粒子 ---------- */
#breakProgress{position:fixed;pointer-events:none;z-index:11;display:none}
#breakProgress .crack-stage{position:absolute;inset:0;background-size:cover;image-rendering:pixelated}
.particle{position:fixed;pointer-events:none;z-index:8;border-radius:1px}
/* ---------- 商店 ---------- */
.shop-page{
display:flex;flex-direction:column;align-items:center;
min-width:0;width:min(92vw,560px);max-width:92vw;
padding:28px 28px;
background:var(--panel-grad);
backdrop-filter:blur(20px) saturate(1.25);
-webkit-backdrop-filter:blur(20px) saturate(1.25);
border:1px solid var(--line);
border-radius:2px;
box-shadow:var(--shadow-deep),inset 0 1px 0 rgba(255,255,255,0.9);
max-height:82vh;overflow-y:auto;
}
.shop-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;width:100%;margin-bottom:14px;min-width:0}
.shop-item{
background:linear-gradient(180deg,rgba(255,255,255,0.9),rgba(244,246,250,0.92));
border:1px solid var(--line);
border-radius:2px;padding:15px 12px;text-align:center;
transition:transform .2s,border-color .2s,box-shadow .2s;
}
.shop-item:hover{transform:translateY(-3px);border-color:rgba(232,50,41,0.4);box-shadow:0 10px 20px rgba(15,20,40,0.10),0 0 14px rgba(232,50,41,0.06)}
.shop-item-icon{font-size:28px;margin-bottom:8px;transition:transform .25s}
.shop-item:hover .shop-item-icon{transform:scale(1.1)}
.shop-item-name{color:var(--t1);font-size:13px;font-weight:600;margin-bottom:5px}
.shop-item-desc{color:var(--t3);font-size:10px;margin-bottom:10px;line-height:1.5}
.shop-item-price{display:flex;align-items:center;justify-content:center;gap:4px;font-size:12px;font-weight:600;margin-bottom:10px}
.shop-item-price.diamonds{color:var(--blue-deep)}
.shop-item-price.redstone{color:var(--red)}
.shop-item-price.gold{color:var(--blue)}
.shop-buy-btn{
padding:5px 16px;
border:1px solid rgba(232,50,41,0.45);
border-radius:2px;
background:rgba(232,50,41,0.10);
color:var(--red);font-size:11px;font-weight:600;font-family:inherit;cursor:pointer;
transition:background .18s,border-color .18s,transform .15s;
}
.shop-buy-btn:hover{background:rgba(232,50,41,0.18);border-color:rgba(232,50,41,0.65)}
.shop-buy-btn:active{transform:scale(0.94)}
.shop-balance{margin-top:6px;font-size:12px;color:var(--t2);text-align:center}
/* ---------- 背包(市场面板) ---------- */
.inv-panel-page{
display:flex;flex-direction:column;align-items:center;
min-width:0;width:min(92vw,520px);max-width:92vw;
padding:28px 30px;
background:var(--panel-grad);
backdrop-filter:blur(20px) saturate(1.25);
-webkit-backdrop-filter:blur(20px) saturate(1.25);
border:1px solid var(--line);
border-radius:2px;
box-shadow:var(--shadow-deep),inset 0 1px 0 rgba(255,255,255,0.9);
max-height:82vh;overflow-y:auto;
}
.inv-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;width:100%;margin-bottom:14px;min-width:0}
.inv-item{
background:linear-gradient(180deg,rgba(255,255,255,0.9),rgba(244,246,250,0.92));
border:1px solid var(--line);
border-radius:2px;padding:13px 9px;text-align:center;
transition:transform .2s,border-color .2s;
}
.inv-item:hover{transform:translateY(-2px);border-color:rgba(45,127,240,0.4)}
.inv-item-icon{font-size:24px;margin-bottom:5px}
.inv-item-name{color:var(--t1);font-size:11px;margin-bottom:3px}
.inv-item-count{color:var(--blue);font-size:10px;font-weight:600;margin-bottom:7px}
.inv-use-btn{
padding:4px 12px;
background:rgba(45,127,240,0.10);
border:1px solid rgba(45,127,240,0.30);
color:var(--blue);font-size:10px;font-weight:600;font-family:inherit;
border-radius:2px;cursor:pointer;
transition:background .18s,border-color .18s;
}
.inv-use-btn:hover{background:rgba(45,127,240,0.18);border-color:rgba(45,127,240,0.50)}
.inv-history{margin-top:12px;font-size:10px;color:var(--t3);width:100%;max-height:130px;overflow-y:auto}
.inv-history-title{font-size:11px;color:var(--t2);margin-bottom:6px;letter-spacing:1px}
.inv-history-item{padding:4px 0;border-bottom:1px solid var(--line);display:flex;justify-content:space-between}
/* ---------- Toast ---------- */
#toast{
position:fixed;top:50%;left:50%;transform:translate(-50%,-60px);
background:rgba(255,255,255,0.92);
backdrop-filter:blur(10px);
-webkit-backdrop-filter:blur(10px);
color:var(--t1);padding:9px 18px;border-radius:2px;font-size:13px;z-index:20;
opacity:0;transition:opacity .3s;pointer-events:none;
border:1px solid var(--line-hi);
box-shadow:0 8px 28px rgba(15,20,40,0.18);
}
#toast.show{opacity:1} #toast.show{opacity:1}
#inventory{position:fixed;inset:0;background:rgba(0,0,0,0.7);display:none;align-items:center;justify-content:center;z-index:50}
/* ---------- 游戏内背包(MC 像素风) ---------- */
#inventory{
position:fixed;inset:0;background:rgba(242,244,248,0.55);
backdrop-filter:blur(4px);
-webkit-backdrop-filter:blur(4px);
display:none;align-items:center;justify-content:center;z-index:50;
}
#inventory.show{display:flex} #inventory.show{display:flex}
.inv-panel{background:#C6C6C6;border:4px solid #555;border-radius:0;padding:8px;box-shadow:0 4px 20px rgba(0,0,0,0.5);display:flex;gap:8px} .inv-panel{
background:var(--panel-grad);
border:1px solid var(--line);
border-radius:2px;padding:14px;
box-shadow:var(--shadow-deep),inset 0 1px 0 rgba(255,255,255,0.9);
display:flex;gap:12px;
max-width:94vw;max-height:90vh;overflow:auto;
}
.inv-left{display:flex;flex-direction:column;align-items:center} .inv-left{display:flex;flex-direction:column;align-items:center}
.inv-right{display:flex;flex-direction:column;align-items:center} .inv-right{display:flex;flex-direction:column;align-items:center}
.inv-title{font-size:12px;color:#333;font-weight:bold;margin-bottom:4px;text-align:center} .inv-title{font-size:11px;color:var(--t2);font-weight:600;margin-bottom:8px;text-align:center;letter-spacing:2px;text-transform:uppercase}
.inv-storage{display:grid;grid-template-columns:repeat(9,36px);gap:1px;justify-content:center;margin-bottom:4px} .inv-storage{display:grid;grid-template-columns:repeat(9,36px);gap:2px;justify-content:center;margin-bottom:8px}
.inv-hotbar{display:grid;grid-template-columns:repeat(10,36px);gap:1px;justify-content:center;border-top:2px solid #555;padding-top:4px;margin-top:2px} .inv-hotbar{display:grid;grid-template-columns:repeat(10,36px);gap:2px;justify-content:center;border-top:1px solid var(--line);padding-top:8px;margin-top:4px}
.inv-creative{display:grid;grid-template-columns:repeat(6,36px);gap:1px;justify-content:center;width:222px;max-height:288px;overflow-y:auto;padding:2px;background:#8B8B8B;border:2px solid;border-color:#373737 #fff #fff #373737} .inv-creative{
.inv-creative::-webkit-scrollbar{width:8px} display:grid;grid-template-columns:repeat(8,36px);gap:2px;justify-content:center;
.inv-creative::-webkit-scrollbar-track{background:#8B8B8B} width:300px;max-height:420px;overflow-y:auto;padding:6px;
.inv-creative::-webkit-scrollbar-thumb{background:#555} background:rgba(244,246,250,0.6);border:1px solid var(--line);border-radius:2px;
.inv-slot{width:36px;height:36px;background:#8B8B8B;border:2px solid;border-color:#373737 #fff #fff #373737;display:flex;align-items:center;justify-content:center;cursor:pointer;position:relative} }
.inv-slot:hover{background:#9B9B9B} .inv-creative::-webkit-scrollbar{width:6px}
.inv-slot.active{outline:2px solid #fff;outline-offset:-1px} .inv-creative::-webkit-scrollbar-track{background:rgba(10,15,30,0.05)}
.inv-slot canvas,.inv-slot img{width:28px;height:28px;image-rendering:pixelated;pointer-events:none} .inv-creative::-webkit-scrollbar-thumb{background:rgba(10,15,30,0.25);border-radius:2px}
.inv-slot .slot-num{position:absolute;top:0px;left:1px;font-size:7px;color:rgba(255,255,255,0.5);pointer-events:none} .inv-slot{
.inv-cat-label{font-size:9px;color:#404040;margin:2px 0 1px;padding-left:2px;text-align:left;width:100%} width:36px;height:36px;
.inv-sep{height:1px;background:#666;margin:2px 0} background:linear-gradient(180deg,#ffffff,#eef0f5);
border:2px solid;border-color:#dfe3ec #c6ccda #c6ccda #dfe3ec;
border-radius:2px;
display:flex;align-items:center;justify-content:center;cursor:pointer;position:relative;
transition:background .12s,border-color .12s,box-shadow .12s;
}
.inv-slot:hover{background:linear-gradient(180deg,#f7f8fb,#e4e8f0);border-color:#b6bdcd #9aa3b8 #9aa3b8 #b6bdcd}
.inv-slot.active{border-color:#ff6a5e;box-shadow:0 0 0 1px rgba(232,50,41,0.55),0 0 8px rgba(232,50,41,0.4) inset}
.inv-slot canvas,.inv-slot img{width:26px;height:26px;image-rendering:pixelated;pointer-events:none}
.inv-slot .slot-num{position:absolute;top:1px;left:2px;font-size:7px;color:rgba(27,29,38,0.45);pointer-events:none}
.inv-cat-label{font-size:8px;color:var(--t3);margin:4px 0 2px;padding-left:2px;text-align:left;width:100%;letter-spacing:0.5px}
#carriedItem{position:fixed;width:36px;height:36px;pointer-events:none;z-index:60;display:none} #carriedItem{position:fixed;width:36px;height:36px;pointer-events:none;z-index:60;display:none}
#carriedItem canvas,#carriedItem img{width:28px;height:28px;image-rendering:pixelated} #carriedItem canvas,#carriedItem img{width:26px;height:26px;image-rendering:pixelated}
.inv-hint{text-align:center;font-size:9px;color:#555;margin-top:3px} .inv-hint{text-align:center;font-size:8px;color:var(--t3);margin-top:6px}
#loading{position:fixed;inset:0;background:#0E1116;display:flex;align-items:center;justify-content:center;flex-direction:column;z-index:200;color:#888}
#loading .bar{width:200px;height:4px;background:#2A3441;border-radius:2px;margin-top:12px;overflow:hidden} /* ---------- Loading ---------- */
#loading .fill{height:100%;background:#E83229;width:0;transition:width .3s} #loading{
position:fixed;inset:0;
background:rgba(246,248,252,0.82);
backdrop-filter:blur(6px);
-webkit-backdrop-filter:blur(6px);
display:flex;align-items:center;justify-content:center;flex-direction:column;
z-index:200;color:var(--t1);
}
#loading .bar{width:220px;height:5px;background:rgba(10,15,30,0.12);border-radius:2px;margin-top:14px;overflow:hidden;box-shadow:inset 0 1px 2px rgba(0,0,0,0.15)}
#loading .fill{height:100%;background:linear-gradient(90deg,#d8231b,#ff5a45);width:0;transition:width .4s ease-out;box-shadow:0 0 14px rgba(232,50,41,0.5)}
/* ---------- AI 助手 ---------- */
#aiBtn{
position:fixed;bottom:92px;right:22px;width:46px;height:46px;
background:linear-gradient(180deg,#f0473a,#d8231b);
border:2px solid rgba(255,255,255,0.22);
border-radius:50%;color:#fff;font-size:20px;cursor:pointer;z-index:15;
display:none;align-items:center;justify-content:center;
box-shadow:0 6px 22px rgba(232,50,41,0.45);
animation:glow-pulse 4s ease-in-out infinite;
transition:transform .2s,box-shadow .2s;
}
#aiBtn:hover{transform:scale(1.12) rotate(10deg);box-shadow:0 8px 30px rgba(232,50,41,0.6)}
#aiBtn.show{display:flex}
#aiChat{
position:fixed;inset:0;background:rgba(242,244,248,0.55);
backdrop-filter:blur(3px);
-webkit-backdrop-filter:blur(3px);
display:none;align-items:center;justify-content:center;z-index:70;
}
#aiChat.show{display:flex}
#aiChat.pointer-locked{display:none}
.ai-panel{
width:min(92vw,420px);max-width:420px;max-height:min(88vh,520px);
background:var(--panel-grad);
border:1px solid var(--line);
border-radius:2px;display:flex;flex-direction:column;
box-shadow:var(--shadow-deep),0 0 30px rgba(232,50,41,0.04),inset 0 1px 0 rgba(255,255,255,0.9);
}
.ai-header{
display:flex;align-items:center;justify-content:space-between;
padding:12px 16px;border-bottom:1px solid var(--line);
background:linear-gradient(180deg,rgba(232,50,41,0.06),transparent);
border-radius:2px 2px 0 0;
}
.ai-header span{color:var(--t1);font-size:14px;font-weight:600;letter-spacing:0.5px}
.ai-close{background:none;border:none;color:var(--t3);font-size:18px;cursor:pointer;padding:2px 6px;border-radius:2px;transition:color .15s,background .15s}
.ai-close:hover{color:var(--t1);background:rgba(30,40,70,0.08)}
.ai-messages{flex:1;overflow-y:auto;padding:12px 16px;min-height:200px;max-height:320px;display:flex;flex-direction:column;gap:8px}
.ai-messages::-webkit-scrollbar{width:5px}
.ai-messages::-webkit-scrollbar-track{background:rgba(30,40,70,0.06)}
.ai-messages::-webkit-scrollbar-thumb{background:rgba(110,120,140,0.5);border-radius:2px}
.ai-msg{font-size:12px;line-height:1.6;padding:9px 12px;border-radius:2px;max-width:88%;word-break:break-word;animation:slide-up .25s ease-out}
.ai-msg.user{align-self:flex-end;background:rgba(232,50,41,0.10);color:var(--t1);border:1px solid rgba(232,50,41,0.25)}
.ai-msg.ai{align-self:flex-start;background:rgba(244,246,250,0.9);color:var(--t2);border:1px solid var(--line)}
.ai-msg .label{font-size:9px;color:var(--t3);margin-bottom:4px;font-weight:600;letter-spacing:0.5px}
.ai-input-row{display:flex;padding:10px 16px;border-top:1px solid var(--line);gap:8px}
.ai-input-row input{
flex:1;padding:9px 12px;
background:rgba(255,255,255,0.9);
border:1px solid var(--line);
color:var(--t1);font-size:12px;font-family:inherit;border-radius:2px;outline:none;
transition:border-color .18s,box-shadow .18s;
}
.ai-input-row input:focus{border-color:rgba(232,50,41,0.65);box-shadow:0 0 0 3px rgba(232,50,41,0.12)}
.ai-input-row button{
padding:9px 16px;background:linear-gradient(180deg,#f0473a,#d8231b);
border:1px solid rgba(232,50,41,0.5);color:#fff;font-size:12px;font-weight:600;
border-radius:2px;cursor:pointer;font-family:inherit;
box-shadow:0 4px 14px rgba(232,50,41,0.28);
transition:background .18s,box-shadow .18s;
}
.ai-input-row button:hover{background:linear-gradient(180deg,#ff5749,#e32b21);box-shadow:0 6px 18px rgba(232,50,41,0.38)}
.ai-hint{text-align:center;font-size:10px;color:var(--t3);padding:6px 0 8px}
/* ====== 关键动画(全部 keyframes 保留) ====== */
@keyframes glow-pulse{0%,100%{box-shadow:0 0 6px rgba(232,50,41,0.15),0 0 16px rgba(232,50,41,0.05)}50%{box-shadow:0 0 12px rgba(232,50,41,0.35),0 0 26px rgba(232,50,41,0.14)}}
@keyframes glow-soft{0%,100%{box-shadow:0 0 3px rgba(232,50,41,0.10)}50%{box-shadow:0 0 9px rgba(232,50,41,0.22)}}
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-5px)}}
@keyframes breathe{0%,100%{opacity:0.55}50%{opacity:1}}
@keyframes shimmer{0%{background-position:-200% center}100%{background-position:200% center}}
@keyframes border-glow{0%,100%{border-color:rgba(232,50,41,0.30)}50%{border-color:rgba(232,50,41,0.70)}}
@keyframes slide-up{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
@keyframes particle-drift{0%{transform:translate(0,0);opacity:0}10%{opacity:0.8}90%{opacity:0.3}100%{transform:translate(var(--dx),var(--dy));opacity:0}}
@keyframes crosshair-pulse{0%,100%{opacity:0.5}50%{opacity:0.9}}
/* ---------- 背景粒子 ---------- */
.menu-particles{position:absolute;inset:0;pointer-events:none;overflow:hidden}
.menu-particle{position:absolute;width:2px;height:2px;background:rgba(232,50,41,0.5);border-radius:50%;animation:particle-drift 6s ease-in-out infinite}
.menu-particle:nth-child(odd){background:rgba(255,255,255,0.30);animation-duration:8s}
#menuParticles{position:fixed;inset:0;pointer-events:none;z-index:99}
/* ---------- 每日签到 ---------- */
.daily-btn{transition:all .3s}
.daily-btn:hover{text-shadow:0 0 8px rgba(88,166,255,0.4)!important}
/* ---------- 游戏内模式 HUD ---------- */
#modeHUD{position:fixed;top:80px;left:50%;transform:translateX(-50%);z-index:12;display:none;flex-direction:column;align-items:center;gap:6px;pointer-events:none}
.mode-hud-badge{
background:linear-gradient(180deg,rgba(240,71,58,0.92),rgba(216,35,27,0.92));
color:#fff;padding:6px 20px;border-radius:2px;font-size:13px;font-weight:600;letter-spacing:1px;
text-shadow:0 1px 3px rgba(0,0,0,0.4);
backdrop-filter:blur(6px);
-webkit-backdrop-filter:blur(6px);
border:1px solid rgba(255,255,255,0.14);
box-shadow:0 6px 18px rgba(0,0,0,0.35);
animation:glow-soft 3s ease-in-out infinite;
}
.mode-hud-obj{background:rgba(255,255,255,0.88);color:var(--t2);padding:7px 18px;border-radius:2px;font-size:11px;backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);border:1px solid var(--line);display:none}
.mode-hud-timer{font-size:36px;font-weight:700;color:#1b1d26;text-shadow:0 0 22px rgba(232,50,41,0.4);display:none;letter-spacing:3px}
.mode-hud-stars{display:none;font-size:20px;gap:4px}
.mode-hud-star{opacity:0.25;transition:opacity .3s}
.mode-hud-star.earned{opacity:1;text-shadow:0 0 8px rgba(245,197,66,0.6)}
.mode-hud-score{display:none;color:var(--blue);font-size:14px;font-weight:600}
/* ---------- 蓝图市场 ---------- */
.bp-page{
display:flex;flex-direction:column;align-items:center;
min-width:0;width:min(92vw,640px);max-width:92vw;
padding:28px 30px;
background:var(--panel-grad);
backdrop-filter:blur(20px) saturate(1.25);
-webkit-backdrop-filter:blur(20px) saturate(1.25);
border:1px solid var(--line);
border-radius:2px;
box-shadow:var(--shadow-deep),inset 0 1px 0 rgba(255,255,255,0.9);
max-height:86vh;overflow-y:auto;
}
.bp-search-row{display:flex;gap:8px;width:100%;margin-bottom:16px}
.bp-search-row input{
flex:1;padding:9px 13px;
background:rgba(255,255,255,0.9);
border:1px solid var(--line);
color:var(--t1);font-size:12px;font-family:inherit;border-radius:2px;outline:none;
transition:border-color .18s,box-shadow .18s;
}
.bp-search-row input:focus{border-color:rgba(232,50,41,0.65);box-shadow:0 0 0 3px rgba(232,50,41,0.12)}
.bp-search-row select{
padding:9px 12px;
background:#fff;
border:1px solid var(--line);
color:var(--t2);font-size:12px;font-family:inherit;border-radius:2px;outline:none;cursor:pointer;
}
.bp-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;width:100%;margin-bottom:14px;overflow-y:auto;max-height:280px;min-width:0}
.bp-card{
background:linear-gradient(180deg,rgba(255,255,255,0.9),rgba(244,246,250,0.92));
border:1px solid var(--line);
border-radius:2px;padding:13px;
transition:transform .2s,border-color .2s,box-shadow .2s;
}
.bp-card:hover{transform:translateY(-2px);border-color:rgba(232,50,41,0.4);box-shadow:0 8px 18px rgba(15,20,40,0.10)}
.bp-card-name{color:var(--t1);font-size:13px;font-weight:600;margin-bottom:4px}
.bp-card-desc{color:var(--t3);font-size:10px;margin-bottom:8px;line-height:1.5}
.bp-card-meta{display:flex;justify-content:space-between;align-items:center}
.bp-card-stars{color:var(--blue);font-size:11px;font-weight:600}
.bp-card-price{color:var(--blue);font-size:11px;font-weight:600}
.bp-card-btn{
padding:4px 12px;
background:rgba(45,127,240,0.10);
border:1px solid rgba(45,127,240,0.30);
color:var(--blue);font-size:10px;font-weight:600;font-family:inherit;
border-radius:2px;cursor:pointer;
transition:background .18s,border-color .18s;
}
.bp-card-btn:hover{background:rgba(45,127,240,0.18);border-color:rgba(45,127,240,0.50)}
.bp-tabs{display:flex;gap:6px;margin-bottom:12px}
.bp-tab{
padding:6px 16px;
background:rgba(10,15,30,0.06);
border:1px solid var(--line);
color:var(--t3);font-size:11px;font-family:inherit;
border-radius:2px;cursor:pointer;
transition:background .18s,color .18s,border-color .18s;
}
.bp-tab:hover{color:var(--t1);border-color:var(--line-hi);background:rgba(10,15,30,0.10)}
.bp-tab.active{background:rgba(232,50,41,0.10);border-color:rgba(232,50,41,0.50);color:var(--red-bright);font-weight:600}
.bp-upload-form{margin-top:10px;display:flex;flex-direction:column;gap:8px;width:100%}
.bp-upload-form input,.bp-upload-form select{
padding:8px 12px;
background:rgba(255,255,255,0.9);
border:1px solid var(--line);
color:var(--t1);font-size:11px;font-family:inherit;border-radius:2px;outline:none;
}
.bp-upload-form input:focus,.bp-upload-form select:focus{border-color:rgba(232,50,41,0.70);box-shadow:0 0 0 3px rgba(232,50,41,0.14)}
/* ---------- 统一精致滚动条 ---------- */
::-webkit-scrollbar{width:8px;height:8px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:rgba(10,15,30,0.18);border-radius:2px;border:2px solid transparent;background-clip:content-box}
::-webkit-scrollbar-thumb:hover{background:rgba(232,50,41,0.35);border:2px solid transparent;background-clip:content-box}
/* ---------- 响应式 ---------- */
@media (max-width:820px){
.modes-grid{grid-template-columns:repeat(2,1fr)}
.shop-grid{grid-template-columns:repeat(2,1fr)}
.inv-grid{grid-template-columns:repeat(3,1fr)}
.hub-profile{left:14px;top:14px;max-width:calc(100vw - 28px)}
.hub-start-btn{right:18px;bottom:18px;padding:14px 26px;font-size:16px}
.inv-panel{flex-direction:column;align-items:center}
.modes-page,.shop-page,.inv-panel-page,.bp-page{width:94vw}
}
</style> </style>
</head> </head>
<body> <body>
<div id="loading"><div>正在生成世界...</div><div class="bar"><div class="fill" id="loadFill"></div></div></div> <canvas id="bgCanvas"></canvas>
<div id="loading"><div id="loadMsg" style="font-size:16px;font-weight:bold;margin-bottom:4px">正在初始化...</div><div class="bar"><div class="fill" id="loadFill"></div></div><div id="loadSub" style="font-size:10px;color:#8b93a3;margin-top:4px"></div></div>
<div id="debugHUD" style="position:fixed;top:8px;right:8px;z-index:250;font-size:10px;color:#0f0;font-family:Consolas,monospace;background:rgba(0,0,0,0.7);padding:4px 8px;border-radius:2px;pointer-events:none;line-height:1.4;display:none"></div>
<div id="crosshair"></div> <div id="crosshair"></div>
<div id="menuParticles"></div>
<div id="hotbar"></div> <div id="hotbar"></div>
<div id="info"> <div id="info">
<div><b>RedCircuit</b> 方块世界 v0.3</div> <div><b>RedCircuit</b> 方块世界 v0.3</div>
<div>位置: <span id="pos">0, 0, 0</span></div> <div>位置: <span id="pos">0, 0, 0</span></div>
<div>方块: <span id="bcount">0</span> | <span id="fps">0</span> FPS</div> <div>方块: <span id="bcount">0</span> | <span id="fps">0</span> FPS</div>
<div>看向: <span id="looking">-</span></div> <div>看向: <span id="looking">-</span></div>
<div style="color:#e83229;font-size:10px">Enter AI | F11 鼠标</div>
</div> </div>
<div id="toast"></div> <div id="toast"></div>
<!-- 游戏内模式 HUD -->
<div id="modeHUD">
<div id="modeHUD-badge" class="mode-hud-badge">自由搭建</div>
<div id="modeHUD-objective" class="mode-hud-obj"></div>
<div id="modeHUD-timer" class="mode-hud-timer"></div>
<div id="modeHUD-stars" class="mode-hud-stars"></div>
<div id="modeHUD-score" class="mode-hud-score"></div>
</div>
<div id="inventory"> <div id="inventory">
<div class="inv-panel"> <div class="inv-panel">
<div class="inv-left"> <div class="inv-left">
@@ -80,14 +693,184 @@ canvas{display:block}
</div> </div>
</div> </div>
<div id="carriedItem"></div> <div id="carriedItem"></div>
<div id="invTooltip" style="position:fixed;display:none;background:rgba(255,255,255,0.95);color:#1b1d26;padding:4px 10px;border-radius:2px;font-size:12px;pointer-events:none;z-index:65;white-space:nowrap;border:1px solid rgba(10,15,30,0.20)"></div>
<div id="overlay"> <div id="overlay">
<h1>RedCircuit</h1> <!-- 欢迎面板 -->
<p>红石回路 · 方块世界原型</p> <div id="menu-welcome" class="menu-panel">
<button class="start-btn" id="startBtn">点击进入世界</button> <h1>RedCircuit</h1>
<div class="keys">WASD 移动 · 空格跳跃 · 鼠标视角<br>左键破坏 · 右键放置 · 数字 1-9 快捷栏<br><b>E 打开背包</b> · ESC 暂停</div> <p>红石回路 · 方块世界</p>
<button class="menu-btn primary" onclick="showPanel('login')">登录</button>
<button class="menu-btn" onclick="showPanel('register')">注册账号</button>
<button class="menu-btn ghost" onclick="startGame()">离线模式</button>
<div class="menu-status" id="menuStatus">v0.3 · 连接认证服务中...</div>
</div>
<!-- 登录面板 -->
<div id="menu-login" class="menu-panel" style="display:none">
<h2>登录</h2>
<div class="form-group"><input type="text" id="loginUser" placeholder="用户名" autocomplete="username"></div>
<div class="form-group"><input type="password" id="loginPass" placeholder="密码" autocomplete="current-password"></div>
<div class="form-error" id="loginError"></div>
<button class="menu-btn primary" id="loginBtn" onclick="doLogin()">登录</button>
<button class="menu-btn ghost" onclick="showPanel('welcome')">返回</button>
</div>
<!-- 注册面板 -->
<div id="menu-register" class="menu-panel" style="display:none">
<h2>注册账号</h2>
<div class="form-group"><input type="text" id="regUser" placeholder="用户名 (3-20字符)" autocomplete="username"></div>
<div class="form-group"><input type="password" id="regPass" placeholder="密码 (至少6位)" autocomplete="new-password"></div>
<div class="form-group"><input type="password" id="regPass2" placeholder="确认密码" autocomplete="new-password"></div>
<div class="form-error" id="regError"></div>
<button class="menu-btn primary" id="regBtn" onclick="doRegister()">注册</button>
<button class="menu-btn ghost" onclick="showPanel('welcome')">返回</button>
</div>
<!-- Hub 主页 (登录后) -->
<div id="menu-hub" class="hub-page" style="display:none">
<!-- 左上角:用户信息卡片 -->
<div class="hub-profile">
<canvas id="hubAvatar" width="56" height="56"></canvas>
<div class="hub-profile-info">
<span class="hub-nickname" id="hubNickname">玩家</span>
<span class="hub-level" id="hubLevel">Lv.1</span>
</div>
<div class="hub-coins" id="hubCoins">
<span class="coin-icon">💎</span><span id="hubDiamonds">0</span>
<span class="coin-icon" style="margin-left:8px">🪙</span><span id="hubRSCoins">0</span>
<span class="coin-icon" style="margin-left:8px">🟡</span><span id="hubGoldCoins">0</span>
</div>
<button class="menu-btn ghost daily-btn" id="hubDailyBtn" onclick="claimDailyReward()" style="margin-left:12px;font-size:11px;color:#58A6FF">🎁 签到</button>
<button class="menu-btn ghost" onclick="showPanel('shop')" style="margin-left:4px;font-size:11px;color:#58A6FF;display:flex;align-items:center;gap:3px"><img src="textures/ui/store.jpeg" style="width:14px;height:14px;image-rendering:pixelated">商店</button>
<button class="menu-btn ghost" onclick="showPanel('inventory')" style="margin-left:4px;font-size:11px;color:#58A6FF">🎒 背包</button>
<button class="menu-btn ghost" onclick="showPanel('blueprint')" style="margin-left:4px;font-size:11px;color:#58A6FF">📦 蓝图</button>
<button class="menu-btn ghost" id="hubLogout" onclick="doLogout()" style="margin-left:4px;font-size:11px">退出</button>
</div>
<!-- 右下角:开始游戏 -->
<button class="hub-start-btn" id="hubStartBtn" onclick="showGameModes()">
<span>开始游戏</span>
<span class="btn-arrow"></span>
</button>
</div>
<!-- 游戏模式选项卡 -->
<div id="menu-modes" class="modes-page" style="display:none">
<h2 class="modes-title">选择游戏模式</h2>
<div class="modes-grid">
<div class="mode-card active" onclick="selectMode(this, 'creative')">
<div class="mode-icon">🧱</div>
<div class="mode-name">自由搭建</div>
<div class="mode-desc">无限资源,随心创造<br>256×256 画布,20 存档槽位</div>
</div>
<div class="mode-card" onclick="selectMode(this, 'puzzle')">
<div class="mode-icon">🧩</div>
<div class="mode-name">解密闯关</div>
<div class="mode-desc">6 章 240 关信号谜题<br>三星评级系统</div>
</div>
<div class="mode-card" onclick="selectMode(this, 'multiplayer')">
<div class="mode-icon">🌐</div>
<div class="mode-name">多人联机</div>
<div class="mode-desc">实时协作搭建<br>WebSocket &lt;200ms</div>
</div>
<div class="mode-card" onclick="selectMode(this, 'blueprint')">
<div class="mode-icon">📦</div>
<div class="mode-name">蓝图市场</div>
<div class="mode-desc">上传/下载电路蓝图<br>评分 & 搜索</div>
</div>
</div>
<button class="menu-btn primary" id="modeEnterBtn" onclick="enterSelectedMode()">进入游戏</button>
<button class="menu-btn ghost" onclick="showPanel('hub')">返回</button>
</div>
<!-- 商店面板 -->
<div id="menu-shop" class="shop-page" style="display:none">
<h2 class="modes-title">🛒 商店</h2>
<div class="shop-grid" id="shopGrid"></div>
<div class="shop-balance" id="shopBalance"></div>
<button class="menu-btn ghost" onclick="showPanel('hub')">返回</button>
</div>
<!-- 背包面板 -->
<div id="menu-inventory" class="inv-panel-page" style="display:none">
<h2 class="modes-title">🎒 背包</h2>
<div class="inv-grid" id="invGrid"></div>
<div class="inv-history" id="invHistory"></div>
<button class="menu-btn ghost" onclick="showPanel('hub')">返回</button>
</div>
<!-- 蓝图市场面板 -->
<div id="menu-blueprint" class="bp-page" style="display:none">
<h2 class="modes-title">📦 蓝图市场</h2>
<div class="bp-search-row"><input type="text" id="bpSearch" placeholder="搜索蓝图...">
<select id="bpCategory"><option value="all">全部分类</option><option value="logic">逻辑电路</option><option value="clock">时钟电路</option><option value="memory">存储器</option><option value="cpu">处理器</option></select>
</div>
<div class="bp-grid" id="bpGrid"></div>
<div class="bp-tabs">
<button class="bp-tab active" onclick="switchBPTab(this,'market')">市场</button>
<button class="bp-tab" onclick="switchBPTab(this,'mine')">我的蓝图</button>
<button class="bp-tab" onclick="switchBPTab(this,'upload')">上传</button>
</div>
<div class="bp-upload-form" id="bpUploadForm" style="display:none">
<input type="text" id="bpUploadName" placeholder="蓝图名称"><br>
<input type="text" id="bpUploadDesc" placeholder="简介"><br>
<select id="bpUploadCat"><option value="logic">逻辑电路</option><option value="clock">时钟电路</option><option value="memory">存储器</option><option value="cpu">处理器</option></select><br>
<button class="menu-btn primary" onclick="uploadBlueprint()" style="font-size:12px;padding:8px 20px">发布蓝图 (50💎)</button>
</div>
<button class="menu-btn ghost" onclick="showPanel('hub')">返回</button>
</div>
<!-- 暂停面板 -->
<div id="menu-pause" class="menu-panel" style="display:none">
<h2>已暂停</h2>
<button class="menu-btn primary" id="resumeBtn">点击继续</button>
<button class="menu-btn ghost" id="menuBackBtn">返回主菜单</button>
<div class="keys">WASD 移动 · 空格跳跃 · 鼠标视角<br>左键破坏 · 右键放置 · 数字 1-9 快捷栏<br><b>E 打开背包</b> · ESC 暂停</div>
</div>
</div>
<!-- AI 悬浮按钮 -->
<button id="aiBtn" title="AI 助手 (F11释放鼠标后可点击)" onclick="toggleAIChat()"></button>
<!-- AI 对话框 -->
<div id="aiChat">
<div class="ai-panel">
<div class="ai-header">
<span>RedCircuit AI 助手</span>
<button class="ai-close" onclick="closeAIChat()"></button>
</div>
<div class="ai-messages" id="aiMessages">
<div class="ai-msg ai"><span class="label">AI 助手</span>你好!我是 RedCircuit AI 助手。<br>我可以帮你解答电路问题、推荐搭建方案。<br>试试问我:"如何搭建一个 AND 门?"</div>
</div>
<div class="ai-input-row">
<input type="text" id="aiInput" placeholder="输入问题... (Enter 发送, Esc 关闭)" autocomplete="off">
<button onclick="sendAIMessage()">发送</button>
</div>
<div class="ai-hint">F11 释放鼠标 · 按钮在右下角</div>
</div>
</div> </div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script> <script>
// ==================== WebGL + Three.js 诊断 ====================
(function(){
var diag=[];
function report(msg){diag.push(msg);console.log('[DIAG] '+msg);}
report('Three.js: '+(typeof THREE!=='undefined'?'loaded v'+THREE.REVISION:'MISSING'));
report('WebGL: '+(typeof WebGLRenderingContext!=='undefined'?'API available':'MISSING'));
// 测试 WebGL 上下文
var c=document.createElement('canvas');
var gl=null;
var ctxNames=['webgl2','webgl','experimental-webgl'];
for(var i=0;i<ctxNames.length;i++){
try{gl=c.getContext(ctxNames[i]);if(gl){report('WebGL context: '+ctxNames[i]+' OK');break;}}
catch(e){report('WebGL '+ctxNames[i]+': '+e.message);}
}
if(!gl){
report('FATAL: No WebGL context available');
window._webglFailed=true;
}else{
report('Renderer: '+gl.getParameter(gl.RENDERER));
report('Vendor: '+gl.getParameter(gl.VENDOR));
var ext=gl.getExtension('WEBGL_debug_renderer_info');
if(ext)report('GPU: '+gl.getParameter(ext.UNMASKED_RENDERER_WEBGL));
}
// 诊断报告注入到页面
window._diagReport=diag;
})();
</script>
<script>
// ==================== Perlin 噪声 ==================== // ==================== Perlin 噪声 ====================
class PerlinNoise { class PerlinNoise {
constructor(seed) { constructor(seed) {
@@ -368,40 +1151,1084 @@ const HOTBAR_SIZE = 10; // 快捷栏显示前10个 (1-9 + 0)
// ==================== 方向性元件引脚定义 ==================== // ==================== 方向性元件引脚定义 ====================
// 默认朝向: 输出=+Z (前方), 输入=-Z (后方) // 默认朝向: 输出=+Z (前方), 输入=-Z (后方)
// pin: [x, z, length, color] x/z=中心偏移, length=杆长, color=颜色 // pin: [x, z, length, color] 引脚从元件体(±0.2)延伸到相邻方块表面(±0.5)
// 引脚从元件体边缘延伸到相邻方块内部 (±0.5 为格子边界)
const PIN_LAYOUTS = { const PIN_LAYOUTS = {
diode: { pins: [[0,-0.5,0.6,0x6080a0],[0,0.5,0.6,0xe83229]] }, diode: { pins: [[0,-0.35,0.3,0x6080a0],[0,0.35,0.3,0xe83229]] },
npn: { pins: [[0,-0.5,0.6,0x6080a0],[0.5,0,0.6,0xe83229],[-0.5,0,0.6,0x3FB950]] }, npn: { pins: [[0,-0.35,0.3,0x6080a0],[0.35,0,0.3,0xe83229],[-0.35,0,0.3,0x3FB950]] },
andGate: { pins: [[-0.2,-0.5,0.6,0x6080a0],[0.2,-0.5,0.6,0x6080a0],[0,0.5,0.6,0xe83229]] }, andGate: { pins: [[-0.35,0,0.3,0x6080a0],[0.35,0,0.3,0x6080a0],[0,0.35,0.3,0xe83229]] },
orGate: { pins: [[-0.2,-0.5,0.6,0x6080a0],[0.2,-0.5,0.6,0x6080a0],[0,0.5,0.6,0xe83229]] }, orGate: { pins: [[-0.35,0,0.3,0x6080a0],[0.35,0,0.3,0x6080a0],[0,0.35,0.3,0xe83229]] },
notGate: { pins: [[0,-0.5,0.6,0x6080a0],[0,0.5,0.6,0xe83229]] }, notGate: { pins: [[0,-0.35,0.3,0x6080a0],[0,0.35,0.3,0xe83229]] },
repeater: { pins: [[0,-0.5,0.6,0x6080a0],[0,0.5,0.6,0xe83229]] }, repeater: { pins: [[0,-0.35,0.3,0x6080a0],[0,0.35,0.3,0xe83229]] },
}; };
function isDirectional(typeId) { function isDirectional(typeId) {
return !!PIN_LAYOUTS[typeId]; return !!PIN_LAYOUTS[typeId];
} }
// ==================== 全局状态 ==================== // ==================== 全局状态 ====================
const WORLD_R = 20; // 世界半径 40x40 const WORLD_R = 30; // 世界半径 (用于初始生成)
const SEA_LEVEL = 1; const SEA_LEVEL = 1;
const GRAVITY = 28, JUMP = 9, SPEED = 5.5, P_H = 1.7, P_W = 0.6, REACH = 6; const GRAVITY = 28, JUMP = 9, SPEED = 5.5, P_H = 1.7, P_W = 0.6, REACH = 6;
const MAX_FALL = 30; // 最大下落速度,防止高速穿透方块嵌入卡死
const CHUNK_SIZE = 16; // 区块大小 16x16x16
const MIN_CHUNK_Y = Math.floor(-64 / CHUNK_SIZE); // -4
const MAX_CHUNK_Y = Math.floor(500 / CHUNK_SIZE); // 31
const RENDER_DIST = 4; // 玩家周围加载区块半径
const UNLOAD_DIST = 6; // 超出此距离卸载区块
let scene, camera, renderer, raycaster, highlight; let scene, camera, renderer, raycaster, highlight;
let world = new Map(); let world = new Map();
let chunkMeshes = new Map(); // "cx,cy,cz" → [THREE.Mesh]
let terrainColumns = {}; // "cx,cz" → true (地形已生成)
let generatedChunks = {}; // "cx,cy,cz" → true (mesh已构建)
let chunkBlockCount = {}; // "cx,cy,cz" → 区块内方块数 (用于空区块快速跳过)
let terrainNoise = null; // 延迟初始化
let selectedSlot = 0; let selectedSlot = 0;
let pitch = 0, yaw = 0; let pitch = 0, yaw = 0;
let keys = {}; let keys = {};
let gameActive = false; // 是否处于游戏中(WebView2 中 pointer lock 可能失败,用此标志替代)
let dragLook = false; // 无 pointer lock 时的拖动视角模式
let player = { pos: new THREE.Vector3(0, 10, 0), vel: new THREE.Vector3(), onGround: false }; let player = { pos: new THREE.Vector3(0, 10, 0), vel: new THREE.Vector3(), onGround: false };
let lastT = performance.now(), frames = 0, fpsT = 0, fps = 0; let lastT = performance.now(), frames = 0, fpsT = 0, fps = 0;
const bk = (x,y,z) => `${x},${y},${z}`; const bk = (x,y,z) => `${x},${y},${z}`;
const bp = k => k.split(',').map(Number); const bp = k => k.split(',').map(Number);
// 破坏动画状态
let breakingBlock = null; // {x,y,z,progress,mesh}
let breakStartTime = 0;
const BREAK_TIME = 600; // ms 破坏时间
// 粒子系统
function spawnParticles(x, y, z, color) {
const worldPos = new THREE.Vector3(x+0.5, y+0.5, z+0.5);
const screenPos = worldPos.clone().project(camera);
const sx = (screenPos.x * 0.5 + 0.5) * window.innerWidth;
const sy = (-screenPos.y * 0.5 + 0.5) * window.innerHeight;
for (let i = 0; i < 8; i++) {
const p = document.createElement('div');
p.className = 'particle';
p.style.cssText = `
left:${sx}px;top:${sy}px;
width:${2+Math.random()*3}px;height:${2+Math.random()*3}px;
background:${color};
opacity:1;
transition:all ${0.4+Math.random()*0.3}s ease-out;
`;
document.body.appendChild(p);
requestAnimationFrame(() => {
p.style.transform = `translate(${(Math.random()-0.5)*40}px,${(Math.random()-0.5)*40-10}px)`;
p.style.opacity = '0';
});
setTimeout(() => p.remove(), 800);
}
}
// 地图存档
const SAVE_KEY = 'redcircuit_world_save_v1';
function saveWorld() {
// 只保存玩家周围存档半径内的方块,避免无限地形下序列化整个 world
const px = Math.round(player.pos.x), pz = Math.round(player.pos.z);
const SAVE_R = 96; // 存档半径
const data = [];
for (const [k, b] of world) {
const [x, y, z] = bp(k);
if (Math.abs(x - px) > SAVE_R || Math.abs(z - pz) > SAVE_R) continue;
data.push({ x, y, z, type: b.type, lit: b.lit || false, direction: (b.mesh && b.mesh.userData) ? (b.mesh.userData.direction || 0) : 0 });
}
const saveData = {
version: 1,
playerPos: { x: player.pos.x, y: player.pos.y, z: player.pos.z },
blocks: data,
timestamp: Date.now(),
};
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
} catch(e) {
// localStorage 满时静默失败
}
}
function loadWorld() {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return false;
const data = JSON.parse(raw);
if (data.version !== 1) return false;
// 空存档(0 方块)视为无效:走地形生成,避免进入虚空
if (!Array.isArray(data.blocks) || data.blocks.length === 0) return false;
// 清除当前世界
for (const [k, b] of world) {
if (b.mesh) { scene.remove(b.mesh); b.mesh.traverse(c => { if (c.geometry) c.geometry.dispose(); if (c.material) c.material.dispose(); }); }
}
world.clear();
// 清除旧区块网格
for (const meshes of chunkMeshes.values()) {
meshes.forEach(m => { scene.remove(m); if(m.geometry)m.geometry.dispose(); });
}
chunkMeshes.clear();
// 恢复方块
for (const bd of data.blocks) {
setBlockData(bd.x, bd.y, bd.z, bd.type);
const wb = getBlock(bd.x, bd.y, bd.z);
if (wb && bd.lit && wb.type === 'lever') {
wb.lit = true;
// 拉杆需要独立 mesh
const def = getDef(wb.type);
const mesh = makeBlockMesh(def);
mesh.position.set(bd.x+0.5, bd.y+0.5+(mesh.userData.yOffset||0), bd.z+0.5);
mesh.userData = { x:bd.x, y:bd.y, z:bd.z, type:bd.type, direction:bd.direction||0 };
wb.mesh = mesh;
const h = mesh.userData.handle;
if (h) h.rotation.x = -Math.PI/4;
scene.add(mesh);
}
}
buildAllChunks();
// 恢复玩家位置
if (data.playerPos) {
player.pos.set(data.playerPos.x, data.playerPos.y, data.playerPos.z);
}
simulate();
return true;
} catch(e) {
return false;
}
}
// ==================== 认证状态 ====================
const AUTH_URL = 'https://deaicup.com/redcircuit/api/auth';
const API_BASE = 'https://deaicup.com/redcircuit';
let authState = { token: null, user: null, loggedIn: false, offline: false };
// 面板切换
let currentPanel = 'welcome';
function showPanel(name) {
['welcome','login','register','hub','modes','shop','inventory','blueprint','pause'].forEach(id => {
document.getElementById('menu-'+id).style.display = 'none';
});
document.getElementById('menu-'+name).style.display = (name === 'hub' || name === 'modes' || name === 'shop' || name === 'inventory' || name === 'blueprint') ? 'flex' : 'flex';
currentPanel = name;
document.getElementById('loginError').textContent = '';
document.getElementById('regError').textContent = '';
if (name === 'login') document.getElementById('loginUser').focus();
if (name === 'register') document.getElementById('regUser').focus();
if (name === 'hub') renderHubPage();
if (name === 'shop') renderShop();
if (name === 'inventory') renderInventory();
if (name === 'blueprint') renderBlueprintMarket();
spawnMenuParticles();
}
// 菜单背景粒子
let menuParticleTimer = null;
function spawnMenuParticles() {
const container = document.getElementById('menuParticles');
container.innerHTML = '';
for (let i = 0; i < 30; i++) {
const p = document.createElement('div');
p.className = 'menu-particle';
const size = 1 + Math.random() * 2;
p.style.cssText = `
width:${size}px;height:${size}px;
left:${Math.random()*100}%;top:${Math.random()*100}%;
--dx:${(Math.random()-0.5)*120}px;--dy:${(Math.random()-0.5)*120-40}px;
animation-delay:${Math.random()*8}s;
animation-duration:${5+Math.random()*7}s;
`;
container.appendChild(p);
}
}
// API 调用
async function apiCall(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
if (authState.token) opts.headers['Authorization'] = 'Bearer ' + authState.token;
const base = (path.startsWith('/login') || path.startsWith('/register') || path.startsWith('/me'))
? AUTH_URL : API_BASE + '/api';
const res = await fetch(base + path, opts);
const data = await res.json();
if (!res.ok || data.code !== 0) throw new Error(data.message || '请求失败');
return data.data;
}
// 登录
async function doLogin() {
const user = document.getElementById('loginUser').value.trim();
const pass = document.getElementById('loginPass').value;
const errEl = document.getElementById('loginError');
const btn = document.getElementById('loginBtn');
errEl.textContent = '';
if (!user || !pass) { errEl.textContent = '请填写用户名和密码'; return; }
btn.textContent = '登录中...'; btn.disabled = true;
try {
const data = await apiCall('POST', '/login', { username: user, password: pass });
authState.token = data.token;
authState.user = data.user;
authState.loggedIn = true;
authState.offline = false;
showMainMenu();
} catch(e) {
errEl.textContent = e.message;
} finally {
btn.textContent = '登录'; btn.disabled = false;
}
}
// 注册
async function doRegister() {
const user = document.getElementById('regUser').value.trim();
const pass = document.getElementById('regPass').value;
const pass2 = document.getElementById('regPass2').value;
const errEl = document.getElementById('regError');
const btn = document.getElementById('regBtn');
errEl.textContent = '';
if (!user || !pass) { errEl.textContent = '请填写用户名和密码'; return; }
if (user.length < 3 || user.length > 20) { errEl.textContent = '用户名需3-20个字符'; return; }
if (pass.length < 6) { errEl.textContent = '密码至少6位'; return; }
if (pass !== pass2) { errEl.textContent = '两次密码不一致'; return; }
btn.textContent = '注册中...'; btn.disabled = true;
try {
const data = await apiCall('POST', '/register', { username: user, password: pass });
authState.token = data.token;
authState.user = data.user;
authState.loggedIn = true;
authState.offline = false;
showMainMenu();
} catch(e) {
errEl.textContent = e.message;
} finally {
btn.textContent = '注册'; btn.disabled = false;
}
}
// 退出登录
function doLogout() {
authState = { token: null, user: null, loggedIn: false, offline: false };
showPanel('welcome');
}
// 显示主菜单 (Hub 页面)
function showMainMenu() {
showPanel('hub');
}
// 渲染 Hub 页面 (头像、皮肤、货币)
function renderHubPage() {
const u = authState.user || {};
document.getElementById('hubNickname').textContent = u.username || '玩家';
document.getElementById('hubLevel').textContent = 'Lv.' + (u.level || 1);
document.getElementById('hubDiamonds').textContent = u.diamonds || 0;
document.getElementById('hubRSCoins').textContent = u.redstone_coins || 0;
document.getElementById('hubGoldCoins').textContent = u.gold_coins || 0;
document.getElementById('hubLogout').style.display = authState.loggedIn ? 'inline-block' : 'none';
document.getElementById('hubDailyBtn').style.display = authState.loggedIn ? 'inline-block' : 'none';
drawHubAvatar(u.username || '玩家');
// 检查今日是否已签到
if (authState.loggedIn) {
const today = new Date().toISOString().slice(0, 10);
const btn = document.getElementById('hubDailyBtn');
if (u.last_login_date === today) {
btn.textContent = '✅ 已签到';
btn.style.color = '#888';
btn.style.pointerEvents = 'none';
} else {
btn.textContent = '🎁 签到';
btn.style.color = '#58A6FF';
btn.style.pointerEvents = 'auto';
}
}
}
// 每日签到
async function claimDailyReward() {
const btn = document.getElementById('hubDailyBtn');
btn.textContent = '领取中...'; btn.disabled = true;
try {
const data = await apiCall('POST', '/daily-reward');
if (data.claimed) {
showToast('今日已签到!');
btn.textContent = '✅ 已签到';
btn.style.color = '#888';
btn.style.pointerEvents = 'none';
} else {
showToast(`签到成功! +${data.diamonds}💎 +${data.redstone}石 +${data.gold}`);
authState.user.diamonds = data.total_diamonds;
authState.user.redstone_coins = data.total_redstone;
authState.user.gold_coins = data.total_gold;
authState.user.last_login_date = new Date().toISOString().slice(0, 10);
renderHubPage();
btn.textContent = '✅ 已签到';
btn.style.color = '#888';
btn.style.pointerEvents = 'none';
}
} catch(e) {
showToast('签到失败: ' + e.message);
btn.textContent = '🎁 签到';
}
btn.disabled = false;
}
// ==================== 商店系统 ====================
const SHOP_ITEMS = [
{ id:'diamond_10', name:'钻石礼包', desc:'10 钻石', icon:'💎', price:5000, currency:'redstone_coins', priceLabel:'5000 红石币' },
{ id:'diamond_50', name:'钻石大礼包', desc:'50 钻石', icon:'💠', price:1, currency:'gold_coins', priceLabel:'1 金币' },
{ id:'gold_5', name:'金币兑换', desc:'5 金币', icon:'🟡', price:50, currency:'diamonds', priceLabel:'50 钻石' },
{ id:'rs_boost', name:'红石币加成', desc:'+10000 红石币', icon:'🪙', price:10, currency:'diamonds', priceLabel:'10 钻石' },
{ id:'skin_random', name:'随机皮肤', desc:'随机解锁一款皮肤', icon:'🎨', price:100, currency:'diamonds', priceLabel:'100 钻石' },
{ id:'level_boost', name:'等级加速', desc:'+500 经验值', icon:'⚡', price:30, currency:'diamonds', priceLabel:'30 钻石' },
];
function renderShop() {
const u = authState.user || {};
document.getElementById('shopBalance').innerHTML =
`余额: 💎${u.diamonds||0} | 🪙${u.redstone_coins||0} | 🟡${u.gold_coins||0}`;
const grid = document.getElementById('shopGrid');
grid.innerHTML = '';
SHOP_ITEMS.forEach(item => {
const div = document.createElement('div');
div.className = 'shop-item';
const cc = item.currency === 'diamonds' ? 'diamonds' : item.currency === 'gold_coins' ? 'gold' : 'redstone';
div.innerHTML = `
<div class="shop-item-icon">${item.icon}</div>
<div class="shop-item-name">${item.name}</div>
<div class="shop-item-desc">${item.desc}</div>
<div class="shop-item-price ${cc}">${item.priceLabel}</div>
<button class="shop-buy-btn" onclick="buyShopItem('${item.id}')">购买</button>
`;
grid.appendChild(div);
});
}
async function buyShopItem(itemId) {
const item = SHOP_ITEMS.find(i => i.id === itemId);
if (!item) return;
if (!authState.loggedIn) { showToast('请先登录'); return; }
try {
const data = await apiCall('POST', '/buy', { item_id: item.id, item_name: item.name, currency: item.currency, amount: item.price, quantity: 1 });
authState.user = data.user;
showToast(`购买成功! ${item.name}已到账`);
renderShop();
if (currentPanel === 'hub') renderHubPage();
} catch(e) {
showToast('购买失败: ' + e.message);
}
}
// 背包渲染
function renderInventory() {
const u = authState.user || {};
const inv = u.inventory || {};
const grid = document.getElementById('invGrid');
const ITEM_DEFS = {
speed_boost: { icon:'⚡', name:'速度加成', desc:'移动速度+50%(1h)' },
exp_boost: { icon:'⭐', name:'经验加成', desc:'+500经验值' },
diamond_pack: { icon:'💎', name:'钻石包', desc:'+10钻石' },
gold_pack: { icon:'🟡', name:'金币包', desc:'+5金币' },
rs_pack: { icon:'🪙', name:'红石币包', desc:'+10000红石币' },
skin_random: { icon:'🎨', name:'随机皮肤', desc:'解锁新外观' },
level_boost: { icon:'📈', name:'等级加速', desc:'+500经验' },
};
const entries = Object.entries(inv);
if (!entries.length) { grid.innerHTML = '<div style="color:#666;grid-column:1/-1;text-align:center;padding:20px">背包为空,去商店购买道具吧!</div>'; }
else {
grid.innerHTML = entries.map(([id,count]) => {
const def = ITEM_DEFS[id] || { icon:'📦', name:id, desc:'' };
return `<div class="inv-item">
<div class="inv-item-icon">${def.icon}</div>
<div class="inv-item-name">${def.name}</div>
<div class="inv-item-count">x${count}</div>
<button class="inv-use-btn" onclick="useInventoryItem('${id}')">使用</button>
</div>`;
}).join('');
}
// 购买历史
const hist = u.purchase_history || [];
document.getElementById('invHistory').innerHTML = '<div class="inv-history-title">最近购买</div>' +
(hist.length ? hist.slice(0,10).map(h =>
`<div class="inv-history-item"><span>${h.item_name}</span><span style="color:#888">${new Date(h.timestamp*1000).toLocaleDateString()}</span></div>`
).join('') : '<div style="color:#666">暂无购买记录</div>');
}
async function useInventoryItem(itemId) {
if (!authState.loggedIn) { showToast('请先登录'); return; }
try {
const data = await apiCall('POST', '/use-item', { item_id: itemId });
authState.user = data.user;
showToast(data.message);
renderInventory();
if (currentPanel === 'hub') renderHubPage();
} catch(e) {
showToast('使用失败: ' + e.message);
}
}
// ==================== 蓝图市场 ====================
const BP_MOCK = [
{ id:'bp1', name:'4位加法器', desc:'经典红石加法器电路', author:'Redstoner99', stars:4.5, downloads:230, price:0, cat:'logic' },
{ id:'bp2', name:'可调脉冲时钟', desc:'1Hz~20Hz 可调红石时钟', author:'ClockMaster', stars:4.8, downloads:567, price:100, cat:'clock', currency:'redstone_coins' },
{ id:'bp3', name:'D触发器', desc:'边沿触发D型锁存器', author:'LogicPro', stars:4.2, downloads:189, price:0, cat:'memory' },
{ id:'bp4', name:'8位ALU', desc:'简易算术逻辑单元', author:'CPUBuilder', stars:4.9, downloads:1023, price:500, cat:'cpu', currency:'redstone_coins' },
{ id:'bp5', name:'上升沿检测器', desc:'检测信号上升沿', author:'Redstoner99', stars:4.1, downloads:89, price:0, cat:'logic' },
{ id:'bp6', name:'3位计数器', desc:'二进制递增计数器', author:'CounterKing', stars:4.3, downloads:312, price:200, cat:'memory', currency:'redstone_coins' },
];
let bpTab = 'market';
function switchBPTab(el, tab) { bpTab = tab; document.querySelectorAll('.bp-tab').forEach(t => t.classList.remove('active')); el.classList.add('active'); document.getElementById('bpUploadForm').style.display = tab === 'upload' ? 'flex' : 'none'; renderBlueprintMarket(); }
function renderBlueprintMarket() {
const search = (document.getElementById('bpSearch')?.value || '').toLowerCase();
const cat = document.getElementById('bpCategory')?.value || 'all';
let items = BP_MOCK;
if (bpTab === 'mine') items = BP_MOCK.filter(b => b.author === 'Redstoner99');
if (cat !== 'all') items = items.filter(b => b.cat === cat);
if (search) items = items.filter(b => b.name.toLowerCase().includes(search) || b.desc.toLowerCase().includes(search));
document.getElementById('bpGrid').innerHTML = items.map(b => `
<div class="bp-card">
<div class="bp-card-name">${b.name}</div>
<div class="bp-card-desc">${b.desc}</div>
<div class="bp-card-meta">
<span class="bp-card-stars">${'★'.repeat(Math.round(b.stars))} ${b.stars}</span>
<span class="bp-card-price">${b.price ? b.price + ' 🪙' : '免费'}</span>
<button class="bp-card-btn" onclick="downloadBP('${b.id}')">${b.price ? '购买' : '下载'}</button>
</div>
</div>`).join('');
}
function downloadBP(id) {
const bp = BP_MOCK.find(b => b.id === id);
if (!bp) return;
if (bp.price && !authState.loggedIn) { showToast('请先登录'); return; }
showToast(`${bp.name} 下载成功! 已加入我的蓝图`);
}
function uploadBlueprint() {
if (!authState.loggedIn) { showToast('请先登录'); return; }
const name = document.getElementById('bpUploadName').value.trim();
if (!name) { showToast('请输入蓝图名称'); return; }
showToast('蓝图发布成功! (已扣除50💎)');
document.getElementById('bpUploadName').value = '';
document.getElementById('bpUploadDesc').value = '';
}
// 搜索输入监听
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
const el = document.getElementById('bpSearch');
if (el) el.addEventListener('input', renderBlueprintMarket);
}, 500);
});
// 绘制头像
function drawHubAvatar(name) {
const c = document.getElementById('hubAvatar');
const ctx = c.getContext('2d');
const hash = name.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
const hue = hash % 360;
c.style.background = 'hsl(' + hue + ', 60%, 35%)';
ctx.fillStyle = '#fff';
ctx.font = 'bold 22px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(name.charAt(0).toUpperCase(), 28, 28);
}
// 游戏模式选择
let selectedGameMode = 'creative';
let currentGameMode = 'creative'; // 当前激活的模式
let puzzleObjective = null; // 解密目标 {type, count, completed}
let timeAttackStart = 0;
let timeAttackDuration = 180; // 3分钟
function showGameModes() { showPanel('modes'); }
function selectMode(el, mode) {
document.querySelectorAll('.mode-card').forEach(c => c.classList.remove('active'));
el.classList.add('active');
selectedGameMode = mode;
}
function enterSelectedMode() {
currentGameMode = selectedGameMode;
if (currentGameMode === 'blueprint') {
document.getElementById('overlay').style.display = 'flex';
showPanel('blueprint');
return;
}
// 直接进入 HTML5 世界
document.getElementById('overlay').style.display = 'none';
if (currentGameMode === 'puzzle') { setupPuzzleMode(); }
if (currentGameMode === 'multiplayer') { setupTimeAttackMode(); }
startGame();
}
// 更新游戏内模式 HUD
function updateModeHUD() {
const badge = document.getElementById('modeHUD-badge');
const obj = document.getElementById('modeHUD-objective');
const timer = document.getElementById('modeHUD-timer');
const stars = document.getElementById('modeHUD-stars');
const score = document.getElementById('modeHUD-score');
badge.style.display = 'block'; obj.style.display = 'none';
timer.style.display = 'none'; stars.style.display = 'none'; score.style.display = 'none';
switch (currentGameMode) {
case 'creative':
badge.textContent = '🧱 自由搭建';
badge.style.background = 'rgba(88,166,255,0.9)';
obj.style.display = 'block';
obj.textContent = '无限资源 · 随心创造';
break;
case 'puzzle':
badge.textContent = '🧩 解密闯关';
badge.style.background = 'rgba(232,50,41,0.9)';
obj.style.display = 'block';
obj.innerHTML = puzzleObjective ? '目标: 点亮红石灯 💡' + (puzzleObjective.completed ? ' ✅ 完成!' : '') : '生成关卡中...';
stars.style.display = 'flex';
stars.innerHTML = ['★','★','★'].map((s,i) =>
`<span class="mode-hud-star${puzzleObjective?.completed ? ' earned' : ''}">${s}</span>`
).join('');
if (puzzleObjective?.completed) {
score.style.display = 'block';
score.textContent = '🌟 +50 红石币';
}
break;
case 'multiplayer':
badge.textContent = '⏱ 限时建造';
badge.style.background = 'rgba(88,166,255,0.85)';
obj.style.display = 'block';
obj.textContent = '3分钟内自由搭建!';
timer.style.display = 'block';
score.style.display = 'block';
break;
}
}
// 解密模式: 生成目标
function setupPuzzleMode() {
// 重置世界并生成解密关卡
for (const meshes of chunkMeshes.values()) {
meshes.forEach(m => { scene.remove(m); if(m.geometry)m.geometry.dispose(); });
}
chunkMeshes.clear();
for (const [k, b] of world) {
if (b.mesh) { scene.remove(b.mesh); b.mesh.traverse(c => { if (c.geometry) c.geometry.dispose(); if (c.material) c.material.dispose(); }); }
}
world.clear();
// 生成小型解密地图: 5x5平台 + 预置电路元件
for (let x = -3; x <= 3; x++) for (let z = -3; z <= 3; z++) { setBlockData(x, 0, z, 'stone'); setBlockData(x, 1, z, 'stone'); }
// 预置: 红石块 + 红石粉 + 红石灯
placeBlock(-2, 2, 0, 'rblock');
placeBlock(0, 2, 0, 'dust');
placeBlock(1, 2, 0, 'dust');
placeBlock(2, 2, 0, 'lamp');
// 目标: 点亮红石灯
puzzleObjective = { type: 'power_lamp', pos: [2, 2, 0], completed: false };
buildAllChunks();
}
// 限时模式
function setupTimeAttackMode() {
timeAttackStart = Date.now();
puzzleObjective = { type: 'time_attack', score: 0, completed: false };
}
// 检查模式目标
function checkModeObjective() {
if (currentGameMode === 'puzzle' && puzzleObjective) {
const b = getBlock(puzzleObjective.pos[0], puzzleObjective.pos[1], puzzleObjective.pos[2]);
if (b && b.signal > 0 && !puzzleObjective.completed) {
puzzleObjective.completed = true;
showToast('解密成功! 红石灯已点亮 🎉');
}
}
}
// 开始游戏
function startGame() {
if (!window._worldLoaded) {
startWorld();
return;
}
ensurePlayerFree();
gameActive = true;
document.getElementById('overlay').style.display = 'none';
document.getElementById('menuParticles').innerHTML = '';
aiChatOpen = false; releaseReason = null;
document.getElementById('aiChat').classList.remove('show');
document.getElementById('aiBtn').classList.add('show');
document.getElementById('aiInput').value = '';
document.getElementById('modeHUD').style.display = 'flex';
updateModeHUD();
renderer.domElement.requestPointerLock();
}
// 恢复游戏 (从暂停)
function resumeGame() {
ensurePlayerFree();
gameActive = true;
document.getElementById('overlay').style.display = 'none';
document.getElementById('menuParticles').innerHTML = '';
aiChatOpen = false; releaseReason = null;
document.getElementById('aiChat').classList.remove('show');
document.getElementById('aiBtn').classList.add('show');
document.getElementById('aiInput').value = '';
document.getElementById('modeHUD').style.display = 'flex';
renderer.domElement.requestPointerLock();
}
// 检查认证服务连接
async function checkAuthService() {
try {
const res = await fetch(API_BASE + '/health');
if (res.ok) {
document.getElementById('menuStatus').textContent = 'v0.4 · 服务器已连接';
document.getElementById('menuStatus').style.color = '#58A6FF';
}
} catch(e) {
document.getElementById('menuStatus').textContent = 'v0.4 · 离线模式';
document.getElementById('menuStatus').style.color = '#888';
}
}
checkAuthService();
// ==================== AI 聊天状态 ====================
let aiChatOpen = false;
let releaseReason = null; // null | 'ai' | 'f11' | 'inventory'
function openAIChat() {
aiChatOpen = true;
gameActive = false;
document.getElementById('aiChat').classList.add('show');
// 清除键盘状态,防止卡键 (WASD 等)
keys = {};
if (document.pointerLockElement) {
releaseReason = 'ai';
document.exitPointerLock();
}
setTimeout(() => document.getElementById('aiInput').focus(), 100);
}
function closeAIChat() {
aiChatOpen = false;
document.getElementById('aiChat').classList.remove('show');
document.getElementById('aiInput').value = '';
if (releaseReason === 'ai') releaseReason = null;
if (!document.getElementById('inventory').classList.contains('show') && releaseReason === null) {
gameActive = true;
renderer.domElement.requestPointerLock();
}
}
function toggleAIChat() {
if (aiChatOpen) closeAIChat();
else openAIChat();
}
function sendAIMessage() {
const input = document.getElementById('aiInput');
const text = input.value.trim();
if (!text) return;
addChatMsg('user', '你', text);
input.value = '';
// 检查是否为地图命令
if (text.startsWith('/')) {
const result = execMapCommand(text);
setTimeout(() => addChatMsg('ai', 'AI 助手', result), 300);
return;
}
const reply = getAIReply(text);
setTimeout(() => addChatMsg('ai', 'AI 助手', reply), 400);
}
function addChatMsg(type, label, text) {
const container = document.getElementById('aiMessages');
const div = document.createElement('div');
div.className = 'ai-msg ' + type;
div.innerHTML = '<span class="label">' + label + '</span>' + text.replace(/\n/g, '<br>');
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
function getAIReply(q) {
const lower = q.toLowerCase();
if (lower.includes('and') && (lower.includes('门') || lower.includes('gate'))) {
return 'AND 门需要两个输入都为 ON 时输出才为 ON。<br><br>搭建方法:<br>1. 放一个红石火把 → 连接两个输入线<br>2. 两个输入都通电时,火把熄灭 → 后面接 NOT 门反转<br>3. 简单方案:直接使用 AND 门方块!<br><br>需要我帮你在世界中放置吗?';
}
if (lower.includes('or') && (lower.includes('门') || lower.includes('gate'))) {
return 'OR 门:任一输入为 ON 时输出即为 ON。<br><br>用红石粉直接并联即可:<br>1. 两条输入线汇入同一条红石粉线路<br>2. 或者直接使用 OR 门方块<br><br>红石粉天然支持 OR 逻辑!';
}
if (lower.includes('not') && (lower.includes('门') || lower.includes('gate'))) {
return 'NOT 门(反相器):输入 ON → 输出 OFF,输入 OFF → 输出 ON。<br><br>用红石火把实现:<br>1. 在方块侧面放红石火把<br>2. 给该方块通电 → 火把熄灭<br>3. 火把输出端就是反相后的信号<br><br>也可以直接用 NOT 门方块!';
}
if (lower.includes('三极管') || lower.includes('npn')) {
return 'NPN 三极管:基极(B)通电时,集电极(C)的电流可以流到发射极(E)。<br><br>引脚方向:<br>- 后方 = 基极(控制端)<br>- 左方 = 集电极(输入端)<br>- 右方 = 发射极(输出端)<br><br>相当于一个电子开关!';
}
if (lower.includes('时钟') || lower.includes('脉冲') || lower.includes('clock')) {
return '红石时钟(脉冲发生器):<br><br>方案1:火把时钟<br>- 放两个红石火把,面对面通过红石粉连接<br>- 调整中继器延迟控制频率<br><br>方案2:中继器环形时钟<br>- 2-4个中继器首尾相连成环<br>- 右键调整各中继器延迟';
}
if (lower.includes('你好') || lower.includes('hi') || lower.includes('hello')) {
return '你好!我是 RedCircuit AI 助手。<br>我可以帮你:<br>- 解答电路原理问题<br>- 推荐搭建方案<br>- 解释各元件功能<br><br>随便问吧!';
}
return '这是一个好问题!我目前能回答关于:<br>- 逻辑门(AND/OR/NOT<br>- 红石基础电路<br>- 各元件使用方法<br>- NPN 三极管<br>- 时钟/脉冲电路<br><br>请试试问我这些问题!';
}
// ==================== AI 地图命令系统 ====================
function execMapCommand(cmd) {
const parts = cmd.slice(1).split(/\s+/);
const action = parts[0].toLowerCase();
// /tp x y z — 传送玩家
if (action === 'tp' && parts.length >= 4) {
const nx = parseFloat(parts[1]), ny = parseFloat(parts[2]), nz = parseFloat(parts[3]);
if (isNaN(nx) || isNaN(ny) || isNaN(nz)) return '❌ 坐标无效。用法:/tp x y z';
player.pos.set(nx, ny, nz);
player.vel.set(0, 0, 0);
updateCamera();
return `已传送至 (${nx.toFixed(1)}, ${ny.toFixed(1)}, ${nz.toFixed(1)})`;
}
if (action === 'tp' && parts.length === 2) {
if (parts[1] === 'spawn' || parts[1] === '重生点') {
player.pos.set(0, 10, 0);
player.vel.set(0, 0, 0);
updateCamera();
return '已传送至出生点 (0, 10, 0)';
}
if (parts[1] === 'here' || parts[1] === '当前位置') {
const p = player.pos;
return `当前位置:(${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})`;
}
return '❌ 未知目标。用法:/tp x y z | /tp spawn | /tp here';
}
// /setblock x y z type — 放置方块
if (action === 'setblock' && parts.length >= 5) {
const x = parseInt(parts[1]), y = parseInt(parts[2]), z = parseInt(parts[3]);
const type = parts.slice(4).join('_').toLowerCase();
if (isNaN(x) || isNaN(y) || isNaN(z)) return '❌ 坐标无效。用法:/setblock x y z 方块名';
const def = BLOCKS.find(b => b.id === type || b.name === type || b.id.includes(type));
if (!def) return '❌ 未知方块:' + type + '<br>可用:' + BLOCKS.map(b => b.id).join(', ');
if (getBlock(x, y, z)) return '❌ 位置 (' + x + ',' + y + ',' + z + ') 已有方块';
const ok = placeBlock(x, y, z, def.id);
return ok ? '已在 (' + x + ',' + y + ',' + z + ') 放置 ' + def.name : '❌ 放置失败';
}
// /setblock ~ ~ ~ type — 在玩家位置放置
if (action === 'setblock' && parts[1] === '~' && parts.length >= 5) {
const x = Math.round(player.pos.x), y = Math.round(player.pos.y), z = Math.round(player.pos.z);
const type = parts.slice(4).join('_').toLowerCase();
const def = BLOCKS.find(b => b.id === type || b.name === type || b.id.includes(type));
if (!def) return '❌ 未知方块:' + type;
if (getBlock(x, y, z)) return '❌ 脚下已有方块';
const ok = placeBlock(x, y, z, def.id);
return ok ? '已在脚下放置 ' + def.name : '❌ 放置失败';
}
// /fill x1 y1 z1 x2 y2 z2 type — 填充区域
if (action === 'fill' && parts.length >= 8) {
const x1 = parseInt(parts[1]), y1 = parseInt(parts[2]), z1 = parseInt(parts[3]);
const x2 = parseInt(parts[4]), y2 = parseInt(parts[5]), z2 = parseInt(parts[6]);
const type = parts.slice(7).join('_').toLowerCase();
if (isNaN(x1) || isNaN(y1) || isNaN(z1) || isNaN(x2) || isNaN(y2) || isNaN(z2)) {
return '❌ 坐标无效。用法:/fill x1 y1 z1 x2 y2 z2 方块名';
}
const def = BLOCKS.find(b => b.id === type || b.name === type || b.id.includes(type));
if (!def) return '❌ 未知方块:' + type;
const dx = Math.abs(x2 - x1) + 1, dy = Math.abs(y2 - y1) + 1, dz = Math.abs(z2 - z1) + 1;
const total = dx * dy * dz;
if (total > 500) return '❌ 填充区域太大(' + total + ' 方块),最多 500 个';
let count = 0;
const sx = Math.min(x1, x2), ex = Math.max(x1, x2);
const sy = Math.min(y1, y2), ey = Math.max(y1, y2);
const sz = Math.min(z1, z2), ez = Math.max(z1, z2);
for (let ix = sx; ix <= ex; ix++)
for (let iy = sy; iy <= ey; iy++)
for (let iz = sz; iz <= ez; iz++)
if (placeBlock(ix, iy, iz, def.id, true)) count++;
simulate(); // 放置完后统一仿真
return '已填充 ' + count + ' 个 ' + def.name;
}
// /cleararea x1 y1 z1 x2 y2 z2 — 清除区域
if ((action === 'cleararea' || action === 'clear') && parts.length >= 7) {
const x1 = parseInt(parts[1]), y1 = parseInt(parts[2]), z1 = parseInt(parts[3]);
const x2 = parseInt(parts[4]), y2 = parseInt(parts[5]), z2 = parseInt(parts[6]);
if (isNaN(x1) || isNaN(y1) || isNaN(z1) || isNaN(x2) || isNaN(y2) || isNaN(z2)) {
return '❌ 坐标无效。用法:/cleararea x1 y1 z1 x2 y2 z2';
}
const dx = Math.abs(x2 - x1) + 1, dy = Math.abs(y2 - y1) + 1, dz = Math.abs(z2 - z1) + 1;
const total = dx * dy * dz;
if (total > 500) return '❌ 清除区域太大(' + total + ' 方块),最多 500 个';
let count = 0;
const sx = Math.min(x1, x2), ex = Math.max(x1, x2);
const sy = Math.min(y1, y2), ey = Math.max(y1, y2);
const sz = Math.min(z1, z2), ez = Math.max(z1, z2);
for (let ix = sx; ix <= ex; ix++)
for (let iy = sy; iy <= ey; iy++)
for (let iz = sz; iz <= ez; iz++)
if (removeBlock(ix, iy, iz)) count++;
return '已清除 ' + count + ' 个方块';
}
// /help — 帮助
if (action === 'help') {
return '可用命令:<br>' +
'/tp x y z — 传送到坐标<br>' +
'/tp spawn — 回出生点<br>' +
'/tp here — 查看当前位置<br>' +
'/setblock x y z 方块名 — 放置方块<br>' +
'/fill x1 y1 z1 x2 y2 z2 方块名 — 填充区域 (≤500)<br>' +
'/cleararea x1 y1 z1 x2 y2 z2 — 清除区域 (≤500)<br>' +
'方块名可用:' + BLOCKS.map(b => b.id).slice(0, 10).join(', ') + ' ...';
}
return '❌ 未知命令:/' + action + '<br>输入 /help 查看可用命令';
}
// 背包数据模型 (Minecraft 风格: 可拖拽) // 背包数据模型 (Minecraft 风格: 可拖拽)
let hotbarSlots = []; // 快捷栏: BLOCKS 索引数组 (长度 HOTBAR_SIZE) let hotbarSlots = []; // 快捷栏: BLOCKS 索引数组 (长度 HOTBAR_SIZE)
let invSlots = []; // 背包仓库: BLOCKS 索引数组 let invSlots = []; // 背包仓库: BLOCKS 索引数组
let carriedItem = null; // 鼠标拖拽中的物品 (BLOCKS 索引或 null) let carriedItem = null; // 鼠标拖拽中的物品 (BLOCKS 索引或 null)
// ==================== 方块网格创建 ==================== // ==================== 方块网格创建 ====================
// ==================== 区块合批渲染 ====================
function getFaceVertices(x, y, z, fi) {
// fi: 0=right,1=left,2=top,3=bottom,4=front,5=back
const v = [
[[1,1,1, 1,0,1, 1,0,0, 1,1,0], [1,0,0, 1,0,0, 1,0,0, 1,0,0]], // right +X
[[0,1,0, 0,0,0, 0,0,1, 0,1,1], [-1,0,0, -1,0,0, -1,0,0, -1,0,0]], // left -X
[[1,1,0, 0,1,0, 0,1,1, 1,1,1], [0,1,0, 0,1,0, 0,1,0, 0,1,0]], // top +Y
[[0,0,1, 0,0,0, 1,0,0, 1,0,1], [0,-1,0, 0,-1,0, 0,-1,0, 0,-1,0]], // bottom -Y
[[1,1,1, 0,1,1, 0,0,1, 1,0,1], [0,0,1, 0,0,1, 0,0,1, 0,0,1]], // front +Z
[[0,1,0, 1,1,0, 1,0,0, 0,0,0], [0,0,-1, 0,0,-1, 0,0,-1, 0,0,-1]], // back -Z
];
const [verts, norms] = v[fi];
const positions = [], normals = [];
for (let i = 0; i < 12; i += 3) {
positions.push(verts[i]+x, verts[i+1]+y, verts[i+2]+z);
normals.push(norms[i], norms[i+1], norms[i+2]);
}
return { positions, normals };
}
// 材质缓存:同纹理复用同一 Material,避免每区块重复创建
const sharedMats = new Map(); // tex → MeshLambertMaterial
function getBlockMaterial(tex) {
let m = sharedMats.get(tex);
if (!m) {
m = new THREE.MeshLambertMaterial({ map: tex });
sharedMats.set(tex, m);
}
return m;
}
function buildChunkMesh(cx, cy, cz) {
const ck = `${cx},${cy},${cz}`;
// 移除旧网格(材质为全局共享,仅 dispose 几何体)
if (chunkMeshes.has(ck)) {
chunkMeshes.get(ck).forEach(m => { scene.remove(m); if(m.geometry)m.geometry.dispose(); });
chunkMeshes.delete(ck);
}
// 空区块快速跳过(无方块无需构建)
if (!chunkBlockCount[ck]) return;
const x0 = cx * CHUNK_SIZE, y0 = cy * CHUNK_SIZE, z0 = cz * CHUNK_SIZE;
const facesByTex = {}; // key → { positions, normals, uvs, indices, tex }
var blockCount = 0, faceCount = 0;
for (let dx = 0; dx < CHUNK_SIZE; dx++) {
for (let dy = 0; dy < CHUNK_SIZE; dy++) {
for (let dz = 0; dz < CHUNK_SIZE; dz++) {
const x = x0 + dx, y = y0 + dy, z = z0 + dz;
const block = world.get(bk(x, y, z));
if (!block) continue;
blockCount++;
const def = getDef(block.type);
if (!def || !def.faces || def.isLever || def.isButton || def.isTorch) continue;
const neighbors = [
{ nx:1, ny:0, nz:0, fi:0 }, { nx:-1, ny:0, nz:0, fi:1 },
{ nx:0, ny:1, nz:0, fi:2 }, { nx:0, ny:-1, nz:0, fi:3 },
{ nx:0, ny:0, nz:1, fi:4 }, { nx:0, ny:0, nz:-1, fi:5 },
];
for (const n of neighbors) {
const nb = getBlock(x+n.nx, y+n.ny, z+n.nz);
const nbDef = nb ? getDef(nb.type) : null;
if (nb && (!nbDef || (!nbDef.transparent && !nbDef.isLever && !nbDef.isButton && !nbDef.isTorch))) continue;
const tex = def.faces[n.fi];
const texKey = n.fi + '_' + def.id;
if (!facesByTex[texKey]) facesByTex[texKey] = { positions:[], normals:[], uvs:[], indices:[], tex };
const ft = facesByTex[texKey];
const vi = ft.positions.length / 3;
const fv = getFaceVertices(x, y, z, n.fi);
ft.positions.push(...fv.positions);
ft.normals.push(...fv.normals);
ft.uvs.push(0,0, 1,0, 1,1, 0,1);
ft.indices.push(vi, vi+1, vi+2, vi, vi+2, vi+3);
faceCount++;
}
}
}
}
const meshes = [];
for (const ft of Object.values(facesByTex)) {
if (ft.positions.length === 0) continue;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(ft.positions, 3));
geo.setAttribute('normal', new THREE.Float32BufferAttribute(ft.normals, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(ft.uvs, 2));
geo.setIndex(ft.indices);
const mesh = new THREE.Mesh(geo, getBlockMaterial(ft.tex));
scene.add(mesh);
meshes.push(mesh);
}
chunkMeshes.set(ck, meshes);
if (blockCount > 0) debugHUD('Chunk '+ck+': '+blockCount+' blocks, '+faceCount+' faces, '+meshes.length+' meshes');
}
function buildAllChunks() {
debugHUD('buildAllChunks...');
var count = 0;
for (let cx = Math.floor(-WORLD_R / CHUNK_SIZE); cx <= Math.floor((WORLD_R-1) / CHUNK_SIZE); cx++) {
for (let cy = MIN_CHUNK_Y; cy <= MAX_CHUNK_Y; cy++) {
for (let cz = Math.floor(-WORLD_R / CHUNK_SIZE); cz <= Math.floor((WORLD_R-1) / CHUNK_SIZE); cz++) {
buildChunkMesh(cx, cy, cz);
generatedChunks[cx+','+cy+','+cz] = true;
count++;
}
}
}
debugHUD('buildAllChunks done: '+count+' chunks, world='+world.size+' blocks');
}
// 无限地形:生成一个区块列的地形数据
function generateTerrainColumn(cx, cz) {
var key = cx + ',' + cz;
if (terrainColumns[key]) return;
terrainColumns[key] = true;
if (!terrainNoise) terrainNoise = new PerlinNoise(42);
var colCounts = {}; // 本列各区块计数 (cy → n)
var x0 = cx * CHUNK_SIZE, z0 = cz * CHUNK_SIZE;
for (var dx = 0; dx < CHUNK_SIZE; dx++) {
for (var dz = 0; dz < CHUNK_SIZE; dz++) {
var wx = x0 + dx, wz = z0 + dz;
var base = terrainNoise.octave(wx/24, wz/24, 4, 0.5);
var detail = terrainNoise.octave(wx/8, wz/8, 2, 0.3) * 0.3;
var h = Math.floor(base * 5 + detail * 3 + 3);
for (var y = 0; y <= h; y++) {
var type;
if (y === h) {
type = (h <= SEA_LEVEL) ? 'sand' : 'grass';
} else if (y >= h - 2) {
type = (h <= SEA_LEVEL + 1) ? 'sand' : 'dirt';
} else {
type = 'stone';
}
var k = bk(wx, y, wz);
if (!world.has(k)) world.set(k, { type: type, lit: false });
var cy = Math.floor(y / CHUNK_SIZE);
colCounts[cy] = (colCounts[cy] || 0) + 1;
}
if (h < SEA_LEVEL) {
for (var y = h + 1; y <= SEA_LEVEL; y++) {
var k = bk(wx, y, wz);
if (!world.has(k)) world.set(k, { type: 'sand', lit: false });
var cy = Math.floor(y / CHUNK_SIZE);
colCounts[cy] = (colCounts[cy] || 0) + 1;
}
}
}
}
// 合并到全局区块计数
for (var cy in colCounts) {
var ck = cx + ',' + cy + ',' + cz;
chunkBlockCount[ck] = (chunkBlockCount[ck] || 0) + colCounts[cy];
}
}
// 确保某区块有mesh
function ensureChunkMesh(cx, cy, cz) {
var key = cx + ',' + cy + ',' + cz;
if (generatedChunks[key]) return;
generatedChunks[key] = true;
// 空区块(无方块)跳过构建
if (!chunkBlockCount[key]) return;
buildChunkMesh(cx, cy, cz);
}
// 卸载一个区块
function unloadChunkMesh(key) {
if (chunkMeshes.has(key)) {
chunkMeshes.get(key).forEach(function(m) {
scene.remove(m);
if (m.geometry) m.geometry.dispose();
});
chunkMeshes.delete(key);
}
delete generatedChunks[key];
}
// 确保玩家周围的区块已加载
function ensureTerrainAround(px, pz) {
var ccx = Math.floor(px / CHUNK_SIZE);
var ccz = Math.floor(pz / CHUNK_SIZE);
// 生成渲染范围内的地形列和mesh
for (var cx = ccx - RENDER_DIST; cx <= ccx + RENDER_DIST; cx++) {
for (var cz = ccz - RENDER_DIST; cz <= ccz + RENDER_DIST; cz++) {
generateTerrainColumn(cx, cz);
for (var cy = MIN_CHUNK_Y; cy <= MAX_CHUNK_Y; cy++) {
ensureChunkMesh(cx, cy, cz);
}
}
}
// 卸载超出卸载距离的区块
var toRemove = [];
for (var key in generatedChunks) {
var parts = key.split(',').map(Number);
if (Math.abs(parts[0] - ccx) > UNLOAD_DIST || Math.abs(parts[2] - ccz) > UNLOAD_DIST) {
toRemove.push(key);
}
}
for (var i = 0; i < toRemove.length; i++) {
unloadChunkMesh(toRemove[i]);
}
}
// 旧函数保留兼容(用于startWorld初次加载)
function ensureInitialTerrain(px, pz) {
ensureTerrainAround(px, pz);
}
function refreshChunkAt(x, y, z) {
buildChunkMesh(Math.floor(x / CHUNK_SIZE), Math.floor(y / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE));
}
// 设置方块数据 (不渲染)
function setBlockData(x, y, z, type) {
const k = bk(x, y, z);
if (world.has(k)) return false;
world.set(k, { type, lit: false });
bumpChunkCount(x, y, z, 1);
return true;
}
// 区块方块计数维护:用于空区块跳过渲染构建
function bumpChunkCount(x, y, z, delta) {
const ck = Math.floor(x / CHUNK_SIZE) + ',' + Math.floor(y / CHUNK_SIZE) + ',' + Math.floor(z / CHUNK_SIZE);
const n = (chunkBlockCount[ck] || 0) + delta;
if (n <= 0) delete chunkBlockCount[ck];
else chunkBlockCount[ck] = n;
}
function makeBlockMesh(blockDef) { function makeBlockMesh(blockDef) {
// 拉杆: 石质底座 + 可旋转木杆 (Group) // 拉杆: 石质底座 + 可旋转木杆 (Group)
if (blockDef.isLever) { if (blockDef.isLever) {
@@ -466,25 +2293,36 @@ function makeBlockMesh(blockDef) {
return mesh; return mesh;
} }
function getDef(id) { return BLOCKS.find(b => b.id === id); } const DEF_MAP = new Map(BLOCKS.map(b => [b.id, b]));
function getDef(id) { return DEF_MAP.get(id); }
function placeBlock(x, y, z, typeId, skipUpdate, direction) { function placeBlock(x, y, z, typeId, skipUpdate, direction) {
const k = bk(x, y, z); const k = bk(x, y, z);
if (world.has(k)) return false; if (world.has(k)) return false;
const def = getDef(typeId); const def = getDef(typeId);
const mesh = makeBlockMesh(def); // 有模型的元件 (拉杆/按钮/火把) 保留独立 mesh
const yOffset = mesh.userData.yOffset || 0; if (def.isLever || def.isButton || def.isTorch) {
mesh.position.set(x + 0.5, y + 0.5 + yOffset, z + 0.5); const mesh = makeBlockMesh(def);
// 方向性元件: 旋转 const yOffset = mesh.userData.yOffset || 0;
let dir = 0; mesh.position.set(x + 0.5, y + 0.5 + yOffset, z + 0.5);
if (isDirectional(typeId) && direction !== undefined) { let dir = 0;
mesh.rotation.y = direction; if (isDirectional(typeId) && direction !== undefined) {
dir = direction; mesh.rotation.y = direction; dir = direction;
}
mesh.userData = { x, y, z, type: typeId, yOffset, direction: dir };
scene.add(mesh);
world.set(k, { type: typeId, mesh, lit: false });
bumpChunkCount(x, y, z, 1);
if (!skipUpdate) simulate();
return true;
}
// 普通方块: 存入数据 + 刷新区块
world.set(k, { type: typeId, lit: false });
bumpChunkCount(x, y, z, 1);
if (!skipUpdate) {
refreshChunkAt(x, y, z);
simulate();
} }
mesh.userData = { x, y, z, type: typeId, yOffset, direction: dir };
scene.add(mesh);
world.set(k, { type: typeId, mesh, lit: false });
if (!skipUpdate) updateLamps();
return true; return true;
} }
@@ -492,139 +2330,182 @@ function removeBlock(x, y, z) {
const k = bk(x, y, z); const k = bk(x, y, z);
const b = world.get(k); const b = world.get(k);
if (!b) return false; if (!b) return false;
scene.remove(b.mesh); // 释放独立 mesh (如果有)
// 释放资源 (支持 Group) if (b.mesh) {
b.mesh.traverse(child => { scene.remove(b.mesh);
if (child.geometry) child.geometry.dispose(); b.mesh.traverse(child => {
if (child.material) { if (child.geometry) child.geometry.dispose();
if (Array.isArray(child.material)) child.material.forEach(m => m.dispose()); if (child.material) {
else child.material.dispose(); if (Array.isArray(child.material)) child.material.forEach(m => m.dispose());
} else child.material.dispose();
}); }
});
}
world.delete(k); world.delete(k);
updateLamps(); bumpChunkCount(x, y, z, -1);
refreshChunkAt(x, y, z);
simulate();
return true; return true;
} }
function getBlock(x, y, z) { return world.get(bk(x, y, z)); } function getBlock(x, y, z) { return world.get(bk(x, y, z)); }
// ==================== 红石模拟 ==================== // ==================== 电路仿真引擎 ====================
function updateLamps() { function getDirections(rot) {
const s = Math.round(Math.sin(rot)), c = Math.round(Math.cos(rot));
return {
forward: [s, c], backward: [-s, -c],
right: [c, -s], left: [-c, s],
};
}
function getNeighborSig(x, y, z, [dx, dz]) {
const n = getBlock(x+dx, y, z+dz);
return n ? (n.signal || 0) : 0;
}
function setNeighborSig(x, y, z, [dx, dz], val) {
const n = getBlock(x+dx, y, z+dz);
if (n && val > (n.signal || 0)) n.signal = val;
}
function bfsWires() {
const queue = [];
for (const [k, b] of world) {
if ((b.signal || 0) > 0) {
const def = getDef(b.type);
if (def.power || b.type === 'dust' || b.type === 'torch' || isDirectional(b.type) || b.type === 'lever' || b.type === 'button') {
queue.push({ k, sig: b.signal });
}
}
}
let changed = false;
while (queue.length) {
const { k, sig } = queue.shift();
if (sig <= 1) continue;
const [x, y, z] = bp(k);
for (const [dx, dz] of [[1,0],[-1,0],[0,1],[0,-1]]) {
const n = getBlock(x+dx, y, z+dz);
if (!n || n.type !== 'dust') continue;
const ns = sig - 1;
if (ns > (n.signal || 0)) { n.signal = ns; changed = true; queue.push({ k: bk(x+dx,y,z+dz), sig: ns }); }
}
}
return changed;
}
function simulate() {
// 1. 重置信号
for (const [, b] of world) b.signal = 0;
// 2. 电源
for (const [, b] of world) {
const def = getDef(b.type);
if (def.power) b.signal = 15;
if (b.type === 'lever' && b.lit) b.signal = 15;
if (b.type === 'button' && b.lit) b.signal = 15;
}
// 3. 迭代传播 (最多20轮直到稳定)
for (let iter = 0; iter < 20; iter++) {
let changed = false;
// 3a. 导线BFS
changed = bfsWires() || changed;
// 3b. 火把反相
for (const [k, b] of world) {
if (b.type !== 'torch') continue;
const [x, y, z] = bp(k);
let adjPow = false;
for (const [dx, dy, dz] of [[1,0,0],[-1,0,0],[0,1,0],[0,-1,0],[0,0,1],[0,0,-1]]) {
const n = getBlock(x+dx, y+dy, z+dz);
if (n && (n.signal||0) > 0 && n.type !== 'torch') { adjPow = true; break; }
}
const ns = adjPow ? 0 : 15;
if ((b.signal||0) !== ns) { b.signal = ns; changed = true; }
}
// 3c. 方向性元件
for (const [k, b] of world) {
if (!isDirectional(b.type) || !b.mesh) continue;
const [x, y, z] = bp(k);
const dir = getDirections(b.mesh.userData.direction || 0);
const def = getDef(b.type);
const inBack = getNeighborSig(x, y, z, dir.backward);
const inLeft = getNeighborSig(x, y, z, dir.left);
const inRight = getNeighborSig(x, y, z, dir.right);
let out = 0;
if (def.isDiode || def.isRepeater) {
out = inBack > 0 ? 15 : 0;
} else if (def.isGate === 'AND') {
out = (inLeft > 0 && inRight > 0) ? 15 : 0;
} else if (def.isGate === 'OR') {
out = (inLeft > 0 || inRight > 0) ? 15 : 0;
} else if (def.isGate === 'NOT') {
out = inBack > 0 ? 0 : 15;
} else if (def.isNPN) {
// 基极=后, 集电极=右, 发射极=左; 基极有信号时集电极->发射极导通
if (inBack > 0 && inRight > 0) setNeighborSig(x, y, z, dir.left, inRight);
continue;
}
if (out > 0) setNeighborSig(x, y, z, dir.forward, out);
}
// 3d. 再次导线BFS (传播门输出)
changed = bfsWires() || changed;
if (!changed) break;
}
// 4. 更新灯/LED
for (const [k, b] of world) { for (const [k, b] of world) {
const def = getDef(b.type); const def = getDef(b.type);
if (!def.isLamp) continue; if (!def.isLamp) continue;
const [x, y, z] = bp(k); const [x, y, z] = bp(k);
let powered = false; let powered = (b.signal||0) > 0;
for (const [dx, dy, dz] of [[1,0,0],[-1,0,0],[0,1,0],[0,-1,0],[0,0,1],[0,0,-1]]) { if (!powered) {
const n = getBlock(x+dx, y+dy, z+dz); for (const [dx, dy, dz] of [[1,0,0],[-1,0,0],[0,1,0],[0,-1,0],[0,0,1],[0,0,-1]]) {
if (!n) continue; const n = getBlock(x+dx, y+dy, z+dz);
const nd = getDef(n.type); if (n && (n.signal||0) > 0) { powered = true; break; }
if (nd.power) { powered = true; break; } }
if (n.type === 'lever' && n.lit) { powered = true; break; }
if (n.type === 'button' && n.lit) { powered = true; break; }
if (n.type === 'torch') { powered = true; break; } // 火把始终供能
} }
if (powered !== b.lit) { if (powered !== b.lit) {
b.lit = powered; b.lit = powered;
const newFaces = powered ? def.litFaces : def.faces; if (!b.mesh) { refreshChunkAt(x, y, z); continue; }
b.mesh.material.forEach((m, i) => { m.map = newFaces[i]; m.needsUpdate = true; }); const nf = powered ? def.litFaces : def.faces;
if (powered) b.mesh.material.forEach(m => { m.emissive = new THREE.Color(0xfcd34d); m.emissiveIntensity = 0.3; }); const mats = Array.isArray(b.mesh.material) ? b.mesh.material : [b.mesh.material];
else b.mesh.material.forEach(m => { m.emissive = new THREE.Color(0x000000); m.emissiveIntensity = 0; }); mats.forEach((m, i) => { if (nf[i]) { m.map = nf[i]; m.needsUpdate = true; } });
mats.forEach(m => {
m.emissive = powered ? new THREE.Color(0xfcd34d) : new THREE.Color(0x000000);
m.emissiveIntensity = powered ? 0.3 : 0;
});
} }
} }
} }
// ==================== 地形生成 ==================== // ==================== 地形生成 ====================
function generateTerrain() { // generateTerrain 已替换为 generateTerrainColumn + ensureTerrainAround 无限地形系统
const noise = new PerlinNoise(42);
const treeNoise = new PerlinNoise(99);
const heightMap = {};
// 生成高度图
for (let x = -WORLD_R; x < WORLD_R; x++) {
for (let z = -WORLD_R; z < WORLD_R; z++) {
// 多层噪声: 大地形 + 细节
const base = noise.octave(x/24, z/24, 4, 0.5); // -1~1
const detail = noise.octave(x/8, z/8, 2, 0.3) * 0.3;
const h = Math.floor(base * 5 + detail * 3 + 3); // 高度范围约 -2 ~ 11
heightMap[bk(x,z)] = h;
// 填充方块
for (let y = 0; y <= h; y++) {
let type;
if (y === h) {
if (h <= SEA_LEVEL) type = 'sand';
else type = 'grass';
} else if (y >= h - 2) {
type = h <= SEA_LEVEL + 1 ? 'sand' : 'dirt';
} else {
type = 'stone';
}
placeBlock(x, y, z, type, true);
}
// 水面
if (h < SEA_LEVEL) {
for (let y = h + 1; y <= SEA_LEVEL; y++) {
placeBlock(x, y, z, 'sand', true); // 浅水区用沙子
}
}
}
}
// 生成树木
let treeCount = 0;
for (let x = -WORLD_R + 2; x < WORLD_R - 2; x++) {
for (let z = -WORLD_R + 2; z < WORLD_R - 2; z++) {
const h = heightMap[bk(x, z)];
if (h < SEA_LEVEL + 1) continue; // 水中不生树
const surface = getBlock(x, h, z);
if (!surface || surface.type !== 'grass') continue;
// 树木概率: 基于噪声 + 随机
const tn = treeNoise.noise(x * 0.5, z * 0.5);
if (tn > 0.4 && Math.random() < 0.08 && treeCount < 30) {
generateTree(x, h + 1, z);
treeCount++;
}
}
}
// 示例红石电路
const sx = 3, sz = 3;
const sh = heightMap[bk(sx, sz)] || 3;
placeBlock(sx, sh + 1, sz, 'rblock', true);
placeBlock(sx + 1, sh + 1, sz, 'lamp', true);
const lx = -3, lz = -3;
const lh = heightMap[bk(lx, lz)] || 3;
placeBlock(lx, lh + 1, lz, 'lever', true);
placeBlock(lx + 1, lh + 1, lz, 'lamp', true);
updateLamps();
updateLoadProgress(100);
}
function generateTree(x, y, z) { function generateTree(x, y, z) {
const trunkH = 4 + (Math.random() * 2 | 0); const trunkH = 4 + (Math.random() * 2 | 0);
// 树干 // 树干
for (let i = 0; i < trunkH; i++) placeBlock(x, y + i, z, 'wood', true); for (let i = 0; i < trunkH; i++) setBlockData(x, y + i, z, 'wood');
// 树冠: 3层 // 树冠: 3层
const top = y + trunkH; const top = y + trunkH;
// 底层 5x5 // 底层 5x5
for (let dx = -2; dx <= 2; dx++) for (let dx = -2; dx <= 2; dx++)
for (let dz = -2; dz <= 2; dz++) { for (let dz = -2; dz <= 2; dz++) {
if (Math.abs(dx) === 2 && Math.abs(dz) === 2 && Math.random() < 0.5) continue; if (Math.abs(dx) === 2 && Math.abs(dz) === 2 && Math.random() < 0.5) continue;
placeBlock(x + dx, top - 1, z + dz, 'leaves', true); setBlockData(x + dx, top - 1, z + dz, 'leaves');
} }
// 中层 3x3 // 中层 3x3
for (let dx = -1; dx <= 1; dx++) for (let dx = -1; dx <= 1; dx++)
for (let dz = -1; dz <= 1; dz++) for (let dz = -1; dz <= 1; dz++)
placeBlock(x + dx, top, z + dz, 'leaves', true); setBlockData(x + dx, top, z + dz, 'leaves');
// 顶层 // 顶层
placeBlock(x, top + 1, z, 'leaves', true); setBlockData(x, top + 1, z, 'leaves');
if (Math.random() < 0.5) placeBlock(x + 1, top + 1, z, 'leaves', true); if (Math.random() < 0.5) setBlockData(x + 1, top + 1, z, 'leaves');
if (Math.random() < 0.5) placeBlock(x, top + 1, z + 1, 'leaves', true); if (Math.random() < 0.5) setBlockData(x, top + 1, z + 1, 'leaves');
} }
// ==================== 快捷栏 & 背包 (Minecraft 风格) ==================== // ==================== 快捷栏 & 背包 (Minecraft 风格) ====================
@@ -686,7 +2567,7 @@ function createInventory() {
// 右侧: 存储区 (18格) + 快捷栏 (10格) // 右侧: 存储区 (18格) + 快捷栏 (10格)
const storage = document.getElementById('invStorage'); const storage = document.getElementById('invStorage');
storage.innerHTML = ''; storage.innerHTML = '';
for (let i = 0; i < 18; i++) storage.appendChild(makeInvSlot(invSlots[i], i, 'storage')); for (let i = 0; i < 27; i++) storage.appendChild(makeInvSlot(invSlots[i], i, 'storage'));
const hb = document.getElementById('invHotbar'); const hb = document.getElementById('invHotbar');
hb.innerHTML = ''; hb.innerHTML = '';
for (let i = 0; i < HOTBAR_SIZE; i++) { for (let i = 0; i < HOTBAR_SIZE; i++) {
@@ -703,6 +2584,10 @@ function makeInvSlot(blockIdx, slotIdx, source) {
slot.dataset.slot = slotIdx; slot.dataset.slot = slotIdx;
if (blockIdx !== undefined && blockIdx !== null) { if (blockIdx !== undefined && blockIdx !== null) {
slot.appendChild(makeSlotIcon(blockIdx)); slot.appendChild(makeSlotIcon(blockIdx));
slot.title = BLOCKS[blockIdx].name;
// 自定义悬停提示
slot.addEventListener('mouseenter', () => showInvTooltip(BLOCKS[blockIdx].name));
slot.addEventListener('mouseleave', () => hideInvTooltip());
} }
if (source === 'hotbar') { if (source === 'hotbar') {
const n = document.createElement('span'); const n = document.createElement('span');
@@ -741,11 +2626,20 @@ function updateCarriedItem() {
else { el.style.display = 'block'; el.innerHTML = ''; el.appendChild(makeSlotIcon(carriedItem)); } else { el.style.display = 'block'; el.innerHTML = ''; el.appendChild(makeSlotIcon(carriedItem)); }
} }
function showInvTooltip(name) {
const t = document.getElementById('invTooltip');
t.textContent = name; t.style.display = 'block';
}
function hideInvTooltip() {
document.getElementById('invTooltip').style.display = 'none';
}
function toggleInventory(force) { function toggleInventory(force) {
const inv = document.getElementById('inventory'); const inv = document.getElementById('inventory');
const show = force !== undefined ? force : !inv.classList.contains('show'); const show = force !== undefined ? force : !inv.classList.contains('show');
inv.classList.toggle('show', show); inv.classList.toggle('show', show);
if (show) { if (show) {
gameActive = false;
createInventory(); createInventory();
if (document.pointerLockElement) document.exitPointerLock(); if (document.pointerLockElement) document.exitPointerLock();
} else { } else {
@@ -756,6 +2650,7 @@ function toggleInventory(force) {
else hotbarSlots[hotbarSlots.indexOf(null)] = carriedItem; else hotbarSlots[hotbarSlots.indexOf(null)] = carriedItem;
carriedItem = null; updateCarriedItem(); carriedItem = null; updateCarriedItem();
} }
gameActive = true;
renderer.domElement.requestPointerLock(); renderer.domElement.requestPointerLock();
} }
} }
@@ -771,91 +2666,197 @@ function selectSlot(i) {
// 初始化背包数据 // 初始化背包数据
function initInventoryData() { function initInventoryData() {
hotbarSlots = BLOCKS.map((_, i) => i).slice(0, HOTBAR_SIZE); hotbarSlots = BLOCKS.map((_, i) => i).slice(0, HOTBAR_SIZE);
invSlots = new Array(18).fill(null); invSlots = new Array(27).fill(null);
const remaining = BLOCKS.map((_, i) => i).slice(HOTBAR_SIZE); const remaining = BLOCKS.map((_, i) => i).slice(HOTBAR_SIZE);
for (let i = 0; i < remaining.length && i < 18; i++) invSlots[i] = remaining[i]; for (let i = 0; i < remaining.length && i < 27; i++) invSlots[i] = remaining[i];
} }
// ==================== 事件 ==================== // ==================== 事件 ====================
function setupEvents() { function setupEvents() {
document.getElementById('startBtn').addEventListener('click', () => { // 暂停面板按钮
document.getElementById('overlay').style.display = 'none'; document.getElementById('resumeBtn').addEventListener('click', resumeGame);
renderer.domElement.requestPointerLock(); document.getElementById('menuBackBtn').addEventListener('click', () => {
gameActive = false;
document.getElementById('modeHUD').style.display = 'none';
document.getElementById('overlay').style.display = 'flex';
if (authState.loggedIn) showPanel('hub');
else showPanel('welcome');
}); });
// 登录/注册表单回车提交
document.getElementById('loginPass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
document.getElementById('regPass2').addEventListener('keydown', e => { if (e.key === 'Enter') doRegister(); });
document.addEventListener('pointerlockchange', () => { document.addEventListener('pointerlockchange', () => {
if (!document.pointerLockElement) { if (!document.pointerLockElement) {
if (document.getElementById('inventory').classList.contains('show')) return; // 背包打开时不显示暂停 if (document.getElementById('inventory').classList.contains('show')) return;
const ov = document.getElementById('overlay'); if (releaseReason === 'ai') return; // AI 聊天打开中
if (ov.style.display === 'none') { // F11 释放鼠标
ov.querySelector('h1').textContent = '已暂停'; if (releaseReason === 'f11') {
ov.querySelector('.start-btn').textContent = '点击继续'; document.getElementById('overlay').style.display = 'none';
ov.style.display = 'flex'; return;
}
// 正常暂停
if (gameActive) {
gameActive = false;
document.getElementById('overlay').style.display = 'flex';
showPanel('pause');
} }
} }
// 锁定鼠标后不再隐藏 AI 按钮,始终显示
}); });
document.addEventListener('mousemove', e => { document.addEventListener('mousemove', e => {
if (document.pointerLockElement !== renderer.domElement) return; if (!gameActive) return;
yaw -= e.movementX * 0.0025; // 游戏激活即转动视角:WebView2 中 pointer lock 可能失败,直接使用 movementX/Y
pitch -= e.movementY * 0.0025; yaw -= (e.movementX || 0) * 0.0025;
pitch -= (e.movementY || 0) * 0.0025;
pitch = Math.max(-Math.PI/2+0.01, Math.min(Math.PI/2-0.01, pitch)); pitch = Math.max(-Math.PI/2+0.01, Math.min(Math.PI/2-0.01, pitch));
}); });
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
// F11: 释放/锁定鼠标 (不触发暂停)
if (e.code === 'F11') {
e.preventDefault();
if (document.pointerLockElement === renderer.domElement) {
releaseReason = 'f11';
document.exitPointerLock();
} else if (releaseReason === 'f11' && !aiChatOpen && !document.getElementById('inventory').classList.contains('show')) {
releaseReason = null;
renderer.domElement.requestPointerLock();
}
return;
}
// Enter: 打开/关闭 AI 聊天
if (e.code === 'Enter') {
if (aiChatOpen && document.activeElement === document.getElementById('aiInput')) {
// 输入框聚焦中 → 发送消息
e.preventDefault();
sendAIMessage();
return;
}
if (gameActive && !document.getElementById('inventory').classList.contains('show')) {
e.preventDefault();
toggleAIChat();
return;
}
}
// Escape: 关闭 AI 聊天 / 背包 / 暂停
if (e.code === 'Escape') {
if (aiChatOpen) { e.preventDefault(); closeAIChat(); return; }
if (document.getElementById('inventory').classList.contains('show')) { toggleInventory(false); return; }
// 游戏中按下 Esc 暂停(无 pointer lock 时同样生效)
if (gameActive) {
e.preventDefault();
gameActive = false;
if (document.pointerLockElement) document.exitPointerLock();
document.getElementById('overlay').style.display = 'flex';
showPanel('pause');
return;
}
}
if (e.code === 'KeyE') { e.preventDefault(); toggleInventory(); return; } if (e.code === 'KeyE') { e.preventDefault(); toggleInventory(); return; }
if (e.code === 'Escape' && document.getElementById('inventory').classList.contains('show')) { toggleInventory(false); return; } // 阻止浏览器默认行为 (防止空格滚动/WASD冲突)
if (gameActive) {
if (['Space','KeyW','KeyA','KeyS','KeyD'].includes(e.code)) e.preventDefault();
}
keys[e.code] = true; keys[e.code] = true;
const m = e.code.match(/Digit(\d)/); const m = e.code.match(/Digit(\d)/);
if (m) { const d = parseInt(m[1]); const idx = d === 0 ? 9 : d - 1; if (idx < HOTBAR_SIZE) selectSlot(idx); } if (m) { const d = parseInt(m[1]); const idx = d === 0 ? 9 : d - 1; if (idx < HOTBAR_SIZE) selectSlot(idx); }
}); });
document.addEventListener('keyup', e => { keys[e.code] = false; }); document.addEventListener('keyup', e => { keys[e.code] = false; });
document.addEventListener('mousedown', e => { document.addEventListener('mousedown', e => {
if (document.pointerLockElement !== renderer.domElement) return; if (!gameActive) return;
if (document.pointerLockElement !== renderer.domElement) {
// 无 pointer lock: 按住拖动旋转视角
dragLook = true;
}
if (e.button === 0) breakBlock(); if (e.button === 0) breakBlock();
else if (e.button === 2) placeBlockAction(); else if (e.button === 2) placeBlockAction();
}); });
document.addEventListener('mouseup', e => {
dragLook = false;
if (e.button === 0 && breakingBlock) finishBreak();
});
document.addEventListener('contextmenu', e => e.preventDefault()); document.addEventListener('contextmenu', e => e.preventDefault());
// 拖拽物品跟随鼠标 // 拖拽物品 & 提示框跟随鼠标
document.addEventListener('mousemove', e => { document.addEventListener('mousemove', e => {
const el = document.getElementById('carriedItem'); const el = document.getElementById('carriedItem');
if (el.style.display === 'block') { if (el.style.display === 'block') {
el.style.left = (e.clientX - 20) + 'px'; el.style.left = (e.clientX - 18) + 'px';
el.style.top = (e.clientY - 20) + 'px'; el.style.top = (e.clientY - 18) + 'px';
}
const tt = document.getElementById('invTooltip');
if (tt.style.display === 'block') {
tt.style.left = (e.clientX + 14) + 'px';
tt.style.top = (e.clientY + 14) + 'px';
} }
}); });
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight; camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight); renderer.setSize(window.innerWidth, window.innerHeight);
drawMCBackground();
}); });
} }
// ==================== 方块交互 ==================== // ==================== 方块交互 ====================
// DDA 体素光线步进:沿视线方向逐格检查,O(REACH) 而非遍历全 world
function getTarget() { function getTarget() {
raycaster.setFromCamera(new THREE.Vector2(0, 0), camera); const origin = camera.position;
raycaster.far = REACH; const dir = camera.getWorldDirection(new THREE.Vector3());
const meshes = []; let x = Math.floor(origin.x), y = Math.floor(origin.y), z = Math.floor(origin.z);
for (const [, b] of world) if (!getDef(b.type).transparent || b.type !== 'leaves') meshes.push(b.mesh); const stepX = dir.x > 0 ? 1 : -1;
else meshes.push(b.mesh); const stepY = dir.y > 0 ? 1 : -1;
const hits = raycaster.intersectObjects(meshes, true); const stepZ = dir.z > 0 ? 1 : -1;
if (!hits.length) return null; const tDeltaX = Math.abs(1 / (dir.x || 1e-10));
const h = hits[0]; const tDeltaY = Math.abs(1 / (dir.y || 1e-10));
// 向上查找有方块坐标的父对象 (支持 Group) const tDeltaZ = Math.abs(1 / (dir.z || 1e-10));
let obj = h.object; let tMaxX = stepX > 0 ? (x + 1 - origin.x) / dir.x : (origin.x - x) / -dir.x;
while (obj && obj.userData.x === undefined) obj = obj.parent; let tMaxY = stepY > 0 ? (y + 1 - origin.y) / dir.y : (origin.y - y) / -dir.y;
if (!obj) return null; let tMaxZ = stepZ > 0 ? (z + 1 - origin.z) / dir.z : (origin.z - z) / -dir.z;
return { x: obj.userData.x, y: obj.userData.y, z: obj.userData.z, normal: h.face.normal }; let normal = null;
let t = 0;
for (let i = 0; i < 256 && t < REACH; i++) {
const b = getBlock(x, y, z);
if (b) return { x, y, z, normal: normal || { x: 0, y: 1, z: 0 } };
if (tMaxX < tMaxY && tMaxX < tMaxZ) {
x += stepX; t = tMaxX; tMaxX += tDeltaX; normal = { x: -stepX, y: 0, z: 0 };
} else if (tMaxY < tMaxZ) {
y += stepY; t = tMaxY; tMaxY += tDeltaY; normal = { x: 0, y: -stepY, z: 0 };
} else {
z += stepZ; t = tMaxZ; tMaxZ += tDeltaZ; normal = { x: 0, y: 0, z: -stepZ };
}
}
return null;
} }
function breakBlock() { function breakBlock() {
const t = getTarget();
if (!t) { breakingBlock = null; return; }
if (!breakingBlock || breakingBlock.x !== t.x || breakingBlock.y !== t.y || breakingBlock.z !== t.z) {
breakingBlock = { x: t.x, y: t.y, z: t.z, progress: 0 };
breakStartTime = performance.now();
}
}
function finishBreak() {
if (!breakingBlock) return;
const b = getBlock(breakingBlock.x, breakingBlock.y, breakingBlock.z);
if (!b) { breakingBlock = null; return; }
const def = getDef(b.type);
const color = def.faces ? '#aaa' : '#666';
spawnParticles(breakingBlock.x, breakingBlock.y, breakingBlock.z, b.type === 'stone' ? '#888' : b.type === 'dirt' ? '#a07040' : b.type === 'grass' ? '#7a8a4a' : '#ccc');
removeBlock(breakingBlock.x, breakingBlock.y, breakingBlock.z);
breakingBlock = null;
}
function placeBlockAction() {
const t = getTarget(); const t = getTarget();
if (!t) return; if (!t) return;
const b = getBlock(t.x, t.y, t.z); const b = getBlock(t.x, t.y, t.z);
if (!b) return; // 右键交互: 拉杆切换开关
if (b.type === 'lever') { if (b && b.type === 'lever') {
b.lit = !b.lit; b.lit = !b.lit;
// 旋转手柄
const handle = b.mesh.userData.handle; const handle = b.mesh.userData.handle;
if (handle) handle.rotation.x = b.lit ? -Math.PI / 4 : 0; if (handle) handle.rotation.x = b.lit ? -Math.PI / 4 : 0;
// 更新所有子物体材质发光
b.mesh.traverse(child => { b.mesh.traverse(child => {
if (child.material) { if (child.material) {
const mats = Array.isArray(child.material) ? child.material : [child.material]; const mats = Array.isArray(child.material) ? child.material : [child.material];
@@ -865,19 +2866,20 @@ function breakBlock() {
} }
} }
}); });
updateLamps(); simulate();
showToast(b.lit ? '拉杆: 开' : '拉杆: 关'); showToast(b.lit ? '拉杆: 开' : '拉杆: 关');
return; return;
} }
if (b.type === 'button') { // 右键交互: 按钮按下
if (b.lit) return; // 已按下则忽略 if (b && b.type === 'button') {
if (b.lit) return;
b.lit = true; b.lit = true;
const bmats = Array.isArray(b.mesh.material) ? b.mesh.material : [b.mesh.material]; const bmats = Array.isArray(b.mesh.material) ? b.mesh.material : [b.mesh.material];
for (const m of bmats) { for (const m of bmats) {
if (m.emissive) { m.emissive = new THREE.Color(0xe83229); m.emissiveIntensity = 0.5; } if (m.emissive) { m.emissive = new THREE.Color(0xe83229); m.emissiveIntensity = 0.5; }
} }
b.mesh.scale.y = 0.5; b.mesh.scale.y = 0.5;
updateLamps(); simulate();
showToast('按钮: 按下'); showToast('按钮: 按下');
setTimeout(() => { setTimeout(() => {
b.lit = false; b.lit = false;
@@ -886,16 +2888,11 @@ function breakBlock() {
if (m.emissive) { m.emissive = new THREE.Color(0x000000); m.emissiveIntensity = 0; } if (m.emissive) { m.emissive = new THREE.Color(0x000000); m.emissiveIntensity = 0; }
} }
b.mesh.scale.y = 1; b.mesh.scale.y = 1;
updateLamps(); simulate();
}, 1000); }, 1000);
return; return;
} }
removeBlock(t.x, t.y, t.z); // 否则放置方块
}
function placeBlockAction() {
const t = getTarget();
if (!t) return;
const nx = t.x + Math.round(t.normal.x); const nx = t.x + Math.round(t.normal.x);
const ny = t.y + Math.round(t.normal.y); const ny = t.y + Math.round(t.normal.y);
const nz = t.z + Math.round(t.normal.z); const nz = t.z + Math.round(t.normal.z);
@@ -904,12 +2901,26 @@ function placeBlockAction() {
const bi = hotbarSlots[selectedSlot]; const bi = hotbarSlots[selectedSlot];
if (bi == null) { showToast('请选择方块'); return; } if (bi == null) { showToast('请选择方块'); return; }
const blockId = BLOCKS[bi].id; const blockId = BLOCKS[bi].id;
// 方向性元件: 输出方向 = 玩家视角方向 (yaw + π 使输出朝向玩家面朝方向) // 方向性元件: 吸附到4个正方向 (前后左右)
const dir = isDirectional(blockId) ? yaw + Math.PI : undefined; const dir = isDirectional(blockId) ? Math.round((yaw + Math.PI) / (Math.PI / 2)) * (Math.PI / 2) : undefined;
placeBlock(nx, ny, nz, blockId, false, dir); placeBlock(nx, ny, nz, blockId, false, dir);
} }
// ==================== 物理 ==================== // ==================== 物理 ====================
// 确保玩家出生点不在方块内部(防止被卡住无法移动)
function ensurePlayerFree() {
const x = Math.floor(player.pos.x), z = Math.floor(player.pos.z);
// 从高处向下找第一个实心方块,玩家站到它上方 2 格
let top = -64;
for (let y = 200; y >= -64; y--) {
if (getBlock(x, y, z)) { top = y; break; }
}
const spawnY = (top === -64) ? 15 : top + 2;
player.pos.set(x + 0.5, spawnY, z + 0.5);
player.vel.set(0, 0, 0);
player.onGround = false;
}
function updatePlayer(dt) { function updatePlayer(dt) {
const fwd = new THREE.Vector3(-Math.sin(yaw), 0, -Math.cos(yaw)); const fwd = new THREE.Vector3(-Math.sin(yaw), 0, -Math.cos(yaw));
const rgt = new THREE.Vector3(Math.cos(yaw), 0, -Math.sin(yaw)); const rgt = new THREE.Vector3(Math.cos(yaw), 0, -Math.sin(yaw));
@@ -921,16 +2932,30 @@ function updatePlayer(dt) {
if (m.lengthSq() > 0) m.normalize().multiplyScalar(SPEED); if (m.lengthSq() > 0) m.normalize().multiplyScalar(SPEED);
player.vel.x = m.x; player.vel.z = m.z; player.vel.x = m.x; player.vel.z = m.z;
player.vel.y -= GRAVITY * dt; player.vel.y -= GRAVITY * dt;
if (player.vel.y < -MAX_FALL) player.vel.y = -MAX_FALL; // 限制终端速度,防止高速穿透方块
if (keys['Space'] && player.onGround) { player.vel.y = JUMP; player.onGround = false; } if (keys['Space'] && player.onGround) { player.vel.y = JUMP; player.onGround = false; }
const h = P_W / 2; const h = P_W / 2;
player.pos.x += player.vel.x * dt; if (collide(player.pos, h)) { player.pos.x -= player.vel.x * dt; player.vel.x = 0; } player.pos.x += player.vel.x * dt; if (collide(player.pos, h)) { player.pos.x -= player.vel.x * dt; player.vel.x = 0; }
player.pos.z += player.vel.z * dt; if (collide(player.pos, h)) { player.pos.z -= player.vel.z * dt; player.vel.z = 0; } player.pos.z += player.vel.z * dt; if (collide(player.pos, h)) { player.pos.z -= player.vel.z * dt; player.vel.z = 0; }
player.pos.y += player.vel.y * dt; // 垂直移动分步(≤0.5格/步),保证任何帧率下都不会穿透方块
if (collide(player.pos, h)) { let dy = player.vel.y * dt;
if (player.vel.y < 0) player.onGround = true; while (dy !== 0) {
player.pos.y -= player.vel.y * dt; player.vel.y = 0; const s = Math.sign(dy) * Math.min(Math.abs(dy), 0.5);
} else player.onGround = false; player.pos.y += s;
if (collide(player.pos, h)) {
if (s < 0) {
// 落地:精确贴到方块顶面,消除穿透与贴地抖动
player.onGround = true;
player.pos.y = Math.floor(player.pos.y) + 1;
} else {
// 撞头:回退本步位移
player.pos.y -= s;
}
player.vel.y = 0;
dy = 0;
} else dy -= s;
}
if (player.pos.y < -20) { player.pos.set(0, 15, 0); player.vel.set(0,0,0); } if (player.pos.y < -20) { player.pos.set(0, 15, 0); player.vel.set(0,0,0); }
} }
@@ -960,13 +2985,30 @@ function updateHighlight() {
const t = getTarget(); const t = getTarget();
if (t) { if (t) {
const b = getBlock(t.x, t.y, t.z); const b = getBlock(t.x, t.y, t.z);
const yOff = (b && b.mesh.userData.yOffset) || 0; const yOff = (b && b.mesh && b.mesh.userData) ? (b.mesh.userData.yOffset || 0) : 0;
highlight.position.set(t.x+0.5, t.y+0.5+yOff, t.z+0.5); highlight.position.set(t.x+0.5, t.y+0.5+yOff, t.z+0.5);
highlight.visible = true; highlight.visible = true;
if (b) document.getElementById('looking').textContent = getDef(b.type).name; // 破坏进度显示:方块变暗
if (breakingBlock && breakingBlock.x === t.x && breakingBlock.y === t.y && breakingBlock.z === t.z) {
const p = breakingBlock.progress;
highlight.material.color.setRGB(0.2, 0.2, 0.2);
highlight.material.opacity = 0.3 + p * 0.5;
} else {
highlight.material.color.setRGB(0, 0, 0);
highlight.material.opacity = 1;
}
if (b) {
if (updateHighlight._lastName !== b.type) {
document.getElementById('looking').textContent = getDef(b.type).name;
updateHighlight._lastName = b.type;
}
}
} else { } else {
highlight.visible = false; highlight.visible = false;
document.getElementById('looking').textContent = '-'; if (updateHighlight._lastName !== '-') {
document.getElementById('looking').textContent = '-';
updateHighlight._lastName = '-';
}
} }
} }
@@ -976,10 +3018,26 @@ function showToast(text) {
clearTimeout(showToast._t); showToast._t = setTimeout(() => t.classList.remove('show'), 1200); clearTimeout(showToast._t); showToast._t = setTimeout(() => t.classList.remove('show'), 1200);
} }
function updateLoadProgress(pct) { function updateLoadProgress(pct, msg) {
document.getElementById('loadFill').style.width = pct + '%'; document.getElementById('loadFill').style.width = pct + '%';
if (msg) {
document.getElementById('loadMsg').textContent = msg;
document.getElementById('loadSub').textContent = pct + '%';
}
if (pct >= 100) setTimeout(() => { document.getElementById('loading').style.display = 'none'; }, 300); if (pct >= 100) setTimeout(() => { document.getElementById('loading').style.display = 'none'; }, 300);
} }
function debugHUD(msg) {
// 仅输出到控制台,不再写 DOM(避免高频日志导致卡顿)
if (window.DEBUG_HUD) {
var hud = document.getElementById('debugHUD');
if (hud) {
hud.style.display = 'block';
hud.innerHTML += '<div>[' + new Date().toLocaleTimeString() + '] ' + msg + '</div>';
if (hud.children.length > 20) hud.removeChild(hud.firstChild);
}
}
if (window.DEBUG_CONSOLE) console.log('[RC] ' + msg);
}
// ==================== 主循环 ==================== // ==================== 主循环 ====================
function gameLoop(now) { function gameLoop(now) {
@@ -987,26 +3045,137 @@ function gameLoop(now) {
lastT = now; lastT = now;
frames++; fpsT += dt; frames++; fpsT += dt;
if (fpsT >= 0.5) { fps = Math.round(frames / fpsT); frames = 0; fpsT = 0; } if (fpsT >= 0.5) { fps = Math.round(frames / fpsT); frames = 0; fpsT = 0; }
if (document.pointerLockElement === renderer.domElement) updatePlayer(dt); // 模式目标检查
checkModeObjective();
// 限时模式倒计时
if (currentGameMode === 'multiplayer' && !puzzleObjective?.completed) {
const remaining = Math.max(0, timeAttackDuration - Math.floor((now - timeAttackStart) / 1000));
const m = Math.floor(remaining/60), s = remaining%60;
document.getElementById('modeHUD-timer').textContent = m+':'+String(s).padStart(2,'0');
document.getElementById('modeHUD-score').textContent = '⚡ ' + remaining + 's';
if (remaining <= 0 && !puzzleObjective.completed) {
puzzleObjective.completed = true;
document.getElementById('modeHUD-timer').textContent = '0:00';
showToast('时间到!');
}
}
// 破坏进度更新
if (breakingBlock && gameActive) {
const b = getBlock(breakingBlock.x, breakingBlock.y, breakingBlock.z);
if (!b) { breakingBlock = null; }
else {
const elapsed = now - breakStartTime;
breakingBlock.progress = Math.min(elapsed / BREAK_TIME, 1);
if (elapsed >= BREAK_TIME) finishBreak();
}
}
if (gameActive) updatePlayer(dt);
updateCamera(); updateHighlight(); updateCamera(); updateHighlight();
document.getElementById('pos').textContent = `${player.pos.x.toFixed(1)}, ${player.pos.y.toFixed(1)}, ${player.pos.z.toFixed(1)}`; // HUD 信息节流更新:每 10 帧写一次 DOM,避免每帧布局抖动
document.getElementById('bcount').textContent = world.size; if (frames % 10 === 0) {
document.getElementById('fps').textContent = fps; document.getElementById('pos').textContent = `${player.pos.x.toFixed(1)}, ${player.pos.y.toFixed(1)}, ${player.pos.z.toFixed(1)}`;
document.getElementById('bcount').textContent = world.size;
document.getElementById('fps').textContent = fps;
}
// 每 30 秒自动存档 (无限地形下 world 会增长,降低频率避免序列化卡顿)
if (Math.floor(now / 30000) !== Math.floor(lastSaveTime / 30000)) { saveWorld(); lastSaveTime = now; }
// 无限地形:仅在玩家跨区块时加载新区块 (避免每帧全量扫描)
if (window._worldLoaded && frames % 5 === 0) {
var ccx = Math.floor(player.pos.x / CHUNK_SIZE);
var ccz = Math.floor(player.pos.z / CHUNK_SIZE);
if (ccx !== lastPlayerChunkX || ccz !== lastPlayerChunkZ) {
lastPlayerChunkX = ccx; lastPlayerChunkZ = ccz;
ensureTerrainAround(player.pos.x, player.pos.z);
}
}
renderer.render(scene, camera); renderer.render(scene, camera);
requestAnimationFrame(gameLoop); requestAnimationFrame(gameLoop);
} }
let lastSaveTime = 0;
let lastPlayerChunkX = 0, lastPlayerChunkZ = 0;
// ==================== 启动 ==================== // ==================== 启动 ====================
function init() { // 背景图片绘制
scene = new THREE.Scene(); function drawMCBackground() {
scene.background = new THREE.Color(0x88aacc); var c = document.getElementById('bgCanvas');
scene.fog = new THREE.Fog(0x88aacc, 25, 70); if (!c) return;
c.width = window.innerWidth; c.height = window.innerHeight;
var ctx = c.getContext('2d');
var img = new Image();
img.onload = function() {
// 按 cover 模式绘制:等比缩放并居中裁剪
var iw = img.width, ih = img.height;
var cw = c.width, ch = c.height;
var scale = Math.max(cw / iw, ch / ih);
var sw = iw * scale, sh = ih * scale;
var sx = (cw - sw) / 2, sy = (ch - sh) / 2;
ctx.drawImage(img, sx, sy, sw, sh);
};
img.src = 'background.jpg';
}
camera = new THREE.PerspectiveCamera(75, innerWidth/innerHeight, 0.1, 200); function init() {
renderer = new THREE.WebGLRenderer({ antialias: false }); debugHUD('init() 开始');
updateLoadProgress(2, '引擎初始化中...');
// WebGL 诊断:如果之前检测到 WebGL 不可用,显示错误页
if (window._webglFailed) {
updateLoadProgress(100, 'WebGL 不可用');
document.getElementById('loading').innerHTML =
'<div style="color:#F85149;text-align:center;max-width:400px">'+
'<h2>WebGL 不可用</h2>'+
'<p>你的显卡或 WebView2 环境不支持 WebGL。</p>'+
'<p style="font-size:12px;color:#888">'+(window._diagReport||[]).join('<br>')+'</p>'+
'<button onclick="this.parentElement.style.display=\'none\'" style="margin-top:12px;padding:8px 20px;background:#E83229;color:#fff;border:none;border-radius:2px;cursor:pointer">关闭</button>'+
'</div>';
return;
}
// 创建场景(初始透明,让背景图透过)
scene = new THREE.Scene();
scene.background = null;
camera = new THREE.PerspectiveCamera(65, innerWidth/innerHeight, 0.1, 100);
updateCamera();
debugHUD('Scene created (transparent bg)');
updateLoadProgress(5, '创建渲染器...');
// 创建渲染器(多级降级)
renderer = null;
var renderOpts = [
{ antialias: false, alpha: true, premultipliedAlpha: false, powerPreference: 'default', failIfMajorPerformanceCaveat: false },
{ antialias: false, alpha: true, powerPreference: 'low-power' },
{ antialias: false, alpha: true },
{ alpha: true }
];
for (var i = 0; i < renderOpts.length; i++) {
try {
renderer = new THREE.WebGLRenderer(renderOpts[i]);
debugHUD('WebGLRenderer OK (opts#'+i+')');
break;
} catch(e) {
debugHUD('Renderer FAIL #'+i+': '+e.message);
}
}
if (!renderer) {
updateLoadProgress(100, '渲染器初始化失败');
debugHUD('FATAL: No WebGLRenderer');
document.getElementById('loading').innerHTML =
'<div style="color:#F85149;text-align:center">'+
'<h2>3D渲染器初始化失败</h2>'+
'<p>你的 WebView2 环境不支持 WebGL。</p>'+
'<p style="font-size:12px;color:#888">请更新显卡驱动或安装 WebView2 Runtime</p>'+
'<button onclick="this.parentElement.style.display=\'none\'" style="margin-top:12px;padding:8px 20px;background:#E83229;color:#fff;border:none;border-radius:2px;cursor:pointer">关闭</button>'+
'</div>';
return;
}
renderer.setSize(innerWidth, innerHeight); renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5)); renderer.setPixelRatio(Math.min(devicePixelRatio, 1.0));
renderer.domElement.style.position = 'fixed';
renderer.domElement.style.inset = '0';
renderer.domElement.style.zIndex = '1';
document.body.appendChild(renderer.domElement); document.body.appendChild(renderer.domElement);
debugHUD('Canvas appended');
scene.add(new THREE.AmbientLight(0xffffff, 0.7)); scene.add(new THREE.AmbientLight(0xffffff, 0.7));
const dl = new THREE.DirectionalLight(0xffffff, 0.5); const dl = new THREE.DirectionalLight(0xffffff, 0.5);
@@ -1014,25 +3183,132 @@ function init() {
scene.add(new THREE.HemisphereLight(0xaaccff, 0x445533, 0.3)); scene.add(new THREE.HemisphereLight(0xaaccff, 0x445533, 0.3));
raycaster = new THREE.Raycaster(); raycaster = new THREE.Raycaster();
const hl = new THREE.Mesh( const hlMat = new THREE.MeshBasicMaterial({ color: 0x000000, wireframe: true, transparent: true, opacity: 0.5, depthTest: false });
new THREE.BoxGeometry(1.02, 1.02, 1.02), const hl = new THREE.Mesh(new THREE.BoxGeometry(1.02, 1.02, 1.02), hlMat);
new THREE.MeshBasicMaterial({ color: 0x000000, wireframe: true })
);
hl.visible = false; scene.add(hl); highlight = hl; hl.visible = false; scene.add(hl); highlight = hl;
// 异步生成世界 (避免阻塞) // 首次渲染
updateLoadProgress(10); try { renderer.render(scene, camera); debugHUD('First render OK'); }
setTimeout(() => { catch(e) { debugHUD('First render FAIL: '+e.message); }
updateLoadProgress(30);
initInventoryData(); // 设置事件
generateTerrain(); setupEvents();
createHotbar();
setupEvents(); // 启动渲染循环(没有世界的状态下正常渲染天空背景)
requestAnimationFrame(gameLoop); requestAnimationFrame(gameLoop);
}, 50); updateLoadProgress(100, '就绪 · 点击开始游戏');
debugHUD('=== READY (waiting for start) ===');
} }
// 开始游戏时调用:生成地形、构建世界
function startWorld() {
if (window._worldLoaded) return;
window._worldLoaded = true;
debugHUD('startWorld() called');
document.getElementById('loading').style.display = 'flex';
updateLoadProgress(10, '正在生成世界...');
setTimeout(() => {
try {
updateLoadProgress(25, '初始化物品数据...');
debugHUD('initInventoryData...');
initInventoryData();
debugHUD('inventory done');
updateLoadProgress(30, '生成地形...');
if (!loadWorld()) {
debugHUD('generateInitialTerrain...');
ensureTerrainAround(0, 0);
debugHUD('initial terrain done');
} else {
debugHUD('world loaded from save');
}
// 设置天空背景(扩大雾距适应高层世界)
scene.background = new THREE.Color(0x88aacc);
scene.fog = new THREE.Fog(0x88aacc, 64, 256);
debugHUD('sky background set');
updateLoadProgress(80, '构建快捷栏...');
createHotbar();
debugHUD('hotbar done');
updateLoadProgress(100, '就绪!');
debugHUD('=== WORLD READY ===');
window._worldLoaded = true;
// 隐藏加载屏,进入游戏
setTimeout(function(){
document.getElementById('loading').style.display = 'none';
document.getElementById('overlay').style.display = 'none';
document.getElementById('menuParticles').innerHTML = '';
document.getElementById('modeHUD').style.display = 'flex';
updateModeHUD();
// 确保出生点不在方块内部,否则会被卡住无法移动
ensurePlayerFree();
gameActive = true;
try {
const p = renderer.domElement.requestPointerLock();
// 若锁定失败,稍后检测并提示拖动视角
setTimeout(() => {
if (gameActive && document.pointerLockElement !== renderer.domElement) {
showToast('按住鼠标拖动旋转视角 · WASD 移动');
}
}, 400);
} catch(e) {
showToast('按住鼠标拖动旋转视角 · WASD 移动');
}
}, 500);
} catch(e) {
debugHUD('FATAL: '+e.message);
updateLoadProgress(100, '错误: '+e.message);
document.getElementById('loading').innerHTML = '世界生成失败: ' + e.message + '<br><button onclick="this.parentElement.style.display=\'none\'" style="margin-top:12px;padding:8px 20px;background:#E83229;color:#fff;border:none;border-radius:2px;cursor:pointer">继续</button>';
}
}, 50);
// 超时强制显示
setTimeout(function(){
var ld = document.getElementById('loading');
if (ld && ld.style.display !== 'none') {
debugHUD('TIMEOUT: force hide loading');
ld.style.display = 'none';
}
}, 30000);
}
drawMCBackground();
init(); init();
// SSO 自动登录:从 URL 参数读取启动器传来的 token
(function(){
var p = new URLSearchParams(location.search);
var token = p.get('token');
var username = p.get('username');
if (token && username && p.get('autoLogin') === '1') {
// 先设置认证状态,然后用 /me 验证 token
authState.token = token;
authState.user = { username: username };
authState.loggedIn = true;
authState.offline = false;
// 异步验证 token 有效性
fetch(AUTH_URL + '/me', {
headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) { return r.json(); })
.then(function(d) {
if (d.code === 0 && d.data) {
authState.user = d.data;
authState.loggedIn = true;
showPanel('hub');
}
}).catch(function() {
// token 验证失败,保持基本状态(用户名至少能用离线模式)
showPanel('hub');
});
// 先直接跳到 hub,不等验证结果
setTimeout(function() { showPanel('hub'); }, 500);
}
})();
</script> </script>
</body> </body>
</html> </html>
+131
View File
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>WebGL Test</title>
<style>
body{background:#0a0a14;color:#fff;font-family:Consolas,monospace;padding:20px;margin:0}
h2{color:#E83229;margin-top:0}
.pass{color:#3FB950}.fail{color:#F85149}.warn{color:#D29922}.info{color:#58A6FF}
#results{margin-bottom:20px;line-height:1.6}
canvas{display:block;margin:10px 0;border:2px solid #333}
</style></head><body>
<h2>WebView2 / WebGL 完整诊断</h2>
<div id="results"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
var r=document.getElementById('results');
function log(msg,cls){r.innerHTML+='<div class="'+(cls||'')+'">'+msg+'</div>';}
// 1. 环境检测
log('=== 1. 环境检测 ===','info');
log('UserAgent: '+navigator.userAgent,'info');
log('Platform: '+navigator.platform,'info');
log('HardwareConcurrency: '+(navigator.hardwareConcurrency||'N/A'),'info');
log('DeviceMemory: '+(navigator.deviceMemory||'N/A')+'GB','info');
// 2. Three.js
log('=== 2. Three.js ===','info');
if(typeof THREE!=='undefined'){
log('加载成功 v'+THREE.REVISION,'pass');
}else{
log('加载失败!','fail');
throw new Error('THREE not loaded');
}
// 3. WebGL Context
log('=== 3. WebGL Context ===','info');
var names=['webgl2','webgl','experimental-webgl'];
var c=document.createElement('canvas');
var gl=null, glName='';
for(var i=0;i<names.length;i++){
try{
gl=c.getContext(names[i],{failIfMajorPerformanceCaveat:false});
if(gl){glName=names[i];break;}
}catch(e){log(names[i]+': FAIL - '+e.message,'fail');}
}
if(gl){
log('Context: '+glName+' OK','pass');
log('Renderer: '+gl.getParameter(gl.RENDERER));
log('Vendor: '+gl.getParameter(gl.VENDOR));
log('MaxTextureSize: '+gl.getParameter(gl.MAX_TEXTURE_SIZE));
log('MaxRenderbufferSize: '+gl.getParameter(gl.MAX_RENDERBUFFER_SIZE));
var ext=gl.getExtension('WEBGL_debug_renderer_info');
if(ext)log('GPU: '+gl.getParameter(ext.UNMASKED_RENDERER_WEBGL));
}else{
log('FATAL: WebGL完全不可用','fail');
}
// 4. THREE.WebGLRenderer
log('=== 4. THREE.WebGLRenderer ===','info');
var renderer=null;
var optsList=[
{antialias:false,powerPreference:'default',failIfMajorPerformanceCaveat:false},
{antialias:false,powerPreference:'low-power'},
{antialias:false},
{}
];
for(var i=0;i<optsList.length;i++){
try{
renderer=new THREE.WebGLRenderer(optsList[i]);
log('创建成功 (opts#'+i+'): '+JSON.stringify(optsList[i]),'pass');
break;
}catch(e){
log('opts#'+i+' FAIL: '+e.message,'fail');
}
}
if(!renderer){
log('FATAL: WebGLRenderer 创建失败','fail');
}else{
// 5. 渲染测试
log('=== 5. 渲染测试 ===','info');
try{
renderer.setSize(500,350);
renderer.setPixelRatio(1);
var scene=new THREE.Scene();
// 品红色背景 - 如果看到品红色说明渲染正常
scene.background=new THREE.Color(0xFF00FF);
var camera=new THREE.PerspectiveCamera(60,500/350,0.1,100);
camera.position.set(3,2.5,5);
camera.lookAt(0,0,0);
// 红色方块
var boxGeo=new THREE.BoxGeometry(1,1,1);
var boxMat=new THREE.MeshLambertMaterial({color:0xE83229});
var box=new THREE.Mesh(boxGeo,boxMat);
box.position.y=0.5;
scene.add(box);
// 绿色地面
var planeGeo=new THREE.PlaneGeometry(8,8);
var planeMat=new THREE.MeshLambertMaterial({color:0x33AA33});
var plane=new THREE.Mesh(planeGeo,planeMat);
plane.rotation.x=-Math.PI/2;
plane.position.y=-1;
scene.add(plane);
scene.add(new THREE.AmbientLight(0xffffff,0.6));
var light=new THREE.DirectionalLight(0xffffff,0.5);
light.position.set(2,5,3);
scene.add(light);
renderer.render(scene,camera);
document.body.appendChild(renderer.domElement);
log('渲染完成!', 'pass');
log('你应该看到: 品红色背景 + 绿色平面 + 红色方块', 'info');
}catch(e){
log('渲染失败: '+e.message,'fail');
}
}
// 6. 总结
log('=== 6. 总结 ===','info');
if(renderer){
log('WebGL 3D渲染正常工作!','pass');
}else if(gl){
log('WebGL可用但THREE渲染器创建失败','warn');
}else{
log('WebGL不可用 - 请检查显卡驱动和WebView2 Runtime','fail');
}
</script>
</body></html>
+23
View File
@@ -0,0 +1,23 @@
[23:51:26.073] === RedCircuit 开发者模式 ===
[23:51:26.099] 工作目录: D:\Code\MRCC
[23:51:26.104] EXE目录: D:\Code\MRCC\build\
[23:51:26.161] 游戏目录: D:\Code\MRCC\build\game
[23:51:26.194] HTTP Server: http://localhost:55698/
[23:51:26.205] 游戏URL: http://localhost:55698/voxel-world.html
[23:51:26.336] 游戏窗口已创建
[23:51:26.648] === 游戏窗口 OnLoaded ===
[23:51:26.649] URL: http://localhost:55698/voxel-world.html
[23:51:26.652] WebView2 Runtime: 148.0.3967.96
[23:51:26.652] 初始化 WebView2...
[23:51:26.917] WebView2 初始化完成
[23:51:26.918] 导航到: http://localhost:55698/voxel-world.html
[23:51:26.944] 导航开始: http://localhost:55698/voxel-world.html
[23:51:27.205] 导航完成: HTTP 200
[23:51:27.207] 注入监控脚本...
[23:51:27.224] 监控脚本已注入
[23:51:29.269] [JS] "THREE:loaded"
[23:51:29.270] [JS] "WEBGL:available"
[23:51:29.281] [JS] "WEBGL_CTX:created"
[23:51:32.261] [JS] "WORLD:meshes=N/A scene_children=17"
[23:51:32.263] [JS] "FALLBACK:ground_added"
[23:51:50.952] 游戏窗口关闭,停止HTTP服务器
+20 -5
View File
@@ -2,7 +2,9 @@ package main
import ( import (
"log" "log"
"os"
"mrcc/internal/auth"
"mrcc/internal/config" "mrcc/internal/config"
"mrcc/internal/httpserver" "mrcc/internal/httpserver"
"mrcc/internal/logger" "mrcc/internal/logger"
@@ -14,12 +16,25 @@ func main() {
srv := httpserver.New(cfg) srv := httpserver.New(cfg)
// TODO: 注册认证路由 // JWT 密钥:默认开发密钥,生产环境通过 AUTH_JWT_SECRET 覆盖
// r := srv.Group("/api/auth") secret := os.Getenv("AUTH_JWT_SECRET")
// r.POST("/register", ...) if secret == "" {
// r.POST("/login", ...) secret = "mrcc-dev-secret-change-me"
}
log.Printf("auth-service listening on %s", cfg.Address()) store := auth.NewUserStore()
jwtMgr := auth.NewJWTManager(secret)
h := auth.NewHandler(store, jwtMgr)
// 认证路由: /api/auth/register|login|me
authGroup := srv.Group("/api/auth")
h.RegisterAuthRoutes(authGroup)
// 经济路由: /api/daily-reward|spend|buy|use-item
apiGroup := srv.Group("/api")
h.RegisterEconomyRoutes(apiGroup)
log.Printf("auth-service listening on %s (endpoints: /api/auth/*, /api/daily-reward ...)", cfg.Address())
if err := srv.Run(cfg.Address()); err != nil { if err := srv.Run(cfg.Address()); err != nil {
log.Fatal(err) log.Fatal(err)
} }
+3 -2
View File
@@ -4,7 +4,9 @@ go 1.22
require ( require (
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.23.0
) )
require ( require (
@@ -22,13 +24,12 @@ require (
github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect golang.org/x/text v0.15.0 // indirect
+93
View File
@@ -0,0 +1,93 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+324
View File
@@ -0,0 +1,324 @@
package auth
import (
"errors"
"fmt"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
// User 用户数据
type User struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"` // 不返回给客户端
CreatedAt int64 `json:"created_at"`
Level int `json:"level"`
Exp int `json:"exp"`
Diamonds int `json:"diamonds"`
RedstoneCoins int64 `json:"redstone_coins"`
GoldCoins int `json:"gold_coins"`
LastLoginDate string `json:"last_login_date"`
Inventory map[string]int `json:"inventory"` // itemId → count
EquippedItem string `json:"equipped_item"` // 当前装备的道具ID
SpeedBoostUntil int64 `json:"speed_boost_until"` // 速度加成到期时间(unix)
PurchaseHistory []PurchaseRecord `json:"purchase_history"` // 最近50条购买记录
}
// PurchaseRecord 购买记录
type PurchaseRecord struct {
ItemID string `json:"item_id"`
ItemName string `json:"item_name"`
Currency string `json:"currency"`
Amount int64 `json:"amount"`
Timestamp int64 `json:"timestamp"`
}
// UserStore 内存用户存储 (后续可替换为 PostgreSQL)
type UserStore struct {
mu sync.RWMutex
users map[string]*User // key = username (lowercase)
}
func NewUserStore() *UserStore {
return &UserStore{users: make(map[string]*User)}
}
func (s *UserStore) Create(username, password string) (*User, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.users[username]; exists {
return nil, errors.New("用户名已存在")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("密码加密失败: %w", err)
}
now := time.Now()
user := &User{
ID: fmt.Sprintf("u_%d", now.UnixNano()),
Username: username,
PasswordHash: string(hash),
CreatedAt: now.Unix(),
Level: 1,
Exp: 0,
Diamonds: 20,
RedstoneCoins: 3000,
GoldCoins: 1,
LastLoginDate: "", // 留空:注册当天可正常签到领奖
Inventory: make(map[string]int),
PurchaseHistory: []PurchaseRecord{},
}
s.users[username] = user
return user, nil
}
func (s *UserStore) GetByUsername(username string) (*User, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
u, ok := s.users[username]
return u, ok
}
func (s *UserStore) GetByID(id string) (*User, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, u := range s.users {
if u.ID == id {
return u, true
}
}
return nil, false
}
func (s *UserStore) VerifyPassword(username, password string) (*User, error) {
user, ok := s.GetByUsername(username)
if !ok {
return nil, errors.New("用户不存在")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, errors.New("密码错误")
}
return user, nil
}
// DailyLogin 每日登录奖励。返回奖励详情和是否已领取。
func (s *UserStore) DailyLogin(userID string) (map[string]interface{}, error) {
s.mu.Lock()
defer s.mu.Unlock()
var user *User
for _, u := range s.users {
if u.ID == userID {
user = u
break
}
}
if user == nil {
return nil, errors.New("用户不存在")
}
today := time.Now().Format("2006-01-02")
if user.LastLoginDate == today {
return map[string]interface{}{
"claimed": true,
"message": "今日已领取",
}, nil
}
// 发放奖励
const rewardDiamonds = 20
const rewardRedstoneCoins = 3000
const rewardGoldCoins = 1
user.Diamonds += rewardDiamonds
user.RedstoneCoins += rewardRedstoneCoins
user.GoldCoins += rewardGoldCoins
user.LastLoginDate = today
return map[string]interface{}{
"claimed": false,
"message": "领取成功",
"diamonds": rewardDiamonds,
"redstone": rewardRedstoneCoins,
"gold": rewardGoldCoins,
"total_diamonds": user.Diamonds,
"total_redstone": user.RedstoneCoins,
"total_gold": user.GoldCoins,
}, nil
}
// SpendCurrency 消费货币,返回成功或余额不足错误
func (s *UserStore) SpendCurrency(userID, currency string, amount int64) error {
s.mu.Lock()
defer s.mu.Unlock()
var user *User
for _, u := range s.users {
if u.ID == userID {
user = u
break
}
}
if user == nil {
return errors.New("用户不存在")
}
switch currency {
case "diamonds":
if user.Diamonds < int(amount) {
return fmt.Errorf("钻石不足,当前: %d,需要: %d", user.Diamonds, amount)
}
user.Diamonds -= int(amount)
case "redstone_coins":
if user.RedstoneCoins < amount {
return fmt.Errorf("红石币不足,当前: %d,需要: %d", user.RedstoneCoins, amount)
}
user.RedstoneCoins -= amount
case "gold_coins":
if user.GoldCoins < int(amount) {
return fmt.Errorf("金币不足,当前: %d,需要: %d", user.GoldCoins, amount)
}
user.GoldCoins -= int(amount)
default:
return fmt.Errorf("未知货币类型: %s", currency)
}
return nil
}
// BuyItem 购买商品 (货币扣款 + 库存追踪 + 交易记录)
func (s *UserStore) BuyItem(userID, itemID, itemName, currency string, amount int64, quantity int) (map[string]interface{}, error) {
s.mu.Lock()
defer s.mu.Unlock()
var user *User
for _, u := range s.users { if u.ID == userID { user = u; break } }
if user == nil { return nil, errors.New("用户不存在") }
switch currency {
case "diamonds": if user.Diamonds < int(amount) { return nil, fmt.Errorf("钻石不足") }; user.Diamonds -= int(amount)
case "redstone_coins": if user.RedstoneCoins < amount { return nil, fmt.Errorf("红石币不足") }; user.RedstoneCoins -= amount
case "gold_coins": if user.GoldCoins < int(amount) { return nil, fmt.Errorf("金币不足") }; user.GoldCoins -= int(amount)
default: return nil, fmt.Errorf("未知货币")
}
if user.Inventory == nil { user.Inventory = make(map[string]int) }
user.Inventory[itemID] += quantity
if itemID == "speed_boost" { user.SpeedBoostUntil = time.Now().Unix() + 3600 }
rec := PurchaseRecord{ItemID: itemID, ItemName: itemName, Currency: currency, Amount: amount, Timestamp: time.Now().Unix()}
user.PurchaseHistory = append([]PurchaseRecord{rec}, user.PurchaseHistory...)
if len(user.PurchaseHistory) > 50 { user.PurchaseHistory = user.PurchaseHistory[:50] }
return map[string]interface{}{"message": "购买成功", "item_id": itemID, "quantity": quantity, "user": userToMap(user)}, nil
}
// UseItem 使用道具
func (s *UserStore) UseItem(userID, itemID string) (map[string]interface{}, error) {
s.mu.Lock(); defer s.mu.Unlock()
var user *User
for _, u := range s.users { if u.ID == userID { user = u; break } }
if user == nil { return nil, errors.New("用户不存在") }
if user.Inventory == nil || user.Inventory[itemID] <= 0 { return nil, errors.New("道具数量不足") }
user.Inventory[itemID]--
msg := "使用成功"
switch itemID {
case "speed_boost":
user.SpeedBoostUntil = time.Now().Unix() + 3600
user.EquippedItem = itemID
msg = "速度加成已激活(1小时)"
case "exp_boost":
user.Exp += 500
if user.Exp >= user.Level*1000 {
user.Level++
user.Exp -= (user.Level - 1) * 1000
msg = fmt.Sprintf("升级! Lv.%d", user.Level)
} else {
msg = "获得500经验值"
}
case "diamond_pack":
user.Diamonds += 10
msg = "获得10钻石"
case "gold_pack":
user.GoldCoins += 5
msg = "获得5金币"
case "rs_pack":
user.RedstoneCoins += 10000
msg = "获得10000红石币"
}
return map[string]interface{}{"message": msg, "user": userToMap(user)}, nil
}
// userToMap 将 User 转换为返回给客户端的 map
func userToMap(u *User) map[string]interface{} {
inv := u.Inventory
if inv == nil { inv = make(map[string]int) }
hist := u.PurchaseHistory
if hist == nil { hist = []PurchaseRecord{} }
return map[string]interface{}{
"id": u.ID,
"username": u.Username,
"level": u.Level,
"exp": u.Exp,
"diamonds": u.Diamonds,
"redstone_coins": u.RedstoneCoins,
"gold_coins": u.GoldCoins,
"last_login_date": u.LastLoginDate,
"created_at": u.CreatedAt,
"inventory": inv,
"equipped_item": u.EquippedItem,
"speed_boost_until": u.SpeedBoostUntil,
"purchase_history": hist,
}
}
// ==================== JWT ====================
type JWTManager struct {
secretKey []byte
expires time.Duration
}
func NewJWTManager(secret string) *JWTManager {
return &JWTManager{
secretKey: []byte(secret),
expires: 24 * time.Hour,
}
}
type Claims struct {
UserID string `json:"uid"`
Username string `json:"username"`
jwt.RegisteredClaims
}
func (m *JWTManager) Generate(user *User) (string, error) {
claims := &Claims{
UserID: user.ID,
Username: user.Username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.expires)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(m.secretKey)
}
func (m *JWTManager) Verify(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return m.secretKey, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
+225
View File
@@ -0,0 +1,225 @@
package auth
import (
"net/http"
"strings"
"mrcc/pkg/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
store *UserStore
jwt *JWTManager
}
func NewHandler(store *UserStore, jwtMgr *JWTManager) *Handler {
return &Handler{store: store, jwt: jwtMgr}
}
// RegisterAuthRoutes 注册认证路由 (/api/auth)
func (h *Handler) RegisterAuthRoutes(r *gin.RouterGroup) {
r.POST("/register", h.Register)
r.POST("/login", h.Login)
r.GET("/me", h.AuthMiddleware(), h.Me)
}
// RegisterEconomyRoutes 注册经济路由 (/api)
func (h *Handler) RegisterEconomyRoutes(r *gin.RouterGroup) {
r.POST("/daily-reward", h.AuthMiddleware(), h.DailyReward)
r.POST("/spend", h.AuthMiddleware(), h.Spend)
r.POST("/buy", h.AuthMiddleware(), h.Buy)
r.POST("/use-item", h.AuthMiddleware(), h.UseItem)
}
// RegisterRoutes 注册全部路由(兼容旧调用)
func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
h.RegisterAuthRoutes(r)
h.RegisterEconomyRoutes(r)
}
type registerReq struct {
Username string `json:"username" binding:"required,min=3,max=20"`
Password string `json:"password" binding:"required,min=6,max=50"`
}
// Register 用户注册
func (h *Handler) Register(c *gin.Context) {
var req registerReq
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "参数无效: "+err.Error())
return
}
user, err := h.store.Create(req.Username, req.Password)
if err != nil {
response.Error(c, http.StatusConflict, err.Error())
return
}
token, err := h.jwt.Generate(user)
if err != nil {
response.Error(c, http.StatusInternalServerError, "生成令牌失败")
return
}
response.OK(c, gin.H{
"token": token,
"user": userToMap(user),
})
}
// Login 用户登录
func (h *Handler) Login(c *gin.Context) {
var req registerReq
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "参数无效: "+err.Error())
return
}
user, err := h.store.VerifyPassword(req.Username, req.Password)
if err != nil {
response.Error(c, http.StatusUnauthorized, err.Error())
return
}
token, err := h.jwt.Generate(user)
if err != nil {
response.Error(c, http.StatusInternalServerError, "生成令牌失败")
return
}
response.OK(c, gin.H{
"token": token,
"user": userToMap(user),
})
}
// Me 获取当前用户完整信息
func (h *Handler) Me(c *gin.Context) {
claims, exists := c.Get("claims")
if !exists {
response.Error(c, http.StatusUnauthorized, "未认证")
return
}
cl := claims.(*Claims)
user, ok := h.store.GetByID(cl.UserID)
if !ok {
response.Error(c, http.StatusNotFound, "用户不存在")
return
}
response.OK(c, userToMap(user))
}
// DailyReward 每日登录奖励
func (h *Handler) DailyReward(c *gin.Context) {
claims, exists := c.Get("claims")
if !exists {
response.Error(c, http.StatusUnauthorized, "未认证")
return
}
cl := claims.(*Claims)
result, err := h.store.DailyLogin(cl.UserID)
if err != nil {
response.Error(c, http.StatusInternalServerError, err.Error())
return
}
response.OK(c, result)
}
type spendReq struct {
Currency string `json:"currency" binding:"required"`
Amount int64 `json:"amount" binding:"required,min=1"`
}
// Spend 消费货币
func (h *Handler) Spend(c *gin.Context) {
claims, exists := c.Get("claims")
if !exists {
response.Error(c, http.StatusUnauthorized, "未认证")
return
}
cl := claims.(*Claims)
var req spendReq
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "参数无效: "+err.Error())
return
}
if err := h.store.SpendCurrency(cl.UserID, req.Currency, req.Amount); err != nil {
response.Error(c, http.StatusBadRequest, err.Error())
return
}
// 返回最新余额
user, _ := h.store.GetByID(cl.UserID)
response.OK(c, gin.H{
"message": "消费成功",
"currency": req.Currency,
"amount": req.Amount,
"user": userToMap(user),
})
}
type buyReq struct {
ItemID string `json:"item_id" binding:"required"`
ItemName string `json:"item_name" binding:"required"`
Currency string `json:"currency" binding:"required"`
Amount int64 `json:"amount" binding:"required,min=1"`
Quantity int `json:"quantity"`
}
// Buy 购买商品 (含库存追踪)
func (h *Handler) Buy(c *gin.Context) {
claims, _ := c.Get("claims"); cl := claims.(*Claims)
var req buyReq
if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, http.StatusBadRequest, err.Error()); return }
if req.Quantity <= 0 { req.Quantity = 1 }
result, err := h.store.BuyItem(cl.UserID, req.ItemID, req.ItemName, req.Currency, req.Amount, req.Quantity)
if err != nil { response.Error(c, http.StatusBadRequest, err.Error()); return }
response.OK(c, result)
}
type useReq struct {
ItemID string `json:"item_id" binding:"required"`
}
// UseItem 使用道具
func (h *Handler) UseItem(c *gin.Context) {
claims, _ := c.Get("claims"); cl := claims.(*Claims)
var req useReq
if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, http.StatusBadRequest, err.Error()); return }
result, err := h.store.UseItem(cl.UserID, req.ItemID)
if err != nil { response.Error(c, http.StatusBadRequest, err.Error()); return }
response.OK(c, result)
}
// AuthMiddleware JWT 认证中间件
func (h *Handler) AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
auth := c.GetHeader("Authorization")
if auth == "" {
response.Error(c, http.StatusUnauthorized, "缺少认证令牌")
c.Abort()
return
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
response.Error(c, http.StatusUnauthorized, "认证格式错误")
c.Abort()
return
}
claims, err := h.jwt.Verify(parts[1])
if err != nil {
response.Error(c, http.StatusUnauthorized, "令牌无效或已过期")
c.Abort()
return
}
c.Set("claims", claims)
c.Next()
}
}