Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@

## [Unreleased]

### Changed

- **Render system caching**: Caches added to the render pipeline to
avoid redundant sprite processing each tick
- **Input system**: Replaced raw AWT key-code set and ad-hoc `gatherInput()` logic inside
`GameEngine` with an input subsystem

## [0.2.3] - 2026-04-07

### Added

- **Coin bounce animation**: Coins now bounce upward when collected, providing satisfying
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ pluginGroup = dev.stillya.vpet
pluginName = vpet
pluginRepositoryUrl = https://github.com/stillya/vpet
# SemVer format -> https://semver.org
pluginVersion = 0.2.3
pluginVersion = 0.2.4

# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
pluginSinceBuild = 242
Expand Down
53 changes: 17 additions & 36 deletions src/main/kotlin/dev/stillya/vpet/game/GameEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import com.intellij.openapi.util.Disposer
import dev.stillya.vpet.AtlasLoader
import dev.stillya.vpet.game.ecs.World
import dev.stillya.vpet.game.ecs.systems.CoinSpawner
import dev.stillya.vpet.game.input.InputState
import dev.stillya.vpet.game.input.InputTracker
import dev.stillya.vpet.game.rendering.GameRenderer
import dev.stillya.vpet.game.resources.AnimationCache
import java.awt.event.ComponentAdapter
Expand All @@ -18,9 +18,9 @@ import java.awt.event.KeyEvent
import javax.swing.Timer

class GameEngine(
private val editor: Editor?,
private val character: Character?,
private val renderer: GameRenderer?,
private val editor: Editor,
private val character: Character,
private val renderer: GameRenderer,
private val onExit: () -> Unit,
) {
private var world: World = World()
Expand All @@ -29,28 +29,24 @@ class GameEngine(

private var coinsSpawned = false
private var lastTickNanos = 0L
private var jumpWasPressed = false
private val keysHeld = mutableSetOf<Int>() // TODO: replace with proper input handling
private val inputTracker = InputTracker()

val finalScore: Int get() = world.score

private val resizeListener = object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
val cc = e.component
renderer?.setBounds(0, 0, cc.width, cc.height)
renderer.setBounds(0, 0, cc.width, cc.height)
}
}

companion object {
private const val TICK_MS = 16
private val LOG = logger<GameEngine>()
private val log = logger<GameEngine>()
}

