From 2434d0f15e352358d3eea98519b632829fe09ab3 Mon Sep 17 00:00:00 2001 From: yafeng2003 <3274412286@qq.com> Date: Thu, 23 Jul 2026 16:50:12 +0800 Subject: [PATCH 01/27] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=99=BE?= =?UTF-8?q?=E4=BD=99=E9=A1=B9DLC=E5=B9=B6=E5=85=A8=E9=9D=A2=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E5=AF=B9=E5=B1=80=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + docs/archive/DLC.json | 263 +- simple-bot/who_designed_adapter.py | 182 + tractor-game-simulator/.gitignore | 2 + tractor-game-simulator/client/package.json | 18 +- .../client/playwright.config.js | 16 + tractor-game-simulator/client/public/DLC.json | 277 +- tractor-game-simulator/client/src/App.jsx | 63 +- .../client/src/components/Game/Card.css | 639 +- .../client/src/components/Game/Card.jsx | 189 +- .../client/src/components/Game/GameBoard.css | 773 +- .../client/src/components/Game/GameBoard.jsx | 8781 ++++++++++-- .../client/src/components/Game/GameTable.css | 3345 ++++- .../client/src/components/Game/GameTable.jsx | 1960 ++- .../client/src/components/Game/Hand.css | 107 +- .../client/src/components/Game/Hand.jsx | 244 +- .../src/components/Game/OpenHandPanel.jsx | 78 + .../src/components/Game/RuleSelector.css | 101 +- .../src/components/Game/RuleSelector.jsx | 312 +- .../src/components/Game/TrumpDeclaration.css | 73 +- .../src/components/Game/TrumpDeclaration.jsx | 66 +- .../src/components/Room/CreateRoomModal.jsx | 34 +- .../client/src/components/Room/RoomList.jsx | 4 +- .../client/src/store/gameStore.js | 31 +- .../client/src/styles/global.css | 2 + .../client/src/utils/actionAvailability.js | 1082 ++ .../client/src/utils/cardPatternUtils.js | 1256 +- .../client/src/utils/cardUtils.js | 69 +- .../client/src/utils/constants.js | 85 +- .../client/src/utils/gameViewUtils.js | 44 + .../client/src/utils/ironEvidenceUtils.js | 8 + .../client/src/utils/ruleCatalog.js | 115 + .../client/src/utils/scoringUtils.js | 128 + .../client/src/utils/trumpUtils.js | 65 + .../client/test/actionAvailability.test.mjs | 2468 ++++ .../client/test/e2e/game-ui.spec.js | 3258 +++++ .../test/fixtures/enduring-preview.html | 78 + .../client/test/fixtures/joker-preview.html | 46 + .../test/fixtures/table-layout-preview.html | 75 + .../fixtures/wooden-ox-layout-preview.html | 96 + .../client/test/gameViewUtils.test.mjs | 76 + .../client/test/global-setup.js | 77 + .../client/test/ruleCatalog.test.mjs | 91 + .../client/test/scoringUtils.test.mjs | 164 + tractor-game-simulator/server/package.json | 4 +- .../server/src/models/Card.js | 32 +- .../server/src/models/GameState.js | 981 +- .../server/src/models/Room.js | 90 +- .../server/src/rules/ruleRegistry.js | 1418 ++ .../server/src/services/BotService.js | 381 +- .../server/src/services/DeckService.js | 20 + .../src/services/DrawingPhaseManager.js | 255 +- .../server/src/services/GameEngine.js | 11389 +++++++++++++++- .../server/src/services/RoundManager.js | 52 +- .../src/services/whoDesignedStrategy.js | 226 + .../src/socket/handlers/gameHandlers.js | 2273 ++- .../src/socket/handlers/playerHandlers.js | 176 +- .../src/socket/handlers/roomHandlers.js | 14 +- .../server/src/utils/cardCooldownUtils.js | 167 + .../server/src/utils/cardPatternUtils.js | 2070 ++- .../server/src/utils/constants.js | 37 +- .../server/src/utils/ironEvidenceUtils.js | 43 + .../src/utils/oneCountryTwoSystemsUtils.js | 148 + .../server/src/utils/pokerUtils.js | 153 + .../server/src/utils/scoringUtils.js | 73 +- .../src/utils/strengthCompensationUtils.js | 114 + .../server/src/utils/threeTigersUtils.js | 59 + .../server/test/botIntegration.mjs | 106 + .../server/test/specialRules.test.mjs | 10988 +++++++++++++++ .../server/test/whoDesignedStrategy.test.mjs | 140 + 70 files changed, 55350 insertions(+), 2901 deletions(-) create mode 100644 simple-bot/who_designed_adapter.py create mode 100644 tractor-game-simulator/client/playwright.config.js create mode 100644 tractor-game-simulator/client/src/components/Game/OpenHandPanel.jsx create mode 100644 tractor-game-simulator/client/src/utils/actionAvailability.js create mode 100644 tractor-game-simulator/client/src/utils/gameViewUtils.js create mode 100644 tractor-game-simulator/client/src/utils/ironEvidenceUtils.js create mode 100644 tractor-game-simulator/client/src/utils/ruleCatalog.js create mode 100644 tractor-game-simulator/client/src/utils/scoringUtils.js create mode 100644 tractor-game-simulator/client/test/actionAvailability.test.mjs create mode 100644 tractor-game-simulator/client/test/e2e/game-ui.spec.js create mode 100644 tractor-game-simulator/client/test/fixtures/enduring-preview.html create mode 100644 tractor-game-simulator/client/test/fixtures/joker-preview.html create mode 100644 tractor-game-simulator/client/test/fixtures/table-layout-preview.html create mode 100644 tractor-game-simulator/client/test/fixtures/wooden-ox-layout-preview.html create mode 100644 tractor-game-simulator/client/test/gameViewUtils.test.mjs create mode 100644 tractor-game-simulator/client/test/global-setup.js create mode 100644 tractor-game-simulator/client/test/ruleCatalog.test.mjs create mode 100644 tractor-game-simulator/client/test/scoringUtils.test.mjs create mode 100644 tractor-game-simulator/server/src/rules/ruleRegistry.js create mode 100644 tractor-game-simulator/server/src/services/whoDesignedStrategy.js create mode 100644 tractor-game-simulator/server/src/utils/cardCooldownUtils.js create mode 100644 tractor-game-simulator/server/src/utils/ironEvidenceUtils.js create mode 100644 tractor-game-simulator/server/src/utils/oneCountryTwoSystemsUtils.js create mode 100644 tractor-game-simulator/server/src/utils/pokerUtils.js create mode 100644 tractor-game-simulator/server/src/utils/strengthCompensationUtils.js create mode 100644 tractor-game-simulator/server/src/utils/threeTigersUtils.js create mode 100644 tractor-game-simulator/server/test/botIntegration.mjs create mode 100644 tractor-game-simulator/server/test/specialRules.test.mjs create mode 100644 tractor-game-simulator/server/test/whoDesignedStrategy.test.mjs diff --git a/.gitignore b/.gitignore index ea01206..bcad0a8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ playing-cards-assets/ rotate_svg.py rotate.py .DS_Store +/WhoDesigned/ diff --git a/docs/archive/DLC.json b/docs/archive/DLC.json index f6c40b2..3da23f4 100644 --- a/docs/archive/DLC.json +++ b/docs/archive/DLC.json @@ -8,8 +8,9 @@ "content": "说一句话需要扣五分。" }, { + "id": "ten_sided_ambush", "name": "十面埋伏", - "content": "庄家队友暗中指定一个点数具有赋分功能(+5,-5)。" + "content": "庄家队友暗选非级牌、非分牌点数。闲家赢得每张-5分,庄家方赢得每张给闲家+5分;底牌乘倍数同理,点数首次出现时公开。" }, { "name": "世事无常", @@ -32,232 +33,280 @@ "content": "庄家16张底牌。闲家开局为60分。" }, { + "id": "perfect_strategy", "name": "算无遗策", - "content": "庄家队友明手。闲家开局为10分。" + "content": "庄家埋底后,庄家队友明手且由庄家代为出牌。闲家开局为10分。" }, { + "id": "know_yourself_and_enemy", "name": "知己知彼", "content": "摸牌后,与对家交换两张牌。" }, { + "id": "news_minister_i", "name": "新闻部长I", "content": "摸牌后,每人给上家两张牌。" }, { + "id": "news_minister_ii", "name": "新闻部长II", "content": "摸牌后,每人给下家两张牌。" }, { + "id": "irresistible_force", "name": "势如破竹", "content": "本局庄家获胜可以连庄。" }, { + "id": "substitute_sacrifice", "name": "李代桃僵", - "content": "玩家可以任意垫一次牌,需要声明(视为小)。" + "content": "每名玩家限一次,可在跟牌时主动声明垫牌:仍须出相同张数,但可无视花色与牌型要求,且本次出牌始终视为小。" }, { + "id": "six_six_great_success", "name": "六六大顺", - "content": "本局具有同花顺(6张起步)。" + "content": "新增同花顺牌型,至少六张连续牌;主同花顺可包含级牌和王牌。" }, { + "id": "single_step_debug", "name": "单步调试", "content": "本局移除甩牌。" }, { + "id": "one_horse_leads", "name": "一马当先", - "content": "庄家队友先出牌。" + "content": "仅第一轮改由庄家队友先出牌;庄家身份、底牌归属及后续轮次均不改变。" }, { + "id": "reform_and_opening_up", "name": "改革开放", - "content": "庄家队友可以再埋一次底牌。闲家获得40分。" + "content": "庄家首次埋底后,庄家队友拿起底牌并重新埋底,随后由庄家先出牌。闲家开局获得40分。" }, { + "id": "heavy_fog", "name": "迷雾重重", - "content": "开局移除牌堆顶8张牌,闲家获得此8张牌中一半分数。" + "content": "开局暗中移除牌堆顶8张牌;全局出牌及其他分数结算完成后公开,闲家补得其中总分的一半。" }, { + "id": "concealed_passage", "name": "暗度陈仓", - "content": "每名玩家限1次,若不是第一个出牌,可以背面向上打出。" + "content": "每名玩家每局限1次;跟牌时可暗置出牌,除自己外本轮结束前无人能看到。一轮可有多人发动,轮末同时公开并正常结算得分和下一轮牌权。" }, { "name": "冰山一角", - "content": "始终保持两张牌明置。" + "content": "进入出牌阶段后,每名玩家自行选择并始终保持两张手牌明置;不足两张时全部明置。" }, { + "id": "fatal_beauty", "name": "红颜祸水", - "content": "黑桃牌视为红桃牌,闲家开局为20分;若红桃为主花色,闲家开局为40分。" + "content": "黑桃牌视为红桃牌,但不新增任何牌型;闲家开局为20分,若红桃为主花色则为40分。" }, { + "id": "stealing_beams", "name": "偷梁换柱", - "content": "四张王牌可以当做任意数字牌使用,若当做分数牌则不计分。" + "content": "每名玩家每局限1次;发动后点击要转换的王牌,先选花色再选点数,转换完成后再自行选牌按基本规则出牌。转换可取消,王牌替成分牌仍不计分,单出可保持王牌。" }, { + "id": "cosmic_shift", "name": "斗转星移", - "content": "当手牌数首次变为不大于12张时所有玩家与队友交换手牌。" + "content": "一轮结束后,若四家手牌均首次不多于12张,所有玩家与队友交换全部手牌。" }, { + "id": "last_stand", "name": "绝处逢生", - "content": "本局不能是无主局;当玩家没有主牌时,若此时手牌数不小于5且花色相同,可以令每张牌视为主牌。" + "content": "本局不能是无主局,也不能用一对王反主;无人亮主时系统随机指定主花色。手牌变化后,若一名玩家不少于5张、没有主牌且全为同一花色,可选择令全部手牌视为主牌;暂时拒绝后仍可在后续手牌变化时再次选择。" }, { + "id": "late_mover_advantage", "name": "后发制人", - "content": "每名玩家限1次,作为3号位时可以令原本的4号位先出牌。" + "content": "每名玩家每局限1次;作为本轮3号位出牌前,可以令下家先出,自己改为本轮最后出牌。只改变出牌顺序,不改变牌的大小结算。" }, { + "id": "frequent_fluctuation", "name": "频繁波动", "content": "有分数牌被打出的轮次结束后,每人交给下家一张牌,闲家开局-10分。" }, { + "id": "minor_disturbance", "name": "微小扰动", "content": "没有分数牌被打出的轮次结束后,每人交给队友一张牌。" }, { + "id": "lingering_discard", "name": "弃掷逦迤", - "content": "有分数牌被打出的轮次结束后,每人弃一张牌(庄家弃分则闲家加分)。" + "content": "有分数牌被打出的轮次结束后,四家各自暗弃一张牌;终局仅公开庄家方两人弃出的分牌,并补给闲家。" }, { + "id": "time_reversal", "name": "时间倒流", - "content": "每名玩家限1次,在一轮出牌结束后可以选择回溯到上一轮出牌结束时的状态。" + "content": "每名玩家每局限一次,可在本轮及轮末额外停留的1秒内预备;轮末询问所有预备者,首个确认发动者令牌局回溯到本轮开始前。" }, { "name": "路线摇摆", - "content": "有不小于10分的牌被打出的轮次结束后逆转出牌顺序。" + "content": "有单张价值不小于10分的牌被打出的轮次结束后,顺时针与逆时针出牌顺序互换。" }, { "name": "一带一路", - "content": "可以使用“小甩牌”牌型(两张单牌)。" + "content": "每名玩家每局限一次,首发时可点击“一带一路”,再出同一有效花色的两张非对子单牌,无需满足甩牌必大条件;未发动时仍可按普通甩牌尝试出这两张牌。跟牌或毙牌不消耗次数。" }, { "name": "昼夜轮转", - "content": "第 x 轮中,点数为 x % 13 + 1 的牌在其相应花色内视为最大,闲家开局为20分。" + "content": "第x轮将点数x % 13 + 1提升为相应花色内最大,其他牌序不变;若轮转到级牌则仍按A最大。闲家开局为20分。" }, { "name": "尊老爱幼", - "content": "每轮打出最小牌的玩家在下一轮先出牌。" + "content": "每轮仍按常规规则结算大牌;另以一号位牌型为准寻找最小牌:同牌型按正常牌力,牌型不一致时先比结构贴合度,异花色牌越多越小(包括主牌),数量相同再从最小牌起按字典序比较,完全相同时后出者更小。最小者下一轮先出牌。" }, { + "id": "rites_collapse", "name": "礼崩乐坏", - "content": "每轮的首位玩家不能主动打出A。" + "content": "每轮一号位主动出牌时不能选择任何A;跟牌时仍可正常打出A。若一号位手中只剩A,则可正常出牌。" }, { + "id": "recommend_talent", "name": "举贤任能", - "content": "每名玩家限一次,作为1号位时可以将出牌权交给下家。" + "content": "每名玩家每局限一次;作为本轮一号位出牌前,可令下家先出,自己改为本轮最后出牌。只改变牌序,不改变牌的大小。" }, { + "id": "accident_insurance", "name": "意外保险", - "content": "单轮得分超过30的部分由对方获得。" + "content": "单轮牌面分超过30时,超出部分归另一方:闲家赢牌最多计30分;庄家方赢牌则将超出30的部分补给闲家。" }, { "name": "为人坦荡", - "content": "手牌数首次变化为不大于5时所有玩家明置手牌。" + "content": "一轮结束时,若所有玩家手牌均不多于5张,所有玩家同时明置剩余手牌。" }, { + "id": "three_powers", "name": "三权分立", - "content": "游戏开始时,2、3、4号位玩家各暗中指定一个数字(不能是主点数),并令本局中这三者点数的牌分别代替原本10、5、K的分数." + "content": "庄家埋底后,2、3、4号位分别暗选一个非级牌点数,依次重载原10、5、K分牌。可选择原5、10、K,也可重复选择并叠加分值;对应点数首次出现时公开。" }, { + "id": "gentleman_promise", "name": "君子一言", - "content": "每名玩家开局时声明自己最少的花色(包括0张)。" + "content": "庄家埋底后,每名玩家公开声明自己手牌最少的有效花色(包括0张)。所有主牌统一算作“主”,不再计入牌面原花色;最少花色唯一时由系统直接声明,并列时由玩家选择。" }, { + "id": "repeated_exhaustion", "name": "再衰三竭", - "content": "每名玩家连续3次最大时,从第3轮开始,每次再作为一号位将失去依次失去5,10,15……分/" + "content": "同一名玩家连续赢得第3、4、5……轮时依次失去5、10、15……分;换人赢牌后由新赢家从第1轮重新累计。庄家方失分补给闲家,闲家失分则从闲家总分扣除。" }, { + "id": "focus_figure", "name": "焦点人物", - "content": "游戏开始前在两方阵营中随机各选择一名成为“焦点”(仅队友相互知晓),本局焦点打出的分数牌分数翻倍,非焦点打出的分数牌不计分。" + "content": "埋底后,两队分别秘密表决本队焦点;有人反对就轮换候选并重新表决,直至队内两人一致同意。本局闲家拿到的焦点人物分牌按双倍计分,非焦点人物分牌不计分;底牌正常结算,焦点与实际总分在终局揭晓。" }, { + "id": "cooldown_time", "name": "冷却时间", - "content": "每轮的出牌不能包含上轮自己出牌含有的点数(除非只有该点数)。" + "content": "每轮的出牌不能包含上轮自己出牌含有的点数;若其余可用牌不足以完成本次出牌,则解除冷却。" }, { + "id": "time_cooling", "name": "时间冷却", - "content": "每轮的出牌不能包含上轮自己出牌含有的花色(除非只有该花色)。" + "content": "每轮的出牌不能包含上轮自己出牌含有的有效花色,所有主牌统一视为“主”;若其余可用牌不足以完成本次出牌,则解除冷却。" }, { + "id": "planned_economy", "name": "计划经济", - "content": "起始手牌为20张,每轮结束后再摸一轮牌直到没有牌剩余。" + "content": "起始每人20张手牌,另封存20张;庄家埋底并开始出牌后,每轮结束四家各摸1张,直至封存牌摸完。" }, { + "id": "go_with_the_flow", "name": "随波逐流", - "content": "手牌数首次不大于16时将手牌交给下家,首次不大于9时再交给下家。" + "content": "一轮结束后,若四家手牌均首次不多于16张则把全部手牌交给下家;均首次不多于9张时再交换一次。" }, { + "id": "equivalent_reciprocity", "name": "等价互惠", - "content": "每名玩家限一次,作为首位出牌时可以与另一名玩家拼点(比较规则为无主局的大小顺序,不区分花色),输的人失去5分,之后交换两张拼点牌。" + "content": "每名玩家每局限一次;作为本轮一号位出牌前,可与另一名玩家各暗选一张牌拼点。主牌大于副牌,副牌只按点数比较;输家使本方损失5分,平局不失分,随后交换两张拼点牌。" }, { + "id": "enduring", "name": "经久不衰", - "content": "单牌/对子的大小视为本轮和上轮同牌型的最大值。" + "content": "每名玩家本轮出牌与自己上轮的有效花色、牌型、张数及组合结构完全一致时,本轮该组合的比较牌力视为两轮中的最大值;主牌统一视为“主”,甩牌必须逐项匹配同样的组件结构。" }, { + "id": "average_pooling", "name": "平均池化", "content": "单牌/对子的大小视为自身和队友的平均值(垫牌、散牌不参与计算)。" }, { + "id": "dream_killing", "name": "梦中杀人", - "content": "玩家没有主牌时可以暗置所有牌,之后出牌完全随机,若随机出的牌与首位玩家花色相同,则视为最大并“醒来”。" + "content": "玩家没有主牌时可以暗置所有牌,之后出牌完全随机;若随机出的牌与首位玩家花色或点数相同,则视为最大并“醒来”。若有两名及以上玩家成功,以先出者为大。" }, { + "id": "joint_harmony", "name": "珠联璧合", - "content": "若一方打出完全相同的牌,则视为最大。" + "content": "若一方打出完全相同的牌,则视为最大,下一轮由该方后出者获得牌权;若两队同时珠联璧合,则按牌面正常结算。" }, { + "id": "divine_weapon", "name": "神兵天降", - "content": "游戏开始时另外随机抽取2张牌并亮出,每名玩家可以将一张花色或点数与之相同的牌当作此牌打出并再翻出2张(每名玩家限一次)。" + "content": "从另一副不含王的牌中随机亮出2张神兵牌。每名玩家每局限一次,可将一张与所选神兵牌花色或点数相同的手牌当作该牌打出,牌面与牌力改变但仍按原牌计分,且跟牌义务按转化前的真实手牌判断。本轮有人发动后其他人不能再发动;轮末两张神兵牌一起替换,未发动则两张都保留。" }, { "name": "魔术戏法", - "content": "每名玩家限一次,在作为一号位出牌前可以暗中选择两名玩家,交换本轮二者打出的牌。" + "content": "每名玩家限一次,在作为一号位出牌前可以暗中选择两名其他玩家,不能选择自己。两人仍按自己的手牌正常出牌,仅在本轮最终结算得分、胜负与下轮牌权时交换二者的出牌结果。" }, { "name": "戛然而止", - "content": "手牌数首次小于5时游戏结束,闲家获得庄家剩余手牌中的分数。" + "content": "一整轮结束后,若任一玩家手牌少于5张,则该轮成为最后一轮。闲家获得庄家本人剩余手牌牌面分的一半;底牌仍按最后一轮胜负正常结算。" }, { "name": "聚类分析", - "content": "可以将一张非分数、非主点数的数字牌当作与其绝对值不超过1的牌打出(转化后的牌也不能是分数牌、主点数)。" + "content": "发动后可依次点击多张非分数、非主点数的普通牌,明确选择各自要临时视为的相邻点数,转换完成后再自行选牌按基本规则出牌;转化前后均不得为分牌或主点数。甩牌时,其他玩家能借此组成更大组件也会令甩牌失败。" }, { + "id": "meticulous_accounting", "name": "锱铢必较", - "content": "本局的分数牌是A,2,3,4,5,6,7,闲家开局-10分。" + "content": "本局的分数牌改为 A、2、3、4、5、6、7,分别计 1、2、3、4、5、6、7 分;闲家以 -10 分开局。" }, { + "id": "lost_in_fog", "name": "如堕云雾", - "content": "禁止查看已打出的牌和已获得的分数。" + "content": "出牌阶段不保留上一轮出牌记录,并隐藏本轮分值、闲家总分及已获得的分牌;终局结算时统一公开。" }, { + "id": "birds_gone_bow_hidden", "name": "鸟尽弓藏", - "content": "一种花色的分数牌全部打出后,不能再主动打出该花色。" + "content": "某一有效花色的所有5、10、K分牌全部实际打出后,一号位不能再主动打出该有效花色;跟牌不受影响。带分级牌统一计入主花色,不计入原牌面副花色。若手中只剩已弓藏花色,则解除限制。" }, { + "id": "odd_even_scoring", "name": "无独有偶", - "content": "奇数轮次的分数为0,偶数轮次的分数为原分数的两倍。" + "content": "奇数轮的分牌不进入计分区且计0分;偶数轮的分牌进入计分区,并按牌面原分的两倍计入。底牌仍按常规抠底规则结算。" }, { + "id": "second_battlefield", "name": "第二战场", - "content": "每当每名玩家打出的牌数不小于5时,使用这些牌进行一次德州扑克,获胜者所属的阵营获得5分。" + "content": "每场先公开5张公共牌。各家打出且尚未参赛的牌会明置在各自桌前并跨轮累计;只在轮末且四家均累计至少5张时由系统判定德州最佳牌型,胜者阵营获得5分,随后收走参赛牌、更换公共牌并重新累计。某场累计达到5张时,只有四家剩余手牌都严格少于5张才延至全部出完后进行最终判定;正好剩5张仍照常判定。王不参与牌型;双副牌的重复实体分别计算,五条高于同花顺;同阵营并列只奖励一次,跨阵营并列时双方各得5分。" }, { + "id": "tai_chi_four_symbols", "name": "太极四象", - "content": "新增牌型:四张花色各不相同且点数相同的牌,此牌型按点数比较大小,若没有任何副花色则可以用四张主花色毙之。" + "content": "新增牌型:四张花色各不相同且点数相同的牌。四象只按点数比较;没有任何副牌时,可以用四张主牌毙之。" }, { + "id": "forbidden_magic", "name": "禁术秘法", - "content": "玩家可以在一轮开始时声明将自己所有主牌不再视为主牌,可以将其当作任意花色点数相同的牌打出(王当作任意牌),限一次。" + "content": "每名玩家每局限一次,可在出牌阶段随时预备,并在轮首依次确认是否发动;确认后本局永久生效且不能撤销。发动者原有的全部主牌不再视为主牌,并可将其改作任意花色的原点数牌;王可改作任意普通点数,但有主局不能改作主花色。" }, { + "id": "one_country_two_systems", "name": "一国两制", - "content": "庄家方、闲家方各自亮出主花色。" - }, - { - "name": "取长补短", - "content": "玩家编号为0,1,2,3,第x轮中,编号为x%4的玩家的牌大小级别+1,编号为(x+2)%4的玩家的牌大小级别-1。" + "content": "庄家方与闲家方分别在本方内亮主、反主,双方声明互不覆盖。任意玩家亮一对王,或双方都未亮主时,双方无主;只有一方亮花色时,双方共用该主花色;双方亮出不同花色时,两种花色按阵营互换后统一进行牌型、跟牌、缺门、毙牌和大小判定。" }, + { + "id": "strength_compensation", + "name": "取长补短", + "content": "以庄家为0号位,逆时针依次为1、2、3号位。第x轮中,x%4号位的全部手牌沿当前牌力序列升一级,(x+2)%4号位降一级,轮末轮换。副牌留在原花色并跳过级牌点数,越过A成为F、越过最低普通牌成为1;主牌依次为主普通牌、副级牌、主级牌、小王、大王、白王,升降不会打乱原有内部次序。牌面变化不改变实体牌原有分值。" + }, { + "id": "wooden_ox_flowing_horse", "name": "木牛流马", - "content": "游戏开始时,2、3号位玩家获得“木牛流马”,持有木牛流马的玩家可以在一轮开始时将一张牌置入其中(已有时改为替换)并交给队友,队友可以如手牌般打出此牌。木牛流马中的牌不计入手牌花色限制,每局每人可以传递两次。当手牌不等对游戏造成影响时,玩家必须传递/收回木牛流马中的牌以使牌数正确。" + "content": "游戏开始时2、3号位各持有一具木牛流马(每队一具)。每轮首张牌打出前,持有者可以跳过操作、将其中原有的牌原样交给队友,或放入/替换一张手牌并必须立即交给队友;首张牌打出后本轮不得再操作。接收者可以跨轮保留其中的牌,并在自己的正常出牌回合将它如手牌般打出;该牌不计入持有者的跟牌花色义务。若队友已经无牌可出,持有者必须在轮首将木牛流马交给队友以恢复牌数。每队的木牛流马每局最多往返两次(共四次单程传递)。" }, { "name": "再续前缘", @@ -268,88 +317,105 @@ "content": "移除4张王牌并令所有主点数牌大小视为原本的数字大小。" }, { + "id": "mutual_support", "name": "同舟共济", - "content": "每名玩家限一次,任意轮次轮到某玩家出牌时,可以要求队友交给自己0-2张牌或选择交给队友1-2张牌,本轮结束时获得牌的玩家再交给给出牌的玩家等量手牌。" + "content": "每名玩家每局限一次;轮到自己且尚未出牌时,可以要求队友交给自己0至2张牌,或选择1至2张手牌交给队友。本轮结束时,获得牌的玩家再选择等量手牌返还给原持有者。" }, { + "id": "candle_to_dawn", "name": "烛尽天明", - "content":"游戏开始时庄家队友选择是否点燃“烛”。每轮出牌结束时根据本轮四号位的出牌调整“烛”的状态:若其打出的牌只含有黑色,则熄灭“烛”,若只含有红色,则点燃“烛”,若均不满足则不改变“烛”的状态。“烛”处于点燃的轮次,红色分数牌具有的的分数+5,黑色分数牌具有的分数-5;“烛”处于熄灭的轮次,红色分数牌具有的的分数-5,黑色分数牌具有的分数+5。" + "content":"游戏开始时庄家队友选择是否点燃“烛”。每轮先按该轮开始时的烛态结算分数,结算完成后再根据本轮四号位的出牌决定下轮烛态:若其打出的牌只含黑色,则熄灭“烛”;若只含红色,则点燃“烛”;否则不变。“烛”点燃的轮次,红色分数牌每张+5分,黑色分数牌每张-5分;“烛”熄灭的轮次则相反。小王是黑色,大王是红色。" }, { + "id": "cultural_revolution", "name": "文化革命", - "content": "每名玩家限一次,作为一号位出牌时可以选择声明一种花色/点数(不能为10/K),两轮之内该花色/点数视为主花色/主点数,若发动时已经处于此效果生效区间内则覆盖之。" + "content": "每名玩家每局限一次;作为一号位出牌前,先选择“革花色”或“革点数”,再声明一种花色或2至A的一种点数(包括10、K)。发动当轮及下一轮内,所选花色替换原主花色,或所选点数替换原级牌点数;被替换的原主暂按副牌处理。生效期间的新声明会整体覆盖旧声明,并重新持续两轮。" }, { + "id": "three_tigers", "name": "三人成虎", - "content": "一轮中,若至少3名玩家打出的牌花色均相同,则该花色在本轮结算时大小视为主花色,但大小-4。" + "content": "一轮中,每名玩家本次打出的全部牌均为同一副花色的副牌时,为该花色计一人;同一副花色累计达到三人后立即成虎。主花色牌、级牌和王不计人数,也不转换。该轮桌上及此后打出的该副花色牌全部视为主牌,并沿剔除本局级牌后的普通牌序列降低4级;例如4为级牌时,降级过程跨过4这一档,最低为−2。转换只影响本轮牌力,不改变实体牌原有分值。" }, { "name": "势者制权", "content":"游戏开始时庄家队友获得“权”并选择一名对方玩家,令其获得“势”。在一轮中,若持有“权”的玩家位于持有“势”玩家的前置位,则后者本轮打出的分数在计算时不会超过前者;若持有“权”的玩家位于持有“势”玩家的后置位,则前者本轮打出牌的大小在计算时不会小于后者且视为先手(均为垫牌则不触发此条)。以上两条效果任意之一被触发的轮次结束时,持有“权”的玩家需将“权”交给持有“势”的玩家,后者再选择对方的一名玩家交给其“势”。" }, { + "id": "invite_into_urn", "name": "请君入瓮", - "content": "每名玩家限一次,作为首置位时可以指定一名玩家和一张牌,若其本轮打出了此牌,则失去5分。" + "content": "每名玩家每局限一次;作为一号位出牌前,可指定一名其他玩家和一种实体牌面。本轮只要目标玩家打出至少一张该牌面,其阵营失去5分;无论同一次打出一张还是多张,都只扣5分。若目标属于庄家方,等价为闲家增加5分。" }, { + "id": "teammate_cheer", "name": "队友加油", - "content": "没有主牌时可以声明之并令队友在本局游戏中的所有牌大小级别+1。" + "content": "每次出牌后,若该玩家还持有至少一张牌、剩余手牌中已经没有主牌且尚未发动过,系统询问是否声明“队友加油”。确认后其队友获得永久加油 Buff,剩余手牌及之后打出的牌沿当前完整牌力序列全部提升一级;副牌不会跨入主牌链,A之上为F,大王之上为白王。获得 Buff 的玩家全场标记,实体牌原有分值不变;暂不发动后可在后续满足条件的出牌后再次选择。" }, { + "id": "old_horse_still_has_strength", "name": "老骥伏枥", - "content": "最后一个获得出牌权的玩家的第一次出牌视为必定最大。" + "content": "开局首发者视为已经获得牌权。四名玩家都至少获得过一次牌权时,最后首次获得牌权的玩家,其随后第一次合法首发视为绝对最大,任何后手都不能压过;该次出牌仍须完整遵守首发牌型与甩牌规则。" }, { "name": "第三战场", "content": "一号和二号位使用每轮打出的四张牌(王牌则不触发)进行加减乘除24点游戏,率先算出者+3分(四号位须在20s内出牌)。" }, { + "id": "trump_wins", "name": "Trump wins", - "content": "打出分数最多的玩家下轮先出牌。" + "content": "每轮仍按正常牌力判定胜者并结算分数,但下一轮改由本轮实体出牌总分最高的玩家先出;若最高分有多人并列,则其中先出牌的玩家获得牌权。" }, { + "id": "openly_revealed", "name": "昭然若揭", - "content": "底牌始终保持明置。" + "content": "底牌从摸牌开始便在牌桌中央明置;庄家重新埋底后,明置内容同步换成新底牌。整局所有玩家都可随时查看当前底牌。" }, { + "id": "straw_boat_borrowing_arrows", "name": "草船借箭", - "content": "若首置位的玩家打出的牌中包含分数之和不小于10且这些分数牌没有被其所属阵营获得,其可以在此轮结束时弃置一张非分数牌并获得本轮打出的非分数牌中最大的一张。" + "content": "若首置位玩家本轮打出的实体分牌合计不少于10分,且这些分牌最终被对方阵营获得,轮末该玩家可以选择发动:向全场公开弃置一张剩余手牌中的非分牌,并获得本轮打出的、按当前主牌体系牌力最大的单张非分牌。若手中无可弃牌或本轮没有非分牌,则不能发动。" }, { + "id": "bush_gate", "name": "布什戈门", - "content": "每名玩家限一次,作为二号位时若认为一号位打出的牌过于离谱,可以令其收回并重新打出另一(些)合法的牌。" + "content": "每名玩家每局限一次;作为本轮二号位且尚未出牌时,可以迫使一号位收回本轮首发并重新合法首发。被收回的每一张牌在这次重新首发中均不可使用;重新首发成功后限制立即解除,后续轮次可正常使用。若一号位没有其他手牌则不能发动。" }, { "name": "千金散尽", "content": "闲家的的分数为 200 - 实际得到的分数,不能低于0分。" }, { + "id": "illusion_and_reality", "name": "虚虚实实", - "content": "每名玩家限一次,若某副花色的牌剩余奇数张,出牌时可以视为没有此花色的牌(若利用此规则,则不能打出该花色的牌)。" + "content": "每名玩家每局限一次。跟随副花色时,若自己手中该副花色恰好剩余奇数张,可以发动并将这些牌虚置,本次出牌完全视为手中没有该花色;被虚置的牌不能在本次出牌中打出,仍须从其余手牌中打出与首家相同的张数。" }, { + "id": "strive_upstream", "name": "力争上游", - "content": "每轮的出牌顺序为上轮打出牌的大小顺序(垫牌按照字典序比较单张大小,考虑主花色)。" + "content": "每轮的出牌顺序按上轮四家出牌由大到小排列。以一号位的牌型为准:同牌型按正常牌力比较;牌型不一致时,先比与首家牌型的贴合程度,异花色牌越多越小(包括主牌),数量相同再从最小牌起按字典序比较;完全相同时后出者更小。" }, { + "id": "afterglow", "name": "回光返照", - "content": "当玩家的主牌不大于 3 张时,其出牌可以不受花色限制且打出的主牌大小+1。若使用此规则进行出牌,则之后的每一轮必须打出主牌直到没有主牌为止。" + "content": "每名玩家每局限一次。开局一号位若持有1至3张主牌,可在首次出牌前直接发动;其余情况下,每次出牌后若该玩家仍有手牌且只剩1至3张主牌,系统询问是否发动。确认后剩余主牌立即沿当前完整主牌序列提升一级;从下一次出牌起,只要手中仍有主牌便无视通常的跟牌要求,但每次出牌只能打出主牌、不能混入副牌。主牌出尽后效果结束;实体牌原有分值不变,暂不发动不消耗机会。" }, { + "id": "outward_harmony_inner_division", "name": "貌合神离", "content": "任意一方两名玩家在一轮中若打出的牌型不一致(不考虑花色),则对方阵营获得5分。" }, { + "id": "ambiguous", "name": "模棱两可", - "content": "每名玩家限一次,出牌时可以展示两种不同的合法出牌方式,在本轮结束时选择其中一种(多人发动则后续玩家再发动无须消耗次数,并从后置位向前选择)。" + "content": "每名玩家每局限一次,仅本轮二、三号位可发动:同时公开两种不同且各自合法的出牌方案,四家出完后再选择其中一种结算。若二、三号位都发动,则三号位作为后续发动者不消耗次数,并由三号位先选、二号位后选。" }, { "name": "良禽择木", "content": "游戏开始前,将所有 A K 移出游戏并在摸牌结束后平均分配给玩家。" }, { + "id": "two_ghosts_knock_door", "name": "二鬼拍门", - "content": "摸到不小于2张王牌时须将其全部亮出。" + "content": "摸牌阶段中,任一玩家手牌里的王达到至少两张时,立即将其持有的全部王作为明置手牌公开;之后再摸到的王也继续明置。王仍属于原玩家手牌,打出或离开手牌后便从明置区消失。" }, { "name": "正大光明", @@ -357,47 +423,55 @@ }, { "name": "互通有无", - "content": "队友的手牌对你可见。" + "content": "进入出牌阶段后,队友的手牌仅对你可见。" }, { + "id": "double_happiness", "name": "双喜临门", - "content":"重新从三个规则中选择两个(不矛盾的)规则,于本局游戏中同时生效。" + "content": "重新从三个规则中选择两个规则,于本局游戏中同时生效。房主可以刷新其中任意一条候选。" }, { + "id":"people_commune", "name":"人民公社", - "content":"每名玩家各埋两张底牌,闲家开局-20分。抄底时,双方均获得且仅获得对方所埋的底牌中的分数。" + "content":"摸牌时不留底牌,四名玩家各摸27张,再各自埋两张;每人可随时查看自己埋的两张牌及其分值。闲家开局-20分。抄底时只结算对方阵营埋下的四张牌:闲家拿底获得庄家方埋分;庄家方拿底则抄走闲家埋分,使闲家按底牌倍数扣分。" }, { "name":"市场经济", "content":"初始时市场具有8张牌,每过3轮每人将一张牌投入市场,根据本轮的大小依次从市场中获得一张牌。游戏结束时闲家获得市场中的一半分数。" }, { + "id":"remove_firewood_from_under_cauldron", "name":"釜底抽薪", - "content":"被反主的玩家可以在反主结算后与其交换手牌。(反多次则由后向前结算)" + "content":"每次反主后,被反主的玩家获得一次选择:亮主与反主全部结算后,可以与反主者交换当前全部手牌,也可以不交换。若发生多次反主,按反主发生顺序由后向前依次询问和结算。" }, { + "id":"mainstay", "name":"中流砥柱", - "content":"摸牌结束后,若本局不是无主局,则从一号位开始,若玩家的主牌数不大于5,则可以将包括所有主牌在内的5张牌交给队友,然后队友再交给其5张牌。" + "content":"摸牌结束后,若本局不是无主局,则从一号位开始依次处理每名玩家。轮到某玩家时,若其当前主牌数不大于5,可以将包括全部主牌在内的5张牌交给队友,再由队友返还5张牌;每名玩家均按轮到自己时的当前手牌独立判断,因此同队两人都可以发动。" }, { + "id":"happy_twins", "name":"欢乐成双", - "content":"庄家与上家交换位置。" + "content":"庄家锁定后与上家交换位置,但双方原有组队关系不变;本局结束后恢复原座次。若庄家方获胜,下一局仍由庄家的固定队友上庄;若闲家方获胜,则由换位后庄家的下家(即换位前的庄家上家)上庄。" }, { + "id":"encircle_three_missing_one", "name":"围三阙一", - "content":"将除王牌与级牌之外的所有牌分为四种花色,记录游戏中最先出现的三种花色,若最后剩余的一种花色不为主花色,则该花色从下一轮开始替换原本的主花色,然后无论是否发生替换,删除所有记录并重新开始记录。" + "content":"摸牌阶段照常亮主。进入出牌阶段后,忽略王牌和级牌,按实际出牌顺序记录最先出现的三种普通花色;缺少的第四种花色若不是当前主花色,则从下一轮起替换当前主花色。无论是否换主,随后均清空记录并重新统计。" }, { "name":"三六九等", "content":"本局除亮主外,玩家可以额外亮出一种主花色以使该花色的牌成为“劣花色”,之后游戏中普通副牌相对于劣牌的关系等同于主牌相对于副牌的关系,同时该花色的级牌小于副花色的级牌(即主花色>副花色>劣花色)。亮出劣花色的过程遵循与主牌相同的反主规则,对王即无劣花色,一张/一对级牌/王牌只能用于亮主与亮劣其中之一。" }, { + "id":"iron_evidence", "name":"铁证如山", - "content":"若在一轮中出现了小王、红桃Q、黑桃J、梅花J,则本轮打出的分数翻倍,每多出现一张则倍数额外+1;若两张大王均已被打出,则改为本轮不计分。" + "content":"每轮开始时锁定铁证牌的效果。若此前两张大王尚未全部打出,本轮每出现一张小王、红桃Q、黑桃J或梅花J,本轮牌面总分的倍数便在1倍基础上加1;若本轮开始前两张大王均已打出,则本轮只在出现至少一张上述铁证牌时清零牌面总分,没有铁证牌则正常计分。本轮内才打出的第二张大王不影响本轮效果,只从下一轮开始生效。" }, { + "id":"waiting_rabbit", "name":"守株待兔", - "content":"游戏开始时,每名玩家可以暗中指定一张牌(不能是王牌、主点数),然后当此牌被其他玩家打出时,当轮座次最先指定了此牌的玩家可以用一张非分数牌与之交换(只交换一张,一张牌的分数只在其首次被打出时计算一次)。" + "content":"埋底完成后,每名玩家暗中指定一张有花色的目标牌,不能指定王或当前级牌,但可以指定5、10、K。当其他玩家在一轮中打出该牌面时,目标牌先正常参与本轮牌力与首次计分;本轮结算完成后,由本轮座次最先指定它且尚未成功交换的玩家决定是否发动,可以用手里一张非分牌与其中一张目标牌交换:目标牌进入其手牌,非分牌原位顶替留在桌上的目标牌,但不倒改本轮赢家或得分。每张实体分牌只在全局首次上桌时计分,被换回手牌后再次打出只保留牌面大小,不再计分。" }, { "name":"破釜沉舟", @@ -408,31 +482,48 @@ "content":"摸牌时随机一部分牌正面向上。" }, { + "id":"hidden_dragon_in_abyss", "name":"潜龙在渊", - "content":"游戏开始时每名玩家指定自己最多的点数(主点数除外),手牌数首次不大于12时,若玩家从未打出过该点数,则所属阵营获得10分。" + "content":"埋底完成后,每名玩家公开声明自己手牌中数量最多的一个点数(本局级牌除外);若最多点数唯一则由系统自动声明,并列时由玩家自行选择。玩家的手牌数首次不大于12时,若其此前及本次均未打出过所声明的点数,则所属阵营获得10分。" }, { + "id":"administrative_review", "name":"行政审查", - "content":"庄家12张底牌,游戏开始前,庄家下家指定一种副花色,庄家上家指定一种点数,直到庄家方打出过该花色和该点数的牌才可埋底牌。" + "content":"本局有12张底牌,四家各持24张牌开始出牌。开始出牌前,庄家下家公开指定一种副花色,庄家上家公开指定一种点数;底牌继续封存,庄家不可查看。庄家或其队友累计打出过指定副花色和指定点数后,庄家才收到并查看12张底牌,立即埋12张后继续当前牌局。" }, { + "id":"political_review", "name":"政治审查", - "content":"每名玩家限一次,可以令队友收回其打出的牌并重新打出(可以继续选择同样的出牌)。" + "content":"每名玩家每局限一次。在该玩家尚未发动前,其队友每次提交出牌后都暂停牌局并询问该玩家是否令队友收回。选择收回才消耗次数;收回只传递反对信号,不产生任何禁出限制,队友可以立即原样重新出牌。选择放行不消耗次数。" }, { + "id":"no_one_survives", "name":"无人生还", - "content":"除一号位的出牌均暗置,在一轮结束后统一展示。" + "content":"每轮一号位的出牌正常明置;二、三、四号位的出牌在本人视角始终明置,对其他玩家自动暗置且只公开张数,本轮牌面总分也不提前显示。四家出完后统一公开暗牌与本轮总分,再按正常规则结算胜负和下轮牌权。" }, { + "id":"lure_tiger_from_mountain", "name": "调虎离山", - "content": "每方阵营限一次,一轮开始前可以指定一名非一号位玩家,令其本轮无法响应甩牌询问,且打出的牌不计大小与分数。" + "content": "每方阵营每局限发动一次。每名玩家都可在出牌阶段随时预备,系统在目标轮开始、首张牌打出前按本轮座次依次询问预备者;同队首名确认并选定目标的玩家抢占本方次数,其队友不能再发动。发动者指定一名本轮非一号位玩家,使其本轮无法参与甩牌询问,且本轮打出的牌不计大小与分数。" }, { + "id": "defense_as_offense", "name": "以守为攻", - "content": "每轮若一号位玩家打出的牌每小于一名其他玩家,则其下轮打出的牌大小+1。" + "content": "每轮结束后,按“力争上游”的完整牌力顺序比较四家出牌;本轮一号位每小于一名其他玩家,其下一轮全部手牌牌面便提升一级,并在玩家名旁标记对应的“+X”。加成只持续下一轮,实体牌原有分值不变;F与白王不再提升,其他牌按完整牌力序列提升并在白王处封顶,因此大王即使+3仍为白王。" }, { + "id": "antinomy", "name": "二律背反", - "content": "游戏开始前每名玩家各自指定一张牌(花色+点数)并同时亮出,被指定的牌本局中视为两张不同的牌(即无法成对),若有多名玩家指定了同一张牌,则不执行此效果。每轮结束后,本轮中指定牌被打出过的所有玩家重新指定一张牌并同时亮出。" + "content": "庄家埋完底、正式开始出牌前,四名玩家各自选择一张普通花色牌面(花色+点数),收齐后同时亮出。仅被一名玩家指定的牌面,其两张实体牌本局视为不同牌,不能组成对子或拖拉机;同一牌面被多人指定时不执行拆对效果。每轮结束后,本轮中自己所指定牌面曾被任何玩家打出的声明者重新选择,收齐后同时更新;完成前不能开始下一轮出牌。" + }, + { + "id": "change_rice_to_mulberry", + "name": "改稻为桑", + "content": "庄家埋底后、正式出牌前,两名闲家分别选择自己手牌中⌊分牌数÷2⌋张分牌;原本为副牌的变为同花色A,原本为主牌的变为大王。被改造的实体牌永久失去分值,只按新牌面参与牌型、跟牌和大小结算。" + }, + { + "id": "destroy_dyke_flood_fields", + "name": "毁堤淹田", + "content": "庄家每局限发动一次。闲家赢得一轮后、该轮计分前,庄家可以令本轮所有分数作废并记录作废分数。接下来的三轮为灾期,期间照常计分;若闲家在灾期内累计获得不少于20分,或游戏在灾期结束前结束,则视为事发,闲家取回作废分数并额外获得20分,随后结束灾期。若牌局恰于灾期第三轮结束,且闲家拿底,也视为事发。若三轮结束时仍未事发,记录的分数永久作废。" } -] \ No newline at end of file +] diff --git a/simple-bot/who_designed_adapter.py b/simple-bot/who_designed_adapter.py new file mode 100644 index 0000000..5504439 --- /dev/null +++ b/simple-bot/who_designed_adapter.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Adapter between the Tractor server snapshot and the WhoDesigned strategy.""" + +import json +import os +import sys +import types + + +# mvGen imports numpy but never uses it. Supplying a tiny placeholder keeps the +# deployment dependency-free while leaving the original bot repository intact. +sys.modules.setdefault("numpy", types.ModuleType("numpy")) + +WHO_DESIGNED_DIR = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "WhoDesigned") +) +sys.path.insert(0, WHO_DESIGNED_DIR) + +from mvGen import move_generator # noqa: E402 +from myutils import get_action_options, setMajor # noqa: E402 + + +CARD_SCALE = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "0", "J", "Q", "K"] +SUITS = ["s", "h", "c", "d"] + + +def card_base_id(card_name): + if card_name == "jo": + return 52 + if card_name == "Jo": + return 53 + return CARD_SCALE.index(card_name[1]) * 4 + SUITS.index(card_name[0]) + + +class CardAllocator: + def __init__(self): + self.available = set(range(108)) + + def allocate(self, card_name): + base = card_base_id(card_name) + for card_id in (base, base + 54): + if card_id in self.available: + self.available.remove(card_id) + return card_id + raise ValueError(f"Too many copies of card {card_name}") + + +def normalize_level(level): + return "0" if str(level) == "10" else str(level) + + +def protect_point_trick_with_trump(generator, hand_ids, current_trick, history_ids, action): + """Keep WhoDesigned's heuristics, but close its unsafe single-card point gap. + + When this player is void in the led side suit, protect table points if an + opponent is currently winning, or if a known-void opponent still acts after + a winning teammate. This uses the same empty-suit memory as the original + full-request constructor. + """ + if not current_trick or any(len(cards) != 1 for cards in history_ids): + return action + + lead = generator.Num2Poker(history_ids[0][0]) + if generator.isMajor(lead): + return action + lead_suit = generator.get_suit(lead) + + hand_pokers = [generator.Num2Poker(card_id) for card_id in hand_ids] + if any(not generator.isMajor(card) and card[0] == lead_suit for card in hand_pokers): + return action + + if not any( + generator.Num2Poker(card_id)[1] in ("5", "0", "K") + for cards in history_ids + for card_id in cards + ): + return action + + winner_position = 0 + winner = generator.Num2Poker(history_ids[0][0]) + for position in range(1, len(history_ids)): + challenger = generator.Num2Poker(history_ids[position][0]) + if challenger != winner and generator.bigger_card(winner, challenger) == challenger: + winner = challenger + winner_position = position + + winner_id = int(current_trick[winner_position]["playerIndex"]) + should_trump = winner_id != generator.teammate_id + if not should_trump and winner_id == generator.teammate_id: + already_played = {int(play["playerIndex"]) for play in current_trick} + future_opponents = [ + player_id + for player_id in range(4) + if player_id not in already_played + and player_id != generator.selfid + and player_id != generator.teammate_id + ] + should_trump = any( + lead_suit in generator.empty_suits[player_id] + and generator.major not in generator.empty_suits[player_id] + for player_id in future_opponents + ) + + if not should_trump: + return action + + trumps = [ + card_id + for card_id in hand_ids + if generator.isMajor(generator.Num2Poker(card_id)) + ] + trumps.sort(key=lambda card_id: generator.card_level(generator.Num2Poker(card_id))) + for card_id in trumps: + poker = generator.Num2Poker(card_id) + if not generator.isMajor(winner) or generator.card_level(poker) > generator.card_level(winner): + return [card_id] + return action + + +def main(): + data = json.loads(sys.stdin.read()) + player_id = int(data.get("id", 0)) + hand_names = data.get("deck", []) + played_names = data.get("played", [[], [], [], []]) + current_trick = data.get("history", []) + empty_suits = data.get("emptySuits", [[], [], [], []]) + level = normalize_level(data.get("level", "2")) + major = data.get("trumpSuit") or "n" + + allocator = CardAllocator() + played_ids = [[], [], [], []] + for index in range(4): + for card_name in played_names[index]: + played_ids[index].append(allocator.allocate(card_name)) + + hand_ids = [allocator.allocate(card_name) for card_name in hand_names] + + # Current-trick cards are the tail of each player's cumulative played list. + history_ids = [] + for play in current_trick: + index = int(play["playerIndex"]) + count = len(play["cards"]) + history_ids.append(played_ids[index][-count:] if count else []) + + setMajor(major, level) + generator = move_generator(level, major) + generator.selfid = player_id + generator.teammate_id = (player_id + 2) % 4 + generator.next_id = (player_id + 1) % 4 + generator.prev_id = (player_id + 3) % 4 + generator.hold = hand_ids[:] + generator.played = played_ids + generator.empty_suits = [ + list(dict.fromkeys(empty_suits[index])) if index < len(empty_suits) else [] + for index in range(4) + ] + generator.cards_left = sorted(allocator.available) + generator.organized_hold_cards = generator.organize_cards(generator.hold) + generator.organized_left_cards = generator.organize_cards(generator.cards_left) + + options = get_action_options(hand_ids, history_ids, level, generator) + action = options[0] if options and options[0] else [] + if not isinstance(action, list): + action = [action] + action = protect_point_trick_with_trump( + generator, + hand_ids, + current_trick, + history_ids, + action, + ) + action_names = [generator.Num2Poker(card_id) for card_id in action] + + print(json.dumps({"player": player_id, "action": action_names})) + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print(f"WhoDesigned adapter failed: {exc}", file=sys.stderr) + raise diff --git a/tractor-game-simulator/.gitignore b/tractor-game-simulator/.gitignore index c286901..81d2e3b 100644 --- a/tractor-game-simulator/.gitignore +++ b/tractor-game-simulator/.gitignore @@ -33,3 +33,5 @@ Thumbs.db # Temporary files *.tmp .cache/ +test-results/ +playwright-report/ diff --git a/tractor-game-simulator/client/package.json b/tractor-game-simulator/client/package.json index 5c6e8a6..b4bb986 100644 --- a/tractor-game-simulator/client/package.json +++ b/tractor-game-simulator/client/package.json @@ -6,20 +6,24 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test:view": "node --test test/gameViewUtils.test.mjs", + "test:unit": "node --test test/*.test.mjs", + "test:e2e": "playwright test" }, "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0", - "antd": "^5.12.0", - "socket.io-client": "^4.7.2", - "zustand": "^4.4.7", "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", - "axios": "^1.6.2" + "antd": "^5.12.0", + "axios": "^1.6.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "socket.io-client": "^4.7.2", + "zustand": "^4.4.7" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.1", diff --git a/tractor-game-simulator/client/playwright.config.js b/tractor-game-simulator/client/playwright.config.js new file mode 100644 index 0000000..355e60c --- /dev/null +++ b/tractor-game-simulator/client/playwright.config.js @@ -0,0 +1,16 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './test/e2e', + globalSetup: './test/global-setup.js', + timeout: 45_000, + expect: { + timeout: 8_000 + }, + use: { + baseURL: 'http://127.0.0.1:3002', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + ...devices['Desktop Chrome'] + } +}); diff --git a/tractor-game-simulator/client/public/DLC.json b/tractor-game-simulator/client/public/DLC.json index f6c40b2..8fd5d88 100644 --- a/tractor-game-simulator/client/public/DLC.json +++ b/tractor-game-simulator/client/public/DLC.json @@ -1,355 +1,428 @@ [ { + "id": "reverse_rank_order", "name": "倒反天罡", - "content": "每个花色内的大小顺序颠倒。" + "content": "除级牌和王外,每个花色内普通牌的大小顺序颠倒。" }, { "name": "万马齐喑", "content": "说一句话需要扣五分。" }, { + "id": "ten_sided_ambush", "name": "十面埋伏", - "content": "庄家队友暗中指定一个点数具有赋分功能(+5,-5)。" + "content": "庄家队友暗选非级牌、非分牌点数。闲家赢得每张-5分,庄家方赢得每张给闲家+5分;底牌乘倍数同理,点数首次出现时公开。" }, { + "id": "normal_game", "name": "世事无常", "content": "正常对局。" }, { + "id": "abundant_harvest", "name": "五谷丰登", "content": "庄家12张底牌。闲家开局为30分。" }, { + "id": "half_realm", "name": "江山半壁", "content": "庄家4张底牌。闲家开局为-10分。" }, { + "id": "shared_prosperity", "name": "与民同乐", "content": "没有底牌。闲家开局为-20分。" }, { + "id": "extreme_challenge", "name": "极限挑战", "content": "庄家16张底牌。闲家开局为60分。" }, { + "id": "perfect_strategy", "name": "算无遗策", - "content": "庄家队友明手。闲家开局为10分。" + "content": "庄家埋底后,庄家队友明手且由庄家代为出牌。闲家开局为10分。" }, { + "id": "know_yourself_and_enemy", "name": "知己知彼", "content": "摸牌后,与对家交换两张牌。" }, { + "id": "news_minister_i", "name": "新闻部长I", "content": "摸牌后,每人给上家两张牌。" }, { + "id": "news_minister_ii", "name": "新闻部长II", "content": "摸牌后,每人给下家两张牌。" }, { + "id": "irresistible_force", "name": "势如破竹", "content": "本局庄家获胜可以连庄。" }, { + "id": "substitute_sacrifice", "name": "李代桃僵", - "content": "玩家可以任意垫一次牌,需要声明(视为小)。" + "content": "每名玩家限一次,可在跟牌时主动声明垫牌:仍须出相同张数,但可无视花色与牌型要求,且本次出牌始终视为小。" }, { + "id": "six_six_great_success", "name": "六六大顺", - "content": "本局具有同花顺(6张起步)。" + "content": "新增同花顺牌型,至少六张连续牌;主同花顺可包含级牌和王牌。" }, { + "id": "single_step_debug", "name": "单步调试", "content": "本局移除甩牌。" }, { + "id": "one_horse_leads", "name": "一马当先", - "content": "庄家队友先出牌。" + "content": "仅第一轮改由庄家队友先出牌;庄家身份、底牌归属及后续轮次均不改变。" }, { + "id": "reform_and_opening_up", "name": "改革开放", - "content": "庄家队友可以再埋一次底牌。闲家获得40分。" + "content": "庄家首次埋底后,庄家队友拿起底牌并重新埋底,随后由庄家先出牌。闲家开局获得40分。" }, { + "id": "heavy_fog", "name": "迷雾重重", - "content": "开局移除牌堆顶8张牌,闲家获得此8张牌中一半分数。" + "content": "开局暗中移除牌堆顶8张牌;全局出牌及其他分数结算完成后公开,闲家补得其中总分的一半。" }, { + "id": "concealed_passage", "name": "暗度陈仓", - "content": "每名玩家限1次,若不是第一个出牌,可以背面向上打出。" + "content": "每名玩家每局限1次;跟牌时可暗置出牌,除自己外本轮结束前无人能看到。一轮可有多人发动,轮末同时公开并正常结算得分和下一轮牌权。" }, { "name": "冰山一角", - "content": "始终保持两张牌明置。" + "content": "进入出牌阶段后,每名玩家自行选择并始终保持两张手牌明置;不足两张时全部明置。" }, { + "id": "fatal_beauty", "name": "红颜祸水", - "content": "黑桃牌视为红桃牌,闲家开局为20分;若红桃为主花色,闲家开局为40分。" + "content": "黑桃牌视为红桃牌,但不新增任何牌型;闲家开局为20分,若红桃为主花色则为40分。" }, { + "id": "stealing_beams", "name": "偷梁换柱", - "content": "四张王牌可以当做任意数字牌使用,若当做分数牌则不计分。" + "content": "每名玩家每局限1次;发动后点击要转换的王牌,先选花色再选点数,转换完成后再自行选牌按基本规则出牌。转换可取消,王牌替成分牌仍不计分,单出可保持王牌。" }, { + "id": "cosmic_shift", "name": "斗转星移", - "content": "当手牌数首次变为不大于12张时所有玩家与队友交换手牌。" + "content": "一轮结束后,若四家手牌均首次不多于12张,所有玩家与队友交换全部手牌。" }, { + "id": "last_stand", "name": "绝处逢生", - "content": "本局不能是无主局;当玩家没有主牌时,若此时手牌数不小于5且花色相同,可以令每张牌视为主牌。" + "content": "本局不能是无主局,也不能用一对王反主;无人亮主时系统随机指定主花色。手牌变化后,若一名玩家不少于5张、没有主牌且全为同一花色,可选择令全部手牌视为主牌;暂时拒绝后仍可在后续手牌变化时再次选择。" }, { + "id": "late_mover_advantage", "name": "后发制人", - "content": "每名玩家限1次,作为3号位时可以令原本的4号位先出牌。" + "content": "每名玩家每局限1次;作为本轮3号位出牌前,可以令下家先出,自己改为本轮最后出牌。只改变出牌顺序,不改变牌的大小结算。" }, { + "id": "frequent_fluctuation", "name": "频繁波动", "content": "有分数牌被打出的轮次结束后,每人交给下家一张牌,闲家开局-10分。" }, { + "id": "minor_disturbance", "name": "微小扰动", "content": "没有分数牌被打出的轮次结束后,每人交给队友一张牌。" }, { + "id": "lingering_discard", "name": "弃掷逦迤", - "content": "有分数牌被打出的轮次结束后,每人弃一张牌(庄家弃分则闲家加分)。" + "content": "有分数牌被打出的轮次结束后,四家各自暗弃一张牌;终局仅公开庄家方两人弃出的分牌,并补给闲家。" }, { + "id": "time_reversal", "name": "时间倒流", - "content": "每名玩家限1次,在一轮出牌结束后可以选择回溯到上一轮出牌结束时的状态。" + "content": "每名玩家每局限一次,可在本轮及轮末额外停留的1秒内预备;轮末询问所有预备者,首个确认发动者令牌局回溯到本轮开始前。" }, { "name": "路线摇摆", - "content": "有不小于10分的牌被打出的轮次结束后逆转出牌顺序。" + "content": "有单张价值不小于10分的牌被打出的轮次结束后,顺时针与逆时针出牌顺序互换。" }, { "name": "一带一路", - "content": "可以使用“小甩牌”牌型(两张单牌)。" + "content": "每名玩家每局限一次,首发时可点击“一带一路”,再出同一有效花色的两张非对子单牌,无需满足甩牌必大条件;未发动时仍可按普通甩牌尝试出这两张牌。跟牌或毙牌不消耗次数。" }, { "name": "昼夜轮转", - "content": "第 x 轮中,点数为 x % 13 + 1 的牌在其相应花色内视为最大,闲家开局为20分。" + "content": "第x轮将点数x % 13 + 1提升为相应花色内最大,其他牌序不变;若轮转到级牌则仍按A最大。闲家开局为20分。" }, { "name": "尊老爱幼", - "content": "每轮打出最小牌的玩家在下一轮先出牌。" + "content": "每轮仍按常规规则结算大牌;另以一号位牌型为准寻找最小牌:同牌型按正常牌力,牌型不一致时先比结构贴合度,异花色牌越多越小(包括主牌),数量相同再从最小牌起按字典序比较,完全相同时后出者更小。最小者下一轮先出牌。" }, { + "id": "rites_collapse", "name": "礼崩乐坏", - "content": "每轮的首位玩家不能主动打出A。" + "content": "每轮一号位主动出牌时不能选择任何A;跟牌时仍可正常打出A。若一号位手中只剩A,则可正常出牌。" }, { + "id": "recommend_talent", "name": "举贤任能", - "content": "每名玩家限一次,作为1号位时可以将出牌权交给下家。" + "content": "每名玩家每局限一次;作为本轮一号位出牌前,可令下家先出,自己改为本轮最后出牌。只改变牌序,不改变牌的大小。" }, { + "id": "accident_insurance", "name": "意外保险", - "content": "单轮得分超过30的部分由对方获得。" + "content": "单轮牌面分超过30时,超出部分归另一方:闲家赢牌最多计30分;庄家方赢牌则将超出30的部分补给闲家。" }, { "name": "为人坦荡", - "content": "手牌数首次变化为不大于5时所有玩家明置手牌。" + "content": "一轮结束时,若所有玩家手牌均不多于5张,所有玩家同时明置剩余手牌。" }, { + "id": "three_powers", "name": "三权分立", - "content": "游戏开始时,2、3、4号位玩家各暗中指定一个数字(不能是主点数),并令本局中这三者点数的牌分别代替原本10、5、K的分数." + "content": "庄家埋底后,2、3、4号位分别暗选一个非级牌点数,依次重载原10、5、K分牌。可选择原5、10、K,也可重复选择并叠加分值;对应点数首次出现时公开。" }, { + "id": "gentleman_promise", "name": "君子一言", - "content": "每名玩家开局时声明自己最少的花色(包括0张)。" + "content": "庄家埋底后,每名玩家公开声明自己手牌最少的有效花色(包括0张)。所有主牌统一算作“主”,不再计入牌面原花色;最少花色唯一时由系统直接声明,并列时由玩家选择。" }, { + "id": "repeated_exhaustion", "name": "再衰三竭", - "content": "每名玩家连续3次最大时,从第3轮开始,每次再作为一号位将失去依次失去5,10,15……分/" + "content": "同一名玩家连续赢得第3、4、5……轮时依次失去5、10、15……分;换人赢牌后由新赢家从第1轮重新累计。庄家方失分补给闲家,闲家失分则从闲家总分扣除。" }, { + "id": "focus_figure", "name": "焦点人物", - "content": "游戏开始前在两方阵营中随机各选择一名成为“焦点”(仅队友相互知晓),本局焦点打出的分数牌分数翻倍,非焦点打出的分数牌不计分。" + "content": "埋底后,两队分别秘密表决本队焦点;有人反对就轮换候选并重新表决,直至队内两人一致同意。本局闲家拿到的焦点人物分牌按双倍计分,非焦点人物分牌不计分;底牌正常结算,焦点与实际总分在终局揭晓。" }, { + "id": "cooldown_time", "name": "冷却时间", - "content": "每轮的出牌不能包含上轮自己出牌含有的点数(除非只有该点数)。" + "content": "每轮的出牌不能包含上轮自己出牌含有的点数;若其余可用牌不足以完成本次出牌,则解除冷却。" }, { + "id": "time_cooling", "name": "时间冷却", - "content": "每轮的出牌不能包含上轮自己出牌含有的花色(除非只有该花色)。" + "content": "每轮的出牌不能包含上轮自己出牌含有的有效花色,所有主牌统一视为“主”;若其余可用牌不足以完成本次出牌,则解除冷却。" }, { + "id": "planned_economy", "name": "计划经济", - "content": "起始手牌为20张,每轮结束后再摸一轮牌直到没有牌剩余。" + "content": "起始每人20张手牌,另封存20张;庄家埋底并开始出牌后,每轮结束四家各摸1张,直至封存牌摸完。" }, { + "id": "go_with_the_flow", "name": "随波逐流", - "content": "手牌数首次不大于16时将手牌交给下家,首次不大于9时再交给下家。" + "content": "一轮结束后,若四家手牌均首次不多于16张则把全部手牌交给下家;均首次不多于9张时再交换一次。" }, { + "id": "equivalent_reciprocity", "name": "等价互惠", - "content": "每名玩家限一次,作为首位出牌时可以与另一名玩家拼点(比较规则为无主局的大小顺序,不区分花色),输的人失去5分,之后交换两张拼点牌。" + "content": "每名玩家每局限一次;作为本轮一号位出牌前,可与另一名玩家各暗选一张牌拼点。主牌大于副牌,副牌只按点数比较;输家使本方损失5分,平局不失分,随后交换两张拼点牌。" }, { + "id": "enduring", "name": "经久不衰", - "content": "单牌/对子的大小视为本轮和上轮同牌型的最大值。" + "content": "每名玩家本轮出牌与自己上轮的有效花色、牌型、张数及组合结构完全一致时,本轮该组合的比较牌力视为两轮中的最大值;主牌统一视为“主”,甩牌必须逐项匹配同样的组件结构。" }, { + "id": "average_pooling", "name": "平均池化", "content": "单牌/对子的大小视为自身和队友的平均值(垫牌、散牌不参与计算)。" }, { + "id": "dream_killing", "name": "梦中杀人", - "content": "玩家没有主牌时可以暗置所有牌,之后出牌完全随机,若随机出的牌与首位玩家花色相同,则视为最大并“醒来”。" + "content": "玩家没有主牌时可以暗置所有牌,之后出牌完全随机;若随机出的牌与首位玩家花色或点数相同,则视为最大并“醒来”。若有两名及以上玩家成功,以先出者为大。" }, { + "id": "joint_harmony", "name": "珠联璧合", - "content": "若一方打出完全相同的牌,则视为最大。" + "content": "若一方打出完全相同的牌,则视为最大,下一轮由该方后出者获得牌权;若两队同时珠联璧合,则按牌面正常结算。" }, { + "id": "divine_weapon", "name": "神兵天降", - "content": "游戏开始时另外随机抽取2张牌并亮出,每名玩家可以将一张花色或点数与之相同的牌当作此牌打出并再翻出2张(每名玩家限一次)。" + "content": "从另一副不含王的牌中随机亮出2张神兵牌。每名玩家每局限一次,可将一张与所选神兵牌花色或点数相同的手牌当作该牌打出,牌面与牌力改变但仍按原牌计分,且跟牌义务按转化前的真实手牌判断。本轮有人发动后其他人不能再发动;轮末两张神兵牌一起替换,未发动则两张都保留。" }, { "name": "魔术戏法", - "content": "每名玩家限一次,在作为一号位出牌前可以暗中选择两名玩家,交换本轮二者打出的牌。" + "content": "每名玩家限一次,在作为一号位出牌前可以暗中选择两名其他玩家,不能选择自己。两人仍按自己的手牌正常出牌,仅在本轮最终结算得分、胜负与下轮牌权时交换二者的出牌结果。" }, { "name": "戛然而止", - "content": "手牌数首次小于5时游戏结束,闲家获得庄家剩余手牌中的分数。" + "content": "一整轮结束后,若任一玩家手牌少于5张,则该轮成为最后一轮。闲家获得庄家本人剩余手牌牌面分的一半;底牌仍按最后一轮胜负正常结算。" }, { "name": "聚类分析", - "content": "可以将一张非分数、非主点数的数字牌当作与其绝对值不超过1的牌打出(转化后的牌也不能是分数牌、主点数)。" + "content": "发动后可依次点击多张非分数、非主点数的普通牌,明确选择各自要临时视为的相邻点数,转换完成后再自行选牌按基本规则出牌;转化前后均不得为分牌或主点数。甩牌时,其他玩家能借此组成更大组件也会令甩牌失败。" }, { + "id": "meticulous_accounting", "name": "锱铢必较", - "content": "本局的分数牌是A,2,3,4,5,6,7,闲家开局-10分。" + "content": "本局的分数牌改为 A、2、3、4、5、6、7,分别计 1、2、3、4、5、6、7 分;闲家以 -10 分开局。" }, { + "id": "lost_in_fog", "name": "如堕云雾", - "content": "禁止查看已打出的牌和已获得的分数。" + "content": "出牌阶段不保留上一轮出牌记录,并隐藏本轮分值、闲家总分及已获得的分牌;终局结算时统一公开。" }, { + "id": "birds_gone_bow_hidden", "name": "鸟尽弓藏", - "content": "一种花色的分数牌全部打出后,不能再主动打出该花色。" + "content": "某一有效花色的所有5、10、K分牌全部实际打出后,一号位不能再主动打出该有效花色;跟牌不受影响。带分级牌统一计入主花色,不计入原牌面副花色。若手中只剩已弓藏花色,则解除限制。" }, { + "id": "odd_even_scoring", "name": "无独有偶", - "content": "奇数轮次的分数为0,偶数轮次的分数为原分数的两倍。" + "content": "奇数轮的分牌不进入计分区且计0分;偶数轮的分牌进入计分区,并按牌面原分的两倍计入。底牌仍按常规抠底规则结算。" }, { + "id": "second_battlefield", "name": "第二战场", - "content": "每当每名玩家打出的牌数不小于5时,使用这些牌进行一次德州扑克,获胜者所属的阵营获得5分。" + "content": "每场先公开5张公共牌。各家打出且尚未参赛的牌会明置在各自桌前并跨轮累计;只在轮末且四家均累计至少5张时由系统判定德州最佳牌型,胜者阵营获得5分,随后收走参赛牌、更换公共牌并重新累计。某场累计达到5张时,只有四家剩余手牌都严格少于5张才延至全部出完后进行最终判定;正好剩5张仍照常判定。王不参与牌型;双副牌的重复实体分别计算,五条高于同花顺;同阵营并列只奖励一次,跨阵营并列时双方各得5分。" }, { + "id": "tai_chi_four_symbols", "name": "太极四象", - "content": "新增牌型:四张花色各不相同且点数相同的牌,此牌型按点数比较大小,若没有任何副花色则可以用四张主花色毙之。" + "content": "新增牌型:四张花色各不相同且点数相同的牌。四象只按点数比较;没有任何副牌时,可以用四张主牌毙之。" }, { + "id": "forbidden_magic", "name": "禁术秘法", - "content": "玩家可以在一轮开始时声明将自己所有主牌不再视为主牌,可以将其当作任意花色点数相同的牌打出(王当作任意牌),限一次。" + "content": "每名玩家每局限一次,可在出牌阶段随时预备,并在轮首依次确认是否发动;确认后本局永久生效且不能撤销。发动者原有的全部主牌不再视为主牌,并可将其改作任意花色的原点数牌;王可改作任意普通点数,但有主局不能改作主花色。" }, { + "id": "one_country_two_systems", "name": "一国两制", - "content": "庄家方、闲家方各自亮出主花色。" - }, - { - "name": "取长补短", - "content": "玩家编号为0,1,2,3,第x轮中,编号为x%4的玩家的牌大小级别+1,编号为(x+2)%4的玩家的牌大小级别-1。" + "content": "庄家方与闲家方分别在本方内亮主、反主,双方声明互不覆盖。任意玩家亮一对王,或双方都未亮主时,双方无主;只有一方亮花色时,双方共用该主花色;双方亮出不同花色时,两种花色按阵营互换后统一进行牌型、跟牌、缺门、毙牌和大小判定。" }, + { + "id": "strength_compensation", + "name": "取长补短", + "content": "以庄家为0号位,逆时针依次为1、2、3号位。第x轮中,x%4号位的全部手牌沿当前牌力序列升一级,(x+2)%4号位降一级,轮末轮换。副牌留在原花色并跳过级牌点数,越过A成为F、越过最低普通牌成为1;主牌依次为主普通牌、副级牌、主级牌、小王、大王、白王,升降不会打乱原有内部次序。牌面变化不改变实体牌原有分值。" + }, { + "id": "wooden_ox_flowing_horse", "name": "木牛流马", - "content": "游戏开始时,2、3号位玩家获得“木牛流马”,持有木牛流马的玩家可以在一轮开始时将一张牌置入其中(已有时改为替换)并交给队友,队友可以如手牌般打出此牌。木牛流马中的牌不计入手牌花色限制,每局每人可以传递两次。当手牌不等对游戏造成影响时,玩家必须传递/收回木牛流马中的牌以使牌数正确。" + "content": "游戏开始时2、3号位各持有一具木牛流马(每队一具)。每轮首张牌打出前,持有者可以跳过操作、将其中原有的牌原样交给队友,或放入/替换一张手牌并必须立即交给队友;首张牌打出后本轮不得再操作。接收者可以跨轮保留其中的牌,并在自己的正常出牌回合将它如手牌般打出;该牌不计入持有者的跟牌花色义务。若队友已经无牌可出,持有者必须在轮首将木牛流马交给队友以恢复牌数。每队的木牛流马每局最多往返两次(共四次单程传递)。" }, { "name": "再续前缘", "content": "打出一张牌时,若另一张相同的牌在本轮或上轮已经被打出,其所属阵营获得2分。" }, { + "id": "unarmed", "name": "手无寸铁", - "content": "移除4张王牌并令所有主点数牌大小视为原本的数字大小。" + "content": "开局移除4张王牌。级牌仍可用于亮主、反主;进入出牌阶段后不再因级牌点数成为主牌,而是回到自身花色并按原点数大小参与跟牌、牌型和胜负比较。" }, { + "id": "mutual_support", "name": "同舟共济", - "content": "每名玩家限一次,任意轮次轮到某玩家出牌时,可以要求队友交给自己0-2张牌或选择交给队友1-2张牌,本轮结束时获得牌的玩家再交给给出牌的玩家等量手牌。" + "content": "每名玩家每局限一次;轮到自己且尚未出牌时,可以要求队友交给自己0至2张牌,或选择1至2张手牌交给队友。本轮结束时,获得牌的玩家再选择等量手牌返还给原持有者。" }, { + "id": "candle_to_dawn", "name": "烛尽天明", - "content":"游戏开始时庄家队友选择是否点燃“烛”。每轮出牌结束时根据本轮四号位的出牌调整“烛”的状态:若其打出的牌只含有黑色,则熄灭“烛”,若只含有红色,则点燃“烛”,若均不满足则不改变“烛”的状态。“烛”处于点燃的轮次,红色分数牌具有的的分数+5,黑色分数牌具有的分数-5;“烛”处于熄灭的轮次,红色分数牌具有的的分数-5,黑色分数牌具有的分数+5。" + "content":"游戏开始时庄家队友选择是否点燃“烛”。每轮先按该轮开始时的烛态结算分数,结算完成后再根据本轮四号位的出牌决定下轮烛态:若其打出的牌只含黑色,则熄灭“烛”;若只含红色,则点燃“烛”;否则不变。“烛”点燃的轮次,红色分数牌每张+5分,黑色分数牌每张-5分;“烛”熄灭的轮次则相反。小王是黑色,大王是红色。" }, { + "id": "cultural_revolution", "name": "文化革命", - "content": "每名玩家限一次,作为一号位出牌时可以选择声明一种花色/点数(不能为10/K),两轮之内该花色/点数视为主花色/主点数,若发动时已经处于此效果生效区间内则覆盖之。" + "content": "每名玩家每局限一次;作为一号位出牌前,先选择“革花色”或“革点数”,再声明一种花色或2至A的一种点数(包括10、K)。发动当轮及下一轮内,所选花色替换原主花色,或所选点数替换原级牌点数;被替换的原主暂按副牌处理。生效期间的新声明会整体覆盖旧声明,并重新持续两轮。" }, { + "id": "three_tigers", "name": "三人成虎", - "content": "一轮中,若至少3名玩家打出的牌花色均相同,则该花色在本轮结算时大小视为主花色,但大小-4。" + "content": "一轮中,每名玩家本次打出的全部牌均为同一副花色的副牌时,为该花色计一人;同一副花色累计达到三人后立即成虎。主花色牌、级牌和王不计人数,也不转换。该轮桌上及此后打出的该副花色牌全部视为主牌,并沿剔除本局级牌后的普通牌序列降低4级;例如4为级牌时,降级过程跨过4这一档,最低为−2。转换只影响本轮牌力,不改变实体牌原有分值。" }, { "name": "势者制权", "content":"游戏开始时庄家队友获得“权”并选择一名对方玩家,令其获得“势”。在一轮中,若持有“权”的玩家位于持有“势”玩家的前置位,则后者本轮打出的分数在计算时不会超过前者;若持有“权”的玩家位于持有“势”玩家的后置位,则前者本轮打出牌的大小在计算时不会小于后者且视为先手(均为垫牌则不触发此条)。以上两条效果任意之一被触发的轮次结束时,持有“权”的玩家需将“权”交给持有“势”的玩家,后者再选择对方的一名玩家交给其“势”。" }, { + "id": "invite_into_urn", "name": "请君入瓮", - "content": "每名玩家限一次,作为首置位时可以指定一名玩家和一张牌,若其本轮打出了此牌,则失去5分。" + "content": "每名玩家每局限一次;作为一号位出牌前,可指定一名其他玩家和一种实体牌面。本轮只要目标玩家打出至少一张该牌面,其阵营失去5分;无论同一次打出一张还是多张,都只扣5分。若目标属于庄家方,等价为闲家增加5分。" }, { + "id": "teammate_cheer", "name": "队友加油", - "content": "没有主牌时可以声明之并令队友在本局游戏中的所有牌大小级别+1。" + "content": "每次出牌后,若该玩家还持有至少一张牌、剩余手牌中已经没有主牌且尚未发动过,系统询问是否声明“队友加油”。确认后其队友获得永久加油 Buff,剩余手牌及之后打出的牌沿当前完整牌力序列全部提升一级;副牌不会跨入主牌链,A之上为F,大王之上为白王。获得 Buff 的玩家全场标记,实体牌原有分值不变;暂不发动后可在后续满足条件的出牌后再次选择。" }, { + "id": "old_horse_still_has_strength", "name": "老骥伏枥", - "content": "最后一个获得出牌权的玩家的第一次出牌视为必定最大。" + "content": "开局首发者视为已经获得牌权。四名玩家都至少获得过一次牌权时,最后首次获得牌权的玩家,其随后第一次合法首发视为绝对最大,任何后手都不能压过;该次出牌仍须完整遵守首发牌型与甩牌规则。" }, { "name": "第三战场", "content": "一号和二号位使用每轮打出的四张牌(王牌则不触发)进行加减乘除24点游戏,率先算出者+3分(四号位须在20s内出牌)。" }, { + "id": "trump_wins", "name": "Trump wins", - "content": "打出分数最多的玩家下轮先出牌。" + "content": "每轮仍按正常牌力判定胜者并结算分数,但下一轮改由本轮实体出牌总分最高的玩家先出;若最高分有多人并列,则其中先出牌的玩家获得牌权。" }, { + "id": "openly_revealed", "name": "昭然若揭", - "content": "底牌始终保持明置。" + "content": "底牌从摸牌开始便在牌桌中央明置;庄家重新埋底后,明置内容同步换成新底牌。整局所有玩家都可随时查看当前底牌。" }, { + "id": "straw_boat_borrowing_arrows", "name": "草船借箭", - "content": "若首置位的玩家打出的牌中包含分数之和不小于10且这些分数牌没有被其所属阵营获得,其可以在此轮结束时弃置一张非分数牌并获得本轮打出的非分数牌中最大的一张。" + "content": "若首置位玩家本轮打出的实体分牌合计不少于10分,且这些分牌最终被对方阵营获得,轮末该玩家可以选择发动:向全场公开弃置一张剩余手牌中的非分牌,并获得本轮打出的、按当前主牌体系牌力最大的单张非分牌。若手中无可弃牌或本轮没有非分牌,则不能发动。" }, { + "id": "bush_gate", "name": "布什戈门", - "content": "每名玩家限一次,作为二号位时若认为一号位打出的牌过于离谱,可以令其收回并重新打出另一(些)合法的牌。" + "content": "每名玩家每局限一次;作为本轮二号位且尚未出牌时,可以迫使一号位收回本轮首发并重新合法首发。被收回的每一张牌在这次重新首发中均不可使用;重新首发成功后限制立即解除,后续轮次可正常使用。若一号位没有其他手牌则不能发动。" }, { "name": "千金散尽", "content": "闲家的的分数为 200 - 实际得到的分数,不能低于0分。" }, { + "id": "illusion_and_reality", "name": "虚虚实实", - "content": "每名玩家限一次,若某副花色的牌剩余奇数张,出牌时可以视为没有此花色的牌(若利用此规则,则不能打出该花色的牌)。" + "content": "每名玩家每局限一次。跟随副花色时,若自己手中该副花色恰好剩余奇数张,可以发动并将这些牌虚置,本次出牌完全视为手中没有该花色;被虚置的牌不能在本次出牌中打出,仍须从其余手牌中打出与首家相同的张数。" }, { + "id": "strive_upstream", "name": "力争上游", - "content": "每轮的出牌顺序为上轮打出牌的大小顺序(垫牌按照字典序比较单张大小,考虑主花色)。" + "content": "每轮的出牌顺序按上轮四家出牌由大到小排列。以一号位的牌型为准:同牌型按正常牌力比较;牌型不一致时,先比与首家牌型的贴合程度,异花色牌越多越小(包括主牌),数量相同再从最小牌起按字典序比较;完全相同时后出者更小。" }, { + "id": "afterglow", "name": "回光返照", - "content": "当玩家的主牌不大于 3 张时,其出牌可以不受花色限制且打出的主牌大小+1。若使用此规则进行出牌,则之后的每一轮必须打出主牌直到没有主牌为止。" + "content": "每名玩家每局限一次。开局一号位若持有1至3张主牌,可在首次出牌前直接发动;其余情况下,每次出牌后若该玩家仍有手牌且只剩1至3张主牌,系统询问是否发动。确认后剩余主牌立即沿当前完整主牌序列提升一级;从下一次出牌起,只要手中仍有主牌便无视通常的跟牌要求,但每次出牌只能打出主牌、不能混入副牌。主牌出尽后效果结束;实体牌原有分值不变,暂不发动不消耗机会。" }, { + "id": "outward_harmony_inner_division", "name": "貌合神离", "content": "任意一方两名玩家在一轮中若打出的牌型不一致(不考虑花色),则对方阵营获得5分。" }, { + "id": "ambiguous", "name": "模棱两可", - "content": "每名玩家限一次,出牌时可以展示两种不同的合法出牌方式,在本轮结束时选择其中一种(多人发动则后续玩家再发动无须消耗次数,并从后置位向前选择)。" + "content": "每名玩家每局限一次,仅本轮二、三号位可发动:同时公开两种不同且各自合法的出牌方案,四家出完后再选择其中一种结算。若二、三号位都发动,则三号位作为后续发动者不消耗次数,并由三号位先选、二号位后选。" }, { "name": "良禽择木", "content": "游戏开始前,将所有 A K 移出游戏并在摸牌结束后平均分配给玩家。" }, { + "id": "two_ghosts_knock_door", "name": "二鬼拍门", - "content": "摸到不小于2张王牌时须将其全部亮出。" + "content": "摸牌阶段中,任一玩家手牌里的王达到至少两张时,立即将其持有的全部王作为明置手牌公开;之后再摸到的王也继续明置。王仍属于原玩家手牌,打出或离开手牌后便从明置区消失。" }, { "name": "正大光明", @@ -357,47 +430,56 @@ }, { "name": "互通有无", - "content": "队友的手牌对你可见。" + "content": "进入出牌阶段后,队友的手牌仅对你可见。" }, { + "id": "double_happiness", "name": "双喜临门", - "content":"重新从三个规则中选择两个(不矛盾的)规则,于本局游戏中同时生效。" + "content": "重新从三个规则中选择两个规则,于本局游戏中同时生效。房主可以刷新其中任意一条候选。" }, { + "id":"people_commune", "name":"人民公社", - "content":"每名玩家各埋两张底牌,闲家开局-20分。抄底时,双方均获得且仅获得对方所埋的底牌中的分数。" + "content":"摸牌时不留底牌,四名玩家各摸27张,再各自埋两张;每人可随时查看自己埋的两张牌及其分值。闲家开局-20分。抄底时只结算对方阵营埋下的四张牌:闲家拿底获得庄家方埋分;庄家方拿底则抄走闲家埋分,使闲家按底牌倍数扣分。" }, { "name":"市场经济", "content":"初始时市场具有8张牌,每过3轮每人将一张牌投入市场,根据本轮的大小依次从市场中获得一张牌。游戏结束时闲家获得市场中的一半分数。" }, { + "id":"remove_firewood_from_under_cauldron", "name":"釜底抽薪", - "content":"被反主的玩家可以在反主结算后与其交换手牌。(反多次则由后向前结算)" + "content":"每次反主后,被反主的玩家获得一次选择:亮主与反主全部结算后,可以与反主者交换当前全部手牌,也可以不交换。若发生多次反主,按反主发生顺序由后向前依次询问和结算。" }, { + "id":"mainstay", "name":"中流砥柱", - "content":"摸牌结束后,若本局不是无主局,则从一号位开始,若玩家的主牌数不大于5,则可以将包括所有主牌在内的5张牌交给队友,然后队友再交给其5张牌。" + "content":"摸牌结束后,若本局不是无主局,则从一号位开始依次处理每名玩家。轮到某玩家时,若其当前主牌数不大于5,可以将包括全部主牌在内的5张牌交给队友,再由队友返还5张牌;每名玩家均按轮到自己时的当前手牌独立判断,因此同队两人都可以发动。" }, { + "id":"happy_twins", "name":"欢乐成双", - "content":"庄家与上家交换位置。" + "content":"庄家锁定后与上家交换位置,但双方原有组队关系不变;本局结束后恢复原座次。若庄家方获胜,下一局仍由庄家的固定队友上庄;若闲家方获胜,则由换位后庄家的下家(即换位前的庄家上家)上庄。" }, { + "id":"encircle_three_missing_one", "name":"围三阙一", - "content":"将除王牌与级牌之外的所有牌分为四种花色,记录游戏中最先出现的三种花色,若最后剩余的一种花色不为主花色,则该花色从下一轮开始替换原本的主花色,然后无论是否发生替换,删除所有记录并重新开始记录。" + "content":"摸牌阶段照常亮主。进入出牌阶段后,忽略王牌和级牌,每次出牌后即时记录本次出现的全部普通花色;若一次更新后四种花色齐全,则立即清空全部记录,并从下一位玩家出牌重新统计。每轮结束时若恰好记录了三种花色,缺少的第四种花色若不是当前主花色,则从下一轮起替换当前主花色;无论是否换主,随后均清空记录并重新统计。" }, { + "id":"three_six_nine_grades", "name":"三六九等", - "content":"本局除亮主外,玩家可以额外亮出一种主花色以使该花色的牌成为“劣花色”,之后游戏中普通副牌相对于劣牌的关系等同于主牌相对于副牌的关系,同时该花色的级牌小于副花色的级牌(即主花色>副花色>劣花色)。亮出劣花色的过程遵循与主牌相同的反主规则,对王即无劣花色,一张/一对级牌/王牌只能用于亮主与亮劣其中之一。" + "content":"亮牌阶段分为亮主与亮劣两条独立的反亮链,均可用一张或一对本花色级牌并按通常强度加固、反亮;王只能亮主。任一普通花色一旦用于亮主或亮劣,其他玩家便不能再在任一链使用该花色。若最终没有普通花色主(无人亮主或对王无主),或无人亮劣,则本局没有劣花色。出牌时主牌大于普通副牌,普通副牌可以像主牌毙副牌一样毙劣牌;劣花色级牌小于其他副花色级牌。" }, { + "id":"iron_evidence", "name":"铁证如山", - "content":"若在一轮中出现了小王、红桃Q、黑桃J、梅花J,则本轮打出的分数翻倍,每多出现一张则倍数额外+1;若两张大王均已被打出,则改为本轮不计分。" + "content":"每轮开始时锁定铁证牌的效果。若此前两张大王尚未全部打出,本轮每出现一张小王、红桃Q、黑桃J或梅花J,本轮牌面总分的倍数便在1倍基础上加1;若本轮开始前两张大王均已打出,则本轮只在出现至少一张上述铁证牌时清零牌面总分,没有铁证牌则正常计分。本轮内才打出的第二张大王不影响本轮效果,只从下一轮开始生效。" }, { + "id":"waiting_rabbit", "name":"守株待兔", - "content":"游戏开始时,每名玩家可以暗中指定一张牌(不能是王牌、主点数),然后当此牌被其他玩家打出时,当轮座次最先指定了此牌的玩家可以用一张非分数牌与之交换(只交换一张,一张牌的分数只在其首次被打出时计算一次)。" + "content":"埋底完成后,每名玩家暗中指定一张有花色的目标牌,不能指定王或当前级牌,但可以指定5、10、K。当其他玩家在一轮中打出该牌面时,目标牌先正常参与本轮牌力与首次计分;本轮结算完成后,由本轮座次最先指定它且尚未成功交换的玩家决定是否发动,可以用手里一张非分牌与其中一张目标牌交换:目标牌进入其手牌,非分牌原位顶替留在桌上的目标牌,但不倒改本轮赢家或得分。每张实体分牌只在全局首次上桌时计分,被换回手牌后再次打出只保留牌面大小,不再计分。" }, { "name":"破釜沉舟", @@ -408,31 +490,48 @@ "content":"摸牌时随机一部分牌正面向上。" }, { + "id":"hidden_dragon_in_abyss", "name":"潜龙在渊", - "content":"游戏开始时每名玩家指定自己最多的点数(主点数除外),手牌数首次不大于12时,若玩家从未打出过该点数,则所属阵营获得10分。" + "content":"埋底完成后,每名玩家公开声明自己手牌中数量最多的一个点数(本局级牌除外);若最多点数唯一则由系统自动声明,并列时由玩家自行选择。玩家的手牌数首次不大于12时,若其此前及本次均未打出过所声明的点数,则所属阵营获得10分。" }, { + "id":"administrative_review", "name":"行政审查", - "content":"庄家12张底牌,游戏开始前,庄家下家指定一种副花色,庄家上家指定一种点数,直到庄家方打出过该花色和该点数的牌才可埋底牌。" + "content":"本局有12张底牌,四家各持24张牌开始出牌。开始出牌前,庄家下家公开指定一种副花色,庄家上家公开指定一种点数;底牌继续封存,庄家不可查看。庄家或其队友累计打出过指定副花色和指定点数后,庄家才收到并查看12张底牌,立即埋12张后继续当前牌局。" }, { + "id":"political_review", "name":"政治审查", - "content":"每名玩家限一次,可以令队友收回其打出的牌并重新打出(可以继续选择同样的出牌)。" + "content":"每名玩家每局限一次。在该玩家尚未发动前,其队友每次提交出牌后都暂停牌局并询问该玩家是否令队友收回。选择收回才消耗次数;收回只传递反对信号,不产生任何禁出限制,队友可以立即原样重新出牌。选择放行不消耗次数。" }, { + "id":"no_one_survives", "name":"无人生还", - "content":"除一号位的出牌均暗置,在一轮结束后统一展示。" + "content":"每轮一号位的出牌正常明置;二、三、四号位的出牌在本人视角始终明置,对其他玩家自动暗置且只公开张数,本轮牌面总分也不提前显示。四家出完后统一公开暗牌与本轮总分,再按正常规则结算胜负和下轮牌权。" }, { + "id":"lure_tiger_from_mountain", "name": "调虎离山", - "content": "每方阵营限一次,一轮开始前可以指定一名非一号位玩家,令其本轮无法响应甩牌询问,且打出的牌不计大小与分数。" + "content": "每方阵营每局限发动一次。每名玩家都可在出牌阶段随时预备,系统在目标轮开始、首张牌打出前按本轮座次依次询问预备者;同队首名确认并选定目标的玩家抢占本方次数,其队友不能再发动。发动者指定一名本轮非一号位玩家,使其本轮无法参与甩牌询问,且本轮打出的牌不计大小与分数。" }, { + "id": "defense_as_offense", "name": "以守为攻", - "content": "每轮若一号位玩家打出的牌每小于一名其他玩家,则其下轮打出的牌大小+1。" + "content": "每轮结束后,按“力争上游”的完整牌力顺序比较四家出牌;本轮一号位每小于一名其他玩家,其下一轮全部手牌牌面便提升一级,并在玩家名旁标记对应的“+X”。加成只持续下一轮,实体牌原有分值不变;F与白王不再提升,其他牌按完整牌力序列提升并在白王处封顶,因此大王即使+3仍为白王。" }, { + "id": "antinomy", "name": "二律背反", - "content": "游戏开始前每名玩家各自指定一张牌(花色+点数)并同时亮出,被指定的牌本局中视为两张不同的牌(即无法成对),若有多名玩家指定了同一张牌,则不执行此效果。每轮结束后,本轮中指定牌被打出过的所有玩家重新指定一张牌并同时亮出。" + "content": "庄家埋完底、正式开始出牌前,四名玩家各自选择一张普通花色牌面(花色+点数),收齐后同时亮出。仅被一名玩家指定的牌面,其两张实体牌本局视为不同牌,不能组成对子或拖拉机;同一牌面被多人指定时不执行拆对效果。每轮结束后,本轮中自己所指定牌面曾被任何玩家打出的声明者重新选择,收齐后同时更新;完成前不能开始下一轮出牌。" + }, + { + "id": "change_rice_to_mulberry", + "name": "改稻为桑", + "content": "庄家埋底后、正式出牌前,两名闲家分别选择自己手牌中⌊分牌数÷2⌋张分牌;原本为副牌的变为同花色A,原本为主牌的变为大王。被改造的实体牌永久失去分值,只按新牌面参与牌型、跟牌和大小结算。" + }, + { + "id": "destroy_dyke_flood_fields", + "name": "毁堤淹田", + "content": "庄家每局限发动一次。闲家赢得一轮后、该轮计分前,庄家可以令本轮所有分数作废并记录作废分数。接下来的三轮为灾期,期间照常计分;若闲家在灾期内累计获得不少于20分,或游戏在灾期结束前结束,则视为事发,闲家取回作废分数并额外获得20分,随后结束灾期。若牌局恰于灾期第三轮结束,且闲家拿底,也视为事发。若三轮结束时仍未事发,记录的分数永久作废。" } -] \ No newline at end of file +] diff --git a/tractor-game-simulator/client/src/App.jsx b/tractor-game-simulator/client/src/App.jsx index bf4c7dc..1307d5a 100644 --- a/tractor-game-simulator/client/src/App.jsx +++ b/tractor-game-simulator/client/src/App.jsx @@ -1,12 +1,14 @@ import { useState, useEffect } from 'react'; -import { Layout, Typography, Button, message, Space, Tabs, Tag, Divider, Modal, InputNumber } from 'antd'; +import { Layout, Typography, Button, message, Space, Tabs, Tag, Divider, Modal, InputNumber, Checkbox, Select } from 'antd'; import socketService from './services/socket'; import { useGameStore } from './store/gameStore'; import CreateRoomModal from './components/Room/CreateRoomModal'; import JoinRoomModal from './components/Room/JoinRoomModal'; import RoomList from './components/Room/RoomList'; import GameBoard from './components/Game/GameBoard'; -import { SOCKET_EVENTS, GamePhases } from './utils/constants'; +import { SOCKET_EVENTS } from './utils/constants'; +import { shouldShowGameBoard } from './utils/gameViewUtils'; +import { RULE_SELECT_OPTIONS } from './utils/ruleCatalog'; import './styles/App.css'; const { Header, Content } = Layout; @@ -20,8 +22,9 @@ function App() { const [selectedRoomId, setSelectedRoomId] = useState(''); const [loadingRooms, setLoadingRooms] = useState(false); const [showConfigModal, setShowConfigModal] = useState(false); - const [newBottomCardsCount, setNewBottomCardsCount] = useState(8); const [newDealInterval, setNewDealInterval] = useState(500); + const [testMode, setTestMode] = useState(false); + const [testRuleId, setTestRuleId] = useState('normal_game'); useEffect(() => { // 连接Socket @@ -131,8 +134,9 @@ function App() { name: values.roomName, playerName: values.playerName, config: { - bottomCardsCount: values.bottomCardsCount, - dealInterval: values.dealInterval + dealInterval: values.dealInterval, + testMode: values.testMode === true, + testRuleId: values.testMode ? values.testRuleId : null } }); }; @@ -184,10 +188,6 @@ function App() { }; const handleUpdateConfig = () => { - if (newBottomCardsCount < 1 || newBottomCardsCount > 20) { - messageApi.warning('底牌数量必须在1-20之间'); - return; - } if (newDealInterval < 10 || newDealInterval > 5000) { messageApi.warning('发牌间隔必须在10-5000毫秒之间'); return; @@ -196,8 +196,9 @@ function App() { socket.emit(SOCKET_EVENTS.UPDATE_CONFIG, { roomId: currentRoom.id, config: { - bottomCardsCount: newBottomCardsCount, - dealInterval: newDealInterval + dealInterval: newDealInterval, + testMode, + testRuleId: testMode ? testRuleId : null } }); setShowConfigModal(false); @@ -213,17 +214,16 @@ function App() { // 同步房间配置 useEffect(() => { if (currentRoom?.config) { - setNewBottomCardsCount(currentRoom.config.bottomCardsCount); setNewDealInterval(currentRoom.config.dealInterval); + setTestMode(currentRoom.config.testMode === true); + setTestRuleId(currentRoom.config.testRuleId || 'normal_game'); } }, [currentRoom]); // 如果在房间内,显示房间界面或游戏界面 if (currentRoom) { - // 如果游戏已开始(不在WAITING阶段)或正在等待准备,显示游戏界面 - const gamePhase = currentRoom.gameState?.phase || GamePhases.WAITING; - const isWaitingForReady = currentRoom.gameState?.isWaitingForReady || false; - if (gamePhase !== GamePhases.WAITING || isWaitingForReady) { + // 规则选择属于两局之间的牌桌流程,不能因为阶段暂时回到 WAITING 就卸载牌桌。 + if (shouldShowGameBoard(currentRoom.gameState)) { return ; } @@ -263,8 +263,11 @@ function App() {
房间配置: -

