feat(ui): add GamePage with player actions and game state display

- Integrated `GamePage` into `MainWindow` for active game state.
- Added player actions: play card, draw card, and call UNO.
- Implemented discard pile, game direction, and current color indicators.
- Designed player hand and opponent details with interactive components.
This commit is contained in:
Kieran Kihn
2025-12-10 21:47:01 +08:00
parent 79c4170aa8
commit df6bbee5d7
2 changed files with 803 additions and 0 deletions

763
ui/GamePage.slint Normal file
View File

@@ -0,0 +1,763 @@
import { VerticalBox, HorizontalBox, ScrollView } from "std-widgets.slint";
import { Button } from "Components.slint";
// 卡牌颜色枚举
export enum CardColor {
Red,
Blue,
Green,
Yellow
}
// 游戏方向枚举
export enum GameDirection {
Clockwise,
CounterClockwise
}
// 其他玩家信息结构
export struct OtherPlayer {
name: string,
card-count: int,
has-uno: bool,
is-current-turn: bool,
}
// 手牌信息结构
export struct HandCard {
image-path: image,
is-selected: bool,
card-id: int,
is-wild: bool,
can-be-played: bool,
}
// 玩家头像组件
component PlayerAvatar inherits Rectangle {
in property <string> player-name;
in property <int> card-count;
in property <bool> has-uno;
in property <bool> is-current-turn;
width: 100px;
height: 130px;
background: transparent;
VerticalLayout {
spacing: 4px;
alignment: center;
// UNO 标志占位区域 (固定高度)
HorizontalLayout {
alignment: center;
Rectangle {
width: 50px;
height: 20px;
background: has-uno ? #FF5722 : transparent;
border-radius: 4px;
Text {
text: "UNO!";
font-size: 11px;
font-weight: 700;
color: has-uno ? #FFFFFF : transparent;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// 头像
HorizontalLayout {
alignment: center;
Rectangle {
width: 60px;
height: 60px;
background: is-current-turn ? #FFD54F : #E0E0E0;
border-radius: 30px;
border-width: is-current-turn ? 3px : 0px;
border-color: #FF9800;
Text {
text: "👤";
font-size: 28px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// 玩家名称
Text {
text: player-name;
font-size: 12px;
font-weight: 500;
color: #333333;
horizontal-alignment: center;
}
// 剩余手牌数
Text {
text: "剩余: " + card-count + " 张";
font-size: 10px;
color: #666666;
horizontal-alignment: center;
}
}
}
// 卡牌背面组件 (起牌堆)
component CardBack inherits Rectangle {
in property <int> card-width: 100;
in property <int> card-height: 150;
width: card-width * 1px;
height: card-height * 1px;
background: #2C2C2C;
border-radius: 10px;
border-width: 2px;
border-color: #444444;
Rectangle {
width: parent.width * 0.75;
height: parent.height * 0.83;
background: #1a1a1a;
border-radius: 8px;
Text {
text: "UNO";
font-size: 20px;
font-weight: 700;
color: #888888;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// 弃牌堆卡牌组件
component DiscardCard inherits Rectangle {
in property <image> card-image;
in property <int> card-width: 100;
in property <int> card-height: 150;
width: card-width * 1px;
height: card-height * 1px;
background: transparent;
border-radius: 10px;
drop-shadow-blur: 12px;
drop-shadow-color: #00000040;
drop-shadow-offset-y: 4px;
Image {
width: 100%;
height: 100%;
source: card-image;
image-fit: contain;
}
}
// 方向环背景组件 - 只绘制环线
component DirectionRingBackground inherits Rectangle {
in property <CardColor> current-color: CardColor.Red;
in property <int> ring-width: 500;
in property <int> ring-height: 280;
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-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> b: ring-height / 2;
width: ring-width * 1px;
height: ring-height * 1px;
background: transparent;
// 底层光晕环
Path {
width: 100%;
height: 100%;
stroke: ring-color-dim;
stroke-width: 8px;
fill: transparent;
opacity: 0.3;
MoveTo {
x: ring-width / 2 + a;
y: ring-height / 2;
}
ArcTo {
x: ring-width / 2 - a;
y: ring-height / 2;
radius-x: a;
radius-y: b;
sweep: true;
large-arc: true;
}
ArcTo {
x: ring-width / 2 + a;
y: ring-height / 2;
radius-x: a;
radius-y: b;
sweep: true;
large-arc: true;
}
}
// 主环
Path {
width: 100%;
height: 100%;
stroke: ring-color;
stroke-width: 3px;
fill: transparent;
opacity: 0.9;
MoveTo {
x: ring-width / 2 + a;
y: ring-height / 2;
}
ArcTo {
x: ring-width / 2 - a;
y: ring-height / 2;
radius-x: a;
radius-y: b;
sweep: true;
large-arc: true;
}
ArcTo {
x: ring-width / 2 + a;
y: ring-height / 2;
radius-x: a;
radius-y: b;
sweep: true;
large-arc: true;
}
}
}
// 方向环光点组件 - 使用内置动画实现平滑移动
component DirectionRingOrbs inherits Rectangle {
in property <GameDirection> direction: GameDirection.Clockwise;
in property <CardColor> current-color: CardColor.Red;
in property <int> ring-width: 500;
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 <float> a: ring-width / 2;
property <float> b: ring-height / 2;
// 基础角度(持续增加)
property <float> base-angle: cycle * 360;
animate base-angle {
duration: 8s;
easing: linear;
}
// 方向系数顺时针为1逆时针为-1
property <float> dir-sign: direction == GameDirection.Clockwise ? 1 : -1;
// 实际角度 = 基础角度 * 方向系数
property <float> actual-angle: base-angle * dir-sign;
width: ring-width * 1px;
height: ring-height * 1px;
background: transparent;
// 光点1
Rectangle {
x: parent.width / 2 - 9px + cos(actual-angle * 1deg) * a * 1px;
y: parent.height / 2 - 9px + sin(actual-angle * 1deg) * b * 1px;
width: 18px;
height: 18px;
background: ring-color;
border-radius: 9px;
drop-shadow-blur: 8px;
drop-shadow-color: ring-color;
}
// 光点2
Rectangle {
x: parent.width / 2 - 9px + cos((actual-angle + 90) * 1deg) * a * 1px;
y: parent.height / 2 - 9px + sin((actual-angle + 90) * 1deg) * b * 1px;
width: 18px;
height: 18px;
background: ring-color;
border-radius: 9px;
drop-shadow-blur: 8px;
drop-shadow-color: ring-color;
}
// 光点3
Rectangle {
x: parent.width / 2 - 9px + cos((actual-angle + 180) * 1deg) * a * 1px;
y: parent.height / 2 - 9px + sin((actual-angle + 180) * 1deg) * b * 1px;
width: 18px;
height: 18px;
background: ring-color;
border-radius: 9px;
drop-shadow-blur: 8px;
drop-shadow-color: ring-color;
}
// 光点4
Rectangle {
x: parent.width / 2 - 9px + cos((actual-angle + 270) * 1deg) * a * 1px;
y: parent.height / 2 - 9px + sin((actual-angle + 270) * 1deg) * b * 1px;
width: 18px;
height: 18px;
background: ring-color;
border-radius: 9px;
drop-shadow-blur: 8px;
drop-shadow-color: ring-color;
}
// 递增计数器
Timer {
interval: 8s;
running: true;
triggered => {
cycle = cycle + 1;
}
}
// 初始化时启动动画
init => {
cycle = 1;
}
}
// 手牌卡牌组件
component HandCardItem inherits Rectangle {
in property <image> card-image;
in property <bool> is-selected: false;
callback clicked;
width: 120px;
height: 180px;
y: is-selected ? -25px : 0px;
background: transparent;
border-radius: 10px;
drop-shadow-blur: is-selected ? 16px : 6px;
drop-shadow-color: is-selected ? #00000040 : #00000025;
drop-shadow-offset-y: is-selected ? 6px : 3px;
animate y {
duration: 150ms;
easing: ease-out;
}
animate drop-shadow-blur { duration: 150ms; }
touch := TouchArea {
clicked => {
root.clicked();
}
}
Image {
width: 100%;
height: 100%;
source: card-image;
image-fit: contain;
}
}
// UNO 圆形按钮组件
component UnoButton inherits Rectangle {
callback clicked;
width: 90px;
height: 90px;
background: touch.pressed ? #D32F2F : #F44336;
border-radius: 45px;
drop-shadow-blur: 12px;
drop-shadow-color: #F4433660;
drop-shadow-offset-y: 4px;
touch := TouchArea {
clicked => {
root.clicked();
}
}
Text {
text: "UNO!";
font-size: 20px;
font-weight: 700;
color: #FFFFFF;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// 颜色选择按钮组件
component ColorButton inherits Rectangle {
in property <color> btn-color;
in property <string> color-name;
callback clicked;
width: 80px;
height: 80px;
background: touch.pressed ? btn-color.darker(20%) : btn-color;
border-radius: 12px;
border-width: 3px;
border-color: btn-color.darker(30%);
drop-shadow-blur: 8px;
drop-shadow-color: btn-color.with-alpha(0.4);
drop-shadow-offset-y: 3px;
touch := TouchArea {
clicked => {
root.clicked();
}
}
Text {
text: color-name;
font-size: 14px;
font-weight: 600;
color: #FFFFFF;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// 颜色选择弹窗组件
component ColorPickerDialog inherits Rectangle {
in property <bool> show: false;
callback color-selected(CardColor);
callback cancel;
width: 100%;
height: 100%;
background: show ? #00000080 : transparent;
opacity: show ? 1.0 : 0.0;
visible: show;
animate opacity {
duration: 200ms;
easing: ease-out;
}
// 阻止点击穿透
TouchArea {
enabled: show;
clicked => {
root.cancel();
}
}
// 弹窗内容
Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: 600px;
height: 280px;
background: #FFFFFF;
border-radius: 20px;
drop-shadow-blur: 30px;
drop-shadow-color: #00000040;
drop-shadow-offset-y: 10px;
// 阻止点击弹窗内容时关闭
TouchArea {
enabled: show;
}
VerticalLayout {
padding: 30px;
spacing: 25px;
alignment: center;
// 标题
Text {
text: "选择颜色";
font-size: 24px;
font-weight: 700;
color: #333333;
horizontal-alignment: center;
}
// 颜色按钮网格
HorizontalLayout {
spacing: 20px;
alignment: center;
ColorButton {
btn-color: #F44336;
color-name: "红色";
clicked => {
root.color-selected(CardColor.Red);
}
}
ColorButton {
btn-color: #2196F3;
color-name: "蓝色";
clicked => {
root.color-selected(CardColor.Blue);
}
}
ColorButton {
btn-color: #4CAF50;
color-name: "绿色";
clicked => {
root.color-selected(CardColor.Green);
}
}
ColorButton {
btn-color: #FFEB3B;
color-name: "黄色";
clicked => {
root.color-selected(CardColor.Yellow);
}
}
}
// 取消按钮
HorizontalLayout {
alignment: center;
Button {
text: "取消";
clicked => {
root.cancel();
}
}
}
}
}
}
// 主游戏页面
export component GamePage inherits Window {
// 其他玩家列表
in property <[OtherPlayer]> other-players;
// 当前玩家信息
in property <string> current-player-name;
in property <int> current-player-card-count;
in property <bool> current-player-has-uno;
in property <bool> is-current-player-turn;
// 手牌列表
in property <[HandCard]> hand-cards;
// 当前选中的卡牌索引 (-1 表示未选中)
property <int> selected-card-index: -1;
// 弃牌堆顶牌
in property <image> discard-top-card;
// 游戏状态
in property <GameDirection> game-direction;
in property <CardColor> current-color;
// 颜色选择弹窗状态
in-out property <bool> show-color-picker;
// 回调
callback request-play-card(int, CardColor);
callback request-draw-card;
callback request-uno;
width: 1920px;
height: 1080px;
// 背景渐变
Rectangle {
background: @linear-gradient(135deg, #fdfbf7 0%, #f3e7e9 100%);
}
// 主布局
VerticalLayout {
padding: 30px;
spacing: 20px;
// ===== 顶部:其他玩家区域 =====
Rectangle {
height: 150px;
background: #FDFBF8;
border-radius: 16px;
drop-shadow-blur: 10px;
drop-shadow-color: #00000010;
drop-shadow-offset-y: 2px;
HorizontalLayout {
padding-top: 10px;
padding-bottom: 15px;
padding-left: 15px;
padding-right: 15px;
spacing: 40px;
alignment: center;
for player[index] in other-players: PlayerAvatar {
player-name: player.name;
card-count: player.card-count;
has-uno: player.has-uno;
is-current-turn: player.is-current-turn;
}
}
}
// ===== 中间:牌堆区域 =====
Rectangle {
vertical-stretch: 1;
background: transparent;
// 中心容器:包含环和两个牌堆
Rectangle {
width: 560px;
height: 320px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
background: transparent;
// 方向环背景 - 在牌堆下面
DirectionRingBackground {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
ring-width: 560;
ring-height: 320;
current-color: root.current-color;
}
// 起牌堆 - 左侧
Rectangle {
x: 80px;
y: (parent.height - 200px) / 2;
width: 130px;
height: 200px;
CardBack {
card-width: 130;
card-height: 200;
}
TouchArea {
width: parent.width;
height: parent.height;
clicked => {
root.request-draw-card();
}
}
}
// 弃牌堆 - 右侧
DiscardCard {
x: parent.width - 130px - 80px;
y: (parent.height - 200px) / 2;
card-width: 130;
card-height: 200;
card-image: root.discard-top-card;
}
// 方向环光点 - 在牌堆上面
DirectionRingOrbs {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
ring-width: 560;
ring-height: 320;
direction: root.game-direction;
current-color: root.current-color;
}
}
}
// ===== 底部:当前玩家区域 =====
Rectangle {
height: 320px;
background: #FDFBF8;
border-radius: 16px;
drop-shadow-blur: 10px;
drop-shadow-color: #00000010;
drop-shadow-offset-y: -2px;
HorizontalLayout {
padding: 20px;
padding-left: 40px;
padding-right: 40px;
spacing: 30px;
alignment: space-between;
// 当前玩家头像 - 靠左垂直居中与UNO按钮对齐
VerticalLayout {
alignment: center;
PlayerAvatar {
player-name: current-player-name;
card-count: current-player-card-count;
has-uno: current-player-has-uno;
is-current-turn: true;
}
}
// 手牌区域
VerticalLayout {
horizontal-stretch: 1;
spacing: 10px;
alignment: center;
// 出牌按钮 (放在手牌上方)
Rectangle {
height: 50px;
background: transparent;
HorizontalLayout {
alignment: center;
padding-bottom: 10px;
Button {
text: "出牌";
enabled: is-current-player-turn && selected-card-index >= 0 && hand-cards[selected-card-index].can-be-played;
clicked => {
if (selected-card-index >= 0) {
// 检查是否是万能牌
if (hand-cards[selected-card-index].is-wild) {
// 显示颜色选择弹窗
root.show-color-picker = true;
} else {
// 非万能牌直接出牌,颜色传 Red
root.request-play-card(selected-card-index, CardColor.Red);
selected-card-index = -1;
}
}
}
}
}
}
// 手牌列表 - 上方留出空间给选中效果
Rectangle {
height: 210px;
background: transparent;
// 手牌容器,向下偏移以给选中卡牌留空间,居中显示
Rectangle {
x: (parent.width - self.width) / 2;
y: 25px;
width: hand-cards-layout.preferred-width;
height: 180px;
hand-cards-layout := HorizontalLayout {
alignment: center;
spacing: -35px; // 卡牌重叠效果
for card[index] in hand-cards: HandCardItem {
card-image: card.image-path;
is-selected: index == selected-card-index;
clicked => {
if (is-current-player-turn && card.can-be-played) {
// 切换选中状态
if (selected-card-index == index) {
selected-card-index = -1;
} else {
selected-card-index = index;
}
}
}
}
}
}
}
}
// UNO 按钮 - 靠右
VerticalLayout {
alignment: center;
UnoButton {
clicked => {
root.request-uno();
}
}
}
}
}
}
// 颜色选择弹窗 (覆盖在最上层)
ColorPickerDialog {
show: root.show-color-picker;
color-selected(color) => {
root.show-color-picker = false;
root.request-play-card(selected-card-index, color);
selected-card-index = -1;
}
cancel => {
root.show-color-picker = false;
}
}
}

View File

@@ -1,5 +1,6 @@
import { ConnectPage } from "ConnectPage.slint";
import { StartPage } from "StartPage.slint";
import { GamePage, OtherPlayer, HandCard, CardColor, GameDirection } from "GamePage.slint";
enum PageType {
ConnectPage,
@@ -9,14 +10,33 @@ enum PageType {
export component MainWindow inherits Window {
in property <PageType> active-page: PageType.ConnectPage;
// ConnectPage
in property <bool> is-connecting;
// StartPage
in property <bool> is-ready;
// GamePage
in property <[OtherPlayer]> other-players;
in property <string> current-player-name;
in property <int> current-player-card-count;
in property <bool> current-player-has-uno;
in property <bool> is-current-player-turn;
in property <[HandCard]> hand-cards;
in property <image> discard-top-card;
in property <GameDirection> game-direction;
in property <CardColor> current-color;
callback request-connect(string, string, string);
callback request-start;
callback request-play-card(int, CardColor);
callback request-draw-card;
callback request-uno;
width: 1920px;
height: 1080px;
title: "UNO!";
if root.active-page == PageType.ConnectPage: connect-page := ConnectPage {
is-connecting: root.is-connecting;
@@ -30,4 +50,24 @@ export component MainWindow inherits Window {
root.request-start();
}
}
if root.active-page == PageType.GamePage: game-page := GamePage {
other-players: root.other-players;
current-player-name: root.current-player-name;
current-player-card-count: root.current-player-card-count;
current-player-has-uno: root.current-player-has-uno;
is-current-player-turn: root.is-current-player-turn;
hand-cards: root.hand-cards;
discard-top-card: root.discard-top-card;
game-direction: root.game-direction;
current-color: root.current-color;
request-play-card(index, card-color) => {
root.request-play-card(index, card-color);
}
request-draw-card => {
root.request-draw-card();
}
request-uno => {
root.request-uno();
}
}
}