From df6bbee5d74069f91c0e5f4b385ea4d00abfcb68 Mon Sep 17 00:00:00 2001 From: Kieran Kihn <114803508+kierankihn@users.noreply.github.com> Date: Wed, 10 Dec 2025 21:47:01 +0800 Subject: [PATCH] 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. --- ui/GamePage.slint | 763 ++++++++++++++++++++++++++++++++++++++++++++ ui/MainWindow.slint | 40 +++ 2 files changed, 803 insertions(+) create mode 100644 ui/GamePage.slint diff --git a/ui/GamePage.slint b/ui/GamePage.slint new file mode 100644 index 0000000..1535993 --- /dev/null +++ b/ui/GamePage.slint @@ -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 player-name; + in property card-count; + in property has-uno; + in property 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 card-width: 100; + in property 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 card-image; + in property card-width: 100; + in property 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 current-color: CardColor.Red; + in property ring-width: 500; + in property ring-height: 280; + property ring-color: current-color == CardColor.Red ? #F44336 : current-color == CardColor.Blue ? #2196F3 : current-color == CardColor.Green ? #4CAF50 : current-color == CardColor.Yellow ? #FFEB3B : #9E9E9E; + property 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 a: ring-width / 2; + property 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 direction: GameDirection.Clockwise; + in property current-color: CardColor.Red; + in property ring-width: 500; + in property ring-height: 280; + + // 单一的动画计数器,持续递增 + in-out property cycle: 0; + property ring-color: current-color == CardColor.Red ? #F44336 : current-color == CardColor.Blue ? #2196F3 : current-color == CardColor.Green ? #4CAF50 : current-color == CardColor.Yellow ? #FFEB3B : #9E9E9E; + property a: ring-width / 2; + property b: ring-height / 2; + + // 基础角度(持续增加) + property base-angle: cycle * 360; + animate base-angle { + duration: 8s; + easing: linear; + } + + // 方向系数:顺时针为1,逆时针为-1 + property dir-sign: direction == GameDirection.Clockwise ? 1 : -1; + + // 实际角度 = 基础角度 * 方向系数 + property 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 card-image; + in property 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 btn-color; + in property 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 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 current-player-name; + in property current-player-card-count; + in property current-player-has-uno; + in property is-current-player-turn; + + // 手牌列表 + in property <[HandCard]> hand-cards; + + // 当前选中的卡牌索引 (-1 表示未选中) + property selected-card-index: -1; + + // 弃牌堆顶牌 + in property discard-top-card; + + // 游戏状态 + in property game-direction; + in property current-color; + + // 颜色选择弹窗状态 + in-out property 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; + } + } +} diff --git a/ui/MainWindow.slint b/ui/MainWindow.slint index 259ef8b..37968c1 100644 --- a/ui/MainWindow.slint +++ b/ui/MainWindow.slint @@ -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 active-page: PageType.ConnectPage; + + // ConnectPage in property is-connecting; + + // StartPage in property is-ready; + // GamePage + in property <[OtherPlayer]> other-players; + in property current-player-name; + in property current-player-card-count; + in property current-player-has-uno; + in property is-current-player-turn; + in property <[HandCard]> hand-cards; + in property discard-top-card; + in property game-direction; + in property 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(); + } + } }