底牌数量: {currentRoom.config.bottomCardsCount} 张

+

默认底牌: 8 张(特殊规则可能调整)

发牌间隔: {currentRoom.config.dealInterval} 毫秒

+

规则模式: {currentRoom.config.testMode + ? `测试模式(${RULE_SELECT_OPTIONS.find(option => option.value === currentRoom.config.testRuleId)?.label || currentRoom.config.testRuleId})` + : '正常随机二选一'}

{/* Bot管理区域 - 仅房主可见 */} @@ -333,16 +336,6 @@ function App() { cancelText="取消" >
- 底牌数量: -
- -
发牌间隔(毫秒):


+ setTestMode(event.target.checked)}> + 规则测试模式 + + {testMode && ( + player.id !== currentPlayer?.id) + .map(player => ({ + value: player.id, + label: player.name + }))} + onChange={targetPlayerId => setInviteIntoUrnSelection(previous => ({ + ...previous, + targetPlayerId + }))} + /> +
+ 指定牌面 + 先选花色,再选点数 +
+
+ {INVITE_INTO_URN_SUITS.map(option => ( + - + ))}
- ); + {inviteIntoUrnSelection?.suit && ( +
+ {(inviteIntoUrnSelection.suit === 'joker' + ? INVITE_INTO_URN_JOKER_RANKS + : TRANSFORMATION_RANKS.map(rank => ({ value: rank, label: rank }))) + .map(option => ( + + ))} +
+ )} +

