TypePHP 编译器
https://swoole.com/aot/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
501 lines
16 KiB
501 lines
16 KiB
<?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);
|
|
}
|
|
}
|
|
|