-
-
Notifications
You must be signed in to change notification settings - Fork 558
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: completed implementation of QuestService, closes #848
- Loading branch information
Showing
5 changed files
with
288 additions
and
74 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 ([email protected]) | ||
*/ | ||
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<QuestObjective>() | ||
private val objectivesReadOnly = FXCollections.unmodifiableObservableList(objectives) | ||
|
||
|
@@ -49,49 +59,66 @@ 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()) { | ||
log.warning("Cannot start quest $name because it has no objectives") | ||
return | ||
} | ||
|
||
if (hasStarted) { | ||
if (isStarted) { | ||
log.warning("Cannot start quest $name because it has already been started") | ||
return | ||
} | ||
|
||
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<QuestState> = 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<Boolean> { _, _, 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)) | ||
) | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 ([email protected]) | ||
*/ | ||
|
@@ -21,57 +22,53 @@ class QuestService : EngineService() { | |
private val quests = FXCollections.observableArrayList<Quest>() | ||
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<Quest> = 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() | ||
} | ||
} |
49 changes: 49 additions & 0 deletions
49
fxgl-gameplay/src/test/kotlin/com/almasb/fxgl/quest/QuestServiceTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
/* | ||
* FXGL - JavaFX Game Library. The MIT License (MIT). | ||
* Copyright (c) AlmasB ([email protected]). | ||
* 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 ([email protected]) | ||
*/ | ||
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()) | ||
} | ||
} |
Oops, something went wrong.