add onepiece-ddz game example

speed_build
yangweijie 3 weeks ago
parent 18decac90b
commit 449afc1516
  1. 124
      examples/onepiece-doudizhu-win32/README.md
  2. 52
      examples/onepiece-doudizhu-win32/build.bat
  3. 402
      examples/onepiece-doudizhu-win32/cpp-src/win32.cc
  4. 87
      examples/onepiece-doudizhu-win32/main.php
  5. 252
      examples/onepiece-doudizhu-win32/php-src/doudizhu/Ai.php
  6. 68
      examples/onepiece-doudizhu-win32/php-src/doudizhu/Card.php
  7. 90
      examples/onepiece-doudizhu-win32/php-src/doudizhu/Character.php
  8. 252
      examples/onepiece-doudizhu-win32/php-src/doudizhu/Combo.php
  9. 61
      examples/onepiece-doudizhu-win32/php-src/doudizhu/Deck.php
  10. 48
      examples/onepiece-doudizhu-win32/php-src/doudizhu/Faction.php
  11. 501
      examples/onepiece-doudizhu-win32/php-src/doudizhu/Game.php
  12. 1570
      examples/onepiece-doudizhu-win32/php-src/doudizhu/GameController.php
  13. 151
      examples/onepiece-doudizhu-win32/php-src/doudizhu/MoveGenerator.php
  14. 49
      examples/onepiece-doudizhu-win32/php-src/doudizhu/PlayerState.php
  15. 261
      examples/onepiece-doudizhu-win32/php-src/doudizhu/Render.php
  16. 103
      examples/onepiece-doudizhu-win32/php-src/doudizhu/Skill.php
  17. 107
      examples/onepiece-doudizhu-win32/php-src/doudizhu/Sound.php
  18. 41
      examples/onepiece-doudizhu-win32/php-src/win32.stub.php
  19. 7
      examples/onepiece-doudizhu-win32/project.yml

@ -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);
}
}

@ -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…
Cancel
Save