录像保存. 重放.
This commit is contained in:
notify 2023-08-01 21:01:01 +08:00 committed by GitHub
parent 1fcd63ddeb
commit 59c25c583c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 617 additions and 14 deletions

View File

@ -40,6 +40,7 @@ QtObject {
property int roomTimeout: 0
property bool enableFreeAssign: false
property bool observing: false
property bool replaying: false
property var blockedUsers: []
function loadConf() {

View File

@ -1,5 +1,6 @@
// SPDX-License-Identifier: GPL-3.0-or-later
/*
var generalsOverviewPage, cardsOverviewPage;
var clientPageCreated = false;
function createClientPages() {
@ -13,6 +14,7 @@ function createClientPages() {
mainWindow.cardsOverviewPage = cardsOverviewPage;
}
}
*/
var callbacks = {};
let sheduled_download = "";
@ -98,7 +100,7 @@ callbacks["BackToStart"] = (jsonData) => {
callbacks["EnterLobby"] = (jsonData) => {
// depth == 1 means the lobby page is not present in mainStack
createClientPages();
// createClientPages();
if (mainStack.depth === 1) {
// we enter the lobby successfully, so save password now.
config.lastLoginServer = config.serverAddr;

View File

@ -168,6 +168,7 @@ Item {
lobby_dialog.sourceComponent = Qt.createComponent("../LobbyElement/CreateRoom.qml");
lobby_drawer.open();
config.observing = false;
config.replaying = false;
}
}
@ -197,6 +198,9 @@ Item {
}
Button {
text: Backend.translate("Replay")
onClicked: {
mainStack.push(mainWindow.replayPage);
}
}
Button {
text: Backend.translate("About")
@ -279,6 +283,7 @@ Item {
}
function enterRoom(roomId, playerNum, capacity, pw) {
config.replaying = false;
if (playerNum < capacity) {
config.observing = false;
Backend.callLuaFunction("SetObserving", [false]);

155
Fk/Pages/Replay.qml Normal file
View File

@ -0,0 +1,155 @@
// SPDX-License-Identifier: GPL-3.0-or-later
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Fk
Item {
id: root
ToolBar {
id: bar
width: parent.width
RowLayout {
anchors.fill: parent
ToolButton {
icon.source: AppPath + "/image/modmaker/back"
onClicked: mainStack.pop();
}
Label {
text: Backend.translate("Replay Manager")
horizontalAlignment: Qt.AlignHCenter
Layout.fillWidth: true
}
ToolButton {
icon.source: AppPath + "/image/modmaker/menu"
onClicked: menu.open()
Menu {
id: menu
y: bar.height
}
}
}
}
Rectangle {
width: parent.width
height: parent.height - bar.height
anchors.top: bar.bottom
color: "snow"
opacity: 0.75
clip: true
ListView {
id: list
clip: true
anchors.fill: parent
model: ListModel {
id: model
}
delegate: Item {
width: root.width
height: 64
Image {
id: generalPic
anchors.top: parent.top
anchors.left: parent.left
anchors.margins: 8
width: 48
height: 48
source: SkinBank.getGeneralExtraPic(general, "avatar/") ?? SkinBank.getGeneralPicture(general)
sourceClipRect: sourceSize.width > 200 ? Qt.rect(61, 0, 128, 128) : undefined
Rectangle {
anchors.fill: parent
color: "transparent"
border.width: 1
}
}
ColumnLayout {
anchors.left: generalPic.right
anchors.margins: 8
Text {
text: {
const win = winner.split("+").indexOf(role) !== -1;
const winStr = win ? Backend.translate("Game Win") : Backend.translate("Game Lose");
return "<b>" + Backend.translate(general) + "</b> " + Backend.translate(role) + " " + winStr;
}
font.pixelSize: 20
textFormat: Text.RichText
}
Text {
text: {
const y = repDate.slice(0,4);
const month = repDate.slice(4,6);
const d = repDate.slice(6,8);
const h = repDate.slice(8,10);
const m = repDate.slice(10,12);
const s = repDate.slice(12,14);
const dateStr = y + "-" + month + "-" + d + " " + h + ":" + m + ":" + s;
return playerName + " " + Backend.translate(gameMode) + " " + dateStr
}
}
}
Button {
id: replayBtn
text: Backend.translate("Play the Replay")
anchors.right: delBtn.left
anchors.rightMargin: 8
onClicked: {
config.observing = true;
config.replaying = true;
Backend.playRecord(fileName);
}
}
Button {
id: delBtn
text: Backend.translate("Delete Replay")
anchors.right: parent.right
anchors.rightMargin: 8
onClicked: {
Backend.removeRecord(fileName);
removeModel(index);
}
}
}
}
}
function updateList() {
model.clear();
const data = Backend.ls("recording");
data.reverse();
data.forEach(s => {
const d = s.split(".");
if (d.length !== 8) return;
// s: <time>.<screenName>.<mode>.<general>.<role>.<winner>.fk.rep
const [t, name, mode, general, role, winner] = d;
model.append({
fileName: s,
repDate: t,
playerName: name,
gameMode: mode,
general: general,
role: role,
winner: winner,
})
});
}
function removeModel(index) {
model.remove(index);
}
Component.onCompleted: {
updateList();
}
}

View File

@ -44,6 +44,10 @@ Item {
property var extra_data: ({})
property var skippedUseEventId: []
property real replayerSpeed
property int replayerElapsed
property int replayerDuration
Image {
source: config.roomBg
anchors.fill: parent
@ -100,7 +104,7 @@ Item {
Button {
id: surrenderButton
enabled: !config.observing
enabled: !config.observing && !config.replaying
text: Backend.translate("Surrender")
onClicked: {
if (isStarted && !getPhoto(Self.id).dead) {
@ -143,7 +147,10 @@ Item {
id: quitButton
text: Backend.translate("Quit")
onClicked: {
if (config.observing) {
if (config.replaying) {
Backend.controlReplayer("shutdown");
mainStack.pop();
} else if (config.observing) {
ClientInstance.notifyServer("QuitRoom", "[]");
} else {
quitDialog.open();
@ -437,12 +444,72 @@ Item {
GlowText {
text: Backend.translate("Observing ...")
visible: config.observing
visible: config.observing && !config.replaying
color: "#4B83CD"
font.family: fontLi2.name
font.pixelSize: 48
}
Rectangle {
id: replayControls
visible: config.replaying
anchors.bottom: dashboard.top
anchors.bottomMargin: -60
anchors.horizontalCenter: parent.horizontalCenter
width: childrenRect.width + 8
height: childrenRect.height + 8
color: "#88EEEEEE"
radius: 4
RowLayout {
x: 4; y: 4
Text {
font.pixelSize: 20
font.bold: true
text: {
const elapsedMin = Math.floor(replayerElapsed / 60);
const elapsedSec = replayerElapsed % 60;
const totalMin = Math.floor(replayerDuration / 60);
const totalSec = replayerDuration % 60;
return elapsedMin.toString() + ":" + elapsedSec + "/" + totalMin + ":" + totalSec;
}
}
Switch {
text: "匀速"
checked: false
onCheckedChanged: Backend.controlReplayer("uniform");
}
Button {
text: "减速"
onClicked: Backend.controlReplayer("slowdown");
}
Text {
font.pixelSize: 20
font.bold: true
text: "x" + replayerSpeed;
}
Button {
text: "加速"
onClicked: Backend.controlReplayer("speedup");
}
Button {
property bool running: true
text: running ? "暂停" : "继续"
onClicked: {
running = !running;
Backend.controlReplayer("toggle");
}
}
}
}
Item {
id: controls
anchors.bottom: dashboard.top
@ -721,6 +788,7 @@ Item {
}
}
Item {
visible: !config.replaying
ChatBox {
id: chat
anchors.fill: parent

View File

@ -1383,6 +1383,7 @@ callbacks["ChangeSelf"] = (j) => {
callbacks["AskForLuckCard"] = (j) => {
// jsonData: int time
if (config.replaying) return;
const time = parseInt(j);
roomScene.setPrompt(Backend.translate("#AskForLuckCard").arg(time), true);
roomScene.state = "replying";
@ -1398,3 +1399,15 @@ callbacks["AskForLuckCard"] = (j) => {
callbacks["CancelRequest"] = (jsonData) => {
ClientInstance.replyToServer("", "__cancel")
}
callbacks["ReplayerDurationSet"] = (j) => {
roomScene.replayerDuration = parseInt(j);
}
callbacks["ReplayerElapsedChange"] = (j) => {
roomScene.replayerElapsed = parseInt(j);
}
callbacks["ReplayerSpeedChange"] = (j) => {
roomScene.replayerSpeed = parseFloat(j);
}

View File

@ -9,5 +9,6 @@ MetroButton 1.0 MetroButton.qml
ModesOverview 1.0 ModesOverview.qml
PackageManage 1.0 PackageManage.qml
Room 1.0 Room.qml
Replay 1.0 Replay.qml
TileButton 1.0 TileButton.qml
ModMaker 1.0 ModMaker.qml

View File

@ -38,8 +38,26 @@ GraphicsBox {
anchors.horizontalCenter: parent.horizontalCenter
onClicked: {
if (config.replaying) {
mainStack.pop();
Backend.controlReplayer("shutdown");
} else {
ClientInstance.notifyServer("QuitRoom", "[]");
}
}
}
MetroButton {
id: repBtn
text: Backend.translate("Save Replay")
anchors.horizontalCenter: parent.horizontalCenter
visible: !config.replaying
onClicked: {
repBtn.visible = false;
Backend.callLuaFunction("SaveRecord", []);
toast.show("OK.");
}
}
}
}

View File

@ -57,13 +57,15 @@ Window {
Component { id: generalsOverview; GeneralsOverview {} }
Component { id: cardsOverview; CardsOverview {} }
Component { id: modesOverview; ModesOverview {} }
Component { id: replay; Replay {} }
Component { id: room; Room {} }
Component { id: aboutPage; About {} }
property var generalsOverviewPage
property var cardsOverviewPage
property alias generalsOverviewPage: generalsOverview
property alias cardsOverviewPage: cardsOverview
property alias modesOverviewPage: modesOverview
property alias aboutPage: aboutPage
property alias replayPage: replay
property bool busy: false
property string busyText: ""
onBusyChanged: busyText = "";

View File

@ -30,8 +30,8 @@ function Client:initialize()
fk.Backend:emitNotifyUI(command, jsonData)
end
self.client.callback = function(_self, command, jsonData, isRequest)
if self.recording and not self.observing then
table.insert(self.record, {os.getms() / 1000, isRequest, command, jsonData})
if self.recording then
table.insert(self.record, {math.floor(os.getms() / 1000), isRequest, command, jsonData})
end
local cb = fk.client_callback[command]
@ -256,7 +256,9 @@ fk.client_callback["EnterRoom"] = function(jsonData)
ClientInstance.alive_players = {Self}
ClientInstance.discard_pile = {}
local data = json.decode(jsonData)[3]
local _data = json.decode(jsonData)
local data = _data[3]
ClientInstance.enter_room_data = jsonData;
ClientInstance.room_settings = data
ClientInstance.disabled_packs = data.disabledPack
ClientInstance.disabled_generals = data.disabledGenerals
@ -793,11 +795,23 @@ end
fk.client_callback["StartGame"] = function(jsonData)
local c = ClientInstance
c.record = { fk.FK_VER, os.date("%Y%m%d%H%M%S") }
c.record = {
fk.FK_VER,
os.date("%Y%m%d%H%M%S"),
c.enter_room_data,
json.encode { Self.id, fk.Self:getScreenName(), fk.Self:getAvatar() },
-- RESERVED
"",
"",
"",
"",
"",
"",
}
for _, p in ipairs(c.players) do
if p.id ~= Self.id then
table.insert(c.record, {
os.getms() / 100,
math.floor(os.getms() / 1000),
false,
"AddPlayer",
json.encode {
@ -815,7 +829,7 @@ end
fk.client_callback["GameOver"] = function(jsonData)
local c = ClientInstance
if c.recording and not c.observing then
if c.recording then
c.recording = false
c.record[2] = table.concat({
c.record[2],
@ -832,6 +846,7 @@ end
fk.client_callback["EnterLobby"] = function(jsonData)
local c = ClientInstance
--[[
if c.recording and not c.observing then
c.recording = false
c.record[2] = table.concat({
@ -844,6 +859,7 @@ fk.client_callback["EnterLobby"] = function(jsonData)
}, ".")
-- c.client:saveRecord(json.encode(c.record), c.record[2])
end
--]]
c:notifyUI("EnterLobby", jsonData)
end

View File

@ -640,4 +640,9 @@ function CheckSurrenderAvailable(playedTime)
return json.encode(Fk.game_modes[curMode]:surrenderFunc(playedTime))
end
function SaveRecord()
local c = ClientInstance
c.client:saveRecord(json.encode(c.record), c.record[2])
end
dofile "lua/client/i18n/init.lua"

View File

@ -72,6 +72,11 @@ Fk:loadTranslationTable{
["Every suit & number:"] = "<b>所有的花色和点数:</b>",
["Scenarios Overview"] = "玩法一览",
["Replay"] = "录像",
["Replay Manager"] = "来欣赏潇洒的录像吧!",
["Game Win"] = "胜利",
["Game Lose"] = "失败",
["Play the Replay"] = "重放",
["Delete Replay"] = "删除",
["About"] = "关于",
["about_freekill_description"] = [[
#
@ -237,6 +242,7 @@ FreeKill使用的是libgit2的C API与此同时使用Git完成拓展包的下
["$NoWinner"] = "平局!",
["Back To Room"] = "回到房间",
["Back To Lobby"] = "返回大厅",
["Save Replay"] = "保存录像",
["Bulletin Info"] = [==[
## v0.2.7

View File

@ -20,6 +20,7 @@ if (NOT DEFINED FK_SERVER_ONLY)
list(APPEND freekill_SRCS
"client/client.cpp"
"client/clientplayer.cpp"
"client/replayer.cpp"
"ui/mod.cpp"
)
endif ()

View File

@ -89,3 +89,7 @@ void Client::saveRecord(const QString &json, const QString &fname) {
c.write(qCompress(json.toUtf8()));
c.close();
}
void Client::processReplay(const QString &c, const QString &j) {
callLua(c, j);
}

View File

@ -33,9 +33,13 @@ public:
void installAESKey(const QByteArray &key);
void saveRecord(const QString &json, const QString &fname);
signals:
void error_message(const QString &msg);
public slots:
void processReplay(const QString &, const QString &);
private:
Router *router;
QMap<int, ClientPlayer *> players;

175
src/client/replayer.cpp Normal file
View File

@ -0,0 +1,175 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "replayer.h"
#include "client.h"
#include "qmlbackend.h"
#include "util.h"
Replayer::Replayer(QObject *parent, const QString &filename) :
QThread(parent), fileName(filename), roomSettings(""), origPlayerInfo(""),
playing(true), killed(false), speed(1.0), uniformRunning(false)
{
setObjectName("Replayer");
QFile file("recording/" + filename);
file.open(QIODevice::ReadOnly);
QByteArray raw = file.readAll();
file.close();
auto data = qUncompress(raw);
auto doc = QJsonDocument::fromJson(data);
auto arr = doc.array();
if (arr.count() < 10) {
return;
}
auto ver = arr[0].toString();
if (ver != FK_VERSION) {
Backend->showToast("Warning: Mismatch version of replay detected, which may cause crashes.");
}
roomSettings = arr[2].toString();
foreach (auto v, arr) {
if (!v.isArray()) {
continue;
}
auto a = v.toArray();
Pair *pair = new Pair;
pair->elapsed = a[0].toInteger();
pair->isRequest = a[1].toBool();
pair->cmd = a[2].toString();
pair->jsonData = a[3].toString();
pairs << pair;
}
connect(this, &Replayer::command_parsed, ClientInstance, &Client::processReplay);
auto playerInfoRaw = arr[3].toString();
auto playerInfo = QJsonDocument::fromJson(playerInfoRaw.toUtf8()).array();
if (playerInfo[0].toInt() != Self->getId()) {
origPlayerInfo = JsonArray2Bytes({ Self->getId(), Self->getScreenName(), Self->getAvatar() });
emit command_parsed("Setup", playerInfoRaw);
}
}
Replayer::~Replayer() {
if (origPlayerInfo != "") {
emit command_parsed("Setup", origPlayerInfo);
}
Backend->setReplayer(nullptr);
foreach (auto e, pairs) {
delete e;
}
}
int Replayer::getDuration() const {
long ret = (pairs.last()->elapsed - pairs.first()->elapsed) / 1000.0;
return (int)ret;
}
qreal Replayer::getSpeed() {
qreal speed;
mutex.lock();
speed = this->speed;
mutex.unlock();
return speed;
}
void Replayer::uniform() {
mutex.lock();
uniformRunning = !uniformRunning;
mutex.unlock();
}
void Replayer::speedUp() {
mutex.lock();
if (speed < 16.0) {
qreal inc = speed >= 2.0 ? 1.0 : 0.5;
speed += inc;
emit speed_changed(speed);
}
mutex.unlock();
}
void Replayer::slowDown() {
mutex.lock();
if (speed >= 1.0) {
qreal dec = speed > 2.0 ? 1.0 : 0.5;
speed -= dec;
emit speed_changed(speed);
}
mutex.unlock();
}
void Replayer::toggle() {
playing = !playing;
if (playing)
play_sem.release();
}
void Replayer::shutdown() {
killed = true;
}
void Replayer::run() {
long last = 0;
long start = 0;
if (roomSettings == "") {
Backend->showToast("Invalid replay file.");
deleteLater();
return;
}
emit command_parsed("EnterRoom", roomSettings);
emit command_parsed("StartGame", "");
emit speed_changed(getSpeed());
emit duration_set(getDuration());
foreach (auto pair, pairs) {
if (killed) {
break;
}
if (pair->isRequest) {
continue;
}
long delay = pair->elapsed - last;
if (uniformRunning) {
delay = qMin(delay, 2000);
if (delay > 500)
delay = 2000;
} else if (last == 0) {
delay = 100;
}
last = pair->elapsed;
if (start == 0) start = last;
bool delayed = true;
if (!pair->isRequest) {
delay /= getSpeed();
msleep(delay);
emit elasped((pair->elapsed - start) / 1000);
emit command_parsed(pair->cmd, pair->jsonData);
if (!playing)
play_sem.acquire();
}
}
deleteLater();
}

52
src/client/replayer.h Normal file
View File

@ -0,0 +1,52 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#ifndef _REPLAYER_H
#define _REPLAYER_H
class Replayer : public QThread {
Q_OBJECT
public:
explicit Replayer(QObject *parent, const QString &filename);
~Replayer();
int getDuration() const;
qreal getSpeed();
signals:
void duration_set(int secs);
void elasped(int secs);
void speed_changed(qreal speed);
void command_parsed(const QString &cmd, const QString &j);
public slots:
void uniform();
void toggle();
void speedUp();
void slowDown();
void shutdown();
protected:
virtual void run();
private:
QString fileName;
qreal speed;
bool playing;
bool killed;
bool uniformRunning;
QString roomSettings;
QString origPlayerInfo;
QMutex mutex;
QSemaphore play_sem;
struct Pair {
long elapsed;
bool isRequest;
QString cmd;
QString jsonData;
};
QList<Pair *> pairs;
};
#endif // _REPLAYER_H

View File

@ -107,7 +107,7 @@ sqlite3 *OpenDatabase(const QString &filename, const QString &initSql) {
}
bool CheckSqlString(const QString &str) {
static const QRegularExpression exp("['\";#]+|(--)|(/\\*)|(\\*/)|(--\\+)");
static const QRegularExpression exp("['\";#* /\\\\?<>|:]+|(--)|(/\\*)|(\\*/)|(--\\+)");
return (!exp.match(str).hasMatch() && !str.isEmpty());
}

View File

@ -20,6 +20,7 @@
#endif
#include "client.h"
#include "util.h"
#include "replayer.h"
QmlBackend *Backend = nullptr;
@ -27,6 +28,7 @@ QmlBackend::QmlBackend(QObject *parent) : QObject(parent) {
Backend = this;
#ifndef FK_SERVER_ONLY
engine = nullptr;
replayer = nullptr;
rsa = RSA_new();
udpSocket = new QUdpSocket(this);
udpSocket->bind(0);
@ -95,6 +97,9 @@ void QmlBackend::joinServer(QString address) {
return;
Client *client = new Client(this);
connect(client, &Client::error_message, this, [=](const QString &msg) {
if (replayer) {
emit replayerShutdown();
}
client->deleteLater();
emit notifyUI("ErrorMsg", msg);
emit notifyUI("BackToStart", "[]");
@ -181,6 +186,7 @@ void QmlBackend::pushLuaValue(lua_State *L, QVariant v) {
QString QmlBackend::callLuaFunction(const QString &func_name,
QVariantList params) {
if (!ClientInstance) return "{}";
lua_State *L = ClientInstance->getLuaState();
lua_getglobal(L, func_name.toLatin1().data());
@ -196,6 +202,7 @@ QString QmlBackend::callLuaFunction(const QString &func_name,
return "";
}
lua_pop(L, 1);
return QString(result);
}
@ -373,4 +380,57 @@ void QmlBackend::readPendingDatagrams() {
}
}
void QmlBackend::removeRecord(const QString &fname) {
QFile::remove("recording/" + fname);
}
void QmlBackend::playRecord(const QString &fname) {
auto replayer = new Replayer(this, fname);
setReplayer(replayer);
replayer->start();
}
Replayer *QmlBackend::getReplayer() const {
return replayer;
}
void QmlBackend::setReplayer(Replayer *rep) {
auto r = replayer;
if (r) {
r->disconnect(this);
disconnect(r);
}
replayer = rep;
if (rep) {
connect(rep, &Replayer::duration_set, this, [this](int sec) {
this->emitNotifyUI("ReplayerDurationSet", QString::number(sec));
});
connect(rep, &Replayer::elasped, this, [this](int sec) {
this->emitNotifyUI("ReplayerElapsedChange", QString::number(sec));
});
connect(rep, &Replayer::speed_changed, this, [this](qreal speed) {
this->emitNotifyUI("ReplayerSpeedChange", QString::number(speed));
});
connect(this, &QmlBackend::replayerToggle, rep, &Replayer::toggle);
connect(this, &QmlBackend::replayerSlowDown, rep, &Replayer::slowDown);
connect(this, &QmlBackend::replayerSpeedUp, rep, &Replayer::speedUp);
connect(this, &QmlBackend::replayerUniform, rep, &Replayer::uniform);
connect(this, &QmlBackend::replayerShutdown, rep, &Replayer::shutdown);
}
}
void QmlBackend::controlReplayer(QString type) {
if (type == "toggle") {
emit replayerToggle();
} else if (type == "speedup") {
emit replayerSpeedUp();
} else if (type == "slowdown") {
emit replayerSlowDown();
} else if (type == "uniform") {
emit replayerUniform();
} else if (type == "shutdown") {
emit replayerShutdown();
}
}
#endif

View File

@ -6,6 +6,8 @@
#include <openssl/rsa.h>
#include <openssl/pem.h>
class Replayer;
#include <qtmetamacros.h>
class QmlBackend : public QObject {
Q_OBJECT
@ -63,9 +65,20 @@ public:
void showToast(const QString &s) { emit notifyUI("ShowToast", s); }
Q_INVOKABLE void removeRecord(const QString &);
Q_INVOKABLE void playRecord(const QString &);
Replayer *getReplayer() const;
void setReplayer(Replayer *rep);
Q_INVOKABLE void controlReplayer(QString type);
signals:
void notifyUI(const QString &command, const QString &jsonData);
void volumeChanged(qreal);
void replayerToggle();
void replayerSpeedUp();
void replayerSlowDown();
void replayerUniform();
void replayerShutdown();
private slots:
void readPendingDatagrams();
@ -80,6 +93,8 @@ private:
QString aes_key;
qreal m_volume;
Replayer *replayer;
void pushLuaValue(lua_State *L, QVariant v);
#endif
};