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