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 { repositories {
jcenter() jcenter()
maven(url = "https://kotlin.bintray.com/kotlinx/")
} }
tasks.withType<Kotlin2JsCompile> { tasks.withType<Kotlin2JsCompile> {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,46 @@
package world.phantasmal.web.application.controllers 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.Val
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.core.PwToolType import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.stores.UiStore import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.webui.controllers.Controller 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 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) { fun setCurrentTool(tool: PwToolType) {
uiStore.setCurrentTool(tool) 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.Icon
import world.phantasmal.webui.dom.div import world.phantasmal.webui.dom.div
import world.phantasmal.webui.dom.icon import world.phantasmal.webui.dom.icon
import world.phantasmal.webui.dom.span
import world.phantasmal.webui.widgets.Select import world.phantasmal.webui.widgets.Select
import world.phantasmal.webui.widgets.Widget import world.phantasmal.webui.widgets.Widget
@ -41,6 +42,11 @@ class NavigationWidget(
addWidget(serverSelect.label!!) addWidget(serverSelect.label!!)
addChild(serverSelect) addChild(serverSelect)
span {
title = "Internet time in beats"
text(ctrl.internetTime)
}
externalLink("https://github.com/DaanVandenBosch/phantasmal-world") { externalLink("https://github.com/DaanVandenBosch/phantasmal-world") {
className = "pw-application-navigation-github" className = "pw-application-navigation-github"
title = "Phantasmal World is open source, code available on 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 m = date.getMinutes().toString().padStart(2, '0')
val s = date.getSeconds().toString().padStart(2, '0') val s = date.getSeconds().toString().padStart(2, '0')
val ms = date.getMilliseconds().toString().padStart(3, '0') val ms = date.getMilliseconds().toString().padStart(3, '0')
return "$h:$m:$s.$ms " return "$h:$m:$s.$ms"
} }
companion object { companion object {

View File

@ -69,7 +69,7 @@ class OrbitalCameraInputManager(
override fun beforeRender() { override fun beforeRender() {
if (camera is PerspectiveCamera) { if (camera is PerspectiveCamera) {
val distance = camera.position.distanceTo(controls.target) 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.far = max(2_000.0, 10 * distance)
camera.updateProjectionMatrix() camera.updateProjectionMatrix()
} }

View File

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

View File

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

View File

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

View File

@ -241,6 +241,8 @@ open external class Object3D {
var visible: Boolean 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. * 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: Color)
constructor(color: String) constructor(color: String)
constructor(color: Int) constructor(color: Int)
fun setHSL(h: Double, s: Double, l: Double): Color
} }
open external class Geometry : EventDispatcher { open external class Geometry : EventDispatcher {
@ -553,6 +558,8 @@ external interface MeshBasicMaterialParameters : MaterialParameters {
var color: Color var color: Color
var opacity: Double var opacity: Double
var map: Texture? var map: Texture?
var wireframe: Boolean
var wireframeLinewidth: Double
var skinning: Boolean 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.degToRad
import world.phantasmal.core.math.radToDeg import world.phantasmal.core.math.radToDeg
import world.phantasmal.observable.value.Val import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.emptyStringVal
import world.phantasmal.observable.value.value import world.phantasmal.observable.value.value
import world.phantasmal.web.core.euler import world.phantasmal.web.core.euler
import world.phantasmal.web.externals.three.Euler import world.phantasmal.web.externals.three.Euler
@ -29,8 +30,15 @@ class EntityInfoController(private val store: QuestEditorStore) : Controller() {
.map { it?.toString() ?: "" } .map { it?.toString() ?: "" }
val wave: Val<String> = store.selectedEntity val wave: Val<String> = store.selectedEntity
.flatMapNull { entity -> (entity as? QuestNpcModel)?.wave?.flatMapNull { it?.id } } .flatMap { entity ->
.map { it?.toString() ?: "" } 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 } 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 rotY: Val<Double> = rot.map { radToDeg(it.y) }
val rotZ: Val<Double> = rot.map { radToDeg(it.z) } val rotZ: Val<Double> = rot.map { radToDeg(it.z) }
fun focused() {
store.makeMainUndoCurrent()
}
fun setPosX(x: Double) { fun setPosX(x: Double) {
store.selectedEntity.value?.let { entity -> store.selectedEntity.value?.let { entity ->
val pos = entity.position.value val pos = entity.position.value

View File

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

View File

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

View File

@ -38,7 +38,7 @@ class AreaAssetLoader(
{ (episode, areaVariant) -> { (episode, areaVariant) ->
val buffer = getAreaAsset(episode, areaVariant, AssetType.Render) val buffer = getAreaAsset(episode, areaVariant, AssetType.Render)
val obj = parseAreaGeometry(buffer.cursor(Endianness.Little)) val obj = parseAreaGeometry(buffer.cursor(Endianness.Little))
areaGeometryToTransformNodeAndSections(obj, areaVariant) areaGeometryToObject3DAndSections(obj, areaVariant)
}, },
{ (obj3d) -> disposeObject3DResources(obj3d) }, { (obj3d) -> disposeObject3DResources(obj3d) },
) )
@ -50,23 +50,17 @@ class AreaAssetLoader(
{ (episode, areaVariant) -> { (episode, areaVariant) ->
val buffer = getAreaAsset(episode, areaVariant, AssetType.Collision) val buffer = getAreaAsset(episode, areaVariant, AssetType.Collision)
val obj = parseAreaCollisionGeometry(buffer.cursor(Endianness.Little)) val obj = parseAreaCollisionGeometry(buffer.cursor(Endianness.Little))
areaCollisionGeometryToTransformNode(obj, episode, areaVariant) areaCollisionGeometryToObject3D(obj, episode, areaVariant)
}, },
::disposeObject3DResources, ::disposeObject3DResources,
) )
) )
suspend fun loadSections(episode: Episode, areaVariant: AreaVariantModel): List<SectionModel> = 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 = suspend fun loadRenderGeometry(episode: Episode, areaVariant: AreaVariantModel): Object3D =
loadRenderGeometryAndSections(episode, areaVariant).first renderObjectCache.get(EpisodeAndAreaVariant(episode, areaVariant)).first
private suspend fun loadRenderGeometryAndSections(
episode: Episode,
areaVariant: AreaVariantModel,
): Pair<Object3D, List<SectionModel>> =
renderObjectCache.get(EpisodeAndAreaVariant(episode, areaVariant))
suspend fun loadCollisionGeometry( suspend fun loadCollisionGeometry(
episode: Episode, episode: Episode,
@ -106,7 +100,7 @@ private val COLLISION_MATERIALS: Array<Material> = arrayOf(
MeshBasicMaterial(obj { MeshBasicMaterial(obj {
color = Color(0x80c0d0) color = Color(0x80c0d0)
transparent = true transparent = true
opacity = 0.25 opacity = .25
}), }),
// Ground // Ground
MeshLambertMaterial(obj { 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( private val AREA_BASE_NAMES: Map<Episode, List<Pair<String, Int>>> = mapOf(
Episode.I to listOf( Episode.I to listOf(
Pair("city00_00", 1), Pair("city00_00", 1),
@ -209,22 +228,28 @@ private fun areaVersionToBaseUrl(episode: Episode, areaVariant: AreaVariantModel
return "/maps/map_${base_name}${variant}" return "/maps/map_${base_name}${variant}"
} }
private fun areaGeometryToTransformNodeAndSections( private fun areaGeometryToObject3DAndSections(
renderObject: RenderObject, renderObject: RenderObject,
areaVariant: AreaVariantModel, areaVariant: AreaVariantModel,
): Pair<Object3D, List<SectionModel>> { ): Pair<Object3D, List<SectionModel>> {
val sections = mutableListOf<SectionModel>() val sections = mutableListOf<SectionModel>()
val obj3d = Group() val obj3d = Group()
for (section in renderObject.sections) { for ((i, section) in renderObject.sections.withIndex()) {
val builder = MeshBuilder() val builder = MeshBuilder()
for (obj in section.objects) { for (obj in section.objects) {
ninjaObjectToMeshBuilder(obj, builder) 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() val mesh = builder.buildMesh()
// TODO: Material.
mesh.position.set( mesh.position.set(
section.position.x.toDouble(), section.position.x.toDouble(),
@ -239,13 +264,12 @@ private fun areaGeometryToTransformNodeAndSections(
mesh.updateMatrixWorld() mesh.updateMatrixWorld()
if (section.id >= 0) { if (section.id >= 0) {
val sec = SectionModel( sections.add(SectionModel(
section.id, section.id,
vec3ToThree(section.position), vec3ToThree(section.position),
euler(section.rotation.x, section.rotation.y, section.rotation.z), euler(section.rotation.x, section.rotation.y, section.rotation.z),
areaVariant, areaVariant,
) ))
sections.add(sec)
} }
(mesh.userData.unsafeCast<AreaUserData>()).sectionId = section.id.takeIf { it >= 0 } (mesh.userData.unsafeCast<AreaUserData>()).sectionId = section.id.takeIf { it >= 0 }
@ -255,7 +279,7 @@ private fun areaGeometryToTransformNodeAndSections(
return Pair(obj3d, sections) return Pair(obj3d, sections)
} }
private fun areaCollisionGeometryToTransformNode( private fun areaCollisionGeometryToObject3D(
obj: CollisionObject, obj: CollisionObject,
episode: Episode, episode: Episode,
areaVariant: AreaVariantModel, areaVariant: AreaVariantModel,
@ -299,7 +323,18 @@ private fun areaCollisionGeometryToTransformNode(
if (geom.faces.isNotEmpty()) { if (geom.faces.isNotEmpty()) {
geom.computeBoundingBox() geom.computeBoundingBox()
geom.computeBoundingSphere() 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 = val suffix =
if ( if (
type === ObjectType.FloatingRocks || type === ObjectType.FloatingRocks ||
(type === ObjectType.BigBrownRock && model == undefined) (type === ObjectType.BigBrownRock && model == null)
) { ) {
"-0" "-0"
} else { } else {
@ -127,8 +127,13 @@ class EntityAssetLoader(
companion object { companion object {
private val DEFAULT_MESH = InstancedMesh( private val DEFAULT_MESH = InstancedMesh(
CylinderBufferGeometry(radiusTop = 2.5, radiusBottom = 2.5, height = 18.0).apply { CylinderBufferGeometry(
translate(0.0, 10.0, 0.0) radiusTop = 2.5,
radiusBottom = 2.5,
height = 18.0,
radialSegments = 16,
).apply {
translate(0.0, 9.0, 0.0)
computeBoundingBox() computeBoundingBox()
computeBoundingSphere() computeBoundingSphere()
}, },

View File

@ -16,6 +16,10 @@ import world.phantasmal.web.externals.three.Vector3
import kotlin.math.PI import kotlin.math.PI
abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>( 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 entity: Entity,
) { ) {
private val _sectionId = mutableVal(entity.sectionId) private val _sectionId = mutableVal(entity.sectionId)
@ -132,16 +136,16 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
_rotation.value = relRot _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 = private fun floorModEuler(euler: Euler): Euler =
euler.set( euler.set(
floorMod(euler.x, 2 * PI), floorMod(euler.x, 2 * PI),
floorMod(euler.y, 2 * PI), floorMod(euler.y, 2 * PI),
floorMod(euler.z, 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 name: Val<String> = _name
val shortDescription: Val<String> = _shortDescription val shortDescription: Val<String> = _shortDescription
val longDescription: Val<String> = _longDescription val longDescription: Val<String> = _longDescription
/**
* Map of area IDs to area variant IDs. One designation per area.
*/
val mapDesignations: Val<Map<Int, Int>> = _mapDesignations val mapDesignations: Val<Map<Int, Int>> = _mapDesignations
/** /**

View File

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

View File

@ -15,7 +15,7 @@ abstract class State {
abstract fun beforeRender() 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. * possible.
*/ */
abstract fun cancel() abstract fun cancel()

View File

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

View File

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

View File

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

View File

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

View File

@ -14,7 +14,6 @@ abstract class QuestRendererWidget(
override fun Node.createElement() = override fun Node.createElement() =
div { div {
className = "pw-quest-editor-quest-renderer" className = "pw-quest-editor-quest-renderer"
tabIndex = -1
addChild(RendererWidget(scope, renderer)) 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.lib.fileFormats.ninja.XvrTexture
import world.phantasmal.web.core.rendering.* import world.phantasmal.web.core.rendering.*
import world.phantasmal.web.core.rendering.Renderer 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.externals.three.*
import world.phantasmal.web.viewer.store.ViewerStore import world.phantasmal.web.viewer.store.ViewerStore
import world.phantasmal.webui.obj import world.phantasmal.webui.obj

View File

@ -22,6 +22,7 @@ class ApplicationTests : WebTestSuite() {
assetLoader = components.assetLoader, assetLoader = components.assetLoader,
applicationUrl = appUrl, applicationUrl = appUrl,
createThreeRenderer = components.createThreeRenderer, 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.actions.Action
import world.phantasmal.web.core.undo.UndoManager
import world.phantasmal.web.core.undo.UndoStack
import world.phantasmal.web.test.WebTestSuite import world.phantasmal.web.test.WebTestSuite
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals 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.*
import io.ktor.client.features.json.serializer.* import io.ktor.client.features.json.serializer.*
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.datetime.Clock
import org.w3c.dom.HTMLCanvasElement import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.core.disposable.Disposable import world.phantasmal.core.disposable.Disposable
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("") } var applicationUrl: ApplicationUrl by default { TestApplicationUrl("") }
// Asset Loaders // Asset Loaders
@ -97,7 +100,7 @@ class TestComponents(private val ctx: TestContext) {
} }
operator fun setValue(thisRef: Any?, prop: KProperty<*>, value: T) { operator fun setValue(thisRef: Any?, prop: KProperty<*>, value: T) {
require(initialized) { require(!initialized) {
"Property ${prop.name} is already initialized." "Property ${prop.name} is already initialized."
} }

View File

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

View File

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