Battleship game in Kotlin
<?xml version="1.0" encoding="utf-8"?> | |
<FrameLayout | |
xmlns:android="http://schemas.android.com/apk/res/android" | |
xmlns:app="http://schemas.android.com/apk/res-auto" | |
xmlns:tools="http://schemas.android.com/tools" | |
android:layout_width="match_parent" | |
android:layout_height="match_parent" | |
tools:context="com.fenchtose.battleship.GameActivity"> | |
<android.support.v7.widget.RecyclerView | |
android:id="@+id/recyclerview" | |
android:background="@color/cell_background" | |
android:layout_width="match_parent" | |
android:layout_height="wrap_content" | |
tools:listitem="@layout/square_cell_itemview"/> | |
<TextView | |
android:id="@+id/gamestate_info" | |
android:layout_width="match_parent" | |
android:layout_height="wrap_content" | |
android:layout_gravity="bottom" | |
android:layout_margin="16dp" | |
android:textSize="21sp" | |
android:gravity="center" | |
tools:text="Your turn!"/> | |
</FrameLayout> |
class BattleshipActivity: AppCompatActivity() { | |
private lateinit var gridAdpater: UiCellAdapter | |
private lateinit var myBoard: Board | |
private lateinit var otherBoard: Board | |
private val width: Int = 10 | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
setContentView(R.layout.activity_game); | |
gridAdapter = UiCellAdapter(this, { playMove(it.point) }) | |
gridAdapter.setHasStableIds(true) | |
val recyclerview = findViewById<RecyclerView>(R.id.recyclerview).apply { | |
layoutManager = GridLayoutManager(this@BattleshipActivity, width) | |
adapter = gridAdpater | |
} | |
setupGame() | |
updateUI() | |
} | |
private fun updateUI() { | |
val cells = convert(myBoard) | |
adapter.cells.clear() | |
adapter.cells.addAll(cells) | |
adapter.cells.notifyDataSetChanged() | |
} | |
private fun playMove(p: Point) { | |
// check if already played | |
if (p in otherBoard.hits || p in otherBoard.misses) { | |
return | |
} | |
var missed = true | |
var hitShip: Ship? = null | |
for (ship in otherBoard.ships) { | |
if (p in ship) { | |
// we hit the ship | |
hitShip = ship.copy(hits=ship.hits.plus(p)) | |
missed = false | |
break | |
} | |
} | |
if (missed) { | |
myBoard = myBoard.copy(misses = myBoard.misses.plus(p)) | |
otherBoard = otherBoard.copy( | |
ships = if (hitShip) != null ? otherBoard.ships.update(hitShip), | |
opponentMisses = otherBoard.opponentMisses.plus(p)) | |
} | |
updateUI() | |
} | |
private fun convert(board: Board): List<Cell> { | |
val cells = ArrayList<Cell>(board.width * board.height) | |
for (i in 0 until board.height) { | |
for (j in 0 until board.width) { | |
val point = Point(j, i) | |
cells.add(Cell( | |
point = point, | |
hasShip = board.ships.contains(point), | |
direction = board.ships.getShipDirection(point), | |
userHit = board.hits.contains(point), | |
userMissed = board.misses.contains(point), | |
opponentMissed = board.opponentMisses.contains(point), | |
opponentHit = board.opponentHits.contains(point) | |
)) | |
} | |
} | |
return cells | |
} | |
private fun setupGame() { | |
myBoard = Board(0, User("Player 1"), width, width, getRandomShips(0)) | |
otherBoard = Board(1, User("Player 2"), width, width, getRandomShips(1)) | |
} | |
private fun getRandomShips(startId: Int) { | |
val ships = ArrayList<Ship>() | |
for (i in 1..5) { | |
ships.add(Ship(startId + i, i, Point(i, i), (if (i%2 == 0) Direction.HORIZONTAL else Direction.VERTICAL ))) | |
} | |
return ships | |
} | |
} |
data class Point(val col: Int, val row: Int) { | |
fun isValid() = col >= 0 && row >= 0 | |
operator fun plus(wd: WeighedDirection) = when (wd.d) { | |
Direction.HORIZONTAL -> Point(col + wd.len, row) | |
Direction.VERTICAL -> Point(col, row + wd.len) | |
} | |
} | |
enum class Direction { | |
HORIZONTAL, VERTICAL; | |
operator fun times(n: Int): WeighedDirection { | |
return WeighedDirection(this, n) | |
} | |
} | |
data class WeighedDirection(val d: Direction, val len: Int) | |
data class Ship( | |
val id: Int, | |
val size: Int, val start: Point, val direction: Direction, | |
val hits: Set<Point> = setOf()) { | |
val end = start + direction*(size-1) | |
val destroyed = hits.size == size | |
// check for overlaps | |
operator fun contains(other: Ship): Boolean { | |
if (other.direction == direction) { | |
return other.start in this || other.end in this | |
} | |
val vertical = if (other.direction == Direction.VERTICAL) other else this | |
val horizontal = if (other.direction == Direction.HORIZONTAL) other else this | |
return horizontal.start.row in vertical.start.row..vertical.end.row | |
&& vertical.start.col in horizontal.start.col..horizontal.end.col | |
} | |
operator fun contains(p: Point): Boolean { | |
return when(direction) { | |
Direction.HORIZONTAL -> start.row == p.row && start.col <= p.col && end.col >= p.col | |
Direction.VERTICAL -> start.col == p.col && start.row <= p.row && end.row >= p.row | |
} | |
} | |
} | |
data class Board( | |
val id: Int, val user: User, | |
val width: Int, val height: Int, | |
val ships: List<Ship> = listOf(), | |
val hits: Set<Point> = setOf(), | |
val misses: Set<Point> = setOf(), | |
val opponentHits: Set<Point> = setOf(), | |
val opponentMisses: Set<Point> = setOf()) { | |
val activeShips = ships.filter { !it.destroyed }.map { it.id } | |
val destroyedShips = ships.filter { it.destroyed }.map { it.id } | |
val lost = ships.isNotEmpty() && activeShips.isEmpty() | |
fun fits(ship: Ship) = ship.start in this && ship.end in this | |
fun isOverlap(ship: Ship): Boolean { | |
return ships.any { ship in it } | |
} | |
operator fun contains(p: Point) = p.isValid() && p.col < width && p.row < height | |
} | |
// Extension function to update the ship (based on id) and return new list | |
fun List<Ship>.update(ship: Ship): List<Ship> { | |
var index = -1 | |
forEachIndexed { i, s -> | |
if (s.id == ship.id) { | |
index = i | |
return@forEachIndexed | |
} | |
} | |
if (index != -1) { | |
val mutable = ArrayList(this) | |
mutable.removeAt(index) | |
mutable.add(index, ship) | |
return mutable | |
} | |
return this | |
} | |
// Extension function to check if any of the ships are at the given point | |
fun List<Ship>.contains(p: Point): Boolean { | |
forEach { | |
if (p in it) { | |
return true | |
} | |
} | |
return false | |
} | |
// Extension function to check the direction of the ship at the given point | |
fun List<Ship>.getShipDirection(p: Point): Direction? { | |
forEach { | |
if (p in it) { | |
return it.direction | |
} | |
} | |
return null | |
} |
data class Cell( | |
val point: Point, | |
val hasShip: Boolean = false, | |
val direction: Direction? = null, | |
val userHit: Boolean = false, | |
val userMissed: Boolean = false, | |
val opponentHit: Boolean = false, | |
val opponentMissed: Boolean = false) | |
class SquareCell: View { | |
private var cell: Cell? = null | |
private val shipPaint: Paint | |
private val shipHitPaint: Paint | |
private val hitPaint: Paint | |
private val missedPaint: Paint | |
private val opponentHitPaint: Paint | |
private val opponentMissedPaint: Paint | |
private val borderPaint: Paint | |
private val rect: Rect = Rect() | |
constructor(context: Context) : this(context, null) | |
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) | |
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { | |
shipPaint = initPaint(R.color.cell_my_ship, cap = Paint.Cap.ROUND) | |
shipHitPaint = initPaint(R.color.cell_my_ship_hit) | |
hitPaint = initPaint(R.color.cell_ship_hit) | |
opponentMissedPaint = initPaint(R.color.cell_opponent_miss) | |
opponentHitPaint = initPaint(R.color.cell_ship_hit) | |
missedPaint = initPaint(R.color.cell_ship_miss) | |
borderPaint = initPaint(R.color.cell_border, Paint.Style.STROKE, 2f) | |
if (isInEditMode) { | |
hasShip = true | |
shipDirection = Direction.HORIZONTAL | |
} | |
} | |
private fun initPaint(@ColorRes color: Int, style: Paint.Style = Paint.Style.FILL, width: Float? = null, cap: Paint.Cap? = null): Paint { | |
return Paint(Paint.ANTI_ALIAS_FLAG).apply { | |
this.color = ContextCompat.getColor(context, color) | |
this.style = style | |
width?.let { this.strokeWidth = it } | |
cap?.let { this.strokeCap = it } | |
} | |
} | |
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { | |
super.onMeasure(widthMeasureSpec, heightMeasureSpec) | |
setMeasuredDimension(widthMeasureSpec, widthMeasureSpec) | |
} | |
override fun onDraw(canvas: Canvas) { | |
rect.left = 0 | |
rect.top = 0 | |
rect.bottom = canvas.height | |
rect.right = canvas.width | |
canvas.drawRect(rect, borderPaint) | |
cell?.apply { | |
if (hasShip && direction != null) { | |
when (direction) { | |
Direction.HORIZONTAL -> { | |
rect.bottom = canvas.height - 64 | |
rect.right = canvas.width | |
} | |
Direction.VERTICAL -> { | |
rect.bottom = canvas.height | |
rect.right = canvas.width - 64 | |
} | |
} | |
val paint = if (!opponentHit) shipPaint else shipHitPaint | |
canvas.drawRect(rect, paint) | |
} | |
if (userHit) { | |
canvas.drawCircle((canvas.width - 24).toFloat(), (canvas.height - 24).toFloat(), 16f, hitPaint) | |
} else if (userMissed) { | |
canvas.drawCircle((canvas.width - 24).toFloat(), (canvas.height - 24).toFloat(), 16f, missedPaint) | |
} | |
if (opponentHit) { | |
canvas.drawCircle(24f, 24f, 16f, opponentHitPaint) | |
} else if (opponentMissed) { | |
canvas.drawCircle(24f, 24f, 16f, opponentMissedPaint) | |
} | |
} | |
} | |
fun bind(cell: Cell) { | |
this.cell = cell | |
invalidate() | |
} | |
} | |
class UiCellAdapter(context: Context, private val onClick: ((Cell) -> Unit)): RecyclerView.Adapter<RecyclerView.ViewHolder>() { | |
val cells = ArrayList<Cell>() | |
private val inflater = LayoutInflater.from(context) | |
override fun getItemCount(): Int { | |
return cells.size | |
} | |
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { | |
return UiCellViewHolder(inflater.inflate(R.layout.square_cell_itemview, parent, false), onClick) | |
} | |
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { | |
holder as UiCellViewHolder | |
holder.bind(cells[position]) | |
} | |
override fun getItemId(position: Int): Long { | |
return cells[position].hashCode().toLong() | |
} | |
class UiCellViewHolder(itemView: View, onClick: (Cell) -> Unit) : RecyclerView.ViewHolder(itemView) { | |
private val view = itemView as SquareCell | |
private var cell: Cell? = null | |
init { | |
view.setOnClickListener { | |
cell?.let(onClick) | |
} | |
} | |
fun bind(cell: Cell) { | |
this.cell = cell | |
view.bind(cell) | |
} | |
} | |
} |
fun Board.reduceOffense(action: Action): Board { | |
return when(action) { | |
is GeneratedAction.MissedMove -> copy(misses = misses.plus(action.point)) | |
is GeneratedAction.DefinitiveAction -> copy(hits = hits.plus(action.point)) | |
else -> this | |
} | |
} | |
fun Board.reduceDefense(action: Action): Board { | |
return when(action) { | |
is GeneratedAction.MissedMove -> copy(opponentMisses = opponentMisses.plus(action.point)) | |
is GeneratedAction.DefinitiveAction -> { | |
val ship = ships.getById(action.ship.id) | |
if (ship != null) { | |
val updated = ship.reduce(action) | |
copy(opponentHits = opponentHits.plus(action.point), ships = ships.update(updated)) | |
} else { | |
this | |
} | |
} | |
else -> this | |
} | |
} | |
fun Board.reduceSetup(action: Action): Board { | |
return when(action) { | |
is AddShip -> copy(ships = ships.add(action.ship)) | |
else -> this | |
} | |
} | |
fun Ship.reduce(action: Action): Ship { | |
return when(action) { | |
is GeneratedAction.DefinitiveAction -> copy(hits = hits.plus(action.point)) | |
else -> this | |
} | |
} |
fun destroyMiddleware(state: GameState, action: Action, dispatch: Dispatch, next: Next<GameState>): Action { | |
if (action is GeneratedAction.DefinitiveAction.HitMove) { | |
if (action.ship.hits.size + 1 == action.ship.size) { | |
return next(state, GeneratedAction.DefinitiveAction.DestroyShip(action.offense, action.defense, action.point, action.ship), dispatch) | |
} | |
} | |
return next(state, action, dispatch) | |
} | |
fun lostMiddleware(state: GameState, action: Action, dispatch: Dispatch, next: Next<GameState>): Action { | |
if (action is GeneratedAction.DefinitiveAction.DestroyShip) { | |
if (state.boardById(action.defense).activeShips.size == 1) { | |
return next(state, GeneratedAction.DefinitiveAction.LostGame(action.offense, action.defense, action.point, action.ship), dispatch) | |
} | |
} | |
return next(state, action, dispatch) | |
} |
class GameActivity : AppCompatActivity() { | |
private lateinit var adapter: UiCellAdapter | |
private lateinit var gamestateInfo: TextView | |
private lateinit var handler: Handler | |
private lateinit var store: Gamestore | |
private lateinit var myBoard: Board | |
private lateinit var otherBoard: Board | |
private var unsubscribe: Unsubscribe? = null | |
private var dispatch: Dispatch? = null | |
private var gameOver: Boolean = false | |
private val width: Int = 10 | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
setContentView(R.layout.activity_game) | |
handler = Handler() | |
gamestateInfo = findViewById(R.id.gamestate_info) | |
val recyclerview = findViewById<RecyclerView>(R.id.recyclerview) | |
adapter = UiCellAdapter(this, { | |
if (gameOver) { | |
return@UiCellAdapter | |
} | |
dispatch?.invoke(Move(myBoard.id, otherBoard.id, it.point)) | |
}) | |
adapter.setHasStableIds(true) | |
recyclerview.layoutManager = GridLayoutManager(this, width) | |
recyclerview.adapter = adapter | |
adapter.notifyDataSetChanged() | |
myBoard = Board(0, User("Player 1"), width, width) | |
otherBoard = Board(1, User("Player 2"), width, width) | |
val initState = GameState( | |
board1 = myBoard, | |
board2 = otherBoard, | |
lastPlayed = otherBoard.id | |
) | |
store = Gamestore(initState) | |
setupGame() | |
} | |
override fun onResume() { | |
super.onResume() | |
unsubscribe = store.subscribe({ state, dispatch -> | |
this.dispatch = dispatch | |
myBoard = state.board1 | |
otherBoard = state.board2 | |
val cells = generateCells(myBoard) | |
adapter.cells.clear() | |
adapter.cells.addAll(cells) | |
adapter.notifyDataSetChanged() | |
gamestateInfo.text = if (state.lastPlayed == otherBoard.id) "Your turn" else "Player 2 turn" | |
gameOver = state.gameOver | |
if (state.gameOver) { | |
gamestateInfo.text = "Game over!" | |
} | |
if(state.lastPlayed == myBoard.id && !state.gameOver) { | |
handler.postDelayed({ | |
playOther() | |
}, 300) | |
} | |
}) | |
} | |
override fun onPause() { | |
super.onPause() | |
unsubscribe?.invoke() | |
} | |
private fun setupGame() { | |
for (i in 1..5) { | |
store.dispatch(AddShip(myBoard.id, Ship(i, i, Point(i, i), (if (i%2 == 0) Direction.HORIZONTAL else Direction.VERTICAL )))) | |
store.dispatch(AddShip(otherBoard.id, Ship(10 + i, i, Point(i, i), (if (i%2 == 0) Direction.HORIZONTAL else Direction.VERTICAL )))) | |
} | |
} | |
private fun playOther() { | |
val random = Random() | |
while(true) { | |
val point = Point(random.nextInt(width), random.nextInt(width)) | |
if (point !in otherBoard.hits && point !in otherBoard.misses) { | |
dispatch?.invoke(Move(otherBoard.id, myBoard.id, point)) | |
break | |
} | |
} | |
} | |
private fun generateCells(board: Board): ArrayList<Cell> { | |
val cells = ArrayList<Cell>(board.width * board.height) | |
for (i in 0 until board.height) { | |
for (j in 0 until board.width) { | |
val point = Point(j, i) | |
cells.add(Cell( | |
point = point, | |
hasShip = board.ships.contains(point), | |
direction = board.ships.getShipDirection(point), | |
userHit = board.hits.contains(point), | |
userMissed = board.misses.contains(point), | |
opponentMissed = board.opponentMisses.contains(point), | |
opponentHit = board.opponentHits.contains(point) | |
)) | |
} | |
} | |
return cells | |
} | |
} |
fun GameState.reduceSetup(action: Action): GameState { | |
return when(action) { | |
is AddShip -> reduceChildState(this, boardById(action.offense), action, Board::reduceSetup, { state, board -> state.updateBoard(board) }) | |
else -> this | |
} | |
} | |
fun GameState.reduceGameplay(action: Action): GameState { | |
return when(action) { | |
is GeneratedAction -> { | |
val offense = boardById(action.offense).reduceOffense(action) | |
val defense = boardById(action.defense).reduceDefense(action) | |
copy(board1 = whichBoard(board1, offense, defense), | |
board2 = whichBoard(board2, offense, defense), | |
gameOver = action is GeneratedAction.DefinitiveAction.LostGame) | |
} | |
is SwitchAction -> { | |
reduceGameplay(action.last).copy(lastPlayed = action.offense) | |
} | |
else -> this | |
} | |
} | |
fun<State, Child> reduceChildState( | |
state: State, | |
child: Child, | |
action: Action, | |
reducer: Reducer<Child>, | |
onReduced: (State, Child) -> State): State { | |
val reduced = reducer.invoke(child, action) | |
if (reduced === child) { | |
return state | |
} | |
return onReduced(state, reduced) | |
} | |
private fun whichBoard(board: Board, offense: Board, defense: Board): Board { | |
return when(board.id) { | |
offense.id -> offense | |
defense.id -> defense | |
else -> board | |
} | |
} |
fun gameSetupMiddleware(state: GameState, action: Action, dispatch: Dispatch, next: Next<GameState>): Action { | |
if (action is AddShip) { | |
val board = state.boardById(action.offense) | |
if (!board.fits(action.ship) || board.isOverlap(action.ship)) { | |
return next(state, AddShipInvalid(action.offense, action.ship), dispatch) | |
} | |
} | |
return next(state, action, dispatch) | |
} |
data class GameState( | |
val board1: Board, | |
val board2: Board, | |
val lastPlayed: Int, | |
val gameOver: Boolean = false) { | |
fun hasBoardById(id: Int): Boolean { | |
return when(id) { | |
board1.id, board2.id -> true | |
else -> false | |
} | |
} | |
fun boardById(id: Int): Board { | |
return when (id) { | |
board1.id -> board1 | |
board2.id -> board2 | |
else -> throw RuntimeException("No such board with id: $id") | |
} | |
} | |
fun updateBoard(board: Board): GameState { | |
return when(board.id) { | |
board1.id -> copy(board1 = board) | |
board2.id -> copy(board2 = board) | |
else -> this | |
} | |
} | |
} |
class Gamestore(initialSate: GameState): SimpleStore<GameState>( | |
initialState = initialSate, | |
middlewares = listOf( | |
::stateValidityMiddleware, | |
::gameSetupMiddleware, | |
::moveMiddleware, | |
::hitMiddleware, | |
::destroyMiddleware, | |
::lostMiddleware, | |
::switcherMiddleware | |
), | |
reducers = listOf( | |
GameState::reduceSetup, | |
GameState::reduceGameplay | |
) | |
) |
data class AddShip(val offense: Int, val ship: Ship): Action | |
data class AddShipInvalid(val offense: Int, val ship: Ship): Action | |
sealed class GeneratedAction(val offense: Int, val defense: Int, val point: Point): Action { | |
class InvalidMove(offense: Int, defense: Int, point: Point): GeneratedAction(offense, defense, point) | |
class PlayMove(offense: Int, defense: Int, point: Point): GeneratedAction(offense, defense, point) | |
class MissedMove(offense: Int, defense: Int, point: Point): GeneratedAction(offense, defense, point) | |
sealed class DefinitiveAction(offense: Int, defense: Int, point: Point, val ship: Ship): GeneratedAction(offense, defense, point) { | |
class HitMove(offense: Int, defense: Int, point: Point, ship: Ship) : DefinitiveAction(offense, defense, point, ship) | |
class DestroyShip(offense: Int, defense: Int, point: Point, ship: Ship) : DefinitiveAction(offense, defense, point, ship) | |
class LostGame(offense: Int, defense: Int, point: Point, ship: Ship) : DefinitiveAction(offense, defense, point, ship) | |
} | |
} | |
data class SwitchAction(val offense: Int, val defense: Int, val last: GeneratedAction): Action | |
object InvalidState: Action |
fun hitMiddleware(state: GameState, action: Action, dispatch: Dispatch, next: Next<GameState>): Action { | |
if (action is GeneratedAction.PlayMove) { | |
val defense = state.boardById(action.defense) | |
for (id in defense.activeShips) { | |
defense.ships.getById(id)?.let { | |
if (action.point in it) { | |
return next(state, GeneratedAction.DefinitiveAction.HitMove(action.offense, action.defense, action.point, it), dispatch) | |
} | |
} | |
} | |
return next(state, GeneratedAction.MissedMove(action.offense, action.defense, action.point), dispatch) | |
} | |
return next(state, action, dispatch) | |
} |
fun moveMiddleware(state: GameState, action: Action, dispatch: Dispatch, next: Next<GameState>): Action { | |
if (action is Move) { | |
val offense = state.boardById(action.offense) | |
if (offense.hits.contains(action.point) || offense.misses.contains(action.point)) { | |
return next(state, GeneratedAction.InvalidMove(action.offense, action.defense, action.point), dispatch) | |
} | |
return next(state, GeneratedAction.PlayMove(action.offense, action.defense, action.point), dispatch) | |
} | |
return next(state, action, dispatch) | |
} |
<?xml version="1.0" encoding="utf-8"?> | |
<com.fenchtose.battleship.ui.SquareCell | |
xmlns:android="http://schemas.android.com/apk/res/android" | |
android:orientation="vertical" | |
android:layout_width="match_parent" | |
android:layout_height="wrap_content"> | |
</com.fenchtose.battleship.ui.SquareCell> |
fun stateValidityMiddleware(state: GameState, action: Action, dispatch: Dispatch, next: Next<GameState>): Action { | |
when(action) { | |
is Move -> { | |
if (!state.hasBoardById(action.offense) || !state.hasBoardById(action.defense)) { | |
return InvalidState | |
} | |
} | |
is GeneratedAction -> { | |
if (!state.hasBoardById(action.offense) || !state.hasBoardById(action.defense)) { | |
return InvalidState | |
} | |
} | |
is AddShip -> { | |
if (!state.hasBoardById(action.offense)) { | |
return InvalidState | |
} | |
} | |
} | |
return next(state, action, dispatch) | |
} |
fun switcherMiddleware(state: GameState, action: Action, dispatch: Dispatch, next: Next<GameState>): Action { | |
if (action is GeneratedAction && action !is GeneratedAction.InvalidMove) { | |
return next(state, SwitchAction(action.offense, action.defense, action), dispatch) | |
} | |
return next(state, action, dispatch) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment