Exit Full View

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

package uk.co.nickthecoder.gamescupboard.client

import ChangePrivacy
import DragObject
import FaceUpOrDown
import HighlightMouse
import ChangeZOrder
import RemoveObjects
import ScaleObject
import com.soywiz.klock.DateTime
import com.soywiz.kmem.clamp
import com.soywiz.korev.Key
import com.soywiz.korev.KeyEvent
import com.soywiz.korev.MouseEvent
import com.soywiz.korge.annotations.KorgeExperimental
import com.soywiz.korge.input.MouseEvents
import com.soywiz.korge.input.doubleClick
import com.soywiz.korge.input.keys
import com.soywiz.korge.input.mouse
import com.soywiz.korge.ui.uiFocusManager
import com.soywiz.korge.view.ClipContainer
import com.soywiz.korge.view.addTo
import com.soywiz.korim.color.Colors
import com.soywiz.korma.geom.Point
import uk.co.nickthecoder.gamescupboard.client.text.TextInputController
import uk.co.nickthecoder.gamescupboard.client.view.*
import uk.co.nickthecoder.gamescupboard.common.*
import kotlin.collections.set
import kotlin.math.abs

data class DragData(
    val startX: Double,
    val startY: Double,
    val gameObjectView: GameObjectView,
    val offsetX: Double,
    val offsetY: Double,
    /**
     * When dragging out of a [SpecialArea], was the special area public?
     * For flippable objects only.
     */
    val fromArea: SpecialArea? = null,
    val fromPoint: SpecialPoint? = null
) {
    var hasMoved = false
}

const val moveMessageInterval = 100L

const val highlightPeriod = 250L

const val highlightScale = 1.15