+ 同一手打出多张指定牌也只扣5分;指定庄家方玩家时,等价为闲家加5分。 +

+
+ - case GamePhases.DRAWING: - return ( -
- {/* 游戏桌面 - 摸牌阶段显示展示的牌 */} - - } - currentTrumpDeclaration={currentTrumpDeclaration} - players={currentRoom.players} - currentPlayer={currentPlayer} - playedCards={{}} - shownCards={shownCards} - myCards={myCards} - selectedCards={selectedCards} - onCardClick={toggleCardSelection} - onReorder={reorderCards} - currentTurnPlayerId={null} - trumpSuit={trumpSuit} - trumpRank={trumpRank} - isHost={isHost} - onSetTrump={() => setTrumpModal(true)} - selectedRule={selectedRule} - onSelectRule={() => setRuleSelectorModal(true)} - renderControls={renderControlButtons()} - isWaitingForReady={false} - buryingPlayerId={gameState?.buryingPlayerId} - dealerCountdown={dealerCountdown} - team1Level={gameState?.team1Level} - team2Level={gameState?.team2Level} - dealerPlayerIndex={gameState?.dealerPlayerIndex} - onRename={handleOpenRenameModal} - /> + + + + + + ) : ( + + )} + > + {!culturalRevolutionSelection?.declarationType ? ( +
+

+ 先选择本次革命的性质。两种效果二选一,不会同时改变主花色和级牌点数。 +

+
+ + +
- ); - - case GamePhases.BURYING: - return ( -
- {/* 游戏桌面 - 埋底阶段 */} - setTrumpModal(true)} - selectedRule={selectedRule} - onSelectRule={() => setRuleSelectorModal(true)} - renderControls={renderControlButtons()} - isWaitingForReady={false} - currentTrumpDeclaration={currentTrumpDeclaration} - buryingPlayerId={gameState?.buryingPlayerId} - dealerCountdown={dealerCountdown} - team1Level={gameState?.team1Level} - team2Level={gameState?.team2Level} - dealerPlayerIndex={gameState?.dealerPlayerIndex} - onRename={handleOpenRenameModal} - /> + ) : ( +
+
+ + {culturalRevolutionSelection.declarationType === 'suit' ? '革花色' : '革点数'} + + + {culturalRevolutionSelection.declarationType === 'suit' + ? '选择新的主花色' + : '选择新的级牌点数(10、K均可)'} + +
+
+ {(culturalRevolutionSelection.declarationType === 'suit' + ? TRANSFORMATION_SUITS + : TRANSFORMATION_RANKS.map(rank => ({ value: rank, label: rank }))) + .map(option => ( + + ))} +
+

+ 发动当轮和下一轮生效;原主对应项暂时变回副牌。若期间另一名玩家发动,新声明会覆盖旧声明并重新计两轮。 +

- ); + )} + - case GamePhases.PLAYING: - // 获取当前轮到出牌的玩家ID - const currentTurnPlayerId = gameState?.currentPlayerIndex !== null && gameState?.currentPlayerIndex !== undefined - ? currentRoom.players[gameState.currentPlayerIndex]?.id - : null; + + + + + ) : ( + + ) + } + > + {ownMainstayAction?.stage === 'decision' ? ( + <> +

+ 你当前的主牌不超过5张,可以发动中流砥柱。 +

+

+ 发动后须将包含当前全部主牌的5张牌交给队友,再由队友选择5张牌返还。 + 队友稍后轮到自己时仍会按当时的手牌重新判断,也可以再次发动并把主牌交回来。 +

+ + ) : ( + <> +

+ {ownMainstayAction?.stage === 'return' + ? `请选择5张牌返还给 ${currentRoom?.players?.find(player => player.id === ownMainstayAction?.actorPlayerId)?.name || '队友'}。` + : `请选择5张牌交给 ${currentRoom?.players?.find(player => player.id === ownMainstayAction?.teammatePlayerId)?.name || '队友'};当前全部主牌已预先选中且不可取消。`} +

+
+ +
+

+ 已选择 {mainstaySelectedCardIds.length} 张,本次必须正好选择5张。 +

+ + )} +
- return ( -
- {/* 游戏桌面 - 出牌阶段不显示展示的牌 */} - setTrumpModal(true)} - selectedRule={selectedRule} - onSelectRule={() => setRuleSelectorModal(true)} - renderControls={renderControlButtons()} - isWaitingForReady={false} - currentTrumpDeclaration={currentTrumpDeclaration} - buryingPlayerId={gameState?.buryingPlayerId} - dealerCountdown={dealerCountdown} - attackerScore={attackerScore} - collectedPointCards={collectedPointCards} - team1Level={gameState?.team1Level} - team2Level={gameState?.team2Level} - dealerPlayerIndex={gameState?.dealerPlayerIndex} - onRename={handleOpenRenameModal} - /> -
- ); + + + + + + )} + > +

+ 选择本次同舟共济的方向。向队友要牌时,队友可以交给你0至2张,也可以不给; + 主动给牌时,你必须交给队友1至2张。 +

+

+ 无论哪个方向,本轮结束时都由当前收牌的一方选择等量手牌返还给原持有者。 + 只有实际确认发动才会消耗每局一次的机会。 +

+ {mutualSupportMaxGiveCount < 1 && ( +

+ 你目前必须保留全部手牌完成本轮出牌,因此暂时只能向队友要牌。 +

+ )} +
- case GamePhases.REVEALING: - // 揭示底牌阶段不需要高亮边框,传递 null - return ( -
- {/* 游戏桌面 - 展示底牌,保留所有人的出牌 */} - setTrumpModal(true)} - revealedBottomCards={revealedBottomCards} - selectedRule={selectedRule} - onSelectRule={() => setRuleSelectorModal(true)} - renderControls={renderControlButtons()} - isWaitingForReady={false} - currentTrumpDeclaration={currentTrumpDeclaration} - buryingPlayerId={gameState?.buryingPlayerId} - dealerCountdown={dealerCountdown} - attackerScore={attackerScore} - collectedPointCards={collectedPointCards} - bottomScoreResult={bottomScoreResult} - upgradeResult={upgradeResult} - team1Level={gameState?.team1Level} - team2Level={gameState?.team2Level} - dealerPlayerIndex={gameState?.dealerPlayerIndex} - /> -
- ); + + {mutualSupportSelection?.source === 'activation' && ( + + )} + + + )} + > +

+ {mutualSupportSelection?.stage === 'return' + ? `本轮结束,请选择${mutualSupportSelection?.requiredCards || 0}张手牌返还给 ${mutualSupportSelection?.otherPlayerName || '队友'}。` + : mutualSupportSelection?.stage === 'request' + ? `${mutualSupportSelection?.otherPlayerName || '队友'} 请求你交牌;你可以选择0至${mutualSupportSelection?.maxCards || 0}张。` + : `请选择1至${mutualSupportSelection?.maxCards || 2}张手牌交给 ${mutualSupportSelection?.otherPlayerName || '队友'}。`} +

+
+ +
+

+ 已选择 {mutualSupportSelectedCardIds.length} 张; + {mutualSupportSelection?.minCards === mutualSupportSelection?.maxCards + ? ` 本次必须正好选择${mutualSupportSelection?.minCards || 0}张。` + : ` 本次可以选择${mutualSupportSelection?.minCards || 0}至${mutualSupportSelection?.maxCards || 0}张。`} +

+
- case GamePhases.FINISHED: - // 游戏结束阶段不需要高亮边框,传递 null - return ( -
- {/* 游戏桌面 - 游戏结束,保留所有人的出牌和底牌 */} - { + socket.emit(SOCKET_EVENTS.ACTIVATE_EQUIVALENT_RECIPROCITY, { + roomId: currentRoom.id, + targetPlayerId: equivalentReciprocityTarget?.id + }); + setEquivalentReciprocityTarget(null); + }} + onCancel={() => setEquivalentReciprocityTarget(null)} + > +

+ 是否与 {equivalentReciprocityTarget?.name || ''} 拼点? +

+

+ 确认后双方各自秘密选择一张牌;主牌大于副牌,副牌只比较点数。 + 输家一方失去5分,点数相同则无人失分,但拼点牌仍会交换。 +

+ + + { + socket.emit(SOCKET_EVENTS.ACTIVATE_MAGIC_TRICK, { + roomId: currentRoom.id, + targetPlayerIds: magicTrickTargetIds + }); + }} + onCancel={() => setMagicTrickTargetIds([])} + > +

+ 是否暗中交换 + {' '} + + {magicTrickTargetIds + .map(playerId => currentRoom?.players?.find(player => player.id === playerId)?.name) + .filter(Boolean) + .join(' 与 ')} + + {' '}本轮的结算出牌? +

+

+ 两人仍按各自真实手牌正常出牌;交换只在四家出完后影响本轮胜负、得分归属和下轮牌权。 +

+
+ + + {equivalentReciprocitySelection?.submitted ? '已暗置,等待对方' : '确认拼点牌'} + + )} + > +

+ 请暗选一张牌,与 {equivalentReciprocitySelection?.opponentPlayerName || ''} 拼点。 +

+
+ setEquivalentReciprocityCardId(previous => previous === cardId ? null : cardId)} + disabled={Boolean(equivalentReciprocitySelection?.submitted)} + small + trumpSuit={trumpSuit} + trumpRank={trumpRank} + minimumVisibleWidth={16} + /> +
+

+ 你的选择在双方都提交前不会公开;提交后不能更改。 +

+
+ + + + + + )} + > +

+ 你首置的 {strawBoatDecision?.leadingPoints || 0} 分未被本方获得,可以弃置一张非分数牌, + 换取本轮打出的最大非分数牌: +

+ {strawBoatDecision?.borrowedCard && ( +
+ setTrumpModal(true)} - revealedBottomCards={revealedBottomCards} - selectedRule={selectedRule} - onSelectRule={() => setRuleSelectorModal(true)} - renderControls={renderControlButtons()} - isWaitingForReady={false} - currentTrumpDeclaration={currentTrumpDeclaration} - buryingPlayerId={gameState?.buryingPlayerId} - dealerCountdown={dealerCountdown} - attackerScore={attackerScore} - collectedPointCards={collectedPointCards} - bottomScoreResult={bottomScoreResult} - upgradeResult={upgradeResult} - team1Level={gameState?.team1Level} - team2Level={gameState?.team2Level} - dealerPlayerIndex={gameState?.dealerPlayerIndex} - onRename={handleOpenRenameModal} />
- ); + )} +
+ setStrawBoatDiscardCardId( + previous => previous === cardId ? null : cardId + )} + small + trumpSuit={trumpSuit} + trumpRank={trumpRank} + minimumVisibleWidth={20} + /> +
+

+ 请选择恰好一张非分数牌。弃掉的牌和获得的牌都会向全场公开;选择放弃则不交换任何牌。 +

+
- default: - return null; - } - }; + + {lureTigerDecision?.stage === 'confirm' && ( + +

+ 是否在第 {lureTigerDecision.round} 轮发动调虎离山? +

+

+ 确认后还需选择一名本轮非一号位玩家。选定目标才会抢占本方阵营唯一一次机会; + 暂不发动不会占用次数,同轮其他预备者会继续依次确认。 +

+ + + + +
+ )} + {lureTigerDecision?.stage === 'target' && ( + +

+ 请选择本轮要沉默的非一号位玩家 +

+

+ 目标本轮不参与甩牌询问,且整次出牌不计大小与分数;下一轮自动解除。 +

+
+ {(lureTigerDecision.eligibleTargetIds || []).map(targetPlayerId => { + const target = currentRoom.players.find(player => player.id === targetPlayerId); + if (!target) return null; + return ( + + ); + })} +
+
+ )} +
- return ( -
- {contextHolder} + { + socket.emit(SOCKET_EVENTS.RESPOND_FORBIDDEN_MAGIC, { + roomId: currentRoom.id, + accept: true + }); + setForbiddenMagicDecision(null); + }} + onCancel={() => { + socket.emit(SOCKET_EVENTS.RESPOND_FORBIDDEN_MAGIC, { + roomId: currentRoom.id, + accept: false + }); + setForbiddenMagicDecision(null); + }} + > +

+ 是否从第 {forbiddenMagicDecision?.round || ''} 轮起发动禁术秘法? +

+

+ 确认后技能将立即生效并持续到本局结束,不能撤销;暂不发动不会消耗机会,以后仍可再次预备。 + 同轮其他预备者会继续依次确认,不受你的选择影响。 +

+
- {/* 毙牌动画 */} - {trumpAnimation && ( -
-
- {trumpAnimation.type === 'overtrump' ? '盖毙!' : '毙了!'} - {trumpAnimation.playerName} -
-
- )} + { + socket.emit(SOCKET_EVENTS.RESPOND_REMOVE_FIREWOOD, { + roomId: currentRoom.id, + accept: true + }); + }} + onCancel={() => { + socket.emit(SOCKET_EVENTS.RESPOND_REMOVE_FIREWOOD, { + roomId: currentRoom.id, + accept: false + }); + }} + > +

+ {removeFirewoodDecision?.counteringPlayerName || ''} 反了你的主。 + 是否与其交换当前的全部手牌? +

+

+ 这是一次可选机会,并非必须发动;选择“不交换”不会改动双方手牌。 + 若本局发生多次反主,系统会继续按照由后向前的顺序逐项询问。 +

+
- {/* 主游戏区域 */} -
- {renderPhaseContent()} -
+ { + socket.emit(SOCKET_EVENTS.RESPOND_TIME_REVERSAL, { + roomId: currentRoom.id, + accept: true + }); + setTimeReversalDecision(null); + }} + onCancel={() => { + socket.emit(SOCKET_EVENTS.RESPOND_TIME_REVERSAL, { + roomId: currentRoom.id, + accept: false + }); + setTimeReversalDecision(null); + }} + > +

