Added area mesh loading.

This commit is contained in:
Daan Vanden Bosch 2020-11-07 19:15:37 +01:00
parent 8ec75f8b4a
commit 8de81c9cb4
53 changed files with 820 additions and 233 deletions

View File

@ -0,0 +1,7 @@
package world.phantasmal.core
fun requireNonNegative(value: Int, name: String) {
require(value >= 0) {
"$name should be non-negative but was $value."
}
}

View File

@ -0,0 +1,103 @@
package world.phantasmal.lib.fileFormats.quest
class Area(
val id: Int,
val name: String,
val order: Int,
val areaVariants: List<AreaVariant>,
)
class AreaVariant(
val id: Int,
val area: Area,
)
fun getAreasForEpisode(episode: Episode): List<Area> =
AREAS.getValue(episode)
fun getAreaVariant(episode: Episode, areaId: Int, variantId: Int): AreaVariant? =
AREAS.getValue(episode).find { it.id == areaId }?.areaVariants?.getOrNull(variantId)
private val AREAS by lazy {
var order = 0
@Suppress("UNUSED_CHANGED_VALUE")
val ep1 = listOf(
createArea(0, "Pioneer II", order++, 1),
createArea(1, "Forest 1", order++, 1),
createArea(2, "Forest 2", order++, 1),
createArea(11, "Under the Dome", order++, 1),
createArea(3, "Cave 1", order++, 6),
createArea(4, "Cave 2", order++, 5),
createArea(5, "Cave 3", order++, 6),
createArea(12, "Underground Channel", order++, 1),
createArea(6, "Mine 1", order++, 6),
createArea(7, "Mine 2", order++, 6),
createArea(13, "Monitor Room", order++, 1),
createArea(8, "Ruins 1", order++, 5),
createArea(9, "Ruins 2", order++, 5),
createArea(10, "Ruins 3", order++, 5),
createArea(14, "Dark Falz", order++, 1),
// TODO:
// createArea(15, "BA Ruins", order++, 3),
// createArea(16, "BA Spaceship", order++, 3),
// createArea(17, "Lobby", order++, 15),
)
order = 0
@Suppress("UNUSED_CHANGED_VALUE")
val ep2 = listOf(
createArea(0, "Lab", order++, 1),
createArea(1, "VR Temple Alpha", order++, 3),
createArea(2, "VR Temple Beta", order++, 3),
createArea(14, "VR Temple Final", order++, 1),
createArea(3, "VR Spaceship Alpha", order++, 3),
createArea(4, "VR Spaceship Beta", order++, 3),
createArea(15, "VR Spaceship Final", order++, 1),
createArea(5, "Central Control Area", order++, 1),
createArea(6, "Jungle Area East", order++, 1),
createArea(7, "Jungle Area North", order++, 1),
createArea(8, "Mountain Area", order++, 3),
createArea(9, "Seaside Area", order++, 1),
createArea(12, "Cliffs of Gal Da Val", order++, 1),
createArea(10, "Seabed Upper Levels", order++, 3),
createArea(11, "Seabed Lower Levels", order++, 3),
createArea(13, "Test Subject Disposal Area", order++, 1),
createArea(16, "Seaside Area at Night", order++, 2),
createArea(17, "Control Tower", order++, 5),
)
order = 0
@Suppress("UNUSED_CHANGED_VALUE")
val ep4 = listOf(
createArea(0, "Pioneer II (Ep. IV)", order++, 1),
createArea(1, "Crater Route 1", order++, 1),
createArea(2, "Crater Route 2", order++, 1),
createArea(3, "Crater Route 3", order++, 1),
createArea(4, "Crater Route 4", order++, 1),
createArea(5, "Crater Interior", order++, 1),
createArea(6, "Subterranean Desert 1", order++, 3),
createArea(7, "Subterranean Desert 2", order++, 3),
createArea(8, "Subterranean Desert 3", order++, 3),
createArea(9, "Meteor Impact Site", order++, 1),
)
mapOf(
Episode.I to ep1,
Episode.II to ep2,
Episode.IV to ep4,
)
}
private fun createArea(id: Int, name: String, order: Int, variants: Int): Area {
val avs = mutableListOf<AreaVariant>()
val area = Area(id, name, order, avs)
for (avId in 0 until variants) {
avs.add(AreaVariant(avId, area))
}
return area
}

View File

