From 646b9f15e02b6d3f957eb556627a8e79f2e46c38 Mon Sep 17 00:00:00 2001 From: stillya Date: Sun, 24 May 2026 02:15:11 +0500 Subject: [PATCH] feat: add dedicated input system --- CHANGELOG.md | 9 ++ gradle.properties | 2 +- .../dev/stillya/vpet/game/GameEngine.kt | 53 +++----- .../dev/stillya/vpet/game/input/GameAction.kt | 3 + .../stillya/vpet/game/input/InputTracker.kt | 33 +++++ .../stillya/vpet/game/input/KeyBindings.kt | 12 ++ .../vpet/game/CoinCollectedListenerTest.kt | 24 ---- .../dev/stillya/vpet/game/GameEngineTest.kt | 121 ------------------ .../ecs/components/AnimationComponentTest.kt | 42 ------ .../vpet/game/input/InputTrackerTest.kt | 108 ++++++++++++++++ 10 files changed, 183 insertions(+), 224 deletions(-) create mode 100644 src/main/kotlin/dev/stillya/vpet/game/input/GameAction.kt create mode 100644 src/main/kotlin/dev/stillya/vpet/game/input/InputTracker.kt create mode 100644 src/main/kotlin/dev/stillya/vpet/game/input/KeyBindings.kt delete mode 100644 src/test/kotlin/dev/stillya/vpet/game/GameEngineTest.kt delete mode 100644 src/test/kotlin/dev/stillya/vpet/game/ecs/components/AnimationComponentTest.kt create mode 100644 src/test/kotlin/dev/stillya/vpet/game/input/InputTrackerTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b30ece..9005631 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/gradle.properties b/gradle.properties index a2c0521..258d05f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/src/main/kotlin/dev/stillya/vpet/game/GameEngine.kt b/src/main/kotlin/dev/stillya/vpet/game/GameEngine.kt index 000a08a..94410bd 100644 --- a/src/main/kotlin/dev/stillya/vpet/game/GameEngine.kt +++ b/src/main/kotlin/dev/stillya/vpet/game/GameEngine.kt @@ -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 @@ -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() @@ -29,28 +29,24 @@ class GameEngine( private var coinsSpawned = false private var lastTickNanos = 0L - private var jumpWasPressed = false - private val keysHeld = mutableSetOf() // 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() + private val log = logger() } 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 @@ -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() @@ -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) { @@ -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 @@ -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() diff --git a/src/main/kotlin/dev/stillya/vpet/game/input/GameAction.kt b/src/main/kotlin/dev/stillya/vpet/game/input/GameAction.kt new file mode 100644 index 0000000..7b31fec --- /dev/null +++ b/src/main/kotlin/dev/stillya/vpet/game/input/GameAction.kt @@ -0,0 +1,3 @@ +package dev.stillya.vpet.game.input + +enum class GameAction { MOVE_LEFT, MOVE_RIGHT, JUMP } diff --git a/src/main/kotlin/dev/stillya/vpet/game/input/InputTracker.kt b/src/main/kotlin/dev/stillya/vpet/game/input/InputTracker.kt new file mode 100644 index 0000000..2110606 --- /dev/null +++ b/src/main/kotlin/dev/stillya/vpet/game/input/InputTracker.kt @@ -0,0 +1,33 @@ +package dev.stillya.vpet.game.input + +class InputTracker(private val bindings: Map = KeyBindings.defaults) { + private var heldActions: Set = emptySet() + private var prevActions: Set = 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 + } +} diff --git a/src/main/kotlin/dev/stillya/vpet/game/input/KeyBindings.kt b/src/main/kotlin/dev/stillya/vpet/game/input/KeyBindings.kt new file mode 100644 index 0000000..2ddbaa1 --- /dev/null +++ b/src/main/kotlin/dev/stillya/vpet/game/input/KeyBindings.kt @@ -0,0 +1,12 @@ +package dev.stillya.vpet.game.input + +import java.awt.event.KeyEvent + +object KeyBindings { + val defaults: Map = 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, + ) +} diff --git a/src/test/kotlin/dev/stillya/vpet/game/CoinCollectedListenerTest.kt b/src/test/kotlin/dev/stillya/vpet/game/CoinCollectedListenerTest.kt index da255c9..45641bf 100644 --- a/src/test/kotlin/dev/stillya/vpet/game/CoinCollectedListenerTest.kt +++ b/src/test/kotlin/dev/stillya/vpet/game/CoinCollectedListenerTest.kt @@ -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 diff --git a/src/test/kotlin/dev/stillya/vpet/game/GameEngineTest.kt b/src/test/kotlin/dev/stillya/vpet/game/GameEngineTest.kt deleted file mode 100644 index 222319c..0000000 --- a/src/test/kotlin/dev/stillya/vpet/game/GameEngineTest.kt +++ /dev/null @@ -1,121 +0,0 @@ -package dev.stillya.vpet.game - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.awt.event.KeyEvent - -/** - * Tests for GameEngine input gathering logic. - * - * start()/stop() lifecycle requires a live IntelliJ platform (Editor, IdeEventQueue) and is - * covered by manual/integration testing. This test suite focuses on the pure gatherInput() - * logic which has no platform dependencies. - */ -class GameEngineTest { - - private lateinit var engine: GameEngine - private val keysHeldField by lazy { - GameEngine::class.java.getDeclaredField("keysHeld").also { it.isAccessible = true } - } - private val jumpWasPressedField by lazy { - GameEngine::class.java.getDeclaredField("jumpWasPressed").also { it.isAccessible = true } - } - - @Suppress("UNCHECKED_CAST") - private fun keysHeld(): MutableSet = - keysHeldField.get(engine) as MutableSet - - private fun setJumpWasPressed(value: Boolean) { - jumpWasPressedField.setBoolean(engine, value) - } - - @Before - fun setUp() { - // editor/character/renderer are not used by gatherInput(); safe to pass null - engine = GameEngine( - editor = null, - character = null, - renderer = null, - onExit = {}, - ) - } - - @Test - fun `gatherInput returns no movement when no keys held`() { - val input = engine.gatherInput() - assertEquals(0, input.moveDirection) - assertFalse(input.jumpJustPressed) - } - - @Test - fun `gatherInput returns left movement when left key held`() { - keysHeld().add(KeyEvent.VK_LEFT) - val input = engine.gatherInput() - assertEquals(-1, input.moveDirection) - } - - @Test - fun `gatherInput returns right movement when right key held`() { - keysHeld().add(KeyEvent.VK_RIGHT) - val input = engine.gatherInput() - assertEquals(1, input.moveDirection) - } - - @Test - fun `gatherInput returns no movement when both left and right held`() { - keysHeld().add(KeyEvent.VK_LEFT) - keysHeld().add(KeyEvent.VK_RIGHT) - val input = engine.gatherInput() - assertEquals(0, input.moveDirection) - } - - @Test - fun `gatherInput detects jump just pressed on first space press`() { - setJumpWasPressed(false) - keysHeld().add(KeyEvent.VK_SPACE) - val input = engine.gatherInput() - assertTrue(input.jumpJustPressed) - } - - @Test - fun `gatherInput does not report jump just pressed when space already held last frame`() { - keysHeld().add(KeyEvent.VK_SPACE) - // first call sets jumpWasPressed = true - engine.gatherInput() - // second call with space still held: jumpJustPressed should be false - val input = engine.gatherInput() - assertFalse(input.jumpJustPressed) - } - - @Test - fun `gatherInput detects jump with UP key`() { - setJumpWasPressed(false) - keysHeld().add(KeyEvent.VK_UP) - val input = engine.gatherInput() - assertTrue(input.jumpJustPressed) - } - - @Test - fun `gatherInput jump registers again after key release`() { - keysHeld().add(KeyEvent.VK_SPACE) - engine.gatherInput() // press: jumpWasPressed = true - keysHeld().remove(KeyEvent.VK_SPACE) - engine.gatherInput() // release: jumpWasPressed = false - keysHeld().add(KeyEvent.VK_SPACE) - val input = engine.gatherInput() // press again: justPressed = true - assertTrue(input.jumpJustPressed) - } - - @Test - fun `gatherInput combines movement and jump`() { - setJumpWasPressed(false) - keysHeld().add(KeyEvent.VK_RIGHT) - keysHeld().add(KeyEvent.VK_SPACE) - val input = engine.gatherInput() - assertEquals(1, input.moveDirection) - assertTrue(input.jumpJustPressed) - } -} diff --git a/src/test/kotlin/dev/stillya/vpet/game/ecs/components/AnimationComponentTest.kt b/src/test/kotlin/dev/stillya/vpet/game/ecs/components/AnimationComponentTest.kt deleted file mode 100644 index c423353..0000000 --- a/src/test/kotlin/dev/stillya/vpet/game/ecs/components/AnimationComponentTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package dev.stillya.vpet.game.ecs.components - -import org.junit.Assert.assertEquals -import org.junit.Test - -class AnimationComponentTest { - - @Test - fun testAnimationComponentReferencesResourceById() { - val component = AnimationComponent( - resourceId = "coin_idle", - currentFrame = 0, - elapsed = 0f - ) - - assertEquals("coin_idle", component.resourceId) - assertEquals(0, component.currentFrame) - assertEquals(0f, component.elapsed, 0.001f) - } - - @Test - fun testAnimationComponentWithAdvancedState() { - val component = AnimationComponent( - resourceId = "coin_idle", - currentFrame = 3, - elapsed = 0.15f - ) - - assertEquals("coin_idle", component.resourceId) - assertEquals(3, component.currentFrame) - assertEquals(0.15f, component.elapsed, 0.001f) - } - - @Test - fun testAnimationComponentDefaultValues() { - val component = AnimationComponent(resourceId = "test_anim") - - assertEquals("test_anim", component.resourceId) - assertEquals(0, component.currentFrame) - assertEquals(0f, component.elapsed, 0.001f) - } -} diff --git a/src/test/kotlin/dev/stillya/vpet/game/input/InputTrackerTest.kt b/src/test/kotlin/dev/stillya/vpet/game/input/InputTrackerTest.kt new file mode 100644 index 0000000..7db9851 --- /dev/null +++ b/src/test/kotlin/dev/stillya/vpet/game/input/InputTrackerTest.kt @@ -0,0 +1,108 @@ +package dev.stillya.vpet.game.input + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.awt.event.KeyEvent + +class InputTrackerTest { + + private lateinit var tracker: InputTracker + + @Before + fun setUp() { + tracker = InputTracker() + } + + @Test + fun `no input when no keys pressed`() { + val input = tracker.snapshot() + assertEquals(0, input.moveDirection) + assertFalse(input.jumpJustPressed) + } + + @Test + fun `left key produces negative move direction`() { + tracker.press(KeyEvent.VK_LEFT) + assertEquals(-1, tracker.snapshot().moveDirection) + } + + @Test + fun `right key produces positive move direction`() { + tracker.press(KeyEvent.VK_RIGHT) + assertEquals(1, tracker.snapshot().moveDirection) + } + + @Test + fun `both left and right held produces no movement`() { + tracker.press(KeyEvent.VK_LEFT) + tracker.press(KeyEvent.VK_RIGHT) + assertEquals(0, tracker.snapshot().moveDirection) + } + + @Test + fun `space produces jump just pressed on first frame`() { + tracker.press(KeyEvent.VK_SPACE) + assertTrue(tracker.snapshot().jumpJustPressed) + } + + @Test + fun `up key produces jump just pressed on first frame`() { + tracker.press(KeyEvent.VK_UP) + assertTrue(tracker.snapshot().jumpJustPressed) + } + + @Test + fun `jump not reported again while key held`() { + tracker.press(KeyEvent.VK_SPACE) + tracker.snapshot() // first frame: just pressed + assertFalse(tracker.snapshot().jumpJustPressed) // still held: no edge + } + + @Test + fun `jump registers again after key release`() { + tracker.press(KeyEvent.VK_SPACE) + tracker.snapshot() // press + tracker.release(KeyEvent.VK_SPACE) + tracker.snapshot() // release + tracker.press(KeyEvent.VK_SPACE) + assertTrue(tracker.snapshot().jumpJustPressed) // re-press: edge detected + } + + @Test + fun `movement and jump can be combined`() { + tracker.press(KeyEvent.VK_RIGHT) + tracker.press(KeyEvent.VK_SPACE) + val input = tracker.snapshot() + assertEquals(1, input.moveDirection) + assertTrue(input.jumpJustPressed) + } + + @Test + fun `reset clears all state`() { + tracker.press(KeyEvent.VK_RIGHT) + tracker.press(KeyEvent.VK_SPACE) + tracker.snapshot() // advance prevActions + tracker.reset() + val input = tracker.snapshot() + assertEquals(0, input.moveDirection) + assertFalse(input.jumpJustPressed) + } + + @Test + fun `release removes action`() { + tracker.press(KeyEvent.VK_LEFT) + tracker.release(KeyEvent.VK_LEFT) + assertEquals(0, tracker.snapshot().moveDirection) + } + + @Test + fun `unknown key code is ignored`() { + tracker.press(KeyEvent.VK_A) + val input = tracker.snapshot() + assertEquals(0, input.moveDirection) + assertFalse(input.jumpJustPressed) + } +}