class PlayingArea(
    width: Double,
    height: Double

) : ClipContainer(width, height) {

    private val gameObjects = mutableListOf<GameObjectView>()
    private val gameObjectsById = mutableMapOf<Int, GameObjectView>()
    private val gameObjectsByName = mutableMapOf<String, GameObjectView>()

    private val avatarsByPlayerId = mutableMapOf<Int, AvatarView>()

    private var dragData: DragData? = null

    private var hoveredOver: GameObjectView? = null

    val specialAreas = mutableListOf<SpecialArea>()

    val specialPoints = mutableListOf<SpecialPoint>()

    /**
     * The [AddBin] Command set this. When a [GameObjectView] is dragged over the bin,
     * the object is deleted.
     *
     * [RegenGameObject]s cannot be placed in the bin, as this would break the game.
     * (There would be no more tiles/pieces/cards).
     */
    private var bin: BinView? = null

    /**
     * Keyed on the name of the [SpecialPoint].
     */
    val pieceAtPointCountersByName = mutableMapOf<String, TextObjectView>()

    /**
     * Are we currently showing our mouse position to other players?
     */
    private var isHighlightingMouse: Boolean = false

    /**
     * Called from the GameInfo message.
     */
    fun begin(isSpectator: Boolean) {
        // Don't allow spectator to move objects!
        if (!isSpectator) {
            with(mouse) {
                down { onMouseDown(it) }
                doubleClick { onDoubleClick(it) }
                upAnywhere { onMouseUp(it) }
                moveAnywhere { onMouseMove(it) }
            }
            keys.down { onKeyDown(it) }
            keys.up { onKeyUp(it) }
        }
    }

    fun addBin(gameObjectView: BinView) {
        add(gameObjectView)
        bin = gameObjectView
    }

    fun add(gameObject: GameObjectView) {
        gameObject.addTo(this)
        gameObjectsById[gameObject.id] = gameObject
        gameObjects.add(gameObject)
        if (gameObject is AvatarView) {
            avatarsByPlayerId[gameObject.player.id] = gameObject
        }
        if (gameObject.objectName.isNotBlank()) {
            gameObjectsByName[gameObject.objectName] = gameObject
        }
    }

    fun remove(gameObjectId: Int): GameObjectView? {
        val gov = gameObjectsById.remove(gameObjectId) ?: return null
        gameObjects.remove(gov)
        gameObjectsByName.remove(gov.objectName)

        removeChild(gov)

        if (gov is AvatarView) {
            avatarsByPlayerId.remove(gov.player.id)
        }

        return gov
    }

    /**
     * Move the avatars to the top of the z-order
     */
    fun avatarsToTop() {
        for (a in avatarsByPlayerId.values) {
            a.removeFromParent()
            add(a)
        }
    }


    fun findGameObject(id: Int) = gameObjectsById[id]

    fun findGameObjectByName(name: String) = gameObjectsByName[name]

    fun findAvatar(playerId: Int) = avatarsByPlayerId[playerId]

    private fun findClickable(point: Point): GameObjectView? {
        for (i in gameObjects.size - 1 downTo 0) {
            val gov: GameObjectView = gameObjects[i]
            if (! gov.isDragging &&
                (gov.isDraggable || gov.isActionable) &&
                (gov.privateToPlayerId == null || gov.privateToPlayerId == localPlayerId()) &&
                gov.isTouching(point)
            ) {
                return gov
            }
        }
        return null
    }

    fun findSpecialArea(x: Double, y: Double): SpecialArea? {
        for (area in specialAreas) {
            if (area.contains(x.toInt(), y.toInt())) {
                return area
            }
        }
        return null
    }

    fun findSpecialPoint(x: Double, y: Double): SpecialPoint? {
        for (specialPoint in specialPoints) {
            if (specialPoint.contains(x.toInt(), y.toInt())) {
                return specialPoint
            }
        }
        return null
    }

    fun updatePrivatePieceCounts() {
        for (avatar in avatarsByPlayerId.values) {
            avatar.privatePieceCount = 0
        }

        // Update the private piece count for each player
        for (area in specialAreas) {
            if (area.areaType == AreaType.PRIVATE) {
                for (piece in gameObjects) {
                    piece.privateToPlayerId?.let { playerId ->
                        findAvatar(playerId)?.let {
                            it.privatePieceCount++
                        }
                    }
                }
            }
        }
    }

    fun updatePointCounter(specialPoint: SpecialPoint) {
        pieceAtPointCountersByName[specialPoint.name]?.let {
            it.text = specialPoint.pieceCount.toString()
        }
    }

    private fun snapToSpecialPoints(gov: GameObjectView) {
        findSpecialPoint(gov.x, gov.y)?.let {
            gov.x = it.x.toDouble()
            gov.y = it.y.toDouble()
        }
    }

    private fun snapToGrid(gov: GameObjectView, area: SpecialArea?) {
        if (area == null) return
        area.snap?.let { snap ->
            val snapped = snap.snap((gov.x - area.x).toInt(), (gov.y - area.y).toInt())
            gov.x = area.x.toDouble() + snapped.first
            gov.y = area.y.toDouble() + snapped.second
        }
    }

    /**
     * True, for either CONTROL key, unless the focus is in a text field
     * (in which case the CONTROL key does NOT trigger the mouse highlighting).
     * This is so that Ctrl+A etc. in a text field does not highlight your mouse to other players.
     */
    private fun isHighlightKey(e: KeyEvent): Boolean {
        @OptIn(KorgeExperimental::class)
        return (e.key == Key.LEFT_CONTROL || e.key == Key.RIGHT_CONTROL) &&
                stage?.uiFocusManager?.uiFocusedView !is TextInputController
    }

    private fun onKeyDown(e: KeyEvent) {
        dragData?.gameObjectView?.let { gov ->
            if (gov is ImageObjectView && gov.isScalable) {
                if (e.key == Key.MINUS) {
                    ScaleObject(gov.id, gov.scaleBy(1 / 1.125)).send()
                }
                if (e.key == Key.PLUS || e.key == Key.EQUAL) {
                    ScaleObject(gov.id, gov.scaleBy(1.125)).send()
                }
            }
        }
        if (!isHighlightingMouse && isHighlightKey(e)) {
            isHighlightingMouse = true
            val point = localMouseXY(gamesCupboardClient.theStage.views)
            HighlightMouse(
                localPlayerId(),
                gamesCupboardClient.mirroredX(point.x.toInt()),
                gamesCupboardClient.mirroredY(point.y.toInt()),
                true
            ).localAndSend()
        }
    }

    private fun onKeyUp(e: KeyEvent) {
        if (isHighlightingMouse && isHighlightKey(e)) {
            isHighlightingMouse = false
            HighlightMouse(localPlayerId(), 0, 0, false).localAndSend()
        }
    }

    /**
     * Turn over cards when they are double-clicked.
     */
    private fun onDoubleClick(e: MouseEvents) {
        val point = e.currentPosLocal
        if (e.button.isLeft) {
            findClickable(point)?.let { gov ->
                gov.onDoubleClick(this)
            }
        }
    }

    private fun onMouseDown(e: MouseEvents) {
        val point = e.currentPosLocal

        // NOTE. I need to check for Type.DRAG, because when I move my mouse, and press the left button
        // (without stopping), then e = Mouse down MouseEvent(type=DRAG button=NONE)
        if (e.button.isLeft || e.currentEvent?.type == MouseEvent.Type.DRAG) {
            findClickable(point)?.let { gov ->
                if (gov is ImageObjectView && gov.isScalable) {
                    with(gamesCupboardClient.helpArea) {
                        clear()
                        addLabel("Press PLUS or MINUS while dragging to change the size")
                        addLabel("Use command :bin for a waste bin to tidy up decorations")
                        show()
                    }
                }
                lastOnMouseMove = 0L
                dragData = DragData(
                    gov.x,
                    gov.y,
                    gov,
                    point.x - gov.x,
                    point.y - gov.y,
                    findSpecialArea(gamesCupboardClient.mirroredX(gov.x), gamesCupboardClient.mirroredY(gov.y)),
                    findSpecialPoint(gamesCupboardClient.mirroredX(gov.x), gamesCupboardClient.mirroredY(gov.y))
                )
            }
        }
    }

    /**
     * If "now" <= [lastOnMouseMove] + [moveMessageInterval], then we will NOT
     * send a move event.
     */
    private var lastOnMouseMove: Long = 0

    private fun onMouseMove(e: MouseEvents) {
        val point = e.currentPosLocal

        if (isHighlightingMouse) {
            HighlightMouse(
                localPlayerId(),
                gamesCupboardClient.mirroredX(point.x.toInt()),
                gamesCupboardClient.mirroredY(point.y.toInt()),
                null
            ).localAndSend()
        }

        val dragData = dragData

        if (dragData == null) {
            // Hover over animation...
            val gov = findClickable(point)
            if (gov != hoveredOver) {
                hoveredOver = gov

                gov?.highlight()
            }
        } else {
            // Dragging...
            val gov = dragData.gameObjectView

            val dx = abs(gov.x - point.x)
            val dy = abs(gov.y - point.y)
            if (dragData.hasMoved || dx > 5.0 || dy > 5.0) {
                dragData.hasMoved = true

                val newArea =
                    findSpecialArea(gamesCupboardClient.mirroredX(point.x), gamesCupboardClient.mirroredY(point.y))

                gov.x = (point.x - dragData.offsetX).clamp(0.0, width)
                gov.y = (point.y - dragData.offsetY).clamp(0.0, height)
                snapToSpecialPoints(gov)
                snapToGrid(gov, newArea)

                // See GameObjectView.privateToPlayerId for details of this logic.
                if (gov.privateToPlayerId == null && newArea.isPrivate()) {
                    ChangePrivacy(gov.id, localPlayerId()).localAndSend()
                } else if (gov.privateToPlayerId != null && newArea.isPublicOrNull()) {
                    // The card is no longer private, other can see it
                    // (but it should be face down if it is Flippable).
                    ChangePrivacy(gov.id, null).localAndSend()
                }
                // Will the card be revealed to other players if we drop it now?
                if (gov is FlippableImageObjectView && gov.isFaceUp && dragData.fromArea.isPrivate() && newArea.isPublic() && newArea?.warnUsingTint == true) {
                    // Note, we aren't changing state on the server, or other clients.
                    gov.tint = warnCardWillBeVisible
                } else {
                    gov.tint = Colors.WHITE
                }

                bin?.tint = if (bin?.isTouching(e.lastPosLocal) == true) {
                    binHighlight
                } else {
                    Colors.WHITE
                }

                // Limit the number of DragObject messages being sent to the server (and forwarded to other clients)
                // This will cause movements to appear jerky though, but this is somewhat counteracted by the
                // tweening on the clients.
                val now = DateTime.now().unixMillisLong
                if (now > lastOnMouseMove + moveMessageInterval) {
                    val forceFaceDown =
                        dragData.fromArea?.areaType == AreaType.PRIVATE && newArea?.areaType != AreaType.PRIVATE
                    DragObject(
                        gov.id,
                        gamesCupboardClient.mirroredX(gov.x),
                        gamesCupboardClient.mirroredY(gov.y),
                        forceFaceDown
                    ).send()
                    lastOnMouseMove = now
                }

            }
        }
    }

    private fun onMouseUp(e: MouseEvents) {

        dragData?.let { dragData ->
            // Remove help text added by onMouseDown.
            gamesCupboardClient.helpArea.clear()

            if (dragData.hasMoved) {
                val gov = dragData.gameObjectView

                // Remove the red tint which may have been added during the onMouseMove.
                gov.tint = Colors.WHITE
                // I saw a bug, where a card remained scaled (due to the hover over animation).
                // This may fix it! It's tricky to reproduce though.
                gov.scale = gov.defaultScale()

                val newSpecialArea = findSpecialArea(
                    gamesCupboardClient.mirroredX(gov.posOpt.x),
                    gamesCupboardClient.mirroredY(gov.posOpt.y)
                )
                val newSpecialPoint =
                    findSpecialPoint(gamesCupboardClient.mirroredX(gov.x), gamesCupboardClient.mirroredY(gov.y))

                // See GameObjectView.privateToPlayerId for details on what and why we are doing...
                if (gov is FlippableImageObjectView) {
                    if (newSpecialArea.isPrivate()) {
                        // NOTE, this is only face up on THIS client.
                        // The server and other clients still think of it as face down.
                        gov.isFaceUp = true
                    }
                    if (dragData.fromArea.isPrivate() && newSpecialArea.isPublic() && gov.isFaceUp) {
                        FaceUpOrDown(gov.id, gov.isFaceUp).send()
                    }
                    if (dragData.fromArea.isPrivate() && newSpecialArea == null) {
                        gov.isFaceUp = false
                    }

                    newSpecialPoint?.isFaceUp?.let { isFaceUp ->
                        if (gov.isFaceUp != isFaceUp) {
                            gov.isFaceUp = isFaceUp
                            FaceUpOrDown(gov.id, gov.isFaceUp).send()
                        }
                    }
                }

                // Update counters if we are moving from or to a SpecialPoint
                dragData.fromPoint?.let {
                    it.pieceCount --
                    updatePointCounter(it)
                }
                newSpecialPoint?.let {
                    it.pieceCount ++
                    updatePointCounter(it)
                }

                val pointZChange = newSpecialPoint?.changeZOrder ?: ChangeZOrder.NO_CHANGE
                if (pointZChange != ChangeZOrder.NO_CHANGE) {
                    changeZOrder(gov, pointZChange)
                    gov.moveMessage(pointZChange).send()
                } else {
                    val areaZChange = newSpecialArea?.changeZOrder ?: ChangeZOrder.NO_CHANGE
                    if (areaZChange != ChangeZOrder.NO_CHANGE) {
                        changeZOrder(gov, areaZChange)
                        gov.moveMessage(areaZChange).send()
                    }
                }

                bin?.let { bin ->
                    bin.tint = Colors.WHITE
                    if (gov !== bin && bin.isTouching(e.lastPosLocal)) {

                        // Don't allow regen objects to be deleted!
                        if (gov !is RegenGameObject || ! gov.regen) {
                            RemoveObjects(listOf(gov.id), true).localAndSend()
                        }
                    }
                }

            }
        }
        dragData = null
    }

    /**
     * Called when another client sends a MoveObject or DragObject message.
     * If this client is dragging the same object, then it means we were both dragging it, and
     * we came second, so we should stop dragging, and let the other client move the object.
     * NOTE, we do NOT send a message to the server nor update the object's position,
     * because the message from the client who beat us to the punch will inform
     * the server all clients (including us) of the "real" location of this object.
     */
    fun cancelDragOnConflict(id: Int) {
        dragData?.let {
            if (it.gameObjectView.id == id) {
                it.gameObjectView.tint = Colors.WHITE
                dragData = null
                playSound(CANCEL_DRAG)
            }
        }
    }

    /**
     * Cancel the drag, not because some other client is also dragging the object, but
     * because WE chose to abort the drag. e.g. if we press ESCAPE while dragging.
     * We move the object back to its original position, and tell all other clients
     * of the "old" position using the MoveObject message.
     */
    fun cancelDrag() {
        dragData?.let { dragData ->
            val gov = dragData.gameObjectView
            gov.x = dragData.startX
            gov.y = dragData.startY
            gov.moveMessage().send()
            gov.tint = Colors.WHITE

            this.dragData = null
        }
    }

    fun changeZOrder(gov: GameObjectView, z: ChangeZOrder) {
        when (z) {
            ChangeZOrder.TOP -> {
                gameObjects.remove(gov)
                gameObjects.add(gov)
                gov.removeFromParent()
                addChild(gov)
            }

            ChangeZOrder.BOTTOM -> {
                gameObjects.remove(gov)
                gameObjects.add(0, gov)
                gov.removeFromParent()
                addChildAt(gov, 0)
            }

            ChangeZOrder.NO_CHANGE -> Unit
        }
    }

}