해당 강의는 바로 이전 강의와 이어집니다.
이전 강의를 보지 않았다면 위 이미지를 클릭하여 강의를 보고 와주십시오.
이번 포스팅에서는 게임 보드판의 주요건물 현황과 버튼들, 카드 클릭 이벤트에 관한 내용을 다루겠습니다. 주요건물 현황은 구매여부에 따라 이미지를 변환시키도록 위젯을 만들어 표현하고 카드 클릭 이벤트는 카드를 클릭했을때 Stack 제일 위에 카드를 강조시키는 위젯을 덮는 식으로 표현할 것입니다.
class _GameScreenState extends State<GameScreen>
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
...
String? selectedCardPath; // 카드 선택 관련 변수
Map<String, int> cardCounts = {}; // 카드 개수 관련 Map
...
late MiniVillGame game;
void initState() {
...
game = MiniVillGame(widget.numOfPlayers);
cardCounts = Map.fromIterable(
game.centerCards,
key: (item) => item.imagePath,
value: (item) => item.availableCount,
);
}
Widget build(BuildContext context) {
double screenWidth = MediaQuery.of(context).size.width;
double sideSpaceWidth = screenWidth * 0.2;
...
return Scaffold(
body: Row(
children: [
//게임스크린 (플레이어 영역)
Container(
...
),
//게임스크린 (게임 보드 영역)
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 10.0),
child: Stack(
children: [
Column(
children: [
// 그리드 뷰 영역
Expanded(
flex: 3,
child: LayoutBuilder(
builder: (BuildContext context,
BoxConstraints constraints) {
double itemWidth = constraints.maxWidth / 5;
double itemHeight = constraints.maxHeight / 3;
return GridView.builder(
physics: NeverScrollableScrollPhysics(),
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
childAspectRatio: itemWidth / itemHeight,
),
itemCount: game.centerCards.length,
itemBuilder: (context, index) {
CenterBuildingCard card =
game.centerCards[index];
String cardImagePath = card.imagePath;
return GestureDetector(
onTap: () {
setState(() {
selectedCardPath = cardImagePath;
});
},
child: Container(
margin:
EdgeInsets.symmetric(vertical: 5.0),
decoration: BoxDecoration(
border: Border.all(color: Colors.black),
),
child: Align(
alignment: Alignment.center,
child: CardStack(
cardImagePath: cardImagePath,
cardCount:
cardCounts[cardImagePath] ?? 6),
),
),
);
},
);
},
),
),
// 주요건물 및 버튼 영역
Expanded(
flex: 1,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
//player의 인덱스로 알맞는 값을 집어넣어야함
Expanded(
flex: 1,
child: _boardMajorCardImage(
game.players[0].id, 0),
),
Expanded(
flex: 1,
child: _boardMajorCardImage(
game.players[0].id, 1),
),
Expanded(
flex: 1,
child: _boardMajorCardImage(
game.players[0].id, 2),
),
Expanded(
flex: 1,
child: _boardMajorCardImage(
game.players[0].id, 3),
),
Expanded(
flex: 1,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
flex: 9,
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Expanded(
flex: 9,
child: FittedBox(
child: Icon(Icons.attach_money),
),
),
Expanded(
flex: 1,
child: SizedBox(),
),
Expanded(
flex: 9,
child: AutoSizeText(
// 플레이어 정보를 담아야함
'3원',
minFontSize: 5,
style: TextStyle(fontSize: 20.0),
// 시작할 폰트 크기
maxLines: 1, // 최대 줄 수
),
),
],
),
),
Expanded(
flex: 1,
child: SizedBox(),
),
Expanded(
flex: 9,
child: ElevatedButton(
onPressed: () {
// 건물 현황 보여주는 Dialog
},
child: AutoSizeText(
'건물 현황',
minFontSize: 5,
style: TextStyle(fontSize: 15.0),
// 시작할 폰트 크기
maxLines: 1, // 최대 줄 수
),
),
),
Expanded(
flex: 1,
child: SizedBox(),
),
Expanded(
flex: 9,
child: ElevatedButton(
onPressed: () {
// 건물 구매 안하고 턴넘기기
},
child: AutoSizeText(
'턴 넘기기',
minFontSize: 5,
style: TextStyle(fontSize: 15.0),
// 시작할 폰트 크기
maxLines: 1, // 최대 줄 수
),
),
),
Expanded(
flex: 1,
child: SizedBox(),
),
],
),
),
],
),
),
],
),
if (selectedCardPath != null)
ExpandedCardOverlay(
cardImagePath: selectedCardPath!,
onClose: () {
setState(() {
selectedCardPath = null;
});
},
onPurchase: () {
// 카드 구매 이벤트 (임시)
if (selectedCardPath != null &&
selectedCardPath!
.startsWith('assets/center_card')) {
cardCounts[selectedCardPath!] -= 1;
}else if (selectedCardPath != null &&
selectedCardPath!
.startsWith('assets/major_card')){
game.players[0].majorBuildings[selectedIndex]
.isActive = true;
}
},
),
],
),
)),
//게임스크린 (주사위 영역)
Container(
...
),
],
),
);
}
// 커스텀 위젯 생성
Widget _boardMajorCardImage(int playerID, int majorNum) {
String frontImagePath = "assets/major_card/major_card_front_$majorNum.jpg";
String backImagePath = "assets/major_card/major_card_back_$majorNum.jpg";
return GestureDetector(
onTap: () {
selectedCardPath = backImagePath;
if (game.players[playerID].majorBuildings[majorNum].isActive) {
setState(() {
selectedCardPath = frontImagePath;
});
} else {
setState(() {
selectedCardPath = backImagePath;
});
}
},
child: Padding(
padding: EdgeInsets.all(5.0),
child: Image.asset(
game.players[playerID].majorBuildings[majorNum].isActive == true
? frontImagePath
: backImagePath,
fit: BoxFit.fitHeight,
),
),
);
}
...
}
class ExpandedCardOverlay extends StatelessWidget {
final String cardImagePath;
final VoidCallback onClose;
final VoidCallback onPurchase;
ExpandedCardOverlay({
required this.cardImagePath,
required this.onClose,
required this.onPurchase,
});
Widget build(BuildContext context) {
double screenHeight = MediaQuery.of(context).size.height;
double screenWidth = MediaQuery.of(context).size.width;
return Container(
color: Colors.black.withOpacity(0.7),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(cardImagePath,
height: screenHeight * 0.7, fit: BoxFit.fitHeight),
SizedBox(height: screenHeight * 0.02),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize:
Size(screenWidth * 0.1, screenHeight * 0.1)),
onPressed: () {
onPurchase();
onClose();
},
child: Text('구매하기')),
SizedBox(width: screenWidth * 0.02),
ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize:
Size(screenWidth * 0.1, screenHeight * 0.1)),
onPressed: onClose,
child: Text('취소하기')),
],
),
],
),
),
);
}
}
주요건물 현황을 알리는 레이아웃은 _boardMajorCardImage
라는 위젯을 만들어서 playerID
와 majorCard
의 인덱스 값을 넘겨받아 플레이어별로 주요건물의 구매여부를 알려줍니다. 그리고 삼항 연산자를 사용하여 구매를 했다면 앞면, 구매를 안했다면 뒷면을 표시하도록 위젯을 구성하였습니다.
그리고 카드를 강조하여 표시하는 이벤트는 ExpandedCardOverlay
라는 위젯을 만들고 카드를 클릭했을때 보드판 Stack
맨 위에 표시되도록 코드를 짰습니다.
간단하게 설명하자면, selectedCardPath
가 기본적으로 null
값을 가지며 그 경우에는 ExpandedCardOverlay
가 표시되지 않습니다.
GridView
item의 GestureDetector
를 통해 카드를 클릭하면 setState()
함수를 통해 해당 item안의 이미지 경로가 selectedCardPath
가 됩니다. 그렇게 된다면 selectedCardPath
가 null값이 아니기 때문에 ExpandedCardOverlay
가 Stack
맨 위에 표시되고 selectedCardPath
의 이미지를 강조하는 것입니다.
위 움짤이 구현결과입니다. 플레이어 영역까지 주요건물이 뒤집하는 이벤트는 게임 로직을 구현할때 추가적으로 설명드리겠습니다.
Widget _boardMajorCardImage(int playerID, int majorNum) {
String frontImagePath = "assets/major_card/major_card_front_$majorNum.jpg";
String backImagePath = "assets/major_card/major_card_back_$majorNum.jpg";
return GestureDetector(
onTap: () {
selectedCardPath = backImagePath;
if (game.players[playerID].majorBuildings[majorNum].isActive) {
setState(() {
selectedCardPath = frontImagePath;
});
} else {
setState(() {
selectedCardPath = backImagePath;
});
}
},
child: Padding(
padding: EdgeInsets.all(5.0),
child: Image.asset(
game.players[playerID].majorBuildings[majorNum].isActive == true
? frontImagePath
: backImagePath,
fit: BoxFit.fitHeight,
),
),
);
}
_boardMajorCardImage
위젯은 GrideView
와 같이 클릭했을때 주요 건물 카드를 강조해야 함으로 GestureDetector
로 위젯 전체를 감싸고 onTap
과 setState
를 사용하여 selectedCardPath
에 이미지 경로를 지정해줍니다.
카드를 클릭했을때 강조할 이미지는 default값으로 뒷면 이미지 경로를 집어넣고 플레이어가 주요건물을 구매한 상태라면 앞면 이미지 경로, 구매하지 않는 상태라면 뒷면 이미지 경로를 넣기 위해 조건문을 사용했습니다.
그리고 기본적으로 화면에 표시될 주요건물 카드는 Padding()
과 Image.asset
을 이용하여 표시합니다. 여기서 앞면인지 뒷면인지의 여부를 삼항연산자를 통해 계산하여 주요건물을 구매했다면 앞면, 구매하지 않았다면 뒷면을 표시합니다.
class ExpandedCardOverlay extends StatelessWidget {
final String cardImagePath;
final VoidCallback onClose;
final VoidCallback onPurchase;
ExpandedCardOverlay({
required this.cardImagePath,
required this.onClose,
required this.onPurchase,
});
Widget build(BuildContext context) {
double screenHeight = MediaQuery.of(context).size.height;
double screenWidth = MediaQuery.of(context).size.width;
return Container(
color: Colors.black.withOpacity(0.7),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(cardImagePath,
height: screenHeight * 0.7, fit: BoxFit.fitHeight),
SizedBox(height: screenHeight * 0.02),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize:
Size(screenWidth * 0.1, screenHeight * 0.1)),
onPressed: () {
onPurchase();
onClose();
},
child: Text('구매하기')),
SizedBox(width: screenWidth * 0.02),
ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize:
Size(screenWidth * 0.1, screenHeight * 0.1)),
onPressed: onClose,
child: Text('취소하기')),
],
),
],
),
),
);
}
}
GameScreen
에서 ExpandedCardOverlay
를 호출하면 카드를 선택할때 지정한 selectedCardPath
의 값과 onClose
, onPurchase
함수를 콜백함수로 받아옵니다. 콜백함수로 받아오는 이유는 버튼을 클릭할때의 onPress()
함수가 먼저 실행되고 그 뒤에 onClose
, onPurchase
함수를 실행해야 하기 때문입니다.
전체적인 위젯의 생김새를 설명하자면 보드판 Stack
위에 Container
를 사용하여 전체 보드판 화면을 덮습니다. 그리고 배경을 Colors.black.withOpacity(0.7)
를 사용해서 투명한 검은색 화면을 통해 카드를 강조하도록 합니다.
이미지와 버튼은 Column
을 통해 배치하고 이미지의 크기는 보드판 높이의 70% + 너비는 이미지 비율에 맞게 지정합니다. 구매하기 버튼을 누르면 onPurchase
와 onClose
를 차례로 불러와서 구매 이벤트를 실행시키고 카드 강조 위젯을 닫습니다.
카드 구매 이벤트는 보드판 레이아웃 구조에서 임시로 지정해놓았습니다. 말 그대로 임시이기 때문에 확인하고 싶다면 코드를 적고 카드가 화면에서 잘 없어지고 주요건물이 잘 뒤집히는지 확인해보세요.
현재는 게임 로직을 설정하지 않았기 때문에 해당 부분이 어려울 수 있습니다. 만약 구현이 안되고 너무 어려운 것 같다면 일단은 넘기고 게임 로직을 구현하는 포스팅에서 다시 구현을 시도해보세요.