package uk.co.nickthecoder.gamescupboard.client
import AddBin
import AddObjects
import ChangeImage
import ChangePrivacy
import ChatMessage
import CommandError
import CommandOk
import CycleText
import DragObject
import FaceUpOrDown
import GameInfo
import HighlightMouse
import Message
import MoveObject
import NotInvited
import Packet
import PlayerJoined
import PlayerLeft
import RemoveObjects
import RenamePlayer
import ResetGame
import RunCommand
import ScaleObject
import ScoreSheetData
import com.soywiz.klock.milliseconds
import com.soywiz.korev.Key
import com.soywiz.korev.KeyEvent
import com.soywiz.korge.annotations.KorgeExperimental
import com.soywiz.korge.component.onStageResized
import com.soywiz.korge.input.keys
import com.soywiz.korge.service.storage.Storage
import com.soywiz.korge.tween.get
import com.soywiz.korge.tween.tween
import com.soywiz.korge.ui.uiButton
import com.soywiz.korge.view.*
import com.soywiz.korim.bitmap.Bitmap
import com.soywiz.korim.bitmap.BitmapSlice
import com.soywiz.korim.color.Colors
import com.soywiz.korim.color.RGBA
import com.soywiz.korim.format.readBitmap
import com.soywiz.korio.file.std.resourcesVfs
import io.ktor.websocket.*
import uk.co.nickthecoder.gamescupboard.client.view.*
import uk.co.nickthecoder.gamescupboard.common.*
import kotlin.collections.set
@OptIn(KorgeExperimental::class)
class GamesCupboardClient(val theStage: Stage, val session: WebSocketSession) {
/**
* [localPlayer] is only set once, and then never changes.
* This is initialised in the [GameInfo] message.
*/
private var lateInitLocalPlayer: Player? = null
val localPlayer: Player
get() = lateInitLocalPlayer!!
private val playersById = mutableMapOf<Int, Player>()
val otherPlayerNames: List<String>
get() = playersById.values.filter {
it.id != localPlayer.id && !it.isSpectator
}.map { it.name }
// Views
private val toolbar = Toolbar(theStage.width, toolbarHeight.toDouble())
val chatInput get() = toolbar.chatInput
private val backgroundArea = BackgroundArea(playingAreaWidth.toDouble(), playingAreaHeight.toDouble())
val playingArea = PlayingArea(playingAreaWidth.toDouble(), playingAreaHeight.toDouble())
private val foregroundArea = ForegroundArea(playingAreaWidth.toDouble(), playingAreaHeight.toDouble())
private val bitmapGridsByName = mutableMapOf<String, BitmapGrid>()
private val chatLog = ChatLog()
private var scoreSheet: ScoreSheet? = null
private var rules: Rules? = null
val commands = mutableListOf<CommandInfo>()
val dock = Dock(dockWidth, theStage.height - toolbar.height, listOf(chatLog))
private var commandPrototypes: List<CommandPrototype> = emptyList()
val helpArea: IHelpArea = HelpArea()
private val playerColors = mutableListOf<RGBA>()
val taunts = listOf("bye", "yes", "no", "go", "lol").sorted()
/**
* If non-zero, then messages should flip the X coordinate about the line x=mirrorX.
*/
var mirrorX: Int = 0
fun mirroredX(x: Double) = if (mirrorX == 0) x else mirrorX * 2 - x
fun mirroredX(x: Int) = if (mirrorX == 0) x else mirrorX * 2 - x
/**
* If non-zero, then messages should flip the Y coordinate about the line y=mirrorY.
*/
var mirrorY: Int = 0
fun mirroredY(y: Double) = if (mirrorY == 0) y else mirrorY * 2 - y
fun mirroredY(y: Int) = if (mirrorY == 0) y else mirrorY * 2 - y
init {
with(theStage) {
addChild(backgroundArea)
addChild(playingArea)
addChild(foregroundArea)
addChild(helpArea as HelpArea)
addChild(toolbar)
// The ClipContainer doesn't work! RoundRect are still rendered outside the clip region.
// (Text and SolidRect are clipped).
// Grr. So add solid rectangles to make it *look* like it works!
roundRect(500, playingAreaHeight + 10, 0, 0, outsideColor).apply {
alignLeftToRightOf(playingArea)
y = toolbar.height
}
addChild(dock)
onStageResized { _, _ -> stageResized() }
(this as View).keys.down { onKeyDown(it) }
}
with(toolbar) {
resize()
}
with(playingArea) {
alignTopToBottomOf(toolbar)
}
with(backgroundArea) {
alignTopToBottomOf(toolbar)
}
with(foregroundArea) {
alignTopToBottomOf(toolbar)
}
with(dock) {
x = theStage.actualVirtualRight - dock.width
y = toolbar.height
}
}
private fun onKeyDown(e: KeyEvent) {
if (e.key == Key.ESCAPE) {
toolbar.chatInput.textInput.text = ""
toolbar.chatInput.textInput.focus()
playingArea.cancelDrag()
helpArea.clear()
}
}
fun findPlayer(id: Int) = playersById[id]
fun findPlayer(name: String): Player? {
val lower = name.lowercase()
playersById.values.firstOrNull { it.name.lowercase() == lower }?.let { return it }
// Allow a player to be referenced using "#N" where N is their playerId.
if (name.startsWith("#")) {
name.substring(1).toIntOrNull()?.let {
return playersById[it]
}
}
return null
}
private fun stageResized() {
toolbar.resize()
dock.x = theStage.actualVirtualRight - dock.width
}
suspend fun listen() {
println("Listen")
try {
for (frame in session.incoming) {
// We are ignoring Frame types : Binary, Ping, Pong, Close
if (frame is Frame.Text) {
val frameText = frame.readText()
val packet = json.decodeOrNull<Packet>(frameText) ?: continue
if (packet.m is NotInvited) {
chatLog.out.println("Incorrect invitation code.")
chatLog.show()
return
}
processMessage(packet.m)
}
}
println("Listen ending normally")
} catch (e: Throwable) {
e.printStackTrace()
println("Error while receiving messages: $e")
}
println("End listen")
}
suspend fun processMessage(message: Message) {
when (message) {
is GameInfo -> gameInfo(message)
is ResetGame -> resetGame(message)
is PlayerJoined -> playerJoined(message.p)
is PlayerLeft -> playerLeft(message.id)
is RenamePlayer -> renamePlayer(message)
is AddObjects -> newGameObject(message)
is RemoveObjects -> removeObject(message)
is MoveObject -> moveObject(message)
is DragObject -> dragObject(message)
is ChangePrivacy -> changePrivacy(message)
is FaceUpOrDown -> faceUpOrDown(message)
is ScaleObject -> scaleObject(message)
is CycleText -> cycleText(message)
is ChangeImage -> changeImage(message)
is CommandOk -> commandOk()
is CommandError -> commandError(message)
is ChatMessage -> chatMessage(message)
is ScoreSheetData -> scoreSheet?.update(message.scores)
is AddBin -> addBin()
is HighlightMouse -> highlightMouse(message)
else -> Unit // Do nothing.
}
}
private suspend fun gameInfo(message: GameInfo) {
playingArea.begin(message.p.isSpectator)
chatLog.out.print("Welcome ")
chatLog.out.println(message.p.name, Colors[message.p.color])
println("PlayerId=${message.p.id} Game : ${message.gameTypeLabel} (${message.variationLabel}) ${message.gameLabel}")
theStage.gameWindow.title = "${message.variationLabel} - ${message.gameLabel}"
commands.addAll(message.commands)
playerColors.addAll(message.playerColors.map { Colors[it] })
mirrorX = message.mirrorX
mirrorY = message.mirrorY
lateInitLocalPlayer = message.p
playersById[message.p.id] = message.p
if (message.otherPlayers.isNotEmpty()) {
chatLog.out.println("Players present :")
for (player in message.otherPlayers) {
chatLog.out.println(" #${player.id} ${player.name}", Colors[player.color])
playersById[player.id] = player
}
}
// Show the chat log for spectators. This is handy if the player *tried* to join as a player,
// but the game filled up before they hit the join button.
if (isSpectator()) {
chatLog.show()
}
for (area in message.specialAreas) {
playingArea.specialAreas.add(area)
}
for (point in message.specialPoints) {
playingArea.specialPoints.add(point)
}
for (grid in message.grids) {
val bitmap = resourcesVfs[grid.path].readBitmap()
bitmapGridsByName[grid.name] = BitmapGrid(bitmap, grid)
}
if (message.bgColor.isNotBlank()) {
backgroundArea.bgColor = Colors[message.bgColor]
}
// Add all the game objects
for (go in message.backgroundObjects) {
createGameObjectView(go, backgroundArea)
}
for (go in message.foregroundObjects) {
createGameObjectView(go, foregroundArea)
}
for (go in message.playingObjects) {
createGameObjectView(go, playingArea)
}
commandPrototypes = message.commandPrototypes
for (prototype in message.commandPrototypes.reversed()) {
toolbar.addCommandPrototype(prototype)
for (objectName in prototype.objectNames) {
playingArea.findGameObjectByName(objectName)?.let {
it.commandPrototype = prototype
}
}
}
for (button in message.buttons) {
backgroundArea.uiButton(button.label).apply {
x = button.x.toDouble() - width / 2
y = button.y.toDouble() - height / 2
onPress.add {
button.commandPrototype.run()
}
}
}
// If we've renamed ourselves in a previous game, let's use that name again.
// Do not rename automatically if this "special" key exists.
// This is so that I can test the experience of a NEW player.
if (Storage(theStage.views).getOrNull("FORGET") == null) {
Storage(theStage.views).getOrNull("playerName")?.let { newName ->
RunCommand("name", listOf(newName)).send()
}
}
scoreSheet = ScoreSheet(message.seatCount, message.scoreSheetHasBidColumn).apply {
dock.add(this)
}
if (message.rules.isNotBlank()) {
rules = Rules(message.rules).also { dock.add(it) }
}
playingArea.updatePrivatePieceCounts()
try {
foregroundArea.addMouseHighlights(
message.playerColors,
resourcesVfs["mouseHighlight.png"].readBitmap()
)
} catch (e: Exception) {
println("Failed to load mouseHighlight image")
}
}
private suspend fun resetGame(message: ResetGame) {
for (id in message.removeIds) {
playingArea.findGameObject(id)?.let {
removeObject(it.id)
}
}
for (go in message.gameObjects) {
createGameObjectView(go, playingArea)
}
playingArea.avatarsToTop()
// We need to re-associate the command prototypes with the named objects.
for (prototype in commandPrototypes) {
for (objectName in prototype.objectNames) {
playingArea.findGameObjectByName(objectName)?.let {
it.commandPrototype = prototype
}
}
}
playingArea.updatePrivatePieceCounts()
}
private fun playerJoined(player: Player) {
if (player.isSpectator) {
chatLog.out.print("Spectator Joined : ")
} else {
chatLog.out.print("Player Joined : ")
}
chatLog.out.println(player.name, Colors[player.color])
playersById[player.id] = player
playSound(PLAYER_JOINED)
if (!player.isSpectator) {
scoreSheet?.renamePlayer(player.id, player.name)
playingArea.updatePrivatePieceCounts()
}
}
private fun playerLeft(id: Int) {
val player = playersById.remove(id)
if (player != null) {
if (player.isSpectator) {
chatLog.out.print("Spectator Left : ")
} else {
chatLog.out.print("Player Left : ")
}
chatLog.out.println(player.name, Colors[player.color])
playSound(PLAYER_LEFT)
if (!player.isSpectator) {
foregroundArea.hideMouseHighlight(id)
}
}
}
private fun renamePlayer(message: RenamePlayer) {
val player = playersById[message.id] ?: return
player.name = message.name
playingArea.findAvatar(player.id)?.playerName = message.name
with(chatLog.out) {
if (player.isSpectator) {
print("Spectator#${player.id}", Colors[player.color])
} else {
print("Player#${player.id}", Colors[player.color])
}
print(" renamed to ")
println(player.name, Colors[player.color])
}
if (!player.isSpectator) {
scoreSheet?.renamePlayer(message.id, message.name)
}
// Save your name, so that we can automatically use it when you start another game.
if (message.id == localPlayer.id) {
Storage(theStage.views)["playerName"] = message.name
}
}
private suspend fun newGameObject(message: AddObjects) {
for (go in message.gameObjects) {
createGameObjectView(go, playingArea)
}
}
private val bitmapCache = mutableMapOf<String, Bitmap>()
private suspend fun loadAndCacheBitmap(path: String): Bitmap {
val result = resourcesVfs[path].readBitmap()
bitmapCache[path] = result
return result
}
private suspend fun loadBitmap(path: String): Bitmap {
return bitmapCache[path] ?: loadAndCacheBitmap(path)
}
private suspend fun createGameObjectView(gameObject: GameObject, to: Container): GameObjectView? {
val view = when (gameObject) {
is TextObject -> TextObjectView(
gameObject.id,
gameObject.text,
gameObject.style,
cyclicText = gameObject.cyclicText
).apply {
if (cyclicIndex != 0) {
cyclicIndex = gameObject.cyclicIndex
}
}
is PieceAtPointCounter -> {
val tov = TextObjectView(
gameObject.id,
"0",
TextStyle.PLAIN
)
playingArea.pieceAtPointCountersByName[gameObject.specialPointName] = tov
tov
}
is ImageObject -> ImageObjectView(
gameObject.id,
loadBitmap(gameObject.path)
).apply {
if (gameObject.isScalable) {
isScalable = true
scaleTo(gameObject.scale)
}
mirrorX = gameObject.mirrorX
mirrorY = gameObject.mirrorY
}
is MultipleGridImageObject -> {
val grid = bitmapGridsByName[gameObject.grid.name]
if (grid == null) {
null
} else {
val images = mutableListOf<BitmapSlice<Bitmap>>()
for (y in gameObject.fromGY..gameObject.toGY) {
for (x in gameObject.fromGX..gameObject.toGX) {
images.add(grid.slice(x, y))
}
}
MultipleImageView(gameObject.id, images)
}
}
is FlippableImageObject -> bitmapGridsByName[gameObject.grid.name]?.let { grid ->
FlippableImageObjectView(
gameObject.id,
grid.slice(gameObject.gx, gameObject.gy),
grid.slice(gameObject.altX, gameObject.altY),
gameObject.isFaceUp
).apply {
if (gameObject.privateToPlayerId == localPlayer.id) {
isFaceUp = true
}
}
}
is GridImageObject -> bitmapGridsByName[gameObject.grid.name]?.let { grid ->
GridImageObjectView(
gameObject.id,
grid.slice(gameObject.gx, gameObject.gy)
)
}
is Avatar -> findPlayer(gameObject.playerId)?.let { player ->
var side = gameObject.side
if (mirrorX != 0 && (side == 1 || side == 3)) side = 4 - side
if (mirrorY != 0 && (side == 0 || side == 2)) side = 2 - side
AvatarView(
gameObject.id,
player,
Colors[player.color],
side
)
}
} ?: return null
view.privateToPlayerId = gameObject.privateToPlayerId
if (gameObject is ImageObject && view is ImageObjectView) {
view.scaleTo(gameObject.scale)
}
if (gameObject.name.isNotBlank()) {
view.objectName = gameObject.name
}
view.x = mirroredX(gameObject.x.toDouble())
view.y = mirroredY(gameObject.y).toDouble()
view.isDraggable = gameObject.draggable
view.visible = gameObject.privateToPlayerId == null || gameObject.privateToPlayerId == localPlayer.id
if (to === playingArea) {
playingArea.add(view)
playingArea.findSpecialPoint(view.x, view.y)?.let {
it.pieceCount++
playingArea.updatePointCounter(it)
}
} else {
to.addChild(view)
}
if (view is AvatarView) {
playingArea.updatePrivatePieceCounts()
}
return view
}
private fun removeObject(id: Int) {
playingArea.remove(id)?.let { gov ->
playingArea.findSpecialPoint(mirroredX(gov.x), mirroredY(gov.y))?.let {
it.pieceCount --
playingArea.updatePointCounter(it)
}
}
}
private fun removeObject(message: RemoveObjects) {
for (id in message.ids) {
removeObject(id)
}
}
private fun moveObject(message: MoveObject) {
playingArea.findGameObject(message.id)?.let { gov ->
playingArea.cancelDragOnConflict(message.id)
val flippedMessageX = mirroredX(message.x)
val flippedGovX = mirroredX(gov.x)
val flippedMessageY = mirroredY(message.y)
val flippedGovY = mirroredY(gov.y)
playingArea.changeZOrder(gov, message.z)
gov.alpha = 1.0
gov.isDragging = false
if (flippedGovX != message.x.toDouble() || flippedGovY != message.y.toDouble()) {
playingArea.findSpecialPoint(flippedGovX, flippedGovY)?.let { specialPoint ->
specialPoint.pieceCount --
playingArea.updatePointCounter(specialPoint)
}
// When a piece is moved to our private area by the "deal" or "take" commands, then we need to flip
// it face up locally. The server may still consider it being face down.
playingArea.findSpecialArea(flippedGovX, flippedGovY)?.let { specialArea ->
if (specialArea.areaType == AreaType.PRIVATE && gov.privateToPlayerId == localPlayerId() && gov is FlippableImageObjectView) {
gov.isFaceUp = true
}
}
// NOTE, we need to do the tween in the KorGR coroutine context,
// not the websocket's context. (therefore we need to use launchTween).
gov.launchTween(
gov::x[flippedMessageX.toDouble()],
gov::y[flippedMessageY.toDouble()],
time = moveMessageInterval.milliseconds
)
playingArea.findSpecialPoint(message.x.toDouble(), message.y.toDouble())?.let { specialPoint ->
specialPoint.pieceCount ++
playingArea.updatePointCounter(specialPoint)
}
}
}
}
private suspend fun dragObject(message: DragObject) {
playingArea.findGameObject(message.id)?.let { gov ->
playingArea.cancelDragOnConflict(message.id)
if (message.forceFaceDown && gov is FlippableImageObjectView) {
gov.isFaceUp = false
}
val flippedMessageX = mirroredX(message.x)
val flippedMessageY = mirroredY(message.y)
val flippedGovX = mirroredX(gov.x)
val flippedGovY = mirroredY(gov.y)
playingArea.findSpecialPoint(flippedGovX, flippedGovY)?.let {
it.pieceCount --
playingArea.updatePointCounter(it)
}
gov.alpha = 0.7
gov.tween(
gov::x[flippedMessageX.toDouble()],
gov::y[flippedMessageY.toDouble()],
time = moveMessageInterval.milliseconds
)
gov.isDragging = true
playingArea.findSpecialPoint(message.x.toDouble(), message.y.toDouble())?.let {
it.pieceCount++
playingArea.updatePointCounter(it)
}
}
}
private fun changePrivacy(message: ChangePrivacy) {
playingArea.findGameObject(message.id)?.let { gov ->
gov.privateToPlayerId?.let {
playingArea.findAvatar(it)?.let {
it.privatePieceCount--
}
}
gov.privateToPlayerId = message.privateToPlayerId
gov.visible = message.privateToPlayerId == null || message.privateToPlayerId == localPlayer.id
gov.privateToPlayerId?.let {
playingArea.findAvatar(it)?.let {
it.privatePieceCount++
}
}
}
}
private fun faceUpOrDown(message: FaceUpOrDown) {
playingArea.findGameObject(message.id)?.let { gov ->
if (gov is FlippableImageObjectView) {
gov.isFaceUp = message.isFaceUp
}
}
}
private fun scaleObject(message: ScaleObject) {
playingArea.findGameObject(message.id)?.let { gov ->
if (gov is ImageObjectView) {
gov.scaleTo(message.scale)
}
}
}
private fun cycleText(message: CycleText) {
playingArea.findGameObject(message.id)?.let { gov ->
if (gov is TextObjectView) {
gov.cyclicIndex = message.cyclicIndex
}
}
}
private fun changeImage(message: ChangeImage) {
playingArea.findGameObject(message.id)?.let { gov ->
if (gov is MultipleImageView) {
gov.imageNumber = message.imageNumber
gov.highlight()
}
}
}
private fun chatMessage(message: ChatMessage) {
chatLog.addMessage(message)
val fromAvatar = playingArea.findAvatar(message.fromId)
val toAvatar = if (message.toId == null) null else playingArea.findAvatar(message.toId!!)
fromAvatar?.speak(message.str, toAvatar)
if (message.str.startsWith("!")) {
playTaunt(message.str.substring(1))
}
}
private fun commandOk() {
chatInput.textInput.text = ""
}
private fun commandError(message: CommandError) {
chatLog.out.println(message.message)
val fromAvatar = playingArea.findAvatar(localPlayer.id)
fromAvatar?.speak(message.message)
}
private suspend fun addBin() {
val gov = BinView(-1000, resourcesVfs["bin.png"].readBitmap()).apply {
x = playingAreaWidth / 2.0
y = playingAreaHeight / 2.0
isDraggable = true
}
playingArea.addBin(gov)
}
private fun highlightMouse(message: HighlightMouse) {
when (message.state) {
true -> foregroundArea.showMouseHighlight(message.playerId, mirroredX(message.x), mirroredY(message.y))
false -> foregroundArea.hideMouseHighlight(message.playerId)
else -> foregroundArea.moveMouseHighlight(message.playerId, mirroredX(message.x), mirroredY(message.y))
}
}
fun playerColor(id: Int) = playerColors[id % playerColors.size]
}