Added some small features and fixed various bugs.

This commit is contained in:
Daan Vanden Bosch 2020-11-28 22:56:35 +01:00
parent d526c837fd
commit 0c0d6355f2
39 changed files with 277 additions and 85 deletions

View File

@ -19,6 +19,7 @@ subprojects {
repositories {
jcenter()
maven(url = "https://kotlin.bintray.com/kotlinx/")
}
tasks.withType<Kotlin2JsCompile> {

View File

@ -32,6 +32,8 @@ open class Problem(
)
enum class Severity {
Trace,
Debug,
Info,
Warning,
Error,
@ -50,6 +52,8 @@ class PwResultBuilder<T>(private val logger: KLogger) {
problem: Problem,
): PwResultBuilder<T> {
when (problem.severity) {
Severity.Trace -> logger.trace(problem.cause) { problem.message ?: problem.uiMessage }
Severity.Debug -> logger.debug(problem.cause) { problem.message ?: problem.uiMessage }
Severity.Info -> logger.info(problem.cause) { problem.message ?: problem.uiMessage }
Severity.Warning -> logger.warn(problem.cause) { problem.message ?: problem.uiMessage }
Severity.Error -> logger.error(problem.cause) { problem.message ?: problem.uiMessage }

View File

@ -1,5 +1,8 @@
package world.phantasmal.core.disposable
/**
* Container for disposables that disposes the contained disposables when it is disposed.
*/
class Disposer(vararg disposables: Disposable) : TrackedDisposable() {
private val disposables = mutableListOf(*disposables)
@ -62,5 +65,6 @@ class Disposer(vararg disposables: Disposable) : TrackedDisposable() {
override fun internalDispose() {
disposeAll()
super.internalDispose()
}
}

View File

@ -25,6 +25,9 @@ class Quest(
val objects: List<QuestObject>,
val npcs: List<QuestNpc>,
val events: List<DatEvent>,
/**
* (Partial) raw DAT data that can't be parsed yet by Phantasmal.
*/
val datUnknowns: List<DatUnknown>,
val bytecodeIr: List<Segment>,
val shopItems: UIntArray,

View File

@ -8,9 +8,9 @@ import kotlin.math.roundToInt
class QuestObject(override var areaId: Int, val data: Buffer) : QuestEntity<ObjectType> {
var typeId: Int
get() = data.getInt(0)
get() = data.getShort(0).toInt()
set(value) {
data.setInt(0, value)
data.setShort(0, value.toShort())
}
override var type: ObjectType

View File

@ -40,6 +40,7 @@ dependencies {
implementation("io.ktor:ktor-client-core-js:$ktorVersion")
implementation("io.ktor:ktor-client-serialization-js:$ktorVersion")
implementation("org.jetbrains.kotlin:kotlin-serialization:$serializationVersion")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.1.1")
implementation(npm("golden-layout", "^1.5.9"))
implementation(npm("monaco-editor", "^0.21.2"))
implementation(npm("three", "^0.122.0"))

View File

@ -8,6 +8,7 @@ import kotlinx.browser.window
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.datetime.Clock
import mu.KotlinLoggingConfiguration
import mu.KotlinLoggingLevel
import org.w3c.dom.HTMLCanvasElement
@ -67,6 +68,7 @@ private fun init(): Disposable {
AssetLoader(httpClient),
disposer.add(HistoryApplicationUrl()),
::createThreeRenderer,
Clock.System,
)
)

View File

@ -2,6 +2,7 @@ package world.phantasmal.web.application
import kotlinx.browser.document
import kotlinx.coroutines.CoroutineScope
import kotlinx.datetime.Clock
import org.w3c.dom.DragEvent
import org.w3c.dom.HTMLCanvasElement
import org.w3c.dom.HTMLElement
@ -29,6 +30,7 @@ class Application(
assetLoader: AssetLoader,
applicationUrl: ApplicationUrl,
createThreeRenderer: (HTMLCanvasElement) -> DisposableThreeRenderer,
clock: Clock,
) : DisposableContainer() {
init {
addDisposables(
@ -55,7 +57,7 @@ class Application(
)
// Controllers.
val navigationController = addDisposable(NavigationController(uiStore))
val navigationController = addDisposable(NavigationController(uiStore, clock))
val mainContentController = addDisposable(MainContentController(uiStore))
// Initialize application view.

View File

@ -1,14 +1,46 @@
package world.phantasmal.web.application.controllers
import kotlinx.browser.window
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.webui.controllers.Controller
import kotlin.math.floor
class NavigationController(private val uiStore: UiStore, private val clock: Clock) : Controller() {
private val _internetTime = mutableVal("@")
private var internetTimeInterval: Int
class NavigationController(private val uiStore: UiStore) : Controller() {
val tools: Map<PwToolType, Val<Boolean>> = uiStore.toolToActive
val internetTime: Val<String> = _internetTime
init {
internetTimeInterval = window.setInterval(::updateInternetTime, 1000)
updateInternetTime()
}
override fun internalDispose() {
window.clearInterval(internetTimeInterval)
super.internalDispose()
}
fun setCurrentTool(tool: PwToolType) {
uiStore.setCurrentTool(tool)
}
private fun updateInternetTime() {
val now = clock.now().toLocalDateTime(INTERNET_TIME_TZ)
_internetTime.value = "@" + floor((now.second + 60 * (now.minute + 60 * now.hour)) / 86.4)
}
companion object {
/**
* Internet time is calculated from UTC+01:00.
*/
private val INTERNET_TIME_TZ = TimeZone.of("UTC+01:00")
}
}

View File

@ -9,6 +9,7 @@ import world.phantasmal.web.core.dom.externalLink
import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.dom.icon
import world.phantasmal.webui.dom.span
import world.phantasmal.webui.widgets.Select
import world.phantasmal.webui.widgets.Widget
@ -41,6 +42,11 @@ class NavigationWidget(
addWidget(serverSelect.label!!)
addChild(serverSelect)
span {
title = "Internet time in beats"
text(ctrl.internetTime)
}
externalLink("https://github.com/DaanVandenBosch/phantasmal-world") {
className = "pw-application-navigation-github"
title = "Phantasmal World is open source, code available on GitHub"

View File

@ -56,7 +56,7 @@ class LogFormatter : Formatter {
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 "
return "$h:$m:$s.$ms"
}
companion object {

View File

@ -69,7 +69,7 @@ class OrbitalCameraInputManager(
override fun beforeRender() {
if (camera is PerspectiveCamera) {
val distance = camera.position.distanceTo(controls.target)
camera.near = distance / 100
camera.near = max(.01, distance / 100)
camera.far = max(2_000.0, 10 * distance)
camera.updateProjectionMatrix()
}

View File

@ -5,7 +5,6 @@ import org.khronos.webgl.Uint16Array
import org.khronos.webgl.set
import world.phantasmal.lib.fileFormats.ninja.XvrTexture
import world.phantasmal.web.externals.three.*
import world.phantasmal.web.viewer.rendering.xvrTextureToThree
import world.phantasmal.webui.obj
class MeshBuilder {
@ -17,6 +16,12 @@ class MeshBuilder {
* One group per material.
*/
private val groups = mutableListOf<Group>()
private var defaultMaterial: Material = MeshLambertMaterial(obj {
// TODO: skinning
side = DoubleSide
})
private val textures = mutableListOf<XvrTexture>()
fun getGroupIndex(
@ -47,23 +52,27 @@ class MeshBuilder {
fun getNormal(index: Int): Vector3 =
normals[index]
fun addVertex(position: Vector3, normal: Vector3, uv: Vector2? = null) {
fun vertex(position: Vector3, normal: Vector3, uv: Vector2? = null) {
positions.add(position)
normals.add(normal)
uv?.let { uvs.add(uv) }
}
fun addIndex(groupIdx: Int, index: Int) {
fun index(groupIdx: Int, index: Int) {
groups[groupIdx].indices.add(index.toShort())
}
fun addBoneWeight(groupIdx: Int, index: Int, weight: Float) {
fun boneWeight(groupIdx: Int, index: Int, weight: Float) {
val group = groups[groupIdx]
group.boneIndices.add(index.toShort())
group.boneWeights.add(weight)
}
fun addTextures(textures: List<XvrTexture>) {
fun defaultMaterial(material: Material) {
defaultMaterial = material
}
fun textures(textures: List<XvrTexture>) {
this.textures.addAll(textures)
}
@ -94,8 +103,8 @@ class MeshBuilder {
}
private fun build(): Pair<BufferGeometry, Array<Material>> {
check(this.positions.size == this.normals.size)
check(this.uvs.isEmpty() || this.positions.size == this.uvs.size)
check(positions.size == normals.size)
check(uvs.isEmpty() || positions.size == uvs.size)
val positions = Float32Array(3 * positions.size)
val normals = Float32Array(3 * normals.size)
@ -143,12 +152,10 @@ class MeshBuilder {
}
val mat = if (tex == null) {
MeshLambertMaterial(obj {
// TODO: skinning
side = DoubleSide
})
defaultMaterial
} else {
MeshBasicMaterial(obj {
// TODO: skinning
map = tex
side = DoubleSide

View File

@ -22,7 +22,7 @@ fun ninjaObjectToMesh(
boundingVolumes: Boolean = false
): Mesh {
val builder = MeshBuilder()
builder.addTextures(textures)
builder.textures(textures)
NinjaToMeshConverter(builder).convert(ninjaObject)
return builder.buildMesh(boundingVolumes)
}
@ -34,7 +34,7 @@ fun ninjaObjectToInstancedMesh(
boundingVolumes: Boolean = false,
): InstancedMesh {
val builder = MeshBuilder()
builder.addTextures(textures)
builder.textures(textures)
NinjaToMeshConverter(builder).convert(ninjaObject)
return builder.buildInstancedMesh(maxInstances, boundingVolumes)
}
@ -138,7 +138,7 @@ private class NinjaToMeshConverter(private val builder: MeshBuilder) {
vertex.normal ?: meshVertex.normal?.let(::vec3ToThree) ?: DEFAULT_NORMAL
val index = builder.vertexCount
builder.addVertex(
builder.vertex(
vertex.position,
normal,
meshVertex.texCoords?.let(::vec2ToThree) ?: DEFAULT_UV
@ -146,13 +146,13 @@ private class NinjaToMeshConverter(private val builder: MeshBuilder) {
if (i >= 2) {
if (i % 2 == if (mesh.clockwiseWinding) 1 else 0) {
builder.addIndex(group, index - 2)
builder.addIndex(group, index - 1)
builder.addIndex(group, index)
builder.index(group, index - 2)
builder.index(group, index - 1)
builder.index(group, index)
} else {
builder.addIndex(group, index - 2)
builder.addIndex(group, index)
builder.addIndex(group, index - 1)
builder.index(group, index - 2)
builder.index(group, index)
builder.index(group, index - 1)
}
}
@ -167,7 +167,7 @@ private class NinjaToMeshConverter(private val builder: MeshBuilder) {
val totalWeight = boneWeights.sum()
for (j in boneIndices.indices) {
builder.addBoneWeight(
builder.boneWeight(
group,
boneIndices[j],
if (totalWeight > 0f) boneWeights[j] / totalWeight else 0f
@ -193,7 +193,7 @@ private class NinjaToMeshConverter(private val builder: MeshBuilder) {
val uv = vertex.uv?.let(::vec2ToThree) ?: DEFAULT_UV
builder.addVertex(p, n, uv)
builder.vertex(p, n, uv)
}
var currentTextureIdx: Int? = null
@ -243,13 +243,13 @@ private class NinjaToMeshConverter(private val builder: MeshBuilder) {
}
if (clockwise) {
builder.addIndex(group, b)
builder.addIndex(group, a)
builder.addIndex(group, c)
builder.index(group, b)
builder.index(group, a)
builder.index(group, c)
} else {
builder.addIndex(group, a)
builder.addIndex(group, b)
builder.addIndex(group, c)
builder.index(group, a)
builder.index(group, b)
builder.index(group, c)
}
clockwise = !clockwise

View File

@ -1,4 +1,4 @@
package world.phantasmal.web.viewer.rendering
package world.phantasmal.web.core.rendering.conversion
import org.khronos.webgl.Uint8Array
import org.khronos.webgl.set

View File

@ -13,7 +13,6 @@ class RendererWidget(
override fun Node.createElement() =
div {
className = "pw-core-renderer"
tabIndex = -1
observe(selfOrAncestorVisible) { visible ->
if (visible) {

View File

@ -241,6 +241,8 @@ open external class Object3D {
var visible: Boolean
var renderOrder: Int
/**
* An object that can be used to store custom data about the Object3d. It should not hold references to functions as these will not be cloned.
*/
@ -422,10 +424,13 @@ external class HemisphereLight(
)
}
external class Color(r: Double, g: Double, b: Double) {
external class Color() {
constructor(r: Double, g: Double, b: Double)
constructor(color: Color)
constructor(color: String)
constructor(color: Int)
fun setHSL(h: Double, s: Double, l: Double): Color
}
open external class Geometry : EventDispatcher {
@ -553,6 +558,8 @@ external interface MeshBasicMaterialParameters : MaterialParameters {
var color: Color
var opacity: Double
var map: Texture?
var wireframe: Boolean
var wireframeLinewidth: Double
var skinning: Boolean
}

View File

@ -3,6 +3,7 @@ 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.emptyStringVal
import world.phantasmal.observable.value.value
import world.phantasmal.web.core.euler
import world.phantasmal.web.externals.three.Euler
@ -29,8 +30,15 @@ class EntityInfoController(private val store: QuestEditorStore) : Controller() {
.map { it?.toString() ?: "" }
val wave: Val<String> = store.selectedEntity
.flatMapNull { entity -> (entity as? QuestNpcModel)?.wave?.flatMapNull { it?.id } }
.map { it?.toString() ?: "" }
.flatMap { entity ->
if (entity is QuestNpcModel) {
entity.wave.flatMap { wave ->
wave?.id?.map(Any::toString) ?: value("None")
}
} else {
emptyStringVal()
}
}
val waveHidden: Val<Boolean> = store.selectedEntity.map { it !is QuestNpcModel }
@ -46,6 +54,10 @@ class EntityInfoController(private val store: QuestEditorStore) : Controller() {
val rotY: Val<Double> = rot.map { radToDeg(it.y) }
val rotZ: Val<Double> = rot.map { radToDeg(it.z) }
fun focused() {
store.makeMainUndoCurrent()
}
fun setPosX(x: Double) {
store.selectedEntity.value?.let { entity ->
val pos = entity.position.value

View File

@ -76,12 +76,14 @@ class QuestEditorToolbarController(
val areaSelectEnabled: Val<Boolean> = questEditorStore.currentQuest.isNotNull()
suspend fun createNewQuest(episode: Episode) {
// TODO: Set filename and version.
questEditorStore.setCurrentQuest(
convertQuestToModel(questLoader.loadDefaultQuest(episode), areaStore::getVariant)
)
}
suspend fun openFiles(files: List<File>) {
// TODO: Set filename and version.
try {
if (files.isEmpty()) return

View File

@ -22,6 +22,10 @@ class QuestInfoController(private val store: QuestEditorStore) : Controller() {
val longDescription: Val<String> =
store.currentQuest.flatMap { it?.longDescription ?: emptyStringVal() }
fun focused() {
store.makeMainUndoCurrent()
}
fun setId(id: Int) {
if (!enabled.value) return

View File

@ -38,7 +38,7 @@ class AreaAssetLoader(
{ (episode, areaVariant) ->
val buffer = getAreaAsset(episode, areaVariant, AssetType.Render)
val obj = parseAreaGeometry(buffer.cursor(Endianness.Little))
areaGeometryToTransformNodeAndSections(obj, areaVariant)
areaGeometryToObject3DAndSections(obj, areaVariant)
},
{ (obj3d) -> disposeObject3DResources(obj3d) },
)
@ -50,23 +50,17 @@ class AreaAssetLoader(
{ (episode, areaVariant) ->
val buffer = getAreaAsset(episode, areaVariant, AssetType.Collision)
val obj = parseAreaCollisionGeometry(buffer.cursor(Endianness.Little))
areaCollisionGeometryToTransformNode(obj, episode, areaVariant)
areaCollisionGeometryToObject3D(obj, episode, areaVariant)
},
::disposeObject3DResources,
)
)
suspend fun loadSections(episode: Episode, areaVariant: AreaVariantModel): List<SectionModel> =
loadRenderGeometryAndSections(episode, areaVariant).second
renderObjectCache.get(EpisodeAndAreaVariant(episode, areaVariant)).second
suspend fun loadRenderGeometry(episode: Episode, areaVariant: AreaVariantModel): Object3D =
loadRenderGeometryAndSections(episode, areaVariant).first
private suspend fun loadRenderGeometryAndSections(
episode: Episode,
areaVariant: AreaVariantModel,
): Pair<Object3D, List<SectionModel>> =
renderObjectCache.get(EpisodeAndAreaVariant(episode, areaVariant))
renderObjectCache.get(EpisodeAndAreaVariant(episode, areaVariant)).first
suspend fun loadCollisionGeometry(
episode: Episode,
@ -106,7 +100,7 @@ private val COLLISION_MATERIALS: Array<Material> = arrayOf(
MeshBasicMaterial(obj {
color = Color(0x80c0d0)
transparent = true
opacity = 0.25
opacity = .25
}),
// Ground
MeshLambertMaterial(obj {
@ -125,6 +119,31 @@ private val COLLISION_MATERIALS: Array<Material> = arrayOf(
}),
)
private val COLLISION_WIREFRAME_MATERIALS: Array<Material> = arrayOf(
// Wall
MeshBasicMaterial(obj {
color = Color(0x90d0e0)
wireframe = true
transparent = true
opacity = .3
}),
// Ground
MeshBasicMaterial(obj {
color = Color(0x506060)
wireframe = true
}),
// Vegetation
MeshBasicMaterial(obj {
color = Color(0x405050)
wireframe = true
}),
// Section transition zone
MeshBasicMaterial(obj {
color = Color(0x503060)
wireframe = true
}),
)
private val AREA_BASE_NAMES: Map<Episode, List<Pair<String, Int>>> = mapOf(
Episode.I to listOf(
Pair("city00_00", 1),
@ -209,22 +228,28 @@ private fun areaVersionToBaseUrl(episode: Episode, areaVariant: AreaVariantModel
return "/maps/map_${base_name}${variant}"
}
private fun areaGeometryToTransformNodeAndSections(
private fun areaGeometryToObject3DAndSections(
renderObject: RenderObject,
areaVariant: AreaVariantModel,
): Pair<Object3D, List<SectionModel>> {
val sections = mutableListOf<SectionModel>()
val obj3d = Group()
for (section in renderObject.sections) {
for ((i, section) in renderObject.sections.withIndex()) {
val builder = MeshBuilder()
for (obj in section.objects) {
ninjaObjectToMeshBuilder(obj, builder)
}
builder.defaultMaterial(MeshBasicMaterial(obj {
color = Color().setHSL((i % 7) / 7.0, 1.0, .5)
transparent = true
opacity = .25
side = DoubleSide
}))
val mesh = builder.buildMesh()
// TODO: Material.
mesh.position.set(
section.position.x.toDouble(),
@ -239,13 +264,12 @@ private fun areaGeometryToTransformNodeAndSections(
mesh.updateMatrixWorld()
if (section.id >= 0) {
val sec = SectionModel(
sections.add(SectionModel(
section.id,
vec3ToThree(section.position),
euler(section.rotation.x, section.rotation.y, section.rotation.z),
areaVariant,
)
sections.add(sec)
))
}
(mesh.userData.unsafeCast<AreaUserData>()).sectionId = section.id.takeIf { it >= 0 }
@ -255,7 +279,7 @@ private fun areaGeometryToTransformNodeAndSections(
return Pair(obj3d, sections)
}
private fun areaCollisionGeometryToTransformNode(
private fun areaCollisionGeometryToObject3D(
obj: CollisionObject,
episode: Episode,
areaVariant: AreaVariantModel,
@ -299,7 +323,18 @@ private fun areaCollisionGeometryToTransformNode(
if (geom.faces.isNotEmpty()) {
geom.computeBoundingBox()
geom.computeBoundingSphere()
obj3d.add(Mesh(geom, COLLISION_MATERIALS))
obj3d.add(
Mesh(geom, COLLISION_MATERIALS).apply {
renderOrder = 1
}
)
obj3d.add(
Mesh(geom, COLLISION_WIREFRAME_MATERIALS).apply {
renderOrder = 2
}
)
}
}

View File

@ -73,7 +73,7 @@ class EntityAssetLoader(
val suffix =
if (
type === ObjectType.FloatingRocks ||
(type === ObjectType.BigBrownRock && model == undefined)
(type === ObjectType.BigBrownRock && model == null)
) {
"-0"
} else {
@ -127,8 +127,13 @@ class EntityAssetLoader(
companion object {
private val DEFAULT_MESH = InstancedMesh(
CylinderBufferGeometry(radiusTop = 2.5, radiusBottom = 2.5, height = 18.0).apply {
translate(0.0, 10.0, 0.0)
CylinderBufferGeometry(
radiusTop = 2.5,
radiusBottom = 2.5,
height = 18.0,
radialSegments = 16,
).apply {
translate(0.0, 9.0, 0.0)
computeBoundingBox()
computeBoundingSphere()
},

View File

@ -16,6 +16,10 @@ import world.phantasmal.web.externals.three.Vector3
import kotlin.math.PI
abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
/**
* Don't modify the underlying entity directly because most of those modifications will not be
* reflected in this model's properties.
*/
private val entity: Entity,
) {
private val _sectionId = mutableVal(entity.sectionId)
@ -132,16 +136,16 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
_rotation.value = relRot
}
companion object {
// These quaternions are used as temporary variables to avoid memory allocation.
private val q1 = Quaternion()
private val q2 = Quaternion()
private fun floorModEuler(euler: Euler): Euler =
euler.set(
floorMod(euler.x, 2 * PI),
floorMod(euler.y, 2 * PI),
floorMod(euler.z, 2 * PI),
)
companion object {
// These quaternions are used as temporary variables to avoid memory allocation.
private val q1 = Quaternion()
private val q2 = Quaternion()
}
}

View File

@ -35,6 +35,10 @@ class QuestModel(
val name: Val<String> = _name
val shortDescription: Val<String> = _shortDescription
val longDescription: Val<String> = _longDescription
/**
* Map of area IDs to area variant IDs. One designation per area.
*/
val mapDesignations: Val<Map<Int, Int>> = _mapDesignations
/**

View File

@ -1,6 +1,6 @@
package world.phantasmal.web.questEditor.rendering.input
import kotlinx.browser.document
import kotlinx.browser.window
import org.w3c.dom.pointerevents.PointerEvent
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.web.core.rendering.InputManager
@ -34,7 +34,7 @@ class QuestInputManager(
* Whether entity transformations, deletions, etc. are enabled or not.
* Hover over and selection still work when this is set to false.
*/
var entityManipulationEnabled: Boolean = true
private var entityManipulationEnabled: Boolean = true
set(enabled) {
field = enabled
returnToIdleState()
@ -45,7 +45,8 @@ class QuestInputManager(
disposableListener(renderContext.canvas, "pointerdown", ::onPointerDown)
)
onPointerMoveListener = disposableListener(document, "pointermove", ::onPointerMove)
onPointerMoveListener =
disposableListener(renderContext.canvas, "pointermove", ::onPointerMove)
// Ensure OrbitalCameraControls attaches its listeners after ours.
cameraInputManager = OrbitalCameraInputManager(
@ -57,7 +58,9 @@ class QuestInputManager(
stateContext = StateContext(questEditorStore, renderContext, cameraInputManager)
state = IdleState(stateContext, entityManipulationEnabled)
observe(questEditorStore.selectedEntity) { returnToIdleState() }
observe(questEditorStore.questEditingEnabled) { entityManipulationEnabled = it }
}
override fun internalDispose() {
@ -92,11 +95,11 @@ class QuestInputManager(
)
)
onPointerUpListener = disposableListener(document, "pointerup", ::onPointerUp)
onPointerUpListener = disposableListener(window, "pointerup", ::onPointerUp)
// Stop listening to canvas move events and start listening to document move events.
// Stop listening to canvas move events and start listening to window move events.
onPointerMoveListener?.dispose()
onPointerMoveListener = disposableListener(document, "pointermove", ::onPointerMove)
onPointerMoveListener = disposableListener(window, "pointermove", ::onPointerMove)
}
private fun onPointerUp(e: PointerEvent) {
@ -115,7 +118,7 @@ class QuestInputManager(
onPointerUpListener?.dispose()
onPointerUpListener = null
// Stop listening to document move events and start listening to canvas move events.
// Stop listening to window move events and start listening to canvas move events again.
onPointerMoveListener?.dispose()
onPointerMoveListener =
disposableListener(renderContext.canvas, "pointermove", ::onPointerMove)

View File

@ -15,7 +15,7 @@ abstract class State {
abstract fun beforeRender()
/**
* The state object should stop doing what it's doing and revert to the idle state as soon as
* When this method is called, the state object should stop doing what it's doing as soon as
* possible.
*/
abstract fun cancel()

View File

@ -46,6 +46,7 @@ class EntityInfoWidget(
td {
addChild(DoubleInput(
this@EntityInfoWidget.scope,
enabled = ctrl.enabled,
value = ctrl.posX,
onChange = ctrl::setPosX,
roundTo = 3,
@ -57,6 +58,7 @@ class EntityInfoWidget(
td {
addChild(DoubleInput(
this@EntityInfoWidget.scope,
enabled = ctrl.enabled,
value = ctrl.posY,
onChange = ctrl::setPosY,
roundTo = 3,
@ -68,6 +70,7 @@ class EntityInfoWidget(
td {
addChild(DoubleInput(
this@EntityInfoWidget.scope,
enabled = ctrl.enabled,
value = ctrl.posZ,
onChange = ctrl::setPosZ,
roundTo = 3,
@ -82,6 +85,7 @@ class EntityInfoWidget(
td {
addChild(DoubleInput(
this@EntityInfoWidget.scope,
enabled = ctrl.enabled,
value = ctrl.rotX,
onChange = ctrl::setRotX,
roundTo = 3,
@ -93,6 +97,7 @@ class EntityInfoWidget(
td {
addChild(DoubleInput(
this@EntityInfoWidget.scope,
enabled = ctrl.enabled,
value = ctrl.rotY,
onChange = ctrl::setRotY,
roundTo = 3,
@ -104,6 +109,7 @@ class EntityInfoWidget(
td {
addChild(DoubleInput(
this@EntityInfoWidget.scope,
enabled = ctrl.enabled,
value = ctrl.rotZ,
onChange = ctrl::setRotZ,
roundTo = 3,
@ -118,6 +124,11 @@ class EntityInfoWidget(
))
}
override fun focus() {
super.focus()
ctrl.focused()
}
companion object {
private const val COORD_CLASS = "pw-quest-editor-entity-info-coord"

View File

@ -1,7 +1,6 @@
package world.phantasmal.web.questEditor.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.web.questEditor.rendering.QuestRenderer
class QuestEditorRendererWidget(

View File

@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.w3c.dom.Node
import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.observable.value.value
import world.phantasmal.web.questEditor.controllers.QuestEditorToolbarController
import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.dom.div
@ -29,6 +30,7 @@ class QuestEditorToolbarWidget(
FileButton(
scope,
text = "Open file...",
tooltip = value("Open a quest file (Ctrl-O)"),
iconLeft = Icon.File,
accept = ".bin, .dat, .qst",
multiple = true,

View File

@ -101,6 +101,11 @@ class QuestInfoWidget(
))
}
override fun focus() {
super.focus()
ctrl.focused()
}
companion object {
init {
@Suppress("CssUnusedSymbol")

View File

@ -14,7 +14,6 @@ abstract class QuestRendererWidget(
override fun Node.createElement() =
div {
className = "pw-quest-editor-quest-renderer"
tabIndex = -1
addChild(RendererWidget(scope, renderer))
}

View File

@ -4,6 +4,7 @@ import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.lib.fileFormats.ninja.XvrTexture
import world.phantasmal.web.core.rendering.*
import world.phantasmal.web.core.rendering.Renderer
import world.phantasmal.web.core.rendering.conversion.xvrTextureToThree
import world.phantasmal.web.externals.three.*
import world.phantasmal.web.viewer.store.ViewerStore
import world.phantasmal.webui.obj

View File

@ -22,6 +22,7 @@ class ApplicationTests : WebTestSuite() {
assetLoader = components.assetLoader,
applicationUrl = appUrl,
createThreeRenderer = components.createThreeRenderer,
clock = components.clock,
)
)
}

View File

@ -0,0 +1,28 @@
package world.phantasmal.web.application.controllers
import kotlinx.datetime.Instant
import world.phantasmal.web.test.StubClock
import world.phantasmal.web.test.WebTestSuite
import kotlin.test.Test
import kotlin.test.assertEquals
class NavigationControllerTests : WebTestSuite() {
@Test
fun internet_time_is_calculated_correctly() = test {
val clock = StubClock()
components.clock = clock
listOf(
Pair("00:00:00", 41),
Pair("13:10:12", 590),
Pair("22:59:59", 999),
Pair("23:00:00", 0),
Pair("23:59:59", 41),
).forEach { (time, beats) ->
clock.currentTime = Instant.parse("2020-01-01T${time}Z")
val ctrl = NavigationController(components.uiStore, components.clock)
assertEquals("@$beats", ctrl.internetTime.value)
}
}
}

View File

@ -1,8 +1,6 @@
package world.phantasmal.web.questEditor.undo
package world.phantasmal.web.core.undo
import world.phantasmal.web.core.actions.Action
import world.phantasmal.web.core.undo.UndoManager
import world.phantasmal.web.core.undo.UndoStack
import world.phantasmal.web.test.WebTestSuite
import kotlin.test.Test
import kotlin.test.assertEquals

View File

@ -0,0 +1,8 @@
package world.phantasmal.web.test
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
class StubClock(var currentTime: Instant = Instant.DISTANT_PAST) : Clock {
override fun now(): Instant = currentTime
}

View File

@ -4,6 +4,7 @@ import io.ktor.client.*
import io.ktor.client.features.json.*
import io.ktor.client.features.json.serializer.*
import kotlinx.coroutines.cancel
import kotlinx.datetime.Clock
import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.disposable
@ -35,6 +36,8 @@ class TestComponents(private val ctx: TestContext) {
}
}
var clock: Clock by default { StubClock() }
var applicationUrl: ApplicationUrl by default { TestApplicationUrl("") }
// Asset Loaders
@ -97,7 +100,7 @@ class TestComponents(private val ctx: TestContext) {
}
operator fun setValue(thisRef: Any?, prop: KProperty<*>, value: T) {
require(initialized) {
require(!initialized) {
"Property ${prop.name} is already initialized."
}

View File

@ -75,7 +75,7 @@ open class Button(
companion object {
init {
@Suppress("CssUnusedSymbol", "CssUnresolvedCustomProperty")
// language=css
// language=css
style("""
.pw-button {
display: inline-flex;

View File

@ -111,11 +111,11 @@ abstract class Input<T>(
border: var(--pw-input-border-focus);
}
.pw-input.disabled {
.pw-input.pw-disabled {
border: var(--pw-input-border-disabled);
}
.pw-input.disabled .pw-input-inner {
.pw-input.pw-disabled .pw-input-inner {
color: var(--pw-input-text-color-disabled);
background-color: var(--pw-input-bg-color-disabled);
}