+ 是否回到第 {timeReversalDecision?.round || ''} 轮开始前,收回本轮四家的出牌并重新出牌? +

+

+ 选择保留结果不会消耗技能;多名玩家预备时,首个确认倒流的人发动成功。 +

+
{/* 埋底玩家选择弹窗 */} - {/* 查看我的底牌弹窗 */} + {/* 普通规则由庄家查看;人民公社只展示请求者自己埋下的两张牌。 */} setViewBottomModal(false)} onCancel={() => setViewBottomModal(false)} @@ -1492,45 +7604,28 @@ export default function GameBoard() { ]} >
- 已埋 {myBottomCards.length} 张底牌 -
-
+
+ + {isPeopleCommuneRule + ? `你埋下的 ${myBottomCards.length} 张牌仅自己可随时查看。` + : isOpenlyRevealedRule + ? `当前 ${myBottomCards.length} 张底牌始终明置,所有玩家均可随时查看。` + : `本局已埋 ${myBottomCards.length} 张底牌,仅庄家可随时查看。`} + + + {bottomCardsScore === null ? ( + <>底牌分数:? 分(按重载点数结算) + ) : ( + <>{isPeopleCommuneRule ? '这两张牌分数' : '底牌分数'}:{bottomCardsScore} 分 + )} + +
{myBottomCards.length > 0 && ( )}
- {/* 设置主牌弹窗 */} - setTrumpModal(false)} - footer={null} - > -
- 花色: -
- - - - - - - -
- 点数: -
- - {['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'].map(rank => ( - - ))} - -
-
- {/* 房间设置弹窗 */}
- 底牌数量: + Bot策略:
-
发牌间隔(毫秒): @@ -1742,9 +7839,485 @@ export default function GameBoard() { {/* 规则选择器弹窗 */} setRuleSelectorModal(false)} + rules={gameState?.ruleOptions || []} + selectionMode={gameState?.ruleSelectionMode || 'single'} + canChoose={isRuleChooser} + canRefresh={canRefreshDoubleHappiness} onRuleSelected={handleRuleSelected} + onRefreshRule={handleRefreshDoubleHappinessOption} + onClose={() => setRuleSelectorModal(false)} /> + + +
+ + 目标只对你可见。可以选 5、10、K;王和本局级牌不能选。 + +
+ {(waitingRabbitSelection?.eligibleSuits || []).map(suit => ( + + ))} +
+
+ {(waitingRabbitSelection?.eligibleRanks || []).map(rank => ( + + ))} +
+ +
+
+ + +
+ + {waitingRabbitDecision?.sourcePlayerName} 打出了你的目标牌 + {' '}{formatPublicCard(waitingRabbitDecision?.targetCard)}。选择一张非分牌后可将它换回手中。 + +
+ {waitingRabbitDecision?.targetCard && ( + + )} +
+ + 本轮胜负和分数已经结算,交换不会倒改本轮结果;被换走的分牌以后再次打出只比较大小,不再计分。 + +
+ {myCards + .filter(card => waitingRabbitDecision?.eligibleDiscardCardIds?.includes(card.id)) + .map(card => ( + setWaitingRabbitDiscardCardId(card.id)} + trumpSuit={trumpSuit} + trumpRank={trumpRank} + /> + ))} +
+ + + + +
+
+ + +
+ + 只有你能看到本次选择;该点数首次出牌后才会向所有玩家揭晓。 + +
+ {(tenSidedAmbushSelection?.eligibleRanks || []).map(rank => ( + + ))} +
+ + 级牌 {tenSidedAmbushSelection?.trumpRank} 以及分牌 5、10、K 不可选择。 + + +
+
+ + +
+ + 你选择的点数将成为 {threePowersSelection?.pointValue || 0} 分牌;首次出现前只有你能看到。 + +
+ {(threePowersSelection?.eligibleRanks || []).map(rank => ( + + ))} +
+ + 仅级牌 {threePowersSelection?.trumpRank} 与王牌不可选;5、10、K可以选择,也可以与其他玩家重复。 + + +
+
+ + +
+ + 以下有效花色均为最少的 {gentlemanPromiseSelection?.minimumCount ?? 0} 张;请选择其中一个公开声明。 + +
+ {(gentlemanPromiseSelection?.eligibleSuits || []).map(suit => ( + + ))} +
+ + 级牌、主花色牌和王统一计入“主”,不再计入牌面原花色。 + + +
+
+ + +
+ + 以下点数在手牌中均为最多的 {hiddenDragonSelection?.maximumCount ?? 0} 张;请选择其中一个公开声明。 + +
+ {(hiddenDragonSelection?.eligibleRanks || []).map(rank => ( + + ))} +
+ + 本局级牌 {hiddenDragonSelection?.trumpRank} 与王牌不参与统计;5、10、K若不是级牌,可以正常声明。 + + +
+
+ + +
+ + 请选择一个普通花色牌面。你的选择在所有待选玩家提交前不会公开。 + +
+ {(antinomySelection?.eligibleSuits || []).map(suit => ( + + ))} +
+
+ {(antinomySelection?.eligibleRanks || []).map(rank => ( + + ))} +
+ + 王不能指定;级牌仍可按其实体花色和点数指定。同一牌面被多人指定时,拆对效果取消。 + + +
+
+ + +
+

+ 请选择恰好{riceToMulberrySelection?.requiredCount || 0}张分牌。 + 副牌会变为同花色A,主牌会变为大王;改造后的实体牌永久计0分。 +

+
+ ( + riceToMulberrySelection?.eligibleCardIds?.includes(card.id) + ))} + selectedCards={selectedRiceToMulberryCardIds} + onCardClick={handleToggleRiceToMulberryCard} + small + trumpSuit={trumpSuit} + trumpRank={trumpRank} + minimumVisibleWidth={16} + /> +
+ + 已选择 {selectedRiceToMulberryCardIds.length}/{riceToMulberrySelection?.requiredCount || 0} + + +
+
+ + handleRespondDestroyDyke(false)}> + 本轮不发动 + , + + ]} + > +

+ 闲家赢得第{destroyDykeDecision?.round || ''}轮,本轮原本获得 + {destroyDykeDecision?.roundPoints || 0}分。 +

