From a92fc0dfd70e92396f2100a945ff9ae65df8e422 Mon Sep 17 00:00:00 2001 From: yangweijie <917647288@qq.com> Date: Mon, 3 Aug 2026 12:43:29 +0800 Subject: [PATCH] add onepiece-ddz game example --- examples/onepiece-doudizhu-win32/README.md | 124 ++ examples/onepiece-doudizhu-win32/build.bat | 52 + .../onepiece-doudizhu-win32/cpp-src/win32.cc | 402 +++++ examples/onepiece-doudizhu-win32/main.php | 87 + .../php-src/doudizhu/Ai.php | 252 +++ .../php-src/doudizhu/Card.php | 68 + .../php-src/doudizhu/Character.php | 90 + .../php-src/doudizhu/Combo.php | 252 +++ .../php-src/doudizhu/Deck.php | 61 + .../php-src/doudizhu/Faction.php | 48 + .../php-src/doudizhu/Game.php | 501 ++++++ .../php-src/doudizhu/GameController.php | 1570 +++++++++++++++++ .../php-src/doudizhu/MoveGenerator.php | 151 ++ .../php-src/doudizhu/PlayerState.php | 49 + .../php-src/doudizhu/Render.php | 261 +++ .../php-src/doudizhu/Skill.php | 103 ++ .../php-src/doudizhu/Sound.php | 107 ++ .../php-src/win32.stub.php | 41 + examples/onepiece-doudizhu-win32/project.yml | 7 + 19 files changed, 4226 insertions(+) create mode 100644 examples/onepiece-doudizhu-win32/README.md create mode 100644 examples/onepiece-doudizhu-win32/build.bat create mode 100644 examples/onepiece-doudizhu-win32/cpp-src/win32.cc create mode 100644 examples/onepiece-doudizhu-win32/main.php create mode 100644 examples/onepiece-doudizhu-win32/php-src/doudizhu/Ai.php create mode 100644 examples/onepiece-doudizhu-win32/php-src/doudizhu/Card.php create mode 100644 examples/onepiece-doudizhu-win32/php-src/doudizhu/Character.php create mode 100644 examples/onepiece-doudizhu-win32/php-src/doudizhu/Combo.php create mode 100644 examples/onepiece-doudizhu-win32/php-src/doudizhu/Deck.php create mode 100644 examples/onepiece-doudizhu-win32/php-src/doudizhu/Faction.php create mode 100644 examples/onepiece-doudizhu-win32/php-src/doudizhu/Game.php create mode 100644 examples/onepiece-doudizhu-win32/php-src/doudizhu/GameController.php create mode 100644 examples/onepiece-doudizhu-win32/php-src/doudizhu/MoveGenerator.php create mode 100644 examples/onepiece-doudizhu-win32/php-src/doudizhu/PlayerState.php create mode 100644 examples/onepiece-doudizhu-win32/php-src/doudizhu/Render.php create mode 100644 examples/onepiece-doudizhu-win32/php-src/doudizhu/Skill.php create mode 100644 examples/onepiece-doudizhu-win32/php-src/doudizhu/Sound.php create mode 100644 examples/onepiece-doudizhu-win32/php-src/win32.stub.php create mode 100644 examples/onepiece-doudizhu-win32/project.yml diff --git a/examples/onepiece-doudizhu-win32/README.md b/examples/onepiece-doudizhu-win32/README.md new file mode 100644 index 00000000..b743c2e9 --- /dev/null +++ b/examples/onepiece-doudizhu-win32/README.md @@ -0,0 +1,124 @@ +# 海贼王 · 斗地主(Win32 / TypePHP 示例) + +将 `HelgeSverre-libui-sdk/examples/onepiece-doudizhu.php`(libui 版)移植为 +**TypePHP(tpc.exe)AOT 编译的原生 Win32 程序**,参考 `landlord-win32` 的 +「纯 PHP 逻辑 + C++ 薄封装 Win32 绑定」架构。 + +## 特性 + +- 完整保留原作玩法:三大势力(海军 / 七武海 / 四皇)、9 名角色技能、 + 叫地主 → 出牌 → 结算全流程,内置 AI 对手与托管。 +- 界面由 Win32 GDI 自绘(海洋渐变背景、势力配色卡牌、对手面板、 + 可拖拽手牌、底部操作按钮)。 +- 游戏逻辑 100% 在 PHP 中实现(`php-src/doudizhu/`),与 libui 版共用同一套 + 领域模型;C++(`cpp-src/win32.cc`)仅封装窗口、消息循环与 GDI 绘制原语。 +- 单文件 exe,无外部 PHP 依赖,不依赖 libui / mbstring / miniaudio。 + +## 目录结构 + +``` +onepiece-doudizhu-win32/ +├── main.php 入口:Win32 消息循环驱动 GameController +├── project.yml tpc.exe 构建配置(mode: bin) +├── build.bat 一键编译脚本(需 VS 2022 x64 工具链环境) +├── cpp-src/ +│ └── win32.cc Win32 窗口 + GDI 绘制原语(C++ 薄封装) +└── php-src/ + ├── win32.stub.php win_* 原生函数声明(stub) + └── doudizhu/ 游戏域模型 + 渲染 shim + ├── Card / Deck / Combo / MoveGenerator / Game / Ai / Skill / + │ Character / Faction / PlayerState 纯逻辑(与 libui 版同源) + ├── GameController.php 对局编排 + Win32 输入/渲染适配 + ├── Render.php libui DrawContext 兼容 shim → GDI + └── Sound.php 音效管理器(TypePHP 版为空实现,保留 API) +``` + +## 编译 + +在 **x64 Native Tools Command Prompt for VS 2022**(或已加载 +`vcvars64.bat` 的终端)中执行: + +```bat +build.bat +``` + +脚本会: +1. 校验 `cl.exe` 可用(不在 VS 环境中会提示)。 +2. 从 TypePHP 根目录调用 `tpc.exe project.yml`(tpc 打包后按 CWD 解析 + `vendor/autoload.php`,必须从根目录运行)。 +3. 将产物 `onepiece_doudizhu.exe` 复制回示例目录。 + +等价手动命令: + +```bat +set PHP_HOME=D:\git\php\tpc_v1095_windows_x86_64 +set PHPX_HOME=%PHP_HOME%\phpx +set PATH=%PHP_HOME%;%PATH% +cd /d D:\git\php\tpc_v1095_windows_x86_64 +tpc.exe examples\onepiece-doudizhu-win32\project.yml --no-progress +``` + +## 运行 + +```bat +onepiece_doudizhu.exe +``` + +- 拖拽手牌选牌,底部按钮:出牌 / 不出 / 提示 / 技能 / 托管。 +- 右上角按钮切换音效(当前为占位实现)。 +- 关闭窗口或 `Esc` 退出。 + +## 移植要点 + +1. **渲染 shim(Render.php)**:原 `GameController` 的绘制代码直接使用 + libui 的 `DrawContext / Brush / Color / FontDescriptor / DrawTextAlign / + TextWeight`,这些符号在 `php-src/doudizhu/Render.php` 中同命名空间重新声明, + `WinDrawContext` 把每个调用翻译为 `win_fill_rect / win_fill_ellipse / + win_fill_rounded_rect / win_stroke_rounded_rect / win_draw_text_ex`。 + 渐变近似为纯色填充;颜色统一 `0xRRGGBB → COLORREF(0xBBGGRR)` 转换。 +2. **输入驱动**:libui 的 `AreaDelegate::mouse/key` 改为 `main.php` 主循环 + 轮询 `win_peek_message()`,把 `WM_LBUTTONDOWN/UP/MOUSEMOVE/KEYDOWN` + 转换为 `GameController::onMouse()/onKey()` 调用。 +3. **定时器**:`Loop::delay` 改为 `GameController::tick()`,由主循环每帧 + 调用,驱动 AI 走子 / 叫分 / 托管(基于 `win_get_tick_count()`)。 +4. **TypePHP 兼容性修正**(相对 libui 版源码): + - 顶层游离代码(`define('FONT', …)`)包装进 `ensureDdzFont()`,由 + `main()` 调用——TPC 要求所有执行代码位于函数内。 + - `readonly` 属性 / `&$ref` 引用遍历等改为普通属性 / 下标赋值(TPC 语法限制)。 + - `mb_strlen / mb_substr` 改为 `ddz_utf8_len / ddz_utf8_substr` + (PCRE 实现,TPC 运行时未链接 mbstring)。 + - `Brush::linearGradient()` 签名改为与 libui 一致的 + `(x0,y0,x1,y1, stops)` 形式,避免 TPC 变参类型推断失败。 +5. **双缓冲位图管理(win32.cc)**:修复 `GetStockObject(BITMAP)` 用法错误 + (`BITMAP` 是类型不是常量),改为保存/恢复 DC 原 bitmap 后再删除离屏位图。 +6. **窗口尺寸(win32.cc)**:`win_create_window` 把传入宽高当作**客户区**尺寸 + (`AdjustWindowRect` 自动补上标题栏/边框),保证底部按钮完整可见;窗口 + 样式为 `WS_OVERLAPPEDWINDOW`,支持拖拽缩放与最大化。新增 + `win_get_client_size()` 返回当前客户区尺寸,`render()`/`drawButtons()` + 每帧按实际尺寸自适应布局(绘制与点击命中均基于当前尺寸记录的矩形)。 +7. **运行时兼容性**:`MoveGenerator::all()` 的 `$cnt` 闭包改为 + `count($byRank[$r] ?? [])`——原实现 `count($byRank[$r])` 在顺子/连对 + 枚举缺失 rank 时对 null 调用 `count()`,TypePHP 运行时(PHP 8 严格类型) + 抛 `TypeError: count(): Argument #1 must be of type Countable|array`。 +8. **窗口标题乱码(win32.cc)**:本机腾讯电脑管家(`tsbx.dll`)会 inline-hook + 进程内 user32 的 `DefWindowProcA` / `CreateWindowExW` / `SetWindowTextW`, + 导致窗口标题被改写为乱码("wm<崑s " 之类)。修复: + - `DdzWndProc` 末尾显式调用 **`DefWindowProcW`**(而非 `DefWindowProc` 宏, + 后者在非 UNICODE 编译下展开为被 hook 的 `DefWindowProcA`)。 + - `php_win_create_window` 通过 `LoadLibraryW("C:\\Windows\\System32\\user32.dll")` + + `GetProcAddress` 解析**原始** `CreateWindowExW` / `SetWindowTextW` 调用, + 创建后再用原始 `SetWindowTextW` 重设一次标题(双保险)。 + - 对照组验证:Explorer / Edge / SmartGit 等其它进程窗口标题读取正常, + 仅本进程自定义 WndProc 窗口受影响,证明是进程内 hook 而非系统/代码问题。 +9. **托管节奏(GameController.php)**:Win32 移植版里 `defer()` 与 `delay()` + 都由主循环 `tick()` 驱动、机制相同。原代码在托管/AI 走子/叫分处同时挂 + `defer`(立即执行)+ `delay`(兜底),导致动作在下一帧瞬间完成,玩家 + 来不及点「取消托管」。修复: + - 去掉托管分支的 `defer`,只保留 **1500ms** 延迟(充足取消窗口)。 + - `scheduleAi()` 去掉 `defer`,AI 出牌节奏改为 **800ms**。 + - `scheduleBidStep()` 去掉 `defer`,AI 叫分节奏改为 **700ms**。 + - `toggleAutoPlay()` 关闭托管时调用 `cancelTimers()` 立即取消挂起的 + 托管定时器;`autoPlayStep()` 原有 `!$this->autoPlay` 保护兜底。 + - `toggleAutoPlay()` 关闭托管且轮到玩家时,重新调用 + `setActionsForHumanTurn()` 重算按钮状态——托管时「出牌/不出/提示/技能」 + 按 `!autoPlay` 被禁用,恢复后必须重算才能重新可点。 diff --git a/examples/onepiece-doudizhu-win32/build.bat b/examples/onepiece-doudizhu-win32/build.bat new file mode 100644 index 00000000..236e0ab8 --- /dev/null +++ b/examples/onepiece-doudizhu-win32/build.bat @@ -0,0 +1,52 @@ +@echo off +rem ============================================================ +rem Build script for the One Piece Dou Dizhu Win32 example. +rem +rem Run this from the "x64 Native Tools Command Prompt for VS 2022" +rem (the MSVC environment must be active so that cl.exe, INCLUDE +rem and LIB are all set up). Usage: +rem +rem build.bat +rem +rem NOTE: tpc.exe is a packaged binary and resolves vendor/autoload.php +rem relative to the CURRENT WORKING DIRECTORY, so we launch it from +rem the TypePHP root (TPC_ROOT), not from this examples dir. The +rem finished binary is copied back into this directory. +rem ============================================================ +setlocal + +rem --- sanity check: are we inside an MSVC environment? --- +where cl.exe >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] cl.exe not found on PATH. + echo Open the "x64 Native Tools Command Prompt for VS 2022" + echo and run this script from there. + exit /b 1 +) + +set "TPC_ROOT=D:\git\php\tpc_v1095_windows_x86_64" +set "PHP_HOME=%TPC_ROOT%" +set "PHPX_HOME=%TPC_ROOT%\phpx" +set "PATH=%TPC_ROOT%;%PATH%" + +rem --- resolve this script's directory --- +set "SCRIPT_DIR=%~dp0" + +pushd "%TPC_ROOT%" +echo [1/2] Running TypePHP compiler (tpc.exe) from %TPC_ROOT%... +"%TPC_ROOT%\tpc.exe" "%SCRIPT_DIR%project.yml" --no-progress +set "RESULT=%errorlevel%" +popd + +if not "%RESULT%"=="0" ( + echo. + echo BUILD FAILED: tpc.exe exited with errorlevel %RESULT% + exit /b 1 +) + +rem --- copy the binary back into the example directory --- +copy /Y "%TPC_ROOT%\onepiece_doudizhu.exe" "%SCRIPT_DIR%onepiece_doudizhu.exe" >nul + +echo. +echo [2/2] Build finished. Output binary: %SCRIPT_DIR%onepiece_doudizhu.exe +endlocal diff --git a/examples/onepiece-doudizhu-win32/cpp-src/win32.cc b/examples/onepiece-doudizhu-win32/cpp-src/win32.cc new file mode 100644 index 00000000..07309116 --- /dev/null +++ b/examples/onepiece-doudizhu-win32/cpp-src/win32.cc @@ -0,0 +1,402 @@ +/** + * One Piece Dou Dizhu - Win32 API Layer (TypePHP example) + * + * C++ only wraps Win32 windowing + GDI drawing primitives. + * ALL game logic and ALL rendering decisions live in PHP + * (see php-src/doudizhu/GameController.php via the WinDrawContext shim). + */ + +#include +#include +#include +#include +#include +#include + +using namespace php; + +// ============================================================ +// Window & Message +// ============================================================ + +static bool g_quitRequested = false; + +// Per memory-DC frame state: the off-screen bitmap plus the DC's previous +// bitmap (so we can restore it before deleting the off-screen bitmap). +struct FrameState { + HBITMAP bitmap; + HBITMAP oldBitmap; +}; +static std::map g_frames; + +// ------------------------------------------------------------------ +// Anti-hook: some Chinese security suites (e.g. Tencent PC Manager's +// tsbx.dll) inline-hook the user32 exports (CreateWindowExW / +// SetWindowTextW) in every process, rewriting window titles to garbage. +// To get a reliable title we resolve the ORIGINAL exports from a freshly +// loaded copy of user32.dll and call those instead of the hooked IAT +// entries. All other Win32 calls are unaffected and stay normal. +// ------------------------------------------------------------------ + +typedef HWND (WINAPI *RealCreateWindowExW)(DWORD, LPCWSTR, LPCWSTR, DWORD, int, int, int, int, HWND, HMENU, HINSTANCE, LPVOID); +typedef BOOL (WINAPI *RealSetWindowTextW)(HWND, LPCWSTR); +typedef int (WINAPI *RealGetWindowTextW)(HWND, LPWSTR, int); + +static HMODULE g_realUser32 = NULL; +static RealCreateWindowExW g_realCreateWindowExW = NULL; +static RealSetWindowTextW g_realSetWindowTextW = NULL; +static RealGetWindowTextW g_realGetWindowTextW = NULL; + +static void resolve_real_user32(void) { + if (g_realCreateWindowExW != NULL) { + return; + } + g_realUser32 = LoadLibraryW(L"C:\\Windows\\System32\\user32.dll"); + if (g_realUser32 == NULL) { + g_realUser32 = GetModuleHandleW(L"user32.dll"); + } + if (g_realUser32 != NULL) { + g_realCreateWindowExW = (RealCreateWindowExW)GetProcAddress(g_realUser32, "CreateWindowExW"); + g_realSetWindowTextW = (RealSetWindowTextW)GetProcAddress(g_realUser32, "SetWindowTextW"); + g_realGetWindowTextW = (RealGetWindowTextW)GetProcAddress(g_realUser32, "GetWindowTextW"); + } + // Fallbacks to the (possibly hooked) IAT entries if resolution failed. + if (g_realCreateWindowExW == NULL) g_realCreateWindowExW = CreateWindowExW; + if (g_realSetWindowTextW == NULL) g_realSetWindowTextW = SetWindowTextW; + if (g_realGetWindowTextW == NULL) g_realGetWindowTextW = GetWindowTextW; +} + +LRESULT CALLBACK DdzWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { + switch (msg) { + case WM_CLOSE: + case WM_DESTROY: + g_quitRequested = true; + PostQuitMessage(0); + return 0; + case WM_PAINT: + // We render the whole client area every frame via GetDC, so just + // validate the paint region to avoid an endless WM_PAINT loop. + ValidateRect(hWnd, NULL); + return 0; + } + // NOTE: must call DefWindowProcW (not the DefWindowProc macro). On this + // machine Tencent PC Manager (tsbx.dll) inline-hooks DefWindowProcA and + // corrupts UTF-16 window titles (they come back as garbage). The W + // variant is not hooked. + return DefWindowProcW(hWnd, msg, wParam, lParam); +} + +Int php_win_create_window(String title, Int width, Int height) { + SetConsoleOutputCP(65001); + resolve_real_user32(); + + WNDCLASSW wc; + ZeroMemory(&wc, sizeof(wc)); + wc.style = CS_HREDRAW | CS_VREDRAW; + wc.lpfnWndProc = DdzWndProc; + wc.hInstance = GetModuleHandle(NULL); + wc.hCursor = LoadCursor(NULL, IDC_ARROW); + wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); + wc.lpszClassName = L"DdzWindow"; + RegisterClassW(&wc); + + // UTF-8 (PHP string) -> UTF-16 window title. + int wtitle_len = MultiByteToWideChar(CP_UTF8, 0, title.data(), -1, NULL, 0); + wchar_t* wtitle = new wchar_t[wtitle_len]; + MultiByteToWideChar(CP_UTF8, 0, title.data(), -1, wtitle, wtitle_len); + + // The requested width/height are treated as the CLIENT area, so every + // control the PHP layout draws is fully visible. The outer window frame + // (title bar + borders) is added on top via AdjustWindowRect. + RECT rc = {0, 0, (int)width, (int)height}; + DWORD style = WS_OVERLAPPEDWINDOW; // resizable + maximizable + AdjustWindowRect(&rc, style, FALSE); + int winW = rc.right - rc.left; + int winH = rc.bottom - rc.top; + + // Use the ORIGINAL (un-hooked) CreateWindowExW so security-suite hooks + // cannot corrupt the window title. Fall back to the normal API if the + // real one could not be resolved. + HWND hWnd = g_realCreateWindowExW( + 0, L"DdzWindow", wtitle, + style, + CW_USEDEFAULT, CW_USEDEFAULT, + winW, winH, + NULL, NULL, GetModuleHandle(NULL), NULL + ); + + // Belt & braces: re-assert the title through the original SetWindowTextW + // (some hooks corrupt the title during CreateWindowExW itself). + if (hWnd != NULL && g_realSetWindowTextW != NULL) { + g_realSetWindowTextW(hWnd, wtitle); + } + + delete[] wtitle; + return (Int)hWnd; +} + +/** Return the current client-area size as [width, height]. */ +Array php_win_get_client_size(Int hWnd) { + RECT rc; + GetClientRect((HWND)hWnd, &rc); + Array result; + result.append((Int)rc.right); + result.append((Int)rc.bottom); + return result; +} + +void php_win_show_window(Int hWnd, Int cmdShow) { + ShowWindow((HWND)hWnd, (int)cmdShow); +} + +Bool php_win_quit_requested() { + return g_quitRequested; +} + +void php_win_post_quit(Int exitCode) { + PostQuitMessage((int)exitCode); +} + +/** + * Drain one queued message and translate it into a typed array: + * [type, a, b, c] + * type 0 = unhandled (still dispatched, ignored by PHP) + * type 1 = mouse down : a=x, b=y + * type 2 = mouse up : a=x, b=y + * type 3 = mouse move : a=x, b=y, c=leftHeld(0/1) + * type 4 = key down : a=vk + * Returns empty array when no message is pending. + */ +Array php_win_peek_message() { + MSG msg; + ZeroMemory(&msg, sizeof(msg)); + if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { + Array result; + int type = 0; + int a = 0, b = 0, c = 0; + switch ((UINT)msg.message) { + case WM_LBUTTONDOWN: + type = 1; + a = (int)(msg.lParam & 0xFFFF); + b = (int)((msg.lParam >> 16) & 0xFFFF); + break; + case WM_LBUTTONUP: + type = 2; + a = (int)(msg.lParam & 0xFFFF); + b = (int)((msg.lParam >> 16) & 0xFFFF); + break; + case WM_MOUSEMOVE: + type = 3; + a = (int)(msg.lParam & 0xFFFF); + b = (int)((msg.lParam >> 16) & 0xFFFF); + c = (msg.wParam & MK_LBUTTON) ? 1 : 0; + break; + case WM_KEYDOWN: + type = 4; + a = (int)msg.wParam; + break; + default: + type = 0; + a = (int)msg.message; + b = (int)msg.wParam; + c = (int)msg.lParam; + break; + } + TranslateMessage(&msg); + DispatchMessage(&msg); + result.append((Int)type); + result.append((Int)a); + result.append((Int)b); + result.append((Int)c); + return result; + } + return Array(); +} + +Int php_win_get_tick_count() { + return (Int)GetTickCount(); +} + +Int php_win_message_box(Int hWnd, String text, String caption, Int uType) { + int wtext_len = MultiByteToWideChar(CP_UTF8, 0, text.data(), -1, NULL, 0); + wchar_t* wtext = new wchar_t[wtext_len]; + MultiByteToWideChar(CP_UTF8, 0, text.data(), -1, wtext, wtext_len); + + int wcaption_len = MultiByteToWideChar(CP_UTF8, 0, caption.data(), -1, NULL, 0); + wchar_t* wcaption = new wchar_t[wcaption_len]; + MultiByteToWideChar(CP_UTF8, 0, caption.data(), -1, wcaption, wcaption_len); + + int result = MessageBoxW((HWND)hWnd, wtext, wcaption, (UINT)uType); + + delete[] wtext; + delete[] wcaption; + return result; +} + +void php_win_message_beep(Int type) { + MessageBeep((UINT)type); +} + +// ============================================================ +// Double-buffered frame +// ============================================================ + +// Begin a frame. Returns the memory-DC handle (an Int) used by all draw calls. +Int php_win_begin_paint(Int hWnd) { + HDC hdc = GetDC((HWND)hWnd); + RECT rc; + GetClientRect((HWND)hWnd, &rc); + + HDC memDC = CreateCompatibleDC(hdc); + HBITMAP memBitmap = CreateCompatibleBitmap(hdc, rc.right, rc.bottom); + HBITMAP oldBitmap = (HBITMAP)SelectObject(memDC, memBitmap); + FrameState state; + state.bitmap = memBitmap; + state.oldBitmap = oldBitmap; + g_frames[memDC] = state; + + ReleaseDC((HWND)hWnd, hdc); + return (Int)memDC; +} + +void php_win_end_paint(Int hWnd, Int hdcHandle) { + HDC memDC = (HDC)hdcHandle; + RECT rc; + GetClientRect((HWND)hWnd, &rc); + + HDC hdc = GetDC((HWND)hWnd); + BitBlt(hdc, 0, 0, rc.right, rc.bottom, memDC, 0, 0, SRCCOPY); + ReleaseDC((HWND)hWnd, hdc); + + auto it = g_frames.find(memDC); + if (it != g_frames.end()) { + SelectObject(memDC, it->second.oldBitmap); + DeleteObject(it->second.bitmap); + g_frames.erase(it); + } + DeleteDC(memDC); +} + +// ============================================================ +// GDI primitives +// ============================================================ + +void php_win_fill_rect(Int hdc, Int x, Int y, Int w, Int h, Int rgbColor) { + HBRUSH brush = CreateSolidBrush((COLORREF)rgbColor); + RECT r = {(int)x, (int)y, (int)(x + w), (int)(y + h)}; + FillRect((HDC)hdc, &r, brush); + DeleteObject(brush); +} + +void php_win_draw_block(Int hdc, Int x, Int y, Int size, Int rgbColor) { + COLORREF color = (COLORREF)rgbColor; + HBRUSH brush = CreateSolidBrush(color); + RECT r = {(int)x + 1, (int)y + 1, (int)(x + size - 1), (int)(y + size - 1)}; + FillRect((HDC)hdc, &r, brush); + DeleteObject(brush); + HPEN borderPen = CreatePen(PS_SOLID, 1, RGB( + (BYTE)(GetRValue(color) * 0.6), + (BYTE)(GetGValue(color) * 0.6), + (BYTE)(GetBValue(color) * 0.6))); + HPEN oldPen = (HPEN)SelectObject((HDC)hdc, borderPen); + HBRUSH oldBrush = (HBRUSH)SelectObject((HDC)hdc, GetStockObject(NULL_BRUSH)); + Rectangle((HDC)hdc, (int)x, (int)y, (int)(x + size), (int)(y + size)); + SelectObject((HDC)hdc, oldBrush); + SelectObject((HDC)hdc, oldPen); + DeleteObject(borderPen); +} + +void php_win_draw_line(Int hdc, Int x1, Int y1, Int x2, Int y2, Int rgbColor) { + HPEN pen = CreatePen(PS_SOLID, 1, (COLORREF)rgbColor); + HPEN oldPen = (HPEN)SelectObject((HDC)hdc, pen); + MoveToEx((HDC)hdc, (int)x1, (int)y1, NULL); + LineTo((HDC)hdc, (int)x2, (int)y2); + SelectObject((HDC)hdc, oldPen); + DeleteObject(pen); +} + +// Ellipse using CENTER coordinate semantics (matches libui's fillEllipse). +void php_win_fill_ellipse(Int hdc, Int cx, Int cy, Int w, Int h, Int rgbColor) { + HBRUSH brush = CreateSolidBrush((COLORREF)rgbColor); + HBRUSH oldBrush = (HBRUSH)SelectObject((HDC)hdc, brush); + HPEN pen = CreatePen(PS_SOLID, 1, (COLORREF)rgbColor); + HPEN oldPen = (HPEN)SelectObject((HDC)hdc, pen); + Ellipse((HDC)hdc, + (int)(cx - w / 2), (int)(cy - h / 2), + (int)(cx + w / 2), (int)(cy + h / 2)); + SelectObject((HDC)hdc, oldPen); + SelectObject((HDC)hdc, oldBrush); + DeleteObject(pen); + DeleteObject(brush); +} + +void php_win_fill_rounded_rect(Int hdc, Int x, Int y, Int w, Int h, Int radius, Int rgbColor) { + HBRUSH brush = CreateSolidBrush((COLORREF)rgbColor); + HPEN nullPen = (HPEN)GetStockObject(NULL_PEN); + HBRUSH oldBrush = (HBRUSH)SelectObject((HDC)hdc, brush); + HPEN oldPen = (HPEN)SelectObject((HDC)hdc, nullPen); + RoundRect((HDC)hdc, (int)x, (int)y, (int)(x + w), (int)(y + h), + (int)radius * 2, (int)radius * 2); + SelectObject((HDC)hdc, oldPen); + SelectObject((HDC)hdc, oldBrush); + DeleteObject(brush); +} + +void php_win_stroke_rounded_rect(Int hdc, Int x, Int y, Int w, Int h, Int radius, Int rgbColor, Int thickness) { + HPEN pen = CreatePen(PS_SOLID, (int)thickness, (COLORREF)rgbColor); + HBRUSH nullBrush = (HBRUSH)GetStockObject(NULL_BRUSH); + HPEN oldPen = (HPEN)SelectObject((HDC)hdc, pen); + HBRUSH oldBrush = (HBRUSH)SelectObject((HDC)hdc, nullBrush); + RoundRect((HDC)hdc, (int)x, (int)y, (int)(x + w), (int)(y + h), + (int)radius * 2, (int)radius * 2); + SelectObject((HDC)hdc, oldBrush); + SelectObject((HDC)hdc, oldPen); + DeleteObject(pen); +} + +// Plain ASCII text (kept for parity / simple labels). +void php_win_draw_text(Int hdc, Int x, Int y, String text, Int fontSize, Int rgbColor, Int bold) { + SetTextColor((HDC)hdc, (COLORREF)rgbColor); + SetBkMode((HDC)hdc, TRANSPARENT); + HFONT hFont = CreateFontA((int)fontSize, 0, 0, 0, + bold ? FW_BOLD : FW_NORMAL, FALSE, FALSE, FALSE, + DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, + DEFAULT_QUALITY, DEFAULT_PITCH | FF_SWISS, "Arial"); + HFONT oldFont = (HFONT)SelectObject((HDC)hdc, hFont); + TextOutA((HDC)hdc, (int)x, (int)y, text.data(), (int)strlen(text.data())); + SelectObject((HDC)hdc, oldFont); + DeleteObject(hFont); +} + +// UTF-8 text with left/center/right alignment inside an optional width box. +// align: 0 = left, 1 = center, 2 = right +void php_win_draw_text_ex(Int hdc, Int x, Int y, String text, Int fontSize, Int rgbColor, Int bold, Int width, Int align) { + SetTextColor((HDC)hdc, (COLORREF)rgbColor); + SetBkMode((HDC)hdc, TRANSPARENT); + + int wtext_len = MultiByteToWideChar(CP_UTF8, 0, text.data(), -1, NULL, 0); + wchar_t* wtext = new wchar_t[wtext_len]; + MultiByteToWideChar(CP_UTF8, 0, text.data(), -1, wtext, wtext_len); + + HFONT hFont = CreateFontW((int)fontSize, 0, 0, 0, + bold ? FW_BOLD : FW_NORMAL, FALSE, FALSE, FALSE, + DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, + DEFAULT_QUALITY, DEFAULT_PITCH | FF_SWISS, L"Microsoft YaHei"); + HFONT oldFont = (HFONT)SelectObject((HDC)hdc, hFont); + + int drawX = (int)x; + if (width > 0) { + SIZE sz; + GetTextExtentPoint32W((HDC)hdc, wtext, (int)wcslen(wtext), &sz); + if (align == 1) { + drawX = (int)x + ((int)width - sz.cx) / 2; + } else if (align == 2) { + drawX = (int)x + (int)width - sz.cx; + } + } + TextOutW((HDC)hdc, drawX, (int)y, wtext, (int)wcslen(wtext)); + + SelectObject((HDC)hdc, oldFont); + DeleteObject(hFont); + delete[] wtext; +} diff --git a/examples/onepiece-doudizhu-win32/main.php b/examples/onepiece-doudizhu-win32/main.php new file mode 100644 index 00000000..b27c0593 --- /dev/null +++ b/examples/onepiece-doudizhu-win32/main.php @@ -0,0 +1,87 @@ +hWnd = $hWnd; + $ctrl->newGame(); + + echo "海贼王 · 斗地主 已启动(Win32 / TypePHP)\n"; + echo "提示:拖拽手牌选牌,底部按钮出牌/不出/提示/技能/托管,右上角切换音效。\n"; + + while (true) { + // 1) 处理所有待处理消息 + while (true) { + $m = win_peek_message(); + if (\count($m) === 0) { + break; + } + $type = $m[0] ?? MSG_OTHER; + if ($type === MSG_MOUSE_DOWN) { + $ctrl->onMouse((object) ['x' => $m[1], 'y' => $m[2], 'down' => 1, 'up' => 0, 'held' => 0]); + } elseif ($type === MSG_MOUSE_UP) { + $ctrl->onMouse((object) ['x' => $m[1], 'y' => $m[2], 'down' => 0, 'up' => 1, 'held' => 0]); + } elseif ($type === MSG_MOUSE_MOVE) { + $ctrl->onMouse((object) ['x' => $m[1], 'y' => $m[2], 'down' => 0, 'up' => 0, 'held' => $m[3]]); + } elseif ($type === MSG_KEY_DOWN) { + $ctrl->onKey((int) $m[1]); + } + // MSG_OTHER (例如 WM_PAINT) 已在 WndProc 中 ValidateRect,此处忽略 + } + + if (win_quit_requested()) { + break; + } + + // 2) 触发到期的定时器(AI 走子 / 叫分 / 托管自动出牌) + $ctrl->tick(); + + // 3) 渲染当前帧(双缓冲,整窗重绘) + $ctrl->render(); + + // 4) 简单节流到 ~60 FPS + \usleep(16000); + } + + Sound::instance()->unload(); + echo "游戏结束,bye!\n"; +} diff --git a/examples/onepiece-doudizhu-win32/php-src/doudizhu/Ai.php b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Ai.php new file mode 100644 index 00000000..3ec67268 --- /dev/null +++ b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Ai.php @@ -0,0 +1,252 @@ +rank] = ($byRank[$c->rank] ?? 0) + 1; + } + foreach ($byRank as $r => $n) { + if ($n === 4) { + $score += 7; // 炸弹 + } + if ($r === Card::JOKER_SMALL) { + $score += 4; + } + if ($r === Card::JOKER_BIG) { + $score += 5; + } + if ($r === 15) { + $score += 2; // 2 + } + if ($r === 14 || $r === 13) { + $score += 1; // A/K + } + } + if ($score >= 16) { + return 3; + } + if ($score >= 10) { + return 2; + } + if ($score >= 6) { + return 1; + } + + return 0; + } + + /** 执行 AI 玩家的整个回合(含技能)。 */ + public static function act(Game $g, int $player): void + { + if ($g->phase !== 'playing' || $g->turn !== $player) { + return; + } + self::maybeTurnStartSkill($g, $player); + if ($g->phase !== 'playing' || $g->turn !== $player) { + return; + } + + $moves = $g->legalMoves($player); + if ($g->lastPlay === null) { + if ($moves === []) { + return; + } + $mv = self::pickLead($g, $player, $moves); + self::maybeArmOnPlay($g, $player, $mv); + self::maybeArmOnBomb($g, $player, $mv); + $g->play($player, $mv->cards); + + return; + } + + $beat = self::pickFollow($g, $player, $moves); + if ($beat === null) { + $g->pass($player); + + return; + } + self::maybeArmOnPlay($g, $player, $beat); + self::maybeArmOnBomb($g, $player, $beat); + $g->play($player, $beat->cards); + } + + /** 赤犬反击:对手刚出炸弹时调用,决定是否反击。 */ + public static function maybeCounter(Game $g, int $player): bool + { + if ($g->lastPlay === null || !$g->lastPlay['combo']->isBomb) { + return false; + } + if ($g->players[$player]->characterId !== 'akainu') { + return false; + } + $skill = Character::byId('akainu')->skill(); + if (!$g->canUse($player, $skill)) { + return false; + } + $bomber = $g->lastPlay['player']; + // 农民反击地主,或地主反击农民(阻止其快出完) + $threat = $g->handCount($bomber) <= 5; + if ($g->side($player) !== $g->side($bomber) || $threat) { + $g->counterBomb($player); + + return true; + } + + return false; + } + + // ---------------------------------------------------------------- 决策细节 + /** + * 首出时挑选要甩出的牌组(公开,供控制器提示与测试复用)。 + * + * @param list $moves + */ + public static function pickLead(Game $g, int $player, array $moves): Combo + { + $nonBomb = \array_filter($moves, static fn (Combo $c) => !$c->isBomb && !$c->isRocket); + if ($nonBomb !== []) { + // 优先甩出牌数多的组合(顺/飞机/连对),其次最小单 + \usort($nonBomb, static function (Combo $a, Combo $b): int { + $ca = \count($a->cards); + $cb = \count($b->cards); + if ($cb !== $ca) { + return $cb <=> $ca; + } + + return $a->rank <=> $b->rank; + }); + + return $nonBomb[0]; + } + // 只剩炸弹/火箭 + \usort($moves, static fn (Combo $a, Combo $b): int => $a->rank <=> $b->rank); + + return $moves[0]; + } + + /** + * 跟牌时挑选要压过的牌组(公开,供控制器提示与测试复用)。 + * + * @param list $moves + */ + public static function pickFollow(Game $g, int $player, array $moves): ?Combo + { + if ($moves === []) { + return null; + } + $oppHand = PHP_INT_MAX; + foreach ([0, 1, 2] as $p) { + if ($p !== $player && $g->side($p) !== $g->side($player)) { + $oppHand = \min($oppHand, $g->handCount($p)); + } + } + $nonBomb = \array_filter($moves, static fn (Combo $c) => !$c->isBomb && !$c->isRocket); + if ($nonBomb !== []) { + \usort($nonBomb, static fn (Combo $a, Combo $b): int => $a->rank <=> $b->rank); + + return $nonBomb[0]; + } + // 只有炸弹/火箭:对手快出完才用 + if ($oppHand <= 2) { + \usort($moves, static fn (Combo $a, Combo $b): int => $a->rank <=> $b->rank); + + return $moves[0]; + } + + return null; // 留炸弹 + } + + private static function maybeTurnStartSkill(Game $g, int $player): void + { + $ps = $g->players[$player]; + if ($ps->characterId === null) { + return; + } + $skill = Character::byId($ps->characterId)->skill(); + if ($skill->trigger !== 'onTurnStart') { + return; + } + if (!$g->canUse($player, $skill)) { + return; + } + // 目标:手牌最少的对手(最具威胁) + $target = null; + $best = PHP_INT_MAX; + foreach ([0, 1, 2] as $p) { + if ($p === $player) { + continue; + } + $c = $g->handCount($p); + if ($c < $best) { + $best = $c; + $target = $p; + } + } + if ($target === null) { + return; + } + // 势力感知:海军/七武海更爱用控制技;四皇(大妈)偷牌也积极 + $g->armSkill($player, $target); + } + + private static function maybeArmOnPlay(Game $g, int $player, Combo $mv): void + { + $ps = $g->players[$player]; + if ($ps->characterId === null || $ps->armed !== null) { + return; + } + $skill = Character::byId($ps->characterId)->skill(); + if ($skill->trigger !== 'onPlay') { + return; + } + if (!$g->canUse($player, $skill)) { + return; + } + + $faction = $ps->faction; + if ($skill->id === 'garp_shock' || $skill->id === 'mihawk_unblock') { + if ($mv->type === 'single' || $mv->type === 'pair') { + // 海军/七武海:用控制技压制 + $g->armSkill($player); + } + } elseif ($skill->id === 'shanks_haki') { + // 四皇:手牌少时花霸气锁 trick;否则偶尔用 + if ($g->handCount($player) <= 6 || ($g->lastPlay !== null && \rand(0, 2) === 0)) { + $g->armSkill($player); + } + } + } + + private static function maybeArmOnBomb(Game $g, int $player, Combo $mv): void + { + $ps = $g->players[$player]; + if ($ps->characterId === null || $ps->armed !== null) { + return; + } + $skill = Character::byId($ps->characterId)->skill(); + if ($skill->trigger !== 'onBombPlayed' || !$mv->isBomb) { + return; + } + if (!$g->canUse($player, $skill)) { + return; + } + if ($skill->id === 'whitebeard_quake') { + $g->armSkill($player); + } + } +} diff --git a/examples/onepiece-doudizhu-win32/php-src/doudizhu/Card.php b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Card.php new file mode 100644 index 00000000..9c706724 --- /dev/null +++ b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Card.php @@ -0,0 +1,68 @@ + 3..10 + * J=11 Q=12 K=13 A=14 2=15 + * 小王=16 大王=17 + */ +final class Card +{ + public const SUITS = ['♠', '♥', '♦', '♣']; // ♠ ♥ ♦ ♣ + public const JOKER_SMALL = 16; + public const JOKER_BIG = 17; + + private const RANK_LABELS = [ + 3 => '3', 4 => '4', 5 => '5', 6 => '6', 7 => '7', 8 => '8', 9 => '9', 10 => '10', + 11 => 'J', 12 => 'Q', 13 => 'K', 14 => 'A', 15 => '2', + 16 => '小王', 17 => '大王', + ]; + + public function __construct( + public int $rank, + public string $suit = '', + ) { + } + + public static function smallJoker(): self + { + return new self(self::JOKER_SMALL, 'JOKER'); + } + + public static function bigJoker(): self + { + return new self(self::JOKER_BIG, 'JOKER'); + } + + /** 人类可读标签,如「♠A」「大王」。 */ + public function label(): string + { + if ($this->rank >= self::JOKER_SMALL) { + return self::RANK_LABELS[$this->rank]; + } + + return $this->suit . self::RANK_LABELS[$this->rank]; + } + + /** 稳定 id,用于去重/比较(同点不同花色算不同牌)。 */ + public function id(): string + { + return $this->suit . ':' . $this->rank; + } + + public function isJoker(): bool + { + return $this->rank >= self::JOKER_SMALL; + } + + public function jsonSerialize(): array + { + return ['rank' => $this->rank, 'suit' => $this->suit, 'label' => $this->label()]; + } +} diff --git a/examples/onepiece-doudizhu-win32/php-src/doudizhu/Character.php b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Character.php new file mode 100644 index 00000000..37c09eb0 --- /dev/null +++ b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Character.php @@ -0,0 +1,90 @@ +skillId, + $this->skillName, + $this->skillDesc, + $this->skillTrigger, + $this->skillCost, + $this->faction, + ); + } + + /** @return list */ + public static function all(): array + { + return [ + // 海军本部 — 控制 / 反制 + new self('garp', '蒙奇·D·卡普', '拳骨', Faction::NAVY, 'garp_shock', '银河碎拳', + '出单/对时启用:该手牌附带冲击波,下家本 trick 强制 pass。', 'onPlay', 'once'), + new self('akainu', '萨卡斯基', '冥狗', Faction::NAVY, 'akainu_magma', '岩浆灼烧', + '对手出炸弹时反击:使其炸弹无效,并禁用其下个炸弹。', 'onBombPlayed', 'charges:2'), + new self('aokiji', '库赞', '冰河时代', Faction::NAVY, 'aokiji_freeze', '冻结', + '冻结一名对手 1 回合(自动 pass)。', 'onTurnStart', 'once'), + + // 王下七武海 — 诡诈 / 交换 + new self('mihawk', '鹰眼 米霍克', '世界第一大剑豪', Faction::WARLORD, 'mihawk_unblock', '黑刀·夜', + '出单/对时启用:该手牌不可被非炸拦截。', 'onPlay', 'charges:2'), + new self('boa', '波雅·汉库克', '女帝', Faction::WARLORD, 'boa_petrify', '虏之矢', + '石化一名对手 2 回合(禁用其技能)。', 'onTurnStart', 'once'), + new self('kuma', '巴索罗米·熊', '暴君', Faction::WARLORD, 'kuma_push', '肉球推送', + '将你最小的一张手牌推给指定对手(扰乱牌型)。', 'onTurnStart', 'once'), + + // 四皇 — 压制 / 掠夺 + new self('shanks', '香克斯', '红发', Faction::EMPEROR, 'shanks_haki', '霸王色霸气', + '出牌时花费 1 霸气:该手牌凌驾一切非炸(除非炸弹/火箭)。', 'onPlay', 'haki:1'), + new self('whitebeard', '爱德华·纽盖特', '白胡子', Faction::EMPEROR, 'whitebeard_quake', '震震果实', + '出炸弹时启用:炸弹威力 +1(视为更大炸弹)。', 'onBombPlayed', 'charges:2'), + new self('bigmom', '夏洛特·玲玲', '大妈', Faction::EMPEROR, 'bigmom_steal', '魂魂召唤', + '随机偷取一名对手 1 张手牌。', 'onTurnStart', 'once'), + ]; + } + + public static function byId(string $id): self + { + static $map = null; + if ($map === null) { + $map = []; + foreach (self::all() as $c) { + $map[$c->id] = $c; + } + } + + if (!isset($map[$id])) { + throw new \InvalidArgumentException("未知角色: $id"); + } + + return $map[$id]; + } + + /** @return list */ + public static function byFaction(string $faction): array + { + return \array_values(\array_filter(self::all(), static fn (self $c) => $c->faction === $faction)); + } +} diff --git a/examples/onepiece-doudizhu-win32/php-src/doudizhu/Combo.php b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Combo.php new file mode 100644 index 00000000..2a8ef633 --- /dev/null +++ b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Combo.php @@ -0,0 +1,252 @@ + 炸弹(比点) > 普通(同型同长比点)。技能修正(霸气/不可拦截) + * 由 Engine 在对局层处理,不在本类内。 + */ +final class Combo +{ + public function __construct( + public string $type, + public int $rank, // 主要比较点(炸弹/火箭用 17) + public int $length, // 顺子/连对/飞机的长度 + public array $cards, // list + public bool $isBomb = false, + public bool $isRocket = false, + ) { + } + + /** + * @param list $cards + */ + public static function parse(array $cards): ?self + { + $n = \count($cards); + if ($n === 0) { + return null; + } + + $byRank = []; + foreach ($cards as $c) { + $byRank[$c->rank][] = $c; + } + $ranks = \array_keys($byRank); + \sort($ranks); + $counts = []; + foreach ($ranks as $r) { + $counts[$r] = \count($byRank[$r]); + } + + // 火箭(双王) + if ($n === 2 && isset($byRank[Card::JOKER_SMALL]) && isset($byRank[Card::JOKER_BIG])) { + return new self('rocket', Card::JOKER_BIG, 1, $cards, false, true); + } + + // 炸弹(四同) + if ($n === 4 && \count($ranks) === 1) { + return new self('bomb', $ranks[0], 1, $cards, true); + } + + // 单 + if ($n === 1) { + return new self('single', $ranks[0], 1, $cards); + } + + // 对 + if ($n === 2 && \count($ranks) === 1) { + return new self('pair', $ranks[0], 1, $cards); + } + + // 三 + if ($n === 3 && \count($ranks) === 1) { + return new self('triple', $ranks[0], 1, $cards); + } + + // 三带一 + if ($n === 4 && \count($ranks) === 2) { + $triple = self::rankWithCount($counts, 3); + if ($triple !== null) { + return new self('triple1', $triple, 1, $cards); + } + + return null; + } + + // 三带二 + if ($n === 5 && \count($ranks) === 2) { + $triple = self::rankWithCount($counts, 3); + $pair = self::rankWithCount($counts, 2); + if ($triple !== null && $pair !== null) { + return new self('triple2', $triple, 1, $cards); + } + + return null; + } + + $maxRank = \max($ranks); + $allOnes = \min($counts) === 1 && \max($counts) === 1; + $allTwos = \min($counts) === 2 && \max($counts) === 2; + $allThrees = \min($counts) === 3 && \max($counts) === 3; + + // 顺子(≥5 连续单张,不含 2/王) + if ($allOnes && $n >= 5 && $maxRank <= 14 && self::isConsecutive($ranks)) { + return new self('straight', $maxRank, $n, $cards); + } + + // 连对(≥3 连续对子,不含 2/王) + if ($allTwos && $n >= 6 && $maxRank <= 14 && self::isConsecutive($ranks)) { + return new self('straight2', $maxRank, (int) ($n / 2), $cards); + } + + // 飞机(≥2 连续三张,可带翼) + if ($allThrees) { + return new self('plane', $maxRank, $n, $cards); + } + $plane = self::planeWithWings($ranks, $counts, $n, $cards); + if ($plane !== null) { + return $plane; + } + + return null; + } + + /** + * @param array $counts + */ + private static function rankWithCount(array $counts, int $want): ?int + { + foreach ($counts as $r => $c) { + if ($c === $want) { + return $r; + } + } + + return null; + } + + /** + * @param list $ranks + * @param array $counts + * @param list $cards + */ + private static function planeWithWings(array $ranks, array $counts, int $n, array $cards): ?self + { + $tripleRanks = []; + $wingRanks = []; + foreach ($ranks as $r) { + if ($counts[$r] === 3) { + $tripleRanks[] = $r; + } else { + $wingRanks[] = $r; + } + } + if (\count($tripleRanks) < 2 || !self::isConsecutive($tripleRanks) || \max($tripleRanks) > 14) { + return null; + } + $t = \count($tripleRanks); + $expectedWings = $n - 3 * $t; + + // 无翼 + if ($expectedWings === 0) { + return new self('plane', \max($tripleRanks), $t, $cards); + } + // 单翼:t 张单牌 + if ($expectedWings === $t && \count($wingRanks) === $t && self::allCount($wingRanks, $counts, 1)) { + return new self('plane1', \max($tripleRanks), $t, $cards); + } + // 对翼:t 个对子 + if ($expectedWings === 2 * $t && \count($wingRanks) === $t && self::allCount($wingRanks, $counts, 2)) { + return new self('plane2', \max($tripleRanks), $t, $cards); + } + + return null; + } + + /** + * @param list $ranks + * @param array $counts + */ + private static function allCount(array $ranks, array $counts, int $want): bool + { + foreach ($ranks as $r) { + if (($counts[$r] ?? 0) !== $want) { + return false; + } + } + + return true; + } + + /** + * @param list $ranks + */ + private static function isConsecutive(array $ranks): bool + { + $r = \array_values($ranks); + if (\count($r) < 2) { + return false; + } + \sort($r); + for ($i = 1; $i < \count($r); $i++) { + if ($r[$i] !== $r[$i - 1] + 1) { + return false; + } + } + + return true; + } + + /** a 是否能压过当前桌面 lastPlay(b)。b=null 表示自由出牌。 */ + public static function beats(self $a, ?self $b): bool + { + if ($b === null) { + return true; + } + if ($a->isRocket) { + return true; + } + if ($b->isRocket) { + return false; + } + if ($a->isBomb) { + if ($b->isBomb) { + return $a->rank > $b->rank; + } + + return true; + } + if ($b->isBomb) { + return false; + } + if ($a->type !== $b->type) { + return false; + } + if ($a->length !== $b->length) { + return false; + } + + return $a->rank > $b->rank; + } + + public function describe(): string + { + $names = [ + 'single' => '单张', 'pair' => '对子', 'triple' => '三张', + 'triple1' => '三带一', 'triple2' => '三带二', 'straight' => '顺子', + 'straight2' => '连对', 'plane' => '飞机', 'plane1' => '飞机带单', + 'plane2' => '飞机带对', 'bomb' => '炸弹', 'rocket' => '王炸', + ]; + $label = $names[$this->type] ?? $this->type; + + return \sprintf('%s(%s)', $label, \implode('', \array_map(static fn (Card $c) => $c->label(), $this->cards))); + } +} diff --git a/examples/onepiece-doudizhu-win32/php-src/doudizhu/Deck.php b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Deck.php new file mode 100644 index 00000000..d941a24b --- /dev/null +++ b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Deck.php @@ -0,0 +1,61 @@ + + */ + public static function build(): array + { + $cards = []; + foreach (Card::SUITS as $suit) { + for ($r = 3; $r <= 15; $r++) { + $cards[] = new Card($r, $suit); + } + } + $cards[] = Card::smallJoker(); + $cards[] = Card::bigJoker(); + + return $cards; + } + + /** + * 洗牌发牌。 + * + * @return array{hands: array>, bottom: list} + */ + public static function deal(): array + { + $cards = self::build(); + \shuffle($cards); + + $hands = [[], [], []]; + for ($i = 0; $i < 51; $i++) { + $hands[$i % 3][] = $cards[$i]; + } + $bottom = \array_slice($cards, 51, 3); + + foreach ($hands as $i => $hand) { + self::sort($hands[$i]); + } + + return ['hands' => $hands, 'bottom' => $bottom]; + } + + /** + * 按牌力升序排序(同点花色保持稳定)。 + * + * @param list $hand + */ + public static function sort(array &$hand): void + { + \usort($hand, static fn (Card $a, Card $b): int => $a->rank <=> $b->rank); + } +} diff --git a/examples/onepiece-doudizhu-win32/php-src/doudizhu/Faction.php b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Faction.php new file mode 100644 index 00000000..81ab4763 --- /dev/null +++ b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Faction.php @@ -0,0 +1,48 @@ + */ + public static array $defs = [ + self::NAVY => [ + 'name' => '海军本部', + 'creed' => '绝对正义', + 'trait' => '正义铁拳:每局可反制一次对手炸弹;情报:叫完地主可查看底牌。', + 'color' => '#1e3a8a', + ], + self::WARLORD => [ + 'name' => '王下七武海', + 'creed' => '被招安的海盗', + 'trait' => '协定漏洞:被迫过牌时可改出最小单张续命(2 次);凭实力借:每局交换 1 张牌。', + 'color' => '#7c3aed', + ], + self::EMPEROR => [ + 'name' => '四皇', + 'creed' => '新世界霸主', + 'trait' => '霸王色霸气:持有霸气 token,出牌时花费使其凌驾一切非炸;召集:每局抽 1 张。', + 'color' => '#b91c1c', + ], + ]; + + public static function name(string $id): string + { + return self::$defs[$id]['name'] ?? $id; + } + + public static function color(string $id): string + { + return self::$defs[$id]['color'] ?? '#444444'; + } +} diff --git a/examples/onepiece-doudizhu-win32/php-src/doudizhu/Game.php b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Game.php new file mode 100644 index 00000000..6686fbc2 --- /dev/null +++ b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Game.php @@ -0,0 +1,501 @@ + */ + public array $players; + public ?int $landlord = null; + public int $turn = 0; + + /** @var array{player:int, combo:Combo, hakiActive:bool, unblockable:bool, bombBonus:int}|null */ + public ?array $lastPlay = null; + public int $passes = 0; + public string $phase = 'bidding'; + public ?int $winner = null; + public ?string $winnerSide = null; + /** @var list */ + public array $log = []; + + /** 战场修正(按目标玩家) */ + public array $mods = [ + 'skipNext' => [false, false, false], + 'frozen' => [0, 0, 0], + 'petrified' => [0, 0, 0], + 'bombDisabled' => [0, 0, 0], + ]; + + /** 叫分状态 */ + public array $bidding = [ + 'scores' => [0, 0, 0], + 'current' => 0, + 'highest' => 0, + 'highestPlayer' => null, + 'acted' => [false, false, false], + ]; + + /** @var list 底牌(叫完分给地主) */ + public array $bottom = []; + /** 是否已把底牌发给地主(用于「情报」查看) */ + public bool $bottomRevealed = false; + + /** 事件回调(音效/UI 钩子):fn(event, payload) */ + public $onEvent = null; + + /** + * @param list $players + * @param list $bottom + */ + public function __construct(array $players, array $bottom) + { + $this->players = $players; + $this->bottom = $bottom; + $this->turn = 0; + } + + public function side(int $player): string + { + return $player === $this->landlord ? 'landlord' : 'peasant'; + } + + public function isOver(): bool + { + return $this->phase === 'over'; + } + + public function handCount(int $player): int + { + return \count($this->players[$player]->hand); + } + + // ---------------------------------------------------------------- 叫地主 + /** + * @return 'ok'|'redeal'|'started' + */ + public function bid(int $player, int $score): string + { + if ($this->phase !== 'bidding') { + throw new RuntimeException('not in bidding phase'); + } + if ($this->bidding['acted'][$player]) { + throw new InvalidArgumentException('player already bid'); + } + $score = \max(0, \min(3, $score)); + $this->bidding['scores'][$player] = $score; + $this->bidding['acted'][$player] = true; + if ($score > $this->bidding['highest']) { + $this->bidding['highest'] = $score; + $this->bidding['highestPlayer'] = $player; + } + $this->emit('bid', ['player' => $player, 'score' => $score]); + + $actedCount = \count(\array_filter($this->bidding['acted'])); + if ($actedCount < 3 && $score < 3) { + // 轮到下一位 + $this->bidding['current'] = ($player + 1) % 3; + + return 'ok'; + } + + // 结束叫分 + if ($this->bidding['highest'] === 0) { + return 'redeal'; + } + $this->startPlaying(); + + return 'started'; + } + + private function startPlaying(): void + { + $this->landlord = $this->bidding['highestPlayer']; + $this->players[$this->landlord]->isLandlord = true; + // 给地主发底牌 + foreach ($this->bottom as $c) { + $this->players[$this->landlord]->hand[] = $c; + } + Deck::sort($this->players[$this->landlord]->hand); + $this->bottomRevealed = true; + $this->turn = $this->landlord; + $this->phase = 'playing'; + $this->emit('gameStart', ['landlord' => $this->landlord]); + } + + // ---------------------------------------------------------------- 出牌 + /** + * @param list $cards + * @param int|null $skillTarget 部分技能需要指定目标 + */ + public function play(int $player, array $cards, ?int $skillTarget = null): void + { + $this->ensureTurn($player); + if ($this->mods['frozen'][$player] > 0) { + throw new RuntimeException('玩家被冻结,本回合无法出牌'); + } + $combo = Combo::parse($cards); + if ($combo === null) { + throw new InvalidArgumentException('无效的牌型'); + } + $this->assertCardsInHand($player, $cards); + + // 比较:考虑上一手的技能修正 + if ($this->lastPlay !== null) { + if (!$this->beatsWithMods($combo, $this->lastPlay)) { + throw new InvalidArgumentException('压不过上一手'); + } + } + + // 白胡子:自身炸弹 +1(arm 标记 bombBonus) + $bombBonus = 0; + $hakiActive = false; + $unblockable = false; + $ps = $this->players[$player]; + if ($ps->armed !== null) { + $skill = Character::byId($ps->characterId)->skill(); + if ($skill->trigger === 'onPlay' && $this->comboQualifies($combo, $skill)) { + if ($skill->id === 'shanks_haki') { + $hakiActive = true; + } + if ($skill->id === 'mihawk_unblock') { + $unblockable = true; + } + if ($skill->id === 'garp_shock') { + // 下家跳过:在 advanceTurn 时处理 + $this->mods['skipNext'][($player + 1) % 3] = true; + } + } + if ($skill->trigger === 'onBombPlayed' && $combo->isBomb) { + if ($skill->id === 'whitebeard_quake') { + $bombBonus = 1; + } + } + // 技能真正生效时才扣费(once/charges/haki),防止无限使用 + $applied = ($skill->trigger === 'onPlay' && $this->comboQualifies($combo, $skill)) + || ($skill->trigger === 'onBombPlayed' && $combo->isBomb); + if ($applied) { + $this->consume($player, $skill); + } + $ps->armed = null; + } + + // 移除手牌 + $this->removeCards($player, $cards); + $this->lastPlay = [ + 'player' => $player, + 'combo' => $combo, + 'hakiActive' => $hakiActive, + 'unblockable' => $unblockable, + 'bombBonus' => $bombBonus, + ]; + $this->passes = 0; + $this->emit('play', [ + 'player' => $player, + 'combo' => $combo, + 'hakiActive' => $hakiActive, + 'unblockable' => $unblockable, + 'bombBonus' => $bombBonus, + ]); + + // 赤犬反击窗口:若有人 armed 赤犬,自动在 counterBomb 中处理(AI/UI 调用) + // 此处不自动触发 + + if ($this->handCount($player) === 0) { + $this->finish($player); + + return; + } + + $this->advanceTurn(); + } + + public function pass(int $player): void + { + $this->ensureTurn($player); + if ($this->lastPlay === null) { + throw new InvalidArgumentException('首出不能过'); + } + // 七武海「续命」:技能可改出最小单张(由 armSkill 处理,这里直接过) + $this->passes++; + $this->emit('pass', ['player' => $player]); + $this->advanceTurn(); + } + + /** 赤犬反击:取消对手刚出的炸弹。 */ + public function counterBomb(int $player): void + { + if ($this->lastPlay === null || !$this->lastPlay['combo']->isBomb) { + throw new RuntimeException('当前没有可反击的炸弹'); + } + $ps = $this->players[$player]; + if ($ps->characterId === null) { + throw new RuntimeException('无角色'); + } + $skill = Character::byId($ps->characterId)->skill(); + if ($skill->id !== 'akainu_magma') { + throw new RuntimeException('该角色无反击炸弹技能'); + } + if (!$this->canUse($player, $skill)) { + throw new RuntimeException('技能不可用'); + } + $bomber = $this->lastPlay['player']; + // 把炸弹牌还给出牌者 + foreach ($this->lastPlay['combo']->cards as $c) { + $this->players[$bomber]->hand[] = $c; + } + Deck::sort($this->players[$bomber]->hand); + // 该 trick 作废,反击者获得 lead 权 + $this->lastPlay = null; + $this->passes = 0; + $this->consume($player, $skill); + $this->mods['bombDisabled'][$bomber] = 2; // 其下个炸弹禁用 + $this->turn = $player; + $this->emit('counter', ['player' => $player, 'target' => $bomber]); + } + + // ---------------------------------------------------------------- 技能 + public function canUse(int $player, Skill $skill): bool + { + if ($this->phase !== 'playing') { + return false; + } + if ($this->mods['petrified'][$player] > 0) { + return false; // 被石化 + } + $ps = $this->players[$player]; + if ($skill->cost === 'once' && $ps->skill['usedOnce']) { + return false; + } + if (\str_starts_with($skill->cost, 'charges:')) { + $n = (int) \substr($skill->cost, 8); + + return $ps->skill['chargesLeft'] >= $n; + } + if (\str_starts_with($skill->cost, 'haki:')) { + $n = (int) \substr($skill->cost, 5); + + return $ps->skill['haki'] >= $n; + } + + return true; + } + + /** + * 激活技能。onTurnStart 立即生效;onPlay/onBombPlayed 预置到下一手。 + */ + public function armSkill(int $player, ?int $target = null): void + { + if ($this->phase !== 'playing') { + throw new RuntimeException('非出牌阶段'); + } + $ps = $this->players[$player]; + if ($ps->characterId === null) { + throw new RuntimeException('玩家未分配角色'); + } + $skill = Character::byId($ps->characterId)->skill(); + if (!$this->canUse($player, $skill)) { + throw new RuntimeException('技能不可用(费用不足或被石化)'); + } + + if ($skill->trigger === 'onTurnStart') { + Skill::apply($this, $player, $target); + $this->consume($player, $skill); + $this->emit('skill', ['player' => $player, 'skill' => $skill->id, 'target' => $target]); + + return; + } + + // onPlay / onBombPlayed:预置 + if ($ps->armed !== null) { + throw new RuntimeException('已有预置技能'); + } + $ps->armed = $skill->id; + $ps->armedTarget = $target; + $this->emit('arm', ['player' => $player, 'skill' => $skill->id]); + } + + private function consume(int $player, Skill $skill): void + { + $ps = $this->players[$player]; + if ($skill->cost === 'once') { + $ps->skill['usedOnce'] = true; + } elseif (\str_starts_with($skill->cost, 'charges:')) { + $n = (int) \substr($skill->cost, 8); + $ps->skill['chargesLeft'] -= $n; + } elseif (\str_starts_with($skill->cost, 'haki:')) { + $n = (int) \substr($skill->cost, 5); + $ps->skill['haki'] -= $n; + } + } + + private function comboQualifies(Combo $combo, Skill $skill): bool + { + if ($skill->id === 'garp_shock' || $skill->id === 'mihawk_unblock') { + return $combo->type === 'single' || $combo->type === 'pair'; + } + if ($skill->id === 'shanks_haki') { + return true; // 任意手牌可附霸气 + } + + return true; + } + + // ---------------------------------------------------------------- 流转 + private function ensureTurn(int $player): void + { + if ($this->phase !== 'playing') { + throw new RuntimeException('非出牌阶段'); + } + if ($player !== $this->turn) { + throw new InvalidArgumentException('还没轮到该玩家'); + } + } + + private function advanceTurn(): void + { + // trick 结束:连续两人 pass + if ($this->passes >= 2) { + $this->lastPlay = null; + $this->passes = 0; + // lead 权归上一个出牌者(this->turn 已是最后出牌者,因 pass 不改变 lastPlay 持有者) + } + $next = ($this->turn + 1) % 3; + // 跳过被冻结/被跳过的玩家 + $guard = 0; + while ($guard < 3) { + if ($this->mods['skipNext'][$next]) { + $this->mods['skipNext'][$next] = false; + $this->emit('skip', ['player' => $next]); + $next = ($next + 1) % 3; + $guard++; + + continue; + } + if ($this->mods['frozen'][$next] > 0) { + $this->mods['frozen'][$next]--; + $this->emit('frozenSkip', ['player' => $next]); + $next = ($next + 1) % 3; + $guard++; + + continue; + } + break; + } + // 递减石化/炸弹禁用计数(在对应玩家回合开始时) + if ($this->mods['petrified'][$next] > 0) { + $this->mods['petrified'][$next]--; + } + if ($this->mods['bombDisabled'][$next] > 0) { + $this->mods['bombDisabled'][$next]--; + } + $this->turn = $next; + } + + private function finish(int $player): void + { + $this->winner = $player; + $this->winnerSide = $this->side($player); + $this->phase = 'over'; + $this->emit('gameOver', ['winner' => $player, 'side' => $this->winnerSide]); + } + + // ---------------------------------------------------------------- 比较(含技能修正) + /** + * @param array{combo:Combo,hakiActive:bool,unblockable:bool,bombBonus:int} $last + */ + private function beatsWithMods(Combo $a, array $last): bool + { + $b = $last['combo']; + $bombBonus = $last['bombBonus']; + // 复制 b 并叠加炸弹加成用于比较 + if ($bombBonus > 0 && $b->isBomb) { + $b = new Combo($b->type, $b->rank + $bombBonus, $b->length, $b->cards, true); + } + + // 霸气/不可拦截:非炸不可压 + if (($last['hakiActive'] || $last['unblockable']) && !$a->isBomb && !$a->isRocket) { + return false; + } + + return Combo::beats($a, $b); + } + + // ---------------------------------------------------------------- 手牌校验 + /** + * @param list $cards + */ + private function assertCardsInHand(int $player, array $cards): void + { + $handIds = []; + foreach ($this->players[$player]->hand as $c) { + $handIds[$c->id()] = true; + } + foreach ($cards as $c) { + if (!isset($handIds[$c->id()])) { + throw new InvalidArgumentException('手牌中没有该牌: ' . $c->label()); + } + $handIds[$c->id()] = false; // 防止同 id 重复(同点不同花色安全) + } + } + + /** + * @param list $cards + */ + private function removeCards(int $player, array $cards): void + { + $remove = []; + foreach ($cards as $c) { + $remove[$c->id()] = ($remove[$c->id()] ?? 0) + 1; + } + $kept = []; + foreach ($this->players[$player]->hand as $c) { + if (($remove[$c->id()] ?? 0) > 0) { + $remove[$c->id()]--; + } else { + $kept[] = $c; + } + } + $this->players[$player]->hand = $kept; + } + + // ---------------------------------------------------------------- 合法着法(AI/提示) + /** + * @return list + */ + public function legalMoves(int $player): array + { + $hand = $this->players[$player]->hand; + $all = MoveGenerator::all($hand); + if ($this->lastPlay === null) { + return $all; + } + + return \array_values(\array_filter($all, fn (Combo $c) => $this->beatsWithMods($c, $this->lastPlay))); + } + + // ---------------------------------------------------------------- 事件 + /** + * @param mixed $payload + */ + private function emit(string $event, $payload): void + { + if (\is_callable($this->onEvent)) { + ($this->onEvent)($event, $payload); + } + $this->log[] = \sprintf('[%s] %s', $event, \is_array($payload) ? \json_encode($payload) : (string) $payload); + } +} diff --git a/examples/onepiece-doudizhu-win32/php-src/doudizhu/GameController.php b/examples/onepiece-doudizhu-win32/php-src/doudizhu/GameController.php new file mode 100644 index 00000000..464ed182 --- /dev/null +++ b/examples/onepiece-doudizhu-win32/php-src/doudizhu/GameController.php @@ -0,0 +1,1570 @@ + int */ +function hx(string|int $hex): int +{ + if (\is_int($hex)) { + return $hex; + } + + return \hexdec(\ltrim($hex, '#')); +} + +/** int hex -> 归一化 [r,g,b,a](用于渐变 stop) */ +function rgbN(int $hex, float $a = 1.0): array +{ + return [ + (($hex >> 16) & 0xFF) / 255.0, + (($hex >> 8) & 0xFF) / 255.0, + ($hex & 0xFF) / 255.0, + $a, + ]; +} + +/** 点是否在矩形内 */ +function inside(array $r, float $x, float $y): bool +{ + return $x >= $r[0] && $x <= $r[0] + $r[2] && $y >= $r[1] && $y <= $r[1] + $r[3]; +} + +/** + * 命中最顶层(最后绘制)的图形。 + * + * 卡牌按 $hand 顺序从左到右绘制,后绘制的牌叠在先绘制的牌之上(重叠 28px)。 + * 点击重叠区域时,视觉上位于顶层的牌应是后者。因此必须**反向**遍历, + * 让后绘制的(更靠右、更靠上)矩形优先匹配——这样点第一张牌露出部分选中第一张, + * 点第二张牌盖住第一张的部分则选中第二张,符合直觉的层级关系。 + * + * @param array $rects 以绘制顺序为正序的命中矩形表 + */ +function hitTopmost(array $rects, float $x, float $y): ?string +{ + $keys = \array_reverse(\array_keys($rects), true); + foreach ($keys as $id) { + if (inside($rects[$id], $x, $y)) { + return (string) $id; + } + } + + return null; +} + +/* ============================ Game Controller ============================ */ + +/** @var bool 开启调试日志(stdout 输出,方便排查卡死) */ +const DEBUG_LOG = true; + +final class GameController +{ + // Window geometry (fixed for the Win32 build). + public const WIN_W = 960; + public const WIN_H = 760; + public const BTN_H = 64; // reserved bottom action-bar height + + public Game $game; + public int $human = 0; + public string $mode = 'select'; // select | bid | play | over + public string $message = '选择你的阵营与将领'; + /** @var list */ + public array $selected = []; + public ?int $pendingTarget = null; // 选择技能目标中 + /** @var array */ + public array $charIds = [0 => '', 1 => '', 2 => '']; + public string $humanCharId = ''; + + // ---- Win32 / TypePHP port state (replaces libui Area/Label/Button) ---- + public int $hWnd = 0; + /** @var array */ + public array $actDefs = []; + /** @var array index => [x,y,w,h] | null */ + public array $btnRects = []; + public ?array $soundRect = null; + public string $soundLabel = '🔊 音效'; + public bool $soundOn = true; + public ?int $aiTimer = null; + public ?int $bidTimer = null; + + /** @var array 命中测试矩形 */ + public array $hit = ['hand' => [], 'opp' => [], 'select' => []]; + /** @var list */ + public array $gameLog = []; + + /** 是否处于托管(自动出牌)模式 */ + public bool $autoPlay = false; + + /** 上次出牌的快照(lastPlay 被 pass 清空后仍保留,用于出牌区显示) */ + public ?array $lastShownPlay = null; + + /** 拖拽选牌状态:是否正在拖拽 */ + private bool $dragging = false; + /** 拖拽模式:true=连选(把经过的牌加入),false=连消(把经过的牌移除) */ + private bool $dragModeSelect = true; + /** @var array 本次拖拽已处理过的卡牌 id,避免重复切换造成闪烁 */ + private array $dragTouched = []; + + /** @var array */ + private array $timers = []; + private int $timerSeq = 0; + + public function __construct() + { + $this->actDefs = []; + $this->btnRects = []; + $this->hit = ['hand' => [], 'opp' => [], 'select' => []]; + $this->gameLog = []; + $this->soundLabel = '🔊 音效'; + $this->soundOn = true; + } + + /* --------------------------- lifecycle --------------------------- */ + + /** 调试日志:带时间戳写入 STDOUT。关闭时设 DEBUG_LOG = false 即可零开销。 */ + private static function dbg(string $msg): void + { + if (DEBUG_LOG) { + \fwrite(STDOUT, '[' . \date('H:i:s') . '] [DDZ] ' . $msg . "\n"); + } + } + + public function newGame(): void + { + $this->cancelTimers(); + $this->mode = 'select'; + $this->message = '选择你的阵营与将领,开始对局'; + $this->selected = []; + $this->pendingTarget = null; + $this->gameLog = []; + $this->autoPlay = false; + $this->lastShownPlay = null; + $this->dragging = false; + $this->dragTouched = []; + $this->redraw(); + } + + /** @param array $charIds */ + private function buildGame(array $charIds): void + { + $this->cancelTimers(); + $deal = Deck::deal(); + $players = []; + foreach ($charIds as $i => $cid) { + $c = Character::byId($cid); + $ps = new PlayerState($deal['hands'][$i]); + $ps->characterId = $c->id; + $ps->faction = $c->faction; + $ps->initSkill($c->skill()); + $players[] = $ps; + } + $this->charIds = $charIds; + $this->humanCharId = $charIds[$this->human]; + $this->game = new Game($players, $deal['bottom']); + $this->game->onEvent = function (string $event, $payload): void { + $this->onGameEvent($event, $payload); + }; + $this->selected = []; + $this->pendingTarget = null; + $this->gameLog = []; + } + + public function startMatch(string $charId): void + { + self::dbg("startMatch(charId={$charId})"); + $this->autoPlay = false; // 每局开始重置托管状态 + $all = Character::all(); + $others = \array_values(\array_filter($all, static fn (Character $c): bool => $c->id !== $charId)); + \shuffle($others); + $charIds = [$charId, $others[0]->id, $others[1]->id]; + self::dbg(" charIds=" . \implode(',', $charIds)); + $this->buildGame($charIds); + $this->enterBidding(); + } + + private function redeal(): void + { + $this->buildGame($this->charIds); + } + + /* ----------------------------- bidding --------------------------- */ + + private function enterBidding(): void + { + self::dbg("enterBidding()"); + $this->mode = 'bid'; + $this->message = '叫地主阶段:你的回合,请选择叫分'; + $this->setBidActions(); + $this->redraw(); + } + + private function setBidActions(): void + { + $this->applyActions([ + ['重新选将', true, fn () => $this->newGame()], + ['不叫', true, fn () => $this->humanBid(0)], + ['1 分', true, fn () => $this->humanBid(1)], + ['2 分', true, fn () => $this->humanBid(2)], + ['3 分', true, fn () => $this->humanBid(3)], + ]); + } + + public function humanBid(int $score): void + { + self::dbg("humanBid(score={$score}) mode={$this->mode} current={$this->game->bidding['current']} human={$this->human}"); + if ($this->mode !== 'bid' || $this->game->bidding['current'] !== $this->human) { + self::dbg(" → SKIP (not your turn or wrong mode)"); + + return; + } + try { + $res = $this->game->bid($this->human, $score); + } catch (\Throwable $e) { + self::dbg(" → EXCEPTION: " . get_class($e) . ': ' . $e->getMessage()); + $this->message = '叫分失败:' . $e->getMessage(); + + return; + } + self::dbg(" → bid result='{$res}' highest={$this->game->bidding['highest']} acted=" . \json_encode($this->game->bidding['acted'])); + if ($res === 'started') { + $this->enterPlaying(); + + return; + } + if ($res === 'redeal') { + $this->message = '无人叫地主,重新发牌'; + $this->redeal(); + $this->enterBidding(); + + return; + } + // 'ok' —— 轮到 AI + $this->scheduleBidStep(); + } + + private function scheduleBidStep(): void + { + self::dbg("scheduleBidStep() phase={$this->game->phase} current={$this->game->bidding['current']} human={$this->human}"); + if ($this->game->phase !== 'bidding') { + self::dbg(" → SKIP (not in bidding phase)"); + + return; + } + if ($this->game->bidding['current'] === $this->human) { + self::dbg(" → human's turn, setBidActions"); + $this->setBidActions(); + $this->message = '请叫分'; + $this->redraw(); + + return; + } + $p = $this->game->bidding['current']; + self::dbg(" → AI player={$p} turn, scheduling tick..."); + $this->applyActions([['重新选将', true, fn () => $this->newGame()], ['对手叫分中…', false, null], ['', false, null], ['', false, null], ['', false, null]]); + $this->message = '对手叫分中…'; + $this->redraw(); + // 用 delay() 驱动 AI 叫分:保留 ~700ms 节奏,避免对手瞬间叫完。 + $done = false; + $doBid = function () use (&$done, $p): void { + if ($done) { + return; + } + $done = true; + try { + self::dbg("BID-STEP(tick) for player={$p} phase={$this->game->phase}"); + $aiScore = Ai::bid($this->game->players[$p]->hand); + $res = $this->game->bid($p, $aiScore); + self::dbg(" AI({$p}) bid={$aiScore} result='{$res}' highest={$this->game->bidding['highest']} acted=" . \json_encode($this->game->bidding['acted'])); + if ($res === 'started') { + self::dbg(" → enterPlaying()"); + $this->enterPlaying(); + } elseif ($res === 'redeal') { + self::dbg(" → redeal + enterBidding()"); + $this->message = '无人叫地主,重新发牌'; + $this->redeal(); + $this->enterBidding(); + } else { + self::dbg(" → scheduleBidStep() again"); + $this->scheduleBidStep(); + } + } catch (\Throwable $e) { + self::dbg(" BID-STEP EXCEPTION: " . get_class($e) . ': ' . $e->getMessage() . "\n" . $e->getTraceAsString()); + } + }; + $this->bidTimer = $this->delay(700, $doBid); + } + + /* ------------------------------ play ----------------------------- */ + + private function enterPlaying(): void + { + self::dbg("enterPlaying() landlord={$this->game->landlord}"); + $this->mode = 'play'; + $ll = $this->game->landlord; + $name = Character::byId($this->game->players[$ll]->characterId)->name; + if ($ll === $this->human) { + $this->message = '你成为了地主!'; + self::dbg(" → YOU are landlord!"); + } else { + $this->message = '地主:' . $name . ' —— 你是' . ($this->game->side($this->human) === 'landlord' ? '地主' : '农民'); + self::dbg(" → landlord is {$name}, you are " . ($this->game->side($this->human) === 'landlord' ? 'landlord' : 'farmer')); + } + $this->scheduleAi(); + } + + private function scheduleAi(): void + { + self::dbg("scheduleAi() phase={$this->game->phase} turn={$this->game->turn} human={$this->human} isOver={$this->game->isOver()}"); + if ($this->game->isOver()) { + self::dbg(" → game over, endGame"); + $this->endGame(); + + return; + } + if ($this->game->phase !== 'playing') { + self::dbg(" → SKIP (not in playing phase)"); + + return; + } + if ($this->game->turn === $this->human) { + self::dbg(" → human's turn, humanTurn()"); + $this->humanTurn(); + + return; + } + $this->applyActions([['重新选将', true, fn () => $this->newGame()], ['对手行动中…', false, null], ['', false, null], ['', false, null], ['', false, null]]); + $this->message = '对手行动中…'; + $this->redraw(); + // 用 delay() 驱动 AI 走子:Win32 移植版里 defer 与 delay 都由主循环 + // tick() 触发,机制相同;这里只用 delay 保留思考节奏(~800ms), + // 避免 AI 瞬间行动、玩家跟不上牌局。$moved 防重复触发。 + $moved = false; + $doMove = function () use (&$moved): void { + if ($moved) { + return; + } + $moved = true; + try { + self::dbg("AI-STEP(tick) turn={$this->game->turn} phase={$this->game->phase}"); + $this->aiStep(); + } catch (\Throwable $e) { + self::dbg(" AI-STEP EXCEPTION: " . get_class($e) . ': ' . $e->getMessage() . "\n" . $e->getTraceAsString()); + } + }; + $this->aiTimer = $this->delay(800, $doMove); + } + + public function aiStep(): void + { + self::dbg("aiStep() turn={$this->game->turn} phase={$this->game->phase} isOver={$this->game->isOver()}"); + if ($this->game->isOver()) { + self::dbg(" → endGame"); + $this->endGame(); + + return; + } + if ($this->game->turn === $this->human) { + self::dbg(" → human's turn, humanTurn()"); + $this->humanTurn(); + + return; + } + $actor = $this->game->turn; + self::dbg(" → AI({actor}) acting..."); + Ai::act($this->game, $actor); + $this->tryAiCounterBomb(); + $this->redraw(); + self::dbg(" → AI({actor}) done, isOver={$this->game->isOver()}"); + if ($this->game->isOver()) { + $this->endGame(); + + return; + } + $this->scheduleAi(); + } + + /** AI 中的赤犬(akainu)可对刚出的炸弹发动反击。 */ + private function tryAiCounterBomb(): void + { + if ($this->game->lastPlay === null || !$this->game->lastPlay['combo']->isBomb) { + return; + } + foreach ([1, 2] as $p) { + if ($p === $this->human) { + continue; + } + if (Ai::maybeCounter($this->game, $p)) { + return; + } + } + } + + private function humanTurn(): void + { + self::dbg("humanTurn() mode={$this->mode} isOver={$this->game->isOver()}"); + if ($this->game->isOver()) { + self::dbg(" → endGame"); + $this->endGame(); + + return; + } + $this->message = $this->pendingTarget !== null ? '请点击一名对手作为技能目标' : '你的回合'; + $this->setActionsForHumanTurn(); + $this->redraw(); + } + + private function setActionsForHumanTurn(): void + { + $moves = $this->game->legalMoves($this->human); + $playEnabled = $this->game->lastPlay === null || $moves !== []; + $passEnabled = $this->game->lastPlay !== null; + + if ($this->pendingTarget !== null) { + $playEnabled = false; + $passEnabled = false; + } + + $skillLabel = '技能'; + $skillEnabled = false; + $skillCb = null; + if ($this->humanCharId === 'akainu') { + $canCounter = $this->game->lastPlay !== null + && $this->game->lastPlay['combo']->isBomb + && $this->game->canUse($this->human, Character::byId('akainu')->skill()); + $skillLabel = $canCounter ? '🔥岩浆反击' : '技能(被动)'; + $skillEnabled = $canCounter; + $skillCb = $canCounter ? fn () => $this->counterNow() : null; + } elseif ($this->pendingTarget !== null) { + $skillLabel = '取消'; + $skillEnabled = true; + $skillCb = fn () => $this->cancelTarget(); + } else { + $skill = Character::byId($this->humanCharId)->skill(); + $skillEnabled = $this->game->canUse($this->human, $skill); + $skillCb = fn () => $this->useSkill(); + } + + $this->applyActions([ + ['重新选将', true, fn () => $this->newGame()], + ['出牌', $playEnabled && !$this->autoPlay, fn () => $this->humanPlay()], + ['不出', $passEnabled && !$this->autoPlay, fn () => $this->humanPass()], + ['提示', true && !$this->autoPlay, fn () => $this->hint()], + [$skillLabel, $skillEnabled && !$this->autoPlay, $skillCb], + [$this->autoPlay ? '取消托管' : '托管', true, fn () => $this->toggleAutoPlay()], + ]); + + // 托管模式:延迟约 1.5s 后自动出牌(类人速度,给玩家充足时间点「取消托管」) + if ($this->autoPlay) { + $this->message = '托管中…'; + $this->redraw(); + $done = false; + $step = function () use (&$done): void { + if ($done) { + return; + } + $done = true; + $this->autoPlayStep(); + }; + $this->aiTimer = $this->delay(1500, $step); + } + } + + /** 执行一次托管自动出牌 */ + public function autoPlayStep(): void + { + if ($this->mode !== 'play' || $this->game->isOver()) { + return; + } + if ($this->game->turn !== $this->human || !$this->autoPlay) { + return; + } + + self::dbg("AUTO-PLAY: executing AI move for human"); + + try { + // 复用 AI 决策逻辑 + $moves = $this->game->legalMoves($this->human); + if ($this->game->lastPlay === null) { + // 领出:用 pickLead 选牌 + $combo = Ai::pickLead($this->game, $this->human, $moves) ?? ($moves[0] ?? null); + } else { + // 跟出:用 pickFollow 选牌或 pass + $combo = Ai::pickFollow($this->game, $this->human, $moves); + } + + if ($combo !== null) { + $cards = $combo->cards; + $this->game->play($this->human, $cards); + self::dbg("AUTO-PLAY: played " . $combo->describe()); + } else { + $this->game->pass($this->human); + self::dbg("AUTO-PLAY: passed"); + } + } catch (\Throwable $e) { + self::dbg("AUTO-PLAY ERROR: " . get_class($e) . ': ' . $e->getMessage()); + } + + $this->redraw(); + if (!$this->game->isOver()) { + $this->scheduleAi(); + } else { + $this->endGame(); + } + } + + /** 切换托管状态 */ + public function toggleAutoPlay(): void + { + $this->autoPlay = !$this->autoPlay; + self::dbg("AUTO-PLAY toggle: " . ($this->autoPlay ? 'ON' : 'OFF')); + if (!$this->autoPlay) { + // 关闭托管:立即取消挂起的托管自动出牌定时器,玩家可手动接管 + $this->cancelTimers(); + // 重新计算按钮状态:托管时「出牌/不出/提示/技能」被禁用, + // 恢复后必须重算才能重新可点(仅轮到玩家且在出牌阶段时) + if ($this->mode === 'play' && $this->game->turn === $this->human && !$this->game->isOver()) { + $this->setActionsForHumanTurn(); + } + $this->redraw(); + + return; + } + if ($this->mode === 'play' && $this->game->turn === $this->human && $this->autoPlay) { + // 打开托管且轮到玩家:稍后自动出牌(1.5s 后),给玩家取消的机会 + $this->message = '托管中…'; + $this->setActionsForHumanTurn(); + } else { + $this->redraw(); + } + } + + private function applyActions(array $defs): void + { + $n = \count($defs); + for ($i = 0; $i < 6; $i++) { + if ($i < $n) { + $d = $defs[$i]; + $this->actDefs[$i] = ['label' => $d[0], 'enabled' => (bool) $d[1], 'cb' => $d[2]]; + } else { + $this->actDefs[$i] = ['label' => '', 'enabled' => false, 'cb' => null]; + } + } + } + + /** @return list */ + private function selectedCards(): array + { + $map = []; + foreach ($this->game->players[$this->human]->hand as $c) { + $map[$c->id()] = $c; + } + $out = []; + foreach ($this->selected as $id) { + if (isset($map[$id])) { + $out[] = $map[$id]; + } + } + + return $out; + } + + public function humanPlay(): void + { + $cards = $this->selectedCards(); + if ($cards === []) { + $this->message = '请先点击选择要出的牌'; + + return; + } + try { + $this->game->play($this->human, $cards); + } catch (\Throwable $e) { + $this->message = '出牌无效:' . $e->getMessage(); + $this->redraw(); + + return; + } + $this->selected = []; + $this->tryAiCounterBomb(); + if ($this->game->isOver()) { + $this->endGame(); + + return; + } + $this->scheduleAi(); + } + + public function humanPass(): void + { + if ($this->game->lastPlay === null) { + $this->message = '你是首出,不能不出'; + $this->redraw(); + + return; + } + try { + $this->game->pass($this->human); + } catch (\Throwable $e) { + $this->message = '无法不出:' . $e->getMessage(); + $this->redraw(); + + return; + } + $this->selected = []; + if ($this->game->isOver()) { + $this->endGame(); + + return; + } + $this->scheduleAi(); + } + + public function hint(): void + { + if ($this->game->turn !== $this->human) { + return; + } + $moves = $this->game->legalMoves($this->human); + if ($moves === []) { + $this->message = '没有能出的牌,请点「不出」'; + $this->redraw(); + + return; + } + $mv = $this->game->lastPlay === null + ? Ai::pickLead($this->game, $this->human, $moves) + : Ai::pickFollow($this->game, $this->human, $moves); + if ($mv === null) { + $this->message = '没有能出的牌,请点「不出」'; + $this->redraw(); + + return; + } + $this->selected = \array_map(static fn (Card $c): string => $c->id(), $mv->cards); + $this->message = '已为你选好一手,点击「出牌」'; + $this->setActionsForHumanTurn(); + $this->redraw(); + } + + public function useSkill(): void + { + $skill = Character::byId($this->humanCharId)->skill(); + if ($skill->trigger === 'onTurnStart') { + $needsTarget = \in_array($skill->id, ['aokiji_freeze', 'boa_petrify', 'kuma_push', 'bigmom_steal'], true); + if ($needsTarget && $this->pendingTarget === null) { + $this->pendingTarget = -1; + $this->message = '请点击一名对手作为技能目标'; + $this->setActionsForHumanTurn(); + $this->redraw(); + + return; + } + $target = $this->pendingTarget !== null && $this->pendingTarget >= 0 ? $this->pendingTarget : null; + try { + $this->game->armSkill($this->human, $target); + } catch (\Throwable $e) { + $this->message = '技能失败:' . $e->getMessage(); + $this->redraw(); + + return; + } + $this->pendingTarget = null; + $this->message = '技能已发动:' . $skill->name; + } else { + if ($this->humanCharId === 'akainu') { + return; + } + try { + $this->game->armSkill($this->human); + } catch (\Throwable $e) { + $this->message = '技能失败:' . $e->getMessage(); + $this->redraw(); + + return; + } + $this->message = '技能已预置:' . $skill->name . '(下一手生效)'; + } + $this->setActionsForHumanTurn(); + $this->redraw(); + } + + public function counterNow(): void + { + try { + $this->game->counterBomb($this->human); + } catch (\Throwable $e) { + $this->message = '反击失败:' . $e->getMessage(); + $this->redraw(); + + return; + } + $this->message = '岩浆反击!获得出牌权'; + if ($this->game->isOver()) { + $this->endGame(); + + return; + } + $this->humanTurn(); + } + + public function armWithTarget(int $p): void + { + $this->pendingTarget = $p; + $this->useSkill(); + } + + public function cancelTarget(): void + { + $this->pendingTarget = null; + $this->message = '你的回合'; + $this->setActionsForHumanTurn(); + $this->redraw(); + } + + public function toggleSound(): void + { + $this->soundOn = !$this->soundOn; + Sound::instance()->setEnabled($this->soundOn); + $this->soundLabel = $this->soundOn ? '🔊 音效' : '🔇 静音'; + } + + private function endGame(): void + { + self::dbg("endGame() mode={$this->mode}"); + if ($this->mode === 'over') { + return; + } + $this->mode = 'over'; + $this->cancelTimers(); + $this->autoPlay = false; // 结束时取消托管状态 + $side = $this->game->winnerSide; + $humanSide = $this->game->side($this->human); + $this->message = ($side === $humanSide ? '🎉 胜利!' : '💀 战败') + . '(' . ($side === 'landlord' ? '地主' : '农民') . '方获胜)'; + $this->applyActions([ + ['重新选将', true, fn () => $this->newGame()], + ['再来一局', true, fn () => $this->startMatch($this->humanCharId)], + ]); + $this->redraw(); + } + + public function cancelTimers(): void + { + if ($this->aiTimer !== null) { + try { + $this->cancelTimer($this->aiTimer); + } catch (\Throwable) { + } + $this->aiTimer = null; + } + if ($this->bidTimer !== null) { + try { + $this->cancelTimer($this->bidTimer); + } catch (\Throwable) { + } + $this->bidTimer = null; + } + } + + /* ----------------------------- timers ----------------------------- */ + + private function defer(callable $cb): void + { + $this->timers[] = ['at' => win_get_tick_count(), 'cb' => $cb, 'id' => ++$this->timerSeq]; + } + + private function delay(int $ms, callable $cb): int + { + $id = ++$this->timerSeq; + $this->timers[] = ['at' => win_get_tick_count() + $ms, 'cb' => $cb, 'id' => $id]; + + return $id; + } + + private function cancelTimer(int $id): void + { + foreach ($this->timers as $k => $t) { + if ($t['id'] === $id) { + unset($this->timers[$k]); + } + } + $this->timers = \array_values($this->timers); + } + + /** Fire all timers whose deadline has passed. Called once per frame. */ + public function tick(): void + { + $now = win_get_tick_count(); + $fired = []; + foreach ($this->timers as $k => $t) { + if ($t['at'] <= $now) { + $fired[] = $t['cb']; + unset($this->timers[$k]); + } + } + $this->timers = \array_values($this->timers); + foreach ($fired as $cb) { + ($cb)(); + } + } + + /* --------------------------- input buttons -------------------------- */ + + /** Returns true if an (enabled) action button was hit at (x, y). */ + private function handleButtonClick(float $x, float $y): bool + { + foreach ($this->btnRects as $i => $r) { + if ($r === null) { + continue; + } + if (inside($r, $x, $y)) { + $d = $this->actDefs[$i] ?? null; + if ($d !== null && $d['enabled'] && $d['cb'] !== null) { + ($d['cb'])(); + } + + return true; + } + } + + return false; + } + + /** Draw the bottom action bar (6 action buttons) + the sound toggle. */ + private function drawButtons(WinDrawContext $ctx, float $W, float $H): void + { + $by = (float) ($H - self::BTN_H + 12); + $h = 40.0; + $margin = 16.0; + $gap = 10.0; + $count = 6; + $bw = ($W - 2 * $margin - ($count - 1) * $gap) / $count; + $x = $margin; + $this->btnRects = []; + for ($i = 0; $i < $count; $i++) { + $d = $this->actDefs[$i] ?? null; + if ($d === null || $d['label'] === '') { + $this->btnRects[$i] = null; + $x += $bw + $gap; + continue; + } + $this->btnRects[$i] = [$x, $by, $bw, $h]; + $col = $d['enabled'] ? 0x2563eb : 0x334155; + $ctx->fillRoundedRect($x, $by, $bw, $h, 8.0, Brush::rgb($col)); + $ctx->drawString( + $d['label'], + new FontDescriptor(FONT, 14, TextWeight::Bold), + Color::rgb(0xffffff), + $x, $by + ($h - 14.0) / 2.0, + (int) $bw, + DrawTextAlign::Center + ); + $x += $bw + $gap; + } + + // 音效开关(右上角) + $sx = (float) ($W - 110); + $sy = 10.0; + $sw = 96.0; + $sh = 28.0; + $this->soundRect = [$sx, $sy, $sw, $sh]; + $ctx->fillRoundedRect($sx, $sy, $sw, $sh, 6.0, Brush::rgb($this->soundOn ? 0x1f2937 : 0x374151)); + $ctx->drawString( + $this->soundLabel, + new FontDescriptor(FONT, 13, TextWeight::Bold), + Color::rgb(0xe2e8f0), + $sx, $sy + 6.0, + (int) $sw, + DrawTextAlign::Center + ); + } + + /* --------------------------- event hook -------------------------- */ + + private function onGameEvent(string $event, $payload): void + { + // ---- SFX 触发 ---- + $s = Sound::instance(); + switch ($event) { + case 'bid': + $s->trigger('bid'); + break; + case 'gameStart': + $s->trigger('deal'); + break; + case 'play': + $combo = $payload['combo']; + $s->trigger($combo->isBomb || $combo->isRocket ? 'bomb' : 'play'); + // 保存出牌快照,供 lastPlay 被 pass 清空后回退显示 + $this->lastShownPlay = [ + 'combo' => $payload['combo'], + 'player' => $payload['player'], + 'hakiActive' => $payload['hakiActive'] ?? false, + 'unblockable' => $payload['unblockable'] ?? false, + ]; + break; + case 'pass': + $s->trigger('pass'); + break; + case 'skill': + case 'arm': + $s->trigger('skill'); + break; + case 'counter': + $s->trigger('bomb'); + break; + case 'gameOver': + $humanSide = $this->game->side($this->human); + $s->trigger($payload['side'] === $humanSide ? 'win' : 'lose'); + break; + } + + // ---- 可读中文日志 ---- + $msg = $this->formatLogLine($event, $payload); + if ($msg !== null) { + $this->gameLog[] = $msg; + $this->gameLog = \array_slice($this->gameLog, -6); // 最多保留 6 行 + } + $this->redraw(); + } + + /** 将游戏事件格式化为中文日志行,返回 null 时不记录。 */ + private function formatLogLine(string $event, $payload): ?string + { + $pname = function (int $idx): string { + if (!isset($this->game->players[$idx])) { + return "玩家{$idx}"; + } + $cid = $this->game->players[$idx]->characterId; + return Character::byId($cid)->name; + }; + switch ($event) { + case 'bid': + $score = $payload['score']; + $txt = $score > 0 ? "叫了 {$score} 分" : '不叫'; + return "{$pname($payload['player'])} {$txt}"; + case 'gameStart': + return "🎮 地主:{$pname($payload['landlord'])}"; + case 'play': + $c = $payload['combo']; + return "{$pname($payload['player'])} 出了 {$c->describe()}"; + case 'pass': + return "{$pname($payload['player'])} 要不起"; + case 'counter': + return "💥 {$pname($payload['player'])} 反炸了 {$pname($payload['target'])}"; + case 'skill': + return "⚡ {$pname($payload['player'])} 发动技能"; + case 'arm': + return "🔥 {$pname($payload['player'])} 蓄力中"; + case 'skip': + return "❄️ {$pname($payload['player'])} 被跳过"; + case 'frozenSkip': + return "🧊 {$pname($payload['player'])} 被冰冻"; + case 'gameOver': + $side = $payload['side'] === 'landlord' ? '地主' : '农民'; + return "🏆 {$pname($payload['winner'])} 获胜({$side})"; + default: + return "[{$event}]"; + } + } + + /* ----------------------------- input ----------------------------- */ + + public function onMouse(object $e): void + { + $x = (float) ($e->x ?? 0); + $y = (float) ($e->y ?? 0); + $down = (int) ($e->down ?? 0); + $up = (int) ($e->up ?? 0); + $held = (int) ($e->held ?? 0); + + // 音效开关(右上角,始终可点) + if ($down === 1 && $up === 0 && $this->soundRect !== null + && inside($this->soundRect, $x, $y)) { + $this->toggleSound(); + + return; + } + + if ($this->mode === 'select') { + if ($down === 1 && $up === 0) { + $id = hitTopmost($this->hit['select'], $x, $y); + if ($id !== null) { + $this->startMatch($id); + } + } + + return; + } + + if ($this->mode === 'over') { + if ($down === 1 && $up === 0) { + $this->handleButtonClick($x, $y); + } + + return; + } + + // bid / play:先处理底部动作按钮(含技能取消 / 重新选将) + if ($down === 1 && $up === 0) { + if ($this->handleButtonClick($x, $y)) { + return; + } + } + + if ($this->mode === 'play') { + // 技能指定目标:仅在左键按下时响应 + if ($this->pendingTarget !== null) { + if ($down === 1 && $up === 0) { + foreach ([1, 2] as $p) { + if (isset($this->hit['opp'][$p]) && inside($this->hit['opp'][$p], $x, $y)) { + $this->armWithTarget($p); + + return; + } + } + } + + return; + } + + if ($this->game->turn === $this->human && $this->game->phase === 'playing') { + $engaged = $down !== 0 || $held !== 0; + $released = $up !== 0; + // 松开鼠标:结束本次拖拽 + if ($released) { + $this->endDrag(); + + return; + } + // 纯悬停(无任何按键按住):不做选牌 + if (!$engaged) { + return; + } + $id = hitTopmost($this->hit['hand'], $x, $y); + if ($id === null) { + // 拖拽过程中移出手牌区域:保持拖拽状态,不处理 + return; + } + if (!$this->dragging) { + // 拖拽起点:以首张牌当前状态决定本次拖拽是「连选」还是「连消」 + $this->dragging = true; + $this->dragTouched = []; + $this->dragModeSelect = !\in_array($id, $this->selected, true); + $this->applyDrag($id); + } else { + $this->applyDrag($id); + } + } + } + } + + /** 拖拽选牌:将一张牌按当前拖拽模式加入/移出选中集合(同一次拖拽每个 id 只处理一次)。 */ + private function applyDrag(string $id): void + { + if (isset($this->dragTouched[$id])) { + return; + } + $this->dragTouched[$id] = true; + + if ($this->dragModeSelect) { + if (!\in_array($id, $this->selected, true)) { + $this->selected[] = $id; + $this->message = '已选 ' . \count($this->selected) . ' 张'; + $this->redraw(); + } + } else { + $i = \array_search($id, $this->selected, true); + if ($i !== false) { + unset($this->selected[$i]); + $this->selected = \array_values($this->selected); + $this->message = '已选 ' . \count($this->selected) . ' 张'; + $this->redraw(); + } + } + } + + /** 结束拖拽,清空本次拖拽的临时状态。 */ + private function endDrag(): void + { + $this->dragging = false; + $this->dragTouched = []; + } + + public function onKey(int $vk, int $up = 0): bool + { + if ($up !== 0 || $this->mode !== 'play' || $this->game->turn !== $this->human) { + return false; + } + if ($vk === 13) { // Enter / Return + if ($this->selected !== []) { + $this->humanPlay(); + } + + return true; + } + if ($vk === 80 || $vk === 112) { // p / P + $this->humanPass(); + + return true; + } + if ($vk === 72 || $vk === 104) { // h / H + $this->hint(); + + return true; + } + + return false; + } + + public function redraw(): void + { + // Win32 rendering is continuous (the message loop calls render() every + // frame), so a redraw request is a no-op here. The message text is read + // directly by render(). + } + + /* ---------------------------- rendering --------------------------- */ + + public function render(): void + { + if ($this->hWnd === 0) { + return; + } + $hdc = win_begin_paint($this->hWnd); + $ctx = new WinDrawContext($hdc); + + // 支持窗口缩放:每帧读取实际客户区尺寸进行自适应布局 + $sz = win_get_client_size($this->hWnd); + $W = (float) ($sz[0] > 0 ? $sz[0] : self::WIN_W); + $H = (float) ($sz[1] > 0 ? $sz[1] : self::WIN_H); + $gh = $H - self::BTN_H; // game-drawing height; bottom strip is the action bar + + $this->hit = ['hand' => [], 'opp' => [], 'select' => []]; + + $this->drawBackground($ctx, $W, $gh); + + if ($this->mode === 'select' || !isset($this->game)) { + $this->drawSelectScreen($ctx, $W, $gh); + } elseif ($this->mode === 'over') { + // 仅绘制背景 + 结算浮层,不绘制牌桌/日志(避免穿透) + $this->drawOver($ctx, $W, $gh); + } else { + $this->drawBoard($ctx, $W, $gh); + } + + $this->drawButtons($ctx, $W, $H); + + win_end_paint($this->hWnd, $hdc); + } + + private function drawBackground(WinDrawContext $ctx, float $W, float $H): void + { + try { + $g = Brush::linearGradient(0.0, 0.0, 0.0, $H, [ + [0.0, ...rgbN(0x0b1a3a)], + [0.55, ...rgbN(0x08152e)], + [1.0, ...rgbN(0x050d1f)], + ]); + $ctx->fillRect(0.0, 0.0, $W, $H, $g); + } catch (\Throwable) { + $ctx->fillRect(0.0, 0.0, $W, $H, Brush::rgb(0x071025)); + } + $ctx->fillEllipse($W * 0.5, $H * 0.12, $W * 0.55, $H * 0.18, + Brush::color(Color::rgba(0.25, 0.45, 0.8, 0.10))); + } + + private function title(WinDrawContext $ctx, float $W): void + { + $f = new FontDescriptor(FONT, 18, TextWeight::Bold); + $ctx->drawString('海贼王 · 斗地主', $f, Color::rgb(0xe2e8f0), 16.0, 12.0); + } + + private function drawBoard(WinDrawContext $ctx, float $W, float $H): void + { + $this->title($ctx, $W); + + $top = 44.0; + $oppY = $top; + $oppW = \min(320.0, ($W - 48.0) / 2.0); + $oppH = 132.0; + $this->drawOpponent($ctx, 16.0, $oppY, $oppW, $oppH, 1); + $this->drawOpponent($ctx, $W - 16.0 - $oppW, $oppY, $oppW, $oppH, 2); + + $handH = 132.0; + $handTop = $H - $handH - 8.0; + $this->drawPlayArea($ctx, $W, $oppY + $oppH + 8.0, $handTop - ($oppY + $oppH + 8.0)); + $this->drawHand($ctx, $W, $handTop, $handH); + + // 日志放在 banner 上方、手牌上方,不与出牌区/手牌重叠 + $this->drawLog($ctx, $W, $handTop - 80.0); + $this->drawBanner($ctx, $W, $handTop - 26.0); + } + + private function factionColor(string $faction): int + { + return hx(Faction::color($faction)); + } + + private function drawOpponent(WinDrawContext $ctx, float $x, float $y, float $w, float $h, int $p): void + { + $this->hit['opp'][$p] = [$x, $y, $w, $h]; + $ps = $this->game->players[$p]; + $char = Character::byId($ps->characterId); + $fc = $this->factionColor($ps->faction); + $isTurn = $this->game->turn === $p && $this->game->phase === 'playing'; + + $ctx->fillRoundedRect($x, $y, $w, $h, 12.0, + Brush::color(Color::rgba(0.05, 0.10, 0.22, 0.85))); + $ctx->strokeRoundedRect($x, $y, $w, $h, 12.0, + $isTurn ? Brush::rgb($fc) : Brush::color(Color::rgba(1, 1, 1, 0.12)), + (new StrokeParams())->thickness($isTurn ? 2.5 : 1.0)); + + $ctx->fillRoundedRect($x, $y, 8.0, $h, 4.0, Brush::rgb($fc)); + + $fName = new FontDescriptor(FONT, 15, TextWeight::Bold); + $fSmall = new FontDescriptor(FONT, 11); + $fTiny = new FontDescriptor(FONT, 10); + + $ctx->drawString($char->name, $fName, Color::rgb(0xf1f5f9), $x + 16.0, $y + 10.0); + $ctx->drawString(Faction::name($ps->faction) . ' · ' . $char->title, $fSmall, + Color::rgba(0.85, 0.9, 1.0, 0.85), $x + 16.0, $y + 32.0); + + $crown = $ps->isLandlord ? ' 👑' : ''; + $ctx->drawString('手牌 ' . $this->game->handCount($p) . $crown, $fSmall, + Color::rgb(0xcbd5e1), $x + 16.0, $y + 52.0); + + $badges = []; + if ($ps->armed !== null) { + $badges[] = '技'; + } + if ($this->game->mods['frozen'][$p] > 0) { + $badges[] = '冻'; + } + if ($this->game->mods['petrified'][$p] > 0) { + $badges[] = '石'; + } + if ($this->game->mods['bombDisabled'][$p] > 0) { + $badges[] = '禁'; + } + if ($badges !== []) { + $ctx->drawString('状态: ' . \implode(' ', $badges), $fTiny, Color::rgb(0xfbbf24), + $x + 16.0, $y + 72.0); + } + + $n = $this->game->handCount($p); + $bw = 26.0; + $bh = 38.0; + $gap = 9.0; + $total = $bw + \max(0, $n - 1) * $gap; + $sx = $x + 16.0; + $sy = $y + $h - $bh - 8.0; + $draw = \min($n, 18); + for ($i = 0; $i < $draw; $i++) { + $this->drawCardBack($ctx, $sx + $i * $gap, $sy, $bw, $bh, $fc); + } + if ($n > 18) { + $ctx->drawString('+' . ($n - 18), $fTiny, Color::rgb(0x94a3b8), + $sx + 18 * $gap + 4.0, $sy + 12.0); + } + } + + private function drawCardBack(WinDrawContext $ctx, float $x, float $y, float $w, float $h, int $fc): void + { + $r = 6.0; + $ctx->fillRoundedRect($x, $y, $w, $h, $r, Brush::rgb($fc)); + $ctx->strokeRoundedRect($x, $y, $w, $h, $r, Brush::color(Color::rgba(1, 1, 1, 0.35)), + (new StrokeParams())->thickness(1.0)); + $ctx->fillRoundedRect($x + 4.0, $y + 4.0, $w - 8.0, $h - 8.0, 4.0, + Brush::color(Color::rgba(1, 1, 1, 0.12))); + } + + private function drawPlayArea(WinDrawContext $ctx, float $W, float $y, float $h): void + { + // 优先显示 lastPlay,若被 pass 清空则回退到 lastShownPlay(保留上次出牌快照) + $lp = $this->game->lastPlay ?? $this->lastShownPlay; + if ($lp === null) { + $f = new FontDescriptor(FONT, 14); + $ctx->drawString('等待首出…', $f, Color::rgb(0x94a3b8), 0.0, $y + $h / 2 - 8.0, $W, + DrawTextAlign::Center); + + return; + } + $combo = $lp['combo']; + $actor = $lp['player']; + $char = Character::byId($this->game->players[$actor]->characterId); + + $fSmall = new FontDescriptor(FONT, 13); + $label = ($actor === $this->human ? '你' : $char->name) . ' 出了 ' . $combo->describe(); + if ($lp['hakiActive']) { + $label .= ' 【霸王色】'; + } + if ($lp['unblockable']) { + $label .= ' 【不可拦截】'; + } + $ctx->drawString($label, $fSmall, Color::rgb(0xe2e8f0), 0.0, $y + 6.0, $W, + DrawTextAlign::Center); + + $cards = $combo->cards; + $cw = 46.0; + $ch = 64.0; + $gap = 8.0; + $total = \count($cards) * ($cw + $gap) - $gap; + $sx = ($W - $total) / 2.0; + $cy = $y + 28.0; + foreach ($cards as $i => $c) { + $this->drawMiniCard($ctx, $sx + $i * ($cw + $gap), $cy, $cw, $ch, $c); + } + } + + private function drawMiniCard(WinDrawContext $ctx, float $x, float $y, float $w, float $h, Card $c): void + { + $r = 6.0; + $ctx->fillRoundedRect($x, $y, $w, $h, $r, Brush::rgb(0xf8fafc)); + $ctx->strokeRoundedRect($x, $y, $w, $h, $r, Brush::rgb(0xcbd5e1), + (new StrokeParams())->thickness(1.0)); + $isRed = \in_array($c->suit, ['♥', '♦'], true); + $col = $c->isJoker() ? ($c->rank === Card::JOKER_BIG ? 0xb45309 : 0x475569) + : ($isRed ? 0xdc2626 : 0x0f172a); + $fRank = new FontDescriptor(FONT, 16, TextWeight::Bold); + $fSuit = new FontDescriptor(FONT, 13); + if ($c->isJoker()) { + $ctx->drawString($c->rank === Card::JOKER_BIG ? '大' : '小', $fRank, Color::rgb($col), + $x, $y + 6.0, $w, DrawTextAlign::Center); + $ctx->drawString('★', $fSuit, Color::rgb($col), $x, $y + $h - 24.0, $w, DrawTextAlign::Center); + } else { + $ctx->drawString($c->label(), $fRank, Color::rgb($col), $x, $y + 6.0, $w, DrawTextAlign::Center); + $ctx->drawString($c->suit, $fSuit, Color::rgb($col), $x, $y + $h - 24.0, $w, DrawTextAlign::Center); + } + } + + private function drawHand(WinDrawContext $ctx, float $W, float $handTop, float $handH): void + { + $hand = $this->game->players[$this->human]->hand; + $cw = 68.0; + $ch = 90.0; + $raise = 18.0; + $gap = 36.0; + $n = \count($hand); + if ($n === 0) { + return; + } + $total = $cw + ($n - 1) * $gap; + $sx = ($W - $total) / 2.0; + + foreach ($hand as $i => $c) { + $id = $c->id(); + $sel = \in_array($id, $this->selected, true); + $cx = $sx + $i * $gap; + $cy = $handTop + ($sel ? 0.0 : $raise); + $this->hit['hand'][$id] = [$cx, $cy, $cw, $ch]; + $this->drawCardFace($ctx, $cx, $cy, $cw, $ch, $c, $sel); + } + } + + private function drawCardFace(WinDrawContext $ctx, float $x, float $y, float $w, float $h, Card $c, bool $sel): void + { + $r = 8.0; + $ctx->fillRoundedRect($x + 2.0, $y + 4.0, $w, $h, $r, Brush::color(Color::rgba(0, 0, 0, 0.30))); + $body = $sel ? Brush::rgb(0xfff7ed) : Brush::rgb(0xf8fafc); + $ctx->fillRoundedRect($x, $y, $w, $h, $r, $body); + $border = $sel ? Brush::rgb(0xf59e0b) : Brush::rgb(0xcbd5e1); + $ctx->strokeRoundedRect($x, $y, $w, $h, $r, $border, (new StrokeParams())->thickness($sel ? 3.0 : 1.5)); + + $isRed = \in_array($c->suit, ['♥', '♦'], true); + $col = $c->isJoker() ? ($c->rank === Card::JOKER_BIG ? 0xb45309 : 0x475569) + : ($isRed ? 0xdc2626 : 0x0f172a); + + $fRank = new FontDescriptor(FONT, 16, TextWeight::Bold); + $fMid = new FontDescriptor(FONT, 22, TextWeight::Bold); + $fSuit = new FontDescriptor(FONT, 14); + + if ($c->isJoker()) { + $ctx->drawString($c->rank === Card::JOKER_BIG ? '大' : '小', $fRank, Color::rgb($col), + $x + 6.0, $y + 6.0); + $ctx->drawString('★', $fMid, Color::rgb($col), $x, $y + $h * 0.30, $w, DrawTextAlign::Center); + $ctx->drawString('JOKER', new FontDescriptor(FONT, 9), Color::rgb($col), + $x, $y + $h - 16.0, $w, DrawTextAlign::Center); + } else { + $ctx->drawString($c->label(), $fRank, Color::rgb($col), $x + 6.0, $y + 6.0); + $ctx->drawString($c->label(), $fMid, Color::rgb($col), $x, $y + $h * 0.30, $w, DrawTextAlign::Center); + $ctx->drawString($c->suit, $fSuit, Color::rgb($col), $x, $y + $h - 24.0, $w, DrawTextAlign::Center); + } + } + + private function drawBanner(WinDrawContext $ctx, float $W, float $y): void + { + $ps = $this->game->players[$this->human]; + $char = Character::byId($ps->characterId); + $fc = $this->factionColor($ps->faction); + $skill = $char->skill(); + $cost = $skill->cost; + if ($cost === 'once') { + $st = $ps->skill['usedOnce'] ? '已用' : '可用'; + } elseif (\str_starts_with($cost, 'charges:')) { + $st = '充能 ' . $ps->skill['chargesLeft']; + } else { + $st = '霸气 ' . $ps->skill['haki']; + } + $f = new FontDescriptor(FONT, 13, TextWeight::Bold); + $text = '你 · ' . $char->name . ' | ' . $skill->name . ':' . $st; + if ($this->autoPlay) { + $text .= ' | 🤖 托管中'; + } + $ctx->drawString($text, $f, Color::rgb($fc), 16.0, $y); + } + + private function drawLog(WinDrawContext $ctx, float $W, float $y): void + { + $f = new FontDescriptor(FONT, 9); + $lines = \array_slice($this->gameLog, -4); + // 左对齐、距左边缘 12px,mb_substr 按字符截断(不是字节!) + $logX = 12.0; + $i = 0; + foreach ($lines as $line) { + // ddz_utf8_substr 安全截断 UTF-8,避免从汉字中间劈开产生乱码 + $ctx->drawString(ddz_utf8_substr($line, 0, 28), $f, Color::rgb(0x94a3b8), + $logX, $y + $i * 12.0); + $i++; + } + } + + private function drawOver(WinDrawContext $ctx, float $W, float $H): void + { + // 深色半透明遮罩 + $ctx->fillRect(0.0, 0.0, $W, $H, Brush::color(Color::rgba(0.02, 0.05, 0.12, 0.85))); + + $win = \str_contains($this->message, '胜利'); + $fBig = new FontDescriptor(FONT, 44, TextWeight::Bold); + $fSub = new FontDescriptor(FONT, 17); + $fHint = new FontDescriptor(FONT, 14); + $col = $win ? Color::rgb(0xfbbf24) : Color::rgb(0xf87171); + + // 垂直居中偏上一点 + $cy = $H * 0.38; + // 水平居中:用 x=W*0.25, width=W*0.5 使对齐区域中心恰在 W*0.5 + // (fillRect 后 DrawTextAlign::Center 的 x=0 行为异常,改用非零宽度区域) + $alignLeft = $W * 0.25; + $alignWidth = $W * 0.5; + + // 主标题:胜利 / 战败 — 居中 + $ctx->drawString($win ? '胜 利' : '战 败', $fBig, $col, + $alignLeft, $cy, $alignWidth, DrawTextAlign::Center); + + // 副标题:如 "🎉 胜利!(农民方获胜)" + $ctx->drawString($this->message, $fSub, Color::rgb(0xe2e8f0), + $alignLeft, $cy + 56.0, $alignWidth, DrawTextAlign::Center); + + // 提示文字 + $ctx->drawString('点击下方「再来一局」按钮开始新一局', $fHint, + Color::rgb(0x94a3b8), $alignLeft, $cy + 92.0, $alignWidth, DrawTextAlign::Center); + } + + /* -------------------------- select screen ------------------------ */ + + private function drawSelectScreen(WinDrawContext $ctx, float $W, float $H): void + { + $this->title($ctx, $W); + $fHead = new FontDescriptor(FONT, 20, TextWeight::Bold); + $ctx->drawString('选择你的阵营与将领', $fHead, Color::rgb(0xe2e8f0), 0.0, 40.0, $W, DrawTextAlign::Center); + + $factions = [Faction::NAVY, Faction::WARLORD, Faction::EMPEROR]; + $colW = ($W - 64.0) / 3.0; + $top = 80.0; + $cardH = 150.0; + $gapY = 14.0; + foreach ($factions as $fi => $fac) { + $fx = 16.0 + $fi * ($colW + 16.0); + $fc = $this->factionColor($fac); + $ctx->fillRoundedRect($fx, $top, $colW, 34.0, 8.0, Brush::rgb($fc)); + $ctx->drawString(Faction::name($fac), new FontDescriptor(FONT, 16, TextWeight::Bold), + Color::rgb(0xffffff), $fx, $top + 7.0, $colW, DrawTextAlign::Center); + $chars = Character::byFaction($fac); + foreach ($chars as $ci => $ch) { + $cy = $top + 44.0 + $ci * ($cardH + $gapY); + $this->hit['select'][$ch->id] = [$fx, $cy, $colW, $cardH]; + $ctx->fillRoundedRect($fx, $cy, $colW, $cardH, 10.0, + Brush::color(Color::rgba(0.06, 0.11, 0.24, 0.9))); + $ctx->strokeRoundedRect($fx, $cy, $colW, $cardH, 10.0, Brush::rgb($fc), + (new StrokeParams())->thickness(1.5)); + $ctx->fillRoundedRect($fx, $cy, 6.0, $cardH, 3.0, Brush::rgb($fc)); + + $ctx->drawString($ch->name, new FontDescriptor(FONT, 15, TextWeight::Bold), + Color::rgb(0xf1f5f9), $fx + 14.0, $cy + 10.0); + $ctx->drawString($ch->title, new FontDescriptor(FONT, 11), + Color::rgba(0.85, 0.9, 1.0, 0.8), $fx + 14.0, $cy + 30.0); + $ctx->drawString('技能:' . $ch->skillName, new FontDescriptor(FONT, 12, TextWeight::Bold), + Color::rgb($fc), $fx + 14.0, $cy + 50.0); + $desc = $this->wrap($ch->skillDesc, 22); + $dy = $cy + 70.0; + foreach ($desc as $line) { + $ctx->drawString($line, new FontDescriptor(FONT, 10), Color::rgb(0xcbd5e1), + $fx + 14.0, $dy); + $dy += 14.0; + } + $ctx->drawString('费用:' . $ch->skillCost, new FontDescriptor(FONT, 10), + Color::rgb(0x94a3b8), $fx + 14.0, $cy + $cardH - 16.0); + } + } + } + + /** 简单按字符数折行(CJK 近似每行固定字数)。 */ + private function wrap(string $s, int $n): array + { + $out = []; + $len = ddz_utf8_len($s); + for ($i = 0; $i < $len; $i += $n) { + $out[] = ddz_utf8_substr($s, $i, $n); + } + + return $out; + } +} diff --git a/examples/onepiece-doudizhu-win32/php-src/doudizhu/MoveGenerator.php b/examples/onepiece-doudizhu-win32/php-src/doudizhu/MoveGenerator.php new file mode 100644 index 00000000..c5358f41 --- /dev/null +++ b/examples/onepiece-doudizhu-win32/php-src/doudizhu/MoveGenerator.php @@ -0,0 +1,151 @@ + $hand + * @return list + */ + public static function all(array $hand): array + { + if ($hand === []) { + return []; + } + $byRank = []; + foreach ($hand as $c) { + $byRank[$c->rank][] = $c; + } + $ranks = \array_keys($byRank); + \sort($ranks); + $has = static fn (int $r) => isset($byRank[$r]); + $cnt = static fn (int $r) => \count($byRank[$r] ?? []); + + $out = []; + $seen = []; + + $push = static function (array $cards) use (&$out, &$seen): void { + $combo = Combo::parse($cards); + if ($combo === null) { + return; + } + $key = $combo->type . ':' . $combo->rank . ':' . $combo->length; + if (isset($seen[$key])) { + return; + } + $seen[$key] = true; + $out[] = $combo; + }; + + // 单 / 对 / 三 + foreach ($ranks as $r) { + $push([$byRank[$r][0]]); + if ($cnt($r) >= 2) { + $push([$byRank[$r][0], $byRank[$r][1]]); + } + if ($cnt($r) >= 3) { + $push([$byRank[$r][0], $byRank[$r][1], $byRank[$r][2]]); + } + } + + // 三带一 / 三带二 + foreach ($ranks as $r) { + if ($cnt($r) < 3) { + continue; + } + $triple = [$byRank[$r][0], $byRank[$r][1], $byRank[$r][2]]; + foreach ($ranks as $s) { + if ($s === $r) { + continue; + } + if ($cnt($s) >= 1) { + $push(\array_merge($triple, [$byRank[$s][0]])); + } + if ($cnt($s) >= 2) { + $push(\array_merge($triple, [$byRank[$s][0], $byRank[$s][1]])); + } + } + } + + // 顺子 (5..12 连) + for ($len = 5; $len <= 12; $len++) { + for ($start = 3; $start + $len - 1 <= 14; $start++) { + $ok = true; + $cards = []; + for ($k = 0; $k < $len; $k++) { + $rr = $start + $k; + if (!$has($rr)) { + $ok = false; + break; + } + $cards[] = $byRank[$rr][0]; + } + if ($ok) { + $push($cards); + } + } + } + + // 连对 (3..10 连) + for ($len = 3; $len <= 10; $len++) { + for ($start = 3; $start + $len - 1 <= 14; $start++) { + $ok = true; + $cards = []; + for ($k = 0; $k < $len; $k++) { + $rr = $start + $k; + if ($cnt($rr) < 2) { + $ok = false; + break; + } + $cards[] = $byRank[$rr][0]; + $cards[] = $byRank[$rr][1]; + } + if ($ok) { + $push($cards); + } + } + } + + // 飞机(纯三连,2..6 连) + for ($len = 2; $len <= 6; $len++) { + for ($start = 3; $start + $len - 1 <= 14; $start++) { + $ok = true; + $cards = []; + for ($k = 0; $k < $len; $k++) { + $rr = $start + $k; + if ($cnt($rr) < 3) { + $ok = false; + break; + } + $cards[] = $byRank[$rr][0]; + $cards[] = $byRank[$rr][1]; + $cards[] = $byRank[$rr][2]; + } + if ($ok) { + $push($cards); + } + } + } + + // 炸弹 + foreach ($ranks as $r) { + if ($cnt($r) === 4) { + $push($byRank[$r]); + } + } + + // 火箭 + if ($has(Card::JOKER_SMALL) && $has(Card::JOKER_BIG)) { + $push([$byRank[Card::JOKER_SMALL][0], $byRank[Card::JOKER_BIG][0]]); + } + + return $out; + } +} diff --git a/examples/onepiece-doudizhu-win32/php-src/doudizhu/PlayerState.php b/examples/onepiece-doudizhu-win32/php-src/doudizhu/PlayerState.php new file mode 100644 index 00000000..dbb84614 --- /dev/null +++ b/examples/onepiece-doudizhu-win32/php-src/doudizhu/PlayerState.php @@ -0,0 +1,49 @@ + */ + public array $hand; + + public ?string $characterId = null; + public ?string $faction = null; + public bool $isLandlord = false; + + /** 技能费用状态 */ + public array $skill = [ + 'chargesLeft' => 0, + 'usedOnce' => false, + 'haki' => 0, + 'cooldown' => 0, + ]; + + /** 预置的下一手技能(onPlay/onBombPlayed) */ + public ?string $armed = null; + public ?int $armedTarget = null; + + /** + * @param list $hand + */ + public function __construct(array $hand) + { + $this->hand = $hand; + } + + public function initSkill(Skill $skill): void + { + if ($skill->cost === 'once') { + $this->skill['usedOnce'] = false; + } elseif (\str_starts_with($skill->cost, 'charges:')) { + $this->skill['chargesLeft'] = (int) \substr($skill->cost, 8); + } elseif (\str_starts_with($skill->cost, 'haki:')) { + $this->skill['haki'] = (int) \substr($skill->cost, 5); + } + } +} diff --git a/examples/onepiece-doudizhu-win32/php-src/doudizhu/Render.php b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Render.php new file mode 100644 index 00000000..a5624ead --- /dev/null +++ b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Render.php @@ -0,0 +1,261 @@ +hex = $hex & 0xFFFFFF; + } + + public static function rgb(int $hex): self + { + return new self($hex); + } + + /** libui accepts normalised floats here (r,g,b in 0..1). */ + public static function rgba(float $r, float $g, float $b, float $a): self + { + $rr = (int) ($r * 255); + $gg = (int) ($g * 255); + $bb = (int) ($b * 255); + + return new self(($rr << 16) | ($gg << 8) | $bb); + } +} + +final class Brush +{ + public int $hex; + + public function __construct(int $hex) + { + $this->hex = $hex & 0xFFFFFF; + } + + public static function rgb(int $hex): self + { + return new self($hex); + } + + /** Extract the int colour from a Color (or pass-through an int brush). */ + public static function color($c): int + { + if ($c instanceof Color) { + return $c->hex; + } + if ($c instanceof Brush) { + return $c->hex; + } + + return (int) $c; + } + + /** + * Gradients are approximated by a solid fill in this GDI port. + * + * Signature mirrors libui's brushForFill linearGradient: four float + * coordinates plus a list of [offset, r, g, b, a] stops. We take the + * colour of the LAST stop (or the deep-navy fallback) as the flat fill. + * + * @param array $stops + */ + public static function linearGradient(float $x0, float $y0, float $x1, float $y1, array $stops): self + { + $hex = 0x08152e; + if (\count($stops) > 0) { + $last = $stops[\count($stops) - 1]; + $r = (int) ($last[1] * 255); + $g = (int) ($last[2] * 255); + $b = (int) ($last[3] * 255); + $hex = ($r << 16) | ($g << 8) | $b; + } + + return new self($hex); + } +} + +final class StrokeParams +{ + public int $thickness = 1; + + public function thickness(int $t): self + { + $this->thickness = $t; + + return $this; + } +} + +final class FontDescriptor +{ + public string $family; + public int $size; + public int $weight; + + public function __construct(string $family, int $size, int $weight = TextWeight::Normal) + { + $this->family = $family; + $this->size = $size; + $this->weight = $weight; + } +} + +/* ----------------------------- helpers -------------------------------- */ + +/** 0xRRGGBB -> Win32 COLORREF (0xBBGGRR). */ +function ddz_rgb(int $hex): int +{ + $r = ($hex >> 16) & 0xFF; + $g = ($hex >> 8) & 0xFF; + $b = $hex & 0xFF; + + return ($r) | ($g << 8) | ($b << 16); +} + +/* ------------------- UTF-8 helpers (no mbstring) ---------------------- */ + +/** + * Character (code point) length of a UTF-8 string. + * mbstring is not linked into the TypePHP runtime, so we count code points + * with PCRE instead of mb_strlen(). + */ +function ddz_utf8_len(string $s): int +{ + if ($s === '') { + return 0; + } + + return \preg_match_all('/./us', $s); +} + +/** + * Safe UTF-8 substring by code points (no mbstring). + * Negative $start/$len behave like mb_substr() (offsets from the end). + */ +function ddz_utf8_substr(string $s, int $start, int $len = 0): string +{ + if ($s === '') { + return ''; + } + \preg_match_all('/./us', $s, $m); + $chars = $m[0]; + $n = \count($chars); + if ($len === 0) { + $len = $n; + } + if ($start < 0) { + $start = \max(0, $n + $start); + } + if ($len < 0) { + $len = \max(0, $n - $start + $len); + } + $slice = \array_slice($chars, $start, $len); + + return \implode('', $slice); +} + +/* --------------------------- draw context ----------------------------- */ + +/** + * Mimics libui's DrawContext but renders through Win32 GDI. The single + * constructor argument is the memory-DC handle produced by win_begin_paint(). + */ +final class WinDrawContext +{ + private int $hdc; + + public function __construct(int $hdc) + { + $this->hdc = $hdc; + } + + private function colOf($b): int + { + if ($b instanceof Color || $b instanceof Brush) { + return $b->hex; + } + + return (int) $b; + } + + public function fillRect(float $x, float $y, float $w, float $h, $brush): void + { + win_fill_rect($this->hdc, (int) $x, (int) $y, (int) $w, (int) $h, ddz_rgb($this->colOf($brush))); + } + + /** Ellipse centred at (cx, cy) — matches libui semantics. */ + public function fillEllipse(float $cx, float $cy, float $w, float $h, $brush): void + { + win_fill_ellipse($this->hdc, (int) $cx, (int) $cy, (int) $w, (int) $h, ddz_rgb($this->colOf($brush))); + } + + public function fillRoundedRect(float $x, float $y, float $w, float $h, float $r, $brush): void + { + win_fill_rounded_rect($this->hdc, (int) $x, (int) $y, (int) $w, (int) $h, (int) $r, ddz_rgb($this->colOf($brush))); + } + + public function strokeRoundedRect(float $x, float $y, float $w, float $h, float $r, $brush, StrokeParams $stroke): void + { + win_stroke_rounded_rect( + $this->hdc, + (int) $x, (int) $y, (int) $w, (int) $h, (int) $r, + ddz_rgb($this->colOf($brush)), + (int) $stroke->thickness + ); + } + + /** + * @param int $w width of the alignment box (0 = no alignment) + * @param int $align DrawTextAlign::Left|Center|Right + */ + public function drawString(string $text, FontDescriptor $font, Color $color, float $x, float $y, int $w = 0, int $align = DrawTextAlign::Left): void + { + win_draw_text_ex( + $this->hdc, + (int) $x, (int) $y, $text, + (int) $font->size, + ddz_rgb($color->hex), + $font->weight >= TextWeight::Bold ? 1 : 0, + (int) $w, + (int) $align + ); + } +} diff --git a/examples/onepiece-doudizhu-win32/php-src/doudizhu/Skill.php b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Skill.php new file mode 100644 index 00000000..bf77b33c --- /dev/null +++ b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Skill.php @@ -0,0 +1,103 @@ +players[$actor]->characterId)->skill(); + $target ??= self::defaultTarget($g, $actor); + + switch ($skill->id) { + case 'aokiji_freeze': + $g->mods['frozen'][$target] = 1; + break; + + case 'boa_petrify': + $g->mods['petrified'][$target] = 2; + break; + + case 'kuma_push': + self::pushCard($g, $actor, $target); + break; + + case 'bigmom_steal': + self::stealCard($g, $actor, $target); + break; + + default: + // onPlay / onBombPlayed 类不在 apply 中处理 + break; + } + } + + private static function defaultTarget(Game $g, int $actor): int + { + // 默认瞄准「手牌最少」的对手(最具威胁) + $best = null; + $bestCount = PHP_INT_MAX; + foreach ([0, 1, 2] as $p) { + if ($p === $actor) { + continue; + } + $c = $g->handCount($p); + if ($c < $bestCount) { + $bestCount = $c; + $best = $p; + } + } + + return $best ?? (($actor + 1) % 3); + } + + private static function pushCard(Game $g, int $actor, int $target): void + { + $hand = $g->players[$actor]->hand; + if ($hand === []) { + return; + } + // 推出最小的一张 + \usort($hand, static fn (Card $a, Card $b) => $a->rank <=> $b->rank); + $card = \array_shift($hand); + $g->players[$actor]->hand = $hand; + $g->players[$target]->hand[] = $card; + Deck::sort($g->players[$target]->hand); + } + + private static function stealCard(Game $g, int $actor, int $target): void + { + $th = $g->players[$target]->hand; + if ($th === []) { + return; + } + $idx = \array_rand($th); + $card = $th[$idx]; + unset($th[$idx]); + $th = \array_values($th); + $g->players[$target]->hand = $th; + $g->players[$actor]->hand[] = $card; + Deck::sort($g->players[$actor]->hand); + } +} diff --git a/examples/onepiece-doudizhu-win32/php-src/doudizhu/Sound.php b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Sound.php new file mode 100644 index 00000000..1505cea9 --- /dev/null +++ b/examples/onepiece-doudizhu-win32/php-src/doudizhu/Sound.php @@ -0,0 +1,107 @@ + self::CLICK, + 'deal' => self::DEAL, + 'play' => self::PLAY, + 'pass' => self::PASS, + 'bomb' => self::BOMB, + 'skill' => self::SKILL, + 'bid' => self::BID, + 'win' => self::WIN, + 'lose' => self::LOSE, + ]; + + private static ?self $instance = null; + + private array $bindings; + private float $volume = 0.8; + private bool $enabled = true; + + public function __construct() + { + $this->bindings = self::DEFAULT_BINDINGS; + } + + public static function instance(): self + { + return self::$instance ??= new self(); + } + + public function setVolume(float $v): self + { + $this->volume = max(0.0, min(1.0, $v)); + + return $this; + } + + public function setEnabled(bool $on): self + { + $this->enabled = $on; + + return $this; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function bind(string $event, string $sound): self + { + $this->bindings[$event] = $sound; + + return $this; + } + + /** Play a sound by name. No-op in this port. */ + public function play(string $name): void + { + // Intentionally silent: no audio assets in the compiled binary. + } + + /** Fire a named game event; plays the bound sound if any. */ + public function trigger(string $event): void + { + $sound = $this->bindings[$event] ?? $event; + $this->play($sound); + } + + public function unload(): void + { + // nothing to release + } + + public function __destruct() + { + $this->unload(); + } +} diff --git a/examples/onepiece-doudizhu-win32/php-src/win32.stub.php b/examples/onepiece-doudizhu-win32/php-src/win32.stub.php new file mode 100644 index 00000000..d398e99d --- /dev/null +++ b/examples/onepiece-doudizhu-win32/php-src/win32.stub.php @@ -0,0 +1,41 @@ +