Made basic quest properties editable and added the entity detail widget.

This commit is contained in:
Daan Vanden Bosch 2020-11-15 20:41:30 +01:00
parent 4e65cc1882
commit c82396326c
36 changed files with 822 additions and 90 deletions

View File

@ -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 {

View File

@ -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].

View File

@ -1,3 +1,5 @@
@file:JvmName("PrimitiveExtensionsJvm")
package world.phantasmal.core
import java.lang.Float.intBitsToFloat

View File

@ -260,7 +260,7 @@ private fun StringBuilder.appendArgs(params: List<Param>, args: List<ArgWithType
}
}
private fun StringBuilder.appendStringArg(value: String) {
private fun StringBuilder.appendStringArg(value: String): StringBuilder {
append("\"")
for (char in value) {
@ -274,9 +274,10 @@ private fun StringBuilder.appendStringArg(value: String) {
}
append("\"")
return this
}
private fun StringBuilder.appendStringSegment(value: String) {
private fun StringBuilder.appendStringSegment(value: String): StringBuilder {
append("\"")
var i = 0
@ -307,4 +308,5 @@ private fun StringBuilder.appendStringSegment(value: String) {
}
append("\"")
return this
}

View File

@ -50,4 +50,7 @@ interface Val<out T> : Observable<T> {
*/
fun <R> flatMap(transform: (T) -> Val<R>): Val<R> =
FlatMappedVal(listOf(this)) { transform(value) }
fun <R> flatMapNull(transform: (T) -> Val<R>?): Val<R?> =
FlatMappedVal(listOf(this)) { transform(value) ?: nullVal() }
}

View File

@ -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"))

View File

@ -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 "
}
}

View File

@ -32,6 +32,8 @@ fun main() {
}
private fun init(): Disposable {
KotlinLoggingConfiguration.FORMATTER = LogFormatter()
if (window.location.hostname == "localhost") {
KotlinLoggingConfiguration.LOG_LEVEL = KotlinLoggingLevel.TRACE
}

View File

@ -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.
*/

View File

@ -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
}
}

View File

@ -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) },
)

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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}"

View File

@ -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<Boolean> = store.selectedEntity.isNull()
val enabled: Val<Boolean> = store.questEditingEnabled
val type: Val<String> = store.selectedEntity.map {
it?.let { if (it is QuestNpcModel) "NPC" else "Object" } ?: ""
}
val name: Val<String> = store.selectedEntity.map { it?.type?.simpleName ?: "" }
val sectionId: Val<String> = store.selectedEntity
.flatMapNull { it?.sectionId }
.map { it?.toString() ?: "" }
val wave: Val<String> = store.selectedEntity
.flatMapNull { entity -> (entity as? QuestNpcModel)?.wave?.flatMapNull { it?.id } }
.map { it?.toString() ?: "" }
val waveHidden: Val<Boolean> = store.selectedEntity.map { it !is QuestNpcModel }
private val pos: Val<Vector3> = store.selectedEntity.flatMap { it?.position ?: DEFAULT_VECTOR }
val posX: Val<Double> = pos.map { it.x }
val posY: Val<Double> = pos.map { it.y }
val posZ: Val<Double> = pos.map { it.z }
private val rot: Val<Vector3> = store.selectedEntity.flatMap { it?.rotation ?: DEFAULT_VECTOR }
val rotX: Val<Double> = rot.map { radToDeg(it.x) }
val rotY: Val<Double> = rot.map { radToDeg(it.y) }
val rotZ: Val<Double> = 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())
}
}

View File

@ -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<Boolean> = store.currentQuest.isNull()
val enabled: Val<Boolean> = store.questEditingEnabled
@ -17,4 +21,40 @@ class QuestInfoController(store: QuestEditorStore) : Controller() {
store.currentQuest.flatMap { it?.shortDescription ?: value("") }
val longDescription: Val<String> =
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)
)
}
}
}

View File

@ -98,6 +98,7 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
floorModEuler(rot)
entity.rotation = babylonToVec3(rot)
_rotation.value = rot
val section = section.value
@ -118,6 +119,34 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
}
}
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),

View File

@ -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<Int> = _id
val areaId: Val<Int> = _areaId
val sectionId: Val<Int> = _sectionId
}

View File

@ -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,
)
}

View File

@ -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()
}
}

View File

@ -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())
}
}
}

View File

@ -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
),
)
),

View File

@ -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,

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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: [],
})
);

View File

@ -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<String>? = null,
preferredLabelPosition: LabelPosition = LabelPosition.Before,
value: Double? = null,
valueVal: Val<Double>? = null,
value: Val<Double> = value(0.0),
onChange: (Double) -> Unit = {},
roundTo: Int = 2,
) : NumberInput<Double>(
@ -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
}

View File

@ -18,8 +18,7 @@ abstract class Input<T>(
private val className: String,
private val inputClassName: String,
private val inputType: String,
private val value: T?,
private val valueVal: Val<T>?,
private val value: Val<T>,
private val onChange: (T) -> Unit,
private val maxLength: Int?,
private val min: Int?,
@ -34,6 +33,8 @@ abstract class Input<T>(
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<T>(
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<T>(
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")

View File

@ -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<String>? = null,
preferredLabelPosition: LabelPosition = LabelPosition.Before,
value: Int? = null,
valueVal: Val<Int>? = null,
value: Val<Int> = value(0),
onChange: (Int) -> Unit = {},
min: Int? = null,
max: Int? = null,
@ -29,7 +29,6 @@ class IntInput(
labelVal,
preferredLabelPosition,
value,
valueVal,
onChange,
min,
max,

View File

@ -11,8 +11,7 @@ abstract class NumberInput<T : Number>(
label: String?,
labelVal: Val<String>?,
preferredLabelPosition: LabelPosition,
value: T?,
valueVal: Val<T>?,
value: Val<T>,
onChange: (T) -> Unit,
min: Int?,
max: Int?,
@ -29,7 +28,6 @@ abstract class NumberInput<T : Number>(
inputClassName = "pw-number-input-inner",
inputType = "number",
value,
valueVal,
onChange,
maxLength = null,
min,

View File

@ -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<String>? = null,
preferredLabelPosition: LabelPosition = LabelPosition.Before,
private val value: String? = null,
private val valueVal: Val<String>? = null,
private val setValue: ((String) -> Unit)? = null,
private val value: Val<String> = 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 }

View File

@ -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<String>? = null,
preferredLabelPosition: LabelPosition = LabelPosition.Before,
value: String? = null,
valueVal: Val<String>? = null,
value: Val<String> = value(""),
onChange: (String) -> Unit = {},
maxLength: Int? = null,
) : Input<String>(
@ -30,7 +30,6 @@ class TextInput(
inputClassName = "pw-number-text-inner",
inputType = "text",
value,
valueVal,
onChange,
maxLength,
min = null,