Exit Full View

Cavern Quest 2 / src / commonMain / kotlin / Monster.kt

import uk.co.nickthecoder.glok.property.property
import uk.co.nickthecoder.glok.scene.Color
import uk.co.nickthecoder.kyd.Actor
import uk.co.nickthecoder.kyd.Enumerable
import uk.co.nickthecoder.kyd.Ticks
import uk.co.nickthecoder.kyd.appearance.AppearanceResource
import uk.co.nickthecoder.kyd.appearance.FlipBook
import uk.co.nickthecoder.kyd.appearance.flipBook
import uk.co.nickthecoder.kyd.appearance.page
import uk.co.nickthecoder.kyd.util.Custom
import uk.co.nickthecoder.kyd.util.CustomWarning

private const val EAST = 0
private const val NORTH = 1
private const val WEST = 2
private const val SOUTH = 3

/**
 * Note, we need to implement [Enumerable] so that these values can be saved in .act files without using reflection
 * (which isn't available in JS).
 */
enum class MonsterMovement : Enumerable<MonsterMovement> {
    ATTACK_STICK_HORIZONTAL,
    ATTACK_STICK_VERTICAL,
    ATTACK_STICK_BOTH,
    ATTACK_STICK_NONE,
    ;

    override fun enumerateValues() = MonsterMovement.entries
}

class Monster : Item(), Killable {

    private var idle = true

    private var direction = NORTH

    /**
     * 0 : Attack, but get stuck horizontally (standard)
     * 1 : Attack, but get stuck vertically (not horizontally) (alternate)
     * 2 : Attack, but get stuck vertically and horizontally (easy)
     * 3 : Attack and never get stuck (hard).
     */
    @Suppress(CustomWarning)
    @Custom
    val movementTypeProperty by property(MonsterMovement.ATTACK_STICK_HORIZONTAL)
    var movementType by movementTypeProperty

    /**
     * A monster can get stuck in horizontal tunnels.
     * (They don't get stuck in vertical tunnels).
     */
    var allowReverse = true

    private lateinit var walkingAnimation: FlipBook
    private lateinit var idleAnimation: FlipBook

    override fun customProperties() = listOf( movementTypeProperty)

    override fun onEnteredStage(actor: Actor) {
        super.onEnteredStage(actor)

        if (! CavernQuest.instance.monstersGetStuck) {
            if (movementType == MonsterMovement.ATTACK_STICK_HORIZONTAL || movementType == MonsterMovement.ATTACK_STICK_VERTICAL || movementType == MonsterMovement.ATTACK_STICK_BOTH) {
                movementType = MonsterMovement.ATTACK_STICK_NONE
            }
        }

        val role = actor.role ?: return
        val a = role.find("a") as AppearanceResource
        val b = role.find("b") as AppearanceResource

        idleAnimation = flipBook {
            page(a, monsterIdleTime)
            page(b, monsterIdleTime)
        }
        walkingAnimation = flipBook {
            val pageCount = 2
            repetitions = 1
            page(a, monsterWalkTime / repetitions / pageCount)
            page(b, monsterWalkTime / repetitions / pageCount)
            onFinished {
                idle = true
                idleAnimation.applyToActor(actor)
            }
        }
        idleAnimation.applyToActor(actor)
    }

    private fun oppositeDirection(direction: Int) = (direction + 2) % 4
    private fun turnRight(direction: Int) = (direction - 1) % 4
    private fun turnLeft(direction: Int) = (direction + 1) % 4

    override fun tick(actor: Actor, seconds: Float) {
        (actor.appearance as? Ticks)?.tick(actor, seconds)
        if (idle) {
            when (movementType) {
                MonsterMovement.ATTACK_STICK_HORIZONTAL -> attack(actor)
                MonsterMovement.ATTACK_STICK_VERTICAL -> attack(actor, stickHorizontal = false, stickVertical = true)
                MonsterMovement.ATTACK_STICK_BOTH -> attack(actor, stickHorizontal = true, stickVertical = true)
                MonsterMovement.ATTACK_STICK_NONE -> attack(actor, stickHorizontal = false, stickVertical = false)
            }
        }
    }

