Exit Full View

Games Cupboard / gamescupboard-client / src / commonMain / kotlin / uk / co / nickthecoder / gamescupboard / client / GamesCupboardClient.kt

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]
}