diff --git a/app/build.gradle b/app/build.gradle
index e1188d25..3115b2a2 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -56,6 +56,7 @@ dependencies {
implementation 'com.google.android.gms:play-services-games-v2:20.1.2'
implementation 'com.google.firebase:firebase-core:21.1.1'
implementation 'com.google.firebase:firebase-auth:21.3.0'
+ implementation 'com.google.firebase:firebase-database:20.3.0'
implementation 'com.google.android.gms:play-services-auth:20.5.0'
implementation 'com.google.android.play:review:2.0.2'
implementation 'com.google.protobuf:protobuf-javalite:3.25.1'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d77b7ae1..03b9437f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -45,6 +45,12 @@
+
+
task) {
if (task.isSuccessful()) {
// Sign in success, update UI with the signed-in user's information
Log.d(TAG, "signInWithCredential:success");
+ mIsFirebaseSignedIn = true;
FirebaseUser user = mFirebaseAuth.getCurrentUser();
+ onSignInSucceeded();
} else {
// If sign in fails, display a message to the user.
Log.w(TAG, "signInWithCredential:failure", task.getException());
@@ -200,12 +203,17 @@ public boolean isSignedIn() {
return mIsSignedIn;
}
+ public boolean isFirebaseSignedIn() {
+ return mIsFirebaseSignedIn || mFirebaseAuth.getCurrentUser() != null;
+ }
+
protected void signOut() {
// Note: PGS v2 does not support programmatic sign out.
// We can sign out from Firebase.
mFirebaseAuth.signOut();
mFirebaseAnalytics.logEvent(AnalyticsConstants.Event.SIGN_OUT, null);
mIsSignedIn = false;
+ mIsFirebaseSignedIn = false;
onSignOut();
}
diff --git a/app/src/main/java/com/antsapps/triples/MainActivity.java b/app/src/main/java/com/antsapps/triples/MainActivity.java
index a655f028..95e47b20 100644
--- a/app/src/main/java/com/antsapps/triples/MainActivity.java
+++ b/app/src/main/java/com/antsapps/triples/MainActivity.java
@@ -118,6 +118,13 @@ public void onClick(View v) {
playZenGame(false);
}
});
+
+ findViewById(R.id.multiplayer_button).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ startActivity(new Intent(MainActivity.this, MatchmakingActivity.class));
+ }
+ });
}
@Override
diff --git a/app/src/main/java/com/antsapps/triples/MatchmakingActivity.java b/app/src/main/java/com/antsapps/triples/MatchmakingActivity.java
new file mode 100644
index 00000000..442dc9ff
--- /dev/null
+++ b/app/src/main/java/com/antsapps/triples/MatchmakingActivity.java
@@ -0,0 +1,279 @@
+package com.antsapps.triples;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.TextView;
+import android.widget.Toast;
+import android.widget.ViewAnimator;
+import androidx.annotation.NonNull;
+import com.antsapps.triples.backend.Card;
+import com.antsapps.triples.backend.Deck;
+import com.antsapps.triples.backend.Game;
+import com.antsapps.triples.backend.Utils;
+import com.antsapps.triples.backend.multiplayer.Player;
+import com.antsapps.triples.backend.multiplayer.Room;
+import com.google.firebase.auth.FirebaseAuth;
+import com.google.firebase.auth.FirebaseUser;
+import com.google.firebase.database.DataSnapshot;
+import com.google.firebase.database.DatabaseError;
+import com.google.firebase.database.DatabaseReference;
+import com.google.firebase.database.FirebaseDatabase;
+import com.google.firebase.database.ValueEventListener;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+
+public class MatchmakingActivity extends BaseTriplesActivity {
+
+ private static final int STATE_MAIN = 0;
+ private static final int STATE_FRIENDS = 1;
+ private static final int STATE_SEARCHING = 2;
+
+ private static final int STATE_CONNECTING = 3;
+
+ private ViewAnimator mAnimator;
+ private TextView mTvStatus;
+ private TextView mTvRoomCodeDisplay;
+ private EditText mEtRoomCode;
+ private View mBtnStartGame;
+
+ private DatabaseReference mRoomsRef;
+ private DatabaseReference mCurrentRoomRef;
+ private ValueEventListener mRoomListener;
+
+ private final Handler mHandler = new Handler();
+ private final Runnable mTimeoutRunnable = () -> {
+ if (mCurrentRoomRef != null) {
+ Toast.makeText(this, R.string.matchmaking_timeout, Toast.LENGTH_LONG).show();
+ cancelMatchmaking();
+ }
+ };
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.matchmaking);
+
+ mAnimator = findViewById(R.id.matchmaking_animator);
+ mTvStatus = findViewById(R.id.tv_status);
+ mTvRoomCodeDisplay = findViewById(R.id.tv_room_code_display);
+ mEtRoomCode = findViewById(R.id.et_room_code);
+ mBtnStartGame = findViewById(R.id.btn_start_game);
+
+ if (!isFirebaseSignedIn()) {
+ if (isSignedIn()) {
+ // PGS is signed in, but Firebase isn't yet. Show connecting state.
+ mAnimator.setDisplayedChild(STATE_SEARCHING);
+ mTvStatus.setText(R.string.connecting_multiplayer);
+ mTvRoomCodeDisplay.setVisibility(View.GONE);
+ mBtnStartGame.setVisibility(View.GONE);
+ setSignInListener(signedIn -> {
+ if (isFirebaseSignedIn()) {
+ mAnimator.setDisplayedChild(STATE_MAIN);
+ setSignInListener(null);
+ }
+ });
+ } else {
+ Toast.makeText(this, "Please sign in to play multiplayer", Toast.LENGTH_SHORT).show();
+ finish();
+ return;
+ }
+ }
+
+ mRoomsRef = FirebaseDatabase.getInstance().getReference("rooms");
+
+ findViewById(R.id.btn_random_match).setOnClickListener(v -> startRandomMatch());
+ findViewById(R.id.btn_play_with_friends).setOnClickListener(v -> mAnimator.setDisplayedChild(STATE_FRIENDS));
+ findViewById(R.id.btn_create_room).setOnClickListener(v -> createFriendRoom());
+ findViewById(R.id.btn_join_room).setOnClickListener(v -> joinFriendRoom());
+ findViewById(R.id.btn_cancel_matchmaking).setOnClickListener(v -> cancelMatchmaking());
+ findViewById(R.id.btn_start_game).setOnClickListener(v -> startGame());
+ }
+
+ private void startRandomMatch() {
+ mAnimator.setDisplayedChild(STATE_SEARCHING);
+ mTvStatus.setText(R.string.searching_for_opponent);
+ mTvRoomCodeDisplay.setVisibility(View.GONE);
+ mBtnStartGame.setVisibility(View.GONE);
+
+ mRoomsRef.orderByChild("gameState").equalTo(Room.STATE_LOBBY).limitToFirst(1)
+ .addListenerForSingleValueEvent(new ValueEventListener() {
+ @Override
+ public void onDataChange(@NonNull DataSnapshot snapshot) {
+ if (snapshot.hasChildren()) {
+ DataSnapshot roomSnapshot = snapshot.getChildren().iterator().next();
+ joinRoom(roomSnapshot.getKey());
+ } else {
+ createRoom(true);
+ }
+ }
+
+ @Override
+ public void onCancelled(@NonNull DatabaseError error) {}
+ });
+
+ mHandler.postDelayed(mTimeoutRunnable, 30000);
+ }
+
+ private void createFriendRoom() {
+ createRoom(false);
+ }
+
+ private void joinFriendRoom() {
+ String code = mEtRoomCode.getText().toString().trim().toUpperCase();
+ if (!code.isEmpty()) {
+ joinRoom(code);
+ }
+ }
+
+ private void createRoom(boolean isRandom) {
+ String code = generateRoomCode();
+ mCurrentRoomRef = mRoomsRef.child(code);
+
+ Room room = new Room();
+ room.code = code;
+ room.gameState = Room.STATE_LOBBY;
+ room.seed = new Random().nextLong();
+
+ FirebaseUser user = FirebaseAuth.getInstance().getCurrentUser();
+ Player me = new Player(user.getUid(), user.getDisplayName() != null ? user.getDisplayName() : "Player 1");
+ room.players.put(user.getUid(), me);
+
+ mCurrentRoomRef.setValue(room).addOnSuccessListener(aVoid -> {
+ mAnimator.setDisplayedChild(STATE_SEARCHING);
+ mTvStatus.setText(isRandom ? R.string.searching_for_opponent : R.string.waiting_for_players);
+ mTvRoomCodeDisplay.setVisibility(View.VISIBLE);
+ mTvRoomCodeDisplay.setText(getString(R.string.room_code_format, code));
+ mBtnStartGame.setVisibility(isRandom ? View.GONE : View.VISIBLE);
+ listenToRoom(code);
+ });
+ }
+
+ private void joinRoom(String code) {
+ mCurrentRoomRef = mRoomsRef.child(code);
+ mCurrentRoomRef.addListenerForSingleValueEvent(new ValueEventListener() {
+ @Override
+ public void onDataChange(@NonNull DataSnapshot snapshot) {
+ Room room = snapshot.getValue(Room.class);
+ if (room != null && Room.STATE_LOBBY.equals(room.gameState)) {
+ FirebaseUser user = FirebaseAuth.getInstance().getCurrentUser();
+ Player me = new Player(user.getUid(), user.getDisplayName() != null ? user.getDisplayName() : "Player " + (room.players.size() + 1));
+ mCurrentRoomRef.child("players").child(user.getUid()).setValue(me);
+
+ mAnimator.setDisplayedChild(STATE_SEARCHING);
+ mTvStatus.setText(R.string.waiting_for_players);
+ mTvRoomCodeDisplay.setVisibility(View.VISIBLE);
+ mTvRoomCodeDisplay.setText(getString(R.string.room_code_format, code));
+ listenToRoom(code);
+ } else {
+ Toast.makeText(MatchmakingActivity.this, "Room not found or already started.", Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ @Override
+ public void onCancelled(@NonNull DatabaseError error) {}
+ });
+ }
+
+ private void listenToRoom(String code) {
+ mRoomListener = mCurrentRoomRef.addValueEventListener(new ValueEventListener() {
+ @Override
+ public void onDataChange(@NonNull DataSnapshot snapshot) {
+ Room room = snapshot.getValue(Room.class);
+ if (room == null) return;
+
+ if (Room.STATE_ACTIVE.equals(room.gameState)) {
+ mHandler.removeCallbacks(mTimeoutRunnable);
+ mCurrentRoomRef.removeEventListener(this);
+ mRoomListener = null;
+ startMultiplayerGameActivity(room);
+ } else if (room.players.size() >= 2 && mTvStatus.getText().equals(getString(R.string.searching_for_opponent))) {
+ // If random match and we have 2 players, start!
+ if (mBtnStartGame.getVisibility() != View.VISIBLE) { // Host of random match doesn't need to press start
+ startGame();
+ }
+ }
+ }
+
+ @Override
+ public void onCancelled(@NonNull DatabaseError error) {}
+ });
+ }
+
+ private void startGame() {
+ if (mCurrentRoomRef == null) return;
+
+ mCurrentRoomRef.addListenerForSingleValueEvent(new ValueEventListener() {
+ @Override
+ public void onDataChange(@NonNull DataSnapshot snapshot) {
+ Room room = snapshot.getValue(Room.class);
+ if (room == null) return;
+
+ // Initialize Deck and Board
+ Deck deck = new Deck(new Random(room.seed));
+ List cardsInPlay = new ArrayList<>();
+ while (cardsInPlay.size() < 12 || Game.getAValidTriple(cardsInPlay, com.google.common.collect.Sets.newHashSet()) == null) {
+ for (int i = 0; i < 3; i++) {
+ cardsInPlay.add(deck.getNextCard());
+ }
+ }
+
+ List boardBytes = new ArrayList<>();
+ for (Card c : cardsInPlay) boardBytes.add((int) Utils.cardToByte(c));
+ List deckBytes = new ArrayList<>();
+ for (Card c : deck.getCardsRemainingList()) deckBytes.add((int) Utils.cardToByte(c));
+
+ mCurrentRoomRef.child("boardCardBytes").setValue(boardBytes);
+ mCurrentRoomRef.child("deckCardBytes").setValue(deckBytes);
+ mCurrentRoomRef.child("gameState").setValue(Room.STATE_ACTIVE);
+ }
+
+ @Override
+ public void onCancelled(@NonNull DatabaseError error) {}
+ });
+ }
+
+ private void startMultiplayerGameActivity(Room room) {
+ Intent intent = new Intent(this, MultiplayerGameActivity.class);
+ intent.putExtra("room_code", room.code);
+ startActivity(intent);
+ finish();
+ }
+
+ private void cancelMatchmaking() {
+ mHandler.removeCallbacks(mTimeoutRunnable);
+ if (mCurrentRoomRef != null) {
+ if (mRoomListener != null) {
+ mCurrentRoomRef.removeEventListener(mRoomListener);
+ mRoomListener = null;
+ }
+ FirebaseUser user = FirebaseAuth.getInstance().getCurrentUser();
+ mCurrentRoomRef.child("players").child(user.getUid()).removeValue();
+ // If no players left, could delete room, but for simplicity just leave it.
+ mCurrentRoomRef = null;
+ }
+ mAnimator.setDisplayedChild(STATE_MAIN);
+ }
+
+ private String generateRoomCode() {
+ String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+ StringBuilder sb = new StringBuilder();
+ Random r = new Random();
+ for (int i = 0; i < 4; i++) {
+ sb.append(chars.charAt(r.nextInt(chars.length())));
+ }
+ return sb.toString();
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ mHandler.removeCallbacks(mTimeoutRunnable);
+ if (mCurrentRoomRef != null && mRoomListener != null) {
+ mCurrentRoomRef.removeEventListener(mRoomListener);
+ }
+ }
+}
diff --git a/app/src/main/java/com/antsapps/triples/MultiplayerGameActivity.java b/app/src/main/java/com/antsapps/triples/MultiplayerGameActivity.java
new file mode 100644
index 00000000..2bb8502b
--- /dev/null
+++ b/app/src/main/java/com/antsapps/triples/MultiplayerGameActivity.java
@@ -0,0 +1,190 @@
+package com.antsapps.triples;
+
+import android.content.Intent;
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.view.View;
+import android.view.ViewStub;
+import android.widget.TextView;
+import androidx.annotation.NonNull;
+import com.antsapps.triples.backend.Card;
+import com.antsapps.triples.backend.Deck;
+import com.antsapps.triples.backend.Game;
+import com.antsapps.triples.backend.Utils;
+import com.antsapps.triples.backend.multiplayer.MultiplayerGame;
+import com.antsapps.triples.backend.multiplayer.Player;
+import com.antsapps.triples.backend.multiplayer.Room;
+import com.antsapps.triples.backend.multiplayer.TripleFoundEvent;
+import com.antsapps.triples.cardsview.CardsView;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.firebase.auth.FirebaseAuth;
+import com.google.firebase.database.ChildEventListener;
+import com.google.firebase.database.DataSnapshot;
+import com.google.firebase.database.DatabaseError;
+import com.google.firebase.database.DatabaseReference;
+import com.google.firebase.database.FirebaseDatabase;
+import com.google.firebase.database.ValueEventListener;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class MultiplayerGameActivity extends BaseGameActivity {
+
+ private MultiplayerGame mGame;
+ private String mRoomCode;
+ private DatabaseReference mRoomRef;
+ private TextView mTvPlayer1Score;
+ private TextView mTvPlayer2Score;
+ private String mMyId;
+ private String mOpponentId;
+ private ChildEventListener mTriplesFoundListener;
+ private ValueEventListener mRoomUpdateListener;
+
+ @Override
+ protected void init(Bundle savedInstanceState) {
+ mRoomCode = getIntent().getStringExtra("room_code");
+ if (FirebaseAuth.getInstance().getCurrentUser() == null) {
+ finish();
+ return;
+ }
+ mMyId = FirebaseAuth.getInstance().getCurrentUser().getUid();
+ mRoomRef = FirebaseDatabase.getInstance().getReference("rooms").child(mRoomCode);
+
+ ViewStub stub = findViewById(R.id.status_bar);
+ stub.setLayoutResource(R.layout.multiplayer_statusbar);
+ View statusBar = stub.inflate();
+ mTvPlayer1Score = statusBar.findViewById(R.id.tv_player_1_score);
+ mTvPlayer2Score = statusBar.findViewById(R.id.tv_player_2_score);
+
+ // Initial dummy game, will be updated via sync
+ mGame = new MultiplayerGame(mRoomCode, mMyId, 0, new ArrayList<>(), new ArrayList<>(), new Deck(new ArrayList<>()), 0, new Date(), Game.GameState.STARTING);
+
+ mRoomUpdateListener = new ValueEventListener() {
+ private boolean firstChange = true;
+ @Override
+ public void onDataChange(@NonNull DataSnapshot snapshot) {
+ Room room = snapshot.getValue(Room.class);
+ if (room != null) {
+ updateScores(room);
+ if (firstChange) {
+ setupTriplesFoundListener();
+ firstChange = false;
+ }
+ }
+ }
+
+ @Override
+ public void onCancelled(@NonNull DatabaseError error) {}
+ };
+ mRoomRef.addValueEventListener(mRoomUpdateListener);
+ }
+
+ private void setupTriplesFoundListener() {
+ mTriplesFoundListener = mRoomRef.child("triplesFound").addChildEventListener(new ChildEventListener() {
+ @Override
+ public void onChildAdded(@NonNull DataSnapshot snapshot, String previousChildName) {
+ TripleFoundEvent event = snapshot.getValue(TripleFoundEvent.class);
+ if (event != null) {
+ onTripleFoundEvent(event);
+ }
+ }
+
+ @Override
+ public void onChildChanged(@NonNull DataSnapshot snapshot, String previousChildName) {}
+ @Override
+ public void onChildRemoved(@NonNull DataSnapshot snapshot) {}
+ @Override
+ public void onChildMoved(@NonNull DataSnapshot snapshot, String previousChildName) {}
+ @Override
+ public void onCancelled(@NonNull DatabaseError error) {}
+ });
+ }
+
+ private void onTripleFoundEvent(TripleFoundEvent event) {
+ Set triple = new HashSet<>();
+ for (Integer b : event.cardBytes) {
+ triple.add(Utils.cardFromByte(b.byteValue()));
+ }
+
+ CardsView cardsView = findViewById(R.id.cards_view);
+ Rect targetRect = new Rect();
+ if (event.playerId.equals(mMyId)) {
+ mTvPlayer1Score.getGlobalVisibleRect(targetRect);
+ } else {
+ mTvPlayer2Score.getGlobalVisibleRect(targetRect);
+ }
+
+ // Convert global target Rect to local coordinates within CardsView
+ int[] location = new int[2];
+ cardsView.getLocationOnScreen(location);
+ targetRect.offset(-location[0], -location[1]);
+
+ animateTripleToTarget(triple, targetRect);
+ }
+
+ private void animateTripleToTarget(Set triple, Rect targetRect) {
+ CardsView cardsView = findViewById(R.id.cards_view);
+ cardsView.animateTripleFound(triple, targetRect);
+ }
+
+ private void updateScores(Room room) {
+ List players = new ArrayList<>(room.players.values());
+ if (players.size() >= 1) {
+ Player p1 = null;
+ Player p2 = null;
+ for (Player p : players) {
+ if (p.id.equals(mMyId)) p1 = p;
+ else p2 = p;
+ }
+ if (p1 != null) mTvPlayer1Score.setText(p1.name + ": " + p1.score);
+ if (p2 != null) {
+ mTvPlayer2Score.setText(p2.name + ": " + p2.score);
+ mOpponentId = p2.id;
+ } else {
+ mTvPlayer2Score.setText("Waiting...");
+ }
+ }
+ }
+
+ @Override
+ protected Game getGame() {
+ return mGame;
+ }
+
+ @Override
+ protected int getAccentColor() {
+ return getResources().getColor(R.color.classic_accent);
+ }
+
+ @Override
+ protected void saveGame() {
+ // No local save for multiplayer
+ }
+
+ @Override
+ protected void submitScore() {
+ // Scores are tracked in Firebase
+ }
+
+ @Override
+ protected Intent createNewGame() {
+ return new Intent(this, MatchmakingActivity.class);
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ if (mTriplesFoundListener != null) {
+ mRoomRef.child("triplesFound").removeEventListener(mTriplesFoundListener);
+ }
+ if (mRoomUpdateListener != null) {
+ mRoomRef.removeEventListener(mRoomUpdateListener);
+ }
+ if (mGame != null) {
+ mGame.cleanup();
+ }
+ }
+}
diff --git a/app/src/main/java/com/antsapps/triples/backend/Deck.java b/app/src/main/java/com/antsapps/triples/backend/Deck.java
index abf765a8..720254b2 100644
--- a/app/src/main/java/com/antsapps/triples/backend/Deck.java
+++ b/app/src/main/java/com/antsapps/triples/backend/Deck.java
@@ -48,6 +48,14 @@ public int getCardsRemaining() {
return mCards.size();
}
+ public List getCardsRemainingList() {
+ return Collections.unmodifiableList(mCards);
+ }
+
+ public void setCards(List cards) {
+ mCards = Lists.newArrayList(cards);
+ }
+
public byte[] toByteArray() {
byte[] b = new byte[mCards.size()];
for (int i = 0; i < mCards.size(); i++) {
diff --git a/app/src/main/java/com/antsapps/triples/backend/Game.java b/app/src/main/java/com/antsapps/triples/backend/Game.java
index a5b60dd3..557f98d4 100644
--- a/app/src/main/java/com/antsapps/triples/backend/Game.java
+++ b/app/src/main/java/com/antsapps/triples/backend/Game.java
@@ -92,7 +92,7 @@ public enum GameState {
private final List mCardsInPlayListeners = Lists.newArrayList();
- Game(
+ public Game(
long id,
long seed,
List cardsInPlay,
diff --git a/app/src/main/java/com/antsapps/triples/backend/multiplayer/MultiplayerGame.java b/app/src/main/java/com/antsapps/triples/backend/multiplayer/MultiplayerGame.java
new file mode 100644
index 00000000..e564bcc1
--- /dev/null
+++ b/app/src/main/java/com/antsapps/triples/backend/multiplayer/MultiplayerGame.java
@@ -0,0 +1,234 @@
+package com.antsapps.triples.backend.multiplayer;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.antsapps.triples.backend.Card;
+import com.antsapps.triples.backend.Deck;
+import com.antsapps.triples.backend.Game;
+import com.antsapps.triples.backend.Utils;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.firebase.database.DataSnapshot;
+import com.google.firebase.database.DatabaseError;
+import com.google.firebase.database.DatabaseReference;
+import com.google.firebase.database.FirebaseDatabase;
+import com.google.firebase.database.MutableData;
+import com.google.firebase.database.Transaction;
+import com.google.firebase.database.ValueEventListener;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public class MultiplayerGame extends Game {
+
+ public interface OnOpponentTripleFoundListener {
+ void onOpponentTripleFound(String playerId, Set triple);
+ }
+
+ private final String mRoomCode;
+ private final String mMyPlayerId;
+ private final DatabaseReference mRoomRef;
+ private OnOpponentTripleFoundListener mOpponentTripleFoundListener;
+ private ValueEventListener mRoomListener;
+
+ public MultiplayerGame(
+ String roomCode,
+ String myPlayerId,
+ long seed,
+ List cardsInPlay,
+ List tripleFindTimes,
+ Deck cardsInDeck,
+ long timeElapsed,
+ Date date,
+ GameState gameState) {
+ super(-1, seed, cardsInPlay, tripleFindTimes, cardsInDeck, timeElapsed, date, gameState, false);
+ mRoomCode = roomCode;
+ mMyPlayerId = myPlayerId;
+ mRoomRef = FirebaseDatabase.getInstance().getReference("rooms").child(roomCode);
+ attachRoomListener();
+ }
+
+ public void setOnOpponentTripleFoundListener(OnOpponentTripleFoundListener listener) {
+ mOpponentTripleFoundListener = listener;
+ }
+
+ private void attachRoomListener() {
+ mRoomListener = new ValueEventListener() {
+ @Override
+ public void onDataChange(@NonNull DataSnapshot snapshot) {
+ Room room = snapshot.getValue(Room.class);
+ if (room == null) return;
+
+ syncFromRoom(room);
+ }
+
+ @Override
+ public void onCancelled(@NonNull DatabaseError error) {
+ }
+ };
+ mRoomRef.addValueEventListener(mRoomListener);
+ }
+
+ public void cleanup() {
+ if (mRoomListener != null) {
+ mRoomRef.removeEventListener(mRoomListener);
+ }
+ }
+
+ private synchronized void syncFromRoom(Room room) {
+ // 1. Update Board
+ if (room.boardCardBytes != null) {
+ List newCards = new ArrayList<>();
+ for (Integer b : room.boardCardBytes) {
+ newCards.add(Utils.cardFromByte(b.byteValue()));
+ }
+ if (!mCardsInPlay.equals(newCards)) {
+ ImmutableList oldCards = ImmutableList.copyOf(mCardsInPlay);
+ mCardsInPlay.clear();
+ mCardsInPlay.addAll(newCards);
+ dispatchCardsInPlayUpdate(oldCards);
+ }
+ }
+
+ // 2. Update Deck
+ if (room.deckCardBytes != null) {
+ List newDeckCards = new ArrayList<>();
+ for (Integer b : room.deckCardBytes) {
+ newDeckCards.add(Utils.cardFromByte(b.byteValue()));
+ }
+ mDeck.setCards(newDeckCards);
+ }
+
+ // 3. Game State
+ if (Room.STATE_COMPLETED.equals(room.gameState) && mGameState != GameState.COMPLETED) {
+ finish();
+ }
+
+ // 4. Opponent Animations (triplesFound)
+ // This part is tricky to do with just ValueEventListener because we need to know what's NEW.
+ // We might need ChildEventListener for triplesFound.
+ }
+
+ @Override
+ protected boolean isGameInValidState() {
+ return true; // Controlled by server
+ }
+
+ @Override
+ public void commitTriple(final Card... cards) {
+ // Override commitTriple to use Firebase Transaction
+ mRoomRef.runTransaction(new Transaction.Handler() {
+ @NonNull
+ @Override
+ public Transaction.Result doTransaction(@NonNull MutableData currentData) {
+ Room room = currentData.getValue(Room.class);
+ if (room == null || !Room.STATE_ACTIVE.equals(room.gameState)) {
+ return Transaction.success(currentData);
+ }
+
+ List roomBoard = new ArrayList<>();
+ for (Integer b : room.boardCardBytes) {
+ roomBoard.add(Utils.cardFromByte(b.byteValue()));
+ }
+
+ if (roomBoard.containsAll(Lists.newArrayList(cards)) && isValidTriple(cards)) {
+ // Success! This player claimed the triple.
+
+ // Update score
+ Player me = room.players.get(mMyPlayerId);
+ if (me != null) {
+ me.score++;
+ }
+
+ // Remove cards from board
+ for (Card c : cards) {
+ int idx = roomBoard.indexOf(c);
+ roomBoard.set(idx, null);
+ }
+
+ // Refill board logic
+ Deck deck = new Deck(Collections.emptyList());
+ List deckCards = new ArrayList<>();
+ for (Integer b : room.deckCardBytes) {
+ deckCards.add(Utils.cardFromByte(b.byteValue()));
+ }
+ deck.setCards(deckCards);
+
+ while (numNotNull(roomBoard) < MIN_CARDS_IN_PLAY && !deck.isEmpty()) {
+ int nullIdx = roomBoard.indexOf(null);
+ roomBoard.set(nullIdx, deck.getNextCard());
+ }
+
+ // Remove trailing nulls and compact
+ List compactedBoard = new ArrayList<>();
+ for (Card c : roomBoard) {
+ if (c != null) compactedBoard.add(c);
+ }
+ roomBoard = compactedBoard;
+
+ while (!checkIfAnyValidTriples(roomBoard) && !deck.isEmpty()) {
+ for (int i = 0; i < 3; i++) {
+ if (!deck.isEmpty()) {
+ roomBoard.add(deck.getNextCard());
+ }
+ }
+ }
+
+ // Update room data
+ room.boardCardBytes = new ArrayList<>();
+ for (Card c : roomBoard) {
+ room.boardCardBytes.add((int) Utils.cardToByte(c));
+ }
+ room.deckCardBytes = new ArrayList<>();
+ for (Card c : deck.getCardsRemainingList()) {
+ room.deckCardBytes.add((int) Utils.cardToByte(c));
+ }
+
+ if (deck.isEmpty() && !checkIfAnyValidTriples(roomBoard)) {
+ room.gameState = Room.STATE_COMPLETED;
+ }
+
+ // Add to triplesFound for animation
+ TripleFoundEvent event = new TripleFoundEvent(mMyPlayerId,
+ Lists.newArrayList((int)Utils.cardToByte(cards[0]), (int)Utils.cardToByte(cards[1]), (int)Utils.cardToByte(cards[2])),
+ System.currentTimeMillis());
+ room.triplesFound.put(mRoomRef.child("triplesFound").push().getKey(), event);
+
+ currentData.setValue(room);
+ return Transaction.success(currentData);
+ } else {
+ // Triple no longer valid or cards gone
+ return Transaction.abort();
+ }
+ }
+
+ @Override
+ public void onComplete(@Nullable DatabaseError error, boolean committed, @Nullable DataSnapshot currentData) {
+ if (!committed) {
+ // Maybe show a "Too slow!" message?
+ mGameRenderer.clearSelectedCards();
+ }
+ }
+ });
+ }
+
+ private static int numNotNull(Iterable cards) {
+ int countNotNull = 0;
+ for (Card card : cards) {
+ if (card != null) countNotNull++;
+ }
+ return countNotNull;
+ }
+
+ private boolean checkIfAnyValidTriples(List cards) {
+ return Game.getAValidTriple(cards, com.google.common.collect.Sets.newHashSet()) != null;
+ }
+
+ @Override
+ public String getGameTypeForAnalytics() {
+ return "multiplayer";
+ }
+}
diff --git a/app/src/main/java/com/antsapps/triples/backend/multiplayer/Player.java b/app/src/main/java/com/antsapps/triples/backend/multiplayer/Player.java
new file mode 100644
index 00000000..2be171cc
--- /dev/null
+++ b/app/src/main/java/com/antsapps/triples/backend/multiplayer/Player.java
@@ -0,0 +1,22 @@
+package com.antsapps.triples.backend.multiplayer;
+
+import com.google.firebase.database.IgnoreExtraProperties;
+
+@IgnoreExtraProperties
+public class Player {
+ public String id;
+ public String name;
+ public int score;
+ public boolean active;
+
+ public Player() {
+ // Default constructor required for calls to DataSnapshot.getValue(Player.class)
+ }
+
+ public Player(String id, String name) {
+ this.id = id;
+ this.name = name;
+ this.score = 0;
+ this.active = true;
+ }
+}
diff --git a/app/src/main/java/com/antsapps/triples/backend/multiplayer/Room.java b/app/src/main/java/com/antsapps/triples/backend/multiplayer/Room.java
new file mode 100644
index 00000000..4ecccd1d
--- /dev/null
+++ b/app/src/main/java/com/antsapps/triples/backend/multiplayer/Room.java
@@ -0,0 +1,24 @@
+package com.antsapps.triples.backend.multiplayer;
+
+import com.google.firebase.database.IgnoreExtraProperties;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@IgnoreExtraProperties
+public class Room {
+ public static final String STATE_LOBBY = "LOBBY";
+ public static final String STATE_ACTIVE = "ACTIVE";
+ public static final String STATE_COMPLETED = "COMPLETED";
+
+ public String code;
+ public long seed;
+ public String gameState;
+ public Map players = new HashMap<>();
+ public List boardCardBytes;
+ public List deckCardBytes;
+ public Map triplesFound = new HashMap<>(); // key can be timestamp or push id
+
+ public Room() {
+ }
+}
diff --git a/app/src/main/java/com/antsapps/triples/backend/multiplayer/TripleFoundEvent.java b/app/src/main/java/com/antsapps/triples/backend/multiplayer/TripleFoundEvent.java
new file mode 100644
index 00000000..30a0e678
--- /dev/null
+++ b/app/src/main/java/com/antsapps/triples/backend/multiplayer/TripleFoundEvent.java
@@ -0,0 +1,20 @@
+package com.antsapps.triples.backend.multiplayer;
+
+import com.google.firebase.database.IgnoreExtraProperties;
+import java.util.List;
+
+@IgnoreExtraProperties
+public class TripleFoundEvent {
+ public String playerId;
+ public List cardBytes;
+ public long timestamp;
+
+ public TripleFoundEvent() {
+ }
+
+ public TripleFoundEvent(String playerId, List cardBytes, long timestamp) {
+ this.playerId = playerId;
+ this.cardBytes = cardBytes;
+ this.timestamp = timestamp;
+ }
+}
diff --git a/app/src/main/java/com/antsapps/triples/cardsview/CardsView.java b/app/src/main/java/com/antsapps/triples/cardsview/CardsView.java
index ee863377..d68fac1c 100644
--- a/app/src/main/java/com/antsapps/triples/cardsview/CardsView.java
+++ b/app/src/main/java/com/antsapps/triples/cardsview/CardsView.java
@@ -305,10 +305,14 @@ public void clearSelectedCards() {
}
public void animateTripleFound(final Set triple) {
+ animateTripleFound(triple, mOffScreenLocation);
+ }
+
+ public void animateTripleFound(final Set triple, Rect targetRect) {
for (Card c : triple) {
CardDrawable cd = mCardDrawables.get(c);
if (cd != null) {
- cd.updateBounds(mOffScreenLocation);
+ cd.updateBounds(targetRect);
}
}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 420be4e5..7587e141 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -207,5 +207,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/matchmaking.xml b/app/src/main/res/layout/matchmaking.xml
new file mode 100644
index 00000000..eedfbb6b
--- /dev/null
+++ b/app/src/main/res/layout/matchmaking.xml
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/multiplayer_statusbar.xml b/app/src/main/res/layout/multiplayer_statusbar.xml
new file mode 100644
index 00000000..4d121f41
--- /dev/null
+++ b/app/src/main/res/layout/multiplayer_statusbar.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d820bf3f..4e6eb3a2 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -142,4 +142,18 @@
Longest Streak:
Today
+ Multiplayer
+ Multiplayer Matchmaking
+ Random Match
+ Play with Friends
+ Join Room
+ Create Room
+ Enter Room Code
+ Connecting to multiplayer…
+ Searching for opponent…
+ Waiting for players…
+ Room Code: %1$s
+ Matchmaking timed out. Try again later.
+ Cancel
+ Start Game
\ No newline at end of file