This commit is contained in:
@@ -79,6 +79,7 @@ desktop.ini
|
|||||||
.dotnet/
|
.dotnet/
|
||||||
.nuget/
|
.nuget/
|
||||||
.userdata/
|
.userdata/
|
||||||
|
dotnet-sdk/
|
||||||
|
|
||||||
# ===================== Docker =====================
|
# ===================== Docker =====================
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|||||||
Binary file not shown.
@@ -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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace MRCC.Launcher;
|
||||||
|
|
||||||
|
public partial class GameWindow : Window
|
||||||
|
{
|
||||||
|
public GameWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
PageHome.Visibility = tag == "home" ? Visibility.Visible : Visibility.Collapsed;
|
if (gameDir == null)
|
||||||
PageDownload.Visibility = tag == "download" ? Visibility.Visible : Visibility.Collapsed;
|
{
|
||||||
PageSettings.Visibility = tag == "settings" ? Visibility.Visible : Visibility.Collapsed;
|
Content = new TextBlock { Text = "游戏文件未找到", Foreground = System.Windows.Media.Brushes.White, FontSize = 14, Margin = new Thickness(20) };
|
||||||
PageAbout.Visibility = tag == "about" ? Visibility.Visible : Visibility.Collapsed;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnLaunchGame(object sender, RoutedEventArgs e)
|
var port = GameServer.Start(gameDir);
|
||||||
|
var url = $"http://localhost:{port}/voxel-world.html";
|
||||||
|
|
||||||
|
_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)
|
||||||
{
|
{
|
||||||
// TODO: 启动 MRCC Unity 客户端
|
Program.Log($"Launcher load failed: {ex}");
|
||||||
MessageBox.Show("游戏启动功能开发中", "MRCC Launcher", MessageBoxButton.OK, MessageBoxImage.Information);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnWebMessage(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
AllocConsole();
|
||||||
|
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
||||||
|
Log("=== RedCircuit 开发模式 ===");
|
||||||
|
Log($"工作目录: {Environment.CurrentDirectory}");
|
||||||
|
Log($"EXE目录: {AppContext.BaseDirectory}");
|
||||||
|
Log("HTML5 世界直接运行于 WebView2 内,控制台用于查看日志。");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动器 UI:WebView2 加载 HTML5 世界
|
||||||
var app = new App();
|
var app = new App();
|
||||||
return app;
|
app.Run(new MainWindow());
|
||||||
};
|
}
|
||||||
|
|
||||||
// 设置主窗口加载委托
|
public static void Log(string message)
|
||||||
MainWindowService.Loading = () => new MainWindow();
|
{
|
||||||
|
var line = $"[{DateTime.Now:HH:mm:ss.fff}] {message}";
|
||||||
// 启动生命周期
|
lock (_logLock)
|
||||||
Lifecycle.OnInitialize();
|
{
|
||||||
|
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 |
+2501
-225
@@ -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">
|
||||||
|
<!-- 欢迎面板 -->
|
||||||
|
<div id="menu-welcome" class="menu-panel">
|
||||||
<h1>RedCircuit</h1>
|
<h1>RedCircuit</h1>
|
||||||
<p>红石回路 · 方块世界原型</p>
|
<p>红石回路 · 方块世界</p>
|
||||||
<button class="start-btn" id="startBtn">点击进入世界</button>
|
<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 <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 class="keys">WASD 移动 · 空格跳跃 · 鼠标视角<br>左键破坏 · 右键放置 · 数字 1-9 快捷栏<br><b>E 打开背包</b> · ESC 暂停</div>
|
||||||
</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>
|
||||||
<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);
|
||||||
|
// 有模型的元件 (拉杆/按钮/火把) 保留独立 mesh
|
||||||
|
if (def.isLever || def.isButton || def.isTorch) {
|
||||||
const mesh = makeBlockMesh(def);
|
const mesh = makeBlockMesh(def);
|
||||||
const yOffset = mesh.userData.yOffset || 0;
|
const yOffset = mesh.userData.yOffset || 0;
|
||||||
mesh.position.set(x + 0.5, y + 0.5 + yOffset, z + 0.5);
|
mesh.position.set(x + 0.5, y + 0.5 + yOffset, z + 0.5);
|
||||||
// 方向性元件: 旋转
|
|
||||||
let dir = 0;
|
let dir = 0;
|
||||||
if (isDirectional(typeId) && direction !== undefined) {
|
if (isDirectional(typeId) && direction !== undefined) {
|
||||||
mesh.rotation.y = direction;
|
mesh.rotation.y = direction; dir = direction;
|
||||||
dir = direction;
|
|
||||||
}
|
}
|
||||||
mesh.userData = { x, y, z, type: typeId, yOffset, direction: dir };
|
mesh.userData = { x, y, z, type: typeId, yOffset, direction: dir };
|
||||||
scene.add(mesh);
|
scene.add(mesh);
|
||||||
world.set(k, { type: typeId, mesh, lit: false });
|
world.set(k, { type: typeId, mesh, lit: false });
|
||||||
if (!skipUpdate) updateLamps();
|
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();
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,8 +2330,9 @@ 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;
|
||||||
|
// 释放独立 mesh (如果有)
|
||||||
|
if (b.mesh) {
|
||||||
scene.remove(b.mesh);
|
scene.remove(b.mesh);
|
||||||
// 释放资源 (支持 Group)
|
|
||||||
b.mesh.traverse(child => {
|
b.mesh.traverse(child => {
|
||||||
if (child.geometry) child.geometry.dispose();
|
if (child.geometry) child.geometry.dispose();
|
||||||
if (child.material) {
|
if (child.material) {
|
||||||
@@ -501,130 +2340,172 @@ function removeBlock(x, y, z) {
|
|||||||
else child.material.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;
|
||||||
|
if (!powered) {
|
||||||
for (const [dx, dy, dz] of [[1,0,0],[-1,0,0],[0,1,0],[0,-1,0],[0,0,1],[0,0,-1]]) {
|
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);
|
const n = getBlock(x+dx, y+dy, z+dz);
|
||||||
if (!n) continue;
|
if (n && (n.signal||0) > 0) { powered = true; break; }
|
||||||
const nd = getDef(n.type);
|
}
|
||||||
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格/步),保证任何帧率下都不会穿透方块
|
||||||
|
let dy = player.vel.y * dt;
|
||||||
|
while (dy !== 0) {
|
||||||
|
const s = Math.sign(dy) * Math.min(Math.abs(dy), 0.5);
|
||||||
|
player.pos.y += s;
|
||||||
if (collide(player.pos, h)) {
|
if (collide(player.pos, h)) {
|
||||||
if (player.vel.y < 0) player.onGround = true;
|
if (s < 0) {
|
||||||
player.pos.y -= player.vel.y * dt; player.vel.y = 0;
|
// 落地:精确贴到方块顶面,消除穿透与贴地抖动
|
||||||
} else player.onGround = false;
|
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;
|
||||||
|
if (updateHighlight._lastName !== '-') {
|
||||||
document.getElementById('looking').textContent = '-';
|
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();
|
||||||
|
// HUD 信息节流更新:每 10 帧写一次 DOM,避免每帧布局抖动
|
||||||
|
if (frames % 10 === 0) {
|
||||||
document.getElementById('pos').textContent = `${player.pos.x.toFixed(1)}, ${player.pos.y.toFixed(1)}, ${player.pos.z.toFixed(1)}`;
|
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('bcount').textContent = world.size;
|
||||||
document.getElementById('fps').textContent = fps;
|
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();
|
|
||||||
createHotbar();
|
|
||||||
setupEvents();
|
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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服务器
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user