+ + 发动后这些分数暂时作废,接下来三轮进入灾期。灾期内闲家累计达到20分, + 或牌局提前结束,将返还作废分数并额外给予闲家20分。 + +
+ + +
+ + 本次选择会立即明告全桌。庄家与其队友都能共同推进这项审查条件。 + +
+ {(administrativeReviewSelection?.eligibleOptions || []).map(value => ( + + ))} +
+ + {administrativeReviewSelection?.type === 'suit' + ? '只能选择当前主花色以外的普通副花色。' + : '可选择2至A中的任意点数,包括本局级牌;王不属于点数。'} + + +
+
); } diff --git a/tractor-game-simulator/client/src/components/Game/GameTable.css b/tractor-game-simulator/client/src/components/Game/GameTable.css index 0642256..a04882c 100644 --- a/tractor-game-simulator/client/src/components/Game/GameTable.css +++ b/tractor-game-simulator/client/src/components/Game/GameTable.css @@ -1,304 +1,3261 @@ .game-table { + --felt-dark: #073f32; + --felt: #147052; + --felt-light: #29936a; width: 100%; height: 100%; + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: clamp(250px, 25vh, 300px) minmax(160px, 1fr) clamp(300px, 34vh, 415px); + position: relative; + isolation: isolate; + overflow: hidden; + padding: 18px 26px 14px; + border: 1px solid rgba(172, 238, 187, 0.36); + border-radius: 30px; + background: + radial-gradient(ellipse at 50% 48%, rgba(78, 177, 119, 0.38) 0%, transparent 48%), + radial-gradient(circle at 18% 16%, rgba(175, 231, 145, 0.16), transparent 26%), + repeating-linear-gradient(115deg, rgba(255, 255, 255, 0.014) 0 1px, transparent 1px 5px), + linear-gradient(145deg, var(--felt-light) 0%, var(--felt) 42%, var(--felt-dark) 100%); + box-shadow: + inset 0 0 0 7px rgba(5, 52, 40, 0.45), + inset 0 0 90px rgba(0, 24, 18, 0.56), + 0 22px 55px rgba(0, 20, 25, 0.45); +} + +.game-table::before { + content: ''; + position: absolute; + z-index: -1; + inset: 16px; + border: 1px solid rgba(173, 239, 194, 0.22); + border-radius: 22px; + box-shadow: inset 0 0 40px rgba(0, 31, 23, 0.28); + pointer-events: none; +} + +.game-table::after { + content: 'TRACTOR · 双升'; + position: absolute; + z-index: -1; + left: 50%; + top: 46%; + transform: translate(-50%, -50%); + color: rgba(215, 244, 218, 0.07); + font-size: clamp(38px, 5vw, 78px); + font-weight: 900; + letter-spacing: 0.16em; + white-space: nowrap; + text-shadow: 0 2px 0 rgba(0, 31, 24, 0.1); + pointer-events: none; +} + +.card-exchange-status { + position: absolute; + z-index: 80; + left: 50%; + top: 48%; display: flex; flex-direction: column; - position: relative; - background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); - border-radius: 12px; - padding: 20px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + align-items: center; + gap: 3px; + min-width: 250px; + padding: 10px 18px; + border: 1px solid rgba(255, 222, 113, 0.64); + border-radius: 999px; + color: rgba(255, 255, 255, 0.9); + background: linear-gradient(145deg, rgba(61, 45, 7, 0.94), rgba(23, 50, 34, 0.94)); + box-shadow: 0 10px 30px rgba(0, 25, 17, 0.34), inset 0 1px rgba(255, 255, 255, 0.1); + font-size: 13px; + transform: translate(-50%, -50%); + pointer-events: none; +} + +.card-exchange-status-title { + color: #ffdc6d; + font-size: 16px; + font-weight: 800; +} + +.card-exchange-animation-layer { + position: absolute; + z-index: 260; + inset: 0; + overflow: hidden; + border-radius: inherit; + pointer-events: none; +} + +.card-exchange-animation-layer::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(circle at center, rgba(255, 222, 102, 0.14), transparent 45%); + animation: exchangeLayerPulse 1.2s ease-out both; +} + +.card-exchange-animation-title { + position: absolute; + z-index: 2; + left: 50%; + top: 48%; + padding: 9px 22px; + border: 1px solid rgba(255, 226, 128, 0.7); + border-radius: 999px; + color: #fff2b5; + background: rgba(42, 37, 16, 0.9); + box-shadow: 0 8px 28px rgba(0, 22, 15, 0.36); + font-size: 18px; + font-weight: 900; + letter-spacing: 0.08em; + transform: translate(-50%, -50%); + animation: exchangeTitlePop 1.2s ease-out both; +} + +.planned-economy-draw-layer .card-exchange-animation-title { + top: 42%; + font-size: 16px; + letter-spacing: 0.04em; +} + +.exchange-flying-card { + --exchange-duration: 1200ms; + position: absolute; + left: var(--exchange-start-x); + top: var(--exchange-start-y); + width: 38px; + height: 54px; + border: 2px solid #f5df9a; + border-radius: 6px; + background: + linear-gradient(45deg, transparent 43%, rgba(255,255,255,0.24) 44% 56%, transparent 57%), + linear-gradient(-45deg, transparent 43%, rgba(255,255,255,0.18) 44% 56%, transparent 57%), + linear-gradient(145deg, #174f98, #0a2864); + background-size: 12px 12px, 12px 12px, 100% 100%; + box-shadow: 0 8px 18px rgba(0, 13, 31, 0.42), inset 0 0 0 3px rgba(238, 223, 158, 0.24); + opacity: 0; + transform: translate(-50%, -50%) rotate(var(--exchange-tilt)); + animation: exchangeCardTravel var(--exchange-duration) cubic-bezier(.33,.02,.23,1) var(--exchange-delay) both; +} + +@keyframes exchangeCardTravel { + 0% { + left: var(--exchange-start-x); + top: var(--exchange-start-y); + opacity: 0; + transform: translate(-50%, -50%) scale(0.72) rotate(var(--exchange-tilt)); + } + 12% { opacity: 1; } + 52% { + opacity: 1; + transform: translate(-50%, -72%) scale(1.16) rotate(0deg); + } + 88% { opacity: 1; } + 100% { + left: var(--exchange-end-x); + top: var(--exchange-end-y); + opacity: 0; + transform: translate(-50%, -50%) scale(0.76) rotate(var(--exchange-tilt)); + } +} + +@keyframes exchangeLayerPulse { + 0% { opacity: 0; } + 25% { opacity: 1; } + 100% { opacity: 0; } +} + +@keyframes exchangeTitlePop { + 0% { opacity: 0; transform: translate(-50%, -45%) scale(0.8); } + 18%, 72% { opacity: 1; transform: translate(-50%, -50%) scale(1); } + 100% { opacity: 0; transform: translate(-50%, -55%) scale(0.92); } +} + +@media (prefers-reduced-motion: reduce) { + .exchange-flying-card, + .card-exchange-animation-layer::before, + .card-exchange-animation-title, + .open-hand-panel, + .open-hand-mini-card { + animation-duration: 1ms !important; + animation-delay: 0ms !important; + } } -/* 上方玩家区域 */ .position-top { - flex: 0 0 auto; + position: relative; + grid-row: 1; + min-width: 0; + min-height: 0; + height: 100%; display: flex; flex-direction: column; - justify-content: center; + justify-content: flex-start; align-items: center; - margin-bottom: 10px; - gap: 8px; + gap: 18px; + padding-top: 6px; + box-sizing: border-box; + z-index: 2; } -/* 中间区域(左、中央、右) */ .position-middle { - flex: 1; - display: flex; - justify-content: space-between; + grid-row: 2; + min-width: 0; + min-height: 0; + height: 100%; + display: grid; + grid-template-columns: minmax(390px, 1fr) minmax(260px, 1fr) minmax(390px, 1fr); align-items: center; - gap: 30px; - min-height: 200px; + gap: 22px; + z-index: 2; } -.position-left { - flex: 0 0 auto; +.position-left, +.position-right { + position: relative; + width: 100%; + min-width: 0; display: flex; - flex-direction: row; - justify-content: flex-start; align-items: center; - gap: 8px; + gap: 18px; } -.table-center { - flex: 1; - display: flex; - justify-content: center; - align-items: center; - background: rgba(255, 255, 255, 0.05); - border-radius: 8px; - min-height: 150px; - max-width: 400px; +.position-left { justify-content: flex-start; } +.position-right { justify-content: flex-start; flex-direction: row-reverse; } + +/* 算无遗策:对家使用可操作的横向牌架,侧家使用分花色竖向牌架。 */ +.open-hand-panel { + position: relative; + z-index: 18; + min-width: 0; + overflow: hidden; + border: 1px solid rgba(255, 222, 112, 0.52); + border-radius: 14px; + background: linear-gradient(150deg, rgba(32, 61, 40, 0.96), rgba(11, 38, 31, 0.94)); + box-shadow: 0 10px 28px rgba(0, 24, 17, 0.34), inset 0 1px rgba(255,255,255,0.08); + animation: openHandReveal 0.58s cubic-bezier(.18,.78,.24,1) both; } -.position-right { - flex: 0 0 auto; +.open-hand-header { + height: 27px; display: flex; - flex-direction: row-reverse; - justify-content: flex-start; align-items: center; - gap: 8px; + gap: 6px; + box-sizing: border-box; + padding: 4px 8px; + color: rgba(255,255,255,0.82); + background: linear-gradient(90deg, rgba(128, 85, 15, 0.54), rgba(26, 65, 44, 0.2)); + font-size: 11px; + line-height: 1; +} + +.open-hand-seal, +.open-hand-avatar-badge { + display: inline-grid; + place-items: center; + color: #6b3d06; + background: linear-gradient(145deg, #fff0a0, #e7b935); + box-shadow: 0 2px 6px rgba(59, 32, 0, 0.34); + font-weight: 900; +} + +.open-hand-seal { width: 19px; height: 19px; flex: 0 0 19px; border-radius: 6px; } +.open-hand-title { overflow: hidden; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; } +.open-hand-count { margin-left: auto; color: #ffe583; font-variant-numeric: tabular-nums; } +.open-hand-control-hint { + padding: 3px 7px; + border: 1px solid rgba(255, 228, 129, 0.38); + border-radius: 999px; + color: #fff0a9; + background: rgba(117, 78, 5, 0.44); + font-weight: 800; +} + +.open-hand-avatar-badge { + position: absolute; + left: -7px; + bottom: -6px; + z-index: 5; + width: 22px; + height: 22px; + border: 1px solid #fff0a0; + border-radius: 50%; + font-size: 11px; +} + +.open-hand-opposite { + width: clamp(430px, 40vw, 620px); + max-width: calc(100vw - 520px); + flex: 0 0 105px; +} + +.open-hand-opposite-cards { height: 78px; padding: 0 8px; } +.open-hand-opposite-cards .hand { min-height: 75px; height: 75px; padding: 4px 3px; } +.open-hand-opposite.open-hand-kind-partial, +.open-hand-opposite.open-hand-kind-jokers { width: 230px; max-width: 36vw; } +.open-hand-kind-jokers { + border-color: rgba(255, 212, 87, 0.72); + background: linear-gradient(150deg, rgba(58, 54, 30, 0.97), rgba(13, 41, 32, 0.95)); +} +.open-hand-opposite .card.selected { transform: translateY(-8px); } +.open-hand-opposite.is-interactive { + border-color: #ffe378; + box-shadow: 0 0 0 3px rgba(255, 221, 100, 0.1), 0 0 27px rgba(255, 206, 65, 0.25); + animation: openHandReveal 0.58s cubic-bezier(.18,.78,.24,1) both, openHandControlPulse 1.9s ease-in-out 0.65s infinite; +} + +.open-hand-side { width: clamp(150px, 10vw, 168px); flex: 0 0 clamp(150px, 10vw, 168px); } +.open-hand-groups { display: flex; flex-direction: column; gap: 3px; padding: 5px 5px 7px; } +.open-hand-group { + min-width: 0; + min-height: 28px; + display: grid; + grid-template-columns: 17px minmax(0, 1fr); + align-items: start; + column-gap: 2px; +} +.open-hand-group-label { + width: 17px; + padding-top: 7px; + color: #f8dda0; + font-size: 11px; + font-weight: 900; + text-align: center; +} +.open-hand-group-hearts .open-hand-group-label, +.open-hand-group-diamonds .open-hand-group-label { color: #ff9a98; } +.open-hand-mini-cards { + min-width: 0; + display: grid; + grid-template-columns: repeat(auto-fill, 18px); + gap: 2px 1px; +} +.open-hand-mini-card { + width: 18px; + height: 28px; + position: relative; + animation: openHandCardFan 0.36s ease-out both; +} +.open-hand-mini-card:last-child { width: 18px; } +.open-hand-side .card.micro { + width: 18px; + height: 28px; + border-radius: 3px; + box-shadow: 0 1px 0 #aeb3ad, 0 2px 4px rgba(0, 21, 15, 0.22); +} +.open-hand-side .card.micro .card-corner.top-left { top: 3px; left: 3px; } +.open-hand-side .card.micro .card-rank { font-size: 9px; } +.open-hand-side .card.micro .card-suit { font-size: 8px; } + +.open-hand-self-status { + padding: 4px 9px; + border: 1px solid rgba(255, 222, 112, 0.42); + border-radius: 999px; + color: #ffe894; + background: rgba(106, 72, 10, 0.38); + font-size: 12px; + font-weight: 800; +} + +.position-top.has-open-hand { gap: 4px; } +.position-top.has-open-hand .played-cards-area { height: 72px; } +.position-top.has-open-hand .played-cards-area .hand { min-height: 72px; } +.position-left.has-open-hand, +.position-right.has-open-hand { gap: 7px; } +.position-left.has-open-hand .played-cards-area, +.position-right.has-open-hand .played-cards-area { width: clamp(138px, 12vw, 210px); } + +@keyframes openHandReveal { + from { opacity: 0; transform: translateY(-10px) scale(0.96); filter: blur(3px); } + to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); } +} +@keyframes openHandCardFan { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes openHandControlPulse { + 50% { box-shadow: 0 0 0 4px rgba(255, 221, 100, 0.06), 0 0 34px rgba(255, 206, 65, 0.38); } } -/* 下方玩家区域(自己) */ .position-bottom { - flex: 0 0 auto; + position: relative; + grid-row: 3; + min-width: 0; + min-height: 0; + height: 100%; + max-height: none; display: flex; flex-direction: column; + justify-content: flex-end; + align-items: center; + gap: 0; + padding: 0 12px 14px; + box-sizing: border-box; + z-index: 3; +} + +.table-center { + min-width: 0; + min-height: 150px; + display: flex; justify-content: center; align-items: center; - margin-top: 10px; - gap: 8px; + border-radius: 50%; + background: radial-gradient(ellipse, rgba(98, 192, 132, 0.12), transparent 68%); } -/* 玩家区域通用样式 */ -.player-area { - background: rgba(255, 255, 255, 0.1); - border-radius: 8px; - padding: 12px; +.round-points-indicator { + position: absolute; + left: 50%; + top: 44%; + z-index: 12; + display: flex; + align-items: baseline; + gap: 6px; + padding: 9px 18px 10px; + border: 1px solid rgba(255, 224, 117, 0.62); + border-radius: 999px; + color: #fff6c8; + background: linear-gradient(145deg, rgba(75, 49, 5, 0.9), rgba(37, 31, 13, 0.88)); + box-shadow: 0 8px 24px rgba(0, 24, 17, 0.3), inset 0 1px rgba(255, 255, 255, 0.12); backdrop-filter: blur(10px); - border: 2px solid transparent; - transition: all 0.3s ease; + transform: translate(-50%, -50%); + pointer-events: none; + animation: roundPointsIn 0.2s ease-out; } -.player-area.current-turn { - border-color: #ffd700; - box-shadow: 0 0 20px rgba(255, 215, 0, 0.5); - animation: pulse 2s ease-in-out infinite; +.round-points-label, +.round-points-unit { + color: rgba(255, 246, 200, 0.78); + font-size: 13px; + font-weight: 700; } -@keyframes pulse { - 0%, 100% { - box-shadow: 0 0 20px rgba(255, 215, 0, 0.5); - } - 50% { - box-shadow: 0 0 30px rgba(255, 215, 0, 0.8); - } +.round-points-value { + color: #ffd85c; + font-size: 30px; + font-variant-numeric: tabular-nums; + line-height: 1; + text-shadow: 0 2px 8px rgba(255, 190, 36, 0.28); } -.player-area.current-player { - background: rgba(255, 255, 255, 0.15); - border: 2px solid rgba(255, 255, 255, 0.3); +.table-center.has-public-bottom .round-points-indicator { + top: 37%; } -/* 玩家信息 */ -.player-info { - margin-bottom: 8px; - text-align: center; - color: white; +.public-bottom-tray { + position: absolute; + left: 50%; + top: 52%; + z-index: 11; + width: min(430px, 42vw); + padding: 9px 13px 8px; + border: 1px solid rgba(255, 217, 92, 0.66); + border-radius: 16px; + color: #fff7ca; + background: linear-gradient(145deg, rgba(74, 53, 10, 0.93), rgba(8, 60, 43, 0.94)); + box-shadow: 0 13px 34px rgba(0, 23, 17, 0.38), inset 0 1px rgba(255, 255, 255, 0.11); + transform: translate(-50%, -50%); + pointer-events: none; + animation: divineWeaponTrayDeal 420ms cubic-bezier(.2,.82,.25,1) both; } -.player-info .ant-typography { - color: white; +.public-bottom-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 0 4px 3px; } -/* 玩家牌区域 - 固定高度,防止出牌时布局变化 */ -.player-cards-area { - min-height: 120px; /* 固定最小高度 */ - display: flex; - flex-direction: column; +.public-bottom-heading strong { + color: #ffe177; + font-size: 14px; + white-space: nowrap; +} + +.public-bottom-heading span { + color: rgba(255, 248, 216, 0.72); + font-size: 11px; + white-space: nowrap; +} + +.public-bottom-tray .hand { + min-height: 84px; + padding: 7px 4px; + background: rgba(2, 40, 30, 0.36); +} + +.second-battlefield-tray { + position: absolute; + left: 50%; + top: 39%; + z-index: 11; + width: min(390px, 42vw); + display: grid; gap: 8px; + padding: 10px 13px 11px; + border: 1px solid rgba(113, 207, 255, 0.62); + border-radius: 15px; + color: #e8f8ff; + background: linear-gradient(145deg, rgba(8, 42, 68, 0.95), rgba(7, 61, 43, 0.95)); + box-shadow: 0 13px 32px rgba(0, 22, 34, 0.38), inset 0 1px rgba(255, 255, 255, 0.12); + transform: translate(-50%, -50%); + animation: divineWeaponTrayDeal 420ms cubic-bezier(.2,.82,.25,1) both; +} + +.second-battlefield-tray.is-final-stage { + border-color: rgba(255, 205, 92, 0.78); + background: linear-gradient(145deg, rgba(75, 43, 7, 0.96), rgba(8, 57, 43, 0.95)); } -/* 亮主区域 - 其他玩家的亮主在这里居中显示 */ -.declared-trump-zone { - min-height: 100px; +.second-battlefield-tray.is-settlement { + top: 28%; + transform: translate(-50%, -50%) scale(0.9); +} + +.second-battlefield-heading { display: flex; - align-items: center; + align-items: flex-start; justify-content: center; - width: 100%; } -/* 出牌区域 - 在玩家框和桌面中央之间,固定高度防止布局变化 */ -.played-cards-area { +.second-battlefield-heading > div:first-child { + display: grid; + gap: 2px; + text-align: center; +} + +.second-battlefield-heading strong { + color: #9ee5ff; + font-size: 14px; +} + +.second-battlefield-tray.is-final-stage .second-battlefield-heading strong { + color: #ffe28a; +} + +.second-battlefield-heading span, +.second-battlefield-last-result { + color: rgba(230, 248, 255, 0.72); + font-size: 9px; + line-height: 1.3; +} + +.second-battlefield-card-row { display: flex; - align-items: center; + align-items: flex-end; justify-content: center; - padding: 8px; - height: 130px; /* 固定高度,即使没有牌也占据空间 */ - flex-shrink: 0; /* 防止收缩 */ + gap: 7px; } -.shown-cards { +.second-battlefield-card-row .card { + animation: divineWeaponCardDeal 360ms cubic-bezier(.2,.82,.25,1) both; +} + +.second-battlefield-round-points { + display: flex; + align-items: baseline; + justify-content: center; + gap: 5px; + padding: 5px 10px 1px; + border-top: 1px solid rgba(150, 222, 255, 0.2); + color: rgba(230, 248, 255, 0.76); + font-size: 11px; + font-weight: 700; +} + +.second-battlefield-round-points strong { + color: #ffd85c; + font-size: 22px; + font-variant-numeric: tabular-nums; + line-height: 1; +} + +.second-battlefield-last-result { + padding-top: 5px; + border-top: 1px solid rgba(150, 222, 255, 0.18); + color: #d8f3ff; + text-align: center; +} + +.divine-weapon-tray { + position: absolute; + left: 50%; + top: 52%; + z-index: 11; + min-width: 174px; + display: grid; + grid-template-columns: 70px auto; + align-items: center; + gap: 9px; + padding: 8px 11px; + border: 1px solid rgba(255, 224, 112, 0.7); + border-radius: 14px; + color: #fff1ae; + background: linear-gradient(145deg, rgba(74, 51, 7, 0.93), rgba(7, 61, 43, 0.94)); + box-shadow: 0 12px 30px rgba(0, 24, 17, 0.35), inset 0 1px rgba(255, 255, 255, 0.12); + transform: translate(-50%, -50%); + animation: divineWeaponTrayDeal 420ms cubic-bezier(.2,.82,.25,1) both; +} + +.divine-weapon-tray-heading { display: flex; flex-direction: column; - gap: 4px; + gap: 3px; + text-align: left; } -.shown-cards .ant-typography { - color: white; - font-size: 12px; +.divine-weapon-tray-heading strong { + color: #ffe178; + font-size: 14px; + white-space: nowrap; } -/* 自己的手牌容器 - 包含亮主区和手牌区 */ -.my-hand-container { +.divine-weapon-tray-heading span { + color: rgba(255, 248, 216, 0.7); + font-size: 9px; + line-height: 1.25; +} + +.divine-weapon-card-row { display: flex; - align-items: flex-start; - gap: 16px; - width: 100%; - max-width: 100%; + align-items: flex-end; + gap: 7px; +} + +.divine-weapon-card-option { + position: relative; + width: 50px; + height: 70px; + border-radius: 7px; + transition: transform 150ms ease, filter 150ms ease; + animation: divineWeaponCardDeal 360ms cubic-bezier(.2,.82,.25,1) both; +} + +.divine-weapon-card-option.is-used .card { + box-shadow: 0 3px 0 #8c6720, 0 0 0 3px rgba(255, 196, 70, 0.72), 0 9px 18px rgba(0, 25, 18, 0.38); +} + +.divine-weapon-card-option.is-pending-refresh::after { + content: '待换'; + position: absolute; + right: -4px; + bottom: -5px; + z-index: 20; + padding: 2px 5px; + border-radius: 999px; + color: #fff7cf; + background: #9a5e0a; + font-size: 8px; + font-weight: 900; + line-height: 1.2; +} + +.divine-weapon-tray.is-selectable .divine-weapon-card-option { + cursor: pointer; +} + +.divine-weapon-tray.is-selectable .divine-weapon-card-option:hover { + transform: translateY(-4px); + filter: brightness(1.08); +} + +.divine-weapon-card-option .card.selected { + transform: translateY(-7px); + box-shadow: 0 3px 0 #9f7920, 0 9px 18px rgba(0, 25, 18, 0.38), 0 0 0 3px rgba(255, 225, 104, 0.5); +} + +.divine-weapon-tray.is-spent { + border-color: rgba(196, 205, 198, 0.32); + filter: saturate(0.55); +} + +@keyframes divineWeaponTrayDeal { + from { opacity: 0; transform: translate(-50%, -42%) scale(0.9); filter: blur(3px); } + to { opacity: 1; transform: translate(-50%, -50%) scale(1); filter: blur(0); } +} + +@keyframes divineWeaponCardDeal { + from { opacity: 0; transform: translateY(-12px) rotate(-3deg) scale(0.9); } + to { opacity: 1; transform: translateY(0) rotate(0) scale(1); } +} + +@keyframes roundPointsIn { + from { opacity: 0; filter: blur(2px); } + to { opacity: 1; filter: blur(0); } +} + +.center-content { + max-width: 460px; + padding: 14px 22px; + border: 1px solid rgba(203, 239, 207, 0.16); + border-radius: 18px; + background: rgba(5, 54, 40, 0.3); + box-shadow: 0 10px 35px rgba(0, 24, 17, 0.18); + backdrop-filter: blur(8px); +} + +.center-content.table-tools { + position: absolute; + top: 28px; + right: 28px; + z-index: 40; + width: clamp(320px, 23vw, 390px); + max-width: calc(100% - 56px); + max-height: calc(clamp(250px, 25vh, 300px) - 34px); + overflow-x: hidden; + overflow-y: auto; box-sizing: border-box; - justify-content: center; /* 默认居中 */ + padding: 12px 16px; + border-radius: 17px; + background: linear-gradient(145deg, rgba(4, 61, 46, 0.9), rgba(4, 47, 38, 0.88)); } -/* 当没有亮主时,手牌区域居中 */ -.my-hand-container > .my-hand:only-child { - margin: 0 auto; +.settlement-table-center { + align-self: stretch; + min-height: 0; + align-items: center; + overflow: visible; + padding: 6px 0 10px; } -/* 自己的亮主区域 - 在手牌左侧 */ -.bottom-trump-zone { - flex: 0 0 auto; - min-width: 100px; - max-width: 180px; - min-height: 120px; +.center-content.settlement-panel { + width: min(520px, 100%); + max-width: 520px; + min-height: 0; + max-height: none; + overflow: visible; + box-sizing: border-box; + padding: 10px 14px 12px; + border-radius: 22px; + background: linear-gradient(180deg, rgba(4, 61, 45, 0.92), rgba(4, 45, 37, 0.96)); + box-shadow: 0 18px 48px rgba(0, 24, 18, 0.38), inset 0 1px rgba(255, 255, 255, 0.08); +} + +.settlement-content { + min-width: 0; display: flex; flex-direction: column; - align-items: center; - justify-content: flex-start; - padding: 8px; + gap: 7px; +} + +.settlement-bottom-title { + font-size: 16px !important; + margin-bottom: 0 !important; + line-height: 1.2 !important; +} + +.settlement-content > .settlement-bottom-result, +.settlement-content > .settlement-upgrade-result { + min-width: 0; box-sizing: border-box; + margin-top: 0 !important; + padding: 7px 12px !important; } -/* 当有亮主时,手牌区域占用剩余空间 */ -.bottom-trump-zone ~ .my-hand { - flex: 1 1 auto; - margin: 0; +.settlement-content > .settlement-bottom-result > span:first-child, +.settlement-content > .settlement-upgrade-result > span:first-child { + margin-bottom: 4px !important; + font-size: 18px !important; + line-height: 1.25 !important; } -/* 底部玩家区域的头部(玩家信息 + 控制按钮) */ -.bottom-player-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 16px; - margin-bottom: 10px; +.settlement-bottom-result .ten-sided-ambush-bottom-summary { + gap: 1px; } -.bottom-player-header .player-info { - flex: 0 0 auto; - text-align: left; - margin-bottom: 0; +.settlement-bottom-result .ant-typography { + font-size: 12px !important; + line-height: 1.35 !important; } -/* 控制区域 - 右侧对齐 */ -.inline-controls { - flex: 0 1 auto; - max-width: 650px; - background: rgba(255, 255, 255, 0.15); - padding: 12px; - border-radius: 8px; - backdrop-filter: blur(10px); - margin-left: auto; +.settlement-bottom-result > div:last-child { + margin-top: 4px !important; } -/* 自己的手牌区域 - 居中显示 */ -.my-hand { - flex: 1 1 auto; - padding: 10px 20px; - background: rgba(0, 0, 0, 0.2); +.settlement-bottom-result > div:last-child .ant-typography { + font-size: 14px !important; +} + +.focus-figure-settlement { + display: flex; + flex-direction: column; + gap: 5px; + margin-top: 6px; + padding: 7px 9px; + border: 1px solid rgba(255, 215, 96, 0.52); border-radius: 8px; - min-height: 120px; + background: rgba(2, 37, 30, 0.46); + color: #f8fff9; +} + +.focus-figure-settlement-title { display: flex; - justify-content: center; - min-width: 0; /* 允许flex收缩 */ - box-sizing: border-box; + align-items: baseline; + justify-content: space-between; + gap: 8px; + font-size: 11px; + color: rgba(244, 255, 247, 0.78); } -/* 不同位置的样式调整 - 固定尺寸防止布局变化 */ -.player-top { - width: 500px; - max-width: 600px; - min-width: 400px; - /* 不设置固定高度,让内容自然撑开 */ +.focus-figure-settlement-title strong { + flex: 0 0 auto; + color: #ffe27a; + font-size: 13px; } -.player-left, -.player-right { - width: 350px; - min-width: 350px; - /* 不设置固定高度,让内容自然撑开 */ +.focus-figure-player-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3px 8px; } -.player-bottom { - width: 100%; - max-width: 1400px; /* 增加最大宽度 */ - /* bottom不设置固定高度,因为包含手牌区域 */ +.focus-figure-player-grid > div { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 0; + padding: 3px 5px; + border-radius: 5px; + background: rgba(255, 255, 255, 0.055); + font-size: 11px; } -/* 响应式调整 */ -@media (max-width: 1200px) { - .position-left, - .position-right { - flex: 0 0 150px; - } +.focus-figure-player-grid > div > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} - .player-left, - .player-right { - width: 140px; - } +.focus-figure-player-grid > div > strong { + flex: 0 0 auto; + margin-left: 5px; + color: rgba(255, 255, 255, 0.52); } -@media (max-width: 768px) { - .game-table { - height: calc(100vh - 150px); - padding: 10px; - } +.focus-figure-player-grid > .is-focus { + background: rgba(255, 205, 63, 0.15); + color: #fff0a6; +} - .position-middle { - gap: 10px; - } +.focus-figure-player-grid > .is-focus > strong { + color: #ffd75d; +} - .player-top, - .player-left, - .player-right { - width: auto; - font-size: 12px; - } +.focus-figure-score-formula { + display: flex; + justify-content: center; + gap: 18px; + color: #fff4bf; + font-size: 11px; } -/* 亮主条wrapper */ -.trump-declaration-wrapper { - margin: 12px 0; - padding: 0; +.settlement-team-levels { + margin-bottom: 6px !important; + padding-bottom: 6px !important; } +.settlement-team-levels .ant-typography, +.settlement-next-dealer .ant-typography { + line-height: 1.3 !important; +} -/* 自定义滚动条样式(用于闲家得分框) */ -.game-table ::-webkit-scrollbar { - height: 6px; +.settlement-team-levels > div > .ant-typography:first-child, +.settlement-next-dealer > .ant-typography:first-child { + font-size: 11px !important; } -.game-table ::-webkit-scrollbar-track { - background: rgba(0, 0, 0, 0.3); - border-radius: 3px; +.settlement-team-levels > div > .ant-typography:nth-child(2), +.settlement-next-dealer > .ant-typography:last-child { + font-size: 14px !important; } -.game-table ::-webkit-scrollbar-thumb { - background: rgba(255, 215, 0, 0.6); - border-radius: 3px; +.settlement-team-levels > div > .ant-typography:nth-child(2) strong { + font-size: 16px !important; +} + +.settlement-team-levels > div > .ant-typography:last-child { + font-size: 12px !important; +} + +.settlement-bottom-cards { + width: min(330px, 100%); + height: 78px; + margin: 0 auto; + overflow: visible; +} + +.settlement-bottom-cards .hand { + min-height: 96px; + padding: 2px; + transform: scale(0.78); + transform-origin: top center; +} + +.settlement-misty-fog { + min-width: 0; + height: 76px; + display: grid; + grid-template-columns: 132px minmax(0, 1fr); + align-items: center; + box-sizing: border-box; + padding: 5px 9px; + overflow: hidden; + border: 1px solid rgba(255, 214, 102, 0.7); + border-radius: 8px; + background: linear-gradient(135deg, rgba(77, 63, 20, 0.52), rgba(8, 58, 45, 0.78)); + animation: misty-fog-reveal 420ms cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +.settlement-lingering-discard { + border-color: rgba(255, 214, 102, 0.7); + background: linear-gradient(100deg, rgba(74, 55, 10, 0.82), rgba(8, 70, 49, 0.94)); +} + +@keyframes misty-fog-reveal { + from { + opacity: 0; + transform: translateY(-7px) scale(0.985); + filter: blur(3px); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0); + } +} + +.settlement-misty-fog-summary { + min-width: 0; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px 6px; + text-align: left; +} + +.settlement-misty-fog-summary .ant-typography { + margin: 0; + color: rgba(255, 255, 255, 0.86); + font-size: 11px; + line-height: 1.25; + white-space: nowrap; +} + +.settlement-misty-fog-summary .ant-typography:first-child { + grid-column: 1 / -1; + color: #ffe58f; + font-size: 12px; +} + +.settlement-misty-fog-summary .ant-typography:nth-child(3), +.settlement-misty-fog-summary .ant-typography:last-child { + color: #ffd666; +} + +.settlement-misty-fog-cards { + min-width: 0; + height: 66px; + overflow: visible; +} + +.settlement-misty-fog-cards .hand { + width: 222px; + max-width: none; + min-height: 82px; + margin: 0 auto; + padding: 2px; + left: -24px; + transform: scale(0.65); + transform-origin: top center; +} + +@media (prefers-reduced-motion: reduce) { + .settlement-misty-fog { animation: none; } +} + +.player-area { + position: relative; + padding: 7px 12px; + border: 1px solid rgba(214, 245, 222, 0.18); + border-radius: 18px; + background: linear-gradient(145deg, rgba(3, 47, 39, 0.82), rgba(9, 71, 53, 0.62)); + box-shadow: 0 8px 25px rgba(0, 22, 18, 0.28); + backdrop-filter: blur(10px); + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; +} + +.player-area.current-turn { + border-color: #f5d76e; + box-shadow: 0 0 0 3px rgba(245, 215, 110, 0.14), 0 0 26px rgba(255, 215, 88, 0.38); + animation: turnGlow 1.8s ease-in-out infinite; +} + +.player-area.skill-targetable { + cursor: pointer; + border-color: rgba(255, 215, 0, 0.82); + animation: skillTargetPulse 1.15s ease-in-out infinite; +} + +.player-area.skill-targetable:hover, +.player-area.skill-targetable:focus-visible, +.player-area.skill-target-selected { + outline: 3px solid rgba(255, 215, 0, 0.72); + outline-offset: 4px; + filter: brightness(1.14); +} + +@keyframes skillTargetPulse { + 50% { box-shadow: 0 0 22px rgba(255, 215, 0, 0.52); } +} + +@keyframes turnGlow { + 50% { box-shadow: 0 0 0 5px rgba(245, 215, 110, 0.08), 0 0 34px rgba(255, 215, 88, 0.52); } +} + +.player-area.current-player { + border-color: rgba(173, 231, 185, 0.32); + background: linear-gradient(180deg, rgba(4, 49, 40, 0.9), rgba(5, 58, 43, 0.74)); +} + +.player-avatar { + width: 34px; + height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 34px; + border: 2px solid rgba(255, 232, 146, 0.8); + border-radius: 11px; + color: #fff4c4; + background: linear-gradient(145deg, #dc9641, #7b461f); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.35), 0 4px 10px rgba(0, 0, 0, 0.28); + font-size: 12px; + font-weight: 900; +} + +.player-avatar-wrap { + position: relative; + display: inline-flex; + flex: 0 0 auto; + line-height: 1; +} + +.dealer-badge { + position: absolute; + right: -7px; + bottom: -6px; + z-index: 4; + width: 22px; + height: 22px; + display: grid; + place-items: center; + border: 1px solid #fff09a; + border-radius: 50%; + color: #784209; + background: linear-gradient(145deg, #ffe45d, #efad17); + box-shadow: 0 2px 5px rgba(60, 33, 0, 0.38); + font-size: 11px; + font-weight: 900; +} + +.secondary-burying-badge { + position: absolute; + left: -7px; + bottom: -6px; + z-index: 4; + width: 22px; + height: 22px; + display: grid; + place-items: center; + border: 1px solid #a9f2bd; + border-radius: 50%; + color: #073d25; + background: linear-gradient(145deg, #b9f6ca, #55c97a); + box-shadow: 0 2px 5px rgba(0, 45, 25, 0.4); + font-size: 11px; + font-weight: 900; +} + +.secondary-burying-self-status { + padding: 2px 8px; + border: 1px solid rgba(163, 241, 184, 0.72); + border-radius: 999px; + color: #c9fbd5; + background: rgba(31, 117, 67, 0.5); + font-size: 12px; + font-weight: 800; +} + +.ready-badge { + position: absolute; + right: -4px; + top: -5px; + z-index: 5; + width: 17px; + height: 17px; + display: grid; + place-items: center; + box-sizing: border-box; + border: 1px solid rgba(255, 255, 255, 0.82); + border-radius: 50%; + font-size: 11px; + font-weight: 900; + line-height: 1; + box-shadow: 0 2px 5px rgba(0, 25, 18, 0.35); +} + +.ready-badge.is-ready { + color: #effff3; + background: #35a853; +} + +.ready-badge.not-ready { + color: #f3f3f3; + background: #758078; +} + +.player-name { + min-width: 0; + white-space: nowrap; +} + +.player-avatar-self { + border-color: #98e4ae; + color: #dfffe8; + background: linear-gradient(145deg, #3b9c64, #15513b); +} + +.player-info { + margin: 0; + text-align: center; + color: white; + line-height: 1.35; +} + +.player-info .ant-typography { color: rgba(244, 255, 247, 0.96); } +.player-info .ant-typography-secondary { color: rgba(219, 238, 224, 0.66) !important; } + +.strive-upstream-order-badge { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 26px; + height: 26px; + padding: 0; + border: 1px solid rgba(255, 229, 137, 0.75); + border-radius: 999px; + background: linear-gradient(145deg, rgba(93, 70, 17, 0.96), rgba(170, 128, 30, 0.96)); + box-shadow: 0 0 10px rgba(245, 200, 75, 0.25); + color: #fff1aa; + font-size: 14px; + font-weight: 900; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.strive-upstream-order-badge.order-2 { + border-color: rgba(220, 232, 229, 0.72); + background: linear-gradient(145deg, rgba(60, 82, 77, 0.96), rgba(109, 136, 129, 0.96)); + box-shadow: 0 0 9px rgba(204, 226, 219, 0.2); + color: #f0faf7; +} + +.strive-upstream-order-badge.order-3 { + border-color: rgba(226, 169, 107, 0.72); + background: linear-gradient(145deg, rgba(91, 50, 24, 0.96), rgba(151, 86, 42, 0.96)); + box-shadow: 0 0 9px rgba(207, 128, 65, 0.2); + color: #ffe0bd; +} + +.strive-upstream-order-badge.order-4 { + border-color: rgba(127, 190, 156, 0.62); + background: linear-gradient(145deg, rgba(23, 70, 53, 0.96), rgba(36, 101, 74, 0.96)); + box-shadow: none; + color: #d9f6e7; +} + +.defense-as-offense-player-badge { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + min-width: 30px; + height: 22px; + padding: 0 7px; + border: 1px solid rgba(135, 225, 171, 0.84); + border-radius: 999px; + background: linear-gradient(145deg, rgba(22, 92, 60, 0.96), rgba(43, 150, 91, 0.96)); + box-shadow: 0 0 10px rgba(80, 220, 137, 0.25); + color: #e3ffec; + font-size: 11px; + font-weight: 900; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.focus-figure-player-badge { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + min-width: 34px; + height: 21px; + padding: 0 7px; + border: 1px solid rgba(255, 222, 103, 0.9); + border-radius: 999px; + background: linear-gradient(145deg, #7a5b0b, #d4a72c); + box-shadow: 0 0 10px rgba(255, 210, 64, 0.32); + color: #fff4b8; + font-size: 11px; + font-weight: 800; + line-height: 1; + letter-spacing: 0.04em; +} + +.dream-killing-player-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 34px; + height: 21px; + padding: 0 7px; + border: 1px solid rgba(137, 201, 255, 0.72); + border-radius: 999px; + color: #dff3ff; + background: linear-gradient(145deg, #173b5d, #285d7d); + box-shadow: 0 0 11px rgba(85, 183, 255, 0.28); + font-size: 11px; + font-weight: 800; +} + +.teammate-cheer-player-badge { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + min-width: 46px; + height: 21px; + padding: 0 7px; + border: 1px solid rgba(255, 220, 102, 0.86); + border-radius: 999px; + color: #fff4b6; + background: linear-gradient(145deg, #9c6b0a, #d5a82c); + box-shadow: 0 0 11px rgba(255, 196, 48, 0.3); + font-size: 10px; + font-weight: 900; + line-height: 1; +} + +.afterglow-player-badge { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + min-width: 46px; + height: 21px; + padding: 0 7px; + border: 1px solid rgba(255, 163, 112, 0.88); + border-radius: 999px; + color: #fff0d8; + background: linear-gradient(145deg, #933716, #d9752b); + box-shadow: 0 0 11px rgba(255, 108, 52, 0.3); + font-size: 10px; + font-weight: 900; + line-height: 1; +} + +.lure-tiger-player-badge { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + min-width: 38px; + height: 21px; + padding: 0 7px; + border: 1px solid rgba(177, 189, 204, 0.82); + border-radius: 999px; + color: #eef3f7; + background: linear-gradient(145deg, #394451, #66717d); + box-shadow: 0 0 11px rgba(162, 176, 190, 0.24); + font-size: 10px; + font-weight: 900; + line-height: 1; +} + +.dream-killing-self-status { + border-color: rgba(123, 200, 255, 0.62); + color: #dff4ff; + background: rgba(19, 65, 89, 0.72); +} + +.focus-figure-captured-points { + width: max-content; + max-width: 100%; + margin: 4px auto 0; + padding: 2px 8px; + border: 1px solid rgba(231, 198, 78, 0.38); + border-radius: 999px; + background: rgba(40, 30, 4, 0.46); + color: #f4df88; + font-size: 11px; + font-weight: 700; + line-height: 1.35; + white-space: nowrap; +} + +.focus-figure-captured-points-self { + margin: 0 0 0 2px; +} + +.player-cards-area { + min-height: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.declared-trump-zone:empty { display: none; } +.declared-trump-zone { display: flex; justify-content: center; gap: 4px; } +.inferior-declaration-zone { + position: relative; + border: 1px solid rgba(231, 166, 139, 0.48); + border-radius: 8px; + background: rgba(112, 52, 40, 0.2); +} + +.three-six-nine-summary { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 22px; + padding: 2px 10px; + border: 1px solid rgba(231, 166, 139, 0.34); + border-radius: 999px; + background: rgba(112, 52, 40, 0.2); +} + +.shown-cards { + display: flex; + flex-direction: column; + gap: 3px; +} + +.shown-cards .ant-typography { color: white; font-size: 11px; } + +.played-cards-area { + position: relative; + width: clamp(100px, 12vw, 220px); + min-width: 0; + height: clamp(84px, 12vh, 116px); + display: flex; + align-items: center; + justify-content: center; + padding: 0; + flex-shrink: 0; + filter: drop-shadow(0 9px 8px rgba(0, 28, 20, 0.32)); +} + +.played-cards-area.is-empty { + visibility: hidden; + filter: none; +} + +.position-top .played-cards-area, +.position-bottom .played-cards-area { + width: clamp(150px, 24vw, 300px); +} + +/* 左右相邻玩家多张出牌时只保留完整点数所需宽度,避免牌列压向桌面中央。 */ +.position-left .played-cards-area, +.position-right .played-cards-area { + width: clamp(280px, 18vw, 300px); +} + +.position-bottom .played-cards-area { + margin-bottom: 22px; +} + +.played-cards-area .hand { + position: relative; + z-index: 3; + min-height: 112px; + padding: 0; + overflow: visible; +} + +/* 毙牌 / 盖毙:参考欢乐升级的局部“双火箭砸牌”反馈。 */ +.trump-strike-impact { + position: absolute; + z-index: 9; + left: 50%; + top: 48%; + width: 104px; + height: 104px; + transform: translate(-50%, -50%); + pointer-events: none; +} + +.trump-strike-smoke, +.trump-strike-shockwave, +.trump-strike-flare { + position: absolute; + left: 50%; + top: 58%; + border-radius: 50%; + opacity: 0; + pointer-events: none; +} + +.trump-strike-smoke { + z-index: 1; + width: 86px; + height: 50px; + background: + radial-gradient(ellipse at center, rgba(0, 11, 8, 0.88) 0 14%, rgba(2, 17, 12, 0.66) 25%, rgba(9, 30, 22, 0.34) 45%, transparent 72%), + repeating-conic-gradient(from 7deg, rgba(4, 17, 12, 0.48) 0 9deg, transparent 9deg 22deg); + filter: blur(2.5px); + transform: translate(-50%, -50%) scale(0.18); + animation: trumpStrikeSmoke 760ms cubic-bezier(.2,.68,.2,1) 300ms both; +} + +.trump-strike-shockwave { + z-index: 2; + width: 26px; + height: 26px; + border: 3px solid rgba(255, 199, 68, 0.9); + box-shadow: 0 0 12px rgba(255, 111, 36, 0.8), inset 0 0 8px rgba(255, 231, 128, 0.72); + transform: translate(-50%, -50%) scale(0.2); + animation: trumpStrikeWave 560ms ease-out 340ms both; +} + +.trump-strike-flare { + z-index: 7; + width: 68px; + height: 68px; + background: + repeating-conic-gradient(from 8deg, rgba(255, 218, 95, 0.98) 0 3deg, transparent 3deg 23deg), + radial-gradient(circle, rgba(255, 241, 171, 0.96), rgba(255, 111, 32, 0.5) 22%, transparent 57%); + filter: drop-shadow(0 0 7px rgba(255, 91, 30, 0.76)); + transform: translate(-50%, -50%) scale(0.16) rotate(-8deg); + animation: trumpStrikeFlare 430ms ease-out 330ms both; +} + +.trump-strike-dart { + position: absolute; + z-index: 10; + left: 50%; + top: 8px; + width: 17px; + height: 47px; + border-radius: 55% 55% 38% 38%; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.26), transparent 23% 67%, rgba(92, 4, 13, 0.34)), + linear-gradient(180deg, #ff5360 0 12%, #e71931 36%, #a80720 77%, #f5ba40 78% 88%, #7b171d 89%); + box-shadow: + inset 0 0 0 1px rgba(102, 4, 20, 0.42), + 0 4px 5px rgba(0, 14, 10, 0.42), + 0 0 8px rgba(255, 40, 56, 0.34); + clip-path: polygon(50% 100%, 13% 78%, 18% 19%, 34% 5%, 50% 0, 66% 5%, 82% 19%, 87% 78%); + opacity: 0; + transform-origin: 50% 100%; + animation: trumpStrikeDart 720ms cubic-bezier(.18,.68,.2,1) both; +} + +.trump-strike-dart::before, +.trump-strike-dart::after { + content: ''; + position: absolute; + top: 9px; + width: 8px; + height: 18px; + background: linear-gradient(180deg, #ff3046, #94051c); + filter: drop-shadow(0 1px rgba(255, 189, 80, 0.35)); +} + +.trump-strike-dart::before { + right: 10px; + transform: rotate(-25deg); +} + +.trump-strike-dart::after { + left: 10px; + transform: rotate(25deg); +} + +.trump-strike-dart.dart-left { + margin-left: -22px; + transform: translate(-50%, -92px) rotate(-8deg) scale(0.74); +} + +.trump-strike-dart.dart-right { + margin-left: 20px; + animation-delay: 72ms; + transform: translate(-50%, -92px) rotate(7deg) scale(0.74); +} + +.trump-strike-impact.overtrump .trump-strike-dart { + filter: saturate(1.16) brightness(1.06); +} + +.trump-strike-impact.overtrump .trump-strike-shockwave { + border-color: rgba(255, 230, 111, 0.96); + box-shadow: 0 0 16px rgba(255, 72, 32, 0.92), inset 0 0 10px rgba(255, 243, 162, 0.88); +} + +.trump-action-callout { + position: absolute; + z-index: 14; + left: 50%; + bottom: -13px; + min-width: 74px; + color: #ffd34e; + text-align: center; + filter: drop-shadow(0 5px 5px rgba(24, 9, 0, 0.52)); + transform: translateX(-50%) rotate(-3deg); + transform-origin: 50% 80%; + pointer-events: none; + animation: trumpActionCallout 1.6s cubic-bezier(.17,.75,.22,1) both; +} + +.trump-action-callout strong { + position: relative; + z-index: 2; + display: block; + padding: 0 5px 3px; + color: #ffd84e; + font-family: "Microsoft YaHei UI", "PingFang SC", sans-serif; + font-size: clamp(25px, 2.25vw, 38px); + font-weight: 1000; + font-style: italic; + letter-spacing: -0.09em; + line-height: 1; + white-space: nowrap; + -webkit-text-stroke: 1.2px #7a2b00; + text-shadow: + 0 2px 0 #b25800, + 0 4px 0 #6e2600, + 2px 5px 2px rgba(35, 11, 0, 0.58), + 0 0 8px rgba(255, 212, 68, 0.42); +} + +.trump-action-callout > span { + position: absolute; + z-index: 1; + left: 4px; + right: -6px; + bottom: -1px; + height: 12px; + border-radius: 50%; + background: linear-gradient(90deg, transparent, #9b3500 15% 72%, transparent); + opacity: 0.9; + transform: skewX(-24deg) rotate(-4deg); +} + +.trump-action-callout.overtrump { + min-width: 88px; + transform: translateX(-50%) rotate(2deg); +} + +.trump-action-callout.overtrump strong { + color: #ffe060; + font-size: clamp(28px, 2.5vw, 42px); + text-shadow: + 0 2px 0 #d06900, + 0 4px 0 #7d2500, + 2px 6px 3px rgba(45, 8, 0, 0.66), + 0 0 11px rgba(255, 128, 42, 0.62); +} + +@keyframes trumpStrikeDart { + 0% { + opacity: 0; + transform: translate(-50%, -92px) rotate(-9deg) scale(0.7); + } + 13% { opacity: 1; } + 54% { + opacity: 1; + transform: translate(-50%, -5px) rotate(1deg) scale(1.06); + } + 64% { + opacity: 1; + transform: translate(-50%, 8px) rotate(0deg) scale(0.94, 0.82); + } + 76% { + opacity: 1; + transform: translate(-50%, -3px) rotate(-2deg) scale(1); + } + 100% { + opacity: 0; + transform: translate(-50%, 5px) rotate(1deg) scale(0.92); + } +} + +@keyframes trumpStrikeSmoke { + 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.16); } + 18% { opacity: 0.94; } + 58% { opacity: 0.7; transform: translate(-50%, -50%) scale(1); } + 100% { opacity: 0; transform: translate(-50%, -60%) scale(1.28); filter: blur(5px); } +} + +@keyframes trumpStrikeWave { + 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.16); } + 16% { opacity: 1; } + 100% { opacity: 0; transform: translate(-50%, -50%) scale(2.45); } +} + +@keyframes trumpStrikeFlare { + 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.16) rotate(-8deg); } + 22% { opacity: 1; } + 100% { opacity: 0; transform: translate(-50%, -50%) scale(1.22) rotate(12deg); } +} + +@keyframes trumpActionCallout { + 0% { opacity: 0; transform: translate(-50%, 11px) rotate(-8deg) scale(0.25); } + 14% { opacity: 1; transform: translate(-50%, -4px) rotate(3deg) scale(1.28); } + 24% { transform: translate(-50%, 0) rotate(-3deg) scale(0.94); } + 34%, 76% { opacity: 1; transform: translate(-50%, 0) rotate(-3deg) scale(1); } + 100% { opacity: 0; transform: translate(-50%, -8px) rotate(-1deg) scale(0.94); } +} + +@media (prefers-reduced-motion: reduce) { + .trump-strike-smoke, + .trump-strike-shockwave, + .trump-strike-flare, + .trump-strike-dart, + .trump-action-callout { + animation: none !important; + } +} + +.ambiguous-play-options { + position: relative; + z-index: 4; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + width: min(360px, 27vw); +} + +.ambiguous-play-option { + position: relative; + min-width: 0; + padding: 10px 3px 1px; + border: 1px solid rgba(255, 220, 91, .55); + border-radius: 9px; + background: rgba(8, 61, 45, .88); + box-shadow: 0 5px 14px rgba(0, 28, 20, .3); +} + +.ambiguous-play-option.is-selected { + border-color: #ffe16a; + box-shadow: 0 0 0 2px rgba(255, 215, 62, .28), 0 5px 14px rgba(0, 28, 20, .3); +} + +.ambiguous-play-option-label { + position: absolute; + top: -10px; + left: 7px; + z-index: 8; + display: grid; + place-items: center; + width: 22px; + height: 22px; + color: #173d30; + background: #ffdb58; + border-radius: 999px; + font-size: 12px; + font-weight: 900; +} + +.played-cards-area .ambiguous-play-option .hand.small { + min-height: 76px; + padding: 3px; +} + +.position-left .ambiguous-play-options, +.position-right .ambiguous-play-options { + width: min(330px, 25vw); +} + +.second-battlefield-staged-cards { + position: absolute; + z-index: 16; + display: flex; + align-items: flex-start; + gap: 7px; + width: max-content; + max-width: min(430px, 38vw); + min-height: 80px; + padding: 5px 9px 6px 7px; + border: 1px solid rgba(116, 210, 255, 0.58); + border-radius: 11px; + background: linear-gradient(145deg, rgba(8, 42, 67, 0.94), rgba(8, 61, 43, 0.94)); + box-shadow: 0 8px 20px rgba(0, 23, 31, 0.38), inset 0 1px rgba(255, 255, 255, 0.1); + pointer-events: none; +} + +.second-battlefield-staged-top { + left: 22%; + bottom: 0; + transform: translateX(-50%); +} + +.second-battlefield-staged-bottom { + left: 72%; + top: -28px; + transform: translateX(-50%); +} + +.second-battlefield-side-player { + position: relative; + flex: 0 0 auto; +} + +.second-battlefield-side-player .second-battlefield-staged-cards { + top: calc(100% + 8px); +} + +.position-left .second-battlefield-staged-left { + left: 0; +} + +.position-right .second-battlefield-staged-right { + right: 0; +} + +.second-battlefield-staged-label { + flex: 0 0 auto; + color: #9ee5ff; + font-size: 11px; + font-weight: 900; + line-height: 1.15; + writing-mode: vertical-rl; +} + +.second-battlefield-staged-card-row { + min-width: 0; + display: flex; + align-items: center; + overflow: visible; +} + +.second-battlefield-staged-card { + position: relative; + z-index: 1; + width: 30px; + height: 70px; + flex: 0 0 30px; +} + +.second-battlefield-staged-card:last-child { + width: 50px; + flex-basis: 50px; +} + +.played-cards-area.treated-as-small { + filter: drop-shadow(0 9px 8px rgba(0, 28, 20, 0.32)) saturate(0.82); +} + +.played-cards-area.enduring-inherited { + filter: drop-shadow(0 8px 8px rgba(0, 28, 20, 0.32)) drop-shadow(0 0 8px rgba(255, 210, 74, 0.48)); +} + +.played-cards-area.enduring-inherited > .hand .card { + border-color: #e8bd3d; + box-shadow: 0 3px 0 #98701b, 0 7px 13px rgba(0, 21, 15, 0.28), 0 0 0 2px rgba(255, 220, 94, 0.3); + animation: enduringCardAwaken 0.62s ease-out both; +} + +.enduring-inheritance-ribbon { + position: absolute; + z-index: 44; + top: -34px; + left: 50%; + min-width: 128px; + max-width: 218px; + height: 34px; + box-sizing: border-box; + display: flex; + align-items: center; + gap: 5px; + padding: 3px 7px 4px 4px; + transform: translateX(-50%); + border: 1px solid rgba(245, 204, 70, 0.78); + border-radius: 999px; + color: #fff4bf; + background: linear-gradient(110deg, rgba(55, 43, 12, 0.97), rgba(5, 54, 39, 0.96)); + box-shadow: 0 5px 13px rgba(0, 24, 16, 0.38), inset 0 0 12px rgba(255, 210, 69, 0.08); + white-space: nowrap; + pointer-events: none; + animation: enduringRibbonIn 0.48s cubic-bezier(0.2, 0.84, 0.24, 1.2) both; +} + +.played-cards-top .enduring-inheritance-ribbon { + top: auto; + bottom: -30px; +} + +.enduring-inheritance-seal { + width: 25px; + height: 25px; + flex: 0 0 25px; + display: grid; + place-items: center; + border: 1px solid #ffe27a; + border-radius: 50%; + color: #4b3300; + background: linear-gradient(145deg, #fff09b, #d99a18); + box-shadow: 0 2px 5px rgba(87, 51, 0, 0.35); + font-size: 15px; + font-weight: 1000; + line-height: 1; +} + +.enduring-inheritance-label { + flex: 0 0 auto; + font-size: 11px; + font-weight: 900; + letter-spacing: 0.04em; +} + +.enduring-inheritance-cards { + min-width: 0; + height: 27px; + display: flex; + align-items: flex-start; + padding-right: 1px; +} + +.enduring-inheritance-cards .card.micro { + width: 20px; + height: 28px; + flex: 0 0 20px; + border-radius: 3px; + box-shadow: 0 1px 0 #9a9e99, 0 2px 4px rgba(0, 15, 9, 0.3); +} + +.enduring-inheritance-cards .card.micro + .card.micro { + margin-left: -8px; +} + +.enduring-inheritance-cards .card.micro .card-corner.top-left { + top: 2px; + left: 3px; +} + +.enduring-inheritance-cards .card.micro .card-rank { font-size: 8px; } +.enduring-inheritance-cards .card.micro .card-suit { font-size: 7px; } +.enduring-inheritance-cards .card.micro .trump-badge { display: none; } + +.enduring-inheritance-more { + align-self: center; + margin-left: 3px; + color: #ffe16d; + font-size: 10px; + font-weight: 900; +} + +@keyframes enduringRibbonIn { + from { opacity: 0; transform: translateX(-50%) translateY(7px) scale(0.88); } + to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } +} + +@keyframes enduringCardAwaken { + 0% { filter: brightness(1); } + 42% { filter: brightness(1.32) saturate(1.18); transform: translateY(-4px); } + 100% { filter: brightness(1); transform: translateY(0); } +} + +.concealed-play-stack { + position: relative; + width: min(150px, 90%); + height: 92px; + display: flex; + align-items: center; + justify-content: center; +} + +.concealed-play-card { + position: absolute; + left: calc(50% - 31px + var(--concealed-offset-x)); + top: calc(50% - 43px + var(--concealed-offset-y)); + width: 62px; + height: 86px; + border: 2px solid #ecd88e; + border-radius: 7px; + background: + linear-gradient(45deg, transparent 43%, rgba(255,255,255,0.22) 44% 56%, transparent 57%), + linear-gradient(-45deg, transparent 43%, rgba(255,255,255,0.16) 44% 56%, transparent 57%), + linear-gradient(145deg, #174f98, #09275f); + background-size: 12px 12px, 12px 12px, 100% 100%; + box-shadow: 0 4px 9px rgba(0, 18, 35, 0.34), inset 0 0 0 3px rgba(245, 224, 145, 0.2); + transform: translateX(calc(0px - var(--concealed-offset-x))); + animation: concealedCardIn 0.24s ease-out both; +} + +.concealed-play-label { + position: absolute; + z-index: 12; + bottom: -8px; + left: 50%; + padding: 2px 8px; + border: 1px solid rgba(255, 222, 108, 0.7); + border-radius: 999px; + color: #fff1b1; + background: rgba(39, 35, 18, 0.92); + font-size: 11px; + font-weight: 800; + white-space: nowrap; + transform: translateX(-50%); +} + +.played-cards-area.concealed-just-revealed .hand { + animation: concealedReveal 0.38s ease-out both; +} + +@keyframes concealedCardIn { + from { opacity: 0; transform: translateY(10px) scale(0.9); } + to { opacity: 1; } +} + +@keyframes concealedReveal { + from { opacity: 0.2; transform: rotateY(75deg); filter: brightness(1.5); } + to { opacity: 1; transform: rotateY(0); filter: brightness(1); } +} + +.active-skill-play-badge { + position: absolute; + left: 50%; + top: -8px; + z-index: 36; + transform: translateX(-50%); + padding: 3px 9px; + border: 1px solid rgba(255, 224, 105, 0.74); + border-radius: 999px; + color: #fff5c4; + background: linear-gradient(180deg, rgba(105, 82, 20, 0.96), rgba(51, 42, 18, 0.96)); + box-shadow: 0 4px 12px rgba(0, 20, 12, 0.34); + font-size: 11px; + font-weight: 800; + line-height: 1.25; + white-space: nowrap; + animation: winnerPop 0.28s ease-out; +} + +.round-rule-play-badge { + position: absolute; + left: 50%; + top: -9px; + z-index: 38; + transform: translateX(-50%); + padding: 3px 9px; + border: 1px solid rgba(255, 222, 104, 0.76); + border-radius: 999px; + color: #fff5bf; + background: linear-gradient(180deg, rgba(101, 78, 17, 0.97), rgba(46, 37, 13, 0.97)); + box-shadow: 0 4px 12px rgba(0, 20, 12, 0.35); + font-size: 10px; + font-weight: 900; + line-height: 1.3; + white-space: nowrap; + animation: winnerPop 0.32s ease-out; +} + +.played-cards-area.lure-tiger-silenced-play .hand { + filter: saturate(0.45) brightness(0.82); + opacity: 0.82; +} + +.lure-tiger-silenced-badge { + border-color: rgba(184, 197, 211, 0.82); + color: #f0f4f7; + background: linear-gradient(180deg, rgba(74, 85, 97, 0.97), rgba(35, 42, 49, 0.97)); +} + +.joint-harmony-badge { + border-color: rgba(255, 145, 197, 0.82); + color: #ffe7f3; + background: linear-gradient(180deg, rgba(111, 28, 69, 0.97), rgba(54, 17, 38, 0.97)); +} + +.dream-killing-success-badge { + border-color: rgba(117, 207, 255, 0.82); + color: #e6f7ff; + background: linear-gradient(180deg, rgba(23, 83, 116, 0.97), rgba(10, 42, 62, 0.97)); +} + +.cluster-analysis-badge { + border-color: rgba(102, 227, 255, 0.85); + color: #e7fbff; + background: linear-gradient(180deg, rgba(19, 113, 134, 0.98), rgba(8, 59, 72, 0.98)); +} + +.magic-trick-badge { + border-color: rgba(207, 147, 255, 0.88); + color: #f8eaff; + background: linear-gradient(180deg, rgba(105, 38, 145, 0.98), rgba(47, 16, 72, 0.98)); +} + +.played-cards-area.magic-trick-swapped .hand { + animation: magicTrickSwapReveal 0.72s cubic-bezier(0.2, 0.84, 0.24, 1.12) both; +} + +.average-pooled .card { box-shadow: 0 3px 0 #96731c, 0 7px 14px rgba(0, 21, 15, 0.3), 0 0 0 2px rgba(255, 220, 85, 0.26); } +.joint-harmony .card { box-shadow: 0 3px 0 #8a2858, 0 7px 14px rgba(0, 21, 15, 0.3), 0 0 0 2px rgba(255, 126, 189, 0.3); } +.dream-killing-success .card { animation: dreamKillingWake 0.68s ease-out both; } + +@keyframes dreamKillingWake { + 0% { filter: brightness(0.55) saturate(0.55); transform: rotateY(70deg); } + 60% { filter: brightness(1.35); transform: rotateY(0) translateY(-5px); } + 100% { filter: none; transform: none; } +} + +@keyframes magicTrickSwapReveal { + 0% { opacity: 0.25; transform: rotateY(78deg) scale(0.88); filter: hue-rotate(65deg) brightness(1.35); } + 65% { opacity: 1; transform: rotateY(0) translateY(-7px) scale(1.04); filter: brightness(1.2); } + 100% { transform: none; filter: none; } +} + +.joker-substitution-badge { + position: absolute; + left: 50%; + bottom: -10px; + z-index: 37; + max-width: 95%; + overflow: hidden; + padding: 3px 9px; + border: 1px solid rgba(126, 216, 255, 0.7); + border-radius: 999px; + color: #d9f5ff; + background: rgba(11, 58, 72, 0.94); + box-shadow: 0 4px 12px rgba(0, 20, 25, 0.3); + font-size: 10px; + font-weight: 800; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; + transform: translateX(-50%); + animation: winnerPop 0.28s ease-out; +} + +.abrupt-stop-settlement { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + margin-top: 9px; + padding: 8px 10px; + border: 1px solid rgba(255, 153, 102, 0.66); + border-radius: 8px; + color: #fff3e8; + background: rgba(113, 42, 18, 0.42); + font-size: 12px; +} + +.abrupt-stop-settlement > strong { color: #ffbb96; font-size: 14px; } +.abrupt-stop-settlement .hand { + width: min(100%, 270px); + min-height: 60px; + padding: 0; +} + +.winning-play-badge { + position: absolute; + right: 0; + bottom: 0; + z-index: 30; + width: 38px; + height: 38px; + display: flex; + align-items: flex-end; + justify-content: flex-end; + box-sizing: border-box; + padding: 0 4px 2px 0; + clip-path: polygon(100% 0, 100% 100%, 0 100%); + color: #fff1a8; + background: linear-gradient(135deg, #ef5b4d 0%, #d82035 54%, #9c0d24 100%); + filter: drop-shadow(-2px -2px 2px rgba(78, 0, 12, 0.28)); + font-size: 16px; + font-weight: 900; + line-height: 1; + text-shadow: 0 1px 2px rgba(81, 0, 10, 0.78); + animation: winnerPop 0.28s ease-out; +} + +@keyframes winnerPop { + from { opacity: 0; transform: scale(0.45); transform-origin: right bottom; } + to { opacity: 1; transform: scale(1); transform-origin: right bottom; } +} + +.score-panel { + top: 18px !important; + left: 18px !important; + width: 218px !important; + padding: 10px 13px !important; + border: 1px solid rgba(255, 224, 117, 0.34) !important; + border-radius: 16px !important; + background: linear-gradient(145deg, rgba(3, 41, 34, 0.91), rgba(13, 72, 51, 0.83)) !important; + box-shadow: 0 10px 28px rgba(0, 23, 17, 0.34) !important; + backdrop-filter: blur(12px); +} + +.score-panel > div:first-child { margin-bottom: 7px !important; padding-bottom: 6px !important; } +.score-panel > div:nth-child(3) { font-size: 21px !important; margin-bottom: 2px !important; } +.score-panel > div:last-child { height: 92px !important; } + +.focus-figure-score-hidden { + color: #fff0a6; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.ten-sided-ambush-score-counter { + display: flex; + align-items: center; + justify-content: space-between; + gap: 7px; + margin: 3px 0 7px; + padding: 5px 7px; + border: 1px solid rgba(255, 220, 91, 0.42); + border-radius: 8px; + color: #fff1ad; + background: rgba(63, 48, 8, 0.36); + font-size: 11px; + white-space: nowrap; +} + +.ten-sided-ambush-score-counter strong { + color: #ffd84f; + font-size: 13px; +} + +.ten-sided-ambush-score-counter.is-negative-score > span:last-child strong { color: #ff9292; } +.ten-sided-ambush-score-counter.is-positive-score > span:last-child strong { color: #9fea80; } + +.score-card-list { + height: 74px; + display: flex; + align-items: flex-start; + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + padding: 1px 3px 4px; +} + +.score-card-item { + position: relative; + flex: 0 0 50px; +} + +.score-card-item:not(:first-child) { + margin-left: -20px; +} + +.score-card-item:nth-child(n) { + z-index: 1; +} + +.game-table.settlement-view { + grid-template-rows: 160px minmax(0, 1fr) 90px; +} + +.settlement-view .position-top .played-cards-area, +.settlement-view .position-bottom .played-cards-area, +.settlement-view .my-hand-container, +.settlement-view .trump-declaration-wrapper { + display: none; +} + +.settlement-view .position-bottom { + padding-bottom: 10px; +} + +.settlement-view .player-bottom { + padding-bottom: 8px; +} + +.player-top { width: clamp(210px, 24vw, 280px); min-width: 0; } +.player-left, .player-right { width: clamp(108px, 9vw, 150px); min-width: 0; flex: 0 0 auto; } + +.player-bottom { + width: min(1440px, 100%); + min-width: 0; + padding: 8px 14px 5px; + border-radius: 22px 22px 8px 8px; +} + +.bottom-player-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 14px; + margin-bottom: 2px; +} + +.bottom-player-header .player-info { flex: 0 0 auto; text-align: left; } + +.inline-controls { + flex: 0 1 auto; + max-width: 720px; + padding: 6px 8px; + margin-left: auto; + border: 1px solid rgba(210, 242, 218, 0.16); + border-radius: 13px; + background: rgba(2, 36, 29, 0.5); + backdrop-filter: blur(12px); +} + +.inline-controls .ant-btn, +.center-content .ant-btn { + border-color: rgba(223, 242, 218, 0.38); + color: #effff2; + background: linear-gradient(180deg, rgba(51, 143, 81, 0.96), rgba(20, 91, 58, 0.96)); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.16), 0 4px 10px rgba(0, 27, 17, 0.22); +} + +.inline-controls .ant-btn-primary { + border-color: #f0cf68; + color: #3c2b00; + background: linear-gradient(180deg, #ffe680, #dcae35); +} + +/* 主动技能按钮默认收敛为灰色,只有“已武装”状态才使用醒目的金色。 */ +.inline-controls .active-skill-button.ant-btn { + border-color: rgba(220, 228, 223, 0.32); + color: rgba(247, 250, 248, 0.78); + background: linear-gradient(180deg, rgba(111, 120, 114, 0.9), rgba(65, 73, 68, 0.94)); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.1), 0 4px 10px rgba(0, 20, 13, 0.2); +} + +.inline-controls .active-skill-button.ant-btn:not(:disabled):hover { + border-color: rgba(255, 224, 119, 0.62); + color: #fff8d4; + background: linear-gradient(180deg, rgba(132, 139, 132, 0.96), rgba(74, 82, 76, 0.98)); +} + +.inline-controls .active-skill-button.ant-btn.is-armed, +.inline-controls .active-skill-button.ant-btn.is-armed:hover, +.inline-controls .active-skill-button.ant-btn.is-ready, +.inline-controls .active-skill-button.ant-btn.is-ready:hover { + border-color: #ffe47c !important; + color: #3e2c00 !important; + background: linear-gradient(180deg, #fff09a, #e6b633) !important; + box-shadow: 0 0 0 2px rgba(255, 224, 96, 0.18), 0 0 20px rgba(255, 202, 51, 0.42) !important; + animation: activeSkillButtonPulse 1.15s ease-in-out infinite; +} + +.inline-controls .active-skill-button.ant-btn.is-ready, +.inline-controls .active-skill-button.ant-btn.is-ready:hover { + animation-name: activeSkillReadyGlow; +} + +.inline-controls .active-skill-button.ant-btn.is-used, +.inline-controls .active-skill-button.ant-btn.is-used:disabled { + color: rgba(235, 240, 237, 0.34) !important; + background: linear-gradient(180deg, rgba(78, 86, 81, 0.58), rgba(48, 55, 51, 0.66)) !important; + box-shadow: none !important; +} + +@keyframes activeSkillButtonPulse { + 0%, 100% { transform: translateY(0); filter: brightness(1); } + 50% { transform: translateY(-1px); filter: brightness(1.1); } +} + +@keyframes activeSkillReadyGlow { + 0%, 100% { filter: brightness(1); } + 50% { filter: brightness(1.1); } +} + +/* 主题色不能覆盖真实禁用状态:不合法时按钮保持灰色且不显示禁止光标。 */ +.inline-controls .ant-btn.ant-btn-disabled, +.inline-controls .ant-btn:disabled, +.center-content .ant-btn.ant-btn-disabled, +.center-content .ant-btn:disabled { + border-color: rgba(205, 215, 208, 0.24); + color: rgba(234, 241, 236, 0.48); + background: linear-gradient(180deg, rgba(112, 125, 117, 0.58), rgba(67, 78, 71, 0.64)); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.06); + cursor: default !important; + opacity: 1; +} + +.my-hand-container { + width: 100%; + display: flex; + align-items: flex-end; + justify-content: center; + gap: 10px; +} + +.bottom-trump-zone { + flex: 0 0 88px; + width: 88px; + min-width: 88px; + display: flex; + align-items: flex-end; + padding: 4px; +} + +.bottom-trump-zone.has-multiple-cards { + flex-basis: 142px; + width: 142px; + min-width: 142px; +} + +.bottom-trump-zone .hand { + min-height: 122px; + padding: 4px; +} + +.bottom-trump-zone ~ .my-hand { flex: 1 1 auto; } + +.my-hand { + flex: 1 1 auto; + min-width: 0; + min-height: 140px; + display: flex; + justify-content: center; + padding: 0 14px; + border: 1px solid rgba(215, 243, 220, 0.14); + border-radius: 16px 16px 7px 7px; + background: + linear-gradient(180deg, rgba(3, 38, 31, 0.3), rgba(1, 27, 23, 0.73)), + repeating-linear-gradient(90deg, transparent 0 48px, rgba(255,255,255,0.013) 48px 49px); + box-shadow: inset 0 8px 22px rgba(0, 18, 14, 0.24); +} + +.trump-declaration-wrapper { margin: 2px 0 0; padding: 0; } + +.ten-sided-ambush-status { + display: flex; + align-items: center; + gap: 7px; + width: max-content; + max-width: 100%; + margin: 2px auto 8px; + padding: 5px 10px; + border: 1px solid rgba(255, 218, 93, 0.42); + border-radius: 999px; + color: #ffe995; + background: rgba(2, 28, 23, 0.58); + box-shadow: inset 0 0 14px rgba(255, 207, 55, 0.06); + font-size: 12px; +} + +.rule-runtime-status { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + width: max-content; + max-width: 100%; + min-height: 30px; + margin: 0 auto 8px; + padding: 4px 11px; + border: 1px solid rgba(255, 216, 70, 0.68); + border-radius: 999px; + color: #fff4bd; + background: linear-gradient(180deg, rgba(107, 77, 12, 0.72), rgba(48, 36, 8, 0.72)); + box-shadow: inset 0 0 12px rgba(255, 211, 66, 0.08); + font-size: 13px; + font-weight: 700; + line-height: 1.2; + animation: ruleRuntimeStatusEnter 480ms cubic-bezier(0.2, 0.82, 0.28, 1.18) both; +} + +.rule-runtime-status strong { + color: #ffd84a; + font-size: 16px; +} + +.rule-runtime-status-icon { + color: #ffd84a; + font-size: 18px; + line-height: 1; +} + +.waiting-rabbit-records { + width: min(100%, 360px); + margin: 0 auto 8px; + overflow: hidden; + border: 1px solid rgba(255, 216, 70, 0.5); + border-radius: 10px; + color: #fff7d1; + background: linear-gradient(155deg, rgba(70, 54, 12, 0.72), rgba(11, 42, 34, 0.78)); + box-shadow: inset 0 0 18px rgba(255, 211, 66, 0.06); + text-align: left; +} + +.waiting-rabbit-records-heading { + display: flex; + align-items: center; + gap: 7px; + padding: 6px 10px; + border-bottom: 1px solid rgba(255, 224, 118, 0.2); + color: #ffe57a; + font-size: 13px; +} + +.waiting-rabbit-records-heading > span { + display: grid; + width: 22px; + height: 22px; + place-items: center; + border-radius: 50%; + color: #1e3e31; + background: #ffe57a; + font-size: 12px; + font-weight: 900; +} + +.waiting-rabbit-records-list { + max-height: 154px; + padding: 4px 8px 6px; + overflow-y: auto; +} + +.waiting-rabbit-record { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 7px; + align-items: baseline; + padding: 4px 2px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + font-size: 12px; + line-height: 1.35; +} + +.waiting-rabbit-record:last-child { + border-bottom: 0; +} + +.waiting-rabbit-record small { + color: rgba(255, 231, 144, 0.65); + font-size: 10px; + white-space: nowrap; +} + +.waiting-rabbit-record.is-target-triggered span { + color: #ffd666; +} + +.waiting-rabbit-record.is-target-exchanged span { + color: #a8f0bd; +} + +.waiting-rabbit-record.is-exchange-declined span, +.waiting-rabbit-record.is-empty span { + color: rgba(255, 255, 255, 0.68); +} + +.waiting-rabbit-record.is-empty { + display: block; + padding: 7px 2px; + text-align: center; +} + +.odd-even-round-status.is-odd { + border-color: rgba(181, 193, 209, 0.52); + background: linear-gradient(135deg, rgba(65, 74, 91, 0.76), rgba(28, 35, 49, 0.72)); +} + +.odd-even-round-status.is-odd strong, +.odd-even-round-status.is-odd .rule-runtime-status-icon { + color: #d7e0eb; +} + +.odd-even-round-status.is-even { + border-color: rgba(255, 211, 84, 0.72); + background: linear-gradient(135deg, rgba(112, 73, 8, 0.76), rgba(61, 40, 5, 0.72)); + box-shadow: inset 0 0 15px rgba(255, 210, 67, 0.12), 0 0 14px rgba(255, 204, 48, 0.12); +} + +.cultural-revolution-status { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + width: min(100%, 292px); + margin: 1px auto 9px; + padding: 8px 12px; + border: 1px solid rgba(255, 201, 70, 0.66); + border-radius: 12px; + color: #fff5c5; + background: linear-gradient(135deg, rgba(99, 55, 13, 0.86), rgba(42, 26, 10, 0.84)); + box-shadow: inset 0 0 18px rgba(255, 201, 70, 0.08); + line-height: 1.25; +} + +.cultural-revolution-status.is-idle { + border-color: rgba(188, 200, 193, 0.36); + background: rgba(23, 51, 43, 0.72); +} + +.cultural-revolution-status-heading, +.cultural-revolution-status-main { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.cultural-revolution-status-heading > span { + display: inline-grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 50%; + color: #5f3304; + background: #ffd666; + font-weight: 900; +} + +.cultural-revolution-status-heading strong, +.cultural-revolution-status-main strong { + color: #ffd666; + font-size: 15px; +} + +.cultural-revolution-status small { + color: rgba(245, 250, 244, 0.72); + font-size: 11px; +} + +.three-tigers-status { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + width: min(100%, 292px); + margin: 1px auto 9px; + padding: 8px 12px; + border: 1px solid rgba(226, 154, 67, 0.55); + border-radius: 12px; + color: #fff2d2; + background: linear-gradient(135deg, rgba(76, 48, 25, 0.82), rgba(35, 31, 24, 0.80)); + box-shadow: inset 0 0 18px rgba(231, 137, 39, 0.07); + line-height: 1.25; +} + +.three-tigers-status.is-triggered { + border-color: rgba(255, 169, 55, 0.88); + background: + repeating-linear-gradient(135deg, rgba(195, 82, 18, 0.13) 0 8px, transparent 8px 17px), + linear-gradient(135deg, rgba(117, 58, 17, 0.9), rgba(50, 29, 15, 0.88)); + box-shadow: inset 0 0 20px rgba(255, 150, 41, 0.12), 0 0 14px rgba(236, 113, 28, 0.12); + animation: ruleRuntimeStatusEnter 420ms ease-out both; +} + +.three-tigers-status-heading, +.three-tigers-status-main { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.three-tigers-status-heading > span { + display: inline-grid; + width: 24px; + height: 24px; + place-items: center; + border-radius: 50%; + color: #fff3d0; + background: linear-gradient(145deg, #df7e26, #88380e); + font-weight: 950; +} + +.three-tigers-status-heading strong, +.three-tigers-status-main strong { + color: #ffbd68; + font-size: 15px; +} + +.three-tigers-status small { + color: rgba(255, 243, 217, 0.77); + font-size: 11px; +} + +.candle-to-dawn-status { + display: flex; + align-items: center; + gap: 10px; + width: min(100%, 292px); + margin: 1px auto 9px; + padding: 8px 12px; + border: 1px solid rgba(255, 207, 91, 0.66); + border-radius: 13px; + background: linear-gradient(135deg, rgba(88, 58, 12, 0.88), rgba(38, 28, 13, 0.82)); + box-shadow: inset 0 0 18px rgba(255, 194, 61, 0.08); + text-align: left; +} + +.candle-to-dawn-status.is-unlit { + border-color: rgba(160, 178, 194, 0.45); + background: linear-gradient(135deg, rgba(48, 59, 67, 0.86), rgba(24, 31, 36, 0.84)); + box-shadow: inset 0 0 18px rgba(173, 197, 212, 0.05); +} + +.candle-to-dawn-visual { + position: relative; + flex: 0 0 28px; + width: 28px; + height: 46px; +} + +.candle-body, +.candle-wick, +.candle-flame { + position: absolute; + left: 50%; + display: block; + transform: translateX(-50%); +} + +.candle-body { + bottom: 0; + width: 18px; + height: 28px; + border-radius: 5px 5px 3px 3px; + background: linear-gradient(90deg, #eadca8, #fff5c8 55%, #c9b875); + box-shadow: 0 2px 7px rgba(0, 0, 0, 0.32); +} + +.candle-wick { + bottom: 27px; + width: 2px; + height: 6px; + border-radius: 2px; + background: #34271c; +} + +.candle-flame { + bottom: 32px; + width: 13px; + height: 17px; + border-radius: 58% 42% 56% 44% / 70% 62% 38% 30%; + background: radial-gradient(circle at 52% 70%, #fffbc8 0 22%, #ffd04d 24% 58%, #ff7a21 61% 100%); + filter: drop-shadow(0 0 6px rgba(255, 180, 45, 0.86)); + transform: translateX(-50%) rotate(5deg); + transform-origin: center bottom; + animation: candleFlameFlicker 1.25s ease-in-out infinite alternate; +} + +.candle-to-dawn-status.is-unlit .candle-flame, +.candle-to-dawn-status.is-pending .candle-flame { + display: none; +} + +.candle-to-dawn-status.is-unlit .candle-body, +.candle-to-dawn-status.is-pending .candle-body { + filter: grayscale(0.72) brightness(0.78); +} + +.candle-to-dawn-copy { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.candle-to-dawn-copy strong { + color: #ffe293; + font-size: 14px; + line-height: 1.25; +} + +.candle-to-dawn-status.is-unlit .candle-to-dawn-copy strong, +.candle-to-dawn-status.is-pending .candle-to-dawn-copy strong { + color: #dce5ea; +} + +.candle-to-dawn-copy small { + color: rgba(255, 248, 216, 0.8); + font-size: 11px; + font-weight: 650; + line-height: 1.3; +} + +.candle-to-dawn-status.is-unlit .candle-to-dawn-copy small, +.candle-to-dawn-status.is-pending .candle-to-dawn-copy small { + color: rgba(224, 234, 239, 0.76); +} + +@keyframes candleFlameFlicker { + from { transform: translateX(-50%) rotate(-4deg) scale(0.94, 1); } + to { transform: translateX(-50%) rotate(5deg) scale(1.04, 0.96); } +} + +.planned-economy-status { + flex-wrap: wrap; + max-width: 245px; +} + +.planned-economy-status small { + flex-basis: 100%; + color: rgba(255, 247, 203, 0.78); + font-size: 11px; + font-weight: 600; +} + +.strength-compensation-status { + display: block; + width: min(100%, 300px); + padding: 7px 8px 8px; + border-radius: 12px; + white-space: normal; +} + +.strength-compensation-heading { + margin-bottom: 6px; + color: #fff2b2; + font-size: 13px; + font-weight: 900; + line-height: 1; + text-align: center; +} + +.strength-compensation-lanes { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; +} + +.strength-compensation-lane { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 2px 6px; + min-width: 0; + padding: 5px 7px; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + background: rgba(255, 255, 255, 0.055); + text-align: left; +} + +.strength-compensation-lane > span:not(.strength-compensation-delta) { + color: rgba(255, 255, 255, 0.82); + font-size: 11px; + font-weight: 700; +} + +.strength-compensation-delta { + display: inline-grid; + min-width: 27px; + height: 20px; + place-items: center; + border-radius: 999px; + color: #082b18; + background: #91f0b6; + font-size: 11px; + font-weight: 950; + line-height: 1; +} + +.strength-compensation-lane strong { + grid-column: 1 / -1; + overflow: hidden; + color: #b7f5cf; + font-size: 12px; + line-height: 1.15; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.strength-compensation-lane.is-minus .strength-compensation-delta { + color: #4a1408; + background: #ffad95; +} + +.strength-compensation-lane.is-minus strong { color: #ffc2b1; } + +@keyframes ruleRuntimeStatusEnter { + 0% { opacity: 0; transform: translateY(-5px) scale(0.9); } + 70% { opacity: 1; transform: translateY(1px) scale(1.04); } + 100% { opacity: 1; transform: translateY(0) scale(1); } +} + +.ten-sided-ambush-status.is-revealed { + border-color: rgba(255, 216, 70, 0.88); + background: linear-gradient(180deg, rgba(121, 83, 11, 0.72), rgba(55, 38, 7, 0.72)); + animation: ambushStatusReveal 700ms ease-out both; +} + +.ten-sided-ambush-status strong, +.ten-sided-ambush-hidden-rank { + display: inline-grid; + place-items: center; + min-width: 26px; + height: 26px; + border-radius: 50%; + color: #553400; + background: linear-gradient(180deg, #fff1a6, #e8ae2c); + font-size: 16px; + line-height: 1; +} + +.ten-sided-ambush-hidden-rank { + color: rgba(255, 242, 184, 0.72); + background: rgba(255, 255, 255, 0.08); +} + +.ten-sided-ambush-status-label { font-weight: 800; } +.ten-sided-ambush-status-note { color: rgba(255, 255, 255, 0.7); } + +.three-powers-status { + width: min(100%, 250px); + margin: 2px auto 8px; + padding: 7px 8px 8px; + border: 1px solid rgba(255, 218, 93, 0.42); + border-radius: 10px; + color: #fff0ad; + background: rgba(2, 28, 23, 0.58); + box-shadow: inset 0 0 14px rgba(255, 207, 55, 0.06); +} + +.three-powers-status-title { + display: block; + margin-bottom: 5px; + font-size: 12px; + font-weight: 800; + line-height: 1; + text-align: center; +} + +.gentleman-promise-status { + width: min(100%, 260px); + margin: 2px auto 8px; + padding: 7px 8px 8px; + border: 1px solid rgba(255, 218, 93, 0.42); + border-radius: 10px; + color: #fff0ad; + background: rgba(2, 28, 23, 0.58); + animation: ruleRuntimeStatusEnter 420ms ease-out both; +} + +.gentleman-promise-status-title { + display: block; + margin-bottom: 6px; + font-size: 12px; + font-weight: 800; + text-align: center; +} + +.gentleman-promise-status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 5px; +} + +.gentleman-promise-status-grid > div { + display: grid; + grid-template-columns: minmax(0, 1fr) 25px; + align-items: center; + min-width: 0; + padding: 4px 6px; + border: 1px solid rgba(255, 255, 255, 0.13); + border-radius: 7px; + background: rgba(255, 255, 255, 0.055); +} + +.gentleman-promise-status-grid span { + overflow: hidden; + color: rgba(255, 255, 255, 0.76); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gentleman-promise-status-grid strong { + color: #ffd84a; + font-size: 14px; + text-align: center; +} + +.gentleman-promise-status-grid .is-pending strong { + color: rgba(255, 255, 255, 0.45); +} + +.hidden-dragon-status .gentleman-promise-status-grid > div { + grid-template-columns: minmax(0, 1fr) 24px 38px; +} + +.hidden-dragon-status .gentleman-promise-status-grid small { + color: rgba(255, 255, 255, 0.52); + font-size: 9px; + text-align: right; + white-space: nowrap; +} + +.hidden-dragon-status .gentleman-promise-status-grid small.not-played { + color: #8ff0aa; + font-weight: 800; +} + +.hidden-dragon-status .gentleman-promise-status-grid small.has-played { + color: #ffae9f; + font-weight: 800; +} + +.hidden-dragon-status .gentleman-promise-status-grid small.score-success { + color: #8ff0aa; + font-weight: 900; +} + +.hidden-dragon-status .gentleman-promise-status-grid small.score-zero { + color: rgba(255, 255, 255, 0.58); + font-weight: 800; +} + +.administrative-review-status-grid { + display: grid; + gap: 5px; +} + +.administrative-review-status-grid > div { + display: grid; + grid-template-columns: minmax(0, 1fr) 28px 48px; + align-items: center; + gap: 4px; + padding: 5px 7px; + border: 1px solid rgba(255, 255, 255, 0.13); + border-radius: 7px; + background: rgba(255, 255, 255, 0.055); +} + +.administrative-review-status-grid span { + overflow: hidden; + color: rgba(255, 255, 255, 0.76); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.administrative-review-status-grid strong { + color: #ffd84a; + font-size: 14px; + text-align: center; +} + +.administrative-review-status-grid small { + color: #ffae9f; + font-size: 9px; + text-align: right; + white-space: nowrap; +} + +.administrative-review-status-grid small.is-matched { + color: #8ff0aa; + font-weight: 800; +} + +.administrative-review-bottom-state { + margin-top: 6px; + color: rgba(255, 255, 255, 0.7); + font-size: 10px; + text-align: center; +} + +.political-review-status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 5px; +} + +.political-review-status-grid > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 5px; + padding: 5px 7px; + border: 1px solid rgba(255, 255, 255, 0.13); + border-radius: 7px; + background: rgba(255, 255, 255, 0.055); +} + +.political-review-status-grid span { + overflow: hidden; + color: rgba(255, 255, 255, 0.78); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.political-review-status-grid small { + font-size: 9px; + white-space: nowrap; +} + +.political-review-status-grid small.is-available { + color: #8ff0aa; + font-weight: 800; +} + +.political-review-status-grid small.is-used { + color: rgba(255, 255, 255, 0.5); +} + +.political-review-pending { + margin-top: 6px; + padding: 6px 7px; + border-radius: 7px; + background: rgba(255, 216, 74, 0.12); + color: #ffe58f; + font-size: 10px; + line-height: 1.45; +} + +.repeated-exhaustion-status { + flex-wrap: wrap; + width: min(100%, 280px); + border-radius: 12px; + line-height: 1.35; + white-space: normal; +} + +.repeated-exhaustion-status .rule-runtime-status-icon { + display: inline-grid; + width: 25px; + height: 25px; + place-items: center; + border-radius: 50%; + color: #5b3600; + background: #ffd84a; + font-size: 13px; + font-weight: 900; +} + +.repeated-exhaustion-status strong { + font-size: 13px; +} + +.three-powers-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 5px; +} + +.three-powers-slot { + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: 24px 13px; + align-items: center; + min-width: 0; + padding: 3px 4px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 7px; + background: rgba(255, 255, 255, 0.055); +} + +.three-powers-slot > span { + color: rgba(255, 246, 204, 0.78); + font-size: 11px; + font-weight: 800; +} + +.three-powers-slot > strong { + color: rgba(255, 247, 208, 0.68); + font-size: 17px; + line-height: 1; + text-align: right; +} + +.three-powers-slot > small { + grid-column: 1 / -1; + overflow: hidden; + color: rgba(255, 255, 255, 0.55); + font-size: 9px; + line-height: 13px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.three-powers-slot.is-private { + border-color: rgba(255, 216, 70, 0.72); + background: rgba(114, 78, 9, 0.46); +} + +.three-powers-slot.is-private > strong, +.three-powers-slot.is-revealed > strong { + color: #ffd84a; +} + +.three-powers-slot.is-revealed { + border-color: rgba(255, 216, 70, 0.82); + background: linear-gradient(180deg, rgba(121, 83, 11, 0.62), rgba(55, 38, 7, 0.62)); + animation: ambushStatusReveal 700ms ease-out both; +} + +.ten-sided-ambush-bottom-summary { + display: flex; + flex-direction: column; + gap: 4px; +} + +@keyframes ambushStatusReveal { + 0% { opacity: 0; transform: scale(0.72); } + 65% { opacity: 1; transform: scale(1.08); } + 100% { transform: scale(1); } +} + +.game-table ::-webkit-scrollbar { height: 6px; } +.game-table ::-webkit-scrollbar-track { background: rgba(0, 0, 0, 0.26); border-radius: 3px; } +.game-table ::-webkit-scrollbar-thumb { background: rgba(248, 214, 101, 0.72); border-radius: 3px; } + +@media (max-width: 1180px) { + .game-table { padding-inline: 14px; } + .position-middle { grid-template-columns: minmax(104px, 0.9fr) minmax(220px, 1.8fr) minmax(104px, 0.9fr); gap: 8px; } + .position-left, .position-right { gap: 10px; } + .player-left, .player-right { width: clamp(100px, 11vw, 128px); } + .score-panel { width: 184px !important; transform: scale(0.88); transform-origin: top left; } + .position-left .played-cards-area, + .position-right .played-cards-area { width: clamp(160px, 18vw, 210px); } + .open-hand-side { width: 146px; flex-basis: 146px; } + .position-left.has-open-hand .played-cards-area, + .position-right.has-open-hand .played-cards-area { width: 138px; } + .open-hand-opposite { width: min(430px, 48vw); max-width: none; } +} + +@media (max-width: 780px) { + .game-table { padding: 10px 8px; border-radius: 18px; } + .game-table::after { font-size: 34px; } + .position-top { flex-basis: 105px; } + .position-middle { grid-template-columns: 82px minmax(150px, 1fr) 82px; } + .player-left, .player-right { width: 82px; padding: 5px; font-size: 11px; } + .player-avatar { width: 27px; height: 27px; flex-basis: 27px; font-size: 10px; } + .played-cards-area { min-width: 82px; width: 116px; height: 95px; } + .score-panel { display: none; } + .open-hand-side { position: absolute; width: 142px; } + .position-left .open-hand-side { left: 78px; } + .position-right .open-hand-side { right: 78px; } + .open-hand-opposite { width: min(390px, 72vw); } + .open-hand-title { max-width: 120px; } + .bottom-player-header { flex-direction: column; align-items: stretch; } + .inline-controls { margin-left: 0; max-width: none; overflow-x: auto; } + .my-hand { padding-inline: 4px; } +} + +@media (max-height: 760px) { + .game-table { + grid-template-rows: 220px minmax(130px, 1fr) 270px; + padding-top: 9px; + padding-bottom: 7px; + } + .position-middle { min-height: 120px; } + .player-area { padding-block: 5px; } + .score-panel { transform: scale(0.72); transform-origin: top left; } + .score-panel > div:last-child { display: none; } + .my-hand { min-height: 122px; } + .my-hand .hand { min-height: 120px; transform: scale(0.9); transform-origin: center bottom; } + + .game-table.settlement-view { + grid-template-rows: 0 minmax(0, 1fr) 72px; + } + + .settlement-view .position-top { + display: none; + } + + .settlement-view .position-bottom { + padding-bottom: 4px; + } + + .center-content.settlement-panel { + padding: 7px 11px 9px; + } + + .settlement-content { + gap: 5px; + } + + .settlement-bottom-cards .hand { + min-height: 96px; + padding: 2px; + } +} +.wooden-ox-card-tray { + position: relative; + z-index: 4; + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border: 1px solid rgba(201, 162, 39, 0.72); + border-radius: 10px; + background: rgba(32, 54, 39, 0.94); + box-shadow: 0 5px 16px rgba(0, 0, 0, 0.3); +} + +.wooden-ox-card-label { + width: 76px; + color: #f6dc87; + font-size: 12px; + font-weight: 700; + line-height: 1.35; + text-align: center; +} + +@media (max-width: 780px) { + .wooden-ox-card-tray { + flex-direction: column; + gap: 4px; + padding: 6px 8px; + } + + .wooden-ox-card-label { + width: 78px; + font-size: 10px; + } +} + +.wooden-ox-rule-status { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin: 8px 0; +} + +.wooden-ox-rule-status > div { + display: grid; + grid-template-columns: 1fr auto; + gap: 2px 8px; + padding: 6px 8px; + border: 1px solid rgba(246, 220, 135, 0.45); + border-radius: 7px; + background: rgba(0, 0, 0, 0.2); + color: #fff; + font-size: 12px; +} + +.wooden-ox-rule-status strong, +.wooden-ox-rule-status small:last-child { + text-align: right; +} +.double-happiness-selected-rules { + display: flex; + justify-content: center; + gap: 6px; + flex-wrap: wrap; + margin: 0 0 8px; } -.game-table ::-webkit-scrollbar-thumb:hover { - background: rgba(255, 215, 0, 0.8); +.double-happiness-selected-rules span { + padding: 3px 9px; + border: 1px solid rgba(255, 215, 0, 0.55); + border-radius: 999px; + color: #ffe58f; + background: rgba(255, 215, 0, 0.09); + font-size: 13px; + font-weight: 700; } diff --git a/tractor-game-simulator/client/src/components/Game/GameTable.jsx b/tractor-game-simulator/client/src/components/Game/GameTable.jsx index 318c47b..a87f60e 100644 --- a/tractor-game-simulator/client/src/components/Game/GameTable.jsx +++ b/tractor-game-simulator/client/src/components/Game/GameTable.jsx @@ -1,6 +1,22 @@ -import { Typography, Button } from 'antd'; +import { Typography, Button } from 'antd'; import Hand from './Hand'; +import Card from './Card'; +import OpenHandPanel from './OpenHandPanel'; import { sortCards } from '../../utils/cardUtils'; +import { + calculateCardPoints, + getScoringDisplayCard, + getOddEvenRoundMultiplier, + getCandleToDawnCardPoints, + getDisplayedCandleState, + getMeticulousAccountingCardPoints, + getThreePowersCardPoints +} from '../../utils/scoringUtils'; +import { + getDisplayedDefenseAsOffense, + getStriveUpstreamActionOrder +} from '../../utils/gameViewUtils'; +import { ruleIncludesId } from '../../utils/ruleCatalog'; import './GameTable.css'; const { Text } = Typography; @@ -14,20 +30,25 @@ const { Text } = Typography; * @param {Object} props.shownCards - 摸牌阶段展示的牌 { [playerId]: { playerName, cards } } * @param {Array} props.myCards - 我的手牌 * @param {Array} props.selectedCards - 选中的牌 + * @param {Array} props.disabledCardIds - 因规则不可选择的手牌ID + * @param {String} props.disabledCardReason - 规则禁用牌的提示 + * @param {Array} props.virtualizedCardIds - “虚虚实实”本次视为不存在的手牌ID * @param {Function} props.onCardClick - 点击牌的回调 * @param {Function} props.onReorder - 重新排序手牌的回调 * @param {String} props.currentTurnPlayerId - 当前轮到谁出牌 + * @param {Object} props.trumpAnimation - 毙牌/盖毙的局部打击动画 * @param {String} props.trumpSuit - 主牌花色 * @param {String} props.trumpRank - 主牌点数 - * @param {Boolean} props.isHost - 是否是房主 - * @param {Function} props.onSetTrump - 设置主牌的回调 + * @param {Array} props.publicBottomCards - “昭然若揭”整局明置的当前底牌 * @param {Array} props.revealedBottomCards - 揭示的底牌 * @param {Object} props.selectedRule - 选中的规则 { name, content } + * @param {Number} props.displayRoundNumber - 当前牌面对应的轮次;轮末停留时锁定为刚结束的轮次 * @param {Function} props.onSelectRule - 选择规则的回调 * @param {ReactNode} props.renderControls - 渲染控制区域的函数或组件 * @param {Boolean} props.isWaitingForReady - 是否在等待玩家准备阶段 * @param {ReactNode} props.trumpDeclarationComponent - 亮主条组件 * @param {Object} props.currentTrumpDeclaration - 当前亮主信息 + * @param {Object} props.currentInferiorDeclaration - “三六九等”当前亮劣信息 * @param {Number} props.attackerScore - 闲家当前得分 * @param {Array} props.collectedPointCards - 闲家收集的分数牌 * @param {Object} props.bottomScoreResult - 底牌得分结果 @@ -35,6 +56,10 @@ const { Text } = Typography; * @param {Number} props.team1Level - 队伍1等级 * @param {Number} props.team2Level - 队伍2等级 * @param {Number} props.dealerPlayerIndex - 庄家玩家索引 + * @param {Object} props.cardExchange - 摸牌后的公开换牌进度 + * @param {Object} props.mainstay - “中流砥柱”当前依次处理进度 + * @param {Object} props.cardExchangeAnimation - 换牌路径动画 + * @param {Object} props.threePowers - 三权分立三个重载分牌槽的可见状态 */ export default function GameTable({ players, @@ -42,22 +67,43 @@ export default function GameTable({ playedCards = {}, shownCards = {}, myCards = [], + woodenOxCard = null, selectedCards = [], + disabledCardIds = [], + disabledCardReason = '', + virtualizedCardIds = [], + transformableCardIds = [], onCardClick, + onRequestCardTransformation, + onCancelCardTransformation, onReorder, currentTurnPlayerId, + currentWinningPlayerId = null, + trumpAnimation = null, trumpSuit = null, trumpRank = null, - isHost = false, - onSetTrump, + publicBottomCards = null, revealedBottomCards = null, selectedRule = null, + ruleRuntimeStatus = null, + displayRoundNumber = null, + ownFocusFigurePlayerId = null, + tenSidedAmbush = null, + threePowers = null, + divineWeapon = null, + selectedDivineWeaponCardId = null, + onDivineWeaponCardClick, + canSelectDivineWeapon = false, onSelectRule, + ruleChooserPlayerId = null, + isRuleSelectionPending = false, renderControls = null, isWaitingForReady = false, trumpDeclarationComponent = null, currentTrumpDeclaration = null, + currentInferiorDeclaration = null, buryingPlayerId = null, + secondaryBuryingPlayerId = null, dealerCountdown = null, attackerScore = 0, collectedPointCards = [], @@ -65,8 +111,29 @@ export default function GameTable({ upgradeResult = null, team1Level = 2, team2Level = 2, - dealerPlayerIndex = null + dealerPlayerIndex = null, + cardExchange = null, + mainstay = null, + cardExchangeAnimation = null, + openHand = null, + ruleVisibleHands = [], + playerTargeting = null, + onPlayerTargetClick, + openHandSelectedCards = [], + onOpenHandCardClick, + canControlOpenHand = false, + disableMyHand = false }) { + const ruleChooser = players.find(player => player.id === ruleChooserPlayerId); + const mainstayAction = mainstay?.currentAction || null; + const mainstayActor = players.find(player => player.id === mainstayAction?.actorPlayerId); + const mainstayChooser = players.find(player => player.id === mainstayAction?.chooserPlayerId); + const ruleVisibleHandsByPlayerId = new Map( + ruleVisibleHands.map(hand => [hand.playerId, hand]) + ); + const hasRevealedHand = player => Boolean( + player && (openHand?.playerId === player.id || ruleVisibleHandsByPlayerId.has(player.id)) + ); // 根据玩家数量和当前玩家位置,计算每个位置显示哪个玩家 const getPlayerPositions = () => { if (!currentPlayer || !players || players.length === 0) { @@ -96,6 +163,148 @@ export default function GameTable({ }; const positions = getPlayerPositions(); + const renderStriveUpstreamOrderBadge = player => { + const actionOrder = getStriveUpstreamActionOrder(players, ruleRuntimeStatus, player?.id); + if (!actionOrder) return null; + return ( + + {actionOrder} + + ); + }; + const isSettlementView = Boolean( + revealedBottomCards?.length && (bottomScoreResult || upgradeResult) + ); + const isPublicBottomVisible = Boolean( + ruleIncludesId(selectedRule, 'openly_revealed') + && publicBottomCards?.length + && !isSettlementView + ); + const isFocusFigureRule = ruleIncludesId(selectedRule, 'focus_figure'); + const isLostInFogScoringHidden = ruleIncludesId(selectedRule, 'lost_in_fog') && !isSettlementView; + const focusFigure = ruleRuntimeStatus?.focusFigure || null; + const knownFocusPlayerIds = new Set( + focusFigure?.isRevealed + ? (focusFigure.teams || []).map(team => team.focusPlayerId).filter(Boolean) + : [ownFocusFigurePlayerId].filter(Boolean) + ); + const focusCapturedPointsByPlayerId = focusFigure?.capturedPointsByPlayerId || {}; + const dreamKillingSleepingPlayerIds = new Set( + ruleRuntimeStatus?.dreamKilling?.sleepingPlayerIds || [] + ); + const showFocusFigureProgress = isFocusFigureRule && Boolean(focusFigure); + const secondBattlefield = ruleRuntimeStatus?.secondBattlefield || null; + const oneCountryTwoSystems = ruleIncludesId(selectedRule, 'one_country_two_systems') + ? ruleRuntimeStatus?.oneCountryTwoSystems || null + : null; + const oneCountryDeclarations = oneCountryTwoSystems?.declarationsByTeam || {}; + const oneCountryResolution = oneCountryTwoSystems?.resolved || null; + const encircleThreeMissingOne = ruleIncludesId(selectedRule, 'encircle_three_missing_one') + ? ruleRuntimeStatus?.encircleThreeMissingOne || null + : null; + const isHoldingSecondBattlefieldResult = Boolean( + secondBattlefield?.lastResult?.triggerRound === displayRoundNumber + && displayRoundNumber !== ruleRuntimeStatus?.currentRound + ); + const displayedSecondBattlefieldCards = isHoldingSecondBattlefieldResult + ? secondBattlefield.lastResult.communityCards + : secondBattlefield?.communityCards; + const displayedSecondBattlefieldAccumulatedCards = isHoldingSecondBattlefieldResult + ? Object.fromEntries( + (secondBattlefield?.lastResult?.players || []).map( + player => [player.playerId, player.accumulatedCards || []] + ) + ) + : secondBattlefield?.accumulatedCardsByPlayerId || {}; + const ambushAttackerNetCardCount = Number.isFinite(tenSidedAmbush?.attackerNetCardCount) + ? tenSidedAmbush.attackerNetCardCount + : 0; + const currentRoundNumber = Math.max( + 1, + displayRoundNumber ?? ruleRuntimeStatus?.currentRound ?? 1 + ); + const candleToDawn = ruleRuntimeStatus?.candleToDawn || null; + const threeTigers = ruleRuntimeStatus?.threeTigers || null; + const inviteIntoUrn = ruleRuntimeStatus?.inviteIntoUrn || null; + const oldHorse = ruleRuntimeStatus?.oldHorse || null; + const trumpWins = ruleRuntimeStatus?.trumpWins || null; + const strawBoatBorrowingArrows = ruleRuntimeStatus?.strawBoatBorrowingArrows || null; + const bushGate = ruleRuntimeStatus?.bushGate || null; + const teammateCheer = ruleRuntimeStatus?.teammateCheer || null; + const teammateCheerBuffedPlayerIds = new Set(teammateCheer?.buffedPlayerIds || []); + const afterglowActivePlayerIds = new Set( + ruleRuntimeStatus?.afterglow?.activePlayerIds || [] + ); + const defenseAsOffense = ruleIncludesId(selectedRule, 'defense_as_offense') + ? getDisplayedDefenseAsOffense(ruleRuntimeStatus, currentRoundNumber) + : null; + const lureTigerSilencedPlayerIds = new Set( + ruleRuntimeStatus?.lureTiger?.silencedPlayerIds || [] + ); + const displayedThreeTigers = threeTigers?.currentRound?.round === currentRoundNumber + ? threeTigers.currentRound + : threeTigers?.lastRound?.round === currentRoundNumber + ? threeTigers.lastRound + : threeTigers?.currentRound || null; + const threeTigersProgress = Object.entries(displayedThreeTigers?.suitCounts || {}) + .reduce((best, entry) => entry[1] > (best?.[1] || 0) ? entry : best, null); + const displayedInviteDeclarations = (inviteIntoUrn?.declarations || []).length > 0 + ? inviteIntoUrn.declarations + : inviteIntoUrn?.lastResult?.round === currentRoundNumber + ? inviteIntoUrn.lastResult.declarations || [] + : []; + // round_updated 与 room_updated 是两个连续事件。即使 React 尚未来得及写入 + // heldCompletedRoundNumber,只要四家的末手仍完整留在桌上,就仍属于刚结束的那轮。 + // 这样服务端的新烛态不会在清桌前闪现一帧。 + const displayedCandleState = getDisplayedCandleState({ + candleToDawn, + currentRound: ruleRuntimeStatus?.currentRound, + displayRoundNumber, + visiblePlayCount: Object.keys(playedCards).length, + playerCount: players.length + }); + const displayedCandleLit = displayedCandleState.isLit; + const displayedCandleRoundNumber = displayedCandleState.round; + const currentRoundPointResolver = ruleIncludesId(selectedRule, 'three_powers') + ? card => getThreePowersCardPoints(card, threePowers) + : ruleIncludesId(selectedRule, 'meticulous_accounting') + ? getMeticulousAccountingCardPoints + : ruleIncludesId(selectedRule, 'candle_to_dawn') + ? card => getCandleToDawnCardPoints(card, displayedCandleLit) + : undefined; + const visibleRoundPlays = Object.values(playedCards); + const hasConcealedRoundPlay = visibleRoundPlays.some(play => play?.concealed); + const hasCompleteRevealedRound = players.length > 0 + && visibleRoundPlays.length >= players.length + && !hasConcealedRoundPlay; + const shouldHideCurrentRoundPoints = ruleIncludesId(selectedRule, 'no_one_survives') + ? !hasCompleteRevealedRound + : hasConcealedRoundPlay; + const baseCurrentRoundPoints = visibleRoundPlays.reduce((total, play) => { + return total + calculateCardPoints(play?.cards || [], currentRoundPointResolver); + }, 0); + const oddEvenRoundMultiplier = getOddEvenRoundMultiplier(selectedRule, currentRoundNumber); + const currentRoundPoints = shouldHideCurrentRoundPoints + ? 0 + : baseCurrentRoundPoints * oddEvenRoundMultiplier; + const positionByPlayerId = Object.fromEntries( + Object.entries(positions) + .filter(([, player]) => player?.id) + .map(([position, player]) => [player.id, position]) + ); + const exchangeCoordinates = { + top: { x: '50%', y: '11%' }, + right: { x: '91%', y: '50%' }, + bottom: { x: '50%', y: '91%' }, + left: { x: '9%', y: '50%' } + }; + const myExchangeTarget = cardExchange + ? players.find(player => player.id === cardExchange.targetByPlayerId?.[currentPlayer?.id]) + : null; // 获取花色符号 const getSuitSymbol = (suit) => { @@ -110,7 +319,7 @@ export default function GameTable({ // 获取花色颜色 const getSuitColor = (suit) => { - return (suit === 'hearts' || suit === 'diamonds') ? 'red' : 'black'; + return (suit === 'hearts' || suit === 'diamonds') ? '#ff4d4f' : '#f5f5f5'; }; // 获取花色符号(扩展版) @@ -120,12 +329,100 @@ export default function GameTable({ diamonds: '♦', clubs: '♣', spades: '♠', + trump: '主', joker: '王', no_trump: '无主' }; return symbols[suit] || suit; }; + const getPublicCardLabel = card => { + if (!card) return '未知牌'; + if (card.rank === 'small_joker') return '小王'; + if (card.rank === 'big_joker') return '大王'; + if (card.rank === 'white_joker') return '白王'; + return `${getSuitSymbolExtended(card.suit)}${card.rank}`; + }; + + const waitingRabbit = ruleIncludesId(selectedRule, 'waiting_rabbit') + ? ruleRuntimeStatus?.waitingRabbit || null + : null; + const getWaitingRabbitRecordText = record => { + switch (record.type) { + case 'target_locked': + return record.playerId === currentPlayer?.id && waitingRabbit?.ownPrivateTarget + ? `${record.playerName} 已暗选 ${getPublicCardLabel(waitingRabbit.ownPrivateTarget)}` + : `${record.playerName} 已完成暗选`; + case 'target_triggered': + return `${record.sourcePlayerName} 打出 ${record.playerName} 的目标 ${getPublicCardLabel(record.targetCard)}`; + case 'target_exchanged': + return `${record.playerName} 用 ${getPublicCardLabel(record.discardedCard)} 换回 ${getPublicCardLabel(record.targetCard)}`; + case 'exchange_declined': + return `${record.playerName} 放弃换回 ${getPublicCardLabel(record.targetCard)}`; + default: + return `${record.playerName || '玩家'} 完成守株待兔操作`; + } + }; + + const getPlayerTeamIndex = player => { + const fixedTeamIndex = ruleRuntimeStatus?.happyTwins + ?.teamIndexByPlayerId?.[player?.id]; + if (Number.isInteger(fixedTeamIndex)) return fixedTeamIndex; + const playerIndex = players.findIndex(candidate => candidate.id === player?.id); + return playerIndex >= 0 ? playerIndex % 2 : null; + }; + + const getPlayerTrumpSuit = player => { + if (!oneCountryTwoSystems) return trumpSuit; + if (oneCountryTwoSystems.hasJokerDeclaration || oneCountryResolution?.isNoTrump) { + return 'no_trump'; + } + const teamIndex = getPlayerTeamIndex(player); + const resolvedSuit = oneCountryResolution?.teamTrumpSuits?.[teamIndex]; + if (resolvedSuit) return resolvedSuit; + + const declaredSuits = [...new Set( + Object.values(oneCountryDeclarations) + .map(declaration => declaration?.suit) + .filter(suit => suit && suit !== 'joker') + )]; + if (declaredSuits.length === 1) return declaredSuits[0]; + return oneCountryDeclarations?.[teamIndex]?.suit || trumpSuit; + }; + + const getPlayerTrumpDeclaration = player => { + if (!oneCountryTwoSystems) return currentTrumpDeclaration; + const teamIndex = getPlayerTeamIndex(player); + return oneCountryDeclarations?.[teamIndex] || null; + }; + const getPlayerInferiorDeclaration = player => ( + currentInferiorDeclaration?.playerId === player?.id + ? currentInferiorDeclaration + : null + ); + + const oneCountryTrumpRows = (() => { + if (!oneCountryTwoSystems) return []; + const dealerTeamIndex = oneCountryResolution?.dealerTeamIndex + ?? (Number.isInteger(dealerPlayerIndex) ? dealerPlayerIndex % 2 : null); + return [0, 1].map(teamIndex => { + const declaration = oneCountryDeclarations?.[teamIndex]; + const resolvedSuit = oneCountryResolution?.teamTrumpSuits?.[teamIndex]; + const suit = oneCountryTwoSystems.hasJokerDeclaration || oneCountryResolution?.isNoTrump + ? 'no_trump' + : resolvedSuit || declaration?.suit || null; + const label = dealerTeamIndex === null + ? `队伍${teamIndex + 1}` + : teamIndex === dealerTeamIndex ? '庄家方' : '闲家方'; + return { + teamIndex, + label, + suit, + playerName: declaration?.playerName || null + }; + }); + })(); + // 渲染单个玩家区域 const renderPlayerArea = (player, position) => { if (!player) return null; @@ -135,7 +432,28 @@ export default function GameTable({ const shown = shownCards[player.id]; // 检查是否是当前亮主的玩家 - const hasDeclaredTrump = currentTrumpDeclaration && currentTrumpDeclaration.playerId === player.id; + const playerTrumpDeclaration = getPlayerTrumpDeclaration(player); + const playerTrumpSuit = getPlayerTrumpSuit(player); + const hasDeclaredTrump = playerTrumpDeclaration?.playerId === player.id; + const playerInferiorDeclaration = getPlayerInferiorDeclaration(player); + const isOpenHand = hasRevealedHand(player); + const isSecondaryBuryingPlayer = player.id === secondaryBuryingPlayerId; + const isKnownFocusFigure = knownFocusPlayerIds.has(player.id); + const isDreamKillingSleeping = dreamKillingSleepingPlayerIds.has(player.id); + const hasTeammateCheerBuff = teammateCheerBuffedPlayerIds.has(player.id); + const hasAfterglow = afterglowActivePlayerIds.has(player.id); + const defenseAsOffenseDelta = defenseAsOffense?.playerId === player.id + ? Number(defenseAsOffense.delta) || 0 + : 0; + const isLureTigerSilenced = lureTigerSilencedPlayerIds.has(player.id); + const focusCapturedPoints = Number(focusCapturedPointsByPlayerId[player.id]) || 0; + const isSelectableTarget = Boolean( + playerTargeting?.active + && (playerTargeting?.allowSelf || player.id !== currentPlayer?.id) + && (playerTargeting?.requireCards === false || player.cardsCount > 0) + ); + const isSelectedTarget = playerTargeting?.targetPlayerId === player.id + || playerTargeting?.selectedPlayerIds?.includes(player.id); // 检查是否是庄家 // 优先使用 dealerPlayerIndex(从第二局开始就知道了) @@ -150,55 +468,105 @@ export default function GameTable({ } return ( -
+
onPlayerTargetClick?.(player) : undefined} + role={isSelectableTarget ? 'button' : undefined} + tabIndex={isSelectableTarget ? 0 : undefined} + aria-label={isSelectableTarget + ? `${playerTargeting?.label || '选择玩家'}:${player.name}` + : undefined} + onKeyDown={isSelectableTarget ? (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onPlayerTargetClick?.(player); + } + } : undefined} + >
- {player.name} - {isDealer && ( - - 庄 + + - )} - {isWaitingForReady && player.isReady !== undefined && ( - } + {isSecondaryBuryingPlayer && } + {isOpenHand && } + {isWaitingForReady && player.isReady !== undefined && ( + + {player.isReady ? '✓' : '○'} + + )} + + {player.name} + {renderStriveUpstreamOrderBadge(player)} + {defenseAsOffenseDelta > 0 && ( + - {player.isReady ? '✓' : '○'} - + +{defenseAsOffenseDelta} + + )} + {isKnownFocusFigure && ( + 焦点 + )} + {isDreamKillingSleeping && ( + 梦中 + )} + {hasTeammateCheerBuff && ( + + 加油+1 + + )} + {hasAfterglow && ( + + 返照+1 + + )} + {isLureTigerSilenced && ( + + 沉默 + )}
{isCurrentTurn && (出牌中)}
手牌: {player.cardsCount || 0} -
- 分数: {player.score || 0} | 等级: {player.level || 2} + {showFocusFigureProgress && ( +
+ 被闲家收走 {focusCapturedPoints} 分 +
+ )}
{/* 亮主区域 - 其他玩家的亮主在这里居中显示 */}
- {hasDeclaredTrump && currentTrumpDeclaration.cards && currentTrumpDeclaration.cards.length > 0 && ( - + {hasDeclaredTrump && playerTrumpDeclaration.cards && playerTrumpDeclaration.cards.length > 0 && ( + + )} + {playerInferiorDeclaration?.cards?.length > 0 && ( +
+ +
)}
{shown && shown.cards && shown.cards.length > 0 && (
展示: - +
)}
@@ -206,6 +574,26 @@ export default function GameTable({ ); }; + const renderOpenHand = (player, position) => { + if (!player) return null; + const isControlledOpenHand = openHand?.playerId === player.id; + const visibleHand = ruleVisibleHandsByPlayerId.get(player.id); + const revealedHand = isControlledOpenHand ? openHand : visibleHand; + if (!revealedHand) return null; + return ( + + ); + }; + // 判断我方队伍(索引0和2是队伍1,索引1和3是队伍2) const getTeamLabels = () => { if (!currentPlayer || !players || players.length === 0) { @@ -217,8 +605,8 @@ export default function GameTable({ return { myTeamLabel: '我方', opponentTeamLabel: '对方', myTeamLevel: team1Level, opponentTeamLevel: team2Level }; } - // 索引0和2是队伍1,索引1和3是队伍2 - const myTeam = myIndex % 2 === 0 ? 1 : 2; + // 欢乐成双只换座位,队伍仍按换位前的固定玩家组合。 + const myTeam = (getPlayerTeamIndex(currentPlayer) ?? (myIndex % 2)) + 1; if (myTeam === 1) { return { @@ -245,19 +633,279 @@ export default function GameTable({ const played = playedCards[player.id]; // 始终渲染出牌区域,即使没有牌,以保持布局稳定 + const hasPlayedCards = Boolean(played && (played.cards?.length > 0 || played.concealed)); + const isWinningPlay = player.id === currentWinningPlayerId && played?.cards?.length > 0; + const isTrumpActionPlayer = trumpAnimation?.playerId === player.id; + const isTrumpActionTarget = trumpAnimation?.targetPlayerId === player.id; return ( -
- {played && played.cards && played.cards.length > 0 && ( - +
+ {isTrumpActionTarget && ( + + )} + {played?.ambiguousOptions?.length === 2 && ( +
+ {played.ambiguousOptions.map(option => ( +
+ + {option.index === 0 ? 'A' : 'B'} + + +
+ ))} +
+ )} + {(!played?.ambiguousOptions || played.ambiguousOptions.length !== 2) + && played?.cards?.length > 0 && ( + + )} + {played?.concealed && !played?.ownConcealedCards && ( +
+ {Array.from({ length: played.cardsCount || 0 }, (_, index) => ( + + ))} + 暗置 · {played.cardsCount || 0} +
+ )} + {played?.treatedAsSmall && ( + + {played.activeSkillName || '李代桃僵'} · 小 + + )} + {played?.lureTigerSilenced && ( + + 默 · 不比大小 · 不计分 + + )} + {played?.jokerSubstitutions?.length > 0 && ( + + {played.jokerSubstitutions.map(substitution => { + const suit = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[substitution.suit] || ''; + return `王→${substitution.rank}${suit}`; + }).join(' · ')} + + )} + {played?.clusterAnalysisSubstitutions?.length > 0 && ( + + 聚 · {played.clusterAnalysisSubstitutions + .map(substitution => `${substitution.fromRank}→${substitution.toRank}`) + .join(' · ')} + + )} + {played?.enduringInheritance && ( +
+ + 上轮牌力 + +
+ )} + {played?.averagePooling && ( + 均 · 队友平均 + )} + {played?.jointHarmony && ( + 合 · 珠联璧合 + )} + {played?.dreamKilling?.success && ( + 醒 · 视为最大 + )} + {played?.oldHorseAbsolute && ( + 骥 · 绝对最大 + )} + {played?.magicTrickSwapped && ( + 术 · 结算换位 + )} + {isTrumpActionPlayer && ( +
+ {trumpAnimation.type === 'overtrump' ? '盖毙' : '毙了'} +
)}
); }; + const renderSecondBattlefieldStagedArea = (player, position) => { + if (!player || !ruleIncludesId(selectedRule, 'second_battlefield')) return null; + const currentCardIds = new Set( + (playedCards[player.id]?.cards || []).map(card => card.id).filter(Boolean) + ); + const stagedCards = ( + displayedSecondBattlefieldAccumulatedCards?.[player.id] || [] + ).filter(card => !currentCardIds.has(card.id)); + if (stagedCards.length === 0) return null; + + return ( +
+ 待比牌 +
+ {sortCards(stagedCards, trumpSuit, trumpRank).map((card, index) => ( + + + + ))} +
+
+ ); + }; + return ( -
+
+ {cardExchange && ( +
+ {cardExchange.ruleName} + + {cardExchange.submittedPlayerIds?.includes(currentPlayer?.id) + ? `等待其他玩家 · ${cardExchange.submittedPlayerIds.length}/${players.length}` + : cardExchange.operation === 'discard' + ? `请选择 ${cardExchange.requiredCards} 张牌暗中弃置` + : `请选择 ${cardExchange.requiredCards} 张牌交给 ${myExchangeTarget?.name || '目标玩家'}`} + +
+ )} + + {mainstayAction && ( +
+ 中流砥柱 + + {mainstayAction.stage === 'decision' + ? `等待${mainstayActor?.name || '当前玩家'}决定是否发动` + : mainstayAction.stage === 'give' + ? `等待${mainstayChooser?.name || '当前玩家'}交出包含全部主牌的5张牌` + : `等待${mainstayChooser?.name || '队友'}返还5张牌`} + +
+ )} + + {cardExchangeAnimation && ( +
+
+ {cardExchangeAnimation.title || cardExchangeAnimation.ruleName} +
+ {(cardExchangeAnimation.transfers || []).flatMap((transfer, transferIndex) => { + const isPlannedEconomyDraw = cardExchangeAnimation.kind === 'planned_economy_draw'; + const start = isPlannedEconomyDraw + ? { x: '50%', y: '50%' } + : exchangeCoordinates[positionByPlayerId[transfer.fromPlayerId]]; + const discardEnds = [ + { x: '49%', y: '49%' }, + { x: '51%', y: '49%' }, + { x: '49%', y: '51%' }, + { x: '51%', y: '51%' } + ]; + const end = cardExchangeAnimation.kind === 'discard' + ? discardEnds[transferIndex % discardEnds.length] + : exchangeCoordinates[positionByPlayerId[transfer.toPlayerId]]; + if (!start || !end) return []; + const visualCardCount = cardExchangeAnimation.kind === 'whole_hand' + ? Math.min(7, transfer.cardsCount || 0) + : isPlannedEconomyDraw + ? 1 + : (transfer.cardsCount || 2); + return Array.from({ length: visualCardCount }, (_, cardIndex) => ( + + )); + })} +
+ )} + {/* 左上角得分和等级显示 - 始终显示 */} -
- 闲家得分 + + {isFocusFigureRule ? '闲家收牌' : '闲家得分'} +
- {attackerScore} 分 + {isFocusFigureRule || isLostInFogScoringHidden ? ( + + {isLostInFogScoringHidden ? '分值终局揭晓' : '实际得分终局揭晓'} + + ) : ( + `${attackerScore} 分` + )}
+ {tenSidedAmbush && ( +
0 ? 'is-negative-score' : ambushAttackerNetCardCount < 0 ? 'is-positive-score' : ''}`} + aria-label={`伏击点数 ${tenSidedAmbush.rank || '未揭晓'},闲家净拿 ${ambushAttackerNetCardCount} 张`} + > + 伏击 {tenSidedAmbush.rank || '?'} + + 闲家净拿 {ambushAttackerNetCardCount > 0 ? '+' : ''}{ambushAttackerNetCardCount} 张 + +
+ )}
- 分数牌 ({collectedPointCards.length}): + {isLostInFogScoringHidden ? '得分牌:' : `分数牌 (${collectedPointCards.length}):`} - {collectedPointCards.length > 0 ? ( -
+ {isLostInFogScoringHidden ? ( + + 牌面与分值已隐藏 + + ) : collectedPointCards.length > 0 ? ( +
{collectedPointCards.map((card, index) => ( -
- +
+
))}
@@ -334,9 +1001,11 @@ export default function GameTable({ {/* 上方玩家 */} {positions.top && ( -
+
{renderPlayerArea(positions.top, 'top')} + {renderOpenHand(positions.top, 'top')} {renderPlayedCardsArea(positions.top, 'top')} + {renderSecondBattlefieldStagedArea(positions.top, 'top')}
)} @@ -344,32 +1013,161 @@ export default function GameTable({
{/* 左边玩家 */} {positions.left && ( -
- {renderPlayerArea(positions.left, 'left')} +
+
+ {renderPlayerArea(positions.left, 'left')} + {renderSecondBattlefieldStagedArea(positions.left, 'left')} +
+ {renderOpenHand(positions.left, 'left')} {renderPlayedCardsArea(positions.left, 'left')}
)} {/* 中央桌面 */} -
-
+
+ {!isSettlementView && !isLostInFogScoringHidden && currentRoundPoints > 0 + && !ruleIncludesId(selectedRule, 'second_battlefield') && ( +
+ 本轮 + {currentRoundPoints} + +
+ )} + {isPublicBottomVisible && ( +
+
+ 昭然若揭 · 明置底牌 + + {ruleRuntimeStatus?.phase === 'drawing' ? '摸牌开始即公开' : '所有玩家随时可见'} + +
+ +
+ )} + {ruleIncludesId(selectedRule, 'second_battlefield') && displayedSecondBattlefieldCards?.length === 5 && ( +
+
+
+ + 第二战场 · {isHoldingSecondBattlefieldResult + ? `第${secondBattlefield.lastResult.showdownNumber}场判定` + : secondBattlefield.lastResult?.isFinal + ? `最终第${secondBattlefield.showdownCount}场` + : `第${secondBattlefield.showdownCount + 1}场`} + + {isHoldingSecondBattlefieldResult + ? `${secondBattlefield.lastResult.winnerPlayerNames.join('、')} · ${secondBattlefield.lastResult.winningCategoryName}` + : secondBattlefield.lastResult?.isFinal + ? '最终场已由系统判定' + : secondBattlefield.isFinalStage + ? '残局锁定 · 出完后最终开牌' + : '五张公共牌已全部亮出'} +
+
+
+ {displayedSecondBattlefieldCards.map(card => ( + + ))} +
+ {!isSettlementView && !isLostInFogScoringHidden && currentRoundPoints > 0 && ( +
+ 本轮分数 + {currentRoundPoints} + +
+ )} + {secondBattlefield.lastResult && ( +
+ 上场:{secondBattlefield.lastResult.winnerPlayerNames.join('、')} + · {secondBattlefield.lastResult.winningCategoryName} + · {secondBattlefield.lastResult.scoreDelta > 0 + ? '闲家+5分' + : secondBattlefield.lastResult.scoreDelta < 0 + ? '庄家+5分' + : '跨阵营平分'} +
+ )} +
+ )} + {!isSettlementView && ruleIncludesId(selectedRule, 'divine_weapon') && divineWeapon?.cards?.length > 0 && ( +
+
+ 本轮神兵 + {divineWeapon.usedThisRound ? '已发动·轮末两张全换' : canSelectDivineWeapon ? '请选择一张' : '可供转化'} +
+
+ {divineWeapon.cards.map(card => ( +
onDivineWeaponCardClick?.(card.id) : undefined} + onKeyDown={canSelectDivineWeapon ? event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onDivineWeaponCardClick?.(card.id); + } + } : undefined} + > + +
+ ))} +
+
+ )} +
{/* 底牌展示(优先显示) */} {revealedBottomCards && revealedBottomCards.length > 0 ? ( -
- +
+ 底牌: - +
+ +
{/* 底牌得分结果 */} {bottomScoreResult && ( -
{bottomScoreResult.resultText} - {bottomScoreResult.attackerWonBottom && ( + {bottomScoreResult.peopleCommune ? ( +
+ + 庄家方埋分:{bottomScoreResult.peopleCommune.dealerBuriedPoints} 分  + 闲家方埋分:{bottomScoreResult.peopleCommune.attackerBuriedPoints} 分 + + = 0 ? '#95de64' : '#ff8f8f', fontSize: '14px' }}> + {bottomScoreResult.attackerWonBottom ? '闲家抄庄家底' : '庄家方抄闲家底'}: + {bottomScoreResult.bottomPoints} × {bottomScoreResult.bottomMultiplier} + + + 闲家分数变化:{bottomScoreResult.bottomScoreGained > 0 ? '+' : ''}{bottomScoreResult.bottomScoreGained} 分 + +
+ ) : bottomScoreResult.ambushCardCount > 0 ? ( +
+ + 常规底分:{bottomScoreResult.attackerWonBottom + ? `${bottomScoreResult.bottomPoints} × ${bottomScoreResult.bottomMultiplier} = ${bottomScoreResult.bottomPoints * bottomScoreResult.bottomMultiplier}` + : '0'} 分 + + 0 ? '#ffd666' : '#ff8f8f', fontSize: '14px' }}> + 伏击 {bottomScoreResult.ambushRank} × {bottomScoreResult.ambushCardCount}:闲家{bottomScoreResult.ambushScoreDelta > 0 ? '+' : ''}{bottomScoreResult.ambushScoreDelta} 分 + + + 底牌净变化:{bottomScoreResult.bottomScoreGained > 0 ? '+' : ''}{bottomScoreResult.bottomScoreGained} 分 + +
+ ) : bottomScoreResult.attackerWonBottom && ( 底牌 {bottomScoreResult.bottomPoints} 分 × {bottomScoreResult.bottomMultiplier} 倍 = {bottomScoreResult.bottomScoreGained} 分 )} + {bottomScoreResult.focusFigure && ( +
+
+ 焦点人物 · 终局揭晓 + + {bottomScoreResult.focusFigure.teams.map(team => ( + `${team.side === 'dealer' ? '庄家方' : '闲家方'}:${team.focusPlayerName}` + )).join(' ')} + +
+
+ {bottomScoreResult.focusFigure.players.map(player => ( +
+ {player.isFocus ? '★ ' : ''}{player.playerName} + + {player.capturedPoints} × {player.isFocus ? 2 : 0} = {player.countedPoints} + +
+ ))} +
+
+ 焦点逐墩 {bottomScoreResult.focusFigure.focusTrickScore} 分 + 正常底牌 {bottomScoreResult.focusFigure.normalBottomScore} 分 +
+
+ )} + {bottomScoreResult.abruptStop && ( +
+ 戛然而止 · 庄家余牌 + + {bottomScoreResult.abruptStop.dealerPlayerName} 剩余牌面分 + {' '}{bottomScoreResult.abruptStop.dealerRemainingPoints}, + 闲家获得一半 +{bottomScoreResult.abruptStop.attackerBonus} 分 + + {bottomScoreResult.abruptStop.dealerRemainingCards?.length > 0 && ( + + )} +
+ )}
- 闲家总分:{bottomScoreResult.totalScore} 分 + 闲家总分:{bottomScoreResult.scoreBeforeMistyFog + ?? bottomScoreResult.scoreBeforeLingeringDiscard + ?? bottomScoreResult.totalScore} 分
)} + {/* 迷雾牌在逐墩分和底牌分之后才公开并补分。 */} + {bottomScoreResult?.mistyFogCards?.length > 0 && ( +
+
+ 迷雾牌 · 终局公开 + 牌面 {bottomScoreResult.mistyFogPoints} 分 + 闲家补 +{bottomScoreResult.mistyFogBonus} 分 + 最终 {bottomScoreResult.totalScore} 分 +
+
+ +
+
+ )} + + {/* 庄家方暗弃的分牌只在终局公开。 */} + {bottomScoreResult?.lingeringDiscardCards?.length > 0 && ( +
+
+ 弃掷逦迤 · 分牌公开 + 牌面 {bottomScoreResult.lingeringDiscardPoints} 分 + 闲家补 +{bottomScoreResult.lingeringDiscardBonus} 分 + 最终 {bottomScoreResult.totalScore} 分 +
+
+ +
+
+ )} + {/* 升级结果 */} {upgradeResult && ( -
-
-
+
- 下一局庄家 + {upgradeResult.dealerContinues ? '势如破竹 · 庄家连庄' : '下一局庄家'} {upgradeResult.nextDealerName} (等级 {upgradeResult.nextDealerLevel}) @@ -462,26 +1383,61 @@ export default function GameTable({
{/* 主牌显示 */}
- 主牌: - {trumpSuit && trumpRank ? ( + 主牌: + {oneCountryTwoSystems ? ( +
+ {oneCountryTrumpRows.map(row => ( + + {row.label} {row.suit === 'no_trump' || row.suit === 'joker' + ? '无主' + : row.suit + ? `${getSuitSymbol(row.suit)} ${trumpRank || ''}` + : '未亮'} + + ))} +
+ ) : ( - {getSuitSymbol(trumpSuit)} {trumpRank} + {!trumpSuit || trumpSuit === 'no_trump' ? '无主' : getSuitSymbol(trumpSuit)}{trumpRank ? ` ${trumpRank}` : ''} - ) : ( - 未设置 - )} - {isHost && ( - )}
+ {ruleIncludesId(selectedRule, 'three_six_nine_grades') && ( +
+ 劣牌: + + {currentInferiorDeclaration?.suit + ? `${getSuitSymbol(currentInferiorDeclaration.suit)} ${trumpRank || ''}` + : '无劣花色'} + +
+ )} {/* 庄家倒计时显示 */} {dealerCountdown !== null && dealerCountdown > 0 && ( @@ -530,22 +1486,681 @@ export default function GameTable({ }}>
规则: - + {isRuleSelectionPending && onSelectRule && ( + + )}
{selectedRule ? (
{selectedRule.name} - + {Array.isArray(selectedRule.rules) && ( +
+ {selectedRule.rules.map(rule => ( + {rule.name} + ))} +
+ )} + {ruleIncludesId(selectedRule, 'route_swing') && ruleRuntimeStatus?.phase === 'playing' && ( +
+ + {ruleRuntimeStatus?.turnDirection === 'clockwise' ? '↻' : '↺'} + + 当前:{ruleRuntimeStatus?.turnDirection === 'clockwise' ? '顺时针' : '逆时针'} +
+ )} + {ruleIncludesId(selectedRule, 'day_night_rotation') && ruleRuntimeStatus?.phase === 'playing' && ( +
+ + 第{Math.max(1, ruleRuntimeStatus?.currentRound || 1)}轮 · 当前最大点数: + {ruleRuntimeStatus?.dayNightHighestRank || '—'} +
+ )} + {ruleIncludesId(selectedRule, 'encircle_three_missing_one') && encircleThreeMissingOne && ( +
+ + + 记录:{encircleThreeMissingOne.seenSuits?.length + ? encircleThreeMissingOne.seenSuits.map(getSuitSymbolExtended).join(' ') + : '—'} ({encircleThreeMissingOne.seenSuits?.length || 0}/3) + +
+ )} + {ruleIncludesId(selectedRule, 'waiting_rabbit') && waitingRabbit && ( +
+
+ + 行为记录 +
+
+ {(waitingRabbit.behaviorRecords || []).length > 0 ? ( + waitingRabbit.behaviorRecords.map(record => ( +
+ {record.type === 'target_locked' ? '暗选' : `第${record.round}轮`} + {getWaitingRabbitRecordText(record)} +
+ )) + ) : ( +
+ 等待四家暗选目标牌 +
+ )} +
+
+ )} + {ruleIncludesId(selectedRule, 'strength_compensation') && ruleRuntimeStatus?.strengthCompensation && ( +
+
+ 第{ruleRuntimeStatus.strengthCompensation.round}轮 +
+
+
+ +1 + {ruleRuntimeStatus.strengthCompensation.plusSeatNumber}号位 + player.id === ruleRuntimeStatus.strengthCompensation.plusPlayerId)?.name || ''}> + {players.find(player => player.id === ruleRuntimeStatus.strengthCompensation.plusPlayerId)?.name || '未知玩家'} + +
+
+ −1 + {ruleRuntimeStatus.strengthCompensation.minusSeatNumber}号位 + player.id === ruleRuntimeStatus.strengthCompensation.minusPlayerId)?.name || ''}> + {players.find(player => player.id === ruleRuntimeStatus.strengthCompensation.minusPlayerId)?.name || '未知玩家'} + +
+
+
+ )} + {ruleIncludesId(selectedRule, 'odd_even_scoring') && ruleRuntimeStatus?.phase === 'playing' && ( +
+ + {oddEvenRoundMultiplier === 2 ? '双' : '零'} + + 第{currentRoundNumber}轮 · {oddEvenRoundMultiplier === 2 ? '偶数轮' : '奇数轮'} + {oddEvenRoundMultiplier === 2 ? '本轮双倍' : '本轮0分'} +
+ )} + {ruleIncludesId(selectedRule, 'candle_to_dawn') && candleToDawn && ( +
+ + + + {candleToDawn.isSelectionPending + ? '等待选择初始烛态' + : `第${displayedCandleRoundNumber}轮 · 烛已${displayedCandleLit ? '点燃' : '熄灭'}`} + + + {candleToDawn.isSelectionPending + ? '确定后才能开始第1轮' + : displayedCandleLit + ? '红色分牌 +5 · 黑色分牌 −5' + : '黑色分牌 +5 · 红色分牌 −5'} + + 小王黑 · 大王红 + +
+ )} + {ruleIncludesId(selectedRule, 'cultural_revolution') && ruleRuntimeStatus?.culturalRevolution && ( +
+ {ruleRuntimeStatus.culturalRevolution.declaration ? ( + <> +
+ + + 第{ruleRuntimeStatus.culturalRevolution.declaration.activatedRound}– + {ruleRuntimeStatus.culturalRevolution.declaration.expiresAfterRound}轮 + +
+
+ {ruleRuntimeStatus.culturalRevolution.declaration.declarationType === 'suit' + ? '革花色' + : '革点数'} + + {ruleRuntimeStatus.culturalRevolution.declaration.declarationType === 'suit' + ? getSuitSymbolExtended(ruleRuntimeStatus.culturalRevolution.declaration.value) + : ruleRuntimeStatus.culturalRevolution.declaration.value} + +
+ + 原主: + {getSuitSymbolExtended(ruleRuntimeStatus.culturalRevolution.baseTrumpSuit)} + {' '}{ruleRuntimeStatus.culturalRevolution.baseTrumpRank || '—'};被替换项暂为副牌 + + + ) : ( + <> +
+ + 尚未发动 +
+ 一号位可选择革花色或革点数 + + )} +
+ )} + {ruleIncludesId(selectedRule, 'three_tigers') && ( +
+
+ + 第{displayedThreeTigers?.round || currentRoundNumber}轮 +
+ {displayedThreeTigers?.triggeredSuit ? ( + <> +
+ {getSuitSymbolExtended(displayedThreeTigers.triggeredSuit)} + 已成虎 +
+ + {(displayedThreeTigers.contributingPlayerIds || []).length}人同花色 · 视为主牌,牌面−4 + + + ) : ( + <> +
+ 待成虎 + {threeTigersProgress?.[1] > 0 && ( + + {getSuitSymbolExtended(threeTigersProgress[0])} {threeTigersProgress[1]}/3 + + )} +
+ + {threeTigersProgress?.[1] > 0 + ? '同花色整手再累计至三人即刻转换' + : '等待本轮同花色整手出牌'} + + + )} +
+ )} + {ruleIncludesId(selectedRule, 'invite_into_urn') && ( +
+ + {displayedInviteDeclarations.length > 0 ? ( + + {displayedInviteDeclarations[0].targetPlayerName} · {' '} + + {displayedInviteDeclarations[0].rank === 'small_joker' + ? '小王' + : displayedInviteDeclarations[0].rank === 'big_joker' + ? '大王' + : `${displayedInviteDeclarations[0].rank}${getSuitSymbolExtended(displayedInviteDeclarations[0].suit)}`} + + {displayedInviteDeclarations[0].triggered === true + ? ' · 已命中,扣5分' + : displayedInviteDeclarations[0].triggered === false + ? ' · 未命中' + : ' · 本轮监视中'} + + ) : ( + 一号位可指定玩家与牌面 + )} +
+ )} + {ruleIncludesId(selectedRule, 'old_horse_still_has_strength') && oldHorse && ( +
+ + + 已获牌权 {oldHorse.rightHolderPlayerIds?.length || 0}/{players.length} + + + {oldHorse.protectedPlayerId + ? `${players.find(player => player.id === oldHorse.protectedPlayerId)?.name || '目标玩家'}下次首发绝大` + : oldHorse.lastAbsolutePlay + ? '绝大已发动' + : '等待最后一人'} + +
+ )} + {ruleIncludesId(selectedRule, 'trump_wins') && ( +
+ T + {trumpWins?.lastResult?.leaderPlayerId ? ( + + 上轮:{trumpWins.lastResult.leaderPlayerName} {' '} + {trumpWins.lastResult.highestPoints}分获权 + + ) : ( + 本轮按各家出牌分值争夺牌权 + )} +
+ )} + {ruleIncludesId(selectedRule, 'straw_boat_borrowing_arrows') && ( +
+ + {strawBoatBorrowingArrows?.pending ? ( + + {strawBoatBorrowingArrows.pending.playerName} 正在决定 · {' '} + + 可取 {getPublicCardLabel(strawBoatBorrowingArrows.pending.borrowedCard)} + + + ) : strawBoatBorrowingArrows?.lastResult?.accepted ? ( + + {strawBoatBorrowingArrows.lastResult.playerName}: + + {getPublicCardLabel(strawBoatBorrowingArrows.lastResult.discardedCard)} + {' → '} + {getPublicCardLabel(strawBoatBorrowingArrows.lastResult.borrowedCard)} + + + ) : strawBoatBorrowingArrows?.lastResult ? ( + {strawBoatBorrowingArrows.lastResult.playerName} 上轮放弃发动 + ) : ( + 首置至少10分失守时,可公开弃牌取箭 + )} +
+ )} + {ruleIncludesId(selectedRule, 'bush_gate') && ( +
+ + {bushGate?.restriction ? ( + + {bushGate.restriction.leaderPlayerName} 重新首发中 · 禁用 {' '} + + {(bushGate.restriction.returnedCards || []) + .map(getPublicCardLabel) + .join('、')} + + + ) : bushGate?.lastResult ? ( + + {bushGate.lastResult.activatorPlayerName} 令 {' '} + {bushGate.lastResult.leaderPlayerName} 收回重出 + {bushGate.lastResult.replayCompleted ? ' · 已完成' : ''} + + ) : ( + 二号位可令一号位收回首发并改出其他牌 + )} +
+ )} + {ruleIncludesId(selectedRule, 'ten_sided_ambush') && tenSidedAmbush && ( +
+ + {tenSidedAmbush.isSelectionPending + ? '布置中' + : tenSidedAmbush.rank + ? '伏击点数' + : '伏兵未现'} + + {tenSidedAmbush.rank ? ( + {tenSidedAmbush.rank} + ) : ( + ? + )} + + {tenSidedAmbush.isSelectionPending + ? '等待庄家队友暗选' + : tenSidedAmbush.isPrivate + ? '仅你可见' + : tenSidedAmbush.isRevealed + ? '已向全场揭晓' + : '首次出现时揭晓'} + +
+ )} + {ruleIncludesId(selectedRule, 'three_powers') && threePowers && ( +
+ 重载分牌 +
+ {(threePowers.slots || []).map(slot => ( +
+ {slot.sourceRank} + {slot.rank || '?'} + + {slot.isPrivate + ? '仅你可见' + : slot.isRevealed + ? `${slot.pointValue}分` + : '待揭晓'} + +
+ ))} +
+
+ )} + {ruleIncludesId(selectedRule, 'gentleman_promise') && ruleRuntimeStatus?.gentlemanPromise && ( +
+ 最短有效花色 +
+ {players.map(player => { + const declaration = ruleRuntimeStatus.gentlemanPromise + ?.declarationsByPlayerId?.[player.id]; + const isPending = ruleRuntimeStatus.gentlemanPromise + ?.pendingPlayerIds?.includes(player.id); + return ( +
+ {player.name} + {declaration ? getSuitSymbolExtended(declaration) : '…'} +
+ ); + })} +
+
+ )} + {ruleIncludesId(selectedRule, 'hidden_dragon_in_abyss') && ruleRuntimeStatus?.hiddenDragon && ( +
+ 潜龙点数 +
+ {players.map(player => { + const declaration = ruleRuntimeStatus.hiddenDragon + ?.declarationsByPlayerId?.[player.id]; + const isPending = ruleRuntimeStatus.hiddenDragon + ?.pendingPlayerIds?.includes(player.id); + const hasPlayedDeclaredRank = ruleRuntimeStatus.hiddenDragon + ?.playedDeclaredRankByPlayerId?.[player.id]; + const result = ruleRuntimeStatus.hiddenDragon + ?.results?.find(item => item.playerId === player.id); + return ( +
+ {player.name} + {declaration || '…'} + + {isPending + ? '待声明' + : result + ? result.success ? '+10' : '0' + : hasPlayedDeclaredRank ? '已打出' : '未打出'} + +
+ ); + })} +
+
+ )} + {ruleIncludesId(selectedRule, 'antinomy') && ruleRuntimeStatus?.antinomy && ( +
+ 二律背反声明 +
+ {players.map(player => { + const declaration = ruleRuntimeStatus.antinomy + ?.declarationsByPlayerId?.[player.id]; + const isPending = ruleRuntimeStatus.antinomy + ?.pendingPlayerIds?.includes(player.id); + return ( +
+ {player.name} + + {declaration + ? `${getSuitSymbolExtended(declaration.suit)}${declaration.rank}` + : '…'} + + + {isPending + ? declaration ? '重选中' : '选择中' + : declaration?.effective + ? '拆对生效' + : declaration ? '重复·不拆对' : '待声明'} + +
+ ); + })} +
+
+ )} + {ruleIncludesId(selectedRule, 'destroy_dyke_flood_fields') && ruleRuntimeStatus?.destroyDyke && ( +
+ 毁堤淹田 +
+
+ {ruleRuntimeStatus.destroyDyke.pending ? ( + <> + 庄家决定中 + {ruleRuntimeStatus.destroyDyke.pending.roundPoints}分 + 计分已暂停 + + ) : ruleRuntimeStatus.destroyDyke.disaster ? ( + <> + + 灾期 {ruleRuntimeStatus.destroyDyke.disaster.roundsElapsed}/3 + + + {ruleRuntimeStatus.destroyDyke.disaster.disasterAttackerPoints}/20 + + + 已作废{ruleRuntimeStatus.destroyDyke.disaster.voidedPoints}分 + + + ) : ruleRuntimeStatus.destroyDyke.lastResult?.status === 'incident' ? ( + <> + 已事发 + + +{ruleRuntimeStatus.destroyDyke.lastResult.scoreDelta} + + 灾期结束 + + ) : ruleRuntimeStatus.destroyDyke.lastResult?.status === 'expired' ? ( + <> + 灾期结束 + 0 + + {ruleRuntimeStatus.destroyDyke.lastResult.voidedPoints}分永久作废 + + + ) : ( + <> + 庄家限一次 + 未发动 + 闲家赢轮后询问 + + )} +
+
+
+ )} + {ruleIncludesId(selectedRule, 'administrative_review') && ruleRuntimeStatus?.administrativeReview && ( +
+ 行政审查 +
+
+ + {players.find(player => ( + player.id === ruleRuntimeStatus.administrativeReview.suitSelectorPlayerId + ))?.name || '庄家下家'} · 副花色 + + + {ruleRuntimeStatus.administrativeReview.suit + ? getSuitSymbolExtended(ruleRuntimeStatus.administrativeReview.suit) + : '…'} + + + {ruleRuntimeStatus.administrativeReview.suitMatched ? '已满足' : '未满足'} + +
+
+ + {players.find(player => ( + player.id === ruleRuntimeStatus.administrativeReview.rankSelectorPlayerId + ))?.name || '庄家上家'} · 点数 + + {ruleRuntimeStatus.administrativeReview.rank || '…'} + + {ruleRuntimeStatus.administrativeReview.rankMatched ? '已满足' : '未满足'} + +
+
+
+ {ruleRuntimeStatus.administrativeReview.isBuried + ? '底牌已埋 · 牌局继续' + : ruleRuntimeStatus.administrativeReview.isBottomReleased + ? '条件满足 · 庄家正在埋底' + : '12张底牌封存 · 庄家不可查看'} +
+
+ )} + {ruleIncludesId(selectedRule, 'political_review') && ruleRuntimeStatus?.politicalReview && ( +
+ 政治审查 +
+ {players.map(player => { + const used = ruleRuntimeStatus.politicalReview.usedPlayerIds?.includes(player.id); + return ( +
+ {player.name} + + {used ? '已使用' : '未使用'} + +
+ ); + })} +
+ {ruleRuntimeStatus.politicalReview.pending && ( +
+ {ruleRuntimeStatus.politicalReview.pending.reviewerPlayerName} + 正在审查 + {ruleRuntimeStatus.politicalReview.pending.teammatePlayerName}: + {ruleRuntimeStatus.politicalReview.pending.cards.map(card => ( + card.suit === 'joker' + ? (card.rank === 'big_joker' ? '大王' : '小王') + : `${getSuitSymbolExtended(card.suit)}${card.rank}` + )).join('、')} +
+ )} +
+ )} + {ruleIncludesId(selectedRule, 'repeated_exhaustion') && ruleRuntimeStatus?.repeatedExhaustion && ( +
+ + + {players.find(player => player.id === ruleRuntimeStatus.repeatedExhaustion.playerId)?.name || '当前赢家'} + 连续 {ruleRuntimeStatus.repeatedExhaustion.streak} 轮 + + + {ruleRuntimeStatus.repeatedExhaustion.streak >= 2 + ? `再赢扣${Math.max(1, ruleRuntimeStatus.repeatedExhaustion.streak - 1) * 5}分` + : '尚未受罚'} + +
+ )} + {ruleIncludesId(selectedRule, 'planned_economy') && ruleRuntimeStatus?.plannedEconomy && ( +
+ + 封存牌 + {ruleRuntimeStatus.plannedEconomy.remainingCards} / 20 + + {ruleRuntimeStatus.plannedEconomy.isDrawingEnabled + ? '每轮结束四家各摸1张' + : '埋底完成前不摸牌'} + +
+ )} + {ruleIncludesId(selectedRule, 'wooden_ox_flowing_horse') && ruleRuntimeStatus?.woodenOx && ( +
+ {(ruleRuntimeStatus.woodenOx.mules || []).map(mule => { + const holder = players.find(player => player.id === mule.holderPlayerId); + return ( +
+ + {mule.teamIndex === ((dealerPlayerIndex ?? 0) % 2) + ? '庄家方' + : '闲家方'} + + {holder?.name || '未知玩家'} + {mule.hasStoredCard ? '已装牌' : '空'} + 往返 {mule.completedRoundTrips} / {mule.maxRoundTrips} +
+ ); + })} +
+ )} + {selectedRule.content}
) : ( - - 未选择规则 + + {isRuleSelectionPending + ? `等待 ${ruleChooser?.name || '指定玩家'} 选择规则` + : '未选择规则'} )}
@@ -556,8 +2171,12 @@ export default function GameTable({ {/* 右边玩家 */} {positions.right && ( -
- {renderPlayerArea(positions.right, 'right')} +
+
+ {renderPlayerArea(positions.right, 'right')} + {renderSecondBattlefieldStagedArea(positions.right, 'right')} +
+ {renderOpenHand(positions.right, 'right')} {renderPlayedCardsArea(positions.right, 'right')}
)} @@ -566,15 +2185,75 @@ export default function GameTable({ {/* 下方玩家(自己) */} {positions.bottom && (
+ {renderSecondBattlefieldStagedArea(positions.bottom, 'bottom')} {/* 我的出牌区域 - 在玩家框上方居中 */} {renderPlayedCardsArea(positions.bottom, 'bottom')} -
+
0) ? 'skill-targetable' : ''} ${playerTargeting?.selectedPlayerIds?.includes(positions.bottom.id) ? 'skill-target-selected' : ''}`} + data-player-id={positions.bottom.id} + onClick={playerTargeting?.active && playerTargeting?.allowSelf + && (playerTargeting?.requireCards === false || positions.bottom.cardsCount > 0) + ? () => onPlayerTargetClick?.(positions.bottom) + : undefined} + role={playerTargeting?.active && playerTargeting?.allowSelf ? 'button' : undefined} + tabIndex={playerTargeting?.active && playerTargeting?.allowSelf ? 0 : undefined} + aria-label={playerTargeting?.active && playerTargeting?.allowSelf + ? `${playerTargeting?.label || '选择玩家'}:${positions.bottom.name}` + : undefined} + onKeyDown={playerTargeting?.active && playerTargeting?.allowSelf ? (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onPlayerTargetClick?.(positions.bottom); + } + } : undefined} + > {/* 上半部分:玩家信息和控制按钮 */}
+ {positions.bottom.name} (我) + {renderStriveUpstreamOrderBadge(positions.bottom)} + {defenseAsOffense?.playerId === positions.bottom.id && Number(defenseAsOffense.delta) > 0 && ( + + +{defenseAsOffense.delta} + + )} + {knownFocusPlayerIds.has(positions.bottom.id) && ( + 焦点 + )} + {teammateCheerBuffedPlayerIds.has(positions.bottom.id) && ( + + 加油+1 + + )} + {afterglowActivePlayerIds.has(positions.bottom.id) && ( + + 返照+1 + + )} + {lureTigerSilencedPlayerIds.has(positions.bottom.id) && ( + + 沉默 + + )} + {showFocusFigureProgress && ( + + 被闲家收走 {Number(focusCapturedPointsByPlayerId[positions.bottom.id]) || 0} 分 + + )} + {positions.bottom.id === secondaryBuryingPlayerId && ( + 再埋底 + )} {(() => { // 检查是否是庄家 - 使用和其他位置相同的判断逻辑 let isDealer = false; @@ -611,8 +2290,22 @@ export default function GameTable({ {positions.bottom.isReady ? '✓ 已准备' : '○ 未准备'} )} + {openHand?.playerId === positions.bottom.id && ( + 明牌 · 由 {openHand.controllerPlayerName} 代打 + )} + {ruleVisibleHandsByPlayerId.has(positions.bottom.id) && ( + + {ruleVisibleHandsByPlayerId.get(positions.bottom.id).kind === 'partial' + ? `已明置 ${ruleVisibleHandsByPlayerId.get(positions.bottom.id).cards.length} 张` + : ruleVisibleHandsByPlayerId.get(positions.bottom.id).kind === 'jokers' + ? `二鬼拍门 · 明置 ${ruleVisibleHandsByPlayerId.get(positions.bottom.id).cards.length} 张王` + : ruleVisibleHandsByPlayerId.get(positions.bottom.id).label} + + )} + {dreamKillingSleepingPlayerIds.has(positions.bottom.id) && ( + 梦中 · 系统随机出牌 + )}
- 分数: {positions.bottom.score || 0} | 等级: {positions.bottom.level || 2}
{/* 控制按钮区域 - 右侧 */} @@ -632,22 +2325,67 @@ export default function GameTable({ {/* 自己的手牌区域 - 左端分一小块作为亮主区 */}
+ {woodenOxCard && ( +
+ 木牛流马 · 可保留后出 + onCardClick?.(woodenOxCard.id)} + disabled={disableMyHand || !onCardClick} + trumpSuit={getPlayerTrumpSuit(positions.bottom)} + trumpRank={trumpRank} + /> +
+ )} + {/* 亮主区域 - 只在有亮主时显示 */} - {currentTrumpDeclaration && currentTrumpDeclaration.playerId === positions.bottom.id && ( -
- {currentTrumpDeclaration.cards && currentTrumpDeclaration.cards.length > 0 && ( - + {getPlayerTrumpDeclaration(positions.bottom)?.playerId === positions.bottom.id && ( +
1 ? 'has-multiple-cards' : ''}`}> + {getPlayerTrumpDeclaration(positions.bottom)?.cards?.length > 0 && ( + )}
)} + {getPlayerInferiorDeclaration(positions.bottom)?.cards?.length > 0 && ( +
+ +
+ )} {/* 手牌区域 */}
card.id) + : [] + } onCardClick={onCardClick} + transformableCardIds={transformableCardIds} + onRequestCardTransformation={onRequestCardTransformation} + onCancelCardTransformation={onCancelCardTransformation} onReorder={onReorder} - trumpSuit={trumpSuit} + disabled={disableMyHand} + faceDown={dreamKillingSleepingPlayerIds.has(positions.bottom.id)} + anticipateNextCard + trumpSuit={getPlayerTrumpSuit(positions.bottom)} trumpRank={trumpRank} />
diff --git a/tractor-game-simulator/client/src/components/Game/Hand.css b/tractor-game-simulator/client/src/components/Game/Hand.css index d495c47..3b031ef 100644 --- a/tractor-game-simulator/client/src/components/Game/Hand.css +++ b/tractor-game-simulator/client/src/components/Game/Hand.css @@ -1,26 +1,27 @@ .hand { - --card-width: 80px; + --card-width: 78px; --card-overlap: 25px; display: flex; flex-direction: row; justify-content: center; /* 改为居中对齐 */ align-items: flex-end; - padding: 10px 5px; /* 减小内边距,增加可用空间 */ - min-height: 150px; + padding: 9px 5px 5px; + min-height: 138px; flex-wrap: nowrap; width: 100%; /* 添加100%宽度 */ max-width: 100%; box-sizing: border-box; /* 添加box-sizing */ overflow-x: visible; /* 允许溢出可见,而不是滚动 */ + position: relative; } /* 根据牌数自动调整间距 - 使用负margin-left让牌重叠 */ -.hand > * { +.hand > .card-wrapper { margin-left: calc(var(--card-overlap, 25px) * -1); flex-shrink: 0; } -.hand > *:first-child { +.hand > .card-wrapper:first-child { margin-left: 0; } @@ -64,20 +65,106 @@ --card-overlap: 38px; /* 小卡片33张时的重叠度 */ } +@media (max-width: 1180px) { + .hand.compact-1 { --card-overlap: 38px; } + .hand.compact-2 { --card-overlap: 46px; } + .hand.compact-3 { --card-overlap: 52px; } + .hand.compact-4 { --card-overlap: 57px; } +} + +@media (max-width: 780px) { + .hand.compact-1 { --card-overlap: 48px; } + .hand.compact-2 { --card-overlap: 57px; } + .hand.compact-3 { --card-overlap: 61px; } + .hand.compact-4 { --card-overlap: 64px; } +} + /* 拖拉机标记样式 */ .card-wrapper { position: relative; + isolation: isolate; display: inline-block; width: var(--card-width, 80px); + height: 110px; + line-height: 0; + vertical-align: bottom; flex-shrink: 0; } -.tractor-line { - pointer-events: none; - z-index: 10; +.card-wrapper.is-publicly-revealed .card { + border-color: #bd8618 !important; + background: + linear-gradient(118deg, rgba(255, 255, 255, 0.72), transparent 42%), + linear-gradient(180deg, #fff6bd 0%, #f5d76e 62%, #d9aa36 100%) !important; + box-shadow: 0 3px 0 #8c6418, 0 8px 16px rgba(0, 25, 18, 0.3), 0 0 0 2px rgba(255, 225, 111, 0.42) !important; + animation: revealedCardGlow 0.42s ease-out both; +} + +@keyframes revealedCardGlow { + from { filter: brightness(1.28); } + to { filter: brightness(1); } +} + +@media (prefers-reduced-motion: reduce) { + .card-wrapper.is-publicly-revealed .card { animation: none; } +} + +.hand.small .card-wrapper { + height: 70px; } -.tractor-label { +.card-wrapper > .card { + position: absolute; + left: 0; + top: 0; +} + +.my-hand .hand { + filter: drop-shadow(0 6px 6px rgba(0, 24, 18, 0.28)); +} + +.my-hand .card:not(.selected) { + box-shadow: 0 3px 0 #aeb3ad; +} + +.tractor-group-marker { + position: absolute; pointer-events: none; - z-index: 11; + z-index: 200; + height: 3px; + border-radius: 2px; + background: linear-gradient(90deg, #d98512, #f3b62e 18%, #f3b62e 82%, #d98512); + box-shadow: 0 1px 3px rgba(83, 43, 0, 0.48); +} + +.tractor-group-marker::before, +.tractor-group-marker::after { + content: ''; + position: absolute; + top: -2px; + width: 3px; + height: 7px; + border-radius: 2px; + background: #f3b62e; +} + +.tractor-group-marker::before { left: 0; } +.tractor-group-marker::after { right: 0; } + +.tractor-group-label { + position: absolute; + left: 50%; + top: 50%; + padding: 3px 9px 4px; + border: 1px solid rgba(247, 195, 72, 0.72); + border-radius: 7px; + color: #ffe36d; + background: rgba(55, 47, 28, 0.88); + box-shadow: 0 2px 6px rgba(21, 17, 7, 0.32); + backdrop-filter: blur(3px); + font-size: 12px; + font-weight: 800; + line-height: 1; + white-space: nowrap; + transform: translate(-50%, -50%); } diff --git a/tractor-game-simulator/client/src/components/Game/Hand.jsx b/tractor-game-simulator/client/src/components/Game/Hand.jsx index 3a1c9f3..a2cece7 100644 --- a/tractor-game-simulator/client/src/components/Game/Hand.jsx +++ b/tractor-game-simulator/client/src/components/Game/Hand.jsx @@ -1,11 +1,14 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useLayoutEffect, useRef } from 'react'; import Card from './Card'; import { getCardStrength, getEffectiveSuit } from '../../utils/cardPatternUtils'; import { RANK_ORDER } from '../../utils/constants.js'; import './Hand.css'; -export default function Hand({ cards, selectedCards = [], onCardClick, disabled = false, small = false, onReorder, trumpSuit = null, trumpRank = null }) { +export default function Hand({ cards, selectedCards = [], disabledCardIds = [], disabledCardReason = '', virtualizedCardIds = [], revealedCardIds = [], transformableCardIds = [], onCardClick, onRequestCardTransformation, onCancelCardTransformation, disabled = false, faceDown = false, small = false, anticipateNextCard = false, showWinningBadge = false, onReorder, trumpSuit = null, trumpRank = null, minimumVisibleWidth = null }) { const [draggedCard, setDraggedCard] = useState(null); + const handRef = useRef(null); + const cardWrapperRefs = useRef(new Map()); + const [layoutMetrics, setLayoutMetrics] = useState(null); const cardIndexMap = useMemo(() => { const map = new Map(); cards.forEach((card, index) => { @@ -13,6 +16,12 @@ export default function Hand({ cards, selectedCards = [], onCardClick, disabled }); return map; }, [cards]); + // 转化牌会在牌数不变的情况下重新排序。布局测量必须感知实际牌序, + // 否则拖拉机标线会继续读取这些牌转换前所在位置的旧坐标。 + const cardOrderKey = useMemo( + () => cards.map(card => card.id).join('|'), + [cards] + ); const getCardIndex = (cardId) => cardIndexMap.get(cardId) ?? -1; @@ -24,7 +33,7 @@ export default function Hand({ cards, selectedCards = [], onCardClick, disabled // 检测手牌中的所有拖拉机 const tractorGroups = useMemo(() => { - if (!trumpSuit || !trumpRank || cards.length < 4) { + if (cards.length < 4) { return []; } @@ -127,40 +136,6 @@ export default function Hand({ cards, selectedCards = [], onCardClick, disabled return tractors; }, [cards, trumpSuit, trumpRank]); - // 获取卡片所属的拖拉机索引 - const getCardTractorIndex = (cardId) => { - for (let i = 0; i < tractorGroups.length; i++) { - if (tractorGroups[i].cardIds.includes(cardId)) { - return i; - } - } - return -1; - }; - - // 检查卡片是否是拖拉机的第一张 - const isTractorStart = (cardId) => { - for (const tractor of tractorGroups) { - const cardIndex = getCardIndex(cardId); - const firstTractorCardIndex = getCardIndex(tractor.cardIds[0]); - if (cardIndex === firstTractorCardIndex) { - return tractor; - } - } - return null; - }; - - // 检查卡片是否是拖拉机的最后一张 - const isTractorEnd = (cardId) => { - for (const tractor of tractorGroups) { - const cardIndex = getCardIndex(cardId); - const lastTractorCardIndex = getCardIndex(tractor.cardIds[tractor.cardIds.length - 1]); - if (cardIndex === lastTractorCardIndex) { - return true; - } - } - return false; - }; - // 根据牌数计算紧凑程度 const getCompactClass = () => { const cardCount = cards.length; @@ -173,6 +148,84 @@ export default function Hand({ cards, selectedCards = [], onCardClick, disabled const compactClass = getCompactClass(); + // 按容器的真实宽度连续计算叠牌量,并提前预留一张牌的露出宽度。 + useLayoutEffect(() => { + const handElement = handRef.current; + if (!handElement) return undefined; + + const updateOverlap = () => { + const cardCount = cards.length; + const cardWidth = small ? 50 : 78; + const naturalOverlap = small ? 18 : 25; + const minimumCardReveal = Number.isFinite(minimumVisibleWidth) + ? Math.max(1, Math.min(cardWidth, minimumVisibleWidth)) + : (small ? 12 : 18); + const computedStyle = window.getComputedStyle(handElement); + const paddingLeft = Number.parseFloat(computedStyle.paddingLeft) || 0; + const paddingRight = Number.parseFloat(computedStyle.paddingRight) || 0; + const paddingBottom = Number.parseFloat(computedStyle.paddingBottom) || 0; + const horizontalPadding = paddingLeft + paddingRight; + const nextCardReserve = anticipateNextCard && cardCount > 1 ? cardWidth - naturalOverlap : 0; + const usableWidth = Math.max( + cardWidth, + handElement.clientWidth - horizontalPadding - nextCardReserve + ); + const requiredOverlap = cardCount > 1 + ? cardWidth - (usableWidth - cardWidth) / (cardCount - 1) + : naturalOverlap; + const overlap = Math.min( + cardWidth - minimumCardReveal, + Math.max(naturalOverlap, Math.ceil(requiredOverlap)) + ); + + handElement.style.setProperty('--card-overlap', `${overlap}px`); + const cardPositions = {}; + cards.forEach((card) => { + const wrapper = cardWrapperRefs.current.get(card.id); + if (!wrapper) return; + // 弹窗入场会用 transform 缩放整只手牌。视觉矩形会暂时变小, + // 而 offset 坐标始终对应最终布局,可避免拖拉机标线停在动画中的旧位置。 + cardPositions[card.id] = { + left: wrapper.offsetLeft, + right: wrapper.offsetLeft + wrapper.offsetWidth, + bottom: wrapper.offsetTop + wrapper.offsetHeight + }; + }); + const nextMetrics = { + width: handElement.clientWidth, + height: handElement.clientHeight, + paddingLeft, + paddingRight, + paddingBottom, + cardWidth, + overlap, + cardPositions + }; + setLayoutMetrics((previous) => { + const sameScalarMetrics = previous && [ + 'width', 'height', 'paddingLeft', 'paddingRight', 'paddingBottom', 'cardWidth', 'overlap' + ].every((key) => previous[key] === nextMetrics[key]); + const sameCardPositions = sameScalarMetrics && cards.every((card) => { + const previousPosition = previous.cardPositions?.[card.id]; + const nextPosition = cardPositions[card.id]; + return previousPosition && nextPosition && + previousPosition.left === nextPosition.left && + previousPosition.right === nextPosition.right && + previousPosition.bottom === nextPosition.bottom; + }); + if (sameCardPositions) { + return previous; + } + return nextMetrics; + }); + }; + + updateOverlap(); + const resizeObserver = new ResizeObserver(updateOverlap); + resizeObserver.observe(handElement); + return () => resizeObserver.disconnect(); + }, [cards.length, cardOrderKey, small, anticipateNextCard, minimumVisibleWidth]); + // 拖拽开始 const handleDragStart = (e, card) => { setDraggedCard(card); @@ -219,52 +272,43 @@ export default function Hand({ cards, selectedCards = [], onCardClick, disabled setDraggedCard(null); }; - // 拖拉机颜色数组 - const tractorColors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7']; - return ( -
+
{cards.map((card) => { - const tractorIndex = getCardTractorIndex(card.id); - const tractorStart = isTractorStart(card.id); - const isLastInTractor = isTractorEnd(card.id); - const isTractor = tractorIndex !== -1; - const tractorColor = isTractor ? tractorColors[tractorIndex % tractorColors.length] : null; const cardIndex = getCardIndex(card.id); const isRightmostCard = cardIndex === cards.length - 1; - - // 计算拖拉机线的样式 - // 如果是拖拉机的最后一张牌,限制线的宽度,不延伸到右边 - const tractorLineStyle = { - position: 'absolute', - bottom: small ? '-2px' : '-4px', - left: 0, - height: small ? '2px' : '3px', - backgroundColor: tractorColor, - borderRadius: '2px' - }; - - if (isLastInTractor) { - // 最后一张牌:根据是否到手牌末尾决定宽度 - if (isRightmostCard) { - tractorLineStyle.width = 'var(--card-width, 80px)'; - } else { - tractorLineStyle.width = 'calc(var(--card-width, 80px) - var(--card-overlap, 25px))'; - } - } else { - // 非最后一张:延伸到右边,会被下一张牌覆盖 - tractorLineStyle.right = 0; - } + const isSelected = selectedCards.includes(card.id); + const isRuleDisabled = disabledCardIds.includes(card.id); + const isVirtualized = virtualizedCardIds.includes(card.id); + const isPubliclyRevealed = revealedCardIds.includes(card.id); return ( -
+
{ + if (element) cardWrapperRefs.current.set(card.id, element); + else cardWrapperRefs.current.delete(card.id); + }} + className={`card-wrapper ${isPubliclyRevealed ? 'is-publicly-revealed' : ''} ${isVirtualized ? 'is-virtualized' : ''}`} + style={{ zIndex: cardIndex + 1 }} + > onCardClick && onCardClick(card.id)} - disabled={disabled} + onRequestTransformation={transformableCardIds.includes(card.id) && onRequestCardTransformation + ? () => onRequestCardTransformation(card.id) + : undefined} + onCancelTransformation={card.explicitTransformationPreview && onCancelCardTransformation + ? () => onCancelCardTransformation(card.id) + : undefined} + disabled={disabled || isRuleDisabled || isVirtualized} + ruleDisabled={isRuleDisabled} + ruleDisabledReason={disabledCardReason} + virtualized={isVirtualized} + faceDown={faceDown} small={small} - draggable={!disabled && !!onReorder} + draggable={!disabled && !isRuleDisabled && !isVirtualized && !!onReorder} onDragStart={handleDragStart} onDragEnd={handleDragEnd} onDragOver={handleDragOver} @@ -272,33 +316,41 @@ export default function Hand({ cards, selectedCards = [], onCardClick, disabled trumpSuit={trumpSuit} trumpRank={trumpRank} /> - {isTractor && ( -
- )} - {tractorStart && ( -
- 拖拉机 -
+ {showWinningBadge && isRightmostCard && ( + )}
); })} + {layoutMetrics && tractorGroups.map((tractor, tractorIndex) => { + const indices = tractor.cardIds.map(getCardIndex).filter((index) => index >= 0); + if (indices.length === 0) return null; + const firstIndex = Math.min(...indices); + const lastIndex = Math.max(...indices); + const firstPosition = layoutMetrics.cardPositions?.[cards[firstIndex]?.id]; + const lastPosition = layoutMetrics.cardPositions?.[cards[lastIndex]?.id]; + if (!firstPosition || !lastPosition) return null; + const nextPosition = layoutMetrics.cardPositions?.[cards[lastIndex + 1]?.id]; + const groupLeft = firstPosition.left; + const groupRight = lastIndex === cards.length - 1 + ? lastPosition.right + : nextPosition?.left ?? lastPosition.right; + const groupWidth = Math.max(3, groupRight - groupLeft); + + return ( +
+ 拖拉机 +
+ ); + })}
); } diff --git a/tractor-game-simulator/client/src/components/Game/OpenHandPanel.jsx b/tractor-game-simulator/client/src/components/Game/OpenHandPanel.jsx new file mode 100644 index 0000000..0fa73d1 --- /dev/null +++ b/tractor-game-simulator/client/src/components/Game/OpenHandPanel.jsx @@ -0,0 +1,78 @@ +import Hand from './Hand'; +import Card from './Card'; +import { sortCards } from '../../utils/cardUtils'; +import { getEffectiveSuit } from '../../utils/cardPatternUtils'; + +const GROUPS = [ + ['trump', '主'], + ['spades', '♠'], + ['hearts', '♥'], + ['clubs', '♣'], + ['diamonds', '♦'] +]; + +export default function OpenHandPanel({ + openHand, + position, + selectedCards = [], + onCardClick, + interactive = false, + label = '明牌', + trumpSuit = null, + trumpRank = null +}) { + if (!openHand?.cards?.length || position === 'bottom') return null; + + const cards = sortCards(openHand.cards, trumpSuit, trumpRank); + const sidePosition = position === 'left' || position === 'right'; + + return ( +
+
+ {openHand.kind === 'jokers' ? '鬼' : '明'} + {openHand.playerName} · {label} + {cards.length} + {interactive && 由你代打} +
+ + {sidePosition ? ( +
+ {GROUPS.map(([suit, label]) => { + const groupedCards = cards.filter(card => getEffectiveSuit(card, trumpSuit, trumpRank) === suit); + if (groupedCards.length === 0) return null; + return ( +
+ {label} +
+ {groupedCards.map((card, index) => ( + + + + ))} +
+
+ ); + })} +
+ ) : ( +
+ +
+ )} +
+ ); +} diff --git a/tractor-game-simulator/client/src/components/Game/RuleSelector.css b/tractor-game-simulator/client/src/components/Game/RuleSelector.css index 195883a..1c38f24 100644 --- a/tractor-game-simulator/client/src/components/Game/RuleSelector.css +++ b/tractor-game-simulator/client/src/components/Game/RuleSelector.css @@ -1,12 +1,99 @@ -.ant-list-item { - transition: background-color 0.2s; +.rule-option.ant-btn { + display: block; + height: auto; + min-height: 104px; + padding: 18px 24px; + text-align: left; + white-space: normal; + border-color: #d8ddd9; + border-radius: 12px; + background: linear-gradient(135deg, #ffffff 0%, #fafcfb 100%); + box-shadow: 0 4px 12px rgba(4, 55, 43, 0.06); + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; } -.ant-list-item:hover { - background-color: #f0f0f0; - border-radius: 4px; +.rule-option-shell { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: stretch; } -.ant-modal-body { - padding: 24px; +.rule-option-shell.is-selected .rule-option.ant-btn { + border-color: #c89a22; + background: linear-gradient(135deg, #fffdf3 0%, #f8edc8 100%); + box-shadow: 0 7px 18px rgba(92, 65, 9, 0.17); +} + +.rule-option-refresh.ant-btn { + align-self: stretch; + height: auto; + min-width: 94px; + border-radius: 12px; +} + +.rule-option-check { + margin-right: 7px; + color: #c89a22; +} + +.rule-option.ant-btn:hover, +.rule-option.ant-btn:focus-visible { + border-color: #c89a22; + color: inherit; + background: linear-gradient(135deg, #fffef9 0%, #fbf7e9 100%); + box-shadow: 0 8px 20px rgba(92, 65, 9, 0.14); + transform: translateY(-1px); +} + +.rule-option-layout { + display: grid; + grid-template-columns: 128px minmax(0, 1fr); + align-items: center; + gap: 22px; + width: 100%; +} + +.rule-option .rule-option-name, +.rule-option .rule-option-description { + margin: 0; +} + +.rule-option .rule-option-name { + padding-right: 22px; + border-right: 1px solid #e4e8e5; + color: #173d32; + font-size: 19px; + line-height: 1.35; + text-align: right; + white-space: nowrap; +} + +.rule-option .rule-option-description { + color: #4f5e59; + font-size: 15px; + line-height: 1.7; + text-align: left; +} + +@media (max-width: 560px) { + .rule-option-shell { + grid-template-columns: 1fr; + } + + .rule-option-refresh.ant-btn { + min-height: 40px; + } + + .rule-option-layout { + grid-template-columns: 1fr; + gap: 8px; + } + + .rule-option .rule-option-name { + padding: 0 0 7px; + border-right: 0; + border-bottom: 1px solid #e4e8e5; + text-align: left; + } } diff --git a/tractor-game-simulator/client/src/components/Game/RuleSelector.jsx b/tractor-game-simulator/client/src/components/Game/RuleSelector.jsx index 221114f..70717fc 100644 --- a/tractor-game-simulator/client/src/components/Game/RuleSelector.jsx +++ b/tractor-game-simulator/client/src/components/Game/RuleSelector.jsx @@ -1,224 +1,128 @@ -import { useState, useEffect } from 'react'; -import { Modal, Button, Input, List, Space, Typography, Divider, message } from 'antd'; -import { SearchOutlined, ReloadOutlined, EditOutlined } from '@ant-design/icons'; +import { useEffect, useState } from 'react'; +import { Modal, Button, Typography, Space, Empty } from 'antd'; +import { CheckCircleFilled, ReloadOutlined } from '@ant-design/icons'; import './RuleSelector.css'; const { Text, Title } = Typography; -const { TextArea } = Input; -/** - * 规则选择器组件 - * @param {Object} props - * @param {Boolean} props.visible - 是否显示弹窗 - * @param {Function} props.onClose - 关闭弹窗的回调 - * @param {Function} props.onRuleSelected - 选择规则的回调,参数为 { name, content } - */ -export default function RuleSelector({ visible, onClose, onRuleSelected }) { - const [rules, setRules] = useState([]); - const [filteredRules, setFilteredRules] = useState([]); - const [searchText, setSearchText] = useState(''); - const [customRuleName, setCustomRuleName] = useState(''); - const [customRuleContent, setCustomRuleContent] = useState(''); - const [showCustomInput, setShowCustomInput] = useState(false); - const [messageApi, contextHolder] = message.useMessage(); +export default function RuleSelector({ + visible, + rules = [], + selectionMode = 'single', + canChoose = true, + canRefresh = false, + onRuleSelected, + onRefreshRule, + onClose +}) { + const isDoubleHappiness = selectionMode === 'double_happiness'; + const [selectedRuleIds, setSelectedRuleIds] = useState([]); - // 加载规则数据 useEffect(() => { - if (visible) { - fetch('/DLC.json') - .then(res => res.json()) - .then(data => { - setRules(data); - setFilteredRules(data); - }) - .catch(err => { - console.error('加载规则失败:', err); - messageApi.error('加载规则失败,请检查DLC.json文件'); - }); - } - }, [visible]); + setSelectedRuleIds(previous => ( + previous.filter(id => rules.some(rule => rule.id === id)) + )); + }, [rules]); - // 搜索规则 useEffect(() => { - if (searchText.trim() === '') { - setFilteredRules(rules); - } else { - const filtered = rules.filter(rule => - rule.name.includes(searchText) || rule.content.includes(searchText) - ); - setFilteredRules(filtered); - } - }, [searchText, rules]); - - // 随机选择规则 - const handleRandomSelect = () => { - if (rules.length === 0) { - messageApi.warning('没有可用的规则'); - return; - } - const randomIndex = Math.floor(Math.random() * rules.length); - const selectedRule = rules[randomIndex]; - onRuleSelected(selectedRule); - messageApi.success(`已随机选择规则: ${selectedRule.name}`); - handleClose(); - }; - - // 选择指定规则 - const handleSelectRule = (rule) => { - onRuleSelected(rule); - messageApi.success(`已选择规则: ${rule.name}`); - handleClose(); - }; - - // 保存自定义规则 - const handleSaveCustomRule = () => { - const trimmedName = customRuleName.trim(); - const trimmedContent = customRuleContent.trim(); - - if (!trimmedName) { - messageApi.warning('规则名称不能为空'); - return; - } - if (!trimmedContent) { - messageApi.warning('规则内容不能为空'); - return; - } - if (trimmedName.length > 20) { - messageApi.warning('规则名称不能超过20个字符'); - return; - } - if (trimmedContent.length > 200) { - messageApi.warning('规则内容不能超过200个字符'); - return; - } - - const customRule = { - name: trimmedName, - content: trimmedContent - }; - - onRuleSelected(customRule); - messageApi.success(`已保存自定义规则: ${trimmedName}`); - handleClose(); + if (!visible || !isDoubleHappiness) setSelectedRuleIds([]); + }, [visible, isDoubleHappiness]); + + const toggleRule = ruleId => { + if (!canChoose) return; + setSelectedRuleIds(previous => { + if (previous.includes(ruleId)) { + return previous.filter(id => id !== ruleId); + } + if (previous.length >= 2) return previous; + return [...previous, ruleId]; + }); }; - // 关闭弹窗 - const handleClose = () => { - setSearchText(''); - setCustomRuleName(''); - setCustomRuleContent(''); - setShowCustomInput(false); - onClose(); + const submitDoubleHappiness = () => { + if (selectedRuleIds.length !== 2) return; + onRuleSelected?.(selectedRuleIds); }; return ( - <> - {contextHolder} - - {!showCustomInput ? ( - <> - {/* 操作按钮区域 */} - - - + + 确认采用这两条规则 + + ) : null} + closable={!canChoose && Boolean(onClose)} + maskClosable={false} + keyboard={!canChoose} + onCancel={!canChoose ? onClose : undefined} + width={760} + destroyOnClose + > + + {isDoubleHappiness + ? canChoose + ? '请选择两条同时生效的规则。若候选组合不合理,可以请房主刷新其中一条。' + : canRefresh + ? '本局选择者正在挑选两条规则;你可以刷新任意一条不合理的候选。' + : '本局选择者正在挑选两条规则;所有玩家都可以查看当前候选。' + : '你是本局的规则选择者。请选择一条规则,选择后本局立即采用且不能更换。'} + + + {rules.length === 0 ? ( + + ) : ( + + {rules.map((rule, index) => { + const selected = selectedRuleIds.includes(rule.id); + const selectionLocked = ( + isDoubleHappiness + && !selected + && selectedRuleIds.length >= 2 + ); + return ( +
- - - - {/* 搜索框 */} - } - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - allowClear - style={{ marginBottom: 16 }} - /> - - {/* 规则列表 */} -
- ( - handleSelectRule(rule)} - hoverable + {isDoubleHappiness && canRefresh && ( + )} - locale={{ emptyText: '没有找到匹配的规则' }} - /> -
- - - -
- - 共 {filteredRules.length} 条规则 - {searchText && ` (从 ${rules.length} 条中筛选)`} - -
- - ) : ( - <> - {/* 自定义规则输入 */} -
- 自定义规则 - - 规则名称: - setCustomRuleName(e.target.value)} - style={{ marginTop: 8, marginBottom: 16 }} - /> - - 规则内容: -