fun start(initialWorld: World, disposable: Disposable) {
require(timer == null) { "GameEngine already started" }
requireNotNull(editor) { "GameEngine requires a non-null editor" }
requireNotNull(renderer) { "GameEngine requires a non-null renderer" }
requireNotNull(character) { "GameEngine requires a non-null character" }

world = initialWorld

Expand All @@ -74,11 +70,10 @@ class GameEngine(
fun stop() {
timer?.stop()
timer = null
keysHeld.clear()
jumpWasPressed = false
inputTracker.reset()
coinsSpawned = false
tileMapSyncer = null
val cc = editor?.contentComponent ?: return
val cc = editor.contentComponent
cc.remove(renderer)
cc.removeComponentListener(resizeListener)
cc.repaint()
Expand All @@ -92,9 +87,9 @@ class GameEngine(
val dt = ((now - lastTickNanos) / 1_000_000_000f).coerceAtMost(0.05f)
lastTickNanos = now

val input = gatherInput()
val input = inputTracker.snapshot()

val lastDocumentLine = ((editor?.document?.lineCount ?: 1) - 1).coerceAtLeast(0)
val lastDocumentLine = (editor.document.lineCount - 1).coerceAtLeast(0)
val visibleRange = 0..lastDocumentLine

if (!coinsSpawned) {
Expand All @@ -103,32 +98,18 @@ class GameEngine(
coinsSpawned = true
}

val currentCharacter = character ?: return

try {
val (frame, intent) = WorldUpdate.tick(world, input, dt, currentCharacter, tileMap, visibleRange)
val (frame, intent) = WorldUpdate.tick(world, input, dt, character, tileMap, visibleRange)
world = frame.world

renderer?.update(frame, intent.animation, tileMap)
renderer?.repaint()
renderer.update(frame, intent.animation, tileMap)
renderer.repaint()
} catch (e: Exception) {
LOG.error("Game tick failed, stopping game loop", e)
log.error("Game tick failed, stopping game loop", e)
onExit()
}
}

fun gatherInput(): InputState {
val move = when {
KeyEvent.VK_LEFT in keysHeld && KeyEvent.VK_RIGHT !in keysHeld -> -1
KeyEvent.VK_RIGHT in keysHeld && KeyEvent.VK_LEFT !in keysHeld -> 1
else -> 0
}
val jumpPressed = KeyEvent.VK_UP in keysHeld || KeyEvent.VK_SPACE in keysHeld
val justPressed = jumpPressed && !jumpWasPressed
jumpWasPressed = jumpPressed
return InputState(move, justPressed)
}

private fun registerKeyDispatcher(disposable: Disposable) {
val dispatcher = IdeEventQueue.EventDispatcher { event ->
if (timer == null) return@EventDispatcher false
Expand All @@ -142,12 +123,12 @@ class GameEngine(
if (event.keyCode == KeyEvent.VK_ESCAPE) {
onExit()
} else {
keysHeld.add(event.keyCode)
inputTracker.press(event.keyCode)
}
}

KeyEvent.KEY_RELEASED -> {
keysHeld.remove(event.keyCode)
inputTracker.release(event.keyCode)
}
}
event.consume()
Expand Down
3 changes: 3 additions & 0 deletions src/main/kotlin/dev/stillya/vpet/game/input/GameAction.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package dev.stillya.vpet.game.input

enum class GameAction { MOVE_LEFT, MOVE_RIGHT, JUMP }
33 changes: 33 additions & 0 deletions src/main/kotlin/dev/stillya/vpet/game/input/InputTracker.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package dev.stillya.vpet.game.input

class InputTracker(private val bindings: Map<Int, GameAction> = KeyBindings.defaults) {
private var heldActions: Set<GameAction> = emptySet()
private var prevActions: Set<GameAction> = emptySet()

fun press(keyCode: Int) {
heldActions = heldActions + (bindings[keyCode] ?: return)
}

fun release(keyCode: Int) {
heldActions = heldActions - (bindings[keyCode] ?: return)
}

fun reset() {
heldActions = emptySet()
prevActions = emptySet()
}

fun snapshot(): InputState {
val input = InputState(
moveDirection = when {
GameAction.MOVE_LEFT in heldActions && GameAction.MOVE_RIGHT !in heldActions -> -1
GameAction.MOVE_RIGHT in heldActions && GameAction.MOVE_LEFT !in heldActions -> 1
else -> 0
},
jumpJustPressed = GameAction.JUMP in heldActions && GameAction.JUMP !in prevActions,
)
prevActions = heldActions

return input
}
}
12 changes: 12 additions & 0 deletions src/main/kotlin/dev/stillya/vpet/game/input/KeyBindings.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package dev.stillya.vpet.game.input

import java.awt.event.KeyEvent

object KeyBindings {
val defaults: Map<Int, GameAction> = mapOf(
KeyEvent.VK_LEFT to GameAction.MOVE_LEFT,
KeyEvent.VK_RIGHT to GameAction.MOVE_RIGHT,
KeyEvent.VK_UP to GameAction.JUMP,
KeyEvent.VK_SPACE to GameAction.JUMP,
)
}
24 changes: 0 additions & 24 deletions src/test/kotlin/dev/stillya/vpet/game/CoinCollectedListenerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,10 @@ package dev.stillya.vpet.game

import com.intellij.openapi.application.ApplicationManager
import com.intellij.testFramework.LightPlatform4TestCase
import dev.stillya.vpet.game.ecs.World
import org.junit.Test

class CoinCollectedListenerTest : LightPlatform4TestCase() {

@Test
fun `GameEngine exposes finalScore from world`() {
val world = World(score = 42)
val engine = GameEngine(
editor = null,
character = null,
renderer = null,
onExit = {},
)

val worldField = GameEngine::class.java.getDeclaredField("world").also { it.isAccessible = true }
worldField.set(engine, world)

assertEquals(42, engine.finalScore)
}

@Test
fun `CoinCollectedListener TOPIC is properly configured`() {
val topic = CoinCollectedListener.TOPIC
assertEquals("CoinCollected", topic.displayName)
assertEquals(CoinCollectedListener::class.java, topic.listenerClass)
}

@Test
fun `message bus can publish coin collected events`() {
var receivedCount = -1
Expand Down
121 changes: 0 additions & 121 deletions src/test/kotlin/dev/stillya/vpet/game/GameEngineTest.kt

This file was deleted.

Loading
Loading