diff --git a/cmd/client/client_gl.go b/cmd/client/client_gl.go index 09c70f1a..6c89b3dd 100644 --- a/cmd/client/client_gl.go +++ b/cmd/client/client_gl.go @@ -26,6 +26,8 @@ import ( "mc/internal/item" "mc/internal/logx" "mc/internal/mesh" + "mc/internal/netclient" + "mc/internal/netproto" "mc/internal/physics" "mc/internal/recipe" "mc/internal/render" @@ -51,6 +53,10 @@ type clientApp struct { titleScreen *ui.TitleScreen pauseScreen *ui.PauseScreen + // 联机(多人同步.md §2:无连接时为单机) + net *netclient.Client + log *logx.Logger + meshBuilder *mesh.Builder mu sync.Mutex @@ -77,6 +83,15 @@ func (a *clientApp) Tick(dt float64) { case "play": a.screen = 1 a.rend.SetCursorMode(true) + case "multi": + // 联机:连接本机服务器(多人同步.md §2) + if err := dialServer(a, "127.0.0.1:25565", "玩家"); err != nil { + a.logf("连接服务器失败: %v", err) + } else { + a.screen = 1 + a.rend.SetCursorMode(true) + a.logf("已连接服务器") + } case "quit": a.rend.Window().SetShouldClose(true) } @@ -112,9 +127,19 @@ func (a *clientApp) Tick(dt float64) { a.in.apply(a.sess) a.sess.Tick(dt) + // 联机:上报移动(20Hz,多人同步.md §3) + if a.net != nil { + p := a.sess.Player + a.net.SendMove(netproto.PlayerMove{ + X: float32(p.Pos[0]), Y: float32(p.Pos[1]), Z: float32(p.Pos[2]), + Yaw: float32(a.sess.Cam.Yaw), Pitch: float32(a.sess.Cam.Pitch), + OnGround: p.OnGround, + }) + } + // 定时存档:30s(存档与持久化.md §2) a.tickCnt++ - if a.tickCnt%(20*30) == 0 { + if a.tickCnt%(20*30) == 0 && a.net == nil { if err := save.SaveWorld(a.sess.World, "worlds", "NewWorld", 123456789, int64(a.tickCnt)); err != nil { // 日志由调用方输出(避免每次 tick 刷屏) _ = err @@ -178,6 +203,13 @@ func (a *clientApp) Render() { // ShouldClose 窗口关闭判定。 func (a *clientApp) ShouldClose() bool { return a.rend.ShouldClose() } +// logf 客户端日志(联机回调使用)。 +func (a *clientApp) logf(format string, args ...any) { + if a.log != nil { + a.log.Infof(format, args...) + } +} + func main() { flag.Parse() @@ -341,6 +373,7 @@ func main() { hud: hud, titleScreen: titleScreen, pauseScreen: pauseScreen, + log: log, meshBuilder: mesh.NewBuilder(reg, uvFn), winW: cfg.Client.WindowWidth, winH: cfg.Client.WindowHeight, diff --git a/cmd/client/multiplayer_gl.go b/cmd/client/multiplayer_gl.go new file mode 100644 index 00000000..a4f896d5 --- /dev/null +++ b/cmd/client/multiplayer_gl.go @@ -0,0 +1,52 @@ +//go:build gl + +// 客户端联机:netclient 接入(多人同步.md §2 权威链路客户端侧)。 +package main + +import ( + "mc/internal/block" + "mc/internal/netclient" + "mc/internal/netproto" + "mc/internal/save" +) + +// mpHandler 联机回调:区块安装与方块变更应用(多人同步.md §4–§5)。 +type mpHandler struct { + app *clientApp +} + +// OnLogin 登录结果。 +func (h *mpHandler) OnLogin(code uint8) { + if code != 0 { + h.app.logf("服务器拒绝登录(码 %d)", code) + h.app.net.Close() + h.app.net = nil + h.app.screen = 0 + } +} + +// OnChunk 收到区块:反序列化并安装到世界(多人同步.md §4)。 +func (h *mpHandler) OnChunk(data []byte) { + c, err := save.DecodeChunk(data) + if err != nil { + h.app.logf("区块解码失败: %v", err) + return + } + h.app.sess.World.InstallChunk(c) +} + +// OnBlockChange 方块变更(服务器权威广播,多人同步.md §5)。 +func (h *mpHandler) OnBlockChange(m netproto.BlockChange) { + h.app.sess.World.SetBlock(m.X, m.Y, m.Z, block.NewState(m.BlockID, m.Meta)) +} + +// dialServer 连接服务器(多人游戏按钮,多人同步.md §2)。 +func dialServer(a *clientApp, addr string, name string) error { + h := &mpHandler{app: a} + nc, err := netclient.Dial(addr, netproto.Handshake{ProtocolVersion: 1, ClientID: name}, h) + if err != nil { + return err + } + a.net = nc + return nil +} diff --git a/cmd/client/screens_gl.go b/cmd/client/screens_gl.go index caee0ee8..97d089a5 100644 --- a/cmd/client/screens_gl.go +++ b/cmd/client/screens_gl.go @@ -52,7 +52,7 @@ func buildScreens(r *ui.Renderer, lang *assets.Lang) (*ui.TitleScreen, *ui.Pause title.TitleH = titleH title.Buttons = []ui.Button{ btn("menu.singleplayer", "单人游戏", "play", 300, 1280, 720), - btn("menu.multiplayer", "多人游戏", "", 350, 1280, 720), + btn("menu.multiplayer", "多人游戏", "multi", 350, 1280, 720), btn("menu.quit", "退出游戏", "quit", 400, 1280, 720), } _ = titleTex diff --git a/dist/年糕历险记-Setup-v0.1.0.zip b/dist/年糕历险记-Setup-v0.1.0.zip new file mode 100644 index 00000000..f60735a3 Binary files /dev/null and b/dist/年糕历险记-Setup-v0.1.0.zip differ diff --git a/scripts/build.ps1 b/scripts/build.ps1 index f441b95a..b79ac03f 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -1,4 +1,4 @@ -# 年糕历险记 构建脚本(Windows) +# 年糕历险记 构建脚本(Windows) # 用法:pwsh -File scripts/build.ps1 [-Release] # 自动定位 C 工具链(tools/ 下的 zig 或 mingw64/w64devkit),构建 GL 客户端与服务器。 param([switch]$Release) diff --git a/scripts/installer/安装.bat b/scripts/installer/安装.bat new file mode 100644 index 00000000..87e715b9 --- /dev/null +++ b/scripts/installer/安装.bat @@ -0,0 +1,33 @@ +@echo off +chcp 65001 >nul +title 年糕历险记 安装程序 +echo. +echo ============================================ +echo 年糕历险记 安装程序 v0.1.0 +echo NianGao Adventure Installer +echo ============================================ +echo. +setlocal enabledelayedexpansion + +rem 默认安装目录(用户目录下) +set "DEFAULT_DIR=%LOCALAPPDATA%\NianGaoAdventure" +set /p "INSTALL_DIR=请输入安装目录(直接回车使用 %DEFAULT_DIR%):" +if "!INSTALL_DIR!"=="" set "INSTALL_DIR=%DEFAULT_DIR%" + +echo. +echo 正在安装到:!INSTALL_DIR! +if not exist "!INSTALL_DIR!" mkdir "!INSTALL_DIR!" + +echo 复制游戏文件... +xcopy /e /i /y "%~dp0*" "!INSTALL_DIR!\" >nul 2>&1 + +echo 创建桌面快捷方式... +powershell -NoProfile -Command "$ws = New-Object -ComObject WScript.Shell; $s = $ws.CreateShortcut([Environment]::GetFolderPath('Desktop') + '\年糕历险记.lnk'); $s.TargetPath = '!INSTALL_DIR!\mc-client.exe'; $s.WorkingDirectory = '!INSTALL_DIR!'; $s.Save()" + +echo. +echo ============================================ +echo 安装完成!桌面已创建「年糕历险记」快捷方式。 +echo 游戏存档保存在安装目录 worlds\ 下。 +echo 按任意键退出... +echo ============================================ +pause >nul diff --git a/scripts/make_installer.ps1 b/scripts/make_installer.ps1 new file mode 100644 index 00000000..58f9a9a4 --- /dev/null +++ b/scripts/make_installer.ps1 @@ -0,0 +1,50 @@ +# 年糕历险记 —— 便携安装包构建脚本(中文安装,构建与发布.md §8 交付方案) +# 用法:pwsh -File scripts/make_installer.ps1 [-Version 0.1.0] +# 产物:dist/年糕历险记-Setup-v<版本>.zip(解压后运行 安装.bat,全中文交互) +param([string]$Version = "0.1.0") + +$ErrorActionPreference = "Stop" +$root = Split-Path $PSScriptRoot -Parent +$dist = Join-Path $root "dist" +$stage = Join-Path $dist "年糕历险记-Setup-v$Version" + +# 1) 构建(复用 build.ps1 逻辑:定位 zig/gcc) +& (Join-Path $PSScriptRoot "build.ps1") +if ($LASTEXITCODE -ne 0) { throw "构建失败" } + +# 2) 组装安装目录 +if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } +New-Item -ItemType Directory -Force -Path $stage | Out-Null +Copy-Item (Join-Path $root "bin\mc-client.exe") $stage +Copy-Item (Join-Path $root "bin\mc-server.exe") $stage +New-Item -ItemType Directory -Force -Path (Join-Path $stage "config") | Out-Null +Copy-Item (Join-Path $root "config\client.yaml") (Join-Path $stage "config") +Copy-Item (Join-Path $root "config\server.yaml") (Join-Path $stage "config") +New-Item -ItemType Directory -Force -Path (Join-Path $stage "mods") | Out-Null +Copy-Item (Join-Path $root "mods\README.md") (Join-Path $stage "mods") +# 默认素材包 +Copy-Item (Join-Path $root "resourcepacks") (Join-Path $stage "resourcepacks") -Recurse +# 安装脚本(GBK 编码保证 cmd 中文显示) +Copy-Item (Join-Path $PSScriptRoot "installer\安装.bat") (Join-Path $stage "安装.bat") +# 游戏说明(中文) +[System.IO.File]::WriteAllText((Join-Path $stage "README.txt"), @" +年糕历险记 v$Version +==================== + +安装:双击 安装.bat(选择安装目录,自动创建桌面快捷方式)。 +卸载:删除安装目录即可(存档保存在安装目录 worlds/ 下)。 + +素材包/光影包:解压到 mods/ 目录(原生 Minecraft 资源包格式直接支持)。 +未安装光影包时使用系统默认光影。 + +$(Get-Date -Format "yyyy-MM-dd") +"@, (New-Object System.Text.UTF8Encoding($false))) + +# 3) 打包 zip(中文文件名) +$zipPath = Join-Path $dist "年糕历险记-Setup-v$Version.zip" +if (Test-Path $zipPath) { Remove-Item $zipPath -Force } +Compress-Archive -Path (Join-Path $stage "*") -DestinationPath $zipPath +Remove-Item $stage -Recurse -Force + +$sizeMB = [math]::Round((Get-Item $zipPath).Length / 1MB, 1) +Write-Host "== 安装包就绪:$zipPath($sizeMB MB)=="