The 3D view now updates when a different area variant is configured for the current area. Undo/redo of entity translations now works more or less correctly even when a different area variant is now active. Fixed a bug that resulted in double onChange calls when editing an input field.

This commit is contained in:
Daan Vanden Bosch 2021-04-17 11:37:16 +02:00
parent ecdf7cafb8
commit fc64f62285
16 changed files with 157 additions and 114 deletions

View File

@ -161,8 +161,6 @@ Features that are in ***bold italics*** are planned but not yet implemented.
- When a modal dialog is open, global keybindings should be disabled - When a modal dialog is open, global keybindings should be disabled
- The ASM editor is slow with big scripts, e.g. Seat of the Heart (#27) - The ASM editor is slow with big scripts, e.g. Seat of the Heart (#27)
- Improve the default camera target for Crater Interior - Improve the default camera target for Crater Interior
- The 3D doesn't update when a different variant is configured with a map designate instruction for
the current area
- Entities with rendering issues: - Entities with rendering issues:
- Caves 4 Button door - Caves 4 Button door
- Pofuilly Slime - Pofuilly Slime

View File

@ -3,40 +3,31 @@ package world.phantasmal.web.questEditor.actions
import world.phantasmal.web.core.actions.Action import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.externals.three.Vector3 import world.phantasmal.web.externals.three.Vector3
import world.phantasmal.web.questEditor.models.QuestEntityModel import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.SectionModel
class TranslateEntityAction( class TranslateEntityAction(
private val setSelectedEntity: (QuestEntityModel<*, *>) -> Unit, private val setSelectedEntity: (QuestEntityModel<*, *>) -> Unit,
private val setEntitySection: (Int) -> Unit,
private val entity: QuestEntityModel<*, *>, private val entity: QuestEntityModel<*, *>,
private val newSection: SectionModel?, private val newSection: Int?,
private val oldSection: SectionModel?, private val oldSection: Int?,
private val newPosition: Vector3, private val newPosition: Vector3,
private val oldPosition: Vector3, private val oldPosition: Vector3,
private val world: Boolean,
) : Action { ) : Action {
override val description: String = "Move ${entity.type.simpleName}" override val description: String = "Move ${entity.type.simpleName}"
override fun execute() { override fun execute() {
setSelectedEntity(entity) setSelectedEntity(entity)
newSection?.let(entity::setSection) newSection?.let(setEntitySection)
if (world) { entity.setPosition(newPosition)
entity.setWorldPosition(newPosition)
} else {
entity.setPosition(newPosition)
}
} }
override fun undo() { override fun undo() {
setSelectedEntity(entity) setSelectedEntity(entity)
oldSection?.let(entity::setSection) oldSection?.let(setEntitySection)
if (world) { entity.setPosition(oldPosition)
entity.setWorldPosition(oldPosition)
} else {
entity.setPosition(oldPosition)
}
} }
} }

View File

@ -123,12 +123,12 @@ class EntityInfoController(
questEditorStore.executeAction(TranslateEntityAction( questEditorStore.executeAction(TranslateEntityAction(
setSelectedEntity = questEditorStore::setSelectedEntity, setSelectedEntity = questEditorStore::setSelectedEntity,
setEntitySection = { /* Won't be called. */ },
entity, entity,
entity.section.value, newSection = null,
entity.section.value, oldSection = null,
Vector3(x, y, z), newPosition = Vector3(x, y, z),
entity.position.value, oldPosition = entity.position.value,
false,
)) ))
} }
@ -165,7 +165,7 @@ class EntityInfoController(
)) ))
} }
fun setPropValue(prop:QuestEntityPropModel, value:Any) { fun setPropValue(prop: QuestEntityPropModel, value: Any) {
questEditorStore.selectedEntity.value?.let { entity -> questEditorStore.selectedEntity.value?.let { entity ->
questEditorStore.executeAction(EditEntityPropAction( questEditorStore.executeAction(EditEntityPropAction(
setSelectedEntity = questEditorStore::setSelectedEntity, setSelectedEntity = questEditorStore::setSelectedEntity,

View File

@ -76,6 +76,9 @@ class AreaAssetLoader(private val assetLoader: AssetLoader) : DisposableContaine
): Object3D = ): Object3D =
cache.get(EpisodeAndAreaVariant(episode, areaVariant)).collisionGeometry cache.get(EpisodeAndAreaVariant(episode, areaVariant)).collisionGeometry
fun getCachedSections(episode: Episode, areaVariant: AreaVariantModel): List<SectionModel>? =
cache.getIfPresentNow(EpisodeAndAreaVariant(episode, areaVariant))?.sections
private suspend fun getAreaAsset( private suspend fun getAreaAsset(
episode: Episode, episode: Episode,
areaVariant: AreaVariantModel, areaVariant: AreaVariantModel,
@ -506,6 +509,24 @@ class AreaAssetLoader(private val assetLoader: AssetLoader) : DisposableContaine
"s_n_0_5i_1_iqqrft", "s_n_0_5i_1_iqqrft",
"s_n_0_g_1_iipv9r", "s_n_0_g_1_iipv9r",
"s_n_0_c_1_ihboen", "s_n_0_c_1_ihboen",
"s_n_0_3l_2_iljrhl",
"s_n_0_5t_2_ill0ej",
"s_n_0_4e_2_iobj4y", // Deletes useful walls.
"s_n_0_6y_2_ipln11", // Deletes useful walls.
"s_n_0_43_1_iqbzr4",
"s_n_0_o_1_ikqpac",
"s_n_0_c_1_ihrvdk",
"s_n_0_c_1_ih2ob6",
"s_n_0_c_1_ihwsxo",
"s_n_0_c_1_igrh47",
"s_n_0_j9_4_iqqrft", // Deletes useful walls.
"s_n_0_p_2_ihe7ca",
"s_n_0_l_2_igkyx3",
"s_n_0_n_2_igubtb",
"s_n_0_l_2_ihuczl",
"s_n_0_o_1_ijn9y2",
"s_n_0_f_1_ijpzol",
"s_n_0_2n_1_ilgim5",
), ),
), ),
// Cave 3 // Cave 3

View File

@ -62,26 +62,10 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
open fun setSectionId(sectionId: Int) { open fun setSectionId(sectionId: Int) {
entity.sectionId = sectionId.toShort() entity.sectionId = sectionId.toShort()
_sectionId.value = sectionId _sectionId.value = sectionId
}
fun initializeSection(section: SectionModel) { if (sectionId != _section.value?.id) {
require(!sectionInitialized.value) { _section.value = null
"Section is already initialized."
} }
require(section.areaVariant.area.id == areaId) {
"Section should lie within the entity's area."
}
setSectionId(section.id)
_section.value = section
// Update world position and rotation by calling setPosition and setRotation with the
// current position and rotation.
setPosition(position.value)
setRotation(rotation.value)
setSectionInitialized()
} }
fun setSectionInitialized() { fun setSectionInitialized() {
@ -89,21 +73,32 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
} }
/** /**
* Will update the entity's relative transformation but keep its world transformation constant. * @param keepRelativeTransform If true, keep the entity's relative transform and update its
* world transform. Otherwise keep its world transform and update its relative transform.
*/ */
fun setSection(section: SectionModel) { fun setSection(section: SectionModel, keepRelativeTransform: Boolean = false) {
require(section.areaVariant.area.id == areaId) { require(section.areaVariant.area.id == areaId) {
"Quest entities can't be moved across areas." "Quest entities can't be moved across areas."
} }
setSectionId(section.id) entity.sectionId = section.id.toShort()
_sectionId.value = section.id
_section.value = section _section.value = section
// Update relative position and rotation by calling setWorldPosition and setWorldRotation if (keepRelativeTransform) {
// with the current world position and rotation. // Update world position and rotation by calling setPosition and setRotation with the
setWorldPosition(worldPosition.value) // current position and rotation.
setWorldRotation(worldRotation.value) setPosition(position.value)
setRotation(rotation.value)
} else {
// Update relative position and rotation by calling setWorldPosition and
// setWorldRotation with the current world position and rotation.
setWorldPosition(worldPosition.value)
setWorldRotation(worldRotation.value)
}
setSectionInitialized()
} }
fun setPosition(pos: Vector3) { fun setPosition(pos: Vector3) {

View File

@ -6,6 +6,8 @@ import world.phantasmal.lib.fileFormats.quest.DatUnknown
import world.phantasmal.observable.value.Val 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.list.SimpleListVal import world.phantasmal.observable.value.list.SimpleListVal
import world.phantasmal.observable.value.list.flatMapToList
import world.phantasmal.observable.value.list.listVal
import world.phantasmal.observable.value.map import world.phantasmal.observable.value.map
import world.phantasmal.observable.value.mutableVal import world.phantasmal.observable.value.mutableVal
@ -57,7 +59,7 @@ class QuestModel(
/** /**
* One variant per area. * One variant per area.
*/ */
val areaVariants: Val<List<AreaVariantModel>> val areaVariants: ListVal<AreaVariantModel>
val npcs: ListVal<QuestNpcModel> = _npcs val npcs: ListVal<QuestNpcModel> = _npcs
val objects: ListVal<QuestObjectModel> = _objects val objects: ListVal<QuestObjectModel> = _objects
@ -88,23 +90,24 @@ class QuestModel(
map map
} }
areaVariants = map(entitiesPerArea, this.mapDesignations) { entitiesPerArea, mds -> areaVariants =
val variants = mutableMapOf<Int, AreaVariantModel>() flatMapToList(entitiesPerArea, this.mapDesignations) { entitiesPerArea, mds ->
val variants = mutableMapOf<Int, AreaVariantModel>()
for (areaId in entitiesPerArea.values) { for (areaId in entitiesPerArea.keys) {
getVariant(episode, areaId, 0)?.let { getVariant(episode, areaId, 0)?.let {
variants[areaId] = it variants[areaId] = it
}
} }
}
for ((areaId, variantId) in mds) { for ((areaId, variantId) in mds) {
getVariant(episode, areaId, variantId)?.let { getVariant(episode, areaId, variantId)?.let {
variants[areaId] = it variants[areaId] = it
}
} }
}
variants.values.toList() listVal(*variants.values.toTypedArray())
} }
} }
fun setId(id: Int): QuestModel { fun setId(id: Int): QuestModel {

View File

@ -12,10 +12,14 @@ class QuestEditorMeshManager(
renderContext: QuestRenderContext, renderContext: QuestRenderContext,
) : QuestMeshManager(areaAssetLoader, entityAssetLoader, questEditorStore, renderContext) { ) : QuestMeshManager(areaAssetLoader, entityAssetLoader, questEditorStore, renderContext) {
init { init {
observe(questEditorStore.currentQuest, questEditorStore.currentArea) { quest, area -> observe(
val areaVariant = quest?.let { questEditorStore.currentQuest,
questEditorStore.currentQuest.flatMapNull { it?.areaVariants },
questEditorStore.currentArea,
) { quest, questAreaVariants, area ->
val areaVariant = questAreaVariants?.let {
area?.let { area?.let {
quest.areaVariants.value.find { it.area.id == area.id } questAreaVariants.find { it.area.id == area.id }
?: area.areaVariants.first() ?: area.areaVariants.first()
} }
} }

View File

@ -130,12 +130,12 @@ class StateContext(
) { ) {
questEditorStore.executeAction(TranslateEntityAction( questEditorStore.executeAction(TranslateEntityAction(
::setSelectedEntity, ::setSelectedEntity,
{ questEditorStore.setEntitySection(entity, it) },
entity, entity,
newSection, newSection?.id,
oldSection, oldSection?.id,
newPosition, newPosition,
oldPosition, oldPosition,
world = true,
)) ))
} }

View File

@ -15,7 +15,7 @@ class TranslationState(
private val grabOffset: Vector3, private val grabOffset: Vector3,
) : State() { ) : State() {
private val initialSection: SectionModel? = entity.section.value private val initialSection: SectionModel? = entity.section.value
private val initialPosition: Vector3 = entity.worldPosition.value private val initialPosition: Vector3 = entity.position.value
private val pointerDevicePosition = Vector2() private val pointerDevicePosition = Vector2()
private var shouldTranslate = false private var shouldTranslate = false
private var shouldTranslateVertically = false private var shouldTranslateVertically = false
@ -46,7 +46,7 @@ class TranslationState(
entity, entity,
entity.section.value, entity.section.value,
initialSection, initialSection,
entity.worldPosition.value, entity.position.value,
initialPosition, initialPosition,
) )
} }
@ -87,6 +87,6 @@ class TranslationState(
entity.setSection(initialSection) entity.setSection(initialSection)
} }
entity.setWorldPosition(initialPosition) entity.setPosition(initialPosition)
} }
} }

View File

@ -9,8 +9,8 @@ import world.phantasmal.webui.stores.Store
import world.phantasmal.lib.fileFormats.quest.getAreasForEpisode as getAreasForEpisodeLib import world.phantasmal.lib.fileFormats.quest.getAreasForEpisode as getAreasForEpisodeLib
class AreaStore(private val areaAssetLoader: AreaAssetLoader) : Store() { class AreaStore(private val areaAssetLoader: AreaAssetLoader) : Store() {
private val areas: Map<Episode, List<AreaModel>> = Episode.values() private val areas: Map<Episode, List<AreaModel>> =
.map { episode -> Episode.values().associate { episode ->
episode to getAreasForEpisodeLib(episode).map { area -> episode to getAreasForEpisodeLib(episode).map { area ->
val variants = mutableListOf<AreaVariantModel>() val variants = mutableListOf<AreaVariantModel>()
val areaModel = AreaModel(area.id, area.name, area.order, variants) val areaModel = AreaModel(area.id, area.name, area.order, variants)
@ -22,7 +22,6 @@ class AreaStore(private val areaAssetLoader: AreaAssetLoader) : Store() {
areaModel areaModel
} }
} }
.toMap()
fun getAreasForEpisode(episode: Episode): List<AreaModel> = fun getAreasForEpisode(episode: Episode): List<AreaModel> =
areas.getValue(episode) areas.getValue(episode)
@ -42,4 +41,7 @@ class AreaStore(private val areaAssetLoader: AreaAssetLoader) : Store() {
suspend fun getSections(episode: Episode, variant: AreaVariantModel): List<SectionModel> = suspend fun getSections(episode: Episode, variant: AreaVariantModel): List<SectionModel> =
areaAssetLoader.loadSections(episode, variant) areaAssetLoader.loadSections(episode, variant)
fun getLoadedSections(episode: Episode, variant: AreaVariantModel): List<SectionModel>? =
areaAssetLoader.getCachedSections(episode, variant)
} }

View File

@ -1,6 +1,7 @@
package world.phantasmal.web.questEditor.stores package world.phantasmal.web.questEditor.stores
import kotlinx.browser.window import kotlinx.browser.window
import kotlinx.coroutines.launch
import world.phantasmal.core.Severity import world.phantasmal.core.Severity
import world.phantasmal.core.disposable.Disposer import world.phantasmal.core.disposable.Disposer
import world.phantasmal.core.disposable.disposable import world.phantasmal.core.disposable.disposable
@ -68,7 +69,7 @@ class AsmStore(
} }
observe(asmAnalyser.mapDesignations) { observe(asmAnalyser.mapDesignations) {
questEditorStore.currentQuest.value?.setMapDesignations(it) scope.launch { questEditorStore.setMapDesignations(it) }
} }
observe(problems) { problems -> observe(problems) { problems ->

View File

@ -37,7 +37,7 @@ class QuestEditorStore(
val devMode: Val<Boolean> = _devMode val devMode: Val<Boolean> = _devMode
val runner = QuestRunner() private val runner = QuestRunner()
val currentQuest: Val<QuestModel?> = _currentQuest val currentQuest: Val<QuestModel?> = _currentQuest
val currentArea: Val<AreaModel?> = _currentArea val currentArea: Val<AreaModel?> = _currentArea
val selectedEvent: Val<QuestEventModel?> = _selectedEvent val selectedEvent: Val<QuestEventModel?> = _selectedEvent
@ -134,12 +134,7 @@ class QuestEditorStore(
_currentQuest.value = quest _currentQuest.value = quest
// Load section data. // Load section data.
quest.areaVariants.value.forEach { variant -> updateQuestEntitySections(quest)
val sections = areaStore.getSections(quest.episode, variant)
variant.setSections(sections)
setSectionOnQuestEntities(quest.npcs.value, variant, sections)
setSectionOnQuestEntities(quest.objects.value, variant, sections)
}
// Ensure all entities have their section initialized. // Ensure all entities have their section initialized.
quest.npcs.value.forEach { it.setSectionInitialized() } quest.npcs.value.forEach { it.setSectionInitialized() }
@ -150,25 +145,6 @@ class QuestEditorStore(
suspend fun getDefaultQuest(episode: Episode): QuestModel = suspend fun getDefaultQuest(episode: Episode): QuestModel =
convertQuestToModel(questLoader.loadDefaultQuest(episode), areaStore::getVariant) convertQuestToModel(questLoader.loadDefaultQuest(episode), areaStore::getVariant)
private fun setSectionOnQuestEntities(
entities: List<QuestEntityModel<*, *>>,
variant: AreaVariantModel,
sections: List<SectionModel>,
) {
entities.forEach { entity ->
if (entity.areaId == variant.area.id) {
val section = sections.find { it.id == entity.sectionId.value }
if (section == null) {
logger.warn { "Section ${entity.sectionId.value} not found." }
entity.setSectionInitialized()
} else {
entity.initializeSection(section)
}
}
}
}
fun setCurrentArea(area: AreaModel?) { fun setCurrentArea(area: AreaModel?) {
val event = selectedEvent.value val event = selectedEvent.value
@ -215,6 +191,30 @@ class QuestEditorStore(
_selectedEntity.value = entity _selectedEntity.value = entity
} }
suspend fun setMapDesignations(mapDesignations: Map<Int, Int>) {
currentQuest.value?.let { quest ->
quest.setMapDesignations(mapDesignations)
updateQuestEntitySections(quest)
}
}
fun setEntitySection(entity: QuestEntityModel<*, *>, sectionId: Int) {
currentQuest.value?.let { quest ->
val variant = quest.areaVariants.value.find { it.area.id == entity.areaId }
variant?.let {
val section = areaStore.getLoadedSections(quest.episode, variant)
?.find { it.id == sectionId }
if (section == null) {
entity.setSectionId(sectionId)
} else {
entity.setSection(section)
}
}
}
}
fun executeAction(action: Action) { fun executeAction(action: Action) {
pushAction(action) pushAction(action)
action.execute() action.execute()
@ -239,4 +239,32 @@ class QuestEditorStore(
fun questSaved() { fun questSaved() {
undoManager.savePoint() undoManager.savePoint()
} }
private suspend fun updateQuestEntitySections(quest: QuestModel) {
quest.areaVariants.value.forEach { variant ->
val sections = areaStore.getSections(quest.episode, variant)
variant.setSections(sections)
setSectionOnQuestEntities(quest.npcs.value, variant, sections)
setSectionOnQuestEntities(quest.objects.value, variant, sections)
}
}
private fun setSectionOnQuestEntities(
entities: List<QuestEntityModel<*, *>>,
variant: AreaVariantModel,
sections: List<SectionModel>,
) {
entities.forEach { entity ->
if (entity.areaId == variant.area.id) {
val section = sections.find { it.id == entity.sectionId.value }
if (section == null) {
logger.warn { "Section ${entity.sectionId.value} not found." }
entity.setSectionInitialized()
} else {
entity.setSection(section, keepRelativeTransform = true)
}
}
}
}
} }

View File

@ -9,7 +9,7 @@ import kotlin.test.assertTrue
class UndoStackTests : WebTestSuite { class UndoStackTests : WebTestSuite {
@Test @Test
fun simple_properties_and_invariants() { fun simple_properties_and_invariants() = test {
val stack = UndoStack(UndoManager()) val stack = UndoStack(UndoManager())
assertFalse(stack.canUndo.value) assertFalse(stack.canUndo.value)
@ -35,7 +35,7 @@ class UndoStackTests : WebTestSuite {
} }
@Test @Test
fun undo() { fun undo() = test {
val stack = UndoStack(UndoManager()) val stack = UndoStack(UndoManager())
var value = 3 var value = 3
@ -56,7 +56,7 @@ class UndoStackTests : WebTestSuite {
} }
@Test @Test
fun redo() { fun redo() = test {
val stack = UndoStack(UndoManager()) val stack = UndoStack(UndoManager())
var value = 3 var value = 3
@ -80,7 +80,7 @@ class UndoStackTests : WebTestSuite {
} }
@Test @Test
fun push_then_undo_then_push_again() { fun push_then_undo_then_push_again() = test {
val stack = UndoStack(UndoManager()) val stack = UndoStack(UndoManager())
var value = 3 var value = 3

View File

@ -19,13 +19,13 @@ class QuestEntityModelTests : WebTestSuite {
assertTrue(entity.position.value.equals(entity.worldPosition.value)) assertTrue(entity.position.value.equals(entity.worldPosition.value))
// When section is initialized, relative position stays the same and world position changes. // Initialize section and keep relative position the same so world position changes.
entity.initializeSection(SectionModel( entity.setSection(SectionModel(
20, 20,
Vector3(7.0, 7.0, 7.0), Vector3(7.0, 7.0, 7.0),
euler(.0, .0, .0), euler(.0, .0, .0),
components.areaStore.getVariant(Episode.I, 0, 0)!!, components.areaStore.getVariant(Episode.I, 0, 0)!!,
)) ), keepRelativeTransform = true)
assertEquals(5.0, entity.position.value.x) assertEquals(5.0, entity.position.value.x)
assertEquals(5.0, entity.position.value.y) assertEquals(5.0, entity.position.value.y)

View File

@ -38,12 +38,6 @@ abstract class Input<T>(
onchange = { callOnChange(this) } onchange = { callOnChange(this) }
onkeydown = { e ->
if (e.key == "Enter") {
callOnChange(this)
}
}
interceptInputElement(this) interceptInputElement(this)
observe(this@Input.value) { observe(this@Input.value) {

View File

@ -1,7 +1,9 @@
package world.phantasmal.webui.widgets package world.phantasmal.webui.widgets
import kotlinx.browser.document import kotlinx.browser.document
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.w3c.dom.Element import org.w3c.dom.Element
import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLStyleElement import org.w3c.dom.HTMLStyleElement
@ -203,6 +205,10 @@ abstract class Widget(
addDisposable(disposablePointerDrag(onPointerDown, onPointerMove, onPointerUp)) addDisposable(disposablePointerDrag(onPointerDown, onPointerMove, onPointerUp))
} }
protected fun launch(block: suspend CoroutineScope.() -> Unit) {
scope.launch(block = block)
}
companion object { companion object {
private val STYLE_EL by lazy { private val STYLE_EL by lazy {
val el = document.createElement("style") as HTMLStyleElement val el = document.createElement("style") as HTMLStyleElement