diff --git a/fxgl-gameplay/src/main/kotlin/com/almasb/fxgl/quest/Quest.kt b/fxgl-gameplay/src/main/kotlin/com/almasb/fxgl/quest/Quest.kt index 999ea7083..85df56894 100644 --- a/fxgl-gameplay/src/main/kotlin/com/almasb/fxgl/quest/Quest.kt +++ b/fxgl-gameplay/src/main/kotlin/com/almasb/fxgl/quest/Quest.kt @@ -6,6 +6,7 @@ package com.almasb.fxgl.quest +import com.almasb.fxgl.core.Updatable import com.almasb.fxgl.core.collection.PropertyMap import com.almasb.fxgl.logging.Logger import javafx.beans.binding.Bindings @@ -27,10 +28,19 @@ import java.util.concurrent.Callable * * @author Almas Baimagambetov (almaslvl@gmail.com) */ -class Quest(val name: String) { +class Quest +@JvmOverloads constructor(name: String, val vars: PropertyMap = PropertyMap()) : Updatable { private val log = Logger.get(javaClass) + private val nameProp = SimpleStringProperty(name) + + var name: String + get() = nameProp.value + set(value) { nameProp.value = value } + + fun nameProperty() = nameProp + private val objectives = FXCollections.observableArrayList() private val objectivesReadOnly = FXCollections.unmodifiableObservableList(objectives) @@ -49,33 +59,34 @@ class Quest(val name: String) { /** * @return true if any of the states apart from NOT_STARTED */ - val hasStarted: Boolean + val isStarted: Boolean get() = state != QuestState.NOT_STARTED @JvmOverloads fun addIntObjective(desc: String, varName: String, varValue: Int, duration: Duration = Duration.ZERO): QuestObjective { - return IntQuestObjective(desc, varName, varValue, duration).also { addObjective(it) } + return IntQuestObjective(desc, vars, varName, varValue, duration).also { addObjective(it) } } @JvmOverloads fun addBooleanObjective(desc: String, varName: String, varValue: Boolean, duration: Duration = Duration.ZERO): QuestObjective { - return BooleanQuestObjective(desc, varName, varValue, duration).also { addObjective(it) } + return BooleanQuestObjective(desc, vars, varName, varValue, duration).also { addObjective(it) } } private fun addObjective(objective: QuestObjective) { objectives += objective - if (hasStarted) + if (isStarted) rebindStateToObjectives() } fun removeObjective(objective: QuestObjective) { objectives -= objective - if (hasStarted) + if (isStarted) rebindStateToObjectives() } /** * Can only be called from NOT_STARTED state. + * Binds quest state to the combined state of its objectives. */ internal fun start() { if (objectives.isEmpty()) { @@ -83,7 +94,7 @@ class Quest(val name: String) { return } - if (hasStarted) { + if (isStarted) { log.warning("Cannot start quest $name because it has already been started") return } @@ -91,7 +102,23 @@ class Quest(val name: String) { rebindStateToObjectives() } + override fun onUpdate(tpf: Double) { + objectives.forEach { it.onUpdate(tpf) } + } + + /** + * Sets the state to NOT_STARTED and unbinds objectives from the variables they are tracking. + */ + internal fun stop() { + stateProp.unbind() + stateProp.value = QuestState.NOT_STARTED + + objectives.forEach { it.unbindFromVars() } + } + private fun rebindStateToObjectives() { + objectives.forEach { it.bindToVars() } + val failedBinding = objectives.map { it.stateProperty() } .foldRight(Bindings.createBooleanBinding(Callable { false })) { state, binding -> state.isEqualTo(QuestState.FAILED).or(binding) @@ -126,11 +153,16 @@ constructor( */ val description: String, + /** + * Variables map, from which to check whether the objective is complete. + */ + protected val vars: PropertyMap, + /** * How much time is given to complete this objective. * Default: 0 - unlimited. */ - val expireDuration: Duration = Duration.ZERO) { + val expireDuration: Duration = Duration.ZERO) : Updatable { private val stateProp = ReadOnlyObjectWrapper(QuestState.ACTIVE) @@ -139,6 +171,17 @@ constructor( fun stateProperty(): ReadOnlyObjectProperty = stateProp.readOnlyProperty + private val timeRemainingProp = ReadOnlyDoubleWrapper(expireDuration.toSeconds()) + + /** + * @return time remaining (in seconds) to complete this objective, + * returns 0.0 if unlimited + */ + val timeRemaining: Double + get() = timeRemainingProp.value + + fun timeRemainingProperty(): ReadOnlyDoubleProperty = timeRemainingProp.readOnlyProperty + protected val successProp = ReadOnlyBooleanWrapper() private val successListener = javafx.beans.value.ChangeListener { _, _, isReached -> @@ -152,12 +195,30 @@ constructor( successProp.addListener(successListener) } + override fun onUpdate(tpf: Double) { + if (state != QuestState.ACTIVE) + return + + // ignore if no duration is set + if (expireDuration.lessThanOrEqualTo(Duration.ZERO)) + return + + val remaining = timeRemaining - tpf + + if (remaining <= 0) { + timeRemainingProp.value = 0.0 + fail() + } else { + timeRemainingProp.value = remaining + } + } + fun complete() { if (state != QuestState.ACTIVE) { return } - unbind() + unbindFromVars() successProp.value = true } @@ -166,7 +227,7 @@ constructor( return } - unbind() + unbindFromVars() successProp.value = false clean() stateProp.value = QuestState.FAILED @@ -175,19 +236,24 @@ constructor( /** * Transition from FAILED -> ACTIVE. */ - fun reactivate(vars: PropertyMap) { + fun reactivate() { if (state != QuestState.FAILED) { return } stateProp.value = QuestState.ACTIVE + timeRemainingProp.value = expireDuration.toSeconds() successProp.addListener(successListener) - bindTo(vars) + bindToVars() } - abstract fun bindTo(vars: PropertyMap) + /** + * Bind the state to variables, so that the state + * is updated as variables change. + */ + internal abstract fun bindToVars() - internal fun unbind() { + internal fun unbindFromVars() { successProp.unbind() } @@ -203,6 +269,8 @@ private class IntQuestObjective */ description: String, + vars: PropertyMap, + /** * Variable name of an int property from the world properties to track. */ @@ -220,9 +288,9 @@ private class IntQuestObjective */ expireDuration: Duration = Duration.ZERO -) : QuestObjective(description, expireDuration) { +) : QuestObjective(description, vars, expireDuration) { - override fun bindTo(vars: PropertyMap) { + override fun bindToVars() { successProp.bind( vars.intProperty(varName).greaterThanOrEqualTo(varValue) ) @@ -236,6 +304,8 @@ private class BooleanQuestObjective */ description: String, + vars: PropertyMap, + /** * Variable name of a boolean property from the world properties to track. */ @@ -252,9 +322,9 @@ private class BooleanQuestObjective */ expireDuration: Duration = Duration.ZERO -) : QuestObjective(description, expireDuration) { +) : QuestObjective(description, vars, expireDuration) { - override fun bindTo(vars: PropertyMap) { + override fun bindToVars() { successProp.bind( vars.booleanProperty(varName).isEqualTo(SimpleBooleanProperty(varValue)) ) diff --git a/fxgl-gameplay/src/main/kotlin/com/almasb/fxgl/quest/QuestService.kt b/fxgl-gameplay/src/main/kotlin/com/almasb/fxgl/quest/QuestService.kt index 212b43886..5e4191ec2 100644 --- a/fxgl-gameplay/src/main/kotlin/com/almasb/fxgl/quest/QuestService.kt +++ b/fxgl-gameplay/src/main/kotlin/com/almasb/fxgl/quest/QuestService.kt @@ -12,7 +12,8 @@ import javafx.collections.FXCollections import javafx.collections.ObservableList /** - * Keeps track of quests, allows adding, removing and starting quests. + * Allows constructing new quests. + * Keeps track of started (active) quests. * * @author Almas Baimagambetov (almaslvl@gmail.com) */ @@ -21,57 +22,53 @@ class QuestService : EngineService() { private val quests = FXCollections.observableArrayList() private val unmodifiableQuests = FXCollections.unmodifiableObservableList(quests) - private lateinit var vars: PropertyMap + private var vars = PropertyMap() /** * @return unmodifiable list of currently tracked quests */ fun questsProperty(): ObservableList = unmodifiableQuests - /** - * Add a quest to be tracked by the service. - */ - fun addQuest(quest: Quest) { - quests.add(quest) + override fun onVarsInitialized(vars: PropertyMap) { + this.vars = vars } /** - * Remove a quest from being tracked by the service. + * Constructs a new quest with given [name] and variables data [varsMap]. + * By default, the variables data is taken from the game variables. */ - fun removeQuest(quest: Quest) { - quests.remove(quest) - - quest.objectivesProperty().forEach { it.unbind() } + @JvmOverloads fun newQuest(name: String, varsMap: PropertyMap = vars): Quest { + return Quest(name, varsMap) } /** - * Start given quest. Will automatically track it. + * Start the [quest] and adds it to tracked list. */ fun startQuest(quest: Quest) { - if (quest !in quests) - addQuest(quest) - - bindToVars(quest) + quests.add(quest) quest.start() } - fun removeAllQuests() { - quests.toList().forEach { removeQuest(it) } + /** + * Stops the [quest] and removes it from tracked list. + */ + fun stopQuest(quest: Quest) { + quests.remove(quest) + quest.stop() } - override fun onGameReady(vars: PropertyMap) { - this.vars = vars + /** + * Stops all quests and removes them from being tracked. + */ + fun stopAllQuests() { + quests.toList().forEach { stopQuest(it) } + } - quests.filter { it.state == QuestState.ACTIVE } - .forEach { - bindToVars(it) - } + override fun onGameUpdate(tpf: Double) { + quests.forEach { it.onUpdate(tpf) } } - private fun bindToVars(quest: Quest) { - quest.objectivesProperty().forEach { - it.unbind() - it.bindTo(vars) - } + override fun onGameReset() { + stopAllQuests() } } \ No newline at end of file diff --git a/fxgl-gameplay/src/test/kotlin/com/almasb/fxgl/quest/QuestServiceTest.kt b/fxgl-gameplay/src/test/kotlin/com/almasb/fxgl/quest/QuestServiceTest.kt new file mode 100644 index 000000000..d10b8873d --- /dev/null +++ b/fxgl-gameplay/src/test/kotlin/com/almasb/fxgl/quest/QuestServiceTest.kt @@ -0,0 +1,49 @@ +/* + * FXGL - JavaFX Game Library. The MIT License (MIT). + * Copyright (c) AlmasB (almaslvl@gmail.com). + * See LICENSE for details. + */ + +package com.almasb.fxgl.quest + +import com.almasb.fxgl.core.collection.PropertyMap +import org.hamcrest.CoreMatchers.* +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * + * @author Almas Baimagambetov (almaslvl@gmail.com) + */ +class QuestServiceTest { + + @Test + fun `Quests lifecycle`() { + val questService = QuestService() + + val quest = questService.newQuest("name") + quest.addIntObjective("", "testInt", 1) + quest.vars.setValue("testInt", 0) + + assertThat(quest.state, `is`(QuestState.NOT_STARTED)) + + questService.startQuest(quest) + assertThat(quest.state, `is`(QuestState.ACTIVE)) + assertThat(questService.questsProperty(), Matchers.contains(quest)) + + questService.stopQuest(quest) + assertThat(quest.state, `is`(QuestState.NOT_STARTED)) + assertTrue(questService.questsProperty().isEmpty()) + + questService.startQuest(quest) + assertThat(quest.state, `is`(QuestState.ACTIVE)) + assertThat(questService.questsProperty(), Matchers.contains(quest)) + + questService.stopAllQuests() + assertThat(quest.state, `is`(QuestState.NOT_STARTED)) + assertTrue(questService.questsProperty().isEmpty()) + } +} \ No newline at end of file diff --git a/fxgl-gameplay/src/test/kotlin/com/almasb/fxgl/quest/QuestTest.kt b/fxgl-gameplay/src/test/kotlin/com/almasb/fxgl/quest/QuestTest.kt index d69fa2b15..12b0d3abc 100644 --- a/fxgl-gameplay/src/test/kotlin/com/almasb/fxgl/quest/QuestTest.kt +++ b/fxgl-gameplay/src/test/kotlin/com/almasb/fxgl/quest/QuestTest.kt @@ -7,10 +7,14 @@ package com.almasb.fxgl.quest import com.almasb.fxgl.core.collection.PropertyMap +import javafx.util.Duration import org.hamcrest.CoreMatchers.* import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows /** * @@ -24,35 +28,129 @@ class QuestTest { assertThat(quest.name, `is`("First test quest")) assertTrue(quest.objectivesProperty().isEmpty()) + assertThat(quest.stateProperty().value, `is`(quest.state)) } @Test - fun `Valid transitions`() { - val quest = Quest("") + fun `Add and remove objectives`() { + val vars = PropertyMap() + vars.setValue("varBoolean", false) + vars.setValue("varInt", 0) + + val quest = Quest("", vars) + + val obj1 = quest.addBooleanObjective("Desc", "varBoolean", true, Duration.seconds(15.0)) + val obj2 = quest.addIntObjective("Desc", "varInt", 5) + + assertThat(quest.objectivesProperty(), Matchers.contains(obj1, obj2)) + + quest.removeObjective(obj1) + assertThat(quest.objectivesProperty(), Matchers.contains(obj2)) + + quest.removeObjective(obj2) + assertTrue(quest.objectivesProperty().isEmpty()) + } + + @Test + fun `Valid transitions and objective completion`() { + val vars = PropertyMap() + vars.setValue("testKey", false) + vars.setValue("testInt", 4) + vars.setValue("testInt2", 0) + + val quest = Quest("", vars) assertThat(quest.state, `is`(QuestState.NOT_STARTED)) val obj = quest.addBooleanObjective("test obj", "testKey", true) - val map = PropertyMap() - map.setValue("testKey", false) - - obj.bindTo(map) + assertFalse(quest.isStarted) quest.start() + assertTrue(quest.isStarted) assertThat(quest.state, `is`(QuestState.ACTIVE)) obj.fail() assertThat(quest.state, `is`(QuestState.FAILED)) - obj.reactivate(map) + obj.reactivate() assertThat(quest.state, `is`(QuestState.ACTIVE)) - map.setValue("testKey", true) + vars.setValue("testKey", true) assertThat(quest.state, `is`(QuestState.COMPLETED)) + + // add new objective, quest is now active again + val obj2 = quest.addIntObjective("Desc", "testInt", 5) + + assertThat(quest.state, `is`(QuestState.ACTIVE)) + + vars.setValue("testInt", 5) + assertThat(quest.state, `is`(QuestState.COMPLETED)) + + // add another new objective + val obj3 = quest.addIntObjective("Desc", "testInt2", 5) + + assertThat(quest.state, `is`(QuestState.ACTIVE)) + + // remove objective, making quest complete + quest.removeObjective(obj3) + + assertThat(quest.state, `is`(QuestState.COMPLETED)) + } + + @Test + fun `Objective fails if timer expired`() { + val vars = PropertyMap() + vars.setValue("testInt", 0) + + val quest = Quest("", vars) + + val obj = quest.addIntObjective("Desc", "testInt", 5, Duration.seconds(2.0)) + assertThat(obj.timeRemainingProperty().value, `is`(2.0)) + + quest.start() + assertThat(obj.state, `is`(QuestState.ACTIVE)) + assertThat(quest.state, `is`(QuestState.ACTIVE)) + + quest.onUpdate(1.0) + assertThat(obj.timeRemainingProperty().value, `is`(1.0)) + assertThat(obj.state, `is`(QuestState.ACTIVE)) + assertThat(quest.state, `is`(QuestState.ACTIVE)) + + // this pushes the objective beyond the 2 sec expiry duration, so obj should fail + quest.onUpdate(1.5) + assertThat(obj.timeRemainingProperty().value, `is`(0.0)) + assertThat(obj.state, `is`(QuestState.FAILED)) + assertThat(quest.state, `is`(QuestState.FAILED)) + + } + + @Test + fun `Objective can be completed directly`() { + val vars = PropertyMap() + vars.setValue("testInt", 0) + + val quest = Quest("", vars) + + val obj = quest.addIntObjective("Desc", "testInt", 5, Duration.seconds(2.0)) + + assertThat(obj.state, `is`(QuestState.ACTIVE)) + + obj.complete() + assertThat(obj.state, `is`(QuestState.COMPLETED)) + } + + @Test + fun `Throws exception if objective has no variables to bind to`() { + val quest = Quest("") + quest.addIntObjective("Desc", "testInt", 5) + + assertThrows { + quest.start() + } } } \ No newline at end of file diff --git a/fxgl-samples/src/main/java/sandbox/QuestSample.java b/fxgl-samples/src/main/java/intermediate/QuestSample.java similarity index 58% rename from fxgl-samples/src/main/java/sandbox/QuestSample.java rename to fxgl-samples/src/main/java/intermediate/QuestSample.java index dbb477828..f65bd2f5c 100644 --- a/fxgl-samples/src/main/java/sandbox/QuestSample.java +++ b/fxgl-samples/src/main/java/intermediate/QuestSample.java @@ -4,23 +4,23 @@ * See LICENSE for details. */ -package sandbox; +package intermediate; import com.almasb.fxgl.app.GameApplication; import com.almasb.fxgl.app.GameSettings; -import com.almasb.fxgl.quest.Quest; import com.almasb.fxgl.quest.QuestService; -import javafx.scene.input.KeyCode; -import javafx.scene.input.MouseButton; import java.util.Map; import static com.almasb.fxgl.dsl.FXGL.*; /** + * Shows how to use QuestService. + * * @author Almas Baimagambetov (almaslvl@gmail.com) */ public class QuestSample extends GameApplication { + @Override protected void initSettings(GameSettings settings) { settings.addEngineService(QuestService.class); @@ -28,26 +28,26 @@ protected void initSettings(GameSettings settings) { @Override protected void initInput() { - onKeyDown(KeyCode.F, () -> { - var quest = new Quest("Your first quest"); + onBtnDownPrimary(() -> inc("clicks", +1)); + } - quest.addIntObjective("Click 5 times", "clicks", 5); + @Override + protected void initGameVars(Map vars) { + vars.put("clicks", 0); + } - quest.stateProperty().addListener((o, old, newState) -> { - System.out.println("Quest state: " + old + " -> " + newState); - }); + @Override + protected void initGame() { + var quest = getQuestService().newQuest("First Quest"); - getQuestService().startQuest(quest); - }); + // returns a ref to objective for refined control of each objective + var objective = quest.addIntObjective("Click 5 times", "clicks", 5); - onBtnDown(MouseButton.PRIMARY, () -> { - inc("clicks", +1); + quest.stateProperty().subscribe((old, newState) -> { + System.out.println("Quest state: " + old + " -> " + newState); }); - } - @Override - protected void initGameVars(Map vars) { - vars.put("clicks", 0); + getQuestService().startQuest(quest); } public static void main(String[] args) {