blob: 1bfd2eb0e5173a2e6a829622082376d51170efab [file] [log] [blame]
// Copyright 2015 The Vanadium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
import 'dart:math' as math;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:vector_math/vector_math_64.dart' as vector_math;
import '../logic/card.dart' as logic_card;
import '../logic/croupier.dart' show Croupier;
import '../logic/game/game.dart' show Game, GameType;
import '../logic/hearts/hearts.dart' show HeartsGame, HeartsPhase;
import '../sound/sound_assets.dart';
import '../styles/common.dart' as style;
import 'card.dart' as component_card;
import 'card_collection.dart'
show CardCollectionComponent, CardCollectionOrientation, DropType, AcceptCb;
import 'croupier_profile.dart' show CroupierProfileComponent;
const double defaultBoardHeight = 400.0;
const double defaultBoardWidth = 400.0;
const double defaultCardHeight = 40.0;
const double defaultCardWidth = 40.0;
/// A Board represents a fixed-size canvas for drawing a Game's UI.
/// While other Widgets may be drawn to accomodate space, a Board is meant to
/// consume a specific amount of space on the screen, which allows for more
/// control when positioning elements within the Board's area.
abstract class Board extends StatefulWidget {
Board(this.game,
{double height, double width, double cardHeight, double cardWidth})
: _height = height,
_width = width,
_cardHeight = cardHeight,
_cardWidth = cardWidth;
final Game game;
final double _height;
final double _width;
final double _cardHeight;
final double _cardWidth;
double get height => _height ?? defaultBoardHeight;
double get width => _width ?? defaultBoardWidth;
double get cardHeight => _cardHeight ?? defaultCardHeight;
double get cardWidth => _cardWidth ?? defaultCardWidth;
}
/// The HeartsBoard represents the Hearts table view, which shows the number of
/// cards each player has, and the cards they are currently playing.
class HeartsBoard extends Board {
HeartsBoard(Croupier croupier, this.sounds,
{double height,
double width,
double cardHeight,
double cardWidth,
this.isMini: false,
this.gameAcceptCallback,
this.setGameStateCallback,
this.bufferedPlay})
: croupier = croupier,
super(croupier.game,
height: height,
width: width,
cardHeight: cardHeight,
cardWidth: cardWidth) {
assert(this.game is HeartsGame);
}
final Croupier croupier;
final SoundAssets sounds;
final bool isMini;
final AcceptCb gameAcceptCallback;
final VoidCallback setGameStateCallback;
final List<logic_card.Card> bufferedPlay;
@override
HeartsGame get game => super.game;
@override
HeartsBoardState createState() => new HeartsBoardState();
}
class HeartsBoardState extends State<HeartsBoard> {
static const double PROFILE_SIZE = 0.17; // multiplier of config.height
// Every time the counter changes, a sound will be played.
// For example, in the pass/take phase, the counter does this:
// 0->1->2->3->4->3->2->1->0.
// We play 4 whooshIn sounds followed by 4 whooshOut sounds upon detecting
// the change. Each sound only occurs during the very first build (the first
// opportunity to detect the change).
// In the play phase, we have this instead: 0->1->2->3->4->0
// This 5-cycle is 4 played cards (whooshIn) and 1 take trick (whooshOut).
int cardCounter = 0;
bool passing = true;
// Used to hide the cards played until it has been incremented enough.
int localAsking = 0;
void _handleCardCounterSounds() {
// Ensure we have the right state while we deal and score.
if (config.game.phase == HeartsPhase.deal ||
config.game.phase == HeartsPhase.score) {
cardCounter = 0;
passing = true;
}
// Passing
if (passing) {
// If it is now someone's turn, we should no longer be passing.
if (config.game.whoseTurn != null) {
passing = false;
// Special: Play a sound for the last take command of the pass phase.
if (cardCounter > 0) {
cardCounter = 0;
_playSoundOut();
}
return;
}
// Passing: If somebody passed cards recently...
if (config.game.numPassed > cardCounter) {
cardCounter = config.game.numPassed;
_playSoundIn();
return;
}
// Passing: If somebody took cards recently...
if (config.game.numPassed < cardCounter) {
cardCounter = config.game.numPassed;
_playSoundOut();
return;
}
return;
}
// Playing: If somebody played a card...
if (config.game.numPlayed > cardCounter) {
cardCounter = config.game.numPlayed;
_playSoundIn();
return;
}
// Playing: If somebody took the trick...
if (config.game.numPlayed == 0 && cardCounter != 0) {
cardCounter = 0;
_playSoundOut();
}
}
void _playSoundIn() {
if (!config.isMini) {
config.sounds.play("whooshIn");
}
}
void _playSoundOut() {
if (!config.isMini) {
config.sounds.play("whooshOut");
}
}
@override
Widget build(BuildContext context) {
double offscreenDelta = config.isMini ? 5.0 : 1.5;
_handleCardCounterSounds();
_handleLocalAskingReset();
Widget boardChild;
if (config.game.phase == HeartsPhase.play) {
boardChild =
config.isMini ? _buildMiniBoardLayout() : _buildBoardLayout();
} else {
boardChild = _buildPassLayout();
}
return new Container(
height: config.height,
width: config.width,
child: new Stack(children: [
new Positioned(top: 0.0, left: 0.0, child: boardChild),
new Positioned(
top: config.height * (offscreenDelta + 0.5),
left: (config.width - config.cardWidth) / 2,
child: _buildOffScreenCards(
config.isMini ? rotateByGamePlayerNumber(0) : 0)), // bottom
new Positioned(
top: (config.height - config.cardHeight) / 2,
left: config.width * (-offscreenDelta + 0.5),
child: _buildOffScreenCards(
config.isMini ? rotateByGamePlayerNumber(1) : 1)), // left
new Positioned(
top: config.height * (-offscreenDelta + 0.5),
left: (config.width - config.cardWidth) / 2,
child: _buildOffScreenCards(
config.isMini ? rotateByGamePlayerNumber(2) : 2)), // top
new Positioned(
top: (config.height - config.cardHeight) / 2,
left: config.width * (offscreenDelta + 0.5),
child: _buildOffScreenCards(
config.isMini ? rotateByGamePlayerNumber(3) : 3)) // right
]));
}
int rotateByGamePlayerNumber(int i) {
return (i + config.game.playerNumber) % 4;
}
static Map<int, String> passBackgrounds = const <int, String>{
0: "images/games/hearts/pass_right.png",
1: "images/games/hearts/pass_left.png",
2: "images/games/hearts/pass_across.png",
3: "",
};
Widget _buildPassLayout() {
String passBackground = ""; // It's possible to have no background.
if (config.game.phase == HeartsPhase.pass ||
config.game.phase == HeartsPhase.take) {
passBackground = passBackgrounds[config.game.roundNumber % 4];
}
return new Container(
height: config.height,
width: config.width,
child: new Stack(children: [
new Positioned(
top: 0.0,
left: 0.0,
child: new AssetImage(
name: passBackground,
height: config.height,
width: config.width)),
new Positioned(top: 0.0, left: 0.0, child: _buildPassLayoutInternal())
]));
}
double _rotationAngle(int pNum) {
return pNum * math.PI / 2;
}
Widget _rotate(Widget w, int pNum) {
return new Transform(
child: w,
transform:
new vector_math.Matrix4.identity().rotateZ(_rotationAngle(pNum)),
alignment: new FractionalOffset(0.5, 0.5));
}
Widget _getPass(int playerNumber) {
double sizeRatio = 0.10;
double cccSize = math.min(sizeRatio * config.width, config.cardWidth * 3.5);
HeartsGame game = config.game;
List<logic_card.Card> cardsToTake = [];
int takeTarget = game.getTakeTarget(playerNumber);
if (takeTarget != null) {
cardsToTake = game.cardCollections[
game.getTakeTarget(playerNumber) + HeartsGame.offsetPass];
}
bool isHorz = playerNumber % 2 == 0;
CardCollectionOrientation ori = isHorz
? CardCollectionOrientation.horz
: CardCollectionOrientation.vert;
return new CardCollectionComponent(cardsToTake, false, ori,
backgroundColor: style.transparentColor,
width: isHorz ? cccSize : null,
height: isHorz ? null : cccSize,
widthCard: config.cardWidth,
heightCard: config.cardHeight,
rotation: playerNumber * math.PI / 2,
useKeys: true);
}
Widget _getProfile(int pNum, double sizeFactor) {
return new CroupierProfileComponent.horizontal(
settings: config.croupier.settingsFromPlayerNumber(pNum));
}
Widget _playerProfile(int pNum, double sizeFactor) {
return _rotate(_getProfile(pNum, sizeFactor), pNum);
}
Widget _buildPassLayoutInternal() {
return new Container(
height: config.height,
width: config.width,
child: new Column(
children: [
new Flexible(child: _playerProfile(2, PROFILE_SIZE), flex: 0),
new Flexible(child: _getPass(2), flex: 0),
new Flexible(
child: new Row(
children: [
new Flexible(
child: _playerProfile(1, PROFILE_SIZE), flex: 0),
new Flexible(child: _getPass(1), flex: 0),
new Flexible(child: new Block(children: []), flex: 1),
new Flexible(child: _getPass(3), flex: 0),
new Flexible(
child: _playerProfile(3, PROFILE_SIZE), flex: 0)
],
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceAround),
flex: 1),
new Flexible(child: _getPass(0), flex: 0),
new Flexible(child: _playerProfile(0, PROFILE_SIZE), flex: 0)
],
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceAround));
}
Widget _buildMiniBoardLayout() {
return new Container(
height: config.height,
width: config.width,
child: new Center(child: new Row(children: [
new Flexible(
flex: 1,
child: new Center(
child: _buildAvatarSlotCombo(rotateByGamePlayerNumber(1)))),
new Flexible(
flex: 1,
child: new Column(children: [
new Flexible(
flex: 1,
child: _buildAvatarSlotCombo(rotateByGamePlayerNumber(2))),
new Flexible(
flex: 1,
child: _buildAvatarSlotCombo(rotateByGamePlayerNumber(0)))
])),
new Flexible(
flex: 1,
child: new Center(
child: _buildAvatarSlotCombo(rotateByGamePlayerNumber(3))))
])));
}
Widget _buildAvatarSlotCombo(int playerNumber) {
HeartsGame game = config.game;
int p = game.playerNumber;
List<Widget> items = new List<Widget>();
bool isMe = playerNumber == p;
List<logic_card.Card> showCard =
game.cardCollections[playerNumber + HeartsGame.offsetPlay];
bool hasPlayed = showCard.length > 0;
bool isTurn = playerNumber == game.whoseTurn && !hasPlayed;
if (isMe && config.bufferedPlay != null) {
showCard = config.bufferedPlay;
}
items.add(new Positioned(
top: 0.0,
left: 0.0,
child: new CardCollectionComponent(
showCard, true, CardCollectionOrientation.show1,
useKeys: true,
acceptCallback: config.gameAcceptCallback,
acceptType: isMe && !hasPlayed ? DropType.card : DropType.none,
widthCard: config.cardWidth - 6.0,
heightCard: config.cardHeight - 6.0,
backgroundColor:
isTurn ? style.theme.accentColor : Colors.grey[500],
altColor: isTurn ? Colors.grey[200] : Colors.grey[600])));
if (!hasPlayed) {
items.add(new Positioned(
top: 0.0,
left: 0.0,
child: new IgnorePointer(child: new CroupierProfileComponent.mini(
settings: config.croupier.settingsFromPlayerNumber(playerNumber),
height: config.cardHeight,
width: config.cardWidth))));
}
return new Container(
width: config.cardWidth,
height: config.cardHeight,
child: new Stack(children: items));
}
Widget _showTrickText(int pNum) {
HeartsGame game = config.game;
int numTrickCards =
game.cardCollections[HeartsGame.offsetTrick + pNum].length;
int numTricks = numTrickCards ~/ 4;
String s = numTricks != 1 ? "s" : "";
return _rotate(new Text("$numTricks trick$s"), pNum);
}
void _handleLocalAskingReset() {
// If the trick was taken, we can reset localAsking.
if (config.game.numPlayed == 0) {
localAsking = 0;
}
}
bool _incrementLocalAsking() {
if (localAsking < config.game.numPlayed) {
setState(() {
localAsking++;
if (config.setGameStateCallback != null) {
config.setGameStateCallback(); // Required for ZCards to redraw.
}
});
return true;
}
return false;
}
void _boardLayoutTapCb() {
// You can tap anywhere on the board to fake "Ask" or "Take Trick".
if (localAsking < 4) {
// Try to increment. If it fails, be lenient! Give 0.5 seconds to check
// this condition again.
if (!_incrementLocalAsking()) {
new Future.delayed(const Duration(milliseconds: 500), () {
_incrementLocalAsking(); // give it one more shot
});
}
} else {
config.game.takeTrickUI();
}
}
Widget _buildBoardLayout() {
int activePlayer = config.game.allPlayed
? config.game.determineTrickWinner()
: config.game.whoseTurn;
return new GestureDetector(
onTap: _boardLayoutTapCb,
child: new Container(
height: config.height,
width: config.width,
decoration: new BoxDecoration(border: new Border(
top: new BorderSide(
color: activePlayer == 2
? style.theme.accentColor
: style.transparentColor,
width: 5.0),
right: new BorderSide(
color: activePlayer == 3
? style.theme.accentColor
: style.transparentColor,
width: 5.0),
left: new BorderSide(
color: activePlayer == 1
? style.theme.accentColor
: style.transparentColor,
width: 5.0),
bottom: new BorderSide(
color: activePlayer == 0
? style.theme.accentColor
: style.transparentColor,
width: 5.0))),
child: new Column(
children: [
new Flexible(child: _playerProfile(2, PROFILE_SIZE), flex: 0),
new Flexible(child: _showTrickText(2), flex: 0),
new Flexible(
child: new Row(
children: [
new Flexible(
child: _playerProfile(1, PROFILE_SIZE),
flex: 0),
new Flexible(child: _showTrickText(1), flex: 0),
new Flexible(
child: new Center(child: _buildCenterCards()),
flex: 1),
new Flexible(child: _showTrickText(3), flex: 0),
new Flexible(
child: _playerProfile(3, PROFILE_SIZE), flex: 0)
],
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceAround),
flex: 1),
new Flexible(child: _showTrickText(0), flex: 0),
new Flexible(child: _playerProfile(0, PROFILE_SIZE), flex: 0)
],
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceAround)));
}
Widget _buildCenterCards() {
double height = config.cardHeight * this._centerScaleFactor;
double width = config.cardWidth * this._centerScaleFactor;
Widget centerPiece = new Container(
height: height, width: width, child: new Block(children: []));
if (localAsking == 4) {
// If all cards played are revealed, show Take Trick button.
int rotateNum = config.game.determineTrickWinner();
double smaller = math.min(height, width);
// TODO(alexfandrianto): The Text looks great within the square
// container, but this is supposed to be pressable like a button.
// The reason why I did it this way is that the button's disappearance
// prevents the board's onTap handler from firing.
// https://github.com/flutter/flutter/issues/1497
centerPiece = _rotate(
new Container(
height: smaller,
width: smaller,
decoration: style.Box.liveBackground,
child: new Center(
child: new Text("Take", style: style.Text.largeStyle))),
rotateNum);
}
return new Column(
children: [
new Flexible(child: new Row(
children: [
new Flexible(child: new Block(children: [])),
new Flexible(child: new Center(child: _buildCenterCard(2))),
new Flexible(child: new Block(children: [])),
],
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center)),
new Flexible(child: new Row(
children: [
new Flexible(child: new Center(child: _buildCenterCard(1))),
new Flexible(child: new Row(
children: [centerPiece],
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center)),
new Flexible(child: new Center(child: _buildCenterCard(3))),
],
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center)),
new Flexible(child: new Row(
children: [
new Flexible(child: new Block(children: [])),
new Flexible(child: new Center(child: _buildCenterCard(0))),
new Flexible(child: new Block(children: [])),
],
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center))
],
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center);
}
double get _centerScaleFactor {
bool wide = (config.width >= config.height);
double heightUsed = 2 * PROFILE_SIZE;
if (wide) {
return config.height * (1 - heightUsed) / (config.cardHeight * 4);
} else {
return (config.width - (1.5 * config.height * heightUsed)) /
(config.cardWidth * 4);
}
}
Widget _buildCenterCard(int playerNumber) {
HeartsGame game = config.game;
List<logic_card.Card> cards =
game.cardCollections[playerNumber + HeartsGame.offsetPlay];
// TODO(alexfandrianto): Clean up soon.
// https://github.com/vanadium/issues/issues/1098
//bool hasPlayed = cards.length > 0;
//bool isTurn = game.whoseTurn == playerNumber && !hasPlayed;
double height = config.cardHeight * this._centerScaleFactor;
double width = config.cardWidth * this._centerScaleFactor;
bool canShow =
(playerNumber - config.game.lastTrickTaker) % 4 < localAsking;
List<Widget> stackWidgets = <Widget>[
new Positioned(
top: 0.0,
left: 0.0,
child: new CardCollectionComponent(
cards, canShow, CardCollectionOrientation.show1,
widthCard: width - 6,
heightCard: height - 6,
rotation: _rotationAngle(playerNumber),
useKeys: true))
];
// TODO(alexfandrianto): Clean up soon.
// https://github.com/vanadium/issues/issues/1098
/*if (isTurn) {
stackWidgets.add(new Positioned(
top: 0.0,
left: 0.0,
child: _rotate(
new Container(
height: height,
width: width,
child: new RaisedButton(
child: new Text("Play", style: style.Text.largeStyle),
onPressed: config.game.asking ? null : config.game.askUI,
color: style.theme.accentColor)),
playerNumber)));
}*/
return new Container(
height: height, width: width, child: new Stack(children: stackWidgets));
}
// The off-screen cards consist of trick cards and play cards.
// When the board is mini, the player's play cards are excluded.
Widget _buildOffScreenCards(int playerNumber) {
HeartsGame game = config.game;
List<logic_card.Card> cards = new List.from(
game.cardCollections[playerNumber + HeartsGame.offsetTrick]);
bool isPlay = game.phase == HeartsPhase.play;
// Prevent over-expansion of cards until a card has been played.
bool alreadyPlaying =
(isPlay && (game.numPlayed > 0 || game.trickNumber > 0));
double sizeFactor = 1.0;
if (config.isMini) {
if (playerNumber != game.playerNumber) {
cards
.addAll(game.cardCollections[playerNumber + HeartsGame.offsetHand]);
}
} else {
cards.addAll(game.cardCollections[playerNumber + HeartsGame.offsetHand]);
if (alreadyPlaying) {
sizeFactor = this._centerScaleFactor;
}
}
return new CardCollectionComponent(
cards, alreadyPlaying, CardCollectionOrientation.show1,
widthCard: config.cardWidth * sizeFactor,
heightCard: config.cardHeight * sizeFactor,
useKeys: true,
rotation: config.isMini ? null : _rotationAngle(playerNumber),
animationType: component_card.CardAnimationType.long);
}
}