Compare commits

..

14 Commits

Author SHA1 Message Date
Kieran Kihn
8bb5a8c9eb docs: add README.md 2025-12-27 19:19:58 +08:00
Kieran Kihn
1b52c87b0e feat(ui): add scroll support for hand cards 2025-12-18 23:34:24 +08:00
Kieran Kihn
150e29c25c feat(ui): add dynamic scaling support
- Introduced `scale` property across components for responsive design.
- Adjusted dimensions and positions dynamically based on scale.
- Enhanced flexibility for varying display resolutions.
2025-12-18 11:42:29 +08:00
Kieran Kihn
6276dbdea3 fix(ui): reset selected-card-index on card draw button click 2025-12-17 23:37:36 +08:00
Kieran Kihn
68ccfe2147 fix(game): ensure drawCount_ is reset in GameState::endGame 2025-12-17 23:37:25 +08:00
Kieran Kihn
5f11c609c1 feat(logging): enable fine-grained spdlog levels based on build type 2025-12-11 23:07:46 +08:00
Kieran Kihn
2fa428a493 fix(game): add initialization for drawCount_ in GameState classes 2025-12-11 23:07:22 +08:00
Kieran Kihn
49b30a7d5a chore: copy assets to output directory post-build
- Added logic to copy the `assets` directory to the output folder for `uno-client` and `uno-server`.
2025-12-11 22:47:54 +08:00
Kieran Kihn
c2a57dba81 feat(logging): integrate spdlog for enhanced logging and debugging
- Added spdlog dependency to `CMakeLists.txt`.
- Implemented a centralized `Logger` with file rotation and console output support.
- Added detailed logging across server, client, and game modules for improved traceability.
2025-12-11 22:47:33 +08:00
Kieran Kihn
11fc7cd74d chore: restructure build targets and add runtime DLL copy logic
- Split `uno-client` and `uno-server` as separate executables.
- Adjusted `uno-game-lib` linking and updated runtime output directory.
- Enabled post-build DLL copying for Windows compatibility.
2025-12-11 12:32:19 +08:00
Kieran Kihn
b0212161ac fix(ui): simplify GamePage animation logic
- Replaced `cycle` property with constant `base-angle`.
- Removed `Timer` and associated counter logic.
- Enabled infinite animation with `iteration-count: -1`.
2025-12-11 11:18:10 +08:00
Kieran Kihn
d494f1af75 style: style library linking in CMakeLists.txt 2025-12-11 10:48:11 +08:00
Kieran Kihn
206286d191 feat(assets): add Windows resource file and icon to executables 2025-12-11 10:47:16 +08:00
Kieran Kihn
f1f61516f7 chore: correct debug configuration check on Windows 2025-12-10 22:37:46 +08:00
21 changed files with 553 additions and 257 deletions

View File

@@ -2,51 +2,81 @@ cmake_minimum_required(VERSION 4.0)
project(uno-game) project(uno-game)
set(CMAKE_CXX_STANDARD 26) set(CMAKE_CXX_STANDARD 26)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
find_package(nlohmann_json REQUIRED) find_package(nlohmann_json REQUIRED)
find_package(asio REQUIRED) find_package(asio REQUIRED)
find_package(argparse REQUIRED) find_package(argparse REQUIRED)
find_package(Slint REQUIRED) find_package(Slint REQUIRED)
find_package(spdlog REQUIRED)
# Uno Game Library
add_library(uno-game-lib add_library(uno-game-lib
src/game/Card.cpp src/game/Card.cpp
src/game/CardTile.cpp src/game/CardTile.cpp
src/game/Player.cpp src/game/Player.cpp
src/game/GameState.cpp src/game/GameState.cpp
src/common/Logger.cpp
src/common/Utils.cpp src/common/Utils.cpp
src/network/Message.cpp src/network/Message.cpp
src/network/MessageSerializer.cpp src/network/MessageSerializer.cpp
src/network/NetworkServer.cpp src/network/NetworkServer.cpp
src/network/NetworkClient.cpp src/network/NetworkClient.cpp
src/client/UnoClient.cpp
src/server/UnoServer.cpp
src/network/Session.cpp src/network/Session.cpp
src/client/PlayerAction.cpp
src/ui/GameUI.cpp
)
target_link_libraries(uno-game-lib
PUBLIC nlohmann_json::nlohmann_json
)
target_link_libraries(uno-game-lib
PUBLIC asio::asio
) )
target_link_libraries(uno-game-lib PUBLIC nlohmann_json::nlohmann_json)
target_link_libraries(uno-game-lib PUBLIC asio::asio)
target_link_libraries(uno-game-lib PUBLIC Slint::Slint) target_link_libraries(uno-game-lib PUBLIC Slint::Slint)
target_link_libraries(uno-game-lib PUBLIC argparse::argparse)
target_link_libraries(uno-game-lib PUBLIC spdlog::spdlog)
# Enable fine-grained spdlog levels: in Debug build, compile-in trace/debug; in others, keep info+
target_compile_definitions(uno-game-lib PUBLIC
$<$<CONFIG:Debug>:SPDLOG_ACTIVE_LEVEL=SPDLOG_LEVEL_TRACE>
$<$<NOT:$<CONFIG:Debug>>:SPDLOG_ACTIVE_LEVEL=SPDLOG_LEVEL_INFO>
)
slint_target_sources(uno-game-lib ui/MainWindow.slint) slint_target_sources(uno-game-lib ui/MainWindow.slint)
add_executable(uno-client src/client/main.cpp) # Uno Client
target_link_libraries(uno-client add_executable(uno-client
PRIVATE uno-game-lib) src/client/main.cpp
src/client/PlayerAction.cpp
src/client/UnoClient.cpp
src/ui/GameUI.cpp
assets/uno.rc
)
target_link_libraries(uno-client PRIVATE uno-game-lib)
if (WIN32 AND CMAKE_BUILD_TYPE STREQUAL "DEBUG") if (WIN32 AND NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
set_target_properties(uno-client PROPERTIES WIN32_EXECUTABLE ON) set_target_properties(uno-client PROPERTIES WIN32_EXECUTABLE ON)
endif () endif ()
add_executable(uno-server src/server/main.cpp)
target_link_libraries(uno-server
PRIVATE uno-game-lib)
target_link_libraries(uno-server
PRIVATE argparse::argparse
)
# Uno Server
add_executable(uno-server
src/server/main.cpp
src/server/UnoServer.cpp
assets/uno.rc
)
target_link_libraries(uno-server PRIVATE uno-game-lib)
# Google Test
add_subdirectory(test) add_subdirectory(test)
foreach (target uno-client uno-server)
# 复制动态库
add_custom_command(TARGET ${target} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
$<TARGET_RUNTIME_DLLS:${target}>
$<TARGET_FILE_DIR:${target}>
COMMAND_EXPAND_LISTS
)
# 复制资源文件
add_custom_command(TARGET ${target} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_SOURCE_DIR}/assets
$<TARGET_FILE_DIR:${target}>/assets
)
endforeach ()

View File

@@ -0,0 +1,32 @@
# UNO
这是一个使用 C++ 编写的现代化 GUI 的 UNO 游戏。
## 快速开始
### 预构建二进制
从 Release 中下载可执行文件,运行服务端后,使用客户端连接上即可游玩。
### 从源码构建
1. 确保你已经安装有 CMake + Visual Studio
2. 使用 vcpkg 或其他工具安装以下依赖argparse, asio, nlohmann-json, spdlog, slint
3. 激活 Visual Studio 环境变量
4. 执行以下代码构建:
```shell
mkdir build
cd build
cmake ..
cmake --build .
```
## TODO
- [ ] 添加喊 UNO 相关功能
- [ ] 完善客户端和服务端对异常的处理,加强鲁棒性
## 许可证
[MIT](./LICENSE)

BIN
assets/uno.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

1
assets/uno.rc Normal file
View File

@@ -0,0 +1 @@
IDI_ICON1 ICON "uno.ico"

View File

