parent
18decac90b
commit
a92fc0dfd7
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