Skip to content

Commit

Permalink
feat: completed implementation of QuestService, closes #848
Browse files Browse the repository at this point in the history
  • Loading branch information
AlmasB committed Mar 17, 2024
1 parent 75fc7f1 commit a9c8e04
Showing 5 changed files with 288 additions and 74 deletions.
106 changes: 88 additions & 18 deletions fxgl-gameplay/src/main/kotlin/com/almasb/fxgl/quest/Quest.kt
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 (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<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))
)
57 changes: 27 additions & 30 deletions fxgl-gameplay/src/main/kotlin/com/almasb/fxgl/quest/QuestService.kt
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 (almaslvl@gmail.com)
*/
@@ -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()
}
}
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 (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())
}
}
Loading

0 comments on commit a9c8e04

Please sign in to comment.