diff --git a/build.gradle.kts b/build.gradle.kts
index 4523be52..ac8ee5ca 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -13,8 +13,8 @@ tasks.wrapper {
subprojects {
project.extra["coroutinesVersion"] = "1.3.9"
project.extra["kotlinLoggingVersion"] = "2.0.2"
- project.extra["ktorVersion"] = "1.4.1"
- project.extra["serializationVersion"] = "1.0.0"
+ project.extra["ktorVersion"] = "1.4.2"
+ project.extra["serializationVersion"] = "1.4.10"
project.extra["slf4jVersion"] = "1.7.30"
repositories {
diff --git a/core/src/commonMain/kotlin/world/phantasmal/core/math/Math.kt b/core/src/commonMain/kotlin/world/phantasmal/core/math/Math.kt
index 8517ee85..4ba78343 100644
--- a/core/src/commonMain/kotlin/world/phantasmal/core/math/Math.kt
+++ b/core/src/commonMain/kotlin/world/phantasmal/core/math/Math.kt
@@ -1,5 +1,20 @@
package world.phantasmal.core.math
+import kotlin.math.PI
+
+private const val TO_DEG = 180 / PI
+private const val TO_RAD = PI / 180
+
+/**
+ * Converts radians to degrees.
+ */
+fun radToDeg(rad: Double): Double = rad * TO_DEG
+
+/**
+ * Converts degrees to radians.
+ */
+fun degToRad(deg: Double): Double = deg * TO_RAD
+
/**
* Returns the floored modulus of its arguments. The computed value will have the same sign as the
* [divisor].
diff --git a/core/src/jvmMain/kotlin/world/phantasmal/core/PrimitiveExtensions.kt b/core/src/jvmMain/kotlin/world/phantasmal/core/PrimitiveExtensions.kt
index 9a0be331..1d36ff57 100644
--- a/core/src/jvmMain/kotlin/world/phantasmal/core/PrimitiveExtensions.kt
+++ b/core/src/jvmMain/kotlin/world/phantasmal/core/PrimitiveExtensions.kt
@@ -1,3 +1,5 @@
+@file:JvmName("PrimitiveExtensionsJvm")
+
package world.phantasmal.core
import java.lang.Float.intBitsToFloat
diff --git a/lib/src/commonMain/kotlin/world/phantasmal/lib/assembly/Disassembly.kt b/lib/src/commonMain/kotlin/world/phantasmal/lib/assembly/Disassembly.kt
index 88fe1b98..ced05eb9 100644
--- a/lib/src/commonMain/kotlin/world/phantasmal/lib/assembly/Disassembly.kt
+++ b/lib/src/commonMain/kotlin/world/phantasmal/lib/assembly/Disassembly.kt
@@ -260,7 +260,7 @@ private fun StringBuilder.appendArgs(params: List, args: List : Observable {
*/
fun flatMap(transform: (T) -> Val): Val =
FlatMappedVal(listOf(this)) { transform(value) }
+
+ fun flatMapNull(transform: (T) -> Val?): Val =
+ FlatMappedVal(listOf(this)) { transform(value) ?: nullVal() }
}
diff --git a/web/build.gradle.kts b/web/build.gradle.kts
index 2eb00754..16b90322 100644
--- a/web/build.gradle.kts
+++ b/web/build.gradle.kts
@@ -30,6 +30,7 @@ kotlin {
val kotlinLoggingVersion: String by project.extra
val ktorVersion: String by project.extra
+val serializationVersion: String by project.extra
dependencies {
implementation(project(":lib"))
@@ -38,12 +39,13 @@ dependencies {
implementation("io.github.microutils:kotlin-logging-js:$kotlinLoggingVersion")
implementation("io.ktor:ktor-client-core-js:$ktorVersion")
implementation("io.ktor:ktor-client-serialization-js:$ktorVersion")
- implementation("org.jetbrains.kotlinx:kotlinx-serialization-core-js:1.0.0")
- implementation(npm("@babylonjs/core", "^4.2.0-rc.5"))
+ implementation("org.jetbrains.kotlin:kotlin-serialization:$serializationVersion")
+ implementation(npm("@babylonjs/core", "^4.2.0"))
implementation(npm("golden-layout", "^1.5.9"))
implementation(npm("monaco-editor", "^0.21.2"))
implementation(devNpm("file-loader", "^6.0.0"))
+ implementation(devNpm("monaco-editor-webpack-plugin", "^2.0.0"))
testImplementation(kotlin("test-js"))
testImplementation(project(":test-utils"))
diff --git a/web/src/main/kotlin/world/phantasmal/web/LogFormatter.kt b/web/src/main/kotlin/world/phantasmal/web/LogFormatter.kt
new file mode 100644
index 00000000..9cd42242
--- /dev/null
+++ b/web/src/main/kotlin/world/phantasmal/web/LogFormatter.kt
@@ -0,0 +1,50 @@
+package world.phantasmal.web
+
+import mu.DefaultMessageFormatter
+import mu.Formatter
+import mu.KotlinLoggingLevel
+import mu.Marker
+import kotlin.js.Date
+
+class LogFormatter : Formatter {
+ override fun formatMessage(
+ level: KotlinLoggingLevel,
+ loggerName: String,
+ msg: () -> Any?,
+ ): String =
+ time() + DefaultMessageFormatter.formatMessage(level, loggerName, msg)
+
+ override fun formatMessage(
+ level: KotlinLoggingLevel,
+ loggerName: String,
+ t: Throwable?,
+ msg: () -> Any?,
+ ): String =
+ time() + DefaultMessageFormatter.formatMessage(level, loggerName, t, msg)
+
+ override fun formatMessage(
+ level: KotlinLoggingLevel,
+ loggerName: String,
+ marker: Marker?,
+ msg: () -> Any?,
+ ): String =
+ time() + DefaultMessageFormatter.formatMessage(level, loggerName, marker, msg)
+
+ override fun formatMessage(
+ level: KotlinLoggingLevel,
+ loggerName: String,
+ marker: Marker?,
+ t: Throwable?,
+ msg: () -> Any?,
+ ): String =
+ time() + DefaultMessageFormatter.formatMessage(level, loggerName, marker, t, msg)
+
+ private fun time(): String {
+ val date = Date()
+ val h = date.getHours().toString().padStart(2, '0')
+ val m = date.getMinutes().toString().padStart(2, '0')
+ val s = date.getSeconds().toString().padStart(2, '0')
+ val ms = date.getMilliseconds().toString().padStart(3, '0')
+ return "$h:$m:$s.$ms "
+ }
+}
diff --git a/web/src/main/kotlin/world/phantasmal/web/Main.kt b/web/src/main/kotlin/world/phantasmal/web/Main.kt
index 71bc8cec..9814d9f9 100644
--- a/web/src/main/kotlin/world/phantasmal/web/Main.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/Main.kt
@@ -32,6 +32,8 @@ fun main() {
}
private fun init(): Disposable {
+ KotlinLoggingConfiguration.FORMATTER = LogFormatter()
+
if (window.location.hostname == "localhost") {
KotlinLoggingConfiguration.LOG_LEVEL = KotlinLoggingLevel.TRACE
}
diff --git a/web/src/main/kotlin/world/phantasmal/web/core/BabylonExtensions.kt b/web/src/main/kotlin/world/phantasmal/web/core/BabylonExtensions.kt
index 5006bab2..5131f6ce 100644
--- a/web/src/main/kotlin/world/phantasmal/web/core/BabylonExtensions.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/core/BabylonExtensions.kt
@@ -53,6 +53,13 @@ operator fun Quaternion.timesAssign(other: Quaternion) {
*/
fun Quaternion.inverse(): Quaternion = Quaternion.Inverse(this)
+/**
+ * Inverts this quaternion.
+ */
+fun Quaternion.invert() {
+ Quaternion.InverseToRef(this, this)
+}
+
/**
* Transforms [p] by this versor.
*/
diff --git a/web/src/main/kotlin/world/phantasmal/web/externals/babylon/babylon.kt b/web/src/main/kotlin/world/phantasmal/web/externals/babylon/babylon.kt
index 71f82975..e3bf6b6b 100644
--- a/web/src/main/kotlin/world/phantasmal/web/externals/babylon/babylon.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/externals/babylon/babylon.kt
@@ -109,6 +109,7 @@ external class Quaternion(
fun FromEulerAnglesToRef(x: Double, y: Double, z: Double, result: Quaternion): Quaternion
fun RotationYawPitchRoll(yaw: Double, pitch: Double, roll: Double): Quaternion
fun Inverse(q: Quaternion): Quaternion
+ fun InverseToRef(q: Quaternion, result: Quaternion): Quaternion
}
}
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/QuestEditor.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/QuestEditor.kt
index 47b6b48b..7e116987 100644
--- a/web/src/main/kotlin/world/phantasmal/web/questEditor/QuestEditor.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/QuestEditor.kt
@@ -8,10 +8,7 @@ import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.externals.babylon.Engine
-import world.phantasmal.web.questEditor.controllers.AssemblyEditorController
-import world.phantasmal.web.questEditor.controllers.NpcCountsController
-import world.phantasmal.web.questEditor.controllers.QuestEditorToolbarController
-import world.phantasmal.web.questEditor.controllers.QuestInfoController
+import world.phantasmal.web.questEditor.controllers.*
import world.phantasmal.web.questEditor.loading.AreaAssetLoader
import world.phantasmal.web.questEditor.loading.EntityAssetLoader
import world.phantasmal.web.questEditor.loading.QuestLoader
@@ -56,6 +53,7 @@ class QuestEditor(
))
val questInfoController = addDisposable(QuestInfoController(questEditorStore))
val npcCountsController = addDisposable(NpcCountsController(questEditorStore))
+ val entityInfoController = addDisposable(EntityInfoController(questEditorStore))
val assemblyEditorController = addDisposable(AssemblyEditorController(assemblyEditorStore))
// Rendering
@@ -76,6 +74,7 @@ class QuestEditor(
{ s -> QuestEditorToolbarWidget(s, toolbarController) },
{ s -> QuestInfoWidget(s, questInfoController) },
{ s -> NpcCountsWidget(s, npcCountsController) },
+ { s -> EntityInfoWidget(s, entityInfoController) },
{ s -> QuestEditorRendererWidget(s, canvas, renderer) },
{ s -> AssemblyEditorWidget(s, assemblyEditorController) },
)
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/EditIdAction.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/EditIdAction.kt
new file mode 100644
index 00000000..9fc7728f
--- /dev/null
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/EditIdAction.kt
@@ -0,0 +1,20 @@
+package world.phantasmal.web.questEditor.actions
+
+import world.phantasmal.web.core.actions.Action
+import world.phantasmal.web.questEditor.models.QuestModel
+
+class EditIdAction(
+ private val quest: QuestModel,
+ private val newId: Int,
+ private val oldId: Int,
+) : Action {
+ override val description: String = "Edit ID"
+
+ override fun execute() {
+ quest.setId(newId)
+ }
+
+ override fun undo() {
+ quest.setId(oldId)
+ }
+}
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/EditLongDescriptionAction.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/EditLongDescriptionAction.kt
new file mode 100644
index 00000000..7b2f7c94
--- /dev/null
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/EditLongDescriptionAction.kt
@@ -0,0 +1,20 @@
+package world.phantasmal.web.questEditor.actions
+
+import world.phantasmal.web.core.actions.Action
+import world.phantasmal.web.questEditor.models.QuestModel
+
+class EditLongDescriptionAction(
+ private val quest: QuestModel,
+ private val newLongDescription: String,
+ private val oldLongDescription: String,
+) : Action {
+ override val description: String = "Edit long description"
+
+ override fun execute() {
+ quest.setLongDescription(newLongDescription)
+ }
+
+ override fun undo() {
+ quest.setLongDescription(oldLongDescription)
+ }
+}
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/EditNameAction.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/EditNameAction.kt
new file mode 100644
index 00000000..894f730e
--- /dev/null
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/EditNameAction.kt
@@ -0,0 +1,20 @@
+package world.phantasmal.web.questEditor.actions
+
+import world.phantasmal.web.core.actions.Action
+import world.phantasmal.web.questEditor.models.QuestModel
+
+class EditNameAction(
+ private val quest: QuestModel,
+ private val newName: String,
+ private val oldName: String,
+) : Action {
+ override val description: String = "Edit name"
+
+ override fun execute() {
+ quest.setName(newName)
+ }
+
+ override fun undo() {
+ quest.setName(oldName)
+ }
+}
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/EditShortDescriptionAction.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/EditShortDescriptionAction.kt
new file mode 100644
index 00000000..4995767c
--- /dev/null
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/EditShortDescriptionAction.kt
@@ -0,0 +1,20 @@
+package world.phantasmal.web.questEditor.actions
+
+import world.phantasmal.web.core.actions.Action
+import world.phantasmal.web.questEditor.models.QuestModel
+
+class EditShortDescriptionAction(
+ private val quest: QuestModel,
+ private val newShortDescription: String,
+ private val oldShortDescription: String,
+) : Action {
+ override val description: String = "Edit short description"
+
+ override fun execute() {
+ quest.setShortDescription(newShortDescription)
+ }
+
+ override fun undo() {
+ quest.setShortDescription(oldShortDescription)
+ }
+}
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/RotateEntityAction.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/RotateEntityAction.kt
new file mode 100644
index 00000000..9e3aa06a
--- /dev/null
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/RotateEntityAction.kt
@@ -0,0 +1,35 @@
+package world.phantasmal.web.questEditor.actions
+
+import world.phantasmal.web.core.actions.Action
+import world.phantasmal.web.externals.babylon.Vector3
+import world.phantasmal.web.questEditor.models.QuestEntityModel
+
+class RotateEntityAction(
+ private val setSelectedEntity: (QuestEntityModel<*, *>) -> Unit,
+ private val entity: QuestEntityModel<*, *>,
+ private val newRotation: Vector3,
+ private val oldRotation: Vector3,
+ private val world: Boolean,
+) : Action {
+ override val description: String = "Rotate ${entity.type.simpleName}"
+
+ override fun execute() {
+ setSelectedEntity(entity)
+
+ if (world) {
+ entity.setWorldRotation(newRotation)
+ } else {
+ entity.setRotation(newRotation)
+ }
+ }
+
+ override fun undo() {
+ setSelectedEntity(entity)
+
+ if (world) {
+ entity.setWorldRotation(oldRotation)
+ } else {
+ entity.setRotation(oldRotation)
+ }
+ }
+}
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/TranslateEntityAction.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/TranslateEntityAction.kt
index 61498df9..e0ceaee9 100644
--- a/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/TranslateEntityAction.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/actions/TranslateEntityAction.kt
@@ -8,10 +8,10 @@ import world.phantasmal.web.questEditor.models.SectionModel
class TranslateEntityAction(
private val setSelectedEntity: (QuestEntityModel<*, *>) -> Unit,
private val entity: QuestEntityModel<*, *>,
- private val oldSection: SectionModel?,
private val newSection: SectionModel?,
- private val oldPosition: Vector3,
+ private val oldSection: SectionModel?,
private val newPosition: Vector3,
+ private val oldPosition: Vector3,
private val world: Boolean,
) : Action {
override val description: String = "Move ${entity.type.simpleName}"
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/controllers/EntityInfoController.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/controllers/EntityInfoController.kt
new file mode 100644
index 00000000..50ed2fe8
--- /dev/null
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/controllers/EntityInfoController.kt
@@ -0,0 +1,117 @@
+package world.phantasmal.web.questEditor.controllers
+
+import world.phantasmal.core.math.degToRad
+import world.phantasmal.core.math.radToDeg
+import world.phantasmal.observable.value.Val
+import world.phantasmal.observable.value.isNull
+import world.phantasmal.observable.value.value
+import world.phantasmal.web.externals.babylon.Vector3
+import world.phantasmal.web.questEditor.actions.RotateEntityAction
+import world.phantasmal.web.questEditor.actions.TranslateEntityAction
+import world.phantasmal.web.questEditor.models.QuestEntityModel
+import world.phantasmal.web.questEditor.models.QuestNpcModel
+import world.phantasmal.web.questEditor.stores.QuestEditorStore
+import world.phantasmal.webui.controllers.Controller
+
+class EntityInfoController(private val store: QuestEditorStore) : Controller() {
+ val unavailable: Val = store.selectedEntity.isNull()
+ val enabled: Val = store.questEditingEnabled
+
+ val type: Val = store.selectedEntity.map {
+ it?.let { if (it is QuestNpcModel) "NPC" else "Object" } ?: ""
+ }
+
+ val name: Val = store.selectedEntity.map { it?.type?.simpleName ?: "" }
+
+ val sectionId: Val = store.selectedEntity
+ .flatMapNull { it?.sectionId }
+ .map { it?.toString() ?: "" }
+
+ val wave: Val = store.selectedEntity
+ .flatMapNull { entity -> (entity as? QuestNpcModel)?.wave?.flatMapNull { it?.id } }
+ .map { it?.toString() ?: "" }
+
+ val waveHidden: Val = store.selectedEntity.map { it !is QuestNpcModel }
+
+ private val pos: Val = store.selectedEntity.flatMap { it?.position ?: DEFAULT_VECTOR }
+ val posX: Val = pos.map { it.x }
+ val posY: Val = pos.map { it.y }
+ val posZ: Val = pos.map { it.z }
+
+ private val rot: Val = store.selectedEntity.flatMap { it?.rotation ?: DEFAULT_VECTOR }
+ val rotX: Val = rot.map { radToDeg(it.x) }
+ val rotY: Val = rot.map { radToDeg(it.y) }
+ val rotZ: Val = rot.map { radToDeg(it.z) }
+
+ fun setPosX(x: Double) {
+ store.selectedEntity.value?.let { entity ->
+ val pos = entity.position.value
+ setPos(entity, x, pos.y, pos.z)
+ }
+ }
+
+ fun setPosY(y: Double) {
+ store.selectedEntity.value?.let { entity ->
+ val pos = entity.position.value
+ setPos(entity, pos.x, y, pos.z)
+ }
+ }
+
+ fun setPosZ(z: Double) {
+ store.selectedEntity.value?.let { entity ->
+ val pos = entity.position.value
+ setPos(entity, pos.x, pos.y, z)
+ }
+ }
+
+ private fun setPos(entity: QuestEntityModel<*, *>, x: Double, y: Double, z: Double) {
+ if (!enabled.value) return
+
+ store.executeAction(TranslateEntityAction(
+ setSelectedEntity = store::setSelectedEntity,
+ entity,
+ entity.section.value,
+ entity.section.value,
+ Vector3(x, y, z),
+ entity.position.value,
+ false,
+ ))
+ }
+
+ fun setRotX(x: Double) {
+ store.selectedEntity.value?.let { entity ->
+ val rot = entity.rotation.value
+ setRot(entity, degToRad(x), rot.y, rot.z)
+ }
+ }
+
+ fun setRotY(y: Double) {
+ store.selectedEntity.value?.let { entity ->
+ val rot = entity.rotation.value
+ setRot(entity, rot.x, degToRad(y), rot.z)
+ }
+ }
+
+ fun setRotZ(z: Double) {
+ store.selectedEntity.value?.let { entity ->
+ val rot = entity.rotation.value
+ setRot(entity, rot.x, rot.y, degToRad(z))
+ }
+ }
+
+ private fun setRot(entity: QuestEntityModel<*, *>, x: Double, y: Double, z: Double) {
+ if (!enabled.value) return
+
+ store.executeAction(RotateEntityAction(
+ setSelectedEntity = store::setSelectedEntity,
+ entity,
+ Vector3(x, y, z),
+ entity.rotation.value,
+ false,
+ ))
+ }
+
+ companion object {
+ private val DEFAULT_VECTOR = value(Vector3.Zero())
+ }
+}
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/controllers/QuestInfoController.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/controllers/QuestInfoController.kt
index 5a057840..3696a501 100644
--- a/web/src/main/kotlin/world/phantasmal/web/questEditor/controllers/QuestInfoController.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/controllers/QuestInfoController.kt
@@ -3,10 +3,14 @@ package world.phantasmal.web.questEditor.controllers
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.isNull
import world.phantasmal.observable.value.value
+import world.phantasmal.web.questEditor.actions.EditIdAction
+import world.phantasmal.web.questEditor.actions.EditLongDescriptionAction
+import world.phantasmal.web.questEditor.actions.EditNameAction
+import world.phantasmal.web.questEditor.actions.EditShortDescriptionAction
import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.webui.controllers.Controller
-class QuestInfoController(store: QuestEditorStore) : Controller() {
+class QuestInfoController(private val store: QuestEditorStore) : Controller() {
val unavailable: Val = store.currentQuest.isNull()
val enabled: Val = store.questEditingEnabled
@@ -17,4 +21,40 @@ class QuestInfoController(store: QuestEditorStore) : Controller() {
store.currentQuest.flatMap { it?.shortDescription ?: value("") }
val longDescription: Val =
store.currentQuest.flatMap { it?.longDescription ?: value("") }
+
+ fun setId(id: Int) {
+ if (!enabled.value) return
+
+ store.currentQuest.value?.let { quest ->
+ store.executeAction(EditIdAction(quest, id, quest.id.value))
+ }
+ }
+
+ fun setName(name: String) {
+ if (!enabled.value) return
+
+ store.currentQuest.value?.let { quest ->
+ store.executeAction(EditNameAction(quest, name, quest.name.value))
+ }
+ }
+
+ fun setShortDescription(shortDescription: String) {
+ if (!enabled.value) return
+
+ store.currentQuest.value?.let { quest ->
+ store.executeAction(
+ EditShortDescriptionAction(quest, shortDescription, quest.shortDescription.value)
+ )
+ }
+ }
+
+ fun setLongDescription(longDescription: String) {
+ if (!enabled.value) return
+
+ store.currentQuest.value?.let { quest ->
+ store.executeAction(
+ EditLongDescriptionAction(quest, longDescription, quest.longDescription.value)
+ )
+ }
+ }
}
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/models/QuestEntityModel.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/models/QuestEntityModel.kt
index fa20e091..bc39b10a 100644
--- a/web/src/main/kotlin/world/phantasmal/web/questEditor/models/QuestEntityModel.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/models/QuestEntityModel.kt
@@ -98,6 +98,7 @@ abstract class QuestEntityModel>(
floorModEuler(rot)
entity.rotation = babylonToVec3(rot)
+ _rotation.value = rot
val section = section.value
@@ -118,6 +119,34 @@ abstract class QuestEntityModel>(
}
}
+ fun setWorldRotation(rot: Vector3) {
+ floorModEuler(rot)
+
+ _worldRotation.value = rot
+
+ val section = section.value
+
+ val relRot = if (section == null) {
+ rot
+ } else {
+ Quaternion.FromEulerAnglesToRef(rot.x, rot.y, rot.z, q1)
+ Quaternion.FromEulerAnglesToRef(
+ section.rotation.x,
+ section.rotation.y,
+ section.rotation.z,
+ q2
+ )
+ q2.invert()
+ q1 *= q2
+ val relRot = q1.toEulerAngles()
+ floorModEuler(relRot)
+ relRot
+ }
+
+ entity.rotation = babylonToVec3(relRot)
+ _rotation.value = relRot
+ }
+
private fun floorModEuler(euler: Vector3) {
euler.set(
floorMod(euler.x, 2 * PI),
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/models/WaveModel.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/models/WaveModel.kt
index 63cfd407..6c137298 100644
--- a/web/src/main/kotlin/world/phantasmal/web/questEditor/models/WaveModel.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/models/WaveModel.kt
@@ -1,3 +1,14 @@
package world.phantasmal.web.questEditor.models
-class WaveModel
+import world.phantasmal.observable.value.Val
+import world.phantasmal.observable.value.mutableVal
+
+class WaveModel(id: Int, areaId: Int, sectionId: Int) {
+ private val _id = mutableVal(id)
+ private val _areaId = mutableVal(areaId)
+ private val _sectionId = mutableVal(sectionId)
+
+ val id: Val = _id
+ val areaId: Val = _areaId
+ val sectionId: Val = _sectionId
+}
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/UserInputManager.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/UserInputManager.kt
index 9b4b919d..a23df264 100644
--- a/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/UserInputManager.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/rendering/UserInputManager.kt
@@ -8,6 +8,7 @@ import world.phantasmal.web.core.minus
import world.phantasmal.web.core.plusAssign
import world.phantasmal.web.core.times
import world.phantasmal.web.externals.babylon.*
+import world.phantasmal.web.questEditor.actions.TranslateEntityAction
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.SectionModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
@@ -159,20 +160,21 @@ private class StateContext(
fun finalizeTranslation(
entity: QuestEntityModel<*, *>,
- oldSection: SectionModel?,
newSection: SectionModel?,
- oldPosition: Vector3,
+ oldSection: SectionModel?,
newPosition: Vector3,
+ oldPosition: Vector3,
world: Boolean,
) {
- questEditorStore.translateEntity(
+ questEditorStore.executeAction(TranslateEntityAction(
+ ::setSelectedEntity,
entity,
- oldSection,
newSection,
- oldPosition,
+ oldSection,
newPosition,
- world
- )
+ oldPosition,
+ world,
+ ))
}
/**
@@ -446,10 +448,10 @@ private class TranslationState(
if (!cancelled && event.movedSinceLastPointerDown) {
ctx.finalizeTranslation(
entity,
- initialSection,
entity.section.value,
- initialPosition,
+ initialSection,
entity.worldPosition.value,
+ initialPosition,
true,
)
}
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/QuestEditorStore.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/QuestEditorStore.kt
index 854000b1..620899c5 100644
--- a/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/QuestEditorStore.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/QuestEditorStore.kt
@@ -4,13 +4,11 @@ import kotlinx.coroutines.CoroutineScope
import mu.KotlinLogging
import world.phantasmal.observable.value.*
import world.phantasmal.web.core.PwToolType
-import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.core.actions.Action
+import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.core.undo.UndoManager
import world.phantasmal.web.core.undo.UndoStack
-import world.phantasmal.web.externals.babylon.Vector3
import world.phantasmal.web.questEditor.QuestRunner
-import world.phantasmal.web.questEditor.actions.TranslateEntityAction
import world.phantasmal.web.questEditor.models.*
import world.phantasmal.webui.stores.Store
@@ -128,22 +126,15 @@ class QuestEditorStore(
_selectedEntity.value = entity
}
- fun translateEntity(
- entity: QuestEntityModel<*, *>,
- oldSection: SectionModel?,
- newSection: SectionModel?,
- oldPosition: Vector3,
- newPosition: Vector3,
- world: Boolean,
- ) {
- mainUndo.push(TranslateEntityAction(
- ::setSelectedEntity,
- entity,
- oldSection,
- newSection,
- oldPosition,
- newPosition,
- world,
- )).execute()
+ fun executeAction(action: Action) {
+ require(questEditingEnabled.value) {
+ val reason = when {
+ currentQuest.value == null -> " (no current quest)"
+ runner.running.value -> " (QuestRunner is running)"
+ else -> ""
+ }
+ "Quest editing is disabled at the moment$reason."
+ }
+ mainUndo.push(action).execute()
}
}
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/EntityInfoWidget.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/EntityInfoWidget.kt
new file mode 100644
index 00000000..19513112
--- /dev/null
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/EntityInfoWidget.kt
@@ -0,0 +1,159 @@
+package world.phantasmal.web.questEditor.widgets
+
+import kotlinx.coroutines.CoroutineScope
+import org.w3c.dom.Node
+import world.phantasmal.web.core.widgets.UnavailableWidget
+import world.phantasmal.web.questEditor.controllers.EntityInfoController
+import world.phantasmal.webui.dom.*
+import world.phantasmal.webui.widgets.DoubleInput
+import world.phantasmal.webui.widgets.Widget
+
+class EntityInfoWidget(
+ scope: CoroutineScope,
+ private val ctrl: EntityInfoController,
+) : Widget(scope, enabled = ctrl.enabled) {
+ override fun Node.createElement() =
+ div {
+ className = "pw-quest-editor-entity-info"
+ tabIndex = -1
+
+ table {
+ hidden(ctrl.unavailable)
+
+ tr {
+ th { textContent = "Type:" }
+ td { text(ctrl.type) }
+ }
+ tr {
+ th { textContent = "Name:" }
+ td { text(ctrl.name) }
+ }
+ tr {
+ th { textContent = "Section:" }
+ td { text(ctrl.sectionId) }
+ }
+ tr {
+ hidden(ctrl.waveHidden)
+
+ th { textContent = "Wave:" }
+ td { text(ctrl.wave) }
+ }
+ tr {
+ th { colSpan = 2; textContent = "Position:" }
+ }
+ tr {
+ th { className = COORD_CLASS; textContent = "X:" }
+ td {
+ addChild(DoubleInput(
+ this@EntityInfoWidget.scope,
+ value = ctrl.posX,
+ onChange = ctrl::setPosX,
+ roundTo = 3,
+ ))
+ }
+ }
+ tr {
+ th { className = COORD_CLASS; textContent = "Y:" }
+ td {
+ addChild(DoubleInput(
+ this@EntityInfoWidget.scope,
+ value = ctrl.posY,
+ onChange = ctrl::setPosY,
+ roundTo = 3,
+ ))
+ }
+ }
+ tr {
+ th { className = COORD_CLASS; textContent = "Z:" }
+ td {
+ addChild(DoubleInput(
+ this@EntityInfoWidget.scope,
+ value = ctrl.posZ,
+ onChange = ctrl::setPosZ,
+ roundTo = 3,
+ ))
+ }
+ }
+ tr {
+ th { colSpan = 2; textContent = "Rotation:" }
+ }
+ tr {
+ th { className = COORD_CLASS; textContent = "X:" }
+ td {
+ addChild(DoubleInput(
+ this@EntityInfoWidget.scope,
+ value = ctrl.rotX,
+ onChange = ctrl::setRotX,
+ roundTo = 3,
+ ))
+ }
+ }
+ tr {
+ th { className = COORD_CLASS; textContent = "Y:" }
+ td {
+ addChild(DoubleInput(
+ this@EntityInfoWidget.scope,
+ value = ctrl.rotY,
+ onChange = ctrl::setRotY,
+ roundTo = 3,
+ ))
+ }
+ }
+ tr {
+ th { className = COORD_CLASS; textContent = "Z:" }
+ td {
+ addChild(DoubleInput(
+ this@EntityInfoWidget.scope,
+ value = ctrl.rotZ,
+ onChange = ctrl::setRotZ,
+ roundTo = 3,
+ ))
+ }
+ }
+ }
+ addChild(UnavailableWidget(
+ scope,
+ visible = ctrl.unavailable,
+ message = "No entity selected.",
+ ))
+ }
+
+ companion object {
+ private const val COORD_CLASS = "pw-quest-editor-entity-info-coord"
+
+ init {
+ @Suppress("CssUnusedSymbol")
+ // language=css
+ style("""
+ .pw-quest-editor-entity-info {
+ outline: none;
+ box-sizing: border-box;
+ padding: 3px;
+ overflow: auto;
+ }
+
+ .pw-quest-editor-entity-info table {
+ table-layout: fixed;
+ width: 100%;
+ margin: 0 auto;
+ }
+
+ .pw-quest-editor-entity-info th {
+ text-align: left;
+ }
+
+ .pw-quest-editor-entity-info th.pw-quest-editor-entity-info-coord {
+ padding-left: 10px;
+ }
+
+ .pw-quest-editor-entity-info .pw-number-input {
+ width: 100%;
+ }
+
+ .pw-quest-editor-entity-info table.pw-quest-editor-entity-info-specific-props {
+ margin-top: -2px;
+ }
+ """.trimIndent())
+ }
+ }
+}
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorWidget.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorWidget.kt
index 23cf1286..3510e02e 100644
--- a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorWidget.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestEditorWidget.kt
@@ -18,13 +18,14 @@ private class TestWidget(scope: CoroutineScope) : Widget(scope) {
}
/**
- * Takes ownership of the widgets created by the given createWidget functions.
+ * Takes ownership of the widgets created by the given creation functions.
*/
class QuestEditorWidget(
scope: CoroutineScope,
private val createToolbar: (CoroutineScope) -> Widget,
private val createQuestInfoWidget: (CoroutineScope) -> Widget,
private val createNpcCountsWidget: (CoroutineScope) -> Widget,
+ private val createEntityInfoWidget: (CoroutineScope) -> Widget,
private val createQuestRendererWidget: (CoroutineScope) -> Widget,
private val createAssemblyEditorWidget: (CoroutineScope) -> Widget,
) : Widget(scope) {
@@ -57,7 +58,7 @@ class QuestEditorWidget(
DockedWidget(
title = "Entity",
id = "entity_info",
- createWidget = ::TestWidget
+ createWidget = createEntityInfoWidget
),
)
),
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestInfoWidget.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestInfoWidget.kt
index a2c3c450..605ac8d0 100644
--- a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestInfoWidget.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/QuestInfoWidget.kt
@@ -32,7 +32,8 @@ class QuestInfoWidget(
addChild(IntInput(
this@QuestInfoWidget.scope,
enabled = ctrl.enabled,
- valueVal = ctrl.id,
+ value = ctrl.id,
+ onChange = ctrl::setId,
min = 0,
step = 1,
))
@@ -44,7 +45,8 @@ class QuestInfoWidget(
addChild(TextInput(
this@QuestInfoWidget.scope,
enabled = ctrl.enabled,
- valueVal = ctrl.name,
+ value = ctrl.name,
+ onChange = ctrl::setName,
maxLength = 32,
))
}
@@ -61,7 +63,8 @@ class QuestInfoWidget(
addChild(TextArea(
this@QuestInfoWidget.scope,
enabled = ctrl.enabled,
- valueVal = ctrl.shortDescription,
+ value = ctrl.shortDescription,
+ onChange = ctrl::setShortDescription,
maxLength = 128,
fontFamily = "\"Courier New\", monospace",
cols = 25,
@@ -81,7 +84,8 @@ class QuestInfoWidget(
addChild(TextArea(
this@QuestInfoWidget.scope,
enabled = ctrl.enabled,
- valueVal = ctrl.longDescription,
+ value = ctrl.longDescription,
+ onChange = ctrl::setLongDescription,
maxLength = 288,
fontFamily = "\"Courier New\", monospace",
cols = 25,
diff --git a/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/EntityInfoControllerTests.kt b/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/EntityInfoControllerTests.kt
new file mode 100644
index 00000000..d9c285a2
--- /dev/null
+++ b/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/EntityInfoControllerTests.kt
@@ -0,0 +1,119 @@
+package world.phantasmal.web.questEditor.controllers
+
+import world.phantasmal.lib.fileFormats.Vec3
+import world.phantasmal.lib.fileFormats.quest.Episode
+import world.phantasmal.lib.fileFormats.quest.NpcType
+import world.phantasmal.lib.fileFormats.quest.QuestNpc
+import world.phantasmal.testUtils.assertCloseTo
+import world.phantasmal.web.questEditor.models.QuestNpcModel
+import world.phantasmal.web.questEditor.models.WaveModel
+import world.phantasmal.web.test.WebTestSuite
+import world.phantasmal.web.test.createQuestModel
+import world.phantasmal.web.test.createQuestNpcModel
+import kotlin.math.PI
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class EntityInfoControllerTests : WebTestSuite() {
+ @Test
+ fun test_unavailable_and_enabled() = asyncTest {
+ val ctrl = EntityInfoController(components.questEditorStore)
+
+ assertTrue(ctrl.unavailable.value)
+ assertFalse(ctrl.enabled.value)
+
+ val npc = createQuestNpcModel(NpcType.Principal, Episode.I)
+ components.questEditorStore.setCurrentQuest(createQuestModel(npcs = listOf(npc)))
+
+ assertTrue(ctrl.unavailable.value)
+ assertTrue(ctrl.enabled.value)
+
+ components.questEditorStore.setSelectedEntity(npc)
+
+ assertFalse(ctrl.unavailable.value)
+ assertTrue(ctrl.enabled.value)
+ }
+
+ @Test
+ fun can_read_regular_properties() = asyncTest {
+ val ctrl = EntityInfoController(components.questEditorStore)
+
+ val questNpc = QuestNpc(NpcType.Booma, Episode.I, areaId = 10, wave = 5)
+ questNpc.sectionId = 7
+ questNpc.position = Vec3(8f, 16f, 32f)
+ questNpc.rotation = Vec3(PI.toFloat() / 4, PI.toFloat() / 2, PI.toFloat())
+ val npc = QuestNpcModel(questNpc, WaveModel(5, 10, 7))
+ components.questEditorStore.setCurrentQuest(createQuestModel(npcs = listOf(npc)))
+ components.questEditorStore.setSelectedEntity(npc)
+
+ assertEquals("NPC", ctrl.type.value)
+ assertEquals("Booma", ctrl.name.value)
+ assertEquals("7", ctrl.sectionId.value)
+ assertEquals("5", ctrl.wave.value)
+ assertFalse(ctrl.waveHidden.value)
+ assertEquals(8.0, ctrl.posX.value)
+ assertEquals(16.0, ctrl.posY.value)
+ assertEquals(32.0, ctrl.posZ.value)
+ assertCloseTo(45.0, ctrl.rotX.value)
+ assertCloseTo(90.0, ctrl.rotY.value)
+ assertCloseTo(180.0, ctrl.rotZ.value)
+ }
+
+ @Test
+ fun can_set_regular_properties_undo_and_redo() = asyncTest {
+ val ctrl = EntityInfoController(components.questEditorStore)
+
+ val npc = createQuestNpcModel(NpcType.Principal, Episode.I)
+ components.questEditorStore.setCurrentQuest(createQuestModel(npcs = listOf(npc)))
+ components.questEditorStore.setSelectedEntity(npc)
+
+ ctrl.setPosX(3.15)
+ ctrl.setPosY(4.15)
+ ctrl.setPosZ(5.15)
+
+ ctrl.setRotX(50.0)
+ ctrl.setRotY(25.4)
+ ctrl.setRotZ(12.5)
+
+ assertEquals(3.15, ctrl.posX.value)
+ assertEquals(4.15, ctrl.posY.value)
+ assertEquals(5.15, ctrl.posZ.value)
+
+ assertCloseTo(50.0, ctrl.rotX.value)
+ assertCloseTo(25.4, ctrl.rotY.value)
+ assertCloseTo(12.5, ctrl.rotZ.value)
+
+ components.questEditorStore.makeMainUndoCurrent()
+ components.questEditorStore.undo()
+ components.questEditorStore.undo()
+ components.questEditorStore.undo()
+ components.questEditorStore.undo()
+ components.questEditorStore.undo()
+ components.questEditorStore.undo()
+
+ assertEquals(0.0, ctrl.posX.value)
+ assertEquals(0.0, ctrl.posY.value)
+ assertEquals(0.0, ctrl.posZ.value)
+
+ assertEquals(0.0, ctrl.rotX.value)
+ assertEquals(0.0, ctrl.rotY.value)
+ assertEquals(0.0, ctrl.rotZ.value)
+
+ components.questEditorStore.redo()
+ components.questEditorStore.redo()
+ components.questEditorStore.redo()
+ components.questEditorStore.redo()
+ components.questEditorStore.redo()
+ components.questEditorStore.redo()
+
+ assertEquals(3.15, ctrl.posX.value)
+ assertEquals(4.15, ctrl.posY.value)
+ assertEquals(5.15, ctrl.posZ.value)
+
+ assertCloseTo(50.0, ctrl.rotX.value)
+ assertCloseTo(25.4, ctrl.rotY.value)
+ assertCloseTo(12.5, ctrl.rotZ.value)
+ }
+}
diff --git a/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestEditorToolbarControllerTests.kt b/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestEditorToolbarControllerTests.kt
index b134e4ff..c60bc1b5 100644
--- a/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestEditorToolbarControllerTests.kt
+++ b/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestEditorToolbarControllerTests.kt
@@ -6,6 +6,8 @@ import world.phantasmal.core.Severity
import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.lib.fileFormats.quest.NpcType
import world.phantasmal.web.externals.babylon.Vector3
+import world.phantasmal.web.questEditor.actions.EditNameAction
+import world.phantasmal.web.questEditor.actions.TranslateEntityAction
import world.phantasmal.web.test.WebTestSuite
import world.phantasmal.web.test.createQuestModel
import world.phantasmal.web.test.createQuestNpcModel
@@ -69,7 +71,8 @@ class QuestEditorToolbarControllerTests : WebTestSuite() {
// Load quest.
val npc = createQuestNpcModel(NpcType.Scientist, Episode.I)
- components.questEditorStore.setCurrentQuest(createQuestModel(npcs = listOf(npc)))
+ val quest = createQuestModel(name = "Old Name", npcs = listOf(npc))
+ components.questEditorStore.setCurrentQuest(quest)
assertEquals(nothingToUndo, ctrl.undoTooltip.value)
assertFalse(ctrl.undoEnabled.value)
@@ -78,16 +81,9 @@ class QuestEditorToolbarControllerTests : WebTestSuite() {
assertFalse(ctrl.redoEnabled.value)
// Add an action to the undo stack.
- components.questEditorStore.translateEntity(
- npc,
- null,
- null,
- Vector3.Zero(),
- Vector3.Up(),
- true,
- )
+ components.questEditorStore.executeAction(EditNameAction(quest, "New Name", quest.name.value))
- assertEquals("Undo \"Move Scientist\" (Ctrl-Z)", ctrl.undoTooltip.value)
+ assertEquals("Undo \"Edit name\" (Ctrl-Z)", ctrl.undoTooltip.value)
assertTrue(ctrl.undoEnabled.value)
assertEquals(nothingToRedo, ctrl.redoTooltip.value)
@@ -99,7 +95,7 @@ class QuestEditorToolbarControllerTests : WebTestSuite() {
assertEquals(nothingToUndo, ctrl.undoTooltip.value)
assertFalse(ctrl.undoEnabled.value)
- assertEquals("Redo \"Move Scientist\" (Ctrl-Shift-Z)", ctrl.redoTooltip.value)
+ assertEquals("Redo \"Edit name\" (Ctrl-Shift-Z)", ctrl.redoTooltip.value)
assertTrue(ctrl.redoEnabled.value)
}
diff --git a/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestInfoControllerTests.kt b/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestInfoControllerTests.kt
index c447362b..996bad6e 100644
--- a/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestInfoControllerTests.kt
+++ b/web/src/test/kotlin/world/phantasmal/web/questEditor/controllers/QuestInfoControllerTests.kt
@@ -33,4 +33,56 @@ class QuestInfoControllerTests : WebTestSuite() {
assertEquals("A short description.", ctrl.shortDescription.value)
assertEquals("A long description.", ctrl.longDescription.value)
}
+
+ @Test
+ fun can_edit_simple_properties_undo_edits_and_redo_edits() = asyncTest {
+ val store = components.questEditorStore
+ val ctrl = disposer.add(QuestInfoController(store))
+
+ store.setCurrentQuest(createQuestModel(
+ id = 1,
+ name = "name 1",
+ shortDescription = "short 1",
+ longDescription = "long 1",
+ episode = Episode.II
+ ))
+
+ assertTrue(ctrl.enabled.value)
+
+ assertEquals(1, ctrl.id.value)
+ assertEquals("name 1", ctrl.name.value)
+ assertEquals("short 1", ctrl.shortDescription.value)
+ assertEquals("long 1", ctrl.longDescription.value)
+
+ ctrl.setId(2)
+ ctrl.setName("name 2")
+ ctrl.setShortDescription("short 2")
+ ctrl.setLongDescription("long 2")
+
+ assertEquals(2, ctrl.id.value)
+ assertEquals("name 2", ctrl.name.value)
+ assertEquals("short 2", ctrl.shortDescription.value)
+ assertEquals("long 2", ctrl.longDescription.value)
+
+ store.makeMainUndoCurrent()
+ store.undo()
+ store.undo()
+ store.undo()
+ store.undo()
+
+ assertEquals(1, ctrl.id.value)
+ assertEquals("name 1", ctrl.name.value)
+ assertEquals("short 1", ctrl.shortDescription.value)
+ assertEquals("long 1", ctrl.longDescription.value)
+
+ store.redo()
+ store.redo()
+ store.redo()
+ store.redo()
+
+ assertEquals(2, ctrl.id.value)
+ assertEquals("name 2", ctrl.name.value)
+ assertEquals("short 2", ctrl.shortDescription.value)
+ assertEquals("long 2", ctrl.longDescription.value)
+ }
}
diff --git a/web/webpack.config.d/webpack.config.js b/web/webpack.config.d/webpack.config.js
index 0bb64d61..624ee716 100644
--- a/web/webpack.config.d/webpack.config.js
+++ b/web/webpack.config.d/webpack.config.js
@@ -1,4 +1,12 @@
+const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin");
+
config.module.rules.push({
test: /\.(gif|jpg|png|svg|ttf)$/,
loader: "file-loader",
});
+
+config.plugins.push(
+ new MonacoWebpackPlugin({
+ languages: [],
+ })
+);
diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/DoubleInput.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/DoubleInput.kt
index 39234b24..e8f9034d 100644
--- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/DoubleInput.kt
+++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/DoubleInput.kt
@@ -5,6 +5,8 @@ import org.w3c.dom.HTMLInputElement
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
+import world.phantasmal.observable.value.value
+import kotlin.math.abs
import kotlin.math.pow
import kotlin.math.round
@@ -16,8 +18,7 @@ class DoubleInput(
label: String? = null,
labelVal: Val? = null,
preferredLabelPosition: LabelPosition = LabelPosition.Before,
- value: Double? = null,
- valueVal: Val? = null,
+ value: Val = value(0.0),
onChange: (Double) -> Unit = {},
roundTo: Int = 2,
) : NumberInput(
@@ -29,7 +30,6 @@ class DoubleInput(
labelVal,
preferredLabelPosition,
value,
- valueVal,
onChange,
min = null,
max = null,
@@ -43,4 +43,7 @@ class DoubleInput(
override fun setInputValue(input: HTMLInputElement, value: Double) {
input.valueAsNumber = round(value * roundingFactor) / roundingFactor
}
+
+ override fun valuesEqual(a: Double, b: Double): Boolean =
+ abs(a - b) * roundingFactor < 1.0
}
diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Input.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Input.kt
index 9f66d963..bf473e7c 100644
--- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Input.kt
+++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Input.kt
@@ -18,8 +18,7 @@ abstract class Input(
private val className: String,
private val inputClassName: String,
private val inputType: String,
- private val value: T?,
- private val valueVal: Val?,
+ private val value: Val,
private val onChange: (T) -> Unit,
private val maxLength: Int?,
private val min: Int?,
@@ -34,6 +33,8 @@ abstract class Input(
labelVal,
preferredLabelPosition,
) {
+ private var settingValue = false
+
override fun Node.createElement() =
span {
classList.add("pw-input", this@Input.className)
@@ -44,18 +45,16 @@ abstract class Input(
observe(this@Input.enabled) { disabled = !it }
- onchange = { onChange(getInputValue(this)) }
+ onchange = { callOnChange(this) }
onkeydown = { e ->
if (e.key == "Enter") {
- onChange(getInputValue(this))
+ callOnChange(this)
}
}
- if (valueVal != null) {
- observe(valueVal) { setInputValue(this, it) }
- } else if (this@Input.value != null) {
- setInputValue(this, this@Input.value)
+ observe(this@Input.value) {
+ setInputValue(this, it)
}
this@Input.maxLength?.let { maxLength = it }
@@ -69,6 +68,17 @@ abstract class Input(
protected abstract fun setInputValue(input: HTMLInputElement, value: T)
+ private fun callOnChange(input: HTMLInputElement) {
+ val v = getInputValue(input)
+
+ if (!valuesEqual(v, this@Input.value.value)) {
+ onChange(v)
+ }
+ }
+
+ protected open fun valuesEqual(a: T, b: T): Boolean =
+ a == b
+
companion object {
init {
@Suppress("CssUnusedSymbol", "CssUnresolvedCustomProperty")
diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/IntInput.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/IntInput.kt
index 14a42b8a..89188e5e 100644
--- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/IntInput.kt
+++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/IntInput.kt
@@ -5,6 +5,7 @@ import org.w3c.dom.HTMLInputElement
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
+import world.phantasmal.observable.value.value
class IntInput(
scope: CoroutineScope,
@@ -14,8 +15,7 @@ class IntInput(
label: String? = null,
labelVal: Val? = null,
preferredLabelPosition: LabelPosition = LabelPosition.Before,
- value: Int? = null,
- valueVal: Val? = null,
+ value: Val = value(0),
onChange: (Int) -> Unit = {},
min: Int? = null,
max: Int? = null,
@@ -29,7 +29,6 @@ class IntInput(
labelVal,
preferredLabelPosition,
value,
- valueVal,
onChange,
min,
max,
diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/NumberInput.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/NumberInput.kt
index d24d0a75..46c306d0 100644
--- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/NumberInput.kt
+++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/NumberInput.kt
@@ -11,8 +11,7 @@ abstract class NumberInput(
label: String?,
labelVal: Val?,
preferredLabelPosition: LabelPosition,
- value: T?,
- valueVal: Val?,
+ value: Val,
onChange: (T) -> Unit,
min: Int?,
max: Int?,
@@ -29,7 +28,6 @@ abstract class NumberInput(
inputClassName = "pw-number-input-inner",
inputType = "number",
value,
- valueVal,
onChange,
maxLength = null,
min,
diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/TextArea.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/TextArea.kt
index 1008bcc5..624a8cf0 100644
--- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/TextArea.kt
+++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/TextArea.kt
@@ -5,6 +5,7 @@ import org.w3c.dom.Node
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
+import world.phantasmal.observable.value.value
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.dom.textarea
@@ -16,9 +17,8 @@ class TextArea(
label: String? = null,
labelVal: Val? = null,
preferredLabelPosition: LabelPosition = LabelPosition.Before,
- private val value: String? = null,
- private val valueVal: Val? = null,
- private val setValue: ((String) -> Unit)? = null,
+ private val value: Val = value(""),
+ private val onChange: ((String) -> Unit)? = null,
private val maxLength: Int? = null,
private val fontFamily: String? = null,
private val rows: Int? = null,
@@ -41,15 +41,11 @@ class TextArea(
observe(this@TextArea.enabled) { disabled = !it }
- if (setValue != null) {
- onchange = { setValue.invoke(value) }
+ if (onChange != null) {
+ onchange = { onChange.invoke(value) }
}
- if (valueVal != null) {
- observe(valueVal) { value = it }
- } else if (this@TextArea.value != null) {
- value = this@TextArea.value
- }
+ observe(this@TextArea.value) { value = it }
this@TextArea.maxLength?.let { maxLength = it }
fontFamily?.let { style.fontFamily = it }
diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/TextInput.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/TextInput.kt
index c9e8df96..cb8d61f9 100644
--- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/TextInput.kt
+++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/TextInput.kt
@@ -5,6 +5,7 @@ import org.w3c.dom.HTMLInputElement
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
+import world.phantasmal.observable.value.value
class TextInput(
scope: CoroutineScope,
@@ -14,8 +15,7 @@ class TextInput(
label: String? = null,
labelVal: Val? = null,
preferredLabelPosition: LabelPosition = LabelPosition.Before,
- value: String? = null,
- valueVal: Val? = null,
+ value: Val = value(""),
onChange: (String) -> Unit = {},
maxLength: Int? = null,
) : Input(
@@ -30,7 +30,6 @@ class TextInput(
inputClassName = "pw-number-text-inner",
inputType = "text",
value,
- valueVal,
onChange,
maxLength,
min = null,