@@ -10,11 +10,13 @@
#include "../network/MessageSerializer.h" #include "../network/MessageSerializer.h"
#include <memory> #include <memory>
#include <spdlog/spdlog.h>
#include <utility> #include <utility>
namespace UNO::CLIENT { namespace UNO::CLIENT {
void UnoClient::handleNetworkConnected() void UnoClient::handleNetworkConnected()
{ {
SPDLOG_INFO("Connected to server, sending JOIN_GAME message");
NETWORK::JoinGamePayload messagePayload = {this->clientGameState_->getPlayerName()}; NETWORK::JoinGamePayload messagePayload = {this->clientGameState_->getPlayerName()};
NETWORK::Message message = {NETWORK::MessageStatus::OK, NETWORK::MessagePayloadType::JOIN_GAME, messagePayload}; NETWORK::Message message = {NETWORK::MessageStatus::OK, NETWORK::MessagePayloadType::JOIN_GAME, messagePayload};
networkClient_->send(NETWORK::MessageSerializer::serialize(message)); networkClient_->send(NETWORK::MessageSerializer::serialize(message));
@@ -25,11 +27,14 @@ namespace UNO::CLIENT {
void UnoClient::handleNetworkInitGame(const NETWORK::InitGamePayload &payload) void UnoClient::handleNetworkInitGame(const NETWORK::InitGamePayload &payload)
{ {
SPDLOG_INFO("Initializing game: player ID={}, {} players total", payload.playerId, payload.players.size());
this->clientGameState_->init(payload.players, payload.discardPile, payload.handCard, payload.currentPlayerIndex, payload.playerId); this->clientGameState_->init(payload.players, payload.discardPile, payload.handCard, payload.currentPlayerIndex, payload.playerId);
} }
void UnoClient::handleNetworkPlayCard(const NETWORK::PlayCardPayload &payload) void UnoClient::handleNetworkPlayCard(const NETWORK::PlayCardPayload &payload)
{ {
SPDLOG_DEBUG(
"Received PLAY_CARD: color={}, type={}", static_cast<int>(payload.card.getColor()), static_cast<int>(payload.card.getType()));
if (clientGameState_->getClientGameStage() == GAME::ClientGameStage::ACTIVE) { if (clientGameState_->getClientGameStage() == GAME::ClientGameStage::ACTIVE) {
clientGameState_->play(payload.card); clientGameState_->play(payload.card);
} }
@@ -38,6 +43,7 @@ namespace UNO::CLIENT {
void UnoClient::handleNetworkDrawCard(const NETWORK::DrawCardPayload &payload) void UnoClient::handleNetworkDrawCard(const NETWORK::DrawCardPayload &payload)
{ {
SPDLOG_DEBUG("Received DRAW_CARD: {} card(s)", payload.drawCount);
if (clientGameState_->getClientGameStage() == GAME::ClientGameStage::ACTIVE) { if (clientGameState_->getClientGameStage() == GAME::ClientGameStage::ACTIVE) {
clientGameState_->draw(payload.cards); clientGameState_->draw(payload.cards);
} }
@@ -46,12 +52,15 @@ namespace UNO::CLIENT {
void UnoClient::handleNetworkEndGame(const NETWORK::EndGamePayload &payload) void UnoClient::handleNetworkEndGame(const NETWORK::EndGamePayload &payload)
{ {
SPDLOG_INFO("Game ended");
this->clientGameState_->endGame(); this->clientGameState_->endGame();
} }
void UnoClient::handleNetworkMessage(const std::string &message) void UnoClient::handleNetworkMessage(const std::string &message)
{ {
auto networkMessage = NETWORK::MessageSerializer::deserialize(message); auto networkMessage = NETWORK::MessageSerializer::deserialize(message);
SPDLOG_DEBUG("Received network message, type: {}", static_cast<int>(networkMessage.getMessagePayloadType()));
if (networkMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::INIT_GAME) { if (networkMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::INIT_GAME) {
this->handleNetworkInitGame(std::get<NETWORK::InitGamePayload>(networkMessage.getMessagePayload())); this->handleNetworkInitGame(std::get<NETWORK::InitGamePayload>(networkMessage.getMessagePayload()));
} }
@@ -68,6 +77,7 @@ namespace UNO::CLIENT {
if (networkMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::EMPTY if (networkMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::EMPTY
|| networkMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::JOIN_GAME || networkMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::JOIN_GAME
|| networkMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::START_GAME) { || networkMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::START_GAME) {
SPDLOG_ERROR("Invalid message type from server: {}", static_cast<int>(networkMessage.getMessagePayloadType()));
throw std::invalid_argument("Invalid message type from server"); throw std::invalid_argument("Invalid message type from server");
} }
@@ -76,6 +86,7 @@ namespace UNO::CLIENT {
void UnoClient::handlePlayerAction(PlayerAction action) void UnoClient::handlePlayerAction(PlayerAction action)
{ {
SPDLOG_DEBUG("Handling player action, type: {}", static_cast<int>(action.playerActionType));
if (action.playerActionType == PlayerActionType::CONNECT) { if (action.playerActionType == PlayerActionType::CONNECT) {
this->handlePlayerConnect(std::get<PlayerConnectPayload>(action.payload)); this->handlePlayerConnect(std::get<PlayerConnectPayload>(action.payload));
} }
@@ -93,6 +104,7 @@ namespace UNO::CLIENT {
void UnoClient::handlePlayerConnect(const PlayerConnectPayload &payload) void UnoClient::handlePlayerConnect(const PlayerConnectPayload &payload)
{ {
SPDLOG_INFO("Player '{}' connecting to {}:{}", payload.playerName, payload.host, payload.port);
clientGameState_->setPlayerName(payload.playerName); clientGameState_->setPlayerName(payload.playerName);
networkClient_->connect(payload.host, payload.port); networkClient_->connect(payload.host, payload.port);
} }
@@ -100,6 +112,7 @@ namespace UNO::CLIENT {
void UnoClient::handlePlayerStartGame(PlayerStartGamePayload payload) void UnoClient::handlePlayerStartGame(PlayerStartGamePayload payload)
{ {
SPDLOG_INFO("Player requests to start game");
NETWORK::StartGamePayload messagePayload = {}; NETWORK::StartGamePayload messagePayload = {};
NETWORK::Message message = {NETWORK::MessageStatus::OK, NETWORK::MessagePayloadType::START_GAME, messagePayload}; NETWORK::Message message = {NETWORK::MessageStatus::OK, NETWORK::MessagePayloadType::START_GAME, messagePayload};
networkClient_->send(NETWORK::MessageSerializer::serialize(message)); networkClient_->send(NETWORK::MessageSerializer::serialize(message));
@@ -115,9 +128,12 @@ namespace UNO::CLIENT {
if ((card->getType() == GAME::CardType::WILD || card->getType() == GAME::CardType::WILDDRAWFOUR) if ((card->getType() == GAME::CardType::WILD || card->getType() == GAME::CardType::WILDDRAWFOUR)
&& card->getColor() != GAME::CardColor::RED) { && card->getColor() != GAME::CardColor::RED) {
SPDLOG_ERROR("Invalid wild card played by player: card doesn't have color set");
throw std::invalid_argument("Invalid card played by player"); throw std::invalid_argument("Invalid card played by player");
} }
SPDLOG_INFO("Player plays card: color={}, type={}", card->colorToString(), card->typeToString());
NETWORK::PlayCardPayload messagePayload = { NETWORK::PlayCardPayload messagePayload = {
{(card->getType() != GAME::CardType::WILD && card->getType() != GAME::CardType::WILDDRAWFOUR) ? card->getColor() {(card->getType() != GAME::CardType::WILD && card->getType() != GAME::CardType::WILDDRAWFOUR) ? card->getColor()
: payload.color, : payload.color,
@@ -128,6 +144,7 @@ namespace UNO::CLIENT {
void UnoClient::handlePlayerDrawCard(PlayerDrawCardPayload payload) void UnoClient::handlePlayerDrawCard(PlayerDrawCardPayload payload)
{ {
SPDLOG_INFO("Player requests to draw card");
NETWORK::DrawCardPayload messagePayload = {}; NETWORK::DrawCardPayload messagePayload = {};
NETWORK::Message message = {NETWORK::MessageStatus::OK, NETWORK::MessagePayloadType::DRAW_CARD, messagePayload}; NETWORK::Message message = {NETWORK::MessageStatus::OK, NETWORK::MessagePayloadType::DRAW_CARD, messagePayload};
networkClient_->send(NETWORK::MessageSerializer::serialize(message)); networkClient_->send(NETWORK::MessageSerializer::serialize(message));
@@ -135,6 +152,7 @@ namespace UNO::CLIENT {
UnoClient::UnoClient() UnoClient::UnoClient()
{ {
SPDLOG_DEBUG("UnoClient initialized");
clientGameState_ = std::make_shared<GAME::ClientGameState>(); clientGameState_ = std::make_shared<GAME::ClientGameState>();
networkClient_ = std::make_shared<NETWORK::NetworkClient>( networkClient_ = std::make_shared<NETWORK::NetworkClient>(
[this]() { this->handleNetworkConnected(); }, [this](const std::string &message) { this->handleNetworkMessage(message); }); [this]() { this->handleNetworkConnected(); }, [this](const std::string &message) { this->handleNetworkMessage(message); });
@@ -143,6 +161,7 @@ namespace UNO::CLIENT {
UnoClient::~UnoClient() UnoClient::~UnoClient()
{ {
SPDLOG_DEBUG("UnoClient shutting down");
networkClient_->stop(); networkClient_->stop();
if (networkThread_.joinable()) { if (networkThread_.joinable()) {
networkThread_.join(); networkThread_.join();
@@ -151,6 +170,7 @@ namespace UNO::CLIENT {
void UnoClient::run() void UnoClient::run()
{ {
SPDLOG_INFO("UnoClient starting");
networkThread_ = std::thread([this]() { this->networkClient_->run(); }); networkThread_ = std::thread([this]() { this->networkClient_->run(); });
gameUI_->run(); gameUI_->run();
} }

View File

@@ -1,11 +1,18 @@
#include "UnoClient.h" #include "UnoClient.h"
#include "../common/Logger.h"
#include <spdlog/spdlog.h>
#include <windows.h> #include <windows.h>
int main() int main()
{ {
UNO::COMMON::Logger::init("uno-client");
SPDLOG_INFO("Starting uno-client application");
UNO::CLIENT::UnoClient client; UNO::CLIENT::UnoClient client;
client.run(); client.run();
SPDLOG_INFO("uno-client exited");
return 0; return 0;
} }

42
src/common/Logger.cpp Normal file
View File

@@ -0,0 +1,42 @@
/**
* @file Logger.cpp
*/
#include "Logger.h"
#include <filesystem>
#include <memory>
#include <spdlog/sinks/rotating_file_sink.h>
#include <spdlog/sinks/stdout_color_sinks.h>
#include <spdlog/spdlog.h>
namespace fs = std::filesystem;
namespace UNO::COMMON {
void Logger::init(const std::string &app_name, const std::string &log_dir)
{
fs::path dir{log_dir};
if (!fs::exists(dir)) {
fs::create_directories(dir);
}
auto logfile = (dir / (app_name + ".log")).string();
constexpr std::size_t max_file_size = 5 * 1024 * 1024;
constexpr std::size_t max_files = 3;
auto rotating_sink = std::make_shared<spdlog::sinks::rotating_file_sink_mt>(logfile, max_file_size, max_files);
auto console_sink = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
rotating_sink->set_level(spdlog::level::debug);
console_sink->set_level(spdlog::level::info);
std::vector<spdlog::sink_ptr> sinks{rotating_sink, console_sink};
auto logger = std::make_shared<spdlog::logger>(app_name, sinks.begin(), sinks.end());
logger->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%^%l%$] [%s:%#] [%!] %v");
spdlog::register_logger(logger);
spdlog::set_default_logger(logger);
}
} // namespace UNO::COMMON

14
src/common/Logger.h Normal file
View File

@@ -0,0 +1,14 @@
/**
* @file Logger.h
*/
#pragma once
#include <string>
namespace UNO::COMMON {
struct Logger {
// Initialize global logging. Safe to call multiple times.
// Default log directory aligns with CMake runtime output: <build>/bin/log
static void init(const std::string &app_name, const std::string &log_dir = "log");
};
} // namespace UNO::COMMON

View File

@@ -7,6 +7,7 @@
#include "GameState.h" #include "GameState.h"
#include <ranges> #include <ranges>
#include <spdlog/spdlog.h>
#include <stdexcept> #include <stdexcept>
#include <utility> #include <utility>
@@ -136,14 +137,17 @@ namespace UNO::GAME {
{ {
this->players_ = players; this->players_ = players;
this->discardPile_ = discardPile; this->discardPile_ = discardPile;
this->drawCount_ = 0;
player_.draw(std::vector<Card>(handCard.begin(), handCard.end())); player_.draw(std::vector<Card>(handCard.begin(), handCard.end()));
this->currentPlayer_ = this->players_.begin() + static_cast<int>(currentPlayerIndex); this->currentPlayer_ = this->players_.begin() + static_cast<int>(currentPlayerIndex);
this->self_ = this->players_.begin() + static_cast<int>(selfIndex); this->self_ = this->players_.begin() + static_cast<int>(selfIndex);
if (this->self_ == this->currentPlayer_) { if (this->self_ == this->currentPlayer_) {
this->clientGameStage_ = ClientGameStage::ACTIVE; this->clientGameStage_ = ClientGameStage::ACTIVE;
SPDLOG_INFO("Client game initialized: {} players, self index: {}, it's our turn", players.size(), selfIndex);
} }
else { else {
this->clientGameStage_ = ClientGameStage::IDLE; this->clientGameStage_ = ClientGameStage::IDLE;
SPDLOG_INFO("Client game initialized: {} players, self index: {}, waiting for turn", players.size(), selfIndex);
} }
} }
@@ -179,6 +183,7 @@ namespace UNO::GAME {
void ClientGameState::endGame() void ClientGameState::endGame()
{ {
SPDLOG_INFO("Client game ended");
this->clientGameStage_ = ClientGameStage::AFTER_GAME; this->clientGameStage_ = ClientGameStage::AFTER_GAME;
this->player_.clear(); this->player_.clear();
} }
@@ -190,19 +195,25 @@ namespace UNO::GAME {
long long currentPlayerIndex = this->currentPlayer_ - this->players_.begin(); long long currentPlayerIndex = this->currentPlayer_ - this->players_.begin();
this->players_.push_back(std::move(playerState)); this->players_.push_back(std::move(playerState));
this->currentPlayer_ = this->players_.begin() + currentPlayerIndex; this->currentPlayer_ = this->players_.begin() + currentPlayerIndex;
SPDLOG_DEBUG("Player '{}' added to game state, total players: {}", playerState.getName(), this->players_.size());
} }
void ServerGameState::init() void ServerGameState::init()
{ {
SPDLOG_INFO("Initializing server game state");
while (discardPile_.isEmpty() || discardPile_.getFront().getType() > CardType::NUM9) { while (discardPile_.isEmpty() || discardPile_.getFront().getType() > CardType::NUM9) {
discardPile_.add(deck_.draw()); discardPile_.add(deck_.draw());
} }
SPDLOG_DEBUG("Initial discard pile card: color={}, type={}",
discardPile_.getFront().colorToString(),
discardPile_.getFront().typeToString());
for (size_t i = 0; i < 7; i++) { for (size_t i = 0; i < 7; i++) {
for (auto &player : this->players_) { for (auto &player : this->players_) {
player.draw(1, this->deck_.draw(1)); player.draw(1, this->deck_.draw(1));
} }
} }
SPDLOG_INFO("Dealt 7 cards to each of {} players", this->players_.size());
this->serverGameStage_ = ServerGameStage::IN_GAME; this->serverGameStage_ = ServerGameStage::IN_GAME;
} }
@@ -212,6 +223,7 @@ namespace UNO::GAME {
if (this->drawCount_ == 0) { if (this->drawCount_ == 0) {
this->drawCount_ = 1; this->drawCount_ = 1;
} }
SPDLOG_DEBUG("Player '{}' drawing {} card(s)", this->currentPlayer_->getName(), this->drawCount_);
auto cards = deck_.draw(this->drawCount_); auto cards = deck_.draw(this->drawCount_);
this->currentPlayer_->draw(this->drawCount_, cards); this->currentPlayer_->draw(this->drawCount_, cards);
this->drawCount_ = 0; this->drawCount_ = 0;
@@ -226,6 +238,7 @@ namespace UNO::GAME {
void ServerGameState::endGame() void ServerGameState::endGame()
{ {
SPDLOG_INFO("Ending server game, returning to pre-game state");
this->serverGameStage_ = ServerGameStage::PRE_GAME; this->serverGameStage_ = ServerGameStage::PRE_GAME;
deck_.clear(); deck_.clear();

View File

@@ -297,6 +297,7 @@ namespace UNO::GAME {
template<PlayerStateTypeConcept PlayerStateType> template<PlayerStateTypeConcept PlayerStateType>
void GameState<PlayerStateType>::endGame() void GameState<PlayerStateType>::endGame()
{ {
drawCount_ = 0;
discardPile_.clear(); discardPile_.clear();
for (auto &player : this->players_) { for (auto &player : this->players_) {
player.clear(); player.clear();

View File

@@ -9,47 +9,63 @@
#include <asio/connect.hpp> #include <asio/connect.hpp>
#include <iostream> #include <iostream>
#include <memory> #include <memory>
#include <spdlog/spdlog.h>
#include <utility> #include <utility>
namespace UNO::NETWORK { namespace UNO::NETWORK {
NetworkClient::NetworkClient(std::function<void()> onConnect, std::function<void(std::string)> callback) : NetworkClient::NetworkClient(std::function<void()> onConnect, std::function<void(std::string)> callback) :
onConnected_(std::move(onConnect)), callback_(std::move(callback)), workGuard_(asio::make_work_guard(io_context_)) onConnected_(std::move(onConnect)), callback_(std::move(callback)), workGuard_(asio::make_work_guard(io_context_))
{ {
SPDLOG_DEBUG("NetworkClient initialized");
} }
void NetworkClient::connect(const std::string &host, uint16_t port) void NetworkClient::connect(const std::string &host, uint16_t port)
{ {
SPDLOG_INFO("Connecting to {}:{}", host, port);
auto socket = std::make_shared<asio::ip::tcp::socket>(io_context_); auto socket = std::make_shared<asio::ip::tcp::socket>(io_context_);
auto resolver = std::make_shared<asio::ip::tcp::resolver>(io_context_); auto resolver = std::make_shared<asio::ip::tcp::resolver>(io_context_);
resolver->async_resolve(host, resolver->async_resolve(
std::to_string(port), host,
[this, resolver, socket](const asio::error_code &ec, const asio::ip::tcp::resolver::results_type &results) { std::to_string(port),
if (!ec) { [this, resolver, socket, host, port](const asio::error_code &ec, const asio::ip::tcp::resolver::results_type &results) {
asio::async_connect( if (!ec) {
*socket, results, [this, socket](const asio::error_code &ec, const asio::ip::tcp::endpoint &) { SPDLOG_DEBUG("Resolved host {}:{}", host, port);
if (!ec) { asio::async_connect(
this->session_ = std::make_shared<Session>(std::move(*socket)); *socket, results, [this, socket, host, port](const asio::error_code &ec, const asio::ip::tcp::endpoint &) {
this->session_->start(callback_); if (!ec) {
this->onConnected_(); SPDLOG_INFO("Successfully connected to {}:{}", host, port);
} this->session_ = std::make_shared<Session>(std::move(*socket));
}); this->session_->start(callback_);
} this->onConnected_();
}); }
else {
SPDLOG_ERROR("Failed to connect to {}:{}: {}", host, port, ec.message());
}
});
}
else {
SPDLOG_ERROR("Failed to resolve host {}:{}: {}", host, port, ec.message());
}
});
} }
void NetworkClient::send(const std::string &message) void NetworkClient::send(const std::string &message)
{ {
SPDLOG_DEBUG("Queueing message to send, size: {} bytes", message.size());
asio::post(io_context_, [session = this->session_, message]() { session->send(message); }); asio::post(io_context_, [session = this->session_, message]() { session->send(message); });
} }
void NetworkClient::run() void NetworkClient::run()
{ {
SPDLOG_INFO("NetworkClient starting io_context loop");
this->io_context_.run(); this->io_context_.run();
SPDLOG_INFO("NetworkClient io_context loop stopped");
} }
void NetworkClient::stop() void NetworkClient::stop()
{ {
SPDLOG_INFO("NetworkClient stopping");
this->workGuard_.reset(); this->workGuard_.reset();
this->io_context_.stop(); this->io_context_.stop();
} }

View File

@@ -6,6 +6,7 @@
*/ */
#include "NetworkServer.h" #include "NetworkServer.h"
#include <spdlog/spdlog.h>
#include <utility> #include <utility>
namespace UNO::NETWORK { namespace UNO::NETWORK {
@@ -13,15 +14,20 @@ namespace UNO::NETWORK {
{ {
this->acceptor_.async_accept([this](const asio::error_code &ec, asio::ip::tcp::socket socket) { this->acceptor_.async_accept([this](const asio::error_code &ec, asio::ip::tcp::socket socket) {
if (!ec) { if (!ec) {
SPDLOG_INFO("Accepted new connection from {}", socket.remote_endpoint().address().to_string());
this->addPlayer(std::move(socket)); this->addPlayer(std::move(socket));
accept(); accept();
} }
else {
SPDLOG_ERROR("Accept error: {}", ec.message());
}
}); });
} }
NetworkServer::NetworkServer(uint16_t port, std::function<void(size_t, std::string)> callback) : NetworkServer::NetworkServer(uint16_t port, std::function<void(size_t, std::string)> callback) :
acceptor_(io_context_, asio::ip::tcp::endpoint(asio::ip::tcp::v4(), port)), playerCount(0), callback_(std::move(callback)) acceptor_(io_context_, asio::ip::tcp::endpoint(asio::ip::tcp::v4(), port)), playerCount(0), callback_(std::move(callback))
{ {
SPDLOG_INFO("NetworkServer initialized on port {}", port);
accept(); accept();
} }
@@ -32,24 +38,30 @@ namespace UNO::NETWORK {
this->sessions_[playerId] = std::make_shared<Session>(std::move(socket)); this->sessions_[playerId] = std::make_shared<Session>(std::move(socket));
this->sessions_[playerId]->start([this, playerId](std::string message) { this->callback_(playerId, std::move(message)); }); this->sessions_[playerId]->start([this, playerId](std::string message) { this->callback_(playerId, std::move(message)); });
this->playerCount++; this->playerCount++;
SPDLOG_INFO("Player {} added, total players: {}", playerId, this->playerCount);
} }
void NetworkServer::send(size_t id, const std::string &message) void NetworkServer::send(size_t id, const std::string &message)
{ {
std::lock_guard<std::mutex> lock(this->mutex_); std::lock_guard<std::mutex> lock(this->mutex_);
if (this->sessions_.contains(id) == false) { if (this->sessions_.contains(id) == false) {
SPDLOG_ERROR("Failed to send message: Player session {} not found", id);
throw std::invalid_argument("Player session not found"); throw std::invalid_argument("Player session not found");
} }
SPDLOG_DEBUG("Sending message to player {}, size: {} bytes", id, message.size());
this->sessions_[id]->send(message); this->sessions_[id]->send(message);
} }
void NetworkServer::run() void NetworkServer::run()
{ {
SPDLOG_INFO("NetworkServer starting io_context loop");
this->io_context_.run(); this->io_context_.run();
SPDLOG_INFO("NetworkServer io_context loop stopped");
} }
void NetworkServer::stop() void NetworkServer::stop()
{ {
SPDLOG_INFO("NetworkServer stopping");
this->acceptor_.close(); this->acceptor_.close();
this->io_context_.stop(); this->io_context_.stop();
} }

View File

@@ -6,11 +6,17 @@
*/ */
#include "Session.h" #include "Session.h"
#include <spdlog/spdlog.h>
namespace UNO::NETWORK { namespace UNO::NETWORK {
Session::Session(asio::ip::tcp::socket socket) : socket_(std::move(socket)) {} Session::Session(asio::ip::tcp::socket socket) : socket_(std::move(socket))
{
SPDLOG_DEBUG("Session created");
}
void Session::start(std::function<void(std::string)> callback) void Session::start(std::function<void(std::string)> callback)
{ {
SPDLOG_DEBUG("Session started");
this->callback_ = std::move(callback); this->callback_ = std::move(callback);
this->doRead(); this->doRead();
} }
@@ -19,6 +25,7 @@ namespace UNO::NETWORK {
{ {
bool writeInProgress = !this->messages_.empty(); bool writeInProgress = !this->messages_.empty();
messages_.push(message); messages_.push(message);
SPDLOG_DEBUG("Message queued for sending, size: {} bytes, queue size: {}", message.size(), messages_.size());
if (writeInProgress == false) { if (writeInProgress == false) {
this->doWrite(); this->doWrite();
} }
@@ -31,13 +38,18 @@ namespace UNO::NETWORK {
asio::buffer(messageLength.get(), sizeof(size_t)), asio::buffer(messageLength.get(), sizeof(size_t)),
[this, self = shared_from_this(), messageLength](const asio::error_code &ec, size_t length) { [this, self = shared_from_this(), messageLength](const asio::error_code &ec, size_t length) {
if (!ec) { if (!ec) {
SPDLOG_TRACE("Read message header, length: {} bytes", *messageLength);
if (*messageLength <= 10 * 1024 * 1024) { if (*messageLength <= 10 * 1024 * 1024) {
this->doReadBody(*messageLength); this->doReadBody(*messageLength);
} }
else { else {
SPDLOG_WARN("Message length {} exceeds limit, skipping", *messageLength);
doRead(); doRead();
} }
} }
else {
SPDLOG_DEBUG("Session read error: {}", ec.message());
}
}); });
} }
@@ -45,12 +57,16 @@ namespace UNO::NETWORK {
{ {
auto buffer = std::make_shared<std::vector<char>>(length); auto buffer = std::make_shared<std::vector<char>>(length);
asio::async_read( asio::async_read(
socket_, asio::buffer(*buffer), [this, self = shared_from_this(), buffer](const asio::error_code &ec, size_t length) { socket_, asio::buffer(*buffer), [this, self = shared_from_this(), buffer, length](const asio::error_code &ec, size_t) {
if (!ec) { if (!ec) {
std::string message = {buffer->begin(), buffer->end()}; std::string message = {buffer->begin(), buffer->end()};
SPDLOG_TRACE("Received message body, size: {} bytes", length);
this->callback_(message); this->callback_(message);
doRead(); doRead();
} }
else {
SPDLOG_DEBUG("Session read body error: {}", ec.message());
}
}); });
} }
@@ -62,10 +78,17 @@ namespace UNO::NETWORK {
auto length = std::make_shared<size_t>(message.size()); auto length = std::make_shared<size_t>(message.size());
auto msg = std::make_shared<std::string>(message); auto msg = std::make_shared<std::string>(message);
SPDLOG_TRACE("Sending message, size: {} bytes", *length);
std::array<asio::const_buffer, 2> buffers = {asio::buffer(length.get(), sizeof(size_t)), asio::buffer(*msg)}; std::array<asio::const_buffer, 2> buffers = {asio::buffer(length.get(), sizeof(size_t)), asio::buffer(*msg)};
asio::async_write(socket_, buffers, [this, self = shared_from_this(), length, msg](const asio::error_code &ec, size_t) { asio::async_write(socket_, buffers, [this, self = shared_from_this(), length, msg](const asio::error_code &ec, size_t) {
if (!ec && this->messages_.empty() == false) { if (!ec) {
this->doWrite(); SPDLOG_TRACE("Message sent successfully, size: {} bytes", *length);
if (this->messages_.empty() == false) {
this->doWrite();
}
}
else {
SPDLOG_ERROR("Session write error: {}", ec.message());
} }
}); });
} }

View File

@@ -7,12 +7,14 @@
#include "UnoServer.h" #include "UnoServer.h"
#include "../network/MessageSerializer.h" #include "../network/MessageSerializer.h"
#include <spdlog/spdlog.h>
namespace UNO::SERVER { namespace UNO::SERVER {
UnoServer::UnoServer(uint16_t port) : UnoServer::UnoServer(uint16_t port) :
networkServer_(port, [this](size_t playerId, const std::string &message) { this->handlePlayerMessage(playerId, message); }), networkServer_(port, [this](size_t playerId, const std::string &message) { this->handlePlayerMessage(playerId, message); }),
playerCount(0) playerCount(0)
{ {
SPDLOG_INFO("UnoServer initialized on port {}", port);
} }
void UnoServer::handlePlayerMessage(size_t playerId, const std::string &message) void UnoServer::handlePlayerMessage(size_t playerId, const std::string &message)
@@ -20,6 +22,8 @@ namespace UNO::SERVER {
auto playerMessage = NETWORK::MessageSerializer::deserialize(message); auto playerMessage = NETWORK::MessageSerializer::deserialize(message);
if (playerMessage.getMessageStatus() == NETWORK::MessageStatus::OK) { if (playerMessage.getMessageStatus() == NETWORK::MessageStatus::OK) {
SPDLOG_DEBUG("Processing message from player {}, type: {}", playerId, static_cast<int>(playerMessage.getMessagePayloadType()));
if (playerMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::JOIN_GAME) { if (playerMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::JOIN_GAME) {
auto playerName = std::get<NETWORK::JoinGamePayload>(playerMessage.getMessagePayload()).playerName; auto playerName = std::get<NETWORK::JoinGamePayload>(playerMessage.getMessagePayload()).playerName;
@@ -27,12 +31,15 @@ namespace UNO::SERVER {
this->gameIdToNetworkId[this->playerCount] = playerId; this->gameIdToNetworkId[this->playerCount] = playerId;
this->playerCount++; this->playerCount++;
this->serverGameState_.addPlayer(GAME::ServerPlayerState{playerName, 0, false}); this->serverGameState_.addPlayer(GAME::ServerPlayerState{playerName, 0, false});
SPDLOG_INFO("Player {} joined with name '{}', game ID: {}", playerId, playerName, this->playerCount - 1);
} }
if (playerMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::START_GAME) { else if (playerMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::START_GAME) {
this->isReadyToStart[networkIdToGameId[playerId]] = true; this->isReadyToStart[networkIdToGameId[playerId]] = true;
SPDLOG_INFO("Player {} (game ID: {}) is ready to start", playerId, networkIdToGameId[playerId]);
for (size_t i = 0; i <= this->playerCount; i++) { for (size_t i = 0; i <= this->playerCount; i++) {
if (i == this->playerCount) { if (i == this->playerCount) {
SPDLOG_INFO("All {} players ready, starting game", this->playerCount);
this->handleStartGame(); this->handleStartGame();
break; break;
} }
@@ -41,25 +48,41 @@ namespace UNO::SERVER {
} }
} }
} }
if (playerMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::INIT_GAME else if (playerMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::INIT_GAME
|| playerMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::END_GAME) { || playerMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::END_GAME) {
SPDLOG_ERROR(
"Invalid message payload type from client {}: {}", playerId, static_cast<int>(playerMessage.getMessagePayloadType()));
throw std::invalid_argument("Invalid message payload type from client"); throw std::invalid_argument("Invalid message payload type from client");
} }
if (this->serverGameState_.getServerGameStage() == GAME::ServerGameStage::IN_GAME else if (this->serverGameState_.getServerGameStage() == GAME::ServerGameStage::IN_GAME
&& this->networkIdToGameId.at(playerId) != this->serverGameState_.getCurrentPlayerId()) { && this->networkIdToGameId.at(playerId) != this->serverGameState_.getCurrentPlayerId()) {
SPDLOG_WARN("Player {} sent message but it's not their turn (current: {})",
this->networkIdToGameId.at(playerId),
this->serverGameState_.getCurrentPlayerId());
throw std::invalid_argument("Invalid player message: not this player's turn"); throw std::invalid_argument("Invalid player message: not this player's turn");
} }
if (playerMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::DRAW_CARD) { else if (playerMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::DRAW_CARD) {
SPDLOG_INFO("Player {} (game ID: {}) draws card", playerId, this->networkIdToGameId.at(playerId));
this->handleDrawCard(playerId); this->handleDrawCard(playerId);
} }
if (playerMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::PLAY_CARD) { else if (playerMessage.getMessagePayloadType() == NETWORK::MessagePayloadType::PLAY_CARD) {
this->handlePlayCard(playerId, std::get<NETWORK::PlayCardPayload>(playerMessage.getMessagePayload()).card); auto card = std::get<NETWORK::PlayCardPayload>(playerMessage.getMessagePayload()).card;
SPDLOG_INFO("Player {} (game ID: {}) plays card: color={}, type={}",
playerId,
this->networkIdToGameId.at(playerId),
card.colorToString(),
card.typeToString());
this->handlePlayCard(playerId, card);
} }
} }
else {
SPDLOG_ERROR("Received message with error status from player {}", playerId);
}
} }
void UnoServer::handleStartGame() void UnoServer::handleStartGame()
{ {
SPDLOG_INFO("Initializing game state");
serverGameState_.init(); serverGameState_.init();
std::vector<GAME::ClientPlayerState> players; std::vector<GAME::ClientPlayerState> players;
players.reserve(serverGameState_.getPlayers().size()); players.reserve(serverGameState_.getPlayers().size());
@@ -67,18 +90,23 @@ namespace UNO::SERVER {
players.emplace_back(player.getName(), player.getRemainingCardCount(), player.getIsUno()); players.emplace_back(player.getName(), player.getRemainingCardCount(), player.getIsUno());
} }
size_t currentPlayerIndex = serverGameState_.getCurrentPlayerId(); size_t currentPlayerIndex = serverGameState_.getCurrentPlayerId();
SPDLOG_INFO("Game started, current player index: {}", currentPlayerIndex);
for (size_t i = 0; i < playerCount; i++) { for (size_t i = 0; i < playerCount; i++) {
NETWORK::InitGamePayload payload = { NETWORK::InitGamePayload payload = {
i, players, serverGameState_.getDiscardPile(), serverGameState_.getPlayers()[i].getCards(), currentPlayerIndex}; i, players, serverGameState_.getDiscardPile(), serverGameState_.getPlayers()[i].getCards(), currentPlayerIndex};
this->networkServer_.send( this->networkServer_.send(
gameIdToNetworkId.at(i), gameIdToNetworkId.at(i),
NETWORK::MessageSerializer::serialize({NETWORK::MessageStatus::OK, NETWORK::MessagePayloadType::INIT_GAME, payload})); NETWORK::MessageSerializer::serialize({NETWORK::MessageStatus::OK, NETWORK::MessagePayloadType::INIT_GAME, payload}));
SPDLOG_DEBUG("Sent INIT_GAME to player {}", i);
} }
} }
void UnoServer::handleDrawCard(size_t playerId) void UnoServer::handleDrawCard(size_t playerId)
{ {
auto cards = this->serverGameState_.updateStateByDraw(); auto cards = this->serverGameState_.updateStateByDraw();
SPDLOG_INFO("Player {} drew {} card(s)", this->networkIdToGameId.at(playerId), cards.size());
for (size_t i = 0; i < playerCount; i++) { for (size_t i = 0; i < playerCount; i++) {
auto networkId = gameIdToNetworkId.at(i); auto networkId = gameIdToNetworkId.at(i);
NETWORK::DrawCardPayload payload; NETWORK::DrawCardPayload payload;
@@ -103,6 +131,7 @@ namespace UNO::SERVER {
for (const auto &player : this->serverGameState_.getPlayers()) { for (const auto &player : this->serverGameState_.getPlayers()) {
if (player.isEmpty()) { if (player.isEmpty()) {
gameEnded = true; gameEnded = true;
SPDLOG_INFO("Player '{}' wins the game!", player.getName());
break; break;
} }
} }
@@ -120,6 +149,7 @@ namespace UNO::SERVER {
void UnoServer::handleEndGame() void UnoServer::handleEndGame()
{ {
SPDLOG_INFO("Game ended, resetting to pre-game state");
this->serverGameState_.endGame(); this->serverGameState_.endGame();
NETWORK::EndGamePayload payload{}; NETWORK::EndGamePayload payload{};
@@ -136,6 +166,7 @@ namespace UNO::SERVER {
void UnoServer::run() void UnoServer::run()
{ {
SPDLOG_INFO("UnoServer starting");
this->networkServer_.run(); this->networkServer_.run();
} }

View File

@@ -4,11 +4,16 @@
* @author Yuzhe Guo * @author Yuzhe Guo
* @date 2025.12.01 * @date 2025.12.01
*/ */
#include "../common/Logger.h"
#include <argparse/argparse.hpp> #include <argparse/argparse.hpp>
#include <spdlog/spdlog.h>
#include "UnoServer.h" #include "UnoServer.h"
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
UNO::COMMON::Logger::init("uno-server");
SPDLOG_INFO("Starting uno-server");
argparse::ArgumentParser parser("Uno Server", "0.1.0"); argparse::ArgumentParser parser("Uno Server", "0.1.0");
parser.add_argument("-p", "--port").help("server port").default_value(static_cast<uint16_t>(10001)).scan<'i', uint16_t>(); parser.add_argument("-p", "--port").help("server port").default_value(static_cast<uint16_t>(10001)).scan<'i', uint16_t>();
@@ -17,17 +22,18 @@ int main(int argc, char *argv[])
parser.parse_args(argc, argv); parser.parse_args(argc, argv);
} }
catch (const std::exception &e) { catch (const std::exception &e) {
std::cerr << e.what() << std::endl; SPDLOG_ERROR("Argument parsing failed: {}", e.what());
std::cerr << parser;
return 1; return 1;
} }
try { try {
UNO::SERVER::UnoServer uno_server(parser.get<uint16_t>("--port")); auto port = parser.get<uint16_t>("--port");
SPDLOG_INFO("Launching server on port {}", port);
UNO::SERVER::UnoServer uno_server(port);
uno_server.run(); uno_server.run();
} }
catch (const std::exception &e) { catch (const std::exception &e) {
std::cerr << e.what() << std::endl; SPDLOG_ERROR("Server crashed with exception: {}", e.what());
return 1; return 1;
} }

View File

@@ -49,7 +49,7 @@ namespace UNO::UI {
for (auto color : GAME::AllColors) { for (auto color : GAME::AllColors) {
for (auto type : GAME::AllTypes) { for (auto type : GAME::AllTypes) {
this->images_[{color, type}] = this->images_[{color, type}] =
slint::Image::load_from_path(std::format("../assets/cards/{}.svg", GAME::Card{color, type}.toString()).data()); slint::Image::load_from_path(std::format("assets/cards/{}.svg", GAME::Card{color, type}.toString()).data());
} }
} }
} }

View File

@@ -1,17 +1,19 @@
import { LineEdit } from "std-widgets.slint"; import { LineEdit } from "std-widgets.slint";
export component Button inherits Rectangle { export component Button inherits Rectangle {
in property <float> scale;
in property <string> text; in property <string> text;
in property <bool> enabled: true; in property <bool> enabled: true;
callback clicked; callback clicked;
width: 100px; width: 100px * scale;
height: 36px; height: 36px * scale;
background: !root.enabled ? #555555 // 禁用状态 background: !root.enabled ? #555555 // 禁用状态
: touch.pressed ? #444444 // 按下 : touch.pressed ? #444444 // 按下
: #2C2C2C; // 默认 : #2C2C2C; // 默认
border-radius: 6px; border-radius: 6px * scale;
touch := TouchArea { touch := TouchArea {
enabled: root.enabled; enabled: root.enabled;
@@ -23,41 +25,43 @@ export component Button inherits Rectangle {
Text { Text {
text: root.text; text: root.text;
color: #FFFFFF; color: #FFFFFF;
font-size: 14px; font-size: 14px * scale;
vertical-alignment: center; vertical-alignment: center;
horizontal-alignment: center; horizontal-alignment: center;
} }
} }
export component LabeledInput inherits VerticalLayout { export component LabeledInput inherits VerticalLayout {
in property <float> scale;
in property <InputType> input-type: InputType.text; in property <InputType> input-type: InputType.text;
in property <string> label-text; in property <string> label-text;
in-out property <string> value <=> input.text; in-out property <string> value <=> input.text;
in property <string> placeholder; in property <string> placeholder;
spacing: 6px; spacing: 6px * scale;
// 标签文本 // 标签文本
Text { Text {
text: root.label-text; text: root.label-text;
font-size: 12px; font-size: 12px * scale;
font-weight: 500; font-weight: 500;
color: #555555; color: #555555;
} }
// 输入框背景和实体 // 输入框背景和实体
Rectangle { Rectangle {
height: 36px; height: 36px * scale;
background: #FFFFFF; background: #FFFFFF;
border-radius: 6px; border-radius: 6px * scale;
border-width: 1px; border-width: 1px * scale;
border-color: input.has-focus ? #888888 : #E0E0E0; // 聚焦时边框变深 border-color: input.has-focus ? #888888 : #E0E0E0; // 聚焦时边框变深
input := LineEdit { input := LineEdit {
width: 100%; width: 100%;
height: 100%; height: 100%;
font-size: 14px; font-size: 14px * scale;
placeholder-text: root.placeholder; placeholder-text: root.placeholder;
input-type: root.input-type; input-type: root.input-type;
} }

View File

@@ -4,13 +4,15 @@ import {
} from "std-widgets.slint"; } from "std-widgets.slint";
import { Button, LabeledInput } from "Components.slint"; import { Button, LabeledInput } from "Components.slint";
export component ConnectPage inherits Window { export component ConnectPage inherits Rectangle {
in property <float> scale;
in property <bool> is-connecting; in property <bool> is-connecting;
callback request-connect(string, string, string); callback request-connect(string, string, string);
width: 1920px; width: 100%;
height: 1080px; height: 100%;
// 背景渐变 (从左上角的米色到右下角的淡粉色) // 背景渐变 (从左上角的米色到右下角的淡粉色)
Rectangle { Rectangle {
@@ -19,29 +21,29 @@ export component ConnectPage inherits Window {
// 中心卡片 // 中心卡片
Rectangle { Rectangle {
width: 600px; width: 600px * scale;
height: 600px; // 根据内容高度调整 height: 600px * scale; // 根据内容高度调整
background: #FDFBF8; // 略微区别于背景的米白色 background: #FDFBF8; // 略微区别于背景的米白色
border-radius: 20px; border-radius: 20px * scale;
// 简单的阴影模拟 // 简单的阴影模拟
drop-shadow-blur: 20px; drop-shadow-blur: 20px * scale;
drop-shadow-color: #00000015; drop-shadow-color: #00000015;
drop-shadow-offset-y: 4px; drop-shadow-offset-y: 4px * scale;
VerticalLayout { VerticalLayout {
padding: 40px; padding: 40px * scale;
spacing: 20px; spacing: 20px * scale;
alignment: center; alignment: center;
// 标题区域 // 标题区域
VerticalLayout { VerticalLayout {
spacing: 8px; spacing: 8px * scale;
alignment: center; alignment: center;
Text { Text {
text: "UNO"; text: "UNO";
font-size: 96px; font-size: 96px * scale;
font-weight: 700; font-weight: 700;
color: #222222; color: #222222;
horizontal-alignment: center; horizontal-alignment: center;
@@ -49,7 +51,7 @@ export component ConnectPage inherits Window {
Text { Text {
text: "连接至服务器以开始游戏"; text: "连接至服务器以开始游戏";
font-size: 14px; font-size: 14px * scale;
color: #666666; color: #666666;
horizontal-alignment: center; horizontal-alignment: center;
} }
@@ -57,32 +59,36 @@ export component ConnectPage inherits Window {
// 占位间隔 // 占位间隔
Rectangle { Rectangle {
height: 10px; height: 10px * scale;
} }
// 表单区域 // 表单区域
address-input := LabeledInput { address-input := LabeledInput {
scale: root.scale;
label-text: "服务器地址"; label-text: "服务器地址";
value: "localhost"; value: "localhost";
} }
port-input := LabeledInput { port-input := LabeledInput {
scale: root.scale;
label-text: "服务器端口"; label-text: "服务器端口";
value: "10001"; value: "10001";
input-type: InputType.number; input-type: InputType.number;
} }
name-input := LabeledInput { name-input := LabeledInput {
scale: root.scale;
label-text: "玩家昵称"; label-text: "玩家昵称";
value: "Player"; value: "Player";
} }
// 底部按钮区域 (增加一点顶部间距) // 底部按钮区域 (增加一点顶部间距)
HorizontalLayout { HorizontalLayout {
padding-top: 10px; padding-top: 10px * scale;
alignment: center; alignment: center;
Button { Button {
scale: root.scale;
text: is-connecting ? "正在连接..." : "连接"; text: is-connecting ? "正在连接..." : "连接";
enabled: !is-connecting; enabled: !is-connecting;
clicked => { clicked => {

View File

@@ -34,28 +34,30 @@ export struct HandCard {
// 玩家头像组件 // 玩家头像组件
component PlayerAvatar inherits Rectangle { component PlayerAvatar inherits Rectangle {
in property <float> scale;
in property <string> player-name; in property <string> player-name;
in property <int> card-count; in property <int> card-count;
in property <bool> has-uno; in property <bool> has-uno;
in property <bool> is-current-turn; in property <bool> is-current-turn;
width: 100px; width: 100px * scale;
height: 130px; height: 130px * scale;
background: transparent; background: transparent;
VerticalLayout { VerticalLayout {
spacing: 4px; spacing: 4px * scale;
alignment: center; alignment: center;
// UNO 标志占位区域 (固定高度) // UNO 标志占位区域 (固定高度)
HorizontalLayout { HorizontalLayout {
alignment: center; alignment: center;
Rectangle { Rectangle {
width: 50px; width: 50px * scale;
height: 20px; height: 20px * scale;
background: has-uno ? #FF5722 : transparent; background: has-uno ? #FF5722 : transparent;
border-radius: 4px; border-radius: 4px * scale;
Text { Text {
text: "UNO!"; text: "UNO!";
font-size: 11px; font-size: 11px * scale;
font-weight: 700; font-weight: 700;
color: has-uno ? #FFFFFF : transparent; color: has-uno ? #FFFFFF : transparent;
horizontal-alignment: center; horizontal-alignment: center;
@@ -68,15 +70,15 @@ component PlayerAvatar inherits Rectangle {
HorizontalLayout { HorizontalLayout {
alignment: center; alignment: center;
Rectangle { Rectangle {
width: 60px; width: 60px * scale;
height: 60px; height: 60px * scale;
background: is-current-turn ? #FFD54F : #E0E0E0; background: is-current-turn ? #FFD54F : #E0E0E0;
border-radius: 30px; border-radius: 30px * scale;
border-width: is-current-turn ? 3px : 0px; border-width: is-current-turn ? 3px * scale : 0px;
border-color: #FF9800; border-color: #FF9800;
Text { Text {
text: "👤"; text: "👤";
font-size: 28px; font-size: 28px * scale;
horizontal-alignment: center; horizontal-alignment: center;
vertical-alignment: center; vertical-alignment: center;
} }
@@ -86,7 +88,7 @@ component PlayerAvatar inherits Rectangle {
// 玩家名称 // 玩家名称
Text { Text {
text: player-name; text: player-name;
font-size: 12px; font-size: 12px * scale;
font-weight: 500; font-weight: 500;
color: #333333; color: #333333;
horizontal-alignment: center; horizontal-alignment: center;
@@ -95,7 +97,7 @@ component PlayerAvatar inherits Rectangle {
// 剩余手牌数 // 剩余手牌数
Text { Text {
text: "剩余: " + card-count + " 张"; text: "剩余: " + card-count + " 张";
font-size: 10px; font-size: 10px * scale;
color: #666666; color: #666666;
horizontal-alignment: center; horizontal-alignment: center;
} }
@@ -104,22 +106,24 @@ component PlayerAvatar inherits Rectangle {
// 卡牌背面组件 (起牌堆) // 卡牌背面组件 (起牌堆)
component CardBack inherits Rectangle { component CardBack inherits Rectangle {
in property <float> scale;
in property <int> card-width: 100; in property <int> card-width: 100;
in property <int> card-height: 150; in property <int> card-height: 150;
width: card-width * 1px; width: card-width * 1px * scale;
height: card-height * 1px; height: card-height * 1px * scale;
background: #2C2C2C; background: #2C2C2C;
border-radius: 10px; border-radius: 10px * scale;
border-width: 2px; border-width: 2px * scale;
border-color: #444444; border-color: #444444;
Rectangle { Rectangle {
width: parent.width * 0.75; width: parent.width * 0.75;
height: parent.height * 0.83; height: parent.height * 0.83;
background: #1a1a1a; background: #1a1a1a;
border-radius: 8px; border-radius: 8px * scale;
Text { Text {
text: "UNO"; text: "UNO";
font-size: 20px; font-size: 20px * scale;
font-weight: 700; font-weight: 700;
color: #888888; color: #888888;
horizontal-alignment: center; horizontal-alignment: center;
@@ -130,16 +134,18 @@ component CardBack inherits Rectangle {
// 弃牌堆卡牌组件 // 弃牌堆卡牌组件
component DiscardCard inherits Rectangle { component DiscardCard inherits Rectangle {
in property <float> scale;
in property <image> card-image; in property <image> card-image;
in property <int> card-width: 100; in property <int> card-width: 100;
in property <int> card-height: 150; in property <int> card-height: 150;
width: card-width * 1px; width: card-width * 1px * scale;
height: card-height * 1px; height: card-height * 1px * scale;
background: transparent; background: transparent;
border-radius: 10px; border-radius: 10px * scale;
drop-shadow-blur: 12px; drop-shadow-blur: 12px * scale;
drop-shadow-color: #00000040; drop-shadow-color: #00000040;
drop-shadow-offset-y: 4px; drop-shadow-offset-y: 4px * scale;
Image { Image {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -150,6 +156,8 @@ component DiscardCard inherits Rectangle {
// 方向环背景组件 - 只绘制环线 // 方向环背景组件 - 只绘制环线
component DirectionRingBackground inherits Rectangle { component DirectionRingBackground inherits Rectangle {
in property <float> scale;
in property <CardColor> current-color: CardColor.Red; in property <CardColor> current-color: CardColor.Red;
in property <int> ring-width: 500; in property <int> ring-width: 500;
in property <int> ring-height: 280; in property <int> ring-height: 280;
@@ -157,8 +165,8 @@ component DirectionRingBackground inherits Rectangle {
property <color> ring-color-dim: current-color == CardColor.Red ? #E57373 : current-color == CardColor.Blue ? #64B5F6 : current-color == CardColor.Green ? #81C784 : current-color == CardColor.Yellow ? #FFF176 : #BDBDBD; property <color> ring-color-dim: current-color == CardColor.Red ? #E57373 : current-color == CardColor.Blue ? #64B5F6 : current-color == CardColor.Green ? #81C784 : current-color == CardColor.Yellow ? #FFF176 : #BDBDBD;
property <float> a: ring-width / 2; property <float> a: ring-width / 2;
property <float> b: ring-height / 2; property <float> b: ring-height / 2;
width: ring-width * 1px; width: ring-width * 1px * scale;
height: ring-height * 1px; height: ring-height * 1px * scale;
background: transparent; background: transparent;
// 底层光晕环 // 底层光晕环
@@ -166,28 +174,28 @@ component DirectionRingBackground inherits Rectangle {
width: 100%; width: 100%;
height: 100%; height: 100%;
stroke: ring-color-dim; stroke: ring-color-dim;
stroke-width: 8px; stroke-width: 8px * scale;
fill: transparent; fill: transparent;
opacity: 0.3; opacity: 0.3;
MoveTo { MoveTo {
x: ring-width / 2 + a; x: (ring-width / 2 + a) * scale;
y: ring-height / 2; y: (ring-height / 2) * scale;
} }
ArcTo { ArcTo {
x: ring-width / 2 - a; x: (ring-width / 2 - a) * scale;
y: ring-height / 2; y: (ring-height / 2) * scale;
radius-x: a; radius-x: a * scale;
radius-y: b; radius-y: b * scale;
sweep: true; sweep: true;
large-arc: true; large-arc: true;
} }
ArcTo { ArcTo {
x: ring-width / 2 + a; x: (ring-width / 2 + a) * scale;
y: ring-height / 2; y: (ring-height / 2) * scale;
radius-x: a; radius-x: a * scale;
radius-y: b; radius-y: b * scale;
sweep: true; sweep: true;
large-arc: true; large-arc: true;
} }
@@ -198,28 +206,28 @@ component DirectionRingBackground inherits Rectangle {
width: 100%; width: 100%;
height: 100%; height: 100%;
stroke: ring-color; stroke: ring-color;
stroke-width: 3px; stroke-width: 3px * scale;
fill: transparent; fill: transparent;
opacity: 0.9; opacity: 0.9;
MoveTo { MoveTo {
x: ring-width / 2 + a; x: (ring-width / 2 + a) * scale;
y: ring-height / 2; y: (ring-height / 2) * scale;
} }
ArcTo { ArcTo {
x: ring-width / 2 - a; x: (ring-width / 2 - a) * scale;
y: ring-height / 2; y: (ring-height / 2) * scale;
radius-x: a; radius-x: a * scale;
radius-y: b; radius-y: b * scale;
sweep: true; sweep: true;
large-arc: true; large-arc: true;
} }
ArcTo { ArcTo {
x: ring-width / 2 + a; x: (ring-width / 2 + a) * scale;
y: ring-height / 2; y: (ring-height / 2) * scale;
radius-x: a; radius-x: a * scale;
radius-y: b; radius-y: b * scale;
sweep: true; sweep: true;
large-arc: true; large-arc: true;
} }
@@ -228,22 +236,21 @@ component DirectionRingBackground inherits Rectangle {
// 方向环光点组件 - 使用内置动画实现平滑移动 // 方向环光点组件 - 使用内置动画实现平滑移动
component DirectionRingOrbs inherits Rectangle { component DirectionRingOrbs inherits Rectangle {
in property <float> scale;
in property <GameDirection> direction: GameDirection.Clockwise; in property <GameDirection> direction: GameDirection.Clockwise;
in property <CardColor> current-color: CardColor.Red; in property <CardColor> current-color: CardColor.Red;
in property <int> ring-width: 500; in property <int> ring-width: 500;
in property <int> ring-height: 280; in property <int> ring-height: 280;
// 单一的动画计数器,持续递增
in-out property <int> cycle: 0;
property <color> ring-color: current-color == CardColor.Red ? #F44336 : current-color == CardColor.Blue ? #2196F3 : current-color == CardColor.Green ? #4CAF50 : current-color == CardColor.Yellow ? #FFEB3B : #9E9E9E; property <color> ring-color: current-color == CardColor.Red ? #F44336 : current-color == CardColor.Blue ? #2196F3 : current-color == CardColor.Green ? #4CAF50 : current-color == CardColor.Yellow ? #FFEB3B : #9E9E9E;
property <float> a: ring-width / 2; property <float> a: ring-width / 2;
property <float> b: ring-height / 2; property <float> b: ring-height / 2;
// 基础角度(持续增加) property <float> base-angle: 0;
property <float> base-angle: cycle * 360;
animate base-angle { animate base-angle {
duration: 8s; duration: 8s;
easing: linear; easing: linear;
iteration-count: -1;
} }
// 方向系数顺时针为1逆时针为-1 // 方向系数顺时针为1逆时针为-1
@@ -251,86 +258,79 @@ component DirectionRingOrbs inherits Rectangle {
// 实际角度 = 基础角度 * 方向系数 // 实际角度 = 基础角度 * 方向系数
property <float> actual-angle: base-angle * dir-sign; property <float> actual-angle: base-angle * dir-sign;
width: ring-width * 1px; width: ring-width * 1px * scale;
height: ring-height * 1px; height: ring-height * 1px * scale;
background: transparent; background: transparent;
// 光点1 // 光点1
Rectangle { Rectangle {
x: parent.width / 2 - 9px + cos(actual-angle * 1deg) * a * 1px; x: (parent.width / 2 - 9px * scale) + cos(actual-angle * 1deg) * a * 1px * scale;
y: parent.height / 2 - 9px + sin(actual-angle * 1deg) * b * 1px; y: (parent.height / 2 - 9px * scale) + sin(actual-angle * 1deg) * b * 1px * scale;
width: 18px; width: 18px * scale;
height: 18px; height: 18px * scale;
background: ring-color; background: ring-color;
border-radius: 9px; border-radius: 9px * scale;
drop-shadow-blur: 8px; drop-shadow-blur: 8px * scale;
drop-shadow-color: ring-color; drop-shadow-color: ring-color;
} }
// 光点2 // 光点2
Rectangle { Rectangle {
x: parent.width / 2 - 9px + cos((actual-angle + 90) * 1deg) * a * 1px; x: (parent.width / 2 - 9px * scale) + cos((actual-angle + 90) * 1deg) * a * 1px * scale;
y: parent.height / 2 - 9px + sin((actual-angle + 90) * 1deg) * b * 1px; y: (parent.height / 2 - 9px * scale) + sin((actual-angle + 90) * 1deg) * b * 1px * scale;
width: 18px; width: 18px * scale;
height: 18px; height: 18px * scale;
background: ring-color; background: ring-color;
border-radius: 9px; border-radius: 9px * scale;
drop-shadow-blur: 8px; drop-shadow-blur: 8px * scale;
drop-shadow-color: ring-color; drop-shadow-color: ring-color;
} }
// 光点3 // 光点3
Rectangle { Rectangle {
x: parent.width / 2 - 9px + cos((actual-angle + 180) * 1deg) * a * 1px; x: (parent.width / 2 - 9px * scale) + cos((actual-angle + 180) * 1deg) * a * 1px * scale;
y: parent.height / 2 - 9px + sin((actual-angle + 180) * 1deg) * b * 1px; y: (parent.height / 2 - 9px * scale) + sin((actual-angle + 180) * 1deg) * b * 1px * scale;
width: 18px; width: 18px * scale;
height: 18px; height: 18px * scale;
background: ring-color; background: ring-color;
border-radius: 9px; border-radius: 9px * scale;
drop-shadow-blur: 8px; drop-shadow-blur: 8px * scale;
drop-shadow-color: ring-color; drop-shadow-color: ring-color;
} }
// 光点4 // 光点4
Rectangle { Rectangle {
x: parent.width / 2 - 9px + cos((actual-angle + 270) * 1deg) * a * 1px; x: (parent.width / 2 - 9px * scale) + cos((actual-angle + 270) * 1deg) * a * 1px * scale;
y: parent.height / 2 - 9px + sin((actual-angle + 270) * 1deg) * b * 1px; y: (parent.height / 2 - 9px * scale) + sin((actual-angle + 270) * 1deg) * b * 1px * scale;
width: 18px; width: 18px * scale;
height: 18px; height: 18px * scale;
background: ring-color; background: ring-color;
border-radius: 9px; border-radius: 9px * scale;
drop-shadow-blur: 8px; drop-shadow-blur: 8px * scale;
drop-shadow-color: ring-color; drop-shadow-color: ring-color;
} }
// 递增计数器
Timer {
interval: 8s;
running: true;
triggered => {
cycle = cycle + 1;
}
}
// 初始化时启动动画 // 初始化时启动动画
init => { init => {
cycle = 1; base-angle = 360;
} }
} }
// 手牌卡牌组件 // 手牌卡牌组件
component HandCardItem inherits Rectangle { component HandCardItem inherits Rectangle {
in property <float> scale;
in property <image> card-image; in property <image> card-image;
in property <bool> is-selected: false; in property <bool> is-selected: false;
callback clicked; callback clicked;
width: 120px; width: 120px * scale;
height: 180px; height: 180px * scale;
y: is-selected ? -25px : 0px; y: is-selected ? -25px * scale : 0px;
background: transparent; background: transparent;
border-radius: 10px; border-radius: 10px * scale;
drop-shadow-blur: is-selected ? 16px : 6px; drop-shadow-blur: is-selected ? 16px * scale : 6px * scale;
drop-shadow-color: is-selected ? #00000040 : #00000025; drop-shadow-color: is-selected ? #00000040 : #00000025;
drop-shadow-offset-y: is-selected ? 6px : 3px; drop-shadow-offset-y: is-selected ? 6px * scale : 3px * scale;
animate y { animate y {
duration: 150ms; duration: 150ms;
easing: ease-out; easing: ease-out;
@@ -352,14 +352,15 @@ component HandCardItem inherits Rectangle {
// UNO 圆形按钮组件 // UNO 圆形按钮组件
component UnoButton inherits Rectangle { component UnoButton inherits Rectangle {
in property <float> scale;
callback clicked; callback clicked;
width: 90px; width: 90px * scale;
height: 90px; height: 90px * scale;
background: touch.pressed ? #D32F2F : #F44336; background: touch.pressed ? #D32F2F : #F44336;
border-radius: 45px; border-radius: 45px * scale;
drop-shadow-blur: 12px; drop-shadow-blur: 12px * scale;
drop-shadow-color: #F4433660; drop-shadow-color: #F4433660;
drop-shadow-offset-y: 4px; drop-shadow-offset-y: 4px * scale;
touch := TouchArea { touch := TouchArea {
clicked => { clicked => {
root.clicked(); root.clicked();
@@ -368,7 +369,7 @@ component UnoButton inherits Rectangle {
Text { Text {
text: "UNO!"; text: "UNO!";
font-size: 20px; font-size: 20px * scale;
font-weight: 700; font-weight: 700;
color: #FFFFFF; color: #FFFFFF;
horizontal-alignment: center; horizontal-alignment: center;
@@ -378,18 +379,19 @@ component UnoButton inherits Rectangle {
// 颜色选择按钮组件 // 颜色选择按钮组件
component ColorButton inherits Rectangle { component ColorButton inherits Rectangle {
in property <float> scale;
in property <color> btn-color; in property <color> btn-color;
in property <string> color-name; in property <string> color-name;
callback clicked; callback clicked;
width: 80px; width: 80px * scale;
height: 80px; height: 80px * scale;
background: touch.pressed ? btn-color.darker(20%) : btn-color; background: touch.pressed ? btn-color.darker(20%) : btn-color;
border-radius: 12px; border-radius: 12px * scale;
border-width: 3px; border-width: 3px * scale;
border-color: btn-color.darker(30%); border-color: btn-color.darker(30%);
drop-shadow-blur: 8px; drop-shadow-blur: 8px * scale;
drop-shadow-color: btn-color.with-alpha(0.4); drop-shadow-color: btn-color.with-alpha(0.4);
drop-shadow-offset-y: 3px; drop-shadow-offset-y: 3px * scale;
touch := TouchArea { touch := TouchArea {
clicked => { clicked => {
root.clicked(); root.clicked();
@@ -398,7 +400,7 @@ component ColorButton inherits Rectangle {
Text { Text {
text: color-name; text: color-name;
font-size: 14px; font-size: 14px * scale;
font-weight: 600; font-weight: 600;
color: #FFFFFF; color: #FFFFFF;
horizontal-alignment: center; horizontal-alignment: center;
@@ -408,6 +410,7 @@ component ColorButton inherits Rectangle {
// 颜色选择弹窗组件 // 颜色选择弹窗组件
component ColorPickerDialog inherits Rectangle { component ColorPickerDialog inherits Rectangle {
in property <float> scale;
in property <bool> show: false; in property <bool> show: false;
callback color-selected(CardColor); callback color-selected(CardColor);
callback cancel; callback cancel;
@@ -433,13 +436,13 @@ component ColorPickerDialog inherits Rectangle {
Rectangle { Rectangle {
x: (parent.width - self.width) / 2; x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;
width: 600px; width: 600px * scale;
height: 280px; height: 280px * scale;
background: #FFFFFF; background: #FFFFFF;
border-radius: 20px; border-radius: 20px * scale;
drop-shadow-blur: 30px; drop-shadow-blur: 30px * scale;
drop-shadow-color: #00000040; drop-shadow-color: #00000040;
drop-shadow-offset-y: 10px; drop-shadow-offset-y: 10px * scale;
// 阻止点击弹窗内容时关闭 // 阻止点击弹窗内容时关闭
TouchArea { TouchArea {
@@ -447,14 +450,14 @@ component ColorPickerDialog inherits Rectangle {
} }
VerticalLayout { VerticalLayout {
padding: 30px; padding: 30px * scale;
spacing: 25px; spacing: 25px * scale;
alignment: center; alignment: center;
// 标题 // 标题
Text { Text {
text: "选择颜色"; text: "选择颜色";
font-size: 24px; font-size: 24px * scale;
font-weight: 700; font-weight: 700;
color: #333333; color: #333333;
horizontal-alignment: center; horizontal-alignment: center;
@@ -462,9 +465,10 @@ component ColorPickerDialog inherits Rectangle {
// 颜色按钮网格 // 颜色按钮网格
HorizontalLayout { HorizontalLayout {
spacing: 20px; spacing: 20px * scale;
alignment: center; alignment: center;
ColorButton { ColorButton {
scale: root.scale;
btn-color: #F44336; btn-color: #F44336;
color-name: "红色"; color-name: "红色";
clicked => { clicked => {
@@ -473,6 +477,7 @@ component ColorPickerDialog inherits Rectangle {
} }
ColorButton { ColorButton {
scale: root.scale;
btn-color: #2196F3; btn-color: #2196F3;
color-name: "蓝色"; color-name: "蓝色";
clicked => { clicked => {
@@ -481,6 +486,7 @@ component ColorPickerDialog inherits Rectangle {
} }
ColorButton { ColorButton {
scale: root.scale;
btn-color: #4CAF50; btn-color: #4CAF50;
color-name: "绿色"; color-name: "绿色";
clicked => { clicked => {
@@ -489,6 +495,7 @@ component ColorPickerDialog inherits Rectangle {
} }
ColorButton { ColorButton {
scale: root.scale;
btn-color: #FFEB3B; btn-color: #FFEB3B;
color-name: "黄色"; color-name: "黄色";
clicked => { clicked => {
@@ -501,6 +508,7 @@ component ColorPickerDialog inherits Rectangle {
HorizontalLayout { HorizontalLayout {
alignment: center; alignment: center;
Button { Button {
scale: root.scale;
text: "取消"; text: "取消";
clicked => { clicked => {
root.cancel(); root.cancel();
@@ -512,7 +520,8 @@ component ColorPickerDialog inherits Rectangle {
} }
// 主游戏页面 // 主游戏页面
export component GamePage inherits Window { export component GamePage inherits Rectangle {
in property <float> scale;
// 其他玩家列表 // 其他玩家列表
in property <[OtherPlayer]> other-players; in property <[OtherPlayer]> other-players;
@@ -542,8 +551,8 @@ export component GamePage inherits Window {
callback request-play-card(int, CardColor); callback request-play-card(int, CardColor);
callback request-draw-card; callback request-draw-card;
callback request-uno; callback request-uno;
width: 1920px; width: 100%;
height: 1080px; height: 100%;
// 背景渐变 // 背景渐变
Rectangle { Rectangle {
@@ -552,25 +561,26 @@ export component GamePage inherits Window {
// 主布局 // 主布局
VerticalLayout { VerticalLayout {
padding: 30px; padding: 30px * scale;
spacing: 20px; spacing: 20px * scale;
// ===== 顶部:其他玩家区域 ===== // ===== 顶部:其他玩家区域 =====
Rectangle { Rectangle {
height: 150px; height: 150px * scale;
background: #FDFBF8; background: #FDFBF8;
border-radius: 16px; border-radius: 16px * scale;
drop-shadow-blur: 10px; drop-shadow-blur: 10px * scale;
drop-shadow-color: #00000010; drop-shadow-color: #00000010;
drop-shadow-offset-y: 2px; drop-shadow-offset-y: 2px * scale;
HorizontalLayout { HorizontalLayout {
padding-top: 10px; padding-top: 10px * scale;
padding-bottom: 15px; padding-bottom: 15px * scale;
padding-left: 15px; padding-left: 15px * scale;
padding-right: 15px; padding-right: 15px * scale;
spacing: 40px; spacing: 40px * scale;
alignment: center; alignment: center;
for player[index] in other-players: PlayerAvatar { for player[index] in other-players: PlayerAvatar {
scale: root.scale;
player-name: player.name; player-name: player.name;
card-count: player.card-count; card-count: player.card-count;
has-uno: player.has-uno; has-uno: player.has-uno;
@@ -586,14 +596,15 @@ export component GamePage inherits Window {
// 中心容器:包含环和两个牌堆 // 中心容器:包含环和两个牌堆
Rectangle { Rectangle {
width: 560px; width: 560px * scale;
height: 320px; height: 320px * scale;
x: (parent.width - self.width) / 2; x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;
background: transparent; background: transparent;
// 方向环背景 - 在牌堆下面 // 方向环背景 - 在牌堆下面
DirectionRingBackground { DirectionRingBackground {
scale: root.scale;
x: (parent.width - self.width) / 2; x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;
ring-width: 560; ring-width: 560;
@@ -603,11 +614,12 @@ export component GamePage inherits Window {
// 起牌堆 - 左侧 // 起牌堆 - 左侧
Rectangle { Rectangle {
x: 80px; x: 80px * scale;
y: (parent.height - 200px) / 2; y: (parent.height - 200px * scale) / 2;
width: 130px; width: 130px * scale;
height: 200px; height: 200px * scale;
CardBack { CardBack {
scale: root.scale;
card-width: 130; card-width: 130;
card-height: 200; card-height: 200;
} }
@@ -617,6 +629,7 @@ export component GamePage inherits Window {
width: parent.width; width: parent.width;
height: parent.height; height: parent.height;
clicked => { clicked => {
root.selected-card-index = -1;
root.request-draw-card(); root.request-draw-card();
} }
} }
@@ -624,15 +637,17 @@ export component GamePage inherits Window {
// 弃牌堆 - 右侧 // 弃牌堆 - 右侧
DiscardCard { DiscardCard {
x: parent.width - 130px - 80px; scale: root.scale;
y: (parent.height - 200px) / 2; x: parent.width - 130px * scale - 80px * scale;
y: (parent.height - 200px * scale) / 2;
card-width: 130; card-width: 130;
card-height: 200; card-height: 200;
card-image: root.discard-top-card; card-image: root.discard-top-card;
} }
// 方向环光点 - 在牌堆上面 // 方向环光点 - 在牌堆上面
DirectionRingOrbs { DirectionRingOrbs {
scale: root.scale;
x: (parent.width - self.width) / 2; x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;
ring-width: 560; ring-width: 560;
@@ -645,23 +660,24 @@ export component GamePage inherits Window {
// ===== 底部:当前玩家区域 ===== // ===== 底部:当前玩家区域 =====
Rectangle { Rectangle {
height: 320px; height: 350px * scale;
background: #FDFBF8; background: #FDFBF8;
border-radius: 16px; border-radius: 16px * scale;
drop-shadow-blur: 10px; drop-shadow-blur: 10px * scale;
drop-shadow-color: #00000010; drop-shadow-color: #00000010;
drop-shadow-offset-y: -2px; drop-shadow-offset-y: -2px * scale;
HorizontalLayout { HorizontalLayout {
padding: 20px; padding: 20px * scale;
padding-left: 40px; padding-left: 40px * scale;
padding-right: 40px; padding-right: 40px * scale;
spacing: 30px; spacing: 30px * scale;
alignment: space-between; alignment: space-between;
// 当前玩家头像 - 靠左垂直居中与UNO按钮对齐 // 当前玩家头像 - 靠左垂直居中与UNO按钮对齐
VerticalLayout { VerticalLayout {
alignment: center; alignment: center;
PlayerAvatar { PlayerAvatar {
scale: root.scale;
player-name: current-player-name; player-name: current-player-name;
card-count: current-player-card-count; card-count: current-player-card-count;
has-uno: current-player-has-uno; has-uno: current-player-has-uno;
@@ -672,17 +688,18 @@ export component GamePage inherits Window {
// 手牌区域 // 手牌区域
VerticalLayout { VerticalLayout {
horizontal-stretch: 1; horizontal-stretch: 1;
spacing: 10px; spacing: 10px * scale;
alignment: center; alignment: center;
// 出牌按钮 (放在手牌上方) // 出牌按钮 (放在手牌上方)
Rectangle { Rectangle {
height: 50px; height: 50px * scale;
background: transparent; background: transparent;
HorizontalLayout { HorizontalLayout {
alignment: center; alignment: center;
padding-bottom: 10px; padding-bottom: 10px * scale;
Button { Button {
scale: root.scale;
text: "出牌"; text: "出牌";
enabled: is-current-player-turn && selected-card-index >= 0 && hand-cards[selected-card-index].can-be-played; enabled: is-current-player-turn && selected-card-index >= 0 && hand-cards[selected-card-index].can-be-played;
clicked => { clicked => {
@@ -704,20 +721,24 @@ export component GamePage inherits Window {
// 手牌列表 - 上方留出空间给选中效果 // 手牌列表 - 上方留出空间给选中效果
Rectangle { Rectangle {
height: 210px; width: 1200px * scale;
height: 230px * scale;
horizontal-stretch: 1;
background: transparent; background: transparent;
// 手牌容器,向下偏移以给选中卡牌留空间,居中显示 // 手牌滚动容器
Rectangle { ScrollView {
x: (parent.width - self.width) / 2; width: 100%;
y: 25px; height: 100%;
width: hand-cards-layout.preferred-width;
height: 180px; // 手牌容器,向下偏移以给选中卡牌留空间
hand-cards-layout := HorizontalLayout { hand-cards-layout := HorizontalLayout {
y: 25px * scale;
alignment: center; alignment: center;
spacing: -35px; // 卡牌重叠效果 spacing: -35px * scale; // 卡牌重叠效果
for card[index] in hand-cards: HandCardItem { for card[index] in hand-cards: HandCardItem {
scale: root.scale;
card-image: card.image-path; card-image: card.image-path;
is-selected: index == selected-card-index; is-selected: index == selected-card-index;
clicked => { clicked => {
@@ -740,6 +761,7 @@ export component GamePage inherits Window {
VerticalLayout { VerticalLayout {
alignment: center; alignment: center;
UnoButton { UnoButton {
scale: root.scale;
clicked => { clicked => {
root.request-uno(); root.request-uno();
} }
@@ -751,6 +773,7 @@ export component GamePage inherits Window {
// 颜色选择弹窗 (覆盖在最上层) // 颜色选择弹窗 (覆盖在最上层)
ColorPickerDialog { ColorPickerDialog {
scale: root.scale;
show: root.show-color-picker; show: root.show-color-picker;
color-selected(color) => { color-selected(color) => {
root.show-color-picker = false; root.show-color-picker = false;

View File

@@ -1,6 +1,12 @@
import { ConnectPage } from "ConnectPage.slint"; import { ConnectPage } from "ConnectPage.slint";
import { StartPage } from "StartPage.slint"; import { StartPage } from "StartPage.slint";
import { GamePage, OtherPlayer, HandCard, CardColor, GameDirection } from "GamePage.slint"; import {
GamePage,
OtherPlayer,
HandCard,
CardColor,
GameDirection,
} from "GamePage.slint";
enum PageType { enum PageType {
ConnectPage, ConnectPage,
@@ -35,17 +41,23 @@ export component MainWindow inherits Window {
callback request-draw-card; callback request-draw-card;
callback request-uno; callback request-uno;
width: 1920px; preferred-width: 1920px;
height: 1080px; preferred-height: 1080px;
min-width: 960px;
min-height: 600px;
title: "UNO!"; title: "UNO!";
property <float> scale: min(self.width / 1920px, self.height / 1080px);
if root.active-page == PageType.ConnectPage: connect-page := ConnectPage { if root.active-page == PageType.ConnectPage: connect-page := ConnectPage {
scale: root.scale;
is-connecting: root.is-connecting; is-connecting: root.is-connecting;
request-connect(server-address, server-port, player-name) => { request-connect(server-address, server-port, player-name) => {
root.request-connect(server-address, server-port, player-name); root.request-connect(server-address, server-port, player-name);
} }
} }
if root.active-page == PageType.StartPage: start-page := StartPage { if root.active-page == PageType.StartPage: start-page := StartPage {
scale: root.scale;
is-ready: root.is-ready; is-ready: root.is-ready;
is-restart: root.is-restart; is-restart: root.is-restart;
request-start => { request-start => {
@@ -53,6 +65,7 @@ export component MainWindow inherits Window {
} }
} }
if root.active-page == PageType.GamePage: game-page := GamePage { if root.active-page == PageType.GamePage: game-page := GamePage {
scale: root.scale;
other-players: root.other-players; other-players: root.other-players;
current-player-name: root.current-player-name; current-player-name: root.current-player-name;
current-player-card-count: root.current-player-card-count; current-player-card-count: root.current-player-card-count;

View File

@@ -1,13 +1,14 @@
import {Button} from "Components.slint"; import {Button} from "Components.slint";
export component StartPage inherits Window { export component StartPage inherits Rectangle {
in property <float> scale;
in property <bool> is-ready; in property <bool> is-ready;
in property <bool> is-restart; in property <bool> is-restart;
callback request-start; callback request-start;
width: 1920px; width: 100%;
height: 1080px; height: 100%;
// 背景渐变 (从左上角的米色到右下角的淡粉色) // 背景渐变 (从左上角的米色到右下角的淡粉色)
Rectangle { Rectangle {
@@ -16,29 +17,29 @@ export component StartPage inherits Window {
// 中心卡片 // 中心卡片
Rectangle { Rectangle {
width: 600px; width: 600px * scale;
height: 600px; // 根据内容高度调整 height: 600px * scale; // 根据内容高度调整
background: #FDFBF8; // 略微区别于背景的米白色 background: #FDFBF8; // 略微区别于背景的米白色
border-radius: 20px; border-radius: 20px * scale;
// 简单的阴影模拟 // 简单的阴影模拟
drop-shadow-blur: 20px; drop-shadow-blur: 20px * scale;
drop-shadow-color: #00000015; drop-shadow-color: #00000015;
drop-shadow-offset-y: 4px; drop-shadow-offset-y: 4px * scale;
VerticalLayout { VerticalLayout {
padding: 40px; padding: 40px * scale;
spacing: 20px; spacing: 20px * scale;
alignment: center; alignment: center;
// 标题区域 // 标题区域
VerticalLayout { VerticalLayout {
spacing: 8px; spacing: 8px * scale;
alignment: center; alignment: center;
Text { Text {
text: "UNO"; text: "UNO";
font-size: 96px; font-size: 96px * scale;
font-weight: 700; font-weight: 700;
color: #222222; color: #222222;
horizontal-alignment: center; horizontal-alignment: center;
@@ -46,7 +47,7 @@ export component StartPage inherits Window {
Text { Text {
text: is-restart ? "游戏结束,重新准备以重新开始游戏" : "连接成功,所有玩家准备后开始游戏"; text: is-restart ? "游戏结束,重新准备以重新开始游戏" : "连接成功,所有玩家准备后开始游戏";
font-size: 14px; font-size: 14px * scale;
color: #666666; color: #666666;
horizontal-alignment: center; horizontal-alignment: center;
} }
@@ -54,15 +55,16 @@ export component StartPage inherits Window {
// 占位间隔 // 占位间隔
Rectangle { Rectangle {
height: 10px; height: 10px * scale;
} }
// 底部按钮区域 (增加一点顶部间距) // 底部按钮区域 (增加一点顶部间距)
HorizontalLayout { HorizontalLayout {
padding-top: 10px; padding-top: 10px * scale;
alignment: center; alignment: center;
Button { Button {
scale: root.scale;
enabled: !is-ready; enabled: !is-ready;
text: is-ready ? "已准备" : "准备"; text: is-ready ? "已准备" : "准备";
clicked => { clicked => {