    /**
     * This is my best-effort to reproduce the original behaviour of Cavern Quest monsters.
     * I'm not sure if it is identical, but the game _feels_ the same to me.
     * In particular the solutions that I used as a kid still work ;-)
     *
     * Note, the movement can be altered by supplying different values to [stickHorizontal] and [stickVertical].
     * Setting them both the `true` will probably make the game too easy.
     */
    private fun attack(actor: Actor, stickHorizontal: Boolean = true, stickVertical: Boolean = false) {
        val player = PlayDirector.instance.player ?: return
        val deltaX = player.gridX - gridX
        val deltaY = player.gridY - gridY

        fun headingAway(direction: Int): Boolean {
            val (dx, dy) = directionToDeltaXY(direction)
            if (dx * deltaX > 0) return false
            if (dy * deltaY > 0) return false
            return true
        }

        var newDirection: Int

        if (deltaY == 0) {
            // In the same column as player. Try to Head DIRECTLY towards them.
            newDirection = if (deltaX > 0) EAST else WEST
            if (stickHorizontal) {
                // If we are in a horizontal tunnel, don't turn around. Get stuck!
                allowReverse = ! (look(0, - 1).isWall() && look(0, 1).isWall())
            }

        } else if (deltaX == 0) {
            // In the same row as the  player. Try to Head DIRECTLY towards them.
            newDirection = if (deltaY > 0) NORTH else SOUTH
            if (stickVertical) {
                // If we are in a vertical tunnel, don't turn around. Get stuck!
                allowReverse = ! (look(- 1, 0).isWall() && look(1, 0).isWall())
            }

        } else {
            // Diagonal to the player. Do not head in one direction; turn if possible.
            // This lets the player be cunning, and isn't optimal for the monster!

            newDirection = turnLeft(direction)
            // If that means we are heading away, then try RIGHT first
            if (headingAway(newDirection)) {
                newDirection = oppositeDirection(newDirection)
            }

            if (! canMove(newDirection)) {
                // Couldn't turn that way, try the other way.
                newDirection = oppositeDirection(newDirection)

                if (! canMove(newDirection)) {
                    // We can't turn left OR right. Try straight ahead
                    if (canMove(direction)) {
                        newDirection = direction
                    } else {
                        // We can't turn left right or travel straight ahead. Reversing is the only other option!
                        // But is this allowed?
                        if (allowReverse) {
                            newDirection = oppositeDirection(direction)
                        }
                    }
                }
            }
        }

        // We've picked a direction, but it might be blocked.
        if (canMove(newDirection)) {
            direction = newDirection
            move(actor, direction)
            // Whenever we successfully move, we can't be in a dead-end, so reversing IS allowed.
            allowReverse = true
        }
        // debugColor(actor)
    }

    /**
     * Helped during debugging.
     */
    private fun debugColor(actor: Actor) {
        actor.appearance.tintIfAvailable = if (allowReverse) Color.WHITE else Color["#dead"]
    }

    /**
     * Oh dear, we got squashed by a rock. Turn into to soil,
     * and spawn another monster at a random spawn point.
     */
    override fun killedBy(killer: Actor, victim: Actor) {

    }

    private fun directionToDeltaXY(direction: Int): Pair<Int, Int> {
        return when (direction) {
            EAST -> Pair(1, 0)
            WEST -> Pair(- 1, 0)
            SOUTH -> Pair(0, - 1)
            else -> Pair(0, 1)
        }
    }

    private fun canMove(direction: Int): Boolean {
        val (dx, dy) = directionToDeltaXY(direction)
        val look = look(dx, dy)
        return if (CavernQuest.instance.friendly) {
            // On Easy mode, the monsters are friendly, they just want to follow you. Good doggie.
            look.isEmpty() && look?.behaviour !is Player
        } else {
            look.isEmpty()
        }
    }

    private fun move(actor: Actor, direction: Int) {
        val (dx, dy) = directionToDeltaXY(direction)
        val hit = look(dx, dy)
        if (move(actor, dx, dy)) {
            idle = false
            walkingAnimation.applyToActor(actor)

            (hit?.behaviour as? Killable)?.killedBy(actor, hit)
        }
    }

}