Warp destinations are now shown in the 3D view. When a warp is selected, a line is drawn from the warp to its destination.

This commit is contained in:
Daan Vanden Bosch 2021-06-03 16:06:39 +02:00
parent a8abd17e5e
commit 6d412b870d
16 changed files with 540 additions and 153 deletions

View File

@ -74,7 +74,40 @@ class QuestObject(override var areaId: Int, override val data: Buffer) : QuestEn
val scriptLabel2: Int?
get() = if (type == ObjectType.RicoMessagePod) data.getInt(60) else null
val model: Int?
/**
* The offset of the model property or -1 if this object doesn't have a model property.
*/
val modelOffset: Int
get() = when (type) {
ObjectType.Probe,
-> 40
ObjectType.Saw,
ObjectType.LaserDetect,
-> 48
ObjectType.Sonic,
ObjectType.LittleCryotube,
ObjectType.Cactus,
ObjectType.BigBrownRock,
ObjectType.BigBlackRocks,
ObjectType.BeeHive,
-> 52
ObjectType.ForestConsole,
-> 56
ObjectType.PrincipalWarp,
ObjectType.LaserFence,
ObjectType.LaserSquareFence,
ObjectType.LaserFenceEx,
ObjectType.LaserSquareFenceEx,
-> 60
else -> -1
}
var model: Int
get() = when (type) {
ObjectType.Probe,
-> data.getFloat(40).roundToInt()
@ -101,7 +134,103 @@ class QuestObject(override var areaId: Int, override val data: Buffer) : QuestEn
ObjectType.LaserSquareFenceEx,
-> data.getInt(60)
else -> null
else -> throw IllegalArgumentException("$type doesn't have a model property.")
}
set(value) {
when (type) {
ObjectType.Probe,
-> data.setFloat(40, value.toFloat())
ObjectType.Saw,
ObjectType.LaserDetect,
-> data.setFloat(48, value.toFloat())
ObjectType.Sonic,
ObjectType.LittleCryotube,
ObjectType.Cactus,
ObjectType.BigBrownRock,
ObjectType.BigBlackRocks,
ObjectType.BeeHive,
-> data.setInt(52, value)
ObjectType.ForestConsole,
-> data.setInt(56, value)
ObjectType.PrincipalWarp,
ObjectType.LaserFence,
ObjectType.LaserSquareFence,
ObjectType.LaserFenceEx,
ObjectType.LaserSquareFenceEx,
-> data.setInt(60, value)
else -> throw IllegalArgumentException("$type doesn't have a model property.")
}
}
val destinationPositionOffset: Int
get() = when (type) {
ObjectType.Warp, ObjectType.PrincipalWarp, ObjectType.RuinsWarpSiteToSite -> 40
else -> -1
}
/**
* Only valid for [ObjectType.Warp], [ObjectType.PrincipalWarp] and
* [ObjectType.RuinsWarpSiteToSite].
*/
var destinationPosition: Vec3
get() = Vec3(
data.getFloat(40),
data.getFloat(44),
data.getFloat(48),
)
set(value) {
setDestinationPosition(value.x, value.y, value.z)
}
/**
* Only valid for [ObjectType.Warp], [ObjectType.PrincipalWarp] and
* [ObjectType.RuinsWarpSiteToSite].
*/
var destinationPositionX: Float
get() = data.getFloat(40)
set(value) {
data.setFloat(40, value)
}
/**
* Only valid for [ObjectType.Warp], [ObjectType.PrincipalWarp] and
* [ObjectType.RuinsWarpSiteToSite].
*/
var destinationPositionY: Float
get() = data.getFloat(44)
set(value) {
data.setFloat(44, value)
}
/**
* Only valid for [ObjectType.Warp], [ObjectType.PrincipalWarp] and
* [ObjectType.RuinsWarpSiteToSite].
*/
var destinationPositionZ: Float
get() = data.getFloat(48)
set(value) {
data.setFloat(48, value)
}
val destinationRotationYOffset: Int
get() = when (type) {
ObjectType.Warp, ObjectType.PrincipalWarp, ObjectType.RuinsWarpSiteToSite -> 52
else -> -1
}
/**
* Only valid for [ObjectType.Warp], [ObjectType.PrincipalWarp] and
* [ObjectType.RuinsWarpSiteToSite].
*/
var destinationRotationY: Float
get() = angleToRad(data.getInt(52))
set(value) {
data.setInt(52, radToAngle(value))
}
init {
@ -121,4 +250,10 @@ class QuestObject(override var areaId: Int, override val data: Buffer) : QuestEn
data.setInt(32, radToAngle(y))
data.setInt(36, radToAngle(z))
}
fun setDestinationPosition(x: Float, y: Float, z: Float) {
data.setFloat(40, x)
data.setFloat(44, y)
data.setFloat(48, z)
}
}

