Entity-specific properties are editable again.

This commit is contained in:
Daan Vanden Bosch 2020-12-21 21:45:29 +01:00
parent d0a4dbd6c7
commit bc3979da11
29 changed files with 463 additions and 87 deletions

View File

@ -3,3 +3,5 @@ package world.phantasmal.core
fun Char.isDigit(): Boolean = this in '0'..'9'
expect fun Int.reinterpretAsFloat(): Float
expect fun Float.reinterpretAsInt(): Int

View File

@ -9,3 +9,8 @@ actual fun Int.reinterpretAsFloat(): Float {
dataView.setInt32(0, this)
return dataView.getFloat32(0)
}
actual fun Float.reinterpretAsInt(): Int {
dataView.setFloat32(0, this)
return dataView.getInt32(0)
}

View File

@ -2,6 +2,9 @@
package world.phantasmal.core
import java.lang.Float.floatToIntBits
import java.lang.Float.intBitsToFloat
actual fun Int.reinterpretAsFloat(): Float = intBitsToFloat(this)
actual fun Float.reinterpretAsInt(): Int = floatToIntBits(this)

View File

@ -1,8 +1,7 @@
package world.phantasmal.lib.fileFormats.quest
/**
* Represents a configurable property for accessing parts of entity data of which the use is not
* fully understood or ambiguous.
* Represents an entity type-specific property for accessing ambiguous parts of the entity data.
*/
class EntityProp(
val name: String,
@ -11,11 +10,6 @@ class EntityProp(
)
enum class EntityPropType {
U8,
U16,
U32,
I8,
I16,
I32,
F32,

View File

@ -13,4 +13,9 @@ interface EntityType {
* Might conflict with other NPC names (e.g. Delsaber from ep. I and ep. II).
*/
val simpleName: String
/**
* Entity-specific properties.
*/
val properties: List<EntityProp>
}

View File

@ -38,7 +38,7 @@ enum class NpcType(
/**
* NPC-specific properties.
*/
val properties: List<EntityProp> = emptyList(),
override val properties: List<EntityProp> = emptyList(),
) : EntityType {
//
// Unknown NPCs

View File

@ -13,7 +13,7 @@ enum class ObjectType(
/**
* Default object-specific properties.
*/
val properties: List<EntityProp> = emptyList(),
override val properties: List<EntityProp> = emptyList(),
) : EntityType {
Unknown(
uniqueName = "Unknown",
@ -126,7 +126,7 @@ enum class ObjectType(
typeId = 8,
properties = listOf(
EntityProp(name = "Radius", offset = 40, type = EntityPropType.F32),
EntityProp(name = "Event ID", offset = 52, type = EntityPropType.U32),
EntityProp(name = "Event ID", offset = 52, type = EntityPropType.I32),
),
),
CharaCollision(
@ -545,7 +545,7 @@ enum class ObjectType(
EntityProp(name = "Destination y", offset = 44, type = EntityPropType.F32),
EntityProp(name = "Destination z", offset = 48, type = EntityPropType.F32),
EntityProp(name = "Dst. rotation y", offset = 52, type = EntityPropType.Angle),
EntityProp(name = "Model", offset = 60, type = EntityPropType.U32),
EntityProp(name = "Model", offset = 60, type = EntityPropType.I32),
),
),
ShopDoor(
@ -651,7 +651,7 @@ enum class ObjectType(
),
typeId = 81,
properties = listOf(
EntityProp(name = "Model", offset = 52, type = EntityPropType.U32),
EntityProp(name = "Model", offset = 52, type = EntityPropType.I32),
),
),
WelcomeBoard(
@ -748,7 +748,7 @@ enum class ObjectType(
properties = listOf(
EntityProp(name = "Color", offset = 40, type = EntityPropType.F32),
EntityProp(name = "Switch ID", offset = 52, type = EntityPropType.I32),
EntityProp(name = "Model", offset = 60, type = EntityPropType.U32),
EntityProp(name = "Model", offset = 60, type = EntityPropType.I32),
),
),
LaserSquareFence(
@ -762,7 +762,7 @@ enum class ObjectType(
properties = listOf(
EntityProp(name = "Color", offset = 40, type = EntityPropType.F32),
EntityProp(name = "Switch ID", offset = 52, type = EntityPropType.I32),
EntityProp(name = "Model", offset = 60, type = EntityPropType.U32),
EntityProp(name = "Model", offset = 60, type = EntityPropType.I32),
),
),
ForestLaserFenceSwitch(
@ -841,7 +841,7 @@ enum class ObjectType(
typeId = 139,
properties = listOf(
EntityProp(name = "Script label", offset = 52, type = EntityPropType.I32),
EntityProp(name = "Model", offset = 56, type = EntityPropType.U32),
EntityProp(name = "Model", offset = 56, type = EntityPropType.I32),
),
),
BlackSlidingDoor(
@ -962,7 +962,7 @@ enum class ObjectType(
EntityProp(name = "Collision width", offset = 44, type = EntityPropType.F32),
EntityProp(name = "Collision depth", offset = 48, type = EntityPropType.F32),
EntityProp(name = "Switch ID", offset = 52, type = EntityPropType.I32),
EntityProp(name = "Model", offset = 60, type = EntityPropType.U32),
EntityProp(name = "Model", offset = 60, type = EntityPropType.I32),
),
),
LaserSquareFenceEx(
@ -974,7 +974,7 @@ enum class ObjectType(
EntityProp(name = "Collision width", offset = 44, type = EntityPropType.F32),
EntityProp(name = "Collision depth", offset = 48, type = EntityPropType.F32),
EntityProp(name = "Switch ID", offset = 52, type = EntityPropType.I32),
EntityProp(name = "Model", offset = 60, type = EntityPropType.U32),
EntityProp(name = "Model", offset = 60, type = EntityPropType.I32),
),
),
FloorPanel1(
@ -2206,7 +2206,7 @@ enum class ObjectType(
),
typeId = 547,
properties = listOf(
EntityProp(name = "Model", offset = 52, type = EntityPropType.U32),
EntityProp(name = "Model", offset = 52, type = EntityPropType.I32),
),
),
WideGlassWallBreakable(
@ -2393,7 +2393,7 @@ enum class ObjectType(
EntityProp(name = "Scale x", offset = 40, type = EntityPropType.F32),
EntityProp(name = "Scale y", offset = 44, type = EntityPropType.F32),
EntityProp(name = "Scale z", offset = 48, type = EntityPropType.F32),
EntityProp(name = "Model", offset = 52, type = EntityPropType.U32),
EntityProp(name = "Model", offset = 52, type = EntityPropType.I32),
),
),
BigBrownRock(
@ -2403,7 +2403,7 @@ enum class ObjectType(
),
typeId = 770,
properties = listOf(
EntityProp(name = "Model", offset = 52, type = EntityPropType.U32),
EntityProp(name = "Model", offset = 52, type = EntityPropType.I32),
),
),
BreakableBrownRock(
@ -2465,7 +2465,7 @@ enum class ObjectType(
),
typeId = 902,
properties = listOf(
EntityProp(name = "Model", offset = 52, type = EntityPropType.U32),
EntityProp(name = "Model", offset = 52, type = EntityPropType.I32),
),
),
UnknownItem903(
@ -2525,7 +2525,7 @@ enum class ObjectType(
),
typeId = 911,
properties = listOf(
EntityProp(name = "Model", offset = 52, type = EntityPropType.U32),
EntityProp(name = "Model", offset = 52, type = EntityPropType.I32),
),
),
UnknownItem912(
@ -2567,6 +2567,6 @@ enum class ObjectType(
/**
* Use this instead of [values] to avoid unnecessary copying.
*/
val VALUES: Array<ObjectType> = ObjectType.values()
val VALUES: Array<ObjectType> = values()
}
}

View File

@ -1,5 +1,6 @@
package world.phantasmal.lib.fileFormats.quest
import world.phantasmal.lib.buffer.Buffer
import world.phantasmal.lib.fileFormats.Vec3
interface QuestEntity<Type : EntityType> {
@ -7,6 +8,8 @@ interface QuestEntity<Type : EntityType> {
var areaId: Int
val data: Buffer
var sectionId: Short
/**

View File

@ -10,7 +10,7 @@ import kotlin.math.roundToInt
class QuestNpc(
var episode: Episode,
override var areaId: Int,
val data: Buffer,
override val data: Buffer,
) : QuestEntity<NpcType> {
constructor(
type: NpcType,

View File

@ -6,7 +6,7 @@ import world.phantasmal.lib.fileFormats.ninja.angleToRad
import world.phantasmal.lib.fileFormats.ninja.radToAngle
import kotlin.math.roundToInt
class QuestObject(override var areaId: Int, val data: Buffer) : QuestEntity<ObjectType> {
class QuestObject(override var areaId: Int, override val data: Buffer) : QuestEntity<ObjectType> {
constructor(type: ObjectType, areaId: Int) : this(areaId, Buffer.withSize(OBJECT_BYTE_SIZE)) {
// TODO: Set default data.
this.type = type

View File

@ -7,7 +7,7 @@ import world.phantasmal.observable.value.nullVal
import world.phantasmal.web.core.actions.Action
class UndoManager {
private val undos = mutableListOf<Undo>()
private val undos = mutableListOf<Undo>(NopUndo)
private val _current = mutableVal<Undo>(NopUndo)
val current: Val<Undo> = _current
@ -21,6 +21,10 @@ class UndoManager {
undos.add(undo)
}
fun makeNopCurrent() {
setCurrent(NopUndo)
}
fun setCurrent(undo: Undo) {
require(undo in undos) { "Undo $undo is not managed by this UndoManager." }

View File

@ -4,9 +4,13 @@ import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal
/**
* Orchestrates everything related to emulating a quest run. Drives a [VirtualMachine] and
* delegates to [Debugger].
* Orchestrates everything related to emulating a quest run. Drives a VirtualMachine and
* delegates to Debugger.
*/
class QuestRunner {
val running: Val<Boolean> = falseVal()
fun stop() {
// TODO
}
}

View File

@ -0,0 +1,25 @@
package world.phantasmal.web.questEditor.actions
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.QuestEntityPropModel
class EditEntityPropAction(
private val setSelectedEntity: (QuestEntityModel<*, *>) -> Unit,
private val entity: QuestEntityModel<*, *>,
private val prop: QuestEntityPropModel,
private val newValue: Any,
private val oldValue: Any,
) : Action {
override val description: String = "Edit ${entity.type.simpleName} ${prop.name}"
override fun execute() {
setSelectedEntity(entity)
prop.setValue(newValue)
}
override fun undo() {
setSelectedEntity(entity)
prop.setValue(oldValue)
}
}

View File

@ -3,16 +3,15 @@ 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.list.emptyListVal
import world.phantasmal.observable.value.value
import world.phantasmal.observable.value.zeroIntVal
import world.phantasmal.web.core.euler
import world.phantasmal.web.externals.three.Euler
import world.phantasmal.web.externals.three.Vector3
import world.phantasmal.web.questEditor.actions.EditEntitySectionAction
import world.phantasmal.web.questEditor.actions.EditPropertyAction
import world.phantasmal.web.questEditor.actions.RotateEntityAction
import world.phantasmal.web.questEditor.actions.TranslateEntityAction
import world.phantasmal.web.questEditor.actions.*
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.QuestEntityPropModel
import world.phantasmal.web.questEditor.models.QuestNpcModel
import world.phantasmal.web.questEditor.stores.AreaStore
import world.phantasmal.web.questEditor.stores.QuestEditorStore
@ -57,6 +56,9 @@ class EntityInfoController(
val rotY: Val<Double> = rot.map { radToDeg(it.y) }
val rotZ: Val<Double> = rot.map { radToDeg(it.z) }
val props: Val<List<QuestEntityPropModel>> =
questEditorStore.selectedEntity.flatMap { it?.properties ?: emptyListVal() }
fun focused() {
questEditorStore.makeMainUndoCurrent()
}
@ -163,6 +165,18 @@ class EntityInfoController(
))
}
fun setPropValue(prop:QuestEntityPropModel, value:Any) {
questEditorStore.selectedEntity.value?.let { entity ->
questEditorStore.executeAction(EditEntityPropAction(
setSelectedEntity = questEditorStore::setSelectedEntity,
entity,
prop,
value,
prop.value.value,
))
}
}
companion object {
private val DEFAULT_POSITION = value(Vector3(0.0, 0.0, 0.0))
private val DEFAULT_ROTATION = value(euler(0.0, 0.0, 0.0))

View File

@ -4,6 +4,8 @@ import world.phantasmal.core.math.floorMod
import world.phantasmal.lib.fileFormats.quest.EntityType
import world.phantasmal.lib.fileFormats.quest.QuestEntity
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.listVal
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.core.euler
import world.phantasmal.web.core.minus
@ -53,6 +55,10 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
val worldRotation: Val<Euler> = _worldRotation
val properties: ListVal<QuestEntityPropModel> = listVal(*Array(type.properties.size) {
QuestEntityPropModel(this, type.properties[it])
})
open fun setSectionId(sectionId: Int) {
entity.sectionId = sectionId.toShort()
_sectionId.value = sectionId

View File

@ -0,0 +1,78 @@
package world.phantasmal.web.questEditor.models
import world.phantasmal.lib.fileFormats.ninja.angleToRad
import world.phantasmal.lib.fileFormats.ninja.radToAngle
import world.phantasmal.lib.fileFormats.quest.EntityProp
import world.phantasmal.lib.fileFormats.quest.EntityPropType
import world.phantasmal.lib.fileFormats.quest.ObjectType
import world.phantasmal.observable.value.MutableVal
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal
class QuestEntityPropModel(private val entity: QuestEntityModel<*, *>, prop: EntityProp) {
private val _value: MutableVal<Any> = mutableVal(when (prop.type) {
EntityPropType.I32 -> entity.entity.data.getInt(prop.offset)
EntityPropType.F32 -> entity.entity.data.getFloat(prop.offset)
EntityPropType.Angle -> angleToRad(entity.entity.data.getInt(prop.offset))
})
private val affectsModel: Boolean =
when (entity.type) {
ObjectType.Probe ->
prop.offset == 40
ObjectType.Saw,
ObjectType.LaserDetect,
-> prop.offset == 48
ObjectType.Sonic,
ObjectType.LittleCryotube,
ObjectType.Cactus,
ObjectType.BigBrownRock,
ObjectType.BigBlackRocks,
ObjectType.BeeHive,
-> prop.offset == 52
ObjectType.ForestConsole ->
prop.offset == 56
ObjectType.PrincipalWarp,
ObjectType.LaserFence,
ObjectType.LaserSquareFence,
ObjectType.LaserFenceEx,
ObjectType.LaserSquareFenceEx,
-> prop.offset == 60
else -> false
}
val name: String = prop.name
val offset = prop.offset
val type: EntityPropType = prop.type
val value: Val<Any> = _value
fun setValue(value: Any, propagateToEntity: Boolean = true) {
when (type) {
EntityPropType.I32 -> {
require(value is Int)
entity.entity.data.setInt(offset, value)
}
EntityPropType.F32 -> {
require(value is Float)
entity.entity.data.setFloat(offset, value)
}
EntityPropType.Angle -> {
require(value is Float)
entity.entity.data.setInt(offset, radToAngle(value))
}
}
_value.value = value
if (propagateToEntity && affectsModel) {
(entity as QuestObjectModel).setModel(
entity.entity.data.getInt(offset),
propagateToProps = false,
)
}
}
}

View File

@ -10,9 +10,45 @@ class QuestObjectModel(obj: QuestObject) : QuestEntityModel<ObjectType, QuestObj
val model: Val<Int?> = _model
fun setModel(model: Int) {
fun setModel(model: Int, propagateToProps: Boolean = true) {
_model.value = model
// TODO: Propagate to props.
if (propagateToProps) {
val props = when (type) {
ObjectType.Probe ->
properties.value.filter { it.offset == 40 }
ObjectType.Saw,
ObjectType.LaserDetect,
->
properties.value.filter { it.offset == 48 }
ObjectType.Sonic,
ObjectType.LittleCryotube,
ObjectType.Cactus,
ObjectType.BigBrownRock,
ObjectType.BigBlackRocks,
ObjectType.BeeHive,
->
properties.value.filter { it.offset == 52 }
ObjectType.ForestConsole ->
properties.value.filter { it.offset == 56 }
ObjectType.PrincipalWarp,
ObjectType.LaserFence,
ObjectType.LaserSquareFence,
ObjectType.LaserFenceEx,
ObjectType.LaserSquareFenceEx,
->
properties.value.filter { it.offset == 60 }
else -> return
}
for (prop in props) {
prop.setValue(model, propagateToEntity = false)
}
}
}
}

View File

@ -74,6 +74,11 @@ class QuestEditorStore(
}
}
override fun internalDispose() {
runner.stop()
super.internalDispose()
}
fun makeMainUndoCurrent() {
undoManager.setCurrent(mainUndo)
}
@ -89,7 +94,7 @@ class QuestEditorStore(
suspend fun setCurrentQuest(quest: QuestModel?) {
undoManager.reset()
// TODO: Stop runner.
runner.stop()
_highlightedEntity.value = null
_selectedEntity.value = null

View File

@ -2,9 +2,15 @@ package world.phantasmal.web.questEditor.widgets
import kotlinx.coroutines.launch
import org.w3c.dom.Node
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.Disposer
import world.phantasmal.core.math.degToRad
import world.phantasmal.core.math.radToDeg
import world.phantasmal.lib.fileFormats.quest.EntityPropType
import world.phantasmal.observable.value.Val
import world.phantasmal.web.core.widgets.UnavailableWidget
import world.phantasmal.web.questEditor.controllers.EntityInfoController
import world.phantasmal.web.questEditor.models.QuestEntityPropModel
import world.phantasmal.webui.dom.*
import world.phantasmal.webui.widgets.DoubleInput
import world.phantasmal.webui.widgets.IntInput
@ -68,6 +74,12 @@ class EntityInfoWidget(private val ctrl: EntityInfoController) : Widget(enabled
createCoordRow("Y:", ctrl.rotY, ctrl::setRotY)
createCoordRow("Z:", ctrl.rotZ, ctrl::setRotZ)
}
table {
className = "pw-quest-editor-entity-info-specific-props"
hidden(ctrl.unavailable)
bindDisposableChildrenTo(ctrl.props) { prop, _ -> createPropRow(prop) }
}
addChild(UnavailableWidget(
visible = ctrl.unavailable,
message = "No entity selected.",
@ -94,6 +106,47 @@ class EntityInfoWidget(private val ctrl: EntityInfoController) : Widget(enabled
}
}
private fun Node.createPropRow(prop: QuestEntityPropModel): Pair<Node, Disposable> {
val disposer = Disposer()
val input = disposer.add(when (prop.type) {
EntityPropType.I32 -> IntInput(
enabled = ctrl.enabled,
label = prop.name + ":",
min = Int.MIN_VALUE,
max = Int.MAX_VALUE,
step = 1,
value = prop.value.map { it as Int },
onChange = { ctrl.setPropValue(prop, it) },
)
EntityPropType.F32 -> DoubleInput(
enabled = ctrl.enabled,
label = prop.name + ":",
roundTo = 3,
value = prop.value.map { (it as Float).toDouble() },
onChange = { ctrl.setPropValue(prop, it.toFloat()) },
)
EntityPropType.Angle -> DoubleInput(
enabled = ctrl.enabled,
label = prop.name + ":",
roundTo = 1,
value = prop.value.map { radToDeg((it as Float).toDouble()) },
onChange = { ctrl.setPropValue(prop, degToRad(it).toFloat()) },
)
})
val node = tr {
th {
addWidget(disposer.add(input.label!!), addToDisposer = false)
}
td {
addWidget(input, addToDisposer = false)
}
}
return Pair(node, disposer)
}
companion object {
@Suppress("CssInvalidHtmlTagReference")
private const val COORD_CLASS = "pw-quest-editor-entity-info-coord"
@ -127,9 +180,14 @@ class EntityInfoWidget(private val ctrl: EntityInfoController) : Widget(enabled
width: 100%;
}
/* Using a selector with high specificity to ensure we override rule above. */
.pw-quest-editor-entity-info table.pw-quest-editor-entity-info-specific-props {
margin-top: -2px;
}
.pw-quest-editor-entity-info-specific-props .pw-number-input {
width: 100%;
}
""".trimIndent())
}
}

View File

@ -25,66 +25,70 @@ class QuestInfoWidget(private val ctrl: QuestInfoController) : Widget(enabled =
td { text(ctrl.episode) }
}
tr {
th { textContent = "ID:" }
td {
addChild(IntInput(
enabled = ctrl.enabled,
value = ctrl.id,
onChange = ctrl::setId,
min = 0,
step = 1,
))
}
val idInput = IntInput(
label = "ID:",
enabled = ctrl.enabled,
value = ctrl.id,
onChange = ctrl::setId,
min = 0,
step = 1,
)
th { addChild(idInput.label!!) }
td { addChild(idInput) }
}
tr {
th { textContent = "Name:" }
td {
addChild(TextInput(
enabled = ctrl.enabled,
value = ctrl.name,
onChange = ctrl::setName,
maxLength = 32,
))
}
val nameInput = TextInput(
label = "Name:",
enabled = ctrl.enabled,
value = ctrl.name,
onChange = ctrl::setName,
maxLength = 32,
)
th { addChild(nameInput.label!!) }
td { addChild(nameInput) }
}
val shortDescriptionTextArea = TextArea(
label = "Short description:",
enabled = ctrl.enabled,
value = ctrl.shortDescription,
onChange = ctrl::setShortDescription,
maxLength = 128,
fontFamily = "\"Courier New\", monospace",
cols = 25,
rows = 5,
)
tr {
th {
colSpan = 2
textContent = "Short description:"
addChild(shortDescriptionTextArea.label!!)
}
}
tr {
td {
colSpan = 2
addChild(TextArea(
enabled = ctrl.enabled,
value = ctrl.shortDescription,
onChange = ctrl::setShortDescription,
maxLength = 128,
fontFamily = "\"Courier New\", monospace",
cols = 25,
rows = 5,
))
addChild(shortDescriptionTextArea)
}
}
val longDescriptionTextArea = TextArea(
label = "Long description:",
enabled = ctrl.enabled,
value = ctrl.longDescription,
onChange = ctrl::setLongDescription,
maxLength = 288,
fontFamily = "\"Courier New\", monospace",
cols = 25,
rows = 10,
)
tr {
th {
colSpan = 2
textContent = "Long description:"
addChild(longDescriptionTextArea.label!!)
}
}
tr {
td {
colSpan = 2
addChild(TextArea(
enabled = ctrl.enabled,
value = ctrl.longDescription,
onChange = ctrl::setLongDescription,
maxLength = 288,
fontFamily = "\"Courier New\", monospace",
cols = 25,
rows = 10,
))
addChild(longDescriptionTextArea)
}
}
}

View File

@ -118,4 +118,24 @@ class EntityInfoControllerTests : WebTestSuite() {
assertCloseTo(25.4, ctrl.rotY.value)
assertCloseTo(12.5, ctrl.rotZ.value)
}
@Test
fun when_focused_main_undo_becomes_current_undo() = asyncTest {
val store = components.questEditorStore
val ctrl = disposer.add(EntityInfoController(components.areaStore, store))
// Put something on the undo stack.
val npc = createQuestNpcModel(NpcType.Principal, Episode.I)
store.setCurrentQuest(createQuestModel(npcs = listOf(npc)))
store.setSelectedEntity(npc)
ctrl.setWaveId(99)
components.undoManager.makeNopCurrent()
// After focusing, the main undo stack becomes the current undo and we can undo.
ctrl.focused()
assertTrue(store.canUndo.value)
}
}

View File

@ -85,4 +85,23 @@ class QuestInfoControllerTests : WebTestSuite() {
assertEquals("short 2", ctrl.shortDescription.value)
assertEquals("long 2", ctrl.longDescription.value)
}
@Test
fun when_focused_main_undo_becomes_current_undo() = asyncTest {
val store = components.questEditorStore
val ctrl = disposer.add(QuestInfoController(store))
// Put something on the undo stack.
store.setCurrentQuest(createQuestModel(
name = "original name",
))
ctrl.setName("new name")
components.undoManager.makeNopCurrent()
// After focusing, the main undo stack becomes the current undo and we can undo.
ctrl.focused()
assertTrue(store.canUndo.value)
}
}

View File

@ -0,0 +1,55 @@
package world.phantasmal.web.questEditor.models
import world.phantasmal.lib.Episode
import world.phantasmal.lib.fileFormats.quest.NpcType
import world.phantasmal.web.core.euler
import world.phantasmal.web.externals.three.Vector3
import world.phantasmal.web.test.WebTestSuite
import world.phantasmal.web.test.createQuestNpcModel
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class QuestEntityModelTests : WebTestSuite() {
@Test
fun positions_are_updated_correctly_when_section_changes() = test {
// Relative and world position start out the same.
val entity = createQuestNpcModel(NpcType.AlRappy, Episode.I)
entity.setPosition(Vector3(5.0, 5.0, 5.0))
assertTrue(entity.position.value.equals(entity.worldPosition.value))
// When section is initialized, relative position stays the same and world position changes.
entity.initializeSection(SectionModel(
20,
Vector3(7.0, 7.0, 7.0),
euler(.0, .0, .0),
components.areaStore.getVariant(Episode.I, 0, 0)!!,
))
assertEquals(5.0, entity.position.value.x)
assertEquals(5.0, entity.position.value.y)
assertEquals(5.0, entity.position.value.z)
assertEquals(12.0, entity.worldPosition.value.x)
assertEquals(12.0, entity.worldPosition.value.y)
assertEquals(12.0, entity.worldPosition.value.z)
// When section is then changed, relative position changes and world position stays the
// same.
entity.setSection(SectionModel(
30,
Vector3(11.0, 11.0, 11.0),
euler(.0, .0, .0),
components.areaStore.getVariant(Episode.I, 0, 0)!!,
))
assertEquals(1.0, entity.position.value.x)
assertEquals(1.0, entity.position.value.y)
assertEquals(1.0, entity.position.value.z)
assertEquals(12.0, entity.worldPosition.value.x)
assertEquals(12.0, entity.worldPosition.value.y)
assertEquals(12.0, entity.worldPosition.value.z)
}
}

View File

@ -1,6 +1,7 @@
package world.phantasmal.webui.widgets
import org.w3c.dom.Node
import org.w3c.dom.get
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.nullVal
import world.phantasmal.observable.value.trueVal
@ -31,4 +32,12 @@ class Checkbox(
onchange = { onChange.invoke(checked) }
}
}
override fun getId(): String {
if (element.id.isBlank()) {
element.id = uniqueId()
}
return element.id
}
}

View File

@ -2,6 +2,7 @@ package world.phantasmal.webui.widgets
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.Node
import org.w3c.dom.get
import world.phantasmal.observable.value.Val
import world.phantasmal.webui.dom.input
import world.phantasmal.webui.dom.span
@ -51,6 +52,16 @@ abstract class Input<T>(
}
}
override fun getId(): String {
val input = element.children[0]!!
if (input.id.isBlank()) {
input.id = uniqueId()
}
return input.id
}
/**
* Called during [createElement].
*/

View File

@ -19,20 +19,9 @@ abstract class LabelledControl(
if (label == null && labelVal == null) {
null
} else {
var id = element.id
if (id.isBlank()) {
id = uniqueId()
element.id = id
}
Label(visible, enabled, label, labelVal, htmlFor = id)
Label(visible, enabled, label, labelVal, htmlFor = getId())
}
}
companion object {
private var id = 0
private fun uniqueId() = "pw-labelled-control-id-${id++}"
}
protected abstract fun getId(): String
}

View File

@ -3,6 +3,7 @@ package world.phantasmal.webui.widgets
import org.w3c.dom.Node
import org.w3c.dom.events.KeyboardEvent
import org.w3c.dom.events.MouseEvent
import org.w3c.dom.get
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.list.emptyListVal
import world.phantasmal.observable.value.mutableVal
@ -67,6 +68,16 @@ class Select<T : Any>(
))
}
override fun getId(): String {
val button = element.children[0]!!
if (button.id.isBlank()) {
button.id = uniqueId()
}
return button.id
}
private fun onButtonMouseDown(e: MouseEvent) {
e.stopPropagation()
justOpened = !menuVisible.value

View File

@ -1,6 +1,7 @@
package world.phantasmal.webui.widgets
import org.w3c.dom.Node
import org.w3c.dom.get
import world.phantasmal.observable.value.*
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.dom.textarea
@ -48,6 +49,16 @@ class TextArea(
}
}
override fun getId(): String {
val textarea = element.children[0]!!
if (textarea.id.isBlank()) {
textarea.id = uniqueId()
}
return textarea.id
}
companion object {
init {
@Suppress("CssUnusedSymbol", "CssUnresolvedCustomProperty")

View File

@ -0,0 +1,5 @@
package world.phantasmal.webui.widgets
private var id = 0
fun uniqueId() = "pw-id-${id++}"