Skip to content

Commit b12cf08

Browse files
author
Parsons
committed
feat: 完成方天畫戟功能
1 parent db52601 commit b12cf08

6 files changed

Lines changed: 354 additions & 33 deletions

File tree

.codegraph/.gitignore

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# CodeGraph data files
2+
# These are local to each machine and should not be committed
3+
4+
# Database
5+
*.db
6+
*.db-wal
7+
*.db-shm
8+
9+
# Cache
10+
cache/
11+
12+
# Logs
13+
*.log
14+
15+
# Hook markers
16+
.dirty

.vscode/mcp.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"servers": {
3+
"codegraph": {
4+
"type": "stdio",
5+
"command": "npx",
6+
"args": ["@colbymchenry/codegraph", "serve", "--mcp"]
7+
}
8+
}
9+
}

API_DOC.md

Lines changed: 108 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
# 三國殺 API 文件
22

3-
> 此檔案不納入 git 追蹤
4-
53
## 架構概述
64

75
```
@@ -488,6 +486,99 @@ A (裝備丈八蛇矛) 呼叫本 API → 棄兩張手牌到墓地 → 視為出
488486

489487
---
490488

489+
## 19. 方天畫戟多目標殺
490+
491+
```
492+
POST /api/games/{gameId}/player:useHeavenlyDoubleHalberdKill
493+
```
494+
495+
| 欄位 | 型別 | 說明 |
496+
| --------------- | -------------- | ----------------------------------------------------------------------------------- |
497+
| playerId | String | 攻擊者玩家 ID(裝備方天畫戟) |
498+
| cardId | String | 要打出的殺 cardId(必須為攻擊者**最後一張手牌**|
499+
| targetPlayerIds | List\<String\> | 全部目標玩家 ID(size 1~3;index 0 為主要目標;不重複、不含自己、皆需在攻擊範圍內) |
500+
501+
**觸發時機**:攻擊者裝備方天畫戟、手上**只剩一張殺**時主動呼叫(不透過 `playCard` API)
502+
503+
**流程**
504+
505+
```
506+
A (裝備方天畫戟) 呼叫本 API → 棄最後一張殺到墓地
507+
→ 系統發出 HeavenlyDoubleHalberdKillTriggerEvent(含所有目標 id)
508+
→ 依序對每個目標:
509+
- 目標收 AskDodgeEvent(若有防具先 AskPlayEquipmentEffectEvent)
510+
- 目標出閃 → 該目標免傷,繼續下一目標
511+
- 目標不出閃 → 扣血(HP=0 進入瀕死流程;救回後繼續下一目標)
512+
→ 所有目標處理完畢 → behavior 結束
513+
```
514+
515+
**特殊情況**
516+
517+
- 攻擊者必須裝備方天畫戟
518+
- 該殺必須是攻擊者最後一張手牌(出牌後手牌為空才會觸發武器效果)
519+
- 所有目標皆需在攻擊者攻擊範圍內、不重複、不可含攻擊者自己
520+
- 單目標時(`targetPlayerIds.size() == 1`)短路為一般殺
521+
- 中間目標進入瀕死:暫停 polling,dying flow 結束後恢復下一目標詢問
522+
- 可與八卦陣 / 青釭劍 / 各防具並存:目標各自獨立判定
523+
524+
**備註**:此 API 獨立於 `playCard`
525+
526+
---
527+
528+
## 20. 奸雄(曹操武將技)效果選擇
529+
530+
```
531+
POST /api/games/{gameId}/player:useJianXiongEffect
532+
```
533+
534+
| 欄位 | 型別 | 說明 |
535+
| -------- | ------ | --------------------------------------------- |
536+
| playerId | String | 受傷玩家(曹操)ID |
537+
| choice | String | `ACCEPT`(獲得造成傷害的牌)或 `SKIP`(放棄) |
538+
539+
**觸發時機**:曹操(`WEI001`)受到傷害且仍存活、top behavior 為 `NormalActiveKillBehavior`(含子類 ViperSpearKill / HeavenlyDoubleHalberdKill)時,系統廣播 `AskJianXiongEffectEvent` 後呼叫。
540+
541+
**啟動條件**(同時成立):
542+
543+
1. 受傷玩家武將為曹操
544+
2. `sourceCard != null`(武將技直接傷害如剛烈/離間/雷擊 sourceCard 為 null,不觸發)
545+
3. 受傷後仍活著(HP > 0;瀕死流程不觸發)
546+
4. Top behavior 屬於白名單之一:
547+
- `NormalActiveKillBehavior`(含子類 ViperSpearKill / HeavenlyDoubleHalberdKill)— 普通殺 / 武器觸發殺
548+
- `DuelBehavior` — 決鬥輸給對手(含 Path A 不出殺、Path B 互換後沒殺)
549+
- `LightningJudgementBehavior` — 閃電判定打中(Ward 路徑)
550+
- `BarbarianInvasionBehavior` / `ArrowBarrageBehavior` — AOE 受傷(曹操不出殺/不出閃)
551+
- 空 stack — 閃電判定無 Ward 路徑
552+
553+
**取牌規則**(依傷害來源):
554+
555+
- **普通殺 / 火攻轉殺 / 武器觸發殺(青龍偃月刀、貫石斧)/ 決鬥 / 閃電 / 南蠻入侵 / 萬箭齊發**:獲得 sourceCard 本身(1 張),`sourceCardIds = [<sourceCard.id>]`
556+
- **丈八蛇矛攻擊(FAQ 特例)**:獲得攻擊者打出的**兩張**手牌(VirtualKill 不算),`sourceCardIds = [<discard1>, <discard2>]`
557+
558+
**流程**
559+
560+
```
561+
B 對曹操出殺(或丈八蛇矛攻擊)→ 曹操不出閃、扣血未死
562+
→ 系統 push WaitingJianXiongResponseBehavior 並 broadcast AskJianXiongEffectEvent
563+
→ 曹操呼叫本 API:
564+
- ACCEPT:把 sourceCardIds 列出的牌全數從棄牌堆移到手牌 → JianXiongEffectEvent(taken=true)
565+
- SKIP:JianXiongEffectEvent(taken=false),牌留在墓地
566+
→ behavior 彈出,回到正常流程
567+
```
568+
569+
**特殊情況 / 整合機制**
570+
571+
- **致命傷被救回後仍可發動**:曹操受致命傷進入瀕死,被桃救回 → DyingAskPeachBehavior 在 revival 分支 replay `SkillEngine.onDamaged`,奸雄正常觸發
572+
- 一般傷害:pendingSourceCardId 帶 sourceCard.id,replay 時 PlayCard.findById 重建
573+
- 丈八蛇矛 (VirtualKill) 致命:pendingViperSpearDiscardCardIds 帶兩張棄牌 id,replay 時 JianXiongSkill 取得 DyingAskPeachBehavior 上的 pending discards → ACCEPT 拿兩張棄牌
574+
- 一次 DamageEvent 只觸發 1 次(多點傷害不重複觸發)
575+
- 若 ACCEPT 時 sourceCard 已不在棄牌堆(被其他效果先取走)→ throw `IllegalStateException`
576+
- AOE polling 整合:BarbarianInvasion / ArrowBarrage 的 polling 流程偵測 WaitingJX 介入後,將 polling-advance 註冊為 callback 由 WaitingJX.resolveChoice 在 isOneRound=true 之前 resume,確保 stack 與 activePlayer 一致
577+
578+
**備註**:此 API 獨立於 `playCard`
579+
580+
---
581+
491582
## WebSocket 事件類型
492583

493584
前端透過 WebSocket 接收以下事件,根據事件類型決定 UI 行為:
@@ -504,19 +595,20 @@ A (裝備丈八蛇矛) 呼叫本 API → 棄兩張手牌到墓地 → 視為出
504595

505596
### 需要玩家回應的事件
506597

507-
| 事件 | 說明 | 玩家需呼叫的 API |
508-
| ---------------------------------------- | ------------------------------------------------------------------------ | --------------------------------- |
509-
| `AskPlayWardEvent` | 詢問是否出無懈可擊(有 Ward 的玩家收到) | playWardCard |
510-
| `WaitForWardEvent` | 等待其他人出無懈可擊(沒 Ward 的玩家收到) | (等待) |
511-
| `AskKillEvent` | 需要出殺(南蠻入侵/決鬥/借刀殺人) | playCard |
512-
| `AskDodgeEvent` | 需要出閃(萬箭齊發/被殺) | playCard |
513-
| `AskPeachEvent` | 瀕死需要出桃 | playCard |
514-
| `BountifulHarvestEvent` | 五穀豐登輪到你選牌 | chooseCardFromBountifulHarvest |
515-
| `AskActivateYinYangSwordsEvent` | 雌雄雙股劍:詢問攻擊者是否發動效果 | activateYinYangSwords |
516-
| `AskYinYangSwordsEffectEvent` | 雌雄雙股劍效果:目標選擇棄牌或讓攻擊者摸牌(攻擊者選 ACTIVATE 後才出現) | useYinYangSwordsEffect |
517-
| `AskGreenDragonCrescentBladeEffectEvent` | 青龍偃月刀效果:攻擊者選擇是否再出一張殺 | useGreenDragonCrescentBladeEffect |
518-
| `AskStonePiercingAxeEffectEvent` | 貫石斧效果:攻擊者選擇是否棄兩張牌強制命中 | useStonePiercingAxeEffect |
519-
| `ViperSpearKillTriggerEvent` | 丈八蛇矛發動:攻擊者棄兩張牌作為虛擬殺使用(通知事件,無需回應) | useViperSpearKill |
598+
| 事件 | 說明 | 玩家需呼叫的 API |
599+
| ---------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------- |
600+
| `AskPlayWardEvent` | 詢問是否出無懈可擊(有 Ward 的玩家收到) | playWardCard |
601+
| `WaitForWardEvent` | 等待其他人出無懈可擊(沒 Ward 的玩家收到) | (等待) |
602+
| `AskKillEvent` | 需要出殺(南蠻入侵/決鬥/借刀殺人) | playCard |
603+
| `AskDodgeEvent` | 需要出閃(萬箭齊發/被殺) | playCard |
604+
| `AskPeachEvent` | 瀕死需要出桃 | playCard |
605+
| `BountifulHarvestEvent` | 五穀豐登輪到你選牌 | chooseCardFromBountifulHarvest |
606+
| `AskActivateYinYangSwordsEvent` | 雌雄雙股劍:詢問攻擊者是否發動效果 | activateYinYangSwords |
607+
| `AskYinYangSwordsEffectEvent` | 雌雄雙股劍效果:目標選擇棄牌或讓攻擊者摸牌(攻擊者選 ACTIVATE 後才出現) | useYinYangSwordsEffect |
608+
| `AskGreenDragonCrescentBladeEffectEvent` | 青龍偃月刀效果:攻擊者選擇是否再出一張殺 | useGreenDragonCrescentBladeEffect |
609+
| `AskStonePiercingAxeEffectEvent` | 貫石斧效果:攻擊者選擇是否棄兩張牌強制命中 | useStonePiercingAxeEffect |
610+
| `ViperSpearKillTriggerEvent` | 丈八蛇矛發動:攻擊者棄兩張牌作為虛擬殺使用(通知事件,無需回應) | useViperSpearKill |
611+
| `AskJianXiongEffectEvent` | 奸雄發動:曹操選擇是否獲得造成傷害的牌(含 `playerId``sourceCardIds : List<String>`| useJianXiongEffect |
520612

521613
### 效果事件
522614

@@ -541,6 +633,7 @@ A (裝備丈八蛇矛) 呼叫本 API → 棄兩張手牌到墓地 → 視為出
541633
| `StonePiercingAxeTriggerEvent` | 貫石斧發動,棄兩張牌強制命中(含 attackerPlayerId、targetPlayerId、discardedCardIds) |
542634
| `ViperSpearKillTriggerEvent` | 丈八蛇矛發動,棄兩張牌當殺使用(含 attackerPlayerId、targetPlayerId、discardedCardIds) |
543635
| `HeavenlyDoubleHalberdKillTriggerEvent` | 方天畫戟發動,多目標殺(含 attackerPlayerId、cardId、targetPlayerIds) |
636+
| `JianXiongEffectEvent` | 奸雄結算結果(含 `playerId``sourceCardIds : List<String>``taken`|
544637

545638
---
546639

composables/useApi.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,16 @@ export function useApi() {
259259
) => {
260260
return api.post(`/api/games/${gameId}/player:useViperSpearKill`, params)
261261
}
262+
const useHeavenlyDoubleHalberdKill = (
263+
gameId: string,
264+
params: {
265+
playerId: string
266+
cardId: ThreeKingdomsCardIds
267+
targetPlayerIds: string[]
268+
},
269+
) => {
270+
return api.post(`/api/games/${gameId}/player:useHeavenlyDoubleHalberdKill`, params)
271+
}
262272
return {
263273
api,
264274
createGame,
@@ -279,6 +289,7 @@ export function useApi() {
279289
useGreenDragonCrescentBladeEffect,
280290
useStonePiercingAxeEffect,
281291
useViperSpearKill,
292+
useHeavenlyDoubleHalberdKill,
282293
getDeck,
283294
setDeck,
284295
}

src/classes/Game.ts

Lines changed: 106 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -174,13 +174,65 @@ export default class Game {
174174
this.api.playCard(this.gameId, params)
175175
}
176176
handleClickPlayer = (player: Player) => {
177+
const handleHeavenlyDoubleHalberdTargetSelection = () => {
178+
const selectedIndex = this.selectTargetPlayers.findIndex((p) => p.id === player.id)
179+
if (selectedIndex >= 0) {
180+
player.setPlayerSelected(false)
181+
this.selectTargetPlayers.splice(selectedIndex, 1)
182+
this.me.updateKillTargetSelectionHint(this.selectTargetPlayers.length, 3, true)
183+
return true
184+
}
185+
if (this.selectTargetPlayers.length >= 3) {
186+
return true
187+
}
188+
player.setPlayerSelected(true, 'from')
189+
this.selectTargetPlayers.push(player)
190+
this.me.updateKillTargetSelectionHint(this.selectTargetPlayers.length, 3, true)
191+
return true
192+
}
177193
if (this.me.viperSpearMode) {
178194
if (!player.isOutofDistance) {
179195
this.me.handleViperSpearTarget(player)
180196
}
181197
return
182198
}
199+
if (this.me.heavenlyDoubleHalberdMode) {
200+
if (
201+
!this.me.selectedCard ||
202+
this.me.selectedCard.name !== '殺' ||
203+
player.isOutofDistance
204+
) {
205+
return
206+
}
207+
handleHeavenlyDoubleHalberdTargetSelection()
208+
return
209+
}
183210
if (!this.me.selectedCard || player.isOutofDistance) return
211+
if (
212+
this.me.selectedCard.name === '殺' &&
213+
this.me.canTriggerHeavenlyDoubleHalberd(this.me.selectedCard)
214+
) {
215+
// 保險:只要符合方天畫戟條件,永遠先進入多目標選擇模式,不能直接走一般殺流程。
216+
this.me.enableHeavenlyDoubleHalberdMode()
217+
handleHeavenlyDoubleHalberdTargetSelection()
218+
return
219+
}
220+
if (this.me.selectedCard.name === '殺') {
221+
const selectedIndex = this.selectTargetPlayers.findIndex((p) => p.id === player.id)
222+
if (selectedIndex >= 0) {
223+
player.setPlayerSelected(false)
224+
this.selectTargetPlayers.splice(selectedIndex, 1)
225+
} else {
226+
this.selectTargetPlayers.forEach((targetPlayer) =>
227+
targetPlayer.setPlayerSelected(false),
228+
)
229+
player.setPlayerSelected(true, 'from')
230+
this.selectTargetPlayers = [player]
231+
}
232+
this.me.updateKillTargetSelectionHint(this.selectTargetPlayers.length, 1, false)
233+
this.me.mainInstanceMap.checkModal?.setAlpha(1)
234+
return
235+
}
184236
if (this.me.selectedCard.name === '過河拆橋') {
185237
this.seats.forEach((player) => player.setOutOfDistance(false))
186238
// 開啟選擇面板
@@ -199,24 +251,6 @@ export default class Game {
199251
this.me.askReaction('useSnatchEffect', event)
200252
return
201253
}
202-
if (this.me.selectedCard.name === '殺') {
203-
const params = {
204-
playerId: this.me.id,
205-
targetPlayerId: player.id,
206-
cardId: this.me.selectedCard.id,
207-
playType: 'active',
208-
}
209-
this.api.playCard(this.gameId, params)
210-
atkLine({
211-
endPoint: new Phaser.Math.Vector2(player.instance.x, player.instance.y),
212-
scene: this.scene,
213-
})
214-
const card = this.me.selectedCard
215-
card.playCard()
216-
console.log(player, this.me)
217-
this.seats.forEach((player) => player.setOutOfDistance(false))
218-
this.me.selectedCard = null
219-
}
220254
if (this.me.selectedCard?.name === '決鬥' || this.me.selectedCard?.name === '樂不思蜀') {
221255
// 清除前一個選擇的玩家邊匡
222256
if (this.selectTargetPlayers.length > 0) {
@@ -322,6 +356,23 @@ export default class Game {
322356
}
323357
return this.api.useViperSpearKill(this.gameId, params)
324358
}
359+
playActiveKill = (cardId: ThreeKingdomsCardIds, targetPlayerId: string) => {
360+
const params = {
361+
playerId: this.me.id,
362+
targetPlayerId,
363+
cardId,
364+
playType: 'active' as PlayType,
365+
}
366+
return this.api.playCard(this.gameId, params)
367+
}
368+
useHeavenlyDoubleHalberdKill = (cardId: ThreeKingdomsCardIds, targetPlayerIds: string[]) => {
369+
const params = {
370+
playerId: this.me.id,
371+
cardId,
372+
targetPlayerIds,
373+
}
374+
return this.api.useHeavenlyDoubleHalberdKill(this.gameId, params)
375+
}
325376
useDismantleEffect = async (
326377
targetPlayerId: string,
327378
cardId: ThreeKingdomsCardIds | undefined,
@@ -629,6 +680,43 @@ export default class Game {
629680
}
630681
break
631682
}
683+
case 'HeavenlyDoubleHalberdKillTriggerEvent': {
684+
const allPlayers = [...this.seats, this.me]
685+
const attacker = allPlayers.find((player) => player.id === data.attackerPlayerId)
686+
const targetPlayerIds: string[] = data.targetPlayerIds || []
687+
const targetNames = targetPlayerIds
688+
.map((id) => allPlayers.find((player) => player.id === id)?.general?.name || id)
689+
.join('、')
690+
if (this.hintInstance) {
691+
const hintText: Phaser.GameObjects.Text = this.hintInstance.getAt(0)
692+
hintText?.setText(
693+
`${
694+
attacker?.general?.name || data.attackerPlayerId
695+
} 發動方天畫戟,對 ${targetNames} 出殺`,
696+
)
697+
this.hintInstance.setAlpha(1)
698+
this.scene.tweens.killTweensOf(this.hintInstance)
699+
this.scene.tweens.add({
700+
targets: this.hintInstance,
701+
alpha: 0,
702+
duration: 1200,
703+
delay: 1000,
704+
ease: 'Power2',
705+
})
706+
}
707+
if (!attacker) break
708+
const startPoint = new Phaser.Math.Vector2(attacker.instance.x, attacker.instance.y)
709+
targetPlayerIds.forEach((targetPlayerId) => {
710+
const target = allPlayers.find((player) => player.id === targetPlayerId)
711+
if (!target) return
712+
atkLine({
713+
startPoint,
714+
endPoint: new Phaser.Math.Vector2(target.instance.x, target.instance.y),
715+
scene: this.scene,
716+
})
717+
})
718+
break
719+
}
632720
case 'PlayerDamagedEvent':
633721
// 血量直接從data更新
634722
// const damage = data.from - data.to

0 commit comments

Comments
 (0)