diff --git a/Fk/Pages/MetroToggleButton.qml b/Fk/Pages/MetroToggleButton.qml new file mode 100644 index 00000000..9111ea27 --- /dev/null +++ b/Fk/Pages/MetroToggleButton.qml @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick + +Item { + property bool enabled: true + property bool triggered: false + property alias text: title.text + property alias textColor: title.color + property alias textFont: title.font + property alias backgroundColor: bg.color + property alias border: bg.border + property alias iconSource: icon.source + property int padding: 5 + + signal clicked + + id: button + width: icon.width + title.implicitWidth + padding * 2 + height: Math.max(icon.height, title.implicitHeight) + padding * 2 + + Rectangle { + id: bg + anchors.fill: parent + color: "black" + border.width: 2 + border.color: "white" + opacity: 0.8 + } + + states: [ + State { + name: "hovered_checked"; when: hover.hovered && triggered + PropertyChanges { target: bg; color: "gold" } + PropertyChanges { target: title; color: "black" } + }, + State { + name: "hovered"; when: hover.hovered + PropertyChanges { target: bg; color: "white" } + PropertyChanges { target: title; color: "black" } + }, + State { + name: "checked"; when: triggered + PropertyChanges { target: border; color: "gold" } + PropertyChanges { target: title; color: "gold" } + }, + State { + name: "disabled"; when: !enabled + PropertyChanges { target: button; opacity: 0.2 } + } + ] + + TapHandler { + id: mouse + onTapped: if (parent.enabled) { + triggered = !triggered; + parent.clicked(); + } + } + + HoverHandler { + id: hover + cursorShape: Qt.PointingHandCursor + } + + Row { + x: padding + y: padding + anchors.centerIn: parent + spacing: 5 + + Image { + id: icon + anchors.verticalCenter: parent.verticalCenter + fillMode: Image.PreserveAspectFit + } + + Text { + id: title + font.pixelSize: 18 + // font.family: "WenQuanYi Micro Hei" + anchors.verticalCenter: parent.verticalCenter + text: "" + color: "white" + } + } +} diff --git a/Fk/Pages/RoomLogic.js b/Fk/Pages/RoomLogic.js index 2ba7fc21..ca03ed1f 100644 --- a/Fk/Pages/RoomLogic.js +++ b/Fk/Pages/RoomLogic.js @@ -1008,6 +1008,48 @@ callbacks["AskForChoice"] = (jsonData) => { }); } +callbacks["AskForCheck"] = (jsonData) => { + // jsonData: [ string[] choices, string skill ] + // TODO: multiple choices, e.g. benxi_ol + const data = JSON.parse(jsonData); + const choices = data[0]; + const all_choices = data[1]; + const min_num = data[2][0]; + const max_num = data[2][1]; + const cancelable = data[3]; + const skill_name = data[4]; + const prompt = data[5]; + const detailed = data[6]; + if (prompt === "") { + roomScene.promptText = Backend.translate("#AskForCheck") + .arg(Backend.translate(skill_name)); + } else { + roomScene.setPrompt(processPrompt(prompt), true); + } + roomScene.state = "replying"; + let qmlSrc; + if (!detailed) { + qmlSrc = "../RoomElement/CheckBox.qml"; + } else { + qmlSrc = "../RoomElement/DetailedCheckBox.qml"; + } + roomScene.popupBox.sourceComponent = Qt.createComponent(qmlSrc); + const box = roomScene.popupBox.item; + box.options = choices; + box.skill_name = skill_name; + box.all_options = all_choices; + box.min_num = min_num; + box.max_num = max_num; + box.cancelable = cancelable; + box.accepted.connect(() => { + const ret = []; + box.result.forEach(id => { + ret.push(all_choices[id]); + }); + replyToServer(JSON.stringify(ret)); + }); +} + callbacks["AskForCardChosen"] = (jsonData) => { // jsonData: [ int[] handcards, int[] equips, int[] delayedtricks, // string reason ] diff --git a/Fk/Pages/qmldir b/Fk/Pages/qmldir index 4faa6988..077cbf73 100644 --- a/Fk/Pages/qmldir +++ b/Fk/Pages/qmldir @@ -6,6 +6,7 @@ GeneralsOverview 1.0 GeneralsOverview.qml Init 1.0 Init.qml Lobby 1.0 Lobby.qml MetroButton 1.0 MetroButton.qml +MetroToggleButton 1.0 MetroToggleButton.qml ModesOverview 1.0 ModesOverview.qml PackageManage 1.0 PackageManage.qml Room 1.0 Room.qml diff --git a/Fk/RoomElement/CheckBox.qml b/Fk/RoomElement/CheckBox.qml new file mode 100644 index 00000000..dcbdc892 --- /dev/null +++ b/Fk/RoomElement/CheckBox.qml @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Layouts +import Fk.Pages + +GraphicsBox { + property var options: [] + property var all_options: [] + property bool cancelable: false + property int min_num: 0 + property int max_num: 0 + property string skill_name: "" + property var result: [] + + id: root + title.text: Backend.translate("$Choice").arg(Backend.translate(skill_name)) + width: Math.max(140, body.width + 20) + height: buttons.height + body.height + title.height + 20 + + function processPrompt(prompt) { + const data = prompt.split(":"); + let raw = Backend.translate(data[0]); + const src = parseInt(data[1]); + const dest = parseInt(data[2]); + if (raw.match("%src")) raw = raw.replace(/%src/g, Backend.translate(getPhoto(src).general)); + if (raw.match("%dest")) raw = raw.replace(/%dest/g, Backend.translate(getPhoto(dest).general)); + if (raw.match("%arg2")) raw = raw.replace(/%arg2/g, Backend.translate(data[4])); + if (raw.match("%arg")) raw = raw.replace(/%arg/g, Backend.translate(data[3])); + return raw; + } + + GridLayout { + id: body + // x: 10 + anchors.horizontalCenter: parent.horizontalCenter + y: title.height + 5 + flow: GridLayout.TopToBottom + rows: 8 + columnSpacing: 10 + + Repeater { + model: all_options + + MetroToggleButton { + // Layout.fillWidth: true + text: processPrompt(modelData) + enabled: options.indexOf(modelData) !== -1 && (root.result.length < max_num || triggered) + + onClicked: { + if (triggered) { + root.result.push(index); + } else { + root.result.splice(root.result.indexOf(index), 1); + } + root.result = root.result; + } + } + } + } + + Row { + id: buttons + anchors.margins: 8 + anchors.top: body.bottom + anchors.horizontalCenter: root.horizontalCenter + spacing: 32 + + MetroButton { + Layout.fillWidth: true + text: processPrompt("OK") + enabled: root.result.length >= min_num + + onClicked: { + root.close(); + } + } + + MetroButton { + Layout.fillWidth: true + text: processPrompt("Cancel") + visible: cancelable + + onClicked: { + root.result = []; + root.close(); + } + } + } +} diff --git a/Fk/RoomElement/DetailedCheckBox.qml b/Fk/RoomElement/DetailedCheckBox.qml new file mode 100644 index 00000000..f9bae3a0 --- /dev/null +++ b/Fk/RoomElement/DetailedCheckBox.qml @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Fk.Pages + +GraphicsBox { + property var options: [] + property var all_options: [] + property bool cancelable: false + property int min_num: 0 + property int max_num: 0 + property string skill_name: "" + property var result: [] + + id: root + title.text: Backend.translate("$Choice").arg(Backend.translate(skill_name)) + width: Math.max(140, body.width + 20) + height: buttons.height + body.height + title.height + 20 + + ListView { + id: body + x: 10 + y: title.height + 5 + width: Math.min(700, 220 * model.length) + height: 300 + orientation: ListView.Horizontal + clip: true + spacing: 20 + + model: all_options + + delegate: Item { + width: 200 + height: 290 + + MetroToggleButton { + id: choicetitle + width: parent.width + text: Backend.translate(modelData) + enabled: options.indexOf(modelData) !== -1 && (root.result.length < max_num || triggered) + textFont.pixelSize: 24 + anchors.top: choiceDetail.bottom + anchors.topMargin: 8 + + onClicked: { + if (triggered) { + root.result.push(index); + } else { + root.result.splice(root.result.indexOf(index), 1); + } + root.result = root.result; + } + } + + Flickable { + id: choiceDetail + x: 4 + height: parent.height - choicetitle.height + contentHeight: detail.height + width: parent.width + clip: true + Text { + id: detail + width: parent.width + text: Backend.translate(":" + modelData) + color: "white" + wrapMode: Text.WordWrap + font.pixelSize: 16 + textFormat: TextEdit.RichText + } + } + } + } + + Row { + id: buttons + anchors.margins: 8 + anchors.bottom: root.bottom + anchors.horizontalCenter: root.horizontalCenter + spacing: 32 + + MetroButton { + width: 120 + height: 35 + text: Backend.translate("OK") + enabled: root.result.length >= min_num + + onClicked: { + root.close(); + } + } + + MetroButton { + width: 120 + height: 35 + text: Backend.translate("Cancel") + visible: root.cancelable + + onClicked: { + result = []; + root.close(); + } + } + } +} diff --git a/Fk/RoomElement/PoxiBox.qml b/Fk/RoomElement/PoxiBox.qml index f3c6ab30..3abdd791 100644 --- a/Fk/RoomElement/PoxiBox.qml +++ b/Fk/RoomElement/PoxiBox.qml @@ -7,7 +7,7 @@ import Fk.Pages GraphicsBox { id: root - title.text: Backend.callLuaFunction("PoxiPrompt", [poxi_type, card_data]) + title.text: Backend.callLuaFunction("PoxiPrompt", [poxi_type, card_data, extra_data]) // TODO: Adjust the UI design in case there are more than 7 cards width: 70 + 700 @@ -116,7 +116,7 @@ GraphicsBox { width: 120 height: 35 text: Backend.translate("Cancel") - enabled: root.cancelable + visible: root.cancelable onClicked: root.cardsSelected([]) } diff --git a/Fk/RoomElement/UltSkillAnimation.qml b/Fk/RoomElement/UltSkillAnimation.qml index 03c25bd8..6812333c 100644 --- a/Fk/RoomElement/UltSkillAnimation.qml +++ b/Fk/RoomElement/UltSkillAnimation.qml @@ -74,6 +74,7 @@ Item { x: root.width + 140 anchors.verticalCenter: parent.verticalCenter opacity: 0 + detailed: false } Text { diff --git a/lua/client/client_util.lua b/lua/client/client_util.lua index abd12a3f..2f47cc00 100644 --- a/lua/client/client_util.lua +++ b/lua/client/client_util.lua @@ -715,11 +715,11 @@ function GetCardProhibitReason(cid, method, pattern) end end -function PoxiPrompt(poxi_type, data) +function PoxiPrompt(poxi_type, data, extra_data) local poxi = Fk.poxi_methods[poxi_type] if not poxi or not poxi.prompt then return "" end if type(poxi.prompt) == "string" then return Fk:translate(poxi.prompt) end - return poxi.prompt(data) + return poxi.prompt(data, extra_data) end function PoxiFilter(poxi_type, to_select, selected, data, extra_data) diff --git a/lua/client/i18n/en_US.lua b/lua/client/i18n/en_US.lua index e5a11e7e..cc3ccbed 100644 --- a/lua/client/i18n/en_US.lua +++ b/lua/client/i18n/en_US.lua @@ -159,6 +159,7 @@ Fk:loadTranslationTable({ ["#AskForLuckCard"] = "Do you want to use luck card (%1 times left)?", ["AskForLuckCard"] = "Luck card", ["#AskForChoice"] = "%1: Please choose", + ["#AskForCheck"] = "%1: Please choose", ["#choose-trigger"] = "Please choose the skill to use", ["trigger"] = "Trigger skill", -- ["Please arrange cards"] = "请拖拽移动卡牌", @@ -169,6 +170,7 @@ Fk:loadTranslationTable({ ["AskForGuanxing"] = "Stargazing", ["AskForExchange"] = "Exchaging", ["AskForChoice"] = "Making choice", + ["AskForCheck"] = "Making choice", ["AskForKingdom"] = "Choosing kingdom", ["AskForPindian"] = "Point fight", ["AskForMoveCardInBoard"] = "Moving cards", diff --git a/lua/client/i18n/zh_CN.lua b/lua/client/i18n/zh_CN.lua index 669eeb74..4be3b9d8 100644 --- a/lua/client/i18n/zh_CN.lua +++ b/lua/client/i18n/zh_CN.lua @@ -205,6 +205,7 @@ FreeKill使用的是libgit2的C API,与此同时使用Git完成拓展包的下 ["#AskForLuckCard"] = "你想使用手气卡吗?还可以使用 %1 次,剩余手气卡∞张", ["AskForLuckCard"] = "手气卡", ["#AskForChoice"] = "%1:请选择", + ["#AskForCheck"] = "%1:请选择", ["#choose-trigger"] = "请选择一项技能发动", ["trigger"] = "选择技能", ["Please arrange cards"] = "请拖拽移动卡牌", @@ -215,6 +216,7 @@ FreeKill使用的是libgit2的C API,与此同时使用Git完成拓展包的下 ["AskForGuanxing"] = "观星", ["AskForExchange"] = "换牌", ["AskForChoice"] = "选择", + ["AskForCheck"] = "选择", ["AskForKingdom"] = "选择势力", ["AskForPindian"] = "拼点", ["AskForMoveCardInBoard"] = "移动卡牌", diff --git a/lua/core/util.lua b/lua/core/util.lua index d8705596..83049cbc 100644 --- a/lua/core/util.lua +++ b/lua/core/util.lua @@ -52,6 +52,24 @@ Util.convertSubtypeAndEquipSlot = function(value) end end +--- 根据花色文字描述(如 黑桃、红桃、梅花、方块)或者符号(如♠♥♣♦,带颜色)返回花色ID。 +---@param symbol string @ 描述/符号(原文,确保没被翻译过) +---@return Suit @ 花色ID +Util.getSuitFromString = function(symbol) + assert(type(symbol) == "string") + if symbol:find("spade") then + return Card.Spade + elseif symbol:find("heart") then + return Card.Heart + elseif symbol:find("club") then + return Card.Club + elseif symbol:find("diamond") then + return Card.Diamond + else + return Card.NoSuit + end +end + function printf(fmt, ...) print(string.format(fmt, ...)) end @@ -118,14 +136,22 @@ function table:map(func) end -- frequenly used filter & map functions + +--- 返回ID Util.IdMapper = function(e) return e.id end +--- 根据卡牌ID返回卡牌 Util.Id2CardMapper = function(id) return Fk:getCardById(id) end +--- 根据玩家ID返回玩家 Util.Id2PlayerMapper = function(id) return Fk:currentRoom():getPlayerById(id) end +--- 返回武将名 Util.NameMapper = function(e) return e.name end +--- 根据武将名返回武将 Util.Name2GeneralMapper = function(e) return Fk.generals[e] end +--- 根据技能名返回技能 Util.Name2SkillMapper = function(e) return Fk.skills[e] end +--- 返回译文 Util.TranslateMapper = function(str) return Fk:translate(str) end -- for card preset diff --git a/lua/fk_ex.lua b/lua/fk_ex.lua index d1b41306..681b3c54 100644 --- a/lua/fk_ex.lua +++ b/lua/fk_ex.lua @@ -179,7 +179,7 @@ end ---@field public on_effect nil|fun(self: ActiveSkill, room: Room, cardEffectEvent: CardEffectEvent): bool ---@field public on_nullified nil|fun(self: ActiveSkill, room: Room, cardEffectEvent: CardEffectEvent): bool ---@field public mod_target_filter nil|fun(self: ActiveSkill, to_select: integer, selected: integer[], user: integer, card: Card, distance_limited: boolean): bool ----@field public prompt nil|string|fun(self: ActiveSkill, selected: integer[], selected_cards: integer[]): string +---@field public prompt nil|string|fun(self: ActiveSkill, selected_cards: integer[], selected_targets: integer[]): string ---@field public interaction any ---@param spec ActiveSkillSpec diff --git a/lua/freekill.lua b/lua/freekill.lua index 1f768c88..27c169d9 100644 --- a/lua/freekill.lua +++ b/lua/freekill.lua @@ -18,7 +18,8 @@ math.randomseed(os.time()) -- 加载实用类,让Lua编写起来更轻松。 local Utils = require "core.util" -TargetGroup, AimGroup, Util = table.unpack(Utils) +-- TargetGroup, AimGroup, Util = table.unpack(Utils) +TargetGroup, AimGroup, Util = Utils[1], Utils[2], Utils[3] dofile "lua/core/debug.lua" -- 加载游戏核心类 diff --git a/lua/server/room.lua b/lua/server/room.lua index 8fa2fffe..d1296bba 100644 --- a/lua/server/room.lua +++ b/lua/server/room.lua @@ -1052,7 +1052,7 @@ end ---@param cancelable bool @ 是否可以点取消 ---@param extra_data table|nil @ 额外信息,因技能而异了 ---@param no_indicate bool @ 是否不显示指示线 ----@return boolean, table +---@return boolean, table|nil function Room:askForUseActiveSkill(player, skill_name, prompt, cancelable, extra_data, no_indicate) prompt = prompt or "" cancelable = (cancelable == nil) and true or cancelable @@ -1322,6 +1322,55 @@ function Room:askForChooseCardAndPlayers(player, targets, minNum, maxNum, patter end end +--- 询问玩家选择X张牌和Y名角色。 +--- +--- 返回两个值,第一个是选择的目标列表,第二个是选择的那张牌的id +---@param player ServerPlayer @ 要询问的玩家 +---@param minCardNum integer @ 选卡牌最小值 +---@param maxCardNum integer @ 选卡牌最大值 +---@param targets integer[] @ 选择目标的id范围 +---@param minTargetNum integer @ 选目标最小值 +---@param maxTargetNum integer @ 选目标最大值 +---@param pattern string|nil @ 选牌规则 +---@param prompt string|nil @ 提示信息 +---@param cancelable bool @ 能否点取消 +---@param no_indicate bool @ 是否不显示指示线 +---@return integer[], integer[] +function Room:askForChooseBoth(player, minCardNum, maxCardNum, targets, minTargetNum, maxTargetNum, pattern, prompt, skillName, cancelable, no_indicate) + if minCardNum < 1 or minTargetNum < 1 then + return table.unpack({}, {}) + end + cancelable = (cancelable == nil) and true or cancelable + no_indicate = no_indicate or false + pattern = pattern or "." + + local pcards = table.filter(player:getCardIds({ Player.Hand, Player.Equip }), function(id) + local c = Fk:getCardById(id) + return c:matchPattern(pattern) + end) + if #pcards < minCardNum and not cancelable then return table.unpack({}, {}) end + + local data = { + targets = targets, + max_target_num = maxTargetNum, + min_target_num = minTargetNum, + max_card_num = maxCardNum, + min_card_num = minCardNum, + pattern = pattern, + skillName = skillName, + } + local _, ret = self:askForUseActiveSkill(player, "ex__choose_skill", prompt or "", cancelable, data, no_indicate) + if ret then + return ret.targets, ret.cards + else + if cancelable then + return table.unpack({}, {}) + else + return table.random(targets, minTargetNum), table.random(pcards, minCardNum) + end + end +end + --- 抽个武将 --- --- 同getNCards,抽出来就没有了,所以记得放回去。 @@ -1609,6 +1658,35 @@ function Room:askForChoice(player, choices, skill_name, prompt, detailed, all_ch return result end +--- 询问一名玩家从众多选项中勾选任意项。 +---@param player ServerPlayer @ 要询问的玩家 +---@param choices string[] @ 可选选项列表 +---@param minNum number @ 最少选择项数 +---@param maxNum number @ 最多选择项数 +---@param skill_name string|nil @ 技能名 +---@param prompt string|nil @ 提示信息 +---@param cancelable bool|nil @ 是否可取消 +---@param detailed bool @ 选项详细描述 +---@param all_choices string[]|nil @ 所有选项(不可选变灰) +---@return string[] @ 选择的选项 +function Room:askForCheck(player, choices, minNum, maxNum, skill_name, prompt, cancelable, detailed, all_choices) + cancelable = (cancelable == nil) and true or cancelable + if #choices <= minNum and not all_choices then return choices end + assert(minNum <= maxNum) + assert(not all_choices or table.every(choices, function(c) return table.contains(all_choices, c) end)) + local command = "AskForCheck" + skill_name = skill_name or "" + prompt = prompt or "" + all_choices = all_choices or choices + detailed = detailed or false + self:notifyMoveFocus(player, skill_name) + local result = self:doRequest(player, command, json.encode{ + choices, all_choices, {minNum, maxNum}, cancelable, skill_name, prompt, detailed + }) + if result == "" then return {} end + return json.decode(result) +end + --- 询问玩家是否发动技能。 ---@param player ServerPlayer @ 要询问的玩家 ---@param skill_name string @ 技能名 diff --git a/packages/standard/aux_skills.lua b/packages/standard/aux_skills.lua index 4013ea23..ba2e878f 100644 --- a/packages/standard/aux_skills.lua +++ b/packages/standard/aux_skills.lua @@ -92,6 +92,23 @@ local choosePlayersSkill = fk.CreateActiveSkill{ max_target_num = function(self) return self.num end, } +local exChooseSkill = fk.CreateActiveSkill{ + name = "ex__choose_skill", + card_filter = function(self, to_select, selected) + return self.pattern ~= "" and Exppattern:Parse(self.pattern):match(Fk:getCardById(to_select)) and #selected < self.max_card_num + end, + target_filter = function(self, to_select, selected, cards) + if self.pattern ~= "" and #cards < self.min_card_num then return end + if #selected < self.max_target_num then + return table.contains(self.targets, to_select) + end + end, + min_target_num = function(self) return self.min_target_num end, + max_target_num = function(self) return self.max_target_num end, + min_card_num = function(self) return self.min_card_num end, + max_card_num = function(self) return self.max_card_num end, +} + local maxCardsSkill = fk.CreateMaxCardsSkill{ name = "max_cards_skill", global = true, @@ -222,6 +239,7 @@ AuxSkills = { discardSkill, chooseCardsSkill, choosePlayersSkill, + exChooseSkill, maxCardsSkill, choosePlayersToMoveCardInBoardSkill, uncompulsoryInvalidity,