@ -5,6 +5,8 @@ import world.phantasmal.lib.fileFormats.Vec3
interface QuestEntity<Type : EntityType> {
val type: Type
var areaId: Int
/**
* Section-relative position.
*/

View File

@ -6,7 +6,11 @@ import world.phantasmal.lib.fileFormats.ninja.angleToRad
import world.phantasmal.lib.fileFormats.ninja.radToAngle
import kotlin.math.roundToInt
class QuestNpc(var episode: Episode, var areaId: Int, val data: Buffer) : QuestEntity<NpcType> {
class QuestNpc(
var episode: Episode,
override var areaId: Int,
val data: Buffer,
) : QuestEntity<NpcType> {
var typeId: Short
get() = data.getShort(0)
set(value) {

View File

@ -6,7 +6,7 @@ import world.phantasmal.lib.fileFormats.ninja.angleToRad
import world.phantasmal.lib.fileFormats.ninja.radToAngle
import kotlin.math.roundToInt
class QuestObject(var areaId: Int, val data: Buffer) : QuestEntity<ObjectType> {
class QuestObject(override var areaId: Int, val data: Buffer) : QuestEntity<ObjectType> {
var typeId: Int
get() = data.getInt(0)
set(value) {

View File

@ -11,6 +11,8 @@ interface MutableListVal<E> : ListVal<E>, MutableVal<List<E>> {
fun removeAt(index: Int): E
fun replaceAll(elements: Iterable<E>)
fun replaceAll(elements: Sequence<E>)
fun clear()

View File

@ -73,6 +73,13 @@ class SimpleListVal<E>(
return removed
}
override fun replaceAll(elements: Iterable<E>) {
val removed = ArrayList(this.elements)
this.elements.clear()
this.elements.addAll(elements)
finalizeUpdate(ListValChangeEvent.Change(0, removed, this.elements))
}
override fun replaceAll(elements: Sequence<E>) {
val removed = ArrayList(this.elements)
this.elements.clear()

View File

@ -12,9 +12,9 @@ import world.phantasmal.web.application.controllers.NavigationController
import world.phantasmal.web.application.widgets.ApplicationWidget
import world.phantasmal.web.application.widgets.MainContentWidget
import world.phantasmal.web.application.widgets.NavigationWidget
import world.phantasmal.web.core.PwTool
import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.stores.ApplicationUrl
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.huntOptimizer.HuntOptimizer
@ -47,6 +47,13 @@ class Application(
// Initialize core stores shared by several submodules.
val uiStore = addDisposable(UiStore(scope, applicationUrl))
// The various tools Phantasmal World consists of.
val tools: List<PwTool> = listOf(
Viewer(createEngine),
QuestEditor(assetLoader, createEngine),
HuntOptimizer(assetLoader, uiStore),
)
// Controllers.
val navigationController = addDisposable(NavigationController(uiStore))
val mainContentController = addDisposable(MainContentController(uiStore))
@ -56,28 +63,11 @@ class Application(
ApplicationWidget(
scope,
NavigationWidget(scope, navigationController),
MainContentWidget(scope, mainContentController, mapOf(
PwTool.Viewer to { widgetScope ->
addDisposable(Viewer(
widgetScope,
createEngine,
)).createWidget()
},
PwTool.QuestEditor to { widgetScope ->
addDisposable(QuestEditor(
widgetScope,
assetLoader,
createEngine,
)).createWidget()
},
PwTool.HuntOptimizer to { widgetScope ->
addDisposable(HuntOptimizer(
widgetScope,
assetLoader,
uiStore,
)).createWidget()
},
))
MainContentWidget(
scope,
mainContentController,
tools.map { it.toolType to it::initialize }.toMap()
)
)
)

View File

@ -1,10 +1,10 @@
package world.phantasmal.web.application.controllers
import world.phantasmal.observable.value.Val
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.webui.controllers.Controller
class MainContentController(uiStore: UiStore) : Controller() {
val tools: Map<PwTool, Val<Boolean>> = uiStore.toolToActive
val tools: Map<PwToolType, Val<Boolean>> = uiStore.toolToActive
}

View File

@ -1,14 +1,14 @@
package world.phantasmal.web.application.controllers
import world.phantasmal.observable.value.Val
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.webui.controllers.Controller
class NavigationController(private val uiStore: UiStore) : Controller() {
val tools: Map<PwTool, Val<Boolean>> = uiStore.toolToActive
val tools: Map<PwToolType, Val<Boolean>> = uiStore.toolToActive
fun setCurrentTool(tool: PwTool) {
fun setCurrentTool(tool: PwToolType) {
uiStore.setCurrentTool(tool)
}
}

View File

@ -4,7 +4,8 @@ import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node
import world.phantasmal.observable.value.not
import world.phantasmal.web.application.controllers.MainContentController
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.PwTool
import world.phantasmal.web.core.PwToolType
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.LazyLoader
import world.phantasmal.webui.widgets.Widget
@ -12,7 +13,7 @@ import world.phantasmal.webui.widgets.Widget
class MainContentWidget(
scope: CoroutineScope,
private val ctrl: MainContentController,
private val toolViews: Map<PwTool, (CoroutineScope) -> Widget>,
private val toolViews: Map<PwToolType, (CoroutineScope) -> Widget>,
) : Widget(scope) {
override fun Node.createElement() =
div {

View File

@ -3,7 +3,7 @@ package world.phantasmal.web.application.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node
import world.phantasmal.observable.Observable
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.PwToolType
import world.phantasmal.webui.dom.input
import world.phantasmal.webui.dom.label
import world.phantasmal.webui.dom.span
@ -11,7 +11,7 @@ import world.phantasmal.webui.widgets.Control
class PwToolButton(
scope: CoroutineScope,
private val tool: PwTool,
private val tool: PwToolType,
private val toggled: Observable<Boolean>,
private val mouseDown: () -> Unit,
) : Control(scope) {

View File

@ -0,0 +1,30 @@
package world.phantasmal.web.core
import world.phantasmal.web.externals.babylon.Matrix
import world.phantasmal.web.externals.babylon.Vector3
operator fun Vector3.minus(other: Vector3): Vector3 =
subtract(other)
infix fun Vector3.dot(other: Vector3): Double =
Vector3.Dot(this, other)
infix fun Vector3.cross(other: Vector3): Vector3 =
cross(other)
operator fun Matrix.timesAssign(other: Matrix) {
other.preMultiply(this)
}
fun Matrix.preMultiply(other: Matrix) {
// Multiplies this by other.
multiplyToRef(other, this)
}
fun Matrix.multiply(v: Vector3) {
Vector3.TransformCoordinatesToRef(v, this, v)
}
fun Matrix.multiply3x3(v: Vector3) {
Vector3.TransformNormalToRef(v, this, v)
}

View File

@ -0,0 +1,18 @@
package world.phantasmal.web.core
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.webui.widgets.Widget
/**
* Phantasmal World consists of several tools.
* An instance of PwTool should do as little work as possible in its constructor and defer any
* initialization work until [initialize] is called.
*/
interface PwTool {
val toolType: PwToolType
/**
* The caller of this method takes ownership of the returned widget.
*/
fun initialize(scope: CoroutineScope): Widget
}

View File

@ -0,0 +1,7 @@
package world.phantasmal.web.core
enum class PwToolType(val uiName: String, val slug: String) {
Viewer("Viewer", "viewer"),
QuestEditor("Quest Editor", "quest_editor"),
HuntOptimizer("Hunt Optimizer", "hunt_optimizer"),
}

View File

@ -1,6 +1,6 @@
package world.phantasmal.web.core.controllers
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.webui.controllers.Tab
import world.phantasmal.webui.controllers.TabController
@ -9,7 +9,7 @@ open class PathAwareTab(override val title: String, val path: String) : Tab
open class PathAwareTabController<T : PathAwareTab>(
private val uiStore: UiStore,
private val tool: PwTool,
private val tool: PwToolType,
tabs: List<T>,
) : TabController<T>(tabs) {
init {

View File

@ -11,12 +11,17 @@ abstract class Renderer(
protected val canvas: HTMLCanvasElement,
protected val engine: Engine,
) : DisposableContainer() {
protected val scene = Scene(engine)
private val light = HemisphericLight("Light", Vector3(-1.0, 1.0, 0.0), scene)
val scene = Scene(engine)
private val light = HemisphericLight("Light", Vector3(-1.0, 1.0, 1.0), scene)
protected abstract val camera: Camera
init {
scene.clearColor = Color4(0.09, 0.09, 0.09, 1.0)
with(scene) {
useRightHandedSystem = true
clearColor = Color4(0.09, 0.09, 0.09, 1.0)
}
}
fun startRendering() {
@ -38,7 +43,7 @@ abstract class Renderer(
}
private fun render() {
val lightDirection = Vector3(-1.0, 1.0, 0.0)
val lightDirection = Vector3(-1.0, 1.0, 1.0)
lightDirection.rotateByQuaternionToRef(camera.absoluteRotation, lightDirection)
light.direction = lightDirection
scene.render()

View File

@ -6,6 +6,7 @@ import world.phantasmal.lib.fileFormats.ninja.NinjaModel
import world.phantasmal.lib.fileFormats.ninja.NinjaObject
import world.phantasmal.lib.fileFormats.ninja.NjModel
import world.phantasmal.lib.fileFormats.ninja.XjModel
import world.phantasmal.web.core.*
import world.phantasmal.web.externals.babylon.*
import kotlin.math.cos
import kotlin.math.sin
@ -39,8 +40,7 @@ private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder)
if (ef.noRotate) NO_ROTATION else eulerToQuat(obj.rotation, ef.zxyRotationOrder),
if (ef.noTranslate) NO_TRANSLATION else vec3ToBabylon(obj.position),
)
matrix.multiplyToRef(parentMatrix, matrix)
matrix.preMultiply(parentMatrix)
if (!ef.hidden) {
obj.model?.let { model ->
@ -72,8 +72,8 @@ private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder)
val position = vec3ToBabylon(vertex.position)
val normal = vertex.normal?.let(::vec3ToBabylon) ?: Vector3.Up()
Vector3.TransformCoordinatesToRef(position, matrix, position)
Vector3.TransformNormalToRef(normal, normalMatrix, normal)
matrix.multiply(position)
normalMatrix.multiply3x3(normal)
Vertex(
boneIndex,
@ -158,10 +158,10 @@ private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder)
for (vertex in model.vertices) {
val p = vec3ToBabylon(vertex.position)
Vector3.TransformCoordinatesToRef(p, matrix, p)
matrix.multiply(p)
val n = vertex.normal?.let(::vec3ToBabylon) ?: Vector3.Up()
Vector3.TransformCoordinatesToRef(n, normalMatrix, n)
normalMatrix.multiply3x3(n)
val uv = vertex.uv?.let(::vec2ToBabylon) ?: DEFAULT_UV
@ -190,16 +190,16 @@ private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder)
// Calculate a surface normal and reverse the vertex winding if at least 2 of the
// vertex normals point in the opposite direction. This hack fixes the winding for
// most models.
val normal = pb.subtract(pa).cross(pc.subtract(pa))
val normal = (pb - pa) cross (pc - pa)
if (!clockwise) {
normal.negateInPlace()
}
val oppositeCount =
(if (Vector3.Dot(normal, na) < 0) 1 else 0) +
(if (Vector3.Dot(normal, nb) < 0) 1 else 0) +
(if (Vector3.Dot(normal, nc) < 0) 1 else 0)
(if (normal dot na < 0) 1 else 0) +
(if (normal dot nb < 0) 1 else 0) +
(if (normal dot nc < 0) 1 else 0)
if (oppositeCount >= 2) {
clockwise = !clockwise

View File

@ -27,10 +27,10 @@ class VertexDataBuilder {
fun getNormal(index: Int): Vector3 =
normals[index]
fun addVertex(position: Vector3, normal: Vector3, uv: Vector2) {
fun addVertex(position: Vector3, normal: Vector3, uv: Vector2? = null) {
positions.add(position)
normals.add(normal)
uvs.add(uv)
uv?.let { uvs.add(uv) }
}
fun addIndex(index: Int) {
@ -55,11 +55,11 @@ class VertexDataBuilder {
fun build(): VertexData {
check(this.positions.size == this.normals.size)
check(this.positions.size == this.uvs.size)
check(this.uvs.isEmpty() || this.positions.size == this.uvs.size)
val positions = Float32Array(3 * positions.size)
val normals = Float32Array(3 * normals.size)
val uvs = Float32Array(2 * uvs.size)
val uvs = if (uvs.isEmpty()) null else Float32Array(2 * uvs.size)
for (i in this.positions.indices) {
val pos = this.positions[i]
@ -72,10 +72,12 @@ class VertexDataBuilder {
normals[3 * i + 1] = normal.y.toFloat()
normals[3 * i + 2] = normal.z.toFloat()
uvs?.let {
val uv = this.uvs[i]
uvs[2 * i] = uv.x.toFloat()
uvs[2 * i + 1] = uv.y.toFloat()
}
}
val data = VertexData()
data.positions = positions

View File

@ -6,19 +6,11 @@ import org.w3c.dom.events.KeyboardEvent
import world.phantasmal.observable.value.MutableVal
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.models.Server
import world.phantasmal.webui.dom.disposableListener
import world.phantasmal.webui.stores.Store
/**
* Phantasmal World consists of several tools.
*/
enum class PwTool(val uiName: String, val slug: String) {
Viewer("Viewer", "viewer"),
QuestEditor("Quest Editor", "quest_editor"),
HuntOptimizer("Hunt Optimizer", "hunt_optimizer"),
}
interface ApplicationUrl {
val url: Val<String>
@ -27,8 +19,11 @@ interface ApplicationUrl {
fun replaceUrl(url: String)
}
class UiStore(scope: CoroutineScope, private val applicationUrl: ApplicationUrl) : Store(scope) {
private val _currentTool: MutableVal<PwTool>
class UiStore(
scope: CoroutineScope,
private val applicationUrl: ApplicationUrl,
) : Store(scope) {
private val _currentTool: MutableVal<PwToolType>
private val _path = mutableVal("")
private val _server = mutableVal(Server.Ephinea)
@ -48,22 +43,22 @@ class UiStore(scope: CoroutineScope, private val applicationUrl: ApplicationUrl)
*/
private val features: MutableSet<String> = mutableSetOf()
val tools: List<PwTool> = PwTool.values().toList()
val tools: List<PwToolType> = PwToolType.values().toList()
/**
* The default tool that is loaded.
*/
val defaultTool: PwTool = PwTool.Viewer
val defaultTool: PwToolType = PwToolType.Viewer
/**
* The tool that is current visible.
* The tool that is currently visible.
*/
val currentTool: Val<PwTool>
val currentTool: Val<PwToolType>
/**
* Map of tools to a boolean Val that says whether they are the current tool or not.
*/
val toolToActive: Map<PwTool, Val<Boolean>>
val toolToActive: Map<PwToolType, Val<Boolean>>
/**
* Application URL without the tool path prefix.
@ -92,7 +87,7 @@ class UiStore(scope: CoroutineScope, private val applicationUrl: ApplicationUrl)
observe(applicationUrl.url) { setDataFromUrl(it) }
}
fun setCurrentTool(tool: PwTool) {
fun setCurrentTool(tool: PwToolType) {
if (tool != currentTool.value) {
updateApplicationUrl(tool, path = "", replace = false)
setCurrentTool(tool, path = "")
@ -150,12 +145,12 @@ class UiStore(scope: CoroutineScope, private val applicationUrl: ApplicationUrl)
}
}
private fun setCurrentTool(tool: PwTool, path: String) {
private fun setCurrentTool(tool: PwToolType, path: String) {
_path.value = path
_currentTool.value = tool
}
private fun updateApplicationUrl(tool: PwTool, path: String, replace: Boolean) {
private fun updateApplicationUrl(tool: PwToolType, path: String, replace: Boolean) {
val fullPath = "/${tool.slug}${path}"
val params: MutableMap<String, String> =
parameters[fullPath]?.let { HashMap(it) } ?: mutableMapOf()
@ -195,12 +190,12 @@ class UiStore(scope: CoroutineScope, private val applicationUrl: ApplicationUrl)
}
}
private fun handlerKey(tool: PwTool, binding: String): String {
private fun handlerKey(tool: PwToolType, binding: String): String {
return "$tool -> $binding"
}
companion object {
private val SLUG_TO_PW_TOOL: Map<String, PwTool> =
PwTool.values().map { it.slug to it }.toMap()
private val SLUG_TO_PW_TOOL: Map<String, PwToolType> =
PwToolType.values().map { it.slug to it }.toMap()
}
}

View File

@ -4,35 +4,35 @@ import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLCanvasElement
import org.w3c.dom.Node
import world.phantasmal.web.core.rendering.Renderer
import world.phantasmal.webui.dom.canvas
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.Widget
import kotlin.math.floor
class RendererWidget(
scope: CoroutineScope,
private val createRenderer: (HTMLCanvasElement) -> Renderer,
private val canvas: HTMLCanvasElement,
private val renderer: Renderer,
) : Widget(scope) {
private var renderer: Renderer? = null
override fun Node.createElement() =
canvas {
div {
className = "pw-core-renderer"
tabIndex = -1
observeResize()
renderer = addDisposable(createRenderer(this))
observe(selfOrAncestorHidden) { hidden ->
if (hidden) {
renderer?.stopRendering()
renderer.stopRendering()
} else {
renderer?.startRendering()
}
renderer.startRendering()
}
}
append(canvas)
}
override fun resized(width: Double, height: Double) {
val canvas = (element as HTMLCanvasElement)
canvas.width = floor(width).toInt()
canvas.height = floor(height).toInt()
}

View File

@ -149,6 +149,7 @@ external class Engine(
) : ThinEngine
external class Scene(engine: Engine) {
var useRightHandedSystem: Boolean
var clearColor: Color4
fun render()

View File

@ -1,6 +1,8 @@
package world.phantasmal.web.huntOptimizer
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.web.core.PwTool
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.huntOptimizer.controllers.HuntOptimizerController
@ -12,20 +14,24 @@ import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.widgets.Widget
class HuntOptimizer(
private val scope: CoroutineScope,
assetLoader: AssetLoader,
uiStore: UiStore,
) : DisposableContainer() {
private val huntMethodStore = addDisposable(HuntMethodStore(scope, uiStore, assetLoader))
private val assetLoader: AssetLoader,
private val uiStore: UiStore,
) : DisposableContainer(), PwTool {
override val toolType = PwToolType.HuntOptimizer
private val huntOptimizerController = addDisposable(HuntOptimizerController(uiStore))
private val methodsController =
addDisposable(MethodsController(uiStore, huntMethodStore))
override fun initialize(scope: CoroutineScope): Widget {
// Stores
val huntMethodStore = addDisposable(HuntMethodStore(scope, uiStore, assetLoader))
fun createWidget(): Widget =
HuntOptimizerWidget(
// Controllers
val huntOptimizerController = addDisposable(HuntOptimizerController(uiStore))
val methodsController = addDisposable(MethodsController(uiStore, huntMethodStore))
// Main Widget
return HuntOptimizerWidget(
scope,
ctrl = huntOptimizerController,
createMethodsWidget = { scope -> MethodsWidget(scope, methodsController) }
createMethodsWidget = { s -> MethodsWidget(s, methodsController) }
)
}
}

View File

@ -1,15 +1,15 @@
package world.phantasmal.web.huntOptimizer.controllers
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.controllers.PathAwareTab
import world.phantasmal.web.core.controllers.PathAwareTabController
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.huntOptimizer.HuntOptimizerUrls
class HuntOptimizerController(uiStore: UiStore) :
PathAwareTabController<PathAwareTab>(
uiStore,
PwTool.HuntOptimizer,
PwToolType.HuntOptimizer,
listOf(
PathAwareTab("Optimize", HuntOptimizerUrls.optimize),
PathAwareTab("Methods", HuntOptimizerUrls.methods),

View File

@ -4,9 +4,9 @@ import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.MutableListVal
import world.phantasmal.observable.value.list.mutableListVal
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.controllers.PathAwareTab
import world.phantasmal.web.core.controllers.PathAwareTabController
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.huntOptimizer.HuntOptimizerUrls
import world.phantasmal.web.huntOptimizer.models.HuntMethodModel
@ -19,7 +19,7 @@ class MethodsController(
huntMethodStore: HuntMethodStore,
) : PathAwareTabController<MethodsTab>(
uiStore,
PwTool.HuntOptimizer,
PwToolType.HuntOptimizer,
listOf(
MethodsTab("Episode I", HuntOptimizerUrls.methodsEpisodeI, Episode.I),
MethodsTab("Episode II", HuntOptimizerUrls.methodsEpisodeII, Episode.II),

View File

@ -1,56 +1,68 @@
package world.phantasmal.web.questEditor
import kotlinx.browser.document
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.web.core.PwTool
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.questEditor.controllers.NpcCountsController
import world.phantasmal.web.questEditor.controllers.QuestEditorToolbarController
import world.phantasmal.web.questEditor.controllers.QuestInfoController
import world.phantasmal.web.questEditor.loading.AreaAssetLoader
import world.phantasmal.web.questEditor.loading.EntityAssetLoader
import world.phantasmal.web.questEditor.loading.QuestLoader
import world.phantasmal.web.questEditor.rendering.QuestEditorMeshManager
import world.phantasmal.web.questEditor.rendering.QuestRenderer
import world.phantasmal.web.questEditor.stores.AreaStore
import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.web.questEditor.widgets.*
import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.widgets.Widget
class QuestEditor(
private val scope: CoroutineScope,
private val assetLoader: AssetLoader,
private val createEngine: (HTMLCanvasElement) -> Engine,
) : DisposableContainer() {
) : DisposableContainer(), PwTool {
override val toolType = PwToolType.QuestEditor
override fun initialize(scope: CoroutineScope): Widget {
// Renderer
val canvas = document.createElement("CANVAS") as HTMLCanvasElement
val renderer = addDisposable(QuestRenderer(canvas, createEngine(canvas)))
// Asset Loaders
private val questLoader = addDisposable(QuestLoader(scope, assetLoader))
val questLoader = addDisposable(QuestLoader(scope, assetLoader))
val areaAssetLoader = addDisposable(AreaAssetLoader(scope, assetLoader, renderer.scene))
val entityAssetLoader = addDisposable(EntityAssetLoader(scope, assetLoader, renderer.scene))
// Stores
private val questEditorStore = addDisposable(QuestEditorStore(scope))
val areaStore = addDisposable(AreaStore(scope, areaAssetLoader))
val questEditorStore = addDisposable(QuestEditorStore(scope, areaStore))
// Controllers
private val toolbarController =
addDisposable(QuestEditorToolbarController(questLoader, questEditorStore))
private val questInfoController = addDisposable(QuestInfoController(questEditorStore))
private val npcCountsController = addDisposable(NpcCountsController(questEditorStore))
val toolbarController =
addDisposable(QuestEditorToolbarController(questLoader, areaStore, questEditorStore))
val questInfoController = addDisposable(QuestInfoController(questEditorStore))
val npcCountsController = addDisposable(NpcCountsController(questEditorStore))
fun createWidget(): Widget =
QuestEditorWidget(
// Rendering
addDisposable(QuestEditorMeshManager(
scope,
questEditorStore,
renderer,
areaAssetLoader,
entityAssetLoader
))
// Main Widget
return QuestEditorWidget(
scope,
QuestEditorToolbar(scope, toolbarController),
{ scope -> QuestInfoWidget(scope, questInfoController) },
{ scope -> NpcCountsWidget(scope, npcCountsController) },
{ scope -> QuestEditorRendererWidget(scope, ::createQuestEditorRenderer) }
)
private fun createQuestEditorRenderer(canvas: HTMLCanvasElement): QuestRenderer =
QuestRenderer(canvas, createEngine(canvas)) { renderer, scene ->
QuestEditorMeshManager(
scope,
questEditorStore.currentQuest,
questEditorStore.currentArea,
questEditorStore.selectedWave,
renderer,
EntityAssetLoader(scope, assetLoader, scene)
{ s -> QuestInfoWidget(s, questInfoController) },
{ s -> NpcCountsWidget(s, npcCountsController) },
{ s -> QuestEditorRendererWidget(s, canvas, renderer) }
)
}
}

View File

@ -12,6 +12,7 @@ import world.phantasmal.lib.fileFormats.quest.parseQstToQuest
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.questEditor.loading.QuestLoader
import world.phantasmal.web.questEditor.stores.AreaStore
import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.web.questEditor.stores.convertQuestToModel
import world.phantasmal.webui.controllers.Controller
@ -21,6 +22,7 @@ private val logger = KotlinLogging.logger {}
class QuestEditorToolbarController(
private val questLoader: QuestLoader,
private val areaStore: AreaStore,
private val questEditorStore: QuestEditorStore,
) : Controller() {
private val _resultDialogVisible = mutableVal(false)
@ -31,7 +33,7 @@ class QuestEditorToolbarController(
suspend fun createNewQuest(episode: Episode) {
questEditorStore.setCurrentQuest(
convertQuestToModel(questLoader.loadDefaultQuest(episode))
convertQuestToModel(questLoader.loadDefaultQuest(episode), areaStore::getVariant)
)
}
@ -80,7 +82,7 @@ class QuestEditorToolbarController(
}
private fun setCurrentQuest(quest: Quest) {
questEditorStore.setCurrentQuest(convertQuestToModel(quest))
questEditorStore.setCurrentQuest(convertQuestToModel(quest, areaStore::getVariant))
}
private fun setResult(result: PwResult<*>) {

View File

@ -0,0 +1,137 @@
package world.phantasmal.web.questEditor.loading
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import org.khronos.webgl.ArrayBuffer
import world.phantasmal.lib.Endianness
import world.phantasmal.lib.cursor.cursor
import world.phantasmal.lib.fileFormats.parseAreaCollisionGeometry
import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.externals.babylon.Scene
import world.phantasmal.web.externals.babylon.TransformNode
import world.phantasmal.web.questEditor.models.AreaVariantModel
import world.phantasmal.web.questEditor.rendering.conversion.areaCollisionGeometryToTransformNode
import world.phantasmal.webui.DisposableContainer
class AreaAssetLoader(
private val scope: CoroutineScope,
private val assetLoader: AssetLoader,
private val scene: Scene,
) : DisposableContainer() {
private val collisionObjectCache =
addDisposable(LoadingCache<Triple<Episode, Int, Int>, TransformNode> { it.dispose() })
suspend fun loadCollisionGeometry(
episode: Episode,
areaVariant: AreaVariantModel,
): TransformNode =
collisionObjectCache.getOrPut(Triple(episode, areaVariant.area.id, areaVariant.id)) {
scope.async {
val buffer = getAreaAsset(episode, areaVariant, AssetType.Collision)
val obj = parseAreaCollisionGeometry(buffer.cursor(Endianness.Little))
areaCollisionGeometryToTransformNode(scene, obj)
}
}.await()
private suspend fun getAreaAsset(
episode: Episode,
areaVariant: AreaVariantModel,
type: AssetType,
): ArrayBuffer {
val baseUrl = areaVersionToBaseUrl(episode, areaVariant)
val suffix = when (type) {
AssetType.Render -> "n.rel"
AssetType.Collision -> "c.rel"
}
return assetLoader.loadArrayBuffer(baseUrl + suffix)
}
enum class AssetType {
Render, Collision
}
}
private val AREA_BASE_NAMES: Map<Episode, List<Pair<String, Int>>> = mapOf(
Episode.I to listOf(
Pair("city00_00", 1),
Pair("forest01", 1),
Pair("forest02", 1),
Pair("cave01_", 6),
Pair("cave02_", 5),
Pair("cave03_", 6),
Pair("machine01_", 6),
Pair("machine02_", 6),
Pair("ancient01_", 5),
Pair("ancient02_", 5),
Pair("ancient03_", 5),
Pair("boss01", 1),
Pair("boss02", 1),
Pair("boss03", 1),
Pair("darkfalz00", 1),
),
Episode.II to listOf(
Pair("labo00_00", 1),
Pair("ruins01_", 3),
Pair("ruins02_", 3),
Pair("space01_", 3),
Pair("space02_", 3),
Pair("jungle01_00", 1),
Pair("jungle02_00", 1),
Pair("jungle03_00", 1),
Pair("jungle04_", 3),
Pair("jungle05_00", 1),
Pair("seabed01_", 3),
Pair("seabed02_", 3),
Pair("boss05", 1),
Pair("boss06", 1),
Pair("boss07", 1),
Pair("boss08", 1),
Pair("jungle06_00", 1),
Pair("jungle07_", 5),
),
Episode.IV to listOf(
Pair("city02_00", 1),
Pair("wilds01_00", 1),
Pair("wilds01_01", 1),
Pair("wilds01_02", 1),
Pair("wilds01_03", 1),
Pair("crater01_00", 1),
Pair("desert01_", 3),
Pair("desert02_", 3),
Pair("desert03_", 3),
Pair("boss09_00", 1),
)
)
private fun areaVersionToBaseUrl(episode: Episode, areaVariant: AreaVariantModel): String {
var areaId = areaVariant.area.id
var areaVariantId = areaVariant.id
// Exception for Seaside area at night, variant 1.
// Phantasmal World 4 and Lost heart breaker use this to have two tower maps.
if (areaId == 16 && areaVariantId == 1) {
areaId = 17
areaVariantId = 1
}
val episodeBaseNames = AREA_BASE_NAMES.getValue(episode)
require(areaId in episodeBaseNames.indices) {
"Unknown episode $episode area $areaId."
}
val (base_name, variants) = episodeBaseNames[areaId]
require(areaVariantId in 0 until variants) {
"Unknown variant $areaVariantId of area $areaId in episode $episode."
}
val variant = if (variants == 1) {
""
} else {
areaVariantId.toString().padStart(2, '0')
}
return "/maps/map_${base_name}${variant}"
}

View File

@ -29,18 +29,24 @@ class EntityAssetLoader(
private val assetLoader: AssetLoader,
private val scene: Scene,
) : DisposableContainer() {
private val defaultMesh = MeshBuilder.CreateCylinder("Entity", obj {
private val defaultMesh =
MeshBuilder.CreateCylinder(
"Entity",
obj {
diameter = 6.0
height = 20.0
}, scene).apply {
},
scene
).apply {
setEnabled(false)
position = Vector3(0.0, 10.0, 0.0)
}
private val meshCache = addDisposable(LoadingCache<Pair<EntityType, Int?>, Mesh>())
private val meshCache =
addDisposable(LoadingCache<Pair<EntityType, Int?>, Mesh> { it.dispose() })
suspend fun loadMesh(type: EntityType, model: Int?): Mesh {
return meshCache.getOrPut(Pair(type, model)) {
suspend fun loadMesh(type: EntityType, model: Int?): Mesh =
meshCache.getOrPut(Pair(type, model)) {
scope.async {
try {
loadGeometry(type, model)?.let { vertexData ->
@ -60,7 +66,6 @@ class EntityAssetLoader(
}
}
}.await()
}
private suspend fun loadGeometry(type: EntityType, model: Int?): VertexData? {
val geomFormat = entityTypeToGeometryFormat(type)

View File

@ -1,20 +1,30 @@
package world.phantasmal.web.questEditor.loading
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import world.phantasmal.core.disposable.TrackedDisposable
class LoadingCache<K, V> : TrackedDisposable() {
class LoadingCache<K, V>(private val disposeValue: (V) -> Unit) : TrackedDisposable() {
private val map = mutableMapOf<K, Deferred<V>>()
operator fun set(key: K, value: Deferred<V>) {
map[key] = value
}
@Suppress("DeferredIsResult")
fun getOrPut(key: K, defaultValue: () -> Deferred<V>): Deferred<V> =
map.getOrPut(key, defaultValue)
@OptIn(ExperimentalCoroutinesApi::class)
override fun internalDispose() {
map.values.forEach { it.cancel() }
map.values.forEach {
if (it.isActive) {
it.cancel()
} else if (it.isCompleted) {
disposeValue(it.getCompleted())
}
}
super.internalDispose()
}
}

View File

@ -15,7 +15,7 @@ class QuestLoader(
private val scope: CoroutineScope,
private val assetLoader: AssetLoader,
) : TrackedDisposable() {
private val cache = LoadingCache<String, ArrayBuffer>()
private val cache = LoadingCache<String, ArrayBuffer> {}
override fun internalDispose() {
cache.dispose()

View File

@ -1,3 +1,17 @@
package world.phantasmal.web.questEditor.models
class AreaModel
import world.phantasmal.core.requireNonNegative
class AreaModel(
/**
* Matches the PSO ID.
*/
val id: Int,
val name: String,
val order: Int,
val areaVariants: List<AreaVariantModel>,
) {
init {
requireNonNegative(id, "id")
}
}

View File

@ -1,3 +1,19 @@
package world.phantasmal.web.questEditor.models
class AreaVariantModel
import world.phantasmal.core.requireNonNegative
import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.mutableListVal
class AreaVariantModel(val id: Int, val area: AreaModel) {
private val _sections = mutableListVal<SectionModel>()
val sections: ListVal<SectionModel> = _sections
init {
requireNonNegative(id, "id")
}
fun setSections(sections: List<SectionModel>) {
_sections.replaceAll(sections)
}
}

View File

@ -15,6 +15,8 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
val type: Type get() = entity.type
val areaId: Int get() = entity.areaId
/**
* Section-relative position
*/

View File

@ -13,14 +13,17 @@ class QuestModel(
shortDescription: String,
longDescription: String,
val episode: Episode,
mapDesignations: Map<Int, Int>,
npcs: MutableList<QuestNpcModel>,
objects: MutableList<QuestObjectModel>,
getVariant: (Episode, areaId: Int, variantId: Int) -> AreaVariantModel?,
) {
private val _id = mutableVal(0)
private val _language = mutableVal(0)
private val _name = mutableVal("")
private val _shortDescription = mutableVal("")
private val _longDescription = mutableVal("")
private val _mapDesignations = mutableVal(mapDesignations)
private val _npcs = mutableListVal(npcs)
private val _objects = mutableListVal(objects)
@ -29,6 +32,18 @@ class QuestModel(
val name: Val<String> = _name
val shortDescription: Val<String> = _shortDescription
val longDescription: Val<String> = _longDescription
val mapDesignations: Val<Map<Int, Int>> = _mapDesignations
/**
* Map of area IDs to entity counts.
*/
val entitiesPerArea: Val<Map<Int, Int>>
/**
* One variant per area.
*/
val areaVariants: Val<List<AreaVariantModel>>
val npcs: ListVal<QuestNpcModel> = _npcs
val objects: ListVal<QuestObjectModel> = _objects
@ -38,6 +53,39 @@ class QuestModel(
setName(name)
setShortDescription(shortDescription)
setLongDescription(longDescription)
entitiesPerArea = this.npcs.map(this.objects) { ns, os ->
val map = mutableMapOf<Int, Int>()
for (npc in ns) {
map[npc.areaId] = (map[npc.areaId] ?: 0) + 1
}
for (obj in os) {
map[obj.areaId] = (map[obj.areaId] ?: 0) + 1
}
map
}
areaVariants =
entitiesPerArea.map(this.mapDesignations) { entitiesPerArea, mds ->
val variants = mutableMapOf<Int, AreaVariantModel>()
for (areaId in entitiesPerArea.values) {
getVariant(episode, areaId, 0)?.let {
variants[areaId] = it
}
}
for ((areaId, variantId) in mds) {
getVariant(episode, areaId, variantId)?.let {
variants[areaId] = it
}
}
variants.values.toList()
}
}
fun setId(id: Int): QuestModel {

View File

@ -0,0 +1,16 @@
package world.phantasmal.web.questEditor.models
import world.phantasmal.web.externals.babylon.Vector3
class SectionModel(
val id: Int,
val position: Vector3,
val rotation: Vector3,
val areaVariant: AreaVariantModel,
) {
init {
require(id >= -1) {
"id should be greater than or equal to -1 but was $id."
}
}
}

View File

@ -0,0 +1,31 @@
package world.phantasmal.web.questEditor.rendering
import mu.KotlinLogging
import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.web.externals.babylon.TransformNode
import world.phantasmal.web.questEditor.loading.AreaAssetLoader
import world.phantasmal.web.questEditor.models.AreaVariantModel
private val logger = KotlinLogging.logger {}
class AreaMeshManager(private val areaAssetLoader: AreaAssetLoader) {
private var currentGeometry: TransformNode? = null
suspend fun load(episode: Episode?, areaVariant: AreaVariantModel?) {
currentGeometry?.setEnabled(false)
if (episode == null || areaVariant == null) {
return
}
try {
val geom = areaAssetLoader.loadCollisionGeometry(episode, areaVariant)
geom.setEnabled(true)
currentGeometry = geom
} catch (e: Exception) {
logger.error(e) {
"Couldn't load models for area ${areaVariant.area.id}, variant ${areaVariant.id}."
}
}
}
}

View File

@ -1,46 +1,50 @@
package world.phantasmal.web.questEditor.rendering
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.observable.value.Val
import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.listVal
import world.phantasmal.web.questEditor.loading.AreaAssetLoader
import world.phantasmal.web.questEditor.loading.EntityAssetLoader
import world.phantasmal.web.questEditor.models.*
import world.phantasmal.web.questEditor.stores.QuestEditorStore
class QuestEditorMeshManager(
scope: CoroutineScope,
private val currentQuest: Val<QuestModel?>,
private val currentArea: Val<AreaModel?>,
selectedWave: Val<WaveModel?>,
questEditorStore: QuestEditorStore,
renderer: QuestRenderer,
areaAssetLoader: AreaAssetLoader,
entityAssetLoader: EntityAssetLoader,
) : QuestMeshManager(scope, selectedWave, renderer, entityAssetLoader) {
) : QuestMeshManager(scope, questEditorStore, renderer, areaAssetLoader, entityAssetLoader) {
init {
disposer.addAll(
currentQuest.observe { areaVariantChanged() },
currentArea.observe { areaVariantChanged() },
questEditorStore.currentQuest.map(questEditorStore.currentArea, ::getAreaVariantDetails)
.observe { (details) ->
loadMeshes(details.episode, details.areaVariant, details.npcs, details.objects)
},
)
}
override fun getAreaVariantDetails(): AreaVariantDetails {
val quest = currentQuest.value
val area = currentArea.value
val areaVariant: AreaVariantModel?
val npcs: ListVal<QuestNpcModel>
val objects: ListVal<QuestObjectModel>
if (quest != null /*&& area != null*/) {
// TODO: Set areaVariant.
areaVariant = null
npcs = quest.npcs // TODO: Filter NPCs.
objects = listVal() // TODO: Filter objects.
} else {
areaVariant = null
npcs = listVal()
objects = listVal()
private fun getAreaVariantDetails(quest: QuestModel?, area: AreaModel?): AreaVariantDetails {
quest?.let {
val areaVariant = area?.let {
quest.areaVariants.value.find { it.area.id == area.id }
}
return AreaVariantDetails(quest?.episode, areaVariant, npcs, objects)
areaVariant?.let {
val npcs = quest.npcs // TODO: Filter NPCs.
val objects = quest.objects // TODO: Filter objects.
return AreaVariantDetails(quest.episode, areaVariant, npcs, objects)
}
}
return AreaVariantDetails(null, null, listVal(), listVal())
}
private class AreaVariantDetails(
val episode: Episode?,
val areaVariant: AreaVariantModel?,
val npcs: ListVal<QuestNpcModel>,
val objects: ListVal<QuestObjectModel>,
)
}

View File

@ -1,54 +1,65 @@
package world.phantasmal.web.questEditor.rendering
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import world.phantasmal.core.disposable.Disposer
import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.ListValChangeEvent
import world.phantasmal.web.questEditor.loading.AreaAssetLoader
import world.phantasmal.web.questEditor.loading.EntityAssetLoader
import world.phantasmal.web.questEditor.models.AreaVariantModel
import world.phantasmal.web.questEditor.models.QuestNpcModel
import world.phantasmal.web.questEditor.models.QuestObjectModel
import world.phantasmal.web.questEditor.models.WaveModel
import world.phantasmal.web.questEditor.stores.QuestEditorStore
/**
* Loads the necessary area and entity 3D models into [QuestRenderer].
*/
abstract class QuestMeshManager protected constructor(
scope: CoroutineScope,
selectedWave: Val<WaveModel?>,
private val scope: CoroutineScope,
questEditorStore: QuestEditorStore,
private val renderer: QuestRenderer,
areaAssetLoader: AreaAssetLoader,
entityAssetLoader: EntityAssetLoader,
) : TrackedDisposable() {
protected val disposer = Disposer()
private val areaDisposer = disposer.add(Disposer())
private val areaMeshManager = AreaMeshManager(areaAssetLoader)
private val npcMeshManager = disposer.add(
EntityMeshManager(scope, selectedWave, renderer, entityAssetLoader)
EntityMeshManager(scope, questEditorStore.selectedWave, renderer, entityAssetLoader)
)
private val objectMeshManager = disposer.add(
EntityMeshManager(scope, selectedWave, renderer, entityAssetLoader)
EntityMeshManager(scope, questEditorStore.selectedWave, renderer, entityAssetLoader)
)
protected abstract fun getAreaVariantDetails(): AreaVariantDetails
private var loadJob: Job? = null
protected fun areaVariantChanged() {
val details = getAreaVariantDetails()
// TODO: Load area mesh.
protected fun loadMeshes(
episode: Episode?,
areaVariant: AreaVariantModel?,
npcs: ListVal<QuestNpcModel>,
objects: ListVal<QuestObjectModel>,
) {
loadJob?.cancel()
loadJob = scope.launch {
areaMeshManager.load(episode, areaVariant)
areaDisposer.disposeAll()
npcMeshManager.removeAll()
objectMeshManager.removeAll()
renderer.resetEntityMeshes()
// Load entity meshes.
areaDisposer.addAll(
details.npcs.observeList(callNow = true, ::npcsChanged),
details.objects.observeList(callNow = true, ::objectsChanged),
npcs.observeList(callNow = true, ::npcsChanged),
objects.observeList(callNow = true, ::objectsChanged),
)
}
}
override fun internalDispose() {
disposer.dispose()
@ -69,10 +80,3 @@ abstract class QuestMeshManager protected constructor(
}
}
}
class AreaVariantDetails(
val episode: Episode?,
val areaVariant: AreaVariantModel?,
val npcs: ListVal<QuestNpcModel>,
val objects: ListVal<QuestObjectModel>,
)

View File

@ -7,16 +7,11 @@ import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.rendering.conversion.EntityMetadata
import kotlin.math.PI
class QuestRenderer(
canvas: HTMLCanvasElement,
engine: Engine,
createMeshManager: (QuestRenderer, Scene) -> QuestMeshManager,
) : Renderer(canvas, engine) {
private val meshManager = createMeshManager(this, scene)
class QuestRenderer(canvas: HTMLCanvasElement, engine: Engine) : Renderer(canvas, engine) {
private var entityMeshes = TransformNode("Entities", scene)
private val entityToMesh = mutableMapOf<QuestEntityModel<*, *>, AbstractMesh>()
override val camera = ArcRotateCamera("Camera", 0.0, PI / 6, 500.0, Vector3.Zero(), scene)
override val camera = ArcRotateCamera("Camera", PI / 2, PI / 6, 500.0, Vector3.Zero(), scene)
init {
with(camera) {
@ -31,14 +26,13 @@ class QuestRenderer(
angularSensibilityY = 200.0
panningInertia = 0.0
panningSensibility = 3.0
panningAxis = Vector3(1.0, 0.0, 1.0)
panningAxis = Vector3(1.0, 0.0, -1.0)
pinchDeltaPercentage = 0.1
wheelDeltaPercentage = 0.1
}
}
override fun internalDispose() {
meshManager.dispose()
entityMeshes.dispose()
entityToMesh.clear()
super.internalDispose()

View File

@ -0,0 +1,50 @@
package world.phantasmal.web.questEditor.rendering.conversion
import world.phantasmal.lib.fileFormats.CollisionObject
import world.phantasmal.web.core.rendering.conversion.VertexDataBuilder
import world.phantasmal.web.core.rendering.conversion.vec3ToBabylon
import world.phantasmal.web.externals.babylon.Mesh
import world.phantasmal.web.externals.babylon.Scene
import world.phantasmal.web.externals.babylon.TransformNode
fun areaCollisionGeometryToTransformNode(scene: Scene, obj: CollisionObject): TransformNode {
val node = TransformNode("", scene)
for (collisionMesh in obj.meshes) {
val builder = VertexDataBuilder()
for (triangle in collisionMesh.triangles) {
val isSectionTransition = (triangle.flags and 0b1000000) != 0
val isVegetation = (triangle.flags and 0b10000) != 0
val isGround = (triangle.flags and 0b1) != 0
val colorIndex = when {
isSectionTransition -> 3
isVegetation -> 2
isGround -> 1
else -> 0
}
// Filter out walls.
if (colorIndex != 0) {
val p1 = vec3ToBabylon(collisionMesh.vertices[triangle.index1])
val p2 = vec3ToBabylon(collisionMesh.vertices[triangle.index2])
val p3 = vec3ToBabylon(collisionMesh.vertices[triangle.index3])
val n = vec3ToBabylon(triangle.normal)
builder.addIndex(builder.vertexCount)
builder.addVertex(p1, n)
builder.addIndex(builder.vertexCount)
builder.addVertex(p3, n)
builder.addIndex(builder.vertexCount)
builder.addVertex(p2, n)
}
}
if (builder.vertexCount > 0) {
val mesh = Mesh("Collision Geometry", scene, parent = node)
builder.build().applyToMesh(mesh)
}
}
return node
}

View File

@ -0,0 +1,36 @@
package world.phantasmal.web.questEditor.stores
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.lib.fileFormats.quest.getAreasForEpisode
import world.phantasmal.web.questEditor.loading.AreaAssetLoader
import world.phantasmal.web.questEditor.models.AreaModel
import world.phantasmal.web.questEditor.models.AreaVariantModel
import world.phantasmal.webui.stores.Store
class AreaStore(scope: CoroutineScope, areaAssetLoader: AreaAssetLoader) : Store(scope) {
private val areas: Map<Episode, List<AreaModel>>
init {
areas = Episode.values()
.map { episode ->
episode to getAreasForEpisode(episode).map { area ->
val variants = mutableListOf<AreaVariantModel>()
val areaModel = AreaModel(area.id, area.name, area.order, variants)
area.areaVariants.forEach { variant ->
variants.add(AreaVariantModel(variant.id, areaModel))
}
areaModel
}
}
.toMap()
}
fun getArea(episode: Episode, areaId: Int): AreaModel? =
areas.getValue(episode).find { it.id == areaId }
fun getVariant(episode: Episode, areaId: Int, variantId: Int): AreaVariantModel? =
getArea(episode, areaId)?.areaVariants?.getOrNull(variantId)
}

View File

@ -1,11 +1,16 @@
package world.phantasmal.web.questEditor.stores
import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.lib.fileFormats.quest.Quest
import world.phantasmal.web.questEditor.models.AreaVariantModel
import world.phantasmal.web.questEditor.models.QuestModel
import world.phantasmal.web.questEditor.models.QuestNpcModel
import world.phantasmal.web.questEditor.models.QuestObjectModel
fun convertQuestToModel(quest: Quest): QuestModel {
fun convertQuestToModel(
quest: Quest,
getVariant: (Episode, areaId: Int, variantId: Int) -> AreaVariantModel?,
): QuestModel {
return QuestModel(
quest.id,
quest.language,
@ -13,8 +18,10 @@ fun convertQuestToModel(quest: Quest): QuestModel {
quest.shortDescription,
quest.longDescription,
quest.episode,
quest.mapDesignations,
// TODO: Add WaveModel to QuestNpcModel
quest.npcs.mapTo(mutableListOf()) { QuestNpcModel(it, null) },
quest.objects.mapTo(mutableListOf()) { QuestObjectModel(it) }
quest.objects.mapTo(mutableListOf()) { QuestObjectModel(it) },
getVariant
)
}

View File

@ -8,7 +8,7 @@ import world.phantasmal.web.questEditor.models.QuestModel
import world.phantasmal.web.questEditor.models.WaveModel
import world.phantasmal.webui.stores.Store
class QuestEditorStore(scope: CoroutineScope) : Store(scope) {
class QuestEditorStore(scope: CoroutineScope, private val areaStore: AreaStore) : Store(scope) {
private val _currentQuest = mutableVal<QuestModel?>(null)
private val _currentArea = mutableVal<AreaModel?>(null)
private val _selectedWave = mutableVal<WaveModel?>(null)
@ -21,6 +21,11 @@ class QuestEditorStore(scope: CoroutineScope) : Store(scope) {
val questEditingDisabled: Val<Boolean> = currentQuest.map { it == null }
fun setCurrentQuest(quest: QuestModel?) {
_currentArea.value = null
_currentQuest.value = quest
quest?.let {
_currentArea.value = areaStore.getArea(quest.episode, 0)
}
}
}

View File

@ -6,5 +6,6 @@ import world.phantasmal.web.questEditor.rendering.QuestRenderer
class QuestEditorRendererWidget(
scope: CoroutineScope,
createRenderer: (HTMLCanvasElement) -> QuestRenderer,
) : QuestRendererWidget(scope, createRenderer)
canvas: HTMLCanvasElement,
renderer: QuestRenderer,
) : QuestRendererWidget(scope, canvas, renderer)

View File

@ -10,14 +10,15 @@ import world.phantasmal.webui.widgets.Widget
abstract class QuestRendererWidget(
scope: CoroutineScope,
private val createRenderer: (HTMLCanvasElement) -> QuestRenderer,
private val canvas: HTMLCanvasElement,
private val renderer: QuestRenderer,
) : Widget(scope) {
override fun Node.createElement() =
div {
className = "pw-quest-editor-quest-renderer"
tabIndex = -1
addChild(RendererWidget(scope, createRenderer))
addChild(RendererWidget(scope, canvas, renderer))
}
companion object {

View File

@ -1,7 +1,10 @@
package world.phantasmal.web.viewer
import kotlinx.browser.document
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.web.core.PwTool
import world.phantasmal.web.core.PwToolType
import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.viewer.controller.ViewerToolbarController
import world.phantasmal.web.viewer.rendering.MeshRenderer
@ -12,18 +15,22 @@ import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.widgets.Widget
class Viewer(
private val scope: CoroutineScope,
private val createEngine: (HTMLCanvasElement) -> Engine,
) : DisposableContainer() {
) : DisposableContainer(), PwTool {
override val toolType = PwToolType.Viewer
override fun initialize(scope: CoroutineScope): Widget {
// Stores
private val viewerStore = addDisposable(ViewerStore(scope))
val viewerStore = addDisposable(ViewerStore(scope))
// Controllers
private val viewerToolbarController = addDisposable(ViewerToolbarController(viewerStore))
val viewerToolbarController = addDisposable(ViewerToolbarController(viewerStore))
fun createWidget(): Widget =
ViewerWidget(scope, ViewerToolbar(scope, viewerToolbarController), ::createViewerRenderer)
// Rendering
val canvas = document.createElement("CANVAS") as HTMLCanvasElement
val renderer = addDisposable(MeshRenderer(viewerStore, canvas, createEngine(canvas)))
private fun createViewerRenderer(canvas: HTMLCanvasElement): MeshRenderer =
MeshRenderer(viewerStore, canvas, createEngine(canvas))
// Main Widget
return ViewerWidget(scope, ViewerToolbar(scope, viewerToolbarController), canvas, renderer)
}
}

View File

@ -4,7 +4,10 @@ import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.lib.fileFormats.ninja.NinjaObject
import world.phantasmal.web.core.rendering.Renderer
import world.phantasmal.web.core.rendering.conversion.ninjaObjectToVertexData
import world.phantasmal.web.externals.babylon.*
import world.phantasmal.web.externals.babylon.ArcRotateCamera
import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.externals.babylon.Mesh
import world.phantasmal.web.externals.babylon.Vector3
import world.phantasmal.web.viewer.store.ViewerStore
import kotlin.math.PI
@ -15,7 +18,7 @@ class MeshRenderer(
) : Renderer(canvas, engine) {
private var mesh: Mesh? = null
override val camera = ArcRotateCamera("Camera", 0.0, PI / 3, 70.0, Vector3.Zero(), scene)
override val camera = ArcRotateCamera("Camera", PI / 2, PI / 3, 70.0, Vector3.Zero(), scene)
init {
with(camera) {
@ -29,7 +32,7 @@ class MeshRenderer(
angularSensibilityX = 200.0
angularSensibilityY = 200.0
panningInertia = 0.0
panningSensibility = 20.0
panningSensibility = 10.0
panningAxis = Vector3(1.0, 1.0, 0.0)
pinchDeltaPercentage = 0.1
wheelDeltaPercentage = 0.1

View File

@ -11,7 +11,8 @@ import world.phantasmal.webui.widgets.Widget
class ViewerWidget(
scope: CoroutineScope,
private val toolbar: Widget,
private val createRenderer: (HTMLCanvasElement) -> Renderer,
private val canvas: HTMLCanvasElement,
private val renderer: Renderer,
) : Widget(scope) {
override fun Node.createElement() =
div {
@ -21,7 +22,7 @@ class ViewerWidget(
div {
className = "pw-viewer-viewer-container"
addChild(RendererWidget(scope, createRenderer))
addChild(RendererWidget(scope, canvas, renderer))
}
}
@ -38,6 +39,7 @@ class ViewerWidget(
flex-grow: 1;
display: flex;
flex-direction: row;
overflow: hidden;
}
""".trimIndent())
}

View File

@ -10,7 +10,7 @@ import world.phantasmal.core.disposable.disposable
import world.phantasmal.core.disposable.use
import world.phantasmal.testUtils.TestSuite
import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.PwTool
import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.test.TestApplicationUrl
import kotlin.test.Test

View File

@ -1,7 +1,7 @@
package world.phantasmal.web.core.controllers
import world.phantasmal.testUtils.TestSuite
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.PwTool
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.test.TestApplicationUrl
import kotlin.test.Test

View File

@ -1,7 +1,7 @@
package world.phantasmal.web.core.store
import world.phantasmal.testUtils.TestSuite
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.PwTool
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.test.TestApplicationUrl
import kotlin.test.Test

View File

@ -7,7 +7,7 @@ import kotlinx.coroutines.cancel
import world.phantasmal.core.disposable.disposable
import world.phantasmal.testUtils.TestSuite
import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.PwTool
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.test.TestApplicationUrl
import kotlin.test.Test