parent
18decac90b
commit
449afc1516
19 changed files with 4226 additions and 0 deletions
@ -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` 被禁用,恢复后必须重算才能重新可点。 |
||||||
@ -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 |
||||||
@ -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 <phpx.h> |
||||||
|
#include <windows.h> |
||||||
|
#include <cstdio> |
||||||
|
#include <cstring> |
||||||
|
#include <cwchar> |
||||||
|
#include <map> |
||||||
|
|
||||||
|
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<HDC, FrameState> 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; |
||||||
|
} |
||||||
@ -0,0 +1,87 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
/** |
||||||
|
* 海贼王 · 斗地主 —— TypePHP / Win32 入口。 |
||||||
|
* |
||||||
|
* 本文件替代原 libui 版 onepiece-doudizhu.php 的入口:不再依赖 libui 事件循环, |
||||||
|
* 而是用 Win32 消息循环驱动 GameController(见 php-src/doudizhu/GameController.php)。 |
||||||
|
* 所有游戏逻辑(发牌 / 叫地主 / 出牌 / AI / 技能 / 渲染)均在 PHP 中实现, |
||||||
|
* C++ 仅负责窗口、GDI 绘制原语与输入(cpp-src/win32.cc)。 |
||||||
|
* |
||||||
|
* 编译:在 examples/onepiece-doudizhu-win32 目录执行 |
||||||
|
* tpc.exe project.yml |
||||||
|
* 运行:生成的 .exe(无外部 PHP 依赖,纯 Win32 + GDI)。 |
||||||
|
* |
||||||
|
* 注意:TypePHP 的 bin 模式会自动以 main() 作为程序入口,无需手动调用。 |
||||||
|
*/ |
||||||
|
|
||||||
|
// Win32 常量 |
||||||
|
const SW_SHOW = 5; |
||||||
|
|
||||||
|
// win_peek_message() 返回的消息类型 |
||||||
|
const MSG_OTHER = 0; |
||||||
|
const MSG_MOUSE_DOWN = 1; |
||||||
|
const MSG_MOUSE_UP = 2; |
||||||
|
const MSG_MOUSE_MOVE = 3; |
||||||
|
const MSG_KEY_DOWN = 4; |
||||||
|
|
||||||
|
use Yangweijie\Ui2\Games\OnePieceDoudizhu\GameController; |
||||||
|
use Yangweijie\Ui2\Games\OnePieceDoudizhu\Sound; |
||||||
|
|
||||||
|
function main(): void |
||||||
|
{ |
||||||
|
\date_default_timezone_set('Asia/Shanghai'); |
||||||
|
\Yangweijie\Ui2\Games\OnePieceDoudizhu\ensureDdzFont(); |
||||||
|
|
||||||
|
$hWnd = win_create_window('海贼王 · 斗地主', GameController::WIN_W, GameController::WIN_H); |
||||||
|
if ($hWnd == 0) { |
||||||
|
echo "窗口创建失败!\n"; |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
win_show_window($hWnd, SW_SHOW); |
||||||
|
|
||||||
|
$ctrl = new GameController(); |
||||||
|
$ctrl->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"; |
||||||
|
} |
||||||
@ -0,0 +1,252 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace Yangweijie\Ui2\Games\OnePieceDoudizhu; |
||||||
|
|
||||||
|
/** |
||||||
|
* AI 对手:手牌评估 + 跟牌/首出启发式 + 势力感知行为 + 技能效用决策。 |
||||||
|
* |
||||||
|
* 设计为「无状态决策器」:每次轮到 AI 时调用 act() 完成整个回合动作。 |
||||||
|
* 赤犬反击(counterBomb)属于反应式,由驱动器在对手出炸弹后单独调用 maybeCounter()。 |
||||||
|
*/ |
||||||
|
final class Ai |
||||||
|
{ |
||||||
|
/** 叫分:按手牌强度评估 0..3。 */ |
||||||
|
public static function bid(array $hand): int |
||||||
|
{ |
||||||
|
$score = 0; |
||||||
|
$byRank = []; |
||||||
|
foreach ($hand as $c) { |
||||||
|
$byRank[$c->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<Combo> $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<Combo> $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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,68 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace Yangweijie\Ui2\Games\OnePieceDoudizhu; |
||||||
|
|
||||||
|
/** |
||||||
|
* 一张牌:rank 决定牌力,suit 仅用于显示与花色技能(四皇「领土宣言」)。 |
||||||
|
* |
||||||
|
* 牌力序(升序): |
||||||
|
* 3..10 -> 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()]; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,90 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace Yangweijie\Ui2\Games\OnePieceDoudizhu; |
||||||
|
|
||||||
|
/** |
||||||
|
* 9 名代表性角色(每势力 3 名),各带一个独特技能。 |
||||||
|
* 技能深度结合卡牌对战:onPlay/onBombPlayed 挂出牌修正,onTurnStart 直接施加战场状态。 |
||||||
|
*/ |
||||||
|
final class Character |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
public string $id, |
||||||
|
public string $name, |
||||||
|
public string $title, |
||||||
|
public string $faction, |
||||||
|
public string $skillId, |
||||||
|
public string $skillName, |
||||||
|
public string $skillDesc, |
||||||
|
public string $skillTrigger, |
||||||
|
public string $skillCost, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
public function skill(): Skill |
||||||
|
{ |
||||||
|
return new Skill( |
||||||
|
$this->skillId, |
||||||
|
$this->skillName, |
||||||
|
$this->skillDesc, |
||||||
|
$this->skillTrigger, |
||||||
|
$this->skillCost, |
||||||
|
$this->faction, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/** @return list<self> */ |
||||||
|
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<self> */ |
||||||
|
public static function byFaction(string $faction): array |
||||||
|
{ |
||||||
|
return \array_values(\array_filter(self::all(), static fn (self $c) => $c->faction === $faction)); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,252 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace Yangweijie\Ui2\Games\OnePieceDoudizhu; |
||||||
|
|
||||||
|
use RuntimeException; |
||||||
|
|
||||||
|
/** |
||||||
|
* 牌型识别与比较。 |
||||||
|
* |
||||||
|
* 支持:单/对/三/三带一/三带二/顺子/连对/飞机(含翼)/炸弹/火箭。 |
||||||
|
* 比较规则:火箭 > 炸弹(比点) > 普通(同型同长比点)。技能修正(霸气/不可拦截) |
||||||
|
* 由 Engine 在对局层处理,不在本类内。 |
||||||
|
*/ |
||||||
|
final class Combo |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
public string $type, |
||||||
|
public int $rank, // 主要比较点(炸弹/火箭用 17) |
||||||
|
public int $length, // 顺子/连对/飞机的长度 |
||||||
|
public array $cards, // list<Card> |
||||||
|
public bool $isBomb = false, |
||||||
|
public bool $isRocket = false, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<Card> $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<int,int> $counts |
||||||
|
*/ |
||||||
|
private static function rankWithCount(array $counts, int $want): ?int |
||||||
|
{ |
||||||
|
foreach ($counts as $r => $c) { |
||||||
|
if ($c === $want) { |
||||||
|
return $r; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<int> $ranks |
||||||
|
* @param array<int,int> $counts |
||||||
|
* @param list<Card> $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<int> $ranks |
||||||
|
* @param array<int,int> $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<int> $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))); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,61 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace Yangweijie\Ui2\Games\OnePieceDoudizhu; |
||||||
|
|
||||||
|
/** |
||||||
|
* 54 张牌组:4 花色 × 3..2 + 双王。洗牌后发 3 手 + 3 张底牌。 |
||||||
|
*/ |
||||||
|
final class Deck |
||||||
|
{ |
||||||
|
/** |
||||||
|
* @return list<Card> |
||||||
|
*/ |
||||||
|
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<int, list<Card>>, bottom: list<Card>} |
||||||
|
*/ |
||||||
|
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<Card> $hand |
||||||
|
*/ |
||||||
|
public static function sort(array &$hand): void |
||||||
|
{ |
||||||
|
\usort($hand, static fn (Card $a, Card $b): int => $a->rank <=> $b->rank); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,48 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace Yangweijie\Ui2\Games\OnePieceDoudizhu; |
||||||
|
|
||||||
|
/** |
||||||
|
* 三大势力:海军本部 / 王下七武海 / 四皇。 |
||||||
|
* 每个势力有「特性」描述(影响 AI 行为与部分被动),成员使用各自主动技能。 |
||||||
|
*/ |
||||||
|
final class Faction |
||||||
|
{ |
||||||
|
public const NAVY = 'navy'; // 海军本部 — 绝对正义(控制/反制) |
||||||
|
public const WARLORD = 'warlord'; // 王下七武海 — 被招安的海盗(诡诈/交换) |
||||||
|
public const EMPEROR = 'emperor'; // 四皇 — 新世界霸主(压制/掠夺) |
||||||
|
|
||||||
|
/** @var array<string, array{name:string, creed:string, trait:string, color:string}> */ |
||||||
|
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'; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,501 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace Yangweijie\Ui2\Games\OnePieceDoudizhu; |
||||||
|
|
||||||
|
use InvalidArgumentException; |
||||||
|
use RuntimeException; |
||||||
|
|
||||||
|
/** |
||||||
|
* 对局状态 + 规则引擎 + 技能调度。 |
||||||
|
* |
||||||
|
* 阶段:bidding(叫地主) → playing(出牌) → over。 |
||||||
|
* 技能采用「先 arm(激活)后行动」模型: |
||||||
|
* - onTurnStart 技能:arm 时立即生效(冻结/石化/偷牌/推牌) |
||||||
|
* - onPlay / onBombPlayed 技能:arm 后给「下一手牌」附加修正(跳过/不可拦截/霸气/炸弹+1) |
||||||
|
* - 赤犬反击:对手出炸弹后调用 counterBomb() 取消 |
||||||
|
*/ |
||||||
|
final class Game |
||||||
|
{ |
||||||
|
/** @var list<PlayerState> */ |
||||||
|
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<string> */ |
||||||
|
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<Card> 底牌(叫完分给地主) */ |
||||||
|
public array $bottom = []; |
||||||
|
/** 是否已把底牌发给地主(用于「情报」查看) */ |
||||||
|
public bool $bottomRevealed = false; |
||||||
|
|
||||||
|
/** 事件回调(音效/UI 钩子):fn(event, payload) */ |
||||||
|
public $onEvent = null; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<PlayerState> $players |
||||||
|
* @param list<Card> $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<Card> $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<Card> $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<Card> $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<Combo> |
||||||
|
*/ |
||||||
|
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); |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,151 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace Yangweijie\Ui2\Games\OnePieceDoudizhu; |
||||||
|
|
||||||
|
/** |
||||||
|
* 从一手牌枚举所有合法牌型(用于 AI 决策与合法性校验)。 |
||||||
|
* 同 (type,rank,length) 的去重,避免手牌多花色导致组合爆炸。 |
||||||
|
*/ |
||||||
|
final class MoveGenerator |
||||||
|
{ |
||||||
|
/** |
||||||
|
* @param list<Card> $hand |
||||||
|
* @return list<Combo> |
||||||
|
*/ |
||||||
|
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; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,49 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace Yangweijie\Ui2\Games\OnePieceDoudizhu; |
||||||
|
|
||||||
|
/** |
||||||
|
* 单名玩家运行时状态。 |
||||||
|
*/ |
||||||
|
final class PlayerState |
||||||
|
{ |
||||||
|
/** @var list<Card> */ |
||||||
|
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<Card> $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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,261 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace Yangweijie\Ui2\Games\OnePieceDoudizhu; |
||||||
|
|
||||||
|
/** |
||||||
|
* Win32 rendering shim. |
||||||
|
* |
||||||
|
* The original GameController was written against libui's DrawContext / |
||||||
|
* Brush / Color / FontDescriptor API. Those symbols are re-declared here, in |
||||||
|
* the SAME namespace, so the controller's drawing code stays byte-for-byte |
||||||
|
* identical while the actual pixels are produced by the Win32 GDI primitives |
||||||
|
* declared in win32.stub.php (implemented in cpp-src/win32.cc). |
||||||
|
* |
||||||
|
* Colour convention: PHP code passes 0xRRGGBB ints. GDI needs 0xBBGGRR, so |
||||||
|
* ddz_rgb() flips the channels on the way down to C++. |
||||||
|
*/ |
||||||
|
|
||||||
|
/* ----------------------------- constants ------------------------------ */ |
||||||
|
|
||||||
|
/** Text alignment — mirrors libui DrawTextAlign. */ |
||||||
|
final class DrawTextAlign |
||||||
|
{ |
||||||
|
public const Left = 0; |
||||||
|
public const Center = 1; |
||||||
|
public const Right = 2; |
||||||
|
} |
||||||
|
|
||||||
|
/** Font weight — mirrors libui TextWeight. */ |
||||||
|
final class TextWeight |
||||||
|
{ |
||||||
|
public const Normal = 400; |
||||||
|
public const Bold = 700; |
||||||
|
} |
||||||
|
|
||||||
|
/* ----------------------------- color types ---------------------------- */ |
||||||
|
|
||||||
|
final class Color |
||||||
|
{ |
||||||
|
public int $hex; |
||||||
|
|
||||||
|
public function __construct(int $hex) |
||||||
|
{ |
||||||
|
$this->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<int, array{0: float, 1: float, 2: float, 3: float, 4?: float}> $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 |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,103 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace Yangweijie\Ui2\Games\OnePieceDoudizhu; |
||||||
|
|
||||||
|
/** |
||||||
|
* 技能定义 + 效果实现。 |
||||||
|
* |
||||||
|
* 效果分类: |
||||||
|
* - onTurnStart 技能:在 armSkill() 时立即由 Skill::apply() 执行(冻结/石化/推牌/偷牌) |
||||||
|
* - onPlay / onBombPlayed 技能:在 Game::play() 中通过预置标记附加到那一手牌(跳过/不可拦截/霸气/炸弹+1) |
||||||
|
* - 赤犬反击:在 Game::counterBomb() 中处理(不属于 arm/apply 路径) |
||||||
|
*/ |
||||||
|
final class Skill |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
public string $id, |
||||||
|
public string $name, |
||||||
|
public string $desc, |
||||||
|
public string $trigger, // onTurnStart | onPlay | onBombPlayed |
||||||
|
public string $cost, // once | charges:N | haki:N |
||||||
|
public string $faction, |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
/** 应用 onTurnStart 类技能(立即生效)。 */ |
||||||
|
public static function apply(Game $g, int $actor, ?int $target): void |
||||||
|
{ |
||||||
|
$skill = Character::byId($g->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); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,107 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace Yangweijie\Ui2\Games\OnePieceDoudizhu; |
||||||
|
|
||||||
|
/** |
||||||
|
* Sound-effects manager (TypePHP / Win32 port). |
||||||
|
* |
||||||
|
* The original relied on procedural WAV files loaded through the |
||||||
|
* Yangweijie\Ui2\System\Audio (miniaudio) bridge, which is not available in a |
||||||
|
* statically-compiled TypePHP binary. This port keeps the exact same public |
||||||
|
* API used by GameController (instance / trigger / setEnabled / unload …) but |
||||||
|
* is a safe no-op, so the game logic and event hooks are unchanged. |
||||||
|
* |
||||||
|
* To add real audio later, generate assets/audio/*.wav and call |
||||||
|
* win_message_beep() (or a miniaudio bridge) from trigger(). |
||||||
|
*/ |
||||||
|
final class Sound |
||||||
|
{ |
||||||
|
public const CLICK = 'click'; |
||||||
|
public const DEAL = 'deal'; |
||||||
|
public const PLAY = 'play'; |
||||||
|
public const PASS = 'pass'; |
||||||
|
public const BOMB = 'bomb'; |
||||||
|
public const SKILL = 'skill'; |
||||||
|
public const BID = 'bid'; |
||||||
|
public const WIN = 'win'; |
||||||
|
public const LOSE = 'lose'; |
||||||
|
|
||||||
|
private const DEFAULT_BINDINGS = [ |
||||||
|
'click' => 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(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,41 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
/** |
||||||
|
* Win32 API declarations (stub). |
||||||
|
* |
||||||
|
* C++ (cpp-src/win32.cc) only provides thin wrappers around Win32 APIs and |
||||||
|
* GDI drawing primitives. ALL game logic and rendering live in PHP. |
||||||
|
* |
||||||
|
* These empty declarations tell the TypePHP compiler about the native |
||||||
|
* functions; the real implementations are linked from win32.cc. |
||||||
|
*/ |
||||||
|
|
||||||
|
// ---- Window & message loop ---- |
||||||
|
function win_create_window(string $title, int $width, int $height): int {} |
||||||
|
/** Current client-area size as [width, height]. */ |
||||||
|
function win_get_client_size(int $hWnd): array {} |
||||||
|
function win_show_window(int $hWnd, int $cmdShow): void {} |
||||||
|
function win_quit_requested(): bool {} |
||||||
|
function win_post_quit(int $exitCode): void {} |
||||||
|
/** Returns [type, a, b, c]; empty array when no message pending. */ |
||||||
|
function win_peek_message(): array {} |
||||||
|
function win_get_tick_count(): int {} |
||||||
|
function win_message_box(int $hWnd, string $text, string $caption, int $uType): int {} |
||||||
|
function win_message_beep(int $type): void {} |
||||||
|
|
||||||
|
// ---- Double-buffered frame ---- |
||||||
|
function win_begin_paint(int $hWnd): int {} |
||||||
|
function win_end_paint(int $hWnd, int $hdc): void {} |
||||||
|
|
||||||
|
// ---- GDI primitives ---- |
||||||
|
function win_fill_rect(int $hdc, int $x, int $y, int $w, int $h, int $rgb): void {} |
||||||
|
function win_draw_block(int $hdc, int $x, int $y, int $size, int $rgb): void {} |
||||||
|
function win_draw_line(int $hdc, int $x1, int $y1, int $x2, int $y2, int $rgb): void {} |
||||||
|
/** Ellipse centered at (cx, cy) with width/height. */ |
||||||
|
function win_fill_ellipse(int $hdc, int $cx, int $cy, int $w, int $h, int $rgb): void {} |
||||||
|
function win_fill_rounded_rect(int $hdc, int $x, int $y, int $w, int $h, int $radius, int $rgb): void {} |
||||||
|
function win_stroke_rounded_rect(int $hdc, int $x, int $y, int $w, int $h, int $radius, int $rgb, int $thickness): void {} |
||||||
|
/** Plain ASCII text. */ |
||||||
|
function win_draw_text(int $hdc, int $x, int $y, string $text, int $fontSize, int $rgb, int $bold): void {} |
||||||
|
/** UTF-8 text; align: 0=left, 1=center, 2=right. width=0 means no alignment. */ |
||||||
|
function win_draw_text_ex(int $hdc, int $x, int $y, string $text, int $fontSize, int $rgb, int $bold, int $width, int $align): void {} |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
name: onepiece-doudizhu |
||||||
|
version: 1.0.0 |
||||||
|
mode: bin |
||||||
|
sources: |
||||||
|
- main.php |
||||||
|
- ./php-src |
||||||
|
- ./cpp-src |
||||||
Loading…
Reference in new issue