View File

@ -0,0 +1,12 @@
@file:JsModule("three/examples/jsm/utils/BufferGeometryUtils")
@file:JsNonModule
@file:Suppress("PropertyName", "unused")
package world.phantasmal.web.externals.three
external object BufferGeometryUtils {
fun mergeBufferGeometries(
geometries: Array<BufferGeometry>,
useGroups: Boolean,
): BufferGeometry?
}

View File

@ -283,6 +283,8 @@ open external class Object3D {
*/
val scale: Vector3
var frustumCulled: Boolean
/**
* Local transform.
*/
@ -382,11 +384,17 @@ external class Skeleton(bones: Array<Bone>, boneInverses: Array<Matrix4> = defin
external class SkeletonHelper(`object`: Object3D) : LineSegments
open external class Line : Object3D {
open external class Line(
geometry: BufferGeometry = definedExternally,
material: Material = definedExternally,
) : Object3D {
var material: dynamic /* Material | Material[] */
}
open external class LineSegments : Line
open external class LineSegments(
geometry: BufferGeometry = definedExternally,
material: Material = definedExternally,
) : Line
open external class BoxHelper(
`object`: Object3D = definedExternally,
@ -517,6 +525,12 @@ open external class BufferGeometry : EventDispatcher {
fun translate(x: Double, y: Double, z: Double): BufferGeometry
fun rotateX(radians: Double): BufferGeometry
fun rotateY(radians: Double): BufferGeometry
fun rotateZ(radians: Double): BufferGeometry
fun scale(x: Double, y: Double, z: Double): BufferGeometry
fun computeBoundingBox()
fun computeBoundingSphere()
@ -530,7 +544,17 @@ external class PlaneGeometry(
heightSegments: Double = definedExternally,
) : BufferGeometry
external class CylinderBufferGeometry(
external class ConeGeometry(
radius: Double = definedExternally,
height: Double = definedExternally,
radialSegments: Int = definedExternally,
heightSegments: Int = definedExternally,
openEnded: Boolean = definedExternally,
thetaStart: Double = definedExternally,
thetaLength: Double = definedExternally,
) : BufferGeometry
external class CylinderGeometry(
radiusTop: Double = definedExternally,
radiusBottom: Double = definedExternally,
height: Double = definedExternally,
@ -541,10 +565,22 @@ external class CylinderBufferGeometry(
thetaLength: Double = definedExternally,
) : BufferGeometry
external class SphereGeometry(
radius: Double = definedExternally,
widthSegments: Int = definedExternally,
heightSegments: Int = definedExternally,
phiStart: Double = definedExternally,
phiLength: Double = definedExternally,
thetaStart: Double = definedExternally,
thetaLength: Double = definedExternally,
) : BufferGeometry
open external class BufferAttribute {
var needsUpdate: Boolean
fun copyAt(index1: Int, bufferAttribute: BufferAttribute, index2: Int): BufferAttribute
fun setXYZ(index: Int, x: Double, y: Double, z: Double): BufferAttribute
}
external class Int32BufferAttribute(
@ -622,8 +658,15 @@ external class MeshLambertMaterial(
parameters: MeshLambertMaterialParameters = definedExternally,
) : Material
external class LineBasicMaterial : Material {
var linewidth: Int
external interface LineBasicMaterialParameters : MaterialParameters {
var color: Color
var linewidth: Double
}
external class LineBasicMaterial(
parameters: LineBasicMaterialParameters = definedExternally,
) : Material {
var linewidth: Double
}
open external class Texture : EventDispatcher {

View File

@ -133,7 +133,7 @@ class EntityAssetLoader(private val assetLoader: AssetLoader) : DisposableContai
private fun createCylinder(color: Color) =
InstancedMesh(
CylinderBufferGeometry(
CylinderGeometry(
radiusTop = 2.5,
radiusBottom = 2.5,
height = 18.0,

View File

@ -4,53 +4,43 @@ 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.cell.Cell
import world.phantasmal.observable.cell.MutableCell
import world.phantasmal.observable.cell.mutableCell
import world.phantasmal.web.externals.three.Vector3
class QuestEntityPropModel(private val entity: QuestEntityModel<*, *>, prop: EntityProp) {
private val _value: MutableCell<Any> = mutableCell(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
private val _value: MutableCell<Any> = mutableCell(
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
private val affectsDestinationPosition: Boolean
private val affectsDestinationRotationY: Boolean
val name: String = prop.name
val offset = prop.offset
val type: EntityPropType = prop.type
val value: Cell<Any> = _value
fun setValue(value: Any, propagateToEntity: Boolean = true) {
init {
affectsModel = entity is QuestObjectModel &&
entity.entity.modelOffset != -1 &&
overlaps(entity.entity.modelOffset, 4)
affectsDestinationPosition = entity is QuestObjectModel &&
entity.entity.destinationPositionOffset != -1 &&
overlaps(entity.entity.destinationPositionOffset, 12)
affectsDestinationRotationY = entity is QuestObjectModel &&
entity.entity.destinationRotationYOffset != -1 &&
overlaps(entity.entity.destinationRotationYOffset, 4)
}
fun setValue(value: Any) {
when (type) {
EntityPropType.I32 -> {
require(value is Int)
@ -68,11 +58,40 @@ class QuestEntityPropModel(private val entity: QuestEntityModel<*, *>, prop: Ent
_value.value = value
if (propagateToEntity && affectsModel) {
if (affectsModel) {
(entity as QuestObjectModel).setModel(
entity.entity.data.getInt(offset),
entity.entity.model,
propagateToProps = false,
)
}
if (affectsDestinationPosition) {
(entity as QuestObjectModel).setDestinationPosition(
Vector3(
entity.entity.destinationPositionX.toDouble(),
entity.entity.destinationPositionY.toDouble(),
entity.entity.destinationPositionZ.toDouble(),
),
propagateToProps = false,
)
}
if (affectsDestinationRotationY) {
(entity as QuestObjectModel).setDestinationRotationY(
entity.entity.destinationRotationY.toDouble(),
propagateToProps = false,
)
}
}
fun updateValue() {
_value.value = when (type) {
EntityPropType.I32 -> entity.entity.data.getInt(offset)
EntityPropType.F32 -> entity.entity.data.getFloat(offset)
EntityPropType.Angle -> angleToRad(entity.entity.data.getInt(offset))
}
}
fun overlaps(offset: Int, size: Int): Boolean =
this.offset < offset + size && this.offset + 4 > offset
}

View File

@ -4,50 +4,52 @@ import world.phantasmal.lib.fileFormats.quest.ObjectType
import world.phantasmal.lib.fileFormats.quest.QuestObject
import world.phantasmal.observable.cell.Cell
import world.phantasmal.observable.cell.mutableCell
import world.phantasmal.web.core.rendering.conversion.vec3ToThree
import world.phantasmal.web.externals.three.Vector3
class QuestObjectModel(obj: QuestObject) : QuestEntityModel<ObjectType, QuestObject>(obj) {
private val _model = mutableCell(obj.model)
val hasDestination = obj.destinationPositionOffset != -1
private val _model = mutableCell(if (obj.modelOffset == -1) null else obj.model)
val model: Cell<Int?> = _model
private val _destinationPosition = mutableCell(vec3ToThree(obj.destinationPosition))
val destinationPosition: Cell<Vector3> = _destinationPosition
private val _destinationRotationY = mutableCell(obj.destinationRotationY.toDouble())
val destinationRotationY: Cell<Double> = _destinationRotationY
fun setModel(model: Int, propagateToProps: Boolean = true) {
entity.model = model
_model.value = model
if (propagateToProps) {
val props = when (type) {
ObjectType.Probe ->
properties.value.filter { it.offset == 40 }
propagateChangeToProperties(entity.modelOffset, 4)
}
}
ObjectType.Saw,
ObjectType.LaserDetect,
->
properties.value.filter { it.offset == 48 }
fun setDestinationPosition(pos: Vector3, propagateToProps: Boolean = true) {
entity.setDestinationPosition(pos.x.toFloat(), pos.y.toFloat(), pos.z.toFloat())
_destinationPosition.value = pos
ObjectType.Sonic,
ObjectType.LittleCryotube,
ObjectType.Cactus,
ObjectType.BigBrownRock,
ObjectType.BigBlackRocks,
ObjectType.BeeHive,
->
properties.value.filter { it.offset == 52 }
if (propagateToProps) {
propagateChangeToProperties(entity.destinationPositionOffset, 12)
}
}
ObjectType.ForestConsole ->
properties.value.filter { it.offset == 56 }
fun setDestinationRotationY(rotY: Double, propagateToProps: Boolean = true) {
entity.destinationRotationY = rotY.toFloat()
_destinationRotationY.value = rotY
ObjectType.PrincipalWarp,
ObjectType.LaserFence,
ObjectType.LaserSquareFence,
ObjectType.LaserFenceEx,
ObjectType.LaserSquareFenceEx,
->
properties.value.filter { it.offset == 60 }
if (propagateToProps) {
propagateChangeToProperties(entity.destinationRotationYOffset, 4)
}
}
else -> return
}
for (prop in props) {
prop.setValue(model, propagateToEntity = false)
private fun propagateChangeToProperties(offset: Int, size: Int) {
for (prop in properties.value) {
if (prop.overlaps(offset, size)) {
prop.updateValue()
}
}
}

View File

@ -0,0 +1,24 @@
package world.phantasmal.web.questEditor.rendering
import world.phantasmal.web.externals.three.InstancedMesh
import world.phantasmal.web.externals.three.Object3D
import world.phantasmal.web.questEditor.models.QuestObjectModel
class DestinationInstance(
entity: QuestObjectModel,
mesh: InstancedMesh,
instanceIndex: Int,
) : Instance<QuestObjectModel>(entity, mesh, instanceIndex) {
init {
addDisposables(
entity.destinationPosition.observe { updateMatrix() },
entity.destinationRotationY.observe { updateMatrix() },
)
}
override fun updateObjectMatrix(obj: Object3D) {
obj.position.copy(entity.destinationPosition.value)
obj.rotation.set(.0, entity.destinationRotationY.value, .0)
obj.updateMatrix()
}
}

View File

@ -0,0 +1,52 @@
package world.phantasmal.web.questEditor.rendering
import world.phantasmal.web.externals.three.*
import world.phantasmal.web.questEditor.models.QuestObjectModel
import world.phantasmal.webui.obj
import kotlin.math.PI
class DestinationInstanceContainer : InstanceContainer<QuestObjectModel, DestinationInstance>(
InstancedMesh(
BufferGeometryUtils.mergeBufferGeometries(
arrayOf(
SphereGeometry(
radius = 4.0,
widthSegments = 16,
heightSegments = 12,
),
CylinderGeometry(
radiusTop = 1.0,
radiusBottom = 1.0,
height = 10.0,
radialSegments = 10,
).apply {
translate(.0, 5.0, .0)
},
ConeGeometry(
radius = 3.0,
height = 6.0,
radialSegments = 20,
).apply {
translate(.0, 13.0, .0)
},
),
useGroups = false,
)!!.apply {
rotateX(PI / 2)
computeBoundingBox()
computeBoundingSphere()
},
MeshLambertMaterial(obj { color = COLOR }),
count = 1000,
).apply {
// Start with 0 instances.
count = 0
}
) {
override fun createInstance(entity: QuestObjectModel, index: Int): DestinationInstance =
DestinationInstance(entity, mesh, index)
companion object {
val COLOR = Color(0x00FFC0)
}
}

View File

@ -4,60 +4,29 @@ import world.phantasmal.web.externals.three.InstancedMesh
import world.phantasmal.web.externals.three.Object3D
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.QuestObjectModel
import world.phantasmal.webui.DisposableContainer
class EntityInstance(
val entity: QuestEntityModel<*, *>,
val mesh: InstancedMesh,
var instanceIndex: Int,
entity: QuestEntityModel<*, *>,
mesh: InstancedMesh,
instanceIndex: Int,
modelChanged: (instanceIndex: Int) -> Unit,
) : DisposableContainer() {
/**
* When set, this object's transform will match the instance's transform.
*/
var follower: Object3D? = null
set(follower) {
follower?.let {
follower.position.copy(entity.worldPosition.value)
follower.rotation.copy(entity.worldRotation.value)
follower.updateMatrix()
}
field = follower
}
) : Instance<QuestEntityModel<*, *>>(entity, mesh, instanceIndex) {
init {
updateMatrix()
if (entity is QuestObjectModel) {
addDisposable(entity.model.observe(callNow = false) {
modelChanged(this.instanceIndex)
})
}
addDisposables(
entity.worldPosition.observe { updateMatrix() },
entity.worldRotation.observe { updateMatrix() },
)
if (entity is QuestObjectModel) {
addDisposable(entity.model.observe(callNow = false) {
modelChanged(instanceIndex)
})
}
}
private fun updateMatrix() {
val pos = entity.worldPosition.value
val rot = entity.worldRotation.value
instanceHelper.position.copy(pos)
instanceHelper.rotation.copy(rot)
instanceHelper.updateMatrix()
mesh.setMatrixAt(instanceIndex, instanceHelper.matrix)
mesh.instanceMatrix.needsUpdate = true
follower?.let { follower ->
follower.position.copy(pos)
follower.rotation.copy(rot)
follower.updateMatrix()
}
}
companion object {
private val instanceHelper = Object3D()
override fun updateObjectMatrix(obj: Object3D) {
obj.position.copy(entity.worldPosition.value)
obj.rotation.copy(entity.worldRotation.value)
obj.updateMatrix()
}
}

View File

@ -0,0 +1,24 @@
package world.phantasmal.web.questEditor.rendering
import world.phantasmal.web.externals.three.InstancedMesh
import world.phantasmal.web.questEditor.models.QuestEntityModel
/**
* Represents a specific entity type and model combination. Contains a single [InstancedMesh] and
* manages its instances. Takes ownership of the given mesh.
*/
class EntityInstanceContainer(
mesh: InstancedMesh,
/**
* Called whenever an entity's model changes. At this point the entity's instance has already
* been removed from this [EntityInstanceContainer]. The entity should then be added to the correct
* [EntityInstanceContainer].
*/
private val modelChanged: (QuestEntityModel<*, *>) -> Unit,
) : InstanceContainer<QuestEntityModel<*, *>, EntityInstance>(mesh) {
override fun createInstance(entity: QuestEntityModel<*, *>, index: Int): EntityInstance =
EntityInstance(entity, mesh, index) { idx ->
removeAt(idx)
modelChanged(entity)
}
}

View File

@ -2,16 +2,19 @@ package world.phantasmal.web.questEditor.rendering
import kotlinx.coroutines.*
import mu.KotlinLogging
import org.khronos.webgl.Float32Array
import world.phantasmal.core.disposable.DisposableSupervisedScope
import world.phantasmal.core.disposable.Disposer
import world.phantasmal.lib.fileFormats.quest.EntityType
import world.phantasmal.web.externals.three.BoxHelper
import world.phantasmal.web.externals.three.Color
import world.phantasmal.web.core.rendering.disposeObject3DResources
import world.phantasmal.web.externals.three.*
import world.phantasmal.web.questEditor.loading.EntityAssetLoader
import world.phantasmal.web.questEditor.loading.LoadingCache
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.QuestObjectModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.obj
private val logger = KotlinLogging.logger {}
@ -23,23 +26,47 @@ class EntityMeshManager(
private val scope = addDisposable(DisposableSupervisedScope(this::class, Dispatchers.Main))
/**
* Contains one [EntityInstancedMesh] per [EntityType] and model.
* Contains one [EntityInstanceContainer] per [EntityType] and model.
*/
private val entityMeshCache = addDisposable(
LoadingCache<TypeAndModel, EntityInstancedMesh>(
LoadingCache<TypeAndModel, EntityInstanceContainer>(
{ (type, model) ->
val mesh = entityAssetLoader.loadInstancedMesh(type, model)
renderContext.entities.add(mesh)
EntityInstancedMesh(mesh, modelChanged = { entity ->
EntityInstanceContainer(mesh, modelChanged = { entity ->
// When an entity's model changes, add it again. At this point it has already
// been removed from its previous EntityInstancedMesh.
add(entity)
})
},
EntityInstancedMesh::dispose,
EntityInstanceContainer::dispose,
)
)
/**
* Warp destinations.
*/
private val destinationInstanceContainer = addDisposable(
DestinationInstanceContainer().also {
renderContext.entities.add(it.mesh)
}
)
// Lines between warps and their destination.
private val warpLineBufferAttribute = Float32BufferAttribute(Float32Array(6), 3)
private val warpLines =
LineSegments(
BufferGeometry().setAttribute("position", warpLineBufferAttribute),
LineBasicMaterial(obj {
color = DestinationInstanceContainer.COLOR
})
).also {
it.visible = false
it.frustumCulled = false
renderContext.helpers.add(it)
}
private var warpLineDisposer = addDisposable(Disposer())
/**
* Entity meshes that are currently being loaded.
*/
@ -81,6 +108,7 @@ class EntityMeshManager(
override fun dispose() {
removeAll()
renderContext.entities.clear()
disposeObject3DResources(warpLines)
super.dispose()
}
@ -88,10 +116,12 @@ class EntityMeshManager(
loadingEntities.getOrPut(entity) {
scope.launch {
try {
val entityInstancedMesh = entityMeshCache.get(TypeAndModel(
type = entity.type,
model = (entity as? QuestObjectModel)?.model?.value
))
val entityInstancedMesh = entityMeshCache.get(
TypeAndModel(
type = entity.type,
model = (entity as? QuestObjectModel)?.model?.value
)
)
val instance = entityInstancedMesh.addInstance(entity)
@ -100,6 +130,10 @@ class EntityMeshManager(
} else if (entity == questEditorStore.highlightedEntity.value) {
markHighlighted(instance)
}
if (entity is QuestObjectModel && entity.hasDestination) {
destinationInstanceContainer.addInstance(entity)
}
} catch (e: CancellationException) {
// Do nothing.
} catch (e: Throwable) {
@ -122,6 +156,10 @@ class EntityMeshManager(
(entity as? QuestObjectModel)?.model?.value
)
)?.removeInstance(entity)
if (entity is QuestObjectModel && entity.hasDestination) {
destinationInstanceContainer.removeInstance(entity)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@ -134,6 +172,8 @@ class EntityMeshManager(
meshContainerDeferred.getCompleted().clearInstances()
}
}
destinationInstanceContainer.clearInstances()
}
private fun markHighlighted(instance: EntityInstance?) {
@ -158,6 +198,7 @@ class EntityMeshManager(
}
attachBoxHelper(selectedBox, selectedEntityInstance, instance)
attachWarpLine(instance)
selectedEntityInstance = instance
}
@ -179,6 +220,26 @@ class EntityMeshManager(
}
}
private fun attachWarpLine(newInstance: EntityInstance?) {
warpLineDisposer.disposeAll()
warpLines.visible = false
if (newInstance != null &&
newInstance.entity is QuestObjectModel &&
newInstance.entity.hasDestination
) {
warpLineDisposer.add(newInstance.entity.worldPosition.observe(callNow = true) {
warpLineBufferAttribute.setXYZ(0, it.value.x, it.value.y, it.value.z)
warpLineBufferAttribute.needsUpdate = true
})
warpLineDisposer.add(newInstance.entity.destinationPosition.observe(callNow = true) {
warpLineBufferAttribute.setXYZ(1, it.value.x, it.value.y, it.value.z)
warpLineBufferAttribute.needsUpdate = true
})
warpLines.visible = true
}
}
private fun getEntityInstance(entity: QuestEntityModel<*, *>): EntityInstance? =
entityMeshCache.getIfPresentNow(
TypeAndModel(

View File

@ -0,0 +1,42 @@
package world.phantasmal.web.questEditor.rendering
import world.phantasmal.web.externals.three.InstancedMesh
import world.phantasmal.web.externals.three.Object3D
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.webui.DisposableContainer
/**
* Represents an instance of an InstancedMesh related to a quest entity.
*/
abstract class Instance<Entity : QuestEntityModel<*, *>>(
val entity: Entity,
val mesh: InstancedMesh,
var instanceIndex: Int,
) : DisposableContainer() {
/**
* When set, this object's transform will match the instance's transform.
*/
var follower: Object3D? = null
set(follower) {
follower?.let(::updateObjectMatrix)
field = follower
}
init {
updateMatrix()
}
protected fun updateMatrix() {
updateObjectMatrix(instanceHelper)
mesh.setMatrixAt(instanceIndex, instanceHelper.matrix)
mesh.instanceMatrix.needsUpdate = true
follower?.let(::updateObjectMatrix)
}
protected abstract fun updateObjectMatrix(obj: Object3D)
companion object {
private val instanceHelper = Object3D()
}
}

View File

@ -6,21 +6,16 @@ import world.phantasmal.web.externals.three.InstancedMesh
import world.phantasmal.web.questEditor.models.QuestEntityModel
/**
* Represents a specific entity type and model combination. Contains a single [InstancedMesh] and
* manages its instances.
* Contains instances of an InstancedMesh related to a quest entity.
*/
class EntityInstancedMesh(
private val mesh: InstancedMesh,
/**
* Called whenever an entity's model changes. At this point the entity's instance has already
* been removed from this [EntityInstancedMesh]. The entity should then be added to the correct
* [EntityInstancedMesh].
*/
private val modelChanged: (QuestEntityModel<*, *>) -> Unit,
abstract class InstanceContainer<Entity : QuestEntityModel<*, *>, Inst : Instance<Entity>>(
val mesh: InstancedMesh,
) : TrackedDisposable() {
private val instances: MutableList<EntityInstance> = mutableListOf()
private val instances: MutableList<Inst> = mutableListOf()
init {
@Suppress("LeakingThis")
mesh.userData = this
}
@ -29,26 +24,22 @@ class EntityInstancedMesh(
super.dispose()
}
fun getInstance(entity: QuestEntityModel<*, *>): EntityInstance? =
fun getInstance(entity: Entity): Inst? =
instances.find { it.entity == entity }
fun getInstanceAt(instanceIndex: Int): EntityInstance =
fun getInstanceAt(instanceIndex: Int): Inst =
instances[instanceIndex]
fun addInstance(entity: QuestEntityModel<*, *>): EntityInstance {
fun addInstance(entity: Entity): Inst {
val instanceIndex = mesh.count
mesh.count++
val instance = EntityInstance(entity, mesh, instanceIndex) { index ->
removeAt(index)
modelChanged(entity)
}
val instance = createInstance(entity, instanceIndex)
instances.add(instance)
return instance
}
fun removeInstance(entity: QuestEntityModel<*, *>) {
fun removeInstance(entity: Entity) {
val index = instances.indexOfFirst { it.entity == entity }
if (index != -1) {
@ -56,7 +47,7 @@ class EntityInstancedMesh(
}
}
private fun removeAt(index: Int) {
protected fun removeAt(index: Int) {
val instance = instances.removeAt(index)
mesh.count--
@ -74,4 +65,6 @@ class EntityInstancedMesh(
instances.clear()
mesh.count = 0
}
protected abstract fun createInstance(entity: Entity, index: Int): Inst
}

View File

@ -10,11 +10,22 @@ class QuestRenderContext(
canvas: HTMLCanvasElement,
camera: Camera,
) : RenderContext(canvas, camera) {
/**
* Things that can be directly manipulated such as NPCs, objects, warp destinations,...
*/
val entities: Object3D = Group().apply {
name = "Entities"
scene.add(this)
}
/**
* Helper objects that can't be directly manipulated such as warp lines.
*/
val helpers: Object3D = Group().apply {
name = "Helpers"
scene.add(this)
}
var collisionGeometryVisible = true
set(visible) {
field = visible

View File

@ -5,7 +5,7 @@ import world.phantasmal.web.externals.three.Mesh
import world.phantasmal.web.externals.three.Vector2
import world.phantasmal.web.externals.three.Vector3
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.rendering.EntityInstancedMesh
import world.phantasmal.web.questEditor.rendering.EntityInstanceContainer
import world.phantasmal.web.questEditor.rendering.input.*
class IdleState(
@ -166,7 +166,7 @@ class IdleState(
val entityInstancedMesh = intersection.`object`.userData
val instanceIndex = intersection.instanceId
if (instanceIndex == null || entityInstancedMesh !is EntityInstancedMesh) {
if (instanceIndex == null || entityInstancedMesh !is EntityInstanceContainer) {
return null
}

View File

@ -158,7 +158,7 @@ class MeshRenderer(
// Add skeleton.
val skeletonHelper = SkeletonHelper(obj3d)
skeletonHelper.visible = viewerStore.showSkeleton.value
skeletonHelper.material.unsafeCast<LineBasicMaterial>().linewidth = 3
skeletonHelper.material.unsafeCast<LineBasicMaterial>().linewidth = 3.0
context.scene.add(skeletonHelper)
this.skeletonHelper = skeletonHelper