diff --git a/Fk/Pages/Room.qml b/Fk/Pages/Room.qml index cde31e6b..97103bb3 100644 --- a/Fk/Pages/Room.qml +++ b/Fk/Pages/Room.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls +import QtQuick.Dialogs import QtQuick.Layouts import QtMultimedia import Fk @@ -71,17 +72,98 @@ Item { } // tmp - DelayButton { - id: quitButton - text: "quit" + Button { + id: menuButton anchors.top: parent.top anchors.right: parent.right - delay: Debugging ? 10 : 1000 - onActivated: { - // ClientInstance.clearPlayers(); - ClientInstance.notifyServer("QuitRoom", "[]"); + anchors.rightMargin: 10 + text: Backend.translate("Menu") + z: 2 + onClicked: { + menuContainer.visible || menuContainer.open(); } } + + Menu { + id: menuContainer + x: parent.width - menuButton.width - menuContainer.width - 17 + width: menuRow.width + height: menuRow.height + verticalPadding: 0 + spacing: 7 + z: 2 + + Row { + id: menuRow + spacing: 7 + + Button { + id: surrenderButton + text: Backend.translate("Surrender") + onClicked: { + if (isStarted) { + const surrenderCheck = JSON.parse(Backend.callLuaFunction('CheckSurrenderAvailable', [miscStatus.playedTime])); + if (!surrenderCheck.length) { + surrenderDialog.informativeText = 'surrender is disabled in this mode'; + } else { + surrenderDialog.informativeText = surrenderCheck.map(str => `${Backend.translate(str.text)}(${str.passed ? '√' : '×'})`).join('
'); + } + surrenderDialog.open(); + } + } + } + + MessageDialog { + id: surrenderDialog + title: Backend.translate("Surrender") + informativeText: '' + buttons: MessageDialog.Ok | MessageDialog.Cancel + onButtonClicked: function (button, role) { + switch (button) { + case MessageDialog.Ok: { + const surrenderCheck = JSON.parse(Backend.callLuaFunction('CheckSurrenderAvailable', [miscStatus.playedTime])); + if (surrenderCheck.length && !surrenderCheck.find(check => !check.passed)) { + ClientInstance.notifyServer("PushRequest", [ + "surrender", true + ]); + } + break; + } + case MessageDialog.Cancel: { + surrenderDialog.close(); + } + } + } + } + + Button { + id: quitButton + text: Backend.translate("Quit") + onClicked: { + quitDialog.open(); + } + } + + MessageDialog { + id: quitDialog + title: Backend.translate("Quit") + informativeText: Backend.translate("Are you sure to quit?") + buttons: MessageDialog.Ok | MessageDialog.Cancel + onButtonClicked: function (button, role) { + switch (button) { + case MessageDialog.Ok: { + ClientInstance.notifyServer("QuitRoom", "[]"); + break; + } + case MessageDialog.Cancel: { + quitDialog.close(); + } + } + } + } + } + } + Button { text: Backend.translate("Add Robot") visible: isOwner && !isStarted && !isFull @@ -264,6 +346,7 @@ Item { drank: model.drank isOwner: model.isOwner ready: model.ready + surrendered: model.surrendered onSelectedChanged: { Logic.updateSelectedTargets(playerid, selected); @@ -314,6 +397,7 @@ Item { MetroButton { text: Backend.translate("Sort Cards") textFont.pixelSize: 28 + onClicked: Logic.resortHandcards(); } MetroButton { text: Backend.translate("Chat") @@ -709,7 +793,7 @@ Item { MiscStatus { id: miscStatus - anchors.right: quitButton.left + anchors.right: menuButton.left anchors.top: parent.top anchors.rightMargin: 16 anchors.topMargin: 8 @@ -986,6 +1070,7 @@ Item { drank: 0, isOwner: false, ready: false, + surrendered: false, }); } diff --git a/Fk/Pages/RoomLogic.js b/Fk/Pages/RoomLogic.js index 6490068b..080964aa 100644 --- a/Fk/Pages/RoomLogic.js +++ b/Fk/Pages/RoomLogic.js @@ -204,6 +204,47 @@ function moveCards(moves) { } } +function resortHandcards() { + if (!dashboard.handcardArea.cards.length) { + return; + } + + const subtypeString2Number = { + ["none"]: Card.SubtypeNone, + ["delayed_trick"]: Card.SubtypeDelayedTrick, + ["weapon"]: Card.SubtypeWeapon, + ["armor"]: Card.SubtypeArmor, + ["defensive_horse"]: Card.SubtypeDefensiveRide, + ["offensive_horse"]: Card.SubtypeOffensiveRide, + ["treasure"]: Card.SubtypeTreasure, + } + + dashboard.handcardArea.cards.sort((prev, next) => { + if (prev.type === next.type) { + const prevSubtypeNumber = subtypeString2Number[prev.subtype]; + const nextSubtypeNumber = subtypeString2Number[next.subtype]; + if (prevSubtypeNumber === nextSubtypeNumber) { + const splitedPrevName = prev.name.split('__'); + const prevTrueName = splitedPrevName[splitedPrevName.length - 1]; + + const splitedNextName = next.name.split('__'); + const nextTrueName = splitedNextName[splitedNextName.length - 1]; + if (prevTrueName === nextTrueName) { + return prev.cid - next.cid; + } else { + return prevTrueName > nextTrueName ? -1 : 1; + } + } else { + return prevSubtypeNumber - nextSubtypeNumber; + } + } else { + return prev.type - next.type; + } + }); + + dashboard.handcardArea.updateCardPosition(true); +} + function setEmotion(id, emotion, isCardId) { let path; if (OS === "Win") { @@ -1336,3 +1377,7 @@ callbacks["AskForLuckCard"] = (j) => { roomScene.okButton.enabled = true; roomScene.cancelButton.enabled = true; } + +callbacks["CancelRequest"] = (jsonData) => { + ClientInstance.replyToServer("", "__cancel") +} diff --git a/Fk/RoomElement/CardArea.qml b/Fk/RoomElement/CardArea.qml index 54a7b1ce..7066a40d 100644 --- a/Fk/RoomElement/CardArea.qml +++ b/Fk/RoomElement/CardArea.qml @@ -62,6 +62,9 @@ Item { card = cards[i]; card.origX = i * spacing; card.origY = 0; + card.z = i + 1; + card.initialZ = i + 1; + card.maxZ = cards.length; } } diff --git a/Fk/RoomElement/CardItem.qml b/Fk/RoomElement/CardItem.qml index aff7ddbe..c41d789a 100644 --- a/Fk/RoomElement/CardItem.qml +++ b/Fk/RoomElement/CardItem.qml @@ -26,6 +26,7 @@ Item { property string name: "slash" property string extension: "" property string virt_name: "" + property int type: 0 property string subtype: "" property string color: "" // only use when suit is empty property string footnote: "" // footnote, e.g. "A use card to B" @@ -51,6 +52,8 @@ Item { property bool showDetail: false property int origX: 0 property int origY: 0 + property int initialZ: 0 + property int maxZ: 0 property real origOpacity: 1 // property bool isClicked: false property bool moveAborted: false @@ -259,10 +262,12 @@ Item { if (!draggable) return; if (hovered) { glow.visible = true; - root.z++; + + root.z = root.maxZ ? root.maxZ + 1 : root.z + 1; } else { glow.visible = false; - root.z--; + + root.z = root.initialZ ? root.initialZ : root.z - 1 } } } @@ -317,6 +322,7 @@ Item { suit = data.suit; number = data.number; color = data.color; + type = data.type ? data.type : 0 subtype = data.subtype ? data.subtype : ""; virt_name = data.virt_name ? data.virt_name : ""; mark = data.mark ?? {}; @@ -330,6 +336,7 @@ Item { suit: suit, number: number, color: color, + type: type, subtype: subtype, virt_name: virt_name, mark: mark, diff --git a/Fk/RoomElement/MiscStatus.qml b/Fk/RoomElement/MiscStatus.qml index f056ce95..00116dbb 100644 --- a/Fk/RoomElement/MiscStatus.qml +++ b/Fk/RoomElement/MiscStatus.qml @@ -9,7 +9,8 @@ Item { visible: roundNum || pileNum function getTimeString(time) { - const s = time % 60; + let s = time % 60; + s < 10 && (s = '0' + s); const m = (time - s) / 60; const h = (time - s - m * 60) / 3600; return h ? `${h}:${m}:${s}` : `${m}:${s}`; diff --git a/Fk/RoomElement/Photo.qml b/Fk/RoomElement/Photo.qml index 516d4070..6eb735cb 100644 --- a/Fk/RoomElement/Photo.qml +++ b/Fk/RoomElement/Photo.qml @@ -52,6 +52,7 @@ Item { property bool selected: false property bool playing: false + property bool surrendered: false onPlayingChanged: { if (playing) { animPlaying.start(); @@ -235,7 +236,7 @@ Item { anchors.fill: photoMask source: generalImgItem saturation: 0 - visible: root.dead + visible: root.dead || root.surrendered } Rectangle { @@ -370,8 +371,8 @@ Item { Image { // id: saveme - visible: root.dead || root.dying - source: SkinBank.DEATH_DIR + (root.dead ? root.role : "saveme") + visible: root.dead || root.dying || root.surrendered + source: SkinBank.DEATH_DIR + (root.dead ? root.role : root.surrendered ? "surrender" : "saveme") anchors.centerIn: photoMask } diff --git a/lua/client/client_util.lua b/lua/client/client_util.lua index 033018a2..b6481a73 100644 --- a/lua/client/client_util.lua +++ b/lua/client/client_util.lua @@ -98,6 +98,7 @@ function GetCardData(id) suit = card:getSuitString(), color = card:getColorString(), mark = mark, + type = card.type, subtype = cardSubtypeStrings[card.sub_type] } if card.skillName ~= "" then @@ -625,4 +626,9 @@ function SetObserving(o) ClientInstance.observing = o end +function CheckSurrenderAvailable(playedTime) + local curMode = ClientInstance.room_settings.gameMode + return json.encode(Fk.game_modes[curMode]:surrenderFunc(playedTime)) +end + dofile "lua/client/i18n/init.lua" diff --git a/lua/client/i18n/zh_CN.lua b/lua/client/i18n/zh_CN.lua index a34d3575..3e68bf3a 100644 --- a/lua/client/i18n/zh_CN.lua +++ b/lua/client/i18n/zh_CN.lua @@ -215,6 +215,11 @@ FreeKill使用的是libgit2的C API,与此同时使用Git完成拓展包的下 ["seat#8"] = "八号位", ["@ControledBy"] = "控制者", + ["Menu"] = "菜单", + ["Surrender"] = "投降", + ["Quit"] = "退出", + ["Are you sure to quit?"] = "是否确认退出对局(若对局开始则将计入逃跑次数)?", + ["Trust"] = "托管", ["Sort Cards"] = "牌序", ["Chat"] = "聊天", diff --git a/lua/core/game_mode.lua b/lua/core/game_mode.lua index 695848a2..bf4b1984 100644 --- a/lua/core/game_mode.lua +++ b/lua/core/game_mode.lua @@ -10,6 +10,7 @@ ---@field public maxPlayer integer @ 最大玩家数 ---@field public rule TriggerSkill @ 规则(通过技能完成,通常用来为特定角色及特定时机提供触发事件) ---@field public logic fun() @ 逻辑(通过function完成,通常用来初始化、分配身份及座次) +---@field public surrenderFunc fun() local GameMode = class("GameMode") --- 构造函数,不可随意调用。 @@ -22,4 +23,16 @@ function GameMode:initialize(name, min, max) self.maxPlayer = math.min(max, 8) end +---@param victim ServerPlayer @ 死者 +---@return string @ 胜者阵营 +function GameMode:getWinner(victim) + return "" +end + +---@param playedTime number @ 游戏时长(单位:秒) +---@return table +function GameMode:surrenderFunc(playedTime) + return {} +end + return GameMode diff --git a/lua/fk_ex.lua b/lua/fk_ex.lua index 978b3bfa..23d5dce2 100644 --- a/lua/fk_ex.lua +++ b/lua/fk_ex.lua @@ -540,5 +540,14 @@ function fk.CreateGameMode(spec) local ret = GameMode:new(spec.name, spec.minPlayer, spec.maxPlayer) ret.rule = spec.rule ret.logic = spec.logic + + if spec.winner_getter then + assert(type(spec.winner_getter) == "function") + ret.getWinner = spec.winner_getter + end + if spec.surrender_func then + assert(type(spec.surrender_func) == "function") + ret.surrenderFunc = spec.surrender_func + end return ret end diff --git a/lua/server/request.lua b/lua/server/request.lua index e25cc686..954ee131 100644 --- a/lua/server/request.lua +++ b/lua/server/request.lua @@ -135,6 +135,23 @@ request_handlers["changeself"] = function(room, id, reqlist) }) end +request_handlers["surrender"] = function(room, id, reqlist) + local logic = room.logic + local curEvent = logic:getCurrentEvent() + if curEvent then + curEvent:addExitFunc( + function() + local player = room:getPlayerById(id) + player.surrendered = true + room:broadcastProperty(player, "surrendered") + room:gameOver(Fk.game_modes[room.settings.gameMode]:getWinner(player)) + end + ) + room.hasSurrendered = true + room:doBroadcastNotify("CancelRequest", "") + end +end + request_handlers["newroom"] = function(s, id) s:registerRoom(id) end diff --git a/lua/server/room.lua b/lua/server/room.lua index 0f0eb7d3..7327501d 100644 --- a/lua/server/room.lua +++ b/lua/server/room.lua @@ -2950,7 +2950,7 @@ function Room:getCardsFromPileByRule(pattern, num, fromPile) if fromPile == "discardPile" then pileToSearch = self.discard_pile elseif fromPile == "allPiles" then - pileToSearch = table.clone(self.draw_pile) + pileToSearch = table.simpleClone(self.draw_pile) table.insertTable(pileToSearch, self.discard_pile) end diff --git a/lua/server/serverplayer.lua b/lua/server/serverplayer.lua index 3bb6321d..80d42d41 100644 --- a/lua/server/serverplayer.lua +++ b/lua/server/serverplayer.lua @@ -92,6 +92,10 @@ local function _waitForReply(player, timeout) player.request_timeout = timeout player.request_start = start if state ~= fk.Player_Online then + if player.room.hasSurrendered then + return "__cancel" + end + if state ~= fk.Player_Robot then player.room:checkNoHuman() player.room:delay(500) @@ -117,6 +121,12 @@ local function _waitForReply(player, timeout) player.serverplayer:setThinking(false) return "" end + + if player.room.hasSurrendered then + player.serverplayer:setThinking(false) + return "" + end + coroutine.yield("__handleRequest", rest) end end diff --git a/packages/standard/game_rule.lua b/packages/standard/game_rule.lua index bca59a8f..4510af08 100644 --- a/packages/standard/game_rule.lua +++ b/packages/standard/game_rule.lua @@ -1,33 +1,5 @@ -- SPDX-License-Identifier: GPL-3.0-or-later ----@param victim ServerPlayer -local function getWinner(victim) - local room = victim.room - local winner = "" - local alive = room.alive_players - - if victim.role == "lord" then - if #alive == 1 and alive[1].role == "renegade" then - winner = "renegade" - else - winner = "rebel" - end - elseif victim.role ~= "loyalist" then - local lord_win = true - for _, p in ipairs(alive) do - if p.role == "rebel" or p.role == "renegade" then - lord_win = false - break - end - end - if lord_win then - winner = "lord+loyalist" - end - end - - return winner -end - ---@param killer ServerPlayer local function rewardAndPunish(killer, victim) if killer.dead then return end @@ -104,7 +76,7 @@ GameRule = fk.CreateTriggerSkill{ end end, [fk.GameOverJudge] = function() - local winner = getWinner(player) + local winner = Fk.game_modes[room.settings.gameMode]:getWinner(player) if winner ~= "" then room:gameOver(winner) return true diff --git a/packages/standard/init.lua b/packages/standard/init.lua index c57e97af..b401c832 100644 --- a/packages/standard/init.lua +++ b/packages/standard/init.lua @@ -1114,8 +1114,126 @@ local role_mode = fk.CreateGameMode{ name = "aaa_role_mode", -- just to let it at the top of list minPlayer = 2, maxPlayer = 8, + winner_getter = function(self, victim) + local room = victim.room + local winner = "" + local alive = table.filter(room.alive_players, function(p) + return not p.surrendered + end) + + if victim.role == "lord" then + if #alive == 1 and alive[1].role == "renegade" then + winner = "renegade" + else + winner = "rebel" + end + elseif victim.role ~= "loyalist" then + local lord_win = true + for _, p in ipairs(alive) do + if p.role == "rebel" or p.role == "renegade" then + lord_win = false + break + end + end + if lord_win then + winner = "lord+loyalist" + end + end + + return winner + end, + surrender_func = function(self, playedTime) + local roleCheck = false + local roleText = "" + local roleTable = { + { "lord" }, + { "lord", "rebel" }, + { "lord", "rebel", "renegade" }, + { "lord", "loyalist", "rebel", "renegade" }, + { "lord", "loyalist", "rebel", "rebel", "renegade" }, + { "lord", "loyalist", "rebel", "rebel", "rebel", "renegade" }, + { "lord", "loyalist", "loyalist", "rebel", "rebel", "rebel", "renegade" }, + { "lord", "loyalist", "loyalist", "rebel", "rebel", "rebel", "rebel", "renegade" }, + } + + roleTable = roleTable[#Fk:currentRoom().players] + + if Self.role == "renegade" then + roleCheck = #Fk:currentRoom().alive_players == 2 + roleText = "only you and me" + elseif Self.role == "rebel" then + local rebelNum = #table.filter(roleTable, function(role) + return role == "rebel" + end) + + local renegadeDead = not table.find(roleTable, function(role) + return role == "renegade" + end) + for _, p in ipairs(Fk:currentRoom().players) do + if p.role == "renegade" and p.dead then + renegadeDead = true + end + + if p ~= Self and p.role == "rebel" then + if p:isAlive() then + break + else + rebelNum = rebelNum - 1 + end + end + end + + roleCheck = renegadeDead and rebelNum == 1 + roleText = "left one rebel alive" + else + if Self.role == "loyalist" then + return { { text = "loyalist never surrender", passed = false } } + else + if #Fk:currentRoom().alive_players == 2 then + roleCheck = true + else + local lordNum = #table.filter(roleTable, function(role) + return role == "lord" or role == "loyalist" + end) + + local renegadeDead = not table.find(roleTable, function(role) + return role == "renegade" + end) + for _, p in ipairs(Fk:currentRoom().players) do + if p.role == "renegade" and p.dead then + renegadeDead = true + end + + if p ~= Self and (p.role == "lord" or p.role == "loyalist") then + if p:isAlive() then + break + else + lordNum = lordNum - 1 + end + end + end + + roleCheck = renegadeDead and lordNum == 1 + end + end + + roleText = "left you alive" + end + + return { + { text = "time limitation: 5 min", passed = playedTime >= 300 }, + { text = roleText, passed = roleCheck }, + } + end, } extension:addGameMode(role_mode) +Fk:loadTranslationTable{ + ["time limitation: 5 min"] = "游戏时长达到5分钟", + ["only you and me"] = "仅剩你和主公存活", + ["left one rebel alive"] = "反贼仅剩你存活且不存在存活内奸", + ["left you alive"] = "主忠方仅剩你存活且其他阵营仅剩一方", + ["loyalist never surrender"] = "忠臣永不投降!", +} local anjiang = General(extension, "anjiang", "unknown", 5) anjiang.gender = General.Agender