mirror of
https://github.com/DaanVandenBosch/phantasmal-world.git
synced 2025-04-04 22:58:29 +08:00
Scope, Store and Controller now implement CoroutineScope. Added NJ parser and several basic widgets.
This commit is contained in:
parent
c3bd1c46cc
commit
18e01f17c7
@ -22,6 +22,7 @@ subprojects {
|
|||||||
|
|
||||||
tasks.withType<Kotlin2JsCompile> {
|
tasks.withType<Kotlin2JsCompile> {
|
||||||
kotlinOptions.freeCompilerArgs += listOf(
|
kotlinOptions.freeCompilerArgs += listOf(
|
||||||
|
"-Xopt-in=kotlin.RequiresOptIn",
|
||||||
"-Xopt-in=kotlin.ExperimentalUnsignedTypes",
|
"-Xopt-in=kotlin.ExperimentalUnsignedTypes",
|
||||||
"-Xopt-in=kotlin.time.ExperimentalTime"
|
"-Xopt-in=kotlin.time.ExperimentalTime"
|
||||||
)
|
)
|
||||||
|
@ -2,6 +2,7 @@ plugins {
|
|||||||
kotlin("multiplatform")
|
kotlin("multiplatform")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val coroutinesVersion: String by project.ext
|
||||||
val kotlinLoggingVersion: String by project.extra
|
val kotlinLoggingVersion: String by project.extra
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@ -12,6 +13,7 @@ kotlin {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain {
|
commonMain {
|
||||||
dependencies {
|
dependencies {
|
||||||
|
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
|
||||||
api("io.github.microutils:kotlin-logging:$kotlinLoggingVersion")
|
api("io.github.microutils:kotlin-logging:$kotlinLoggingVersion")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
package world.phantasmal.core.disposable
|
package world.phantasmal.core.disposable
|
||||||
|
|
||||||
class DisposableScope : Scope, Disposable {
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
class DisposableScope(override val coroutineContext: CoroutineContext) : Scope, Disposable {
|
||||||
private val disposables = mutableListOf<Disposable>()
|
private val disposables = mutableListOf<Disposable>()
|
||||||
private var disposed = false
|
private var disposed = false
|
||||||
|
|
||||||
@ -9,7 +14,7 @@ class DisposableScope : Scope, Disposable {
|
|||||||
*/
|
*/
|
||||||
val size: Int get() = disposables.size
|
val size: Int get() = disposables.size
|
||||||
|
|
||||||
override fun scope(): Scope = DisposableScope().also(::add)
|
override fun scope(): Scope = DisposableScope(coroutineContext + SupervisorJob()).also(::add)
|
||||||
|
|
||||||
override fun add(disposable: Disposable) {
|
override fun add(disposable: Disposable) {
|
||||||
require(!disposed) { "Scope already disposed." }
|
require(!disposed) { "Scope already disposed." }
|
||||||
@ -65,6 +70,11 @@ class DisposableScope : Scope, Disposable {
|
|||||||
override fun dispose() {
|
override fun dispose() {
|
||||||
if (!disposed) {
|
if (!disposed) {
|
||||||
disposeAll()
|
disposeAll()
|
||||||
|
|
||||||
|
if (coroutineContext[Job] != null) {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
disposed = true
|
disposed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
package world.phantasmal.core.disposable
|
package world.phantasmal.core.disposable
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Container for disposables. Takes ownership of all held disposables and automatically disposes
|
* Container for disposables. Takes ownership of all held disposables and automatically disposes
|
||||||
* them when the Scope is disposed.
|
* them when the Scope is disposed.
|
||||||
*/
|
*/
|
||||||
interface Scope {
|
interface Scope: CoroutineScope {
|
||||||
fun add(disposable: Disposable)
|
fun add(disposable: Disposable)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
package world.phantasmal.core.disposable
|
package world.phantasmal.core.disposable
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlin.test.*
|
import kotlin.test.*
|
||||||
|
|
||||||
class DisposableScopeTests {
|
class DisposableScopeTests {
|
||||||
@Test
|
@Test
|
||||||
fun calling_add_or_addAll_increases_size_correctly() {
|
fun calling_add_or_addAll_increases_size_correctly() {
|
||||||
TrackedDisposable.checkNoLeaks {
|
TrackedDisposable.checkNoLeaks {
|
||||||
val scope = DisposableScope()
|
val scope = DisposableScope(Job())
|
||||||
assertEquals(scope.size, 0)
|
assertEquals(scope.size, 0)
|
||||||
|
|
||||||
scope.add(Dummy())
|
scope.add(Dummy())
|
||||||
@ -28,7 +29,7 @@ class DisposableScopeTests {
|
|||||||
@Test
|
@Test
|
||||||
fun disposes_all_its_disposables_when_disposed() {
|
fun disposes_all_its_disposables_when_disposed() {
|
||||||
TrackedDisposable.checkNoLeaks {
|
TrackedDisposable.checkNoLeaks {
|
||||||
val scope = DisposableScope()
|
val scope = DisposableScope(Job())
|
||||||
var disposablesDisposed = 0
|
var disposablesDisposed = 0
|
||||||
|
|
||||||
for (i in 1..5) {
|
for (i in 1..5) {
|
||||||
@ -56,7 +57,7 @@ class DisposableScopeTests {
|
|||||||
@Test
|
@Test
|
||||||
fun disposeAll_disposes_all_disposables() {
|
fun disposeAll_disposes_all_disposables() {
|
||||||
TrackedDisposable.checkNoLeaks {
|
TrackedDisposable.checkNoLeaks {
|
||||||
val scope = DisposableScope()
|
val scope = DisposableScope(Job())
|
||||||
|
|
||||||
var disposablesDisposed = 0
|
var disposablesDisposed = 0
|
||||||
|
|
||||||
@ -79,7 +80,7 @@ class DisposableScopeTests {
|
|||||||
@Test
|
@Test
|
||||||
fun size_and_is_empty_should_correctly_reflect_the_contained_disposables() {
|
fun size_and_is_empty_should_correctly_reflect_the_contained_disposables() {
|
||||||
TrackedDisposable.checkNoLeaks {
|
TrackedDisposable.checkNoLeaks {
|
||||||
val scope = DisposableScope()
|
val scope = DisposableScope(Job())
|
||||||
|
|
||||||
assertEquals(scope.size, 0)
|
assertEquals(scope.size, 0)
|
||||||
assertTrue(scope.isEmpty())
|
assertTrue(scope.isEmpty())
|
||||||
@ -101,7 +102,7 @@ class DisposableScopeTests {
|
|||||||
@Test
|
@Test
|
||||||
fun adding_disposables_after_being_disposed_throws() {
|
fun adding_disposables_after_being_disposed_throws() {
|
||||||
TrackedDisposable.checkNoLeaks {
|
TrackedDisposable.checkNoLeaks {
|
||||||
val scope = DisposableScope()
|
val scope = DisposableScope(Job())
|
||||||
scope.dispose()
|
scope.dispose()
|
||||||
|
|
||||||
for (i in 1..3) {
|
for (i in 1..3) {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package world.phantasmal.core.disposable
|
package world.phantasmal.core.disposable
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
@ -50,6 +51,8 @@ class TrackedDisposableTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private class DummyScope : Scope {
|
private class DummyScope : Scope {
|
||||||
|
override val coroutineContext = Job()
|
||||||
|
|
||||||
override fun add(disposable: Disposable) {
|
override fun add(disposable: Disposable) {
|
||||||
// Do nothing.
|
// Do nothing.
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
package world.phantasmal.lib.fileformats
|
package world.phantasmal.lib.fileFormats
|
||||||
|
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import world.phantasmal.core.PwResult
|
import world.phantasmal.core.PwResult
|
@ -0,0 +1,9 @@
|
|||||||
|
package world.phantasmal.lib.fileFormats
|
||||||
|
|
||||||
|
import world.phantasmal.lib.cursor.Cursor
|
||||||
|
|
||||||
|
class Vec2(val x: Float, val y: Float)
|
||||||
|
|
||||||
|
class Vec3(val x: Float, val y: Float, val z: Float)
|
||||||
|
|
||||||
|
fun Cursor.vec3F32(): Vec3 = Vec3(f32(), f32(), f32())
|
@ -0,0 +1,15 @@
|
|||||||
|
package world.phantasmal.lib.fileFormats.ninja
|
||||||
|
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.round
|
||||||
|
|
||||||
|
private const val ANGLE_TO_RAD = ((2 * PI) / 0x10000).toFloat()
|
||||||
|
private const val RAD_TO_ANGLE = (0x10000 / (2 * PI)).toFloat()
|
||||||
|
|
||||||
|
fun angleToRad(angle: Int): Float {
|
||||||
|
return angle * ANGLE_TO_RAD
|
||||||
|
}
|
||||||
|
|
||||||
|
fun radToAngle(rad: Float): Int {
|
||||||
|
return round(rad * RAD_TO_ANGLE).toInt()
|
||||||
|
}
|
@ -0,0 +1,126 @@
|
|||||||
|
package world.phantasmal.lib.fileFormats.ninja
|
||||||
|
|
||||||
|
import world.phantasmal.core.Failure
|
||||||
|
import world.phantasmal.core.PwResult
|
||||||
|
import world.phantasmal.core.Success
|
||||||
|
import world.phantasmal.lib.cursor.Cursor
|
||||||
|
import world.phantasmal.lib.fileFormats.Vec3
|
||||||
|
import world.phantasmal.lib.fileFormats.parseIff
|
||||||
|
import world.phantasmal.lib.fileFormats.vec3F32
|
||||||
|
|
||||||
|
private const val NJCM: UInt = 0x4D434A4Eu
|
||||||
|
|
||||||
|
class NjObject<Model>(
|
||||||
|
val evaluationFlags: NjEvaluationFlags,
|
||||||
|
val model: Model?,
|
||||||
|
val position: Vec3,
|
||||||
|
/**
|
||||||
|
* Euler angles in radians.
|
||||||
|
*/
|
||||||
|
val rotation: Vec3,
|
||||||
|
val scale: Vec3,
|
||||||
|
val children: List<NjObject<Model>>,
|
||||||
|
)
|
||||||
|
|
||||||
|
class NjEvaluationFlags(
|
||||||
|
val noTranslate: Boolean,
|
||||||
|
val noRotate: Boolean,
|
||||||
|
val noScale: Boolean,
|
||||||
|
val hidden: Boolean,
|
||||||
|
val breakChildTrace: Boolean,
|
||||||
|
val zxyRotationOrder: Boolean,
|
||||||
|
val skip: Boolean,
|
||||||
|
val shapeSkip: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun parseNj(cursor: Cursor): PwResult<List<NjObject<NjcmModel>>> =
|
||||||
|
parseNinja(cursor, ::parseNjcmModel, mutableMapOf())
|
||||||
|
|
||||||
|
private fun <Model, Context> parseNinja(
|
||||||
|
cursor: Cursor,
|
||||||
|
parse_model: (cursor: Cursor, context: Context) -> Model,
|
||||||
|
context: Context,
|
||||||
|
): PwResult<List<NjObject<Model>>> =
|
||||||
|
when (val parseIffResult = parseIff(cursor)) {
|
||||||
|
is Failure -> parseIffResult
|
||||||
|
is Success -> {
|
||||||
|
// POF0 and other chunks types are ignored.
|
||||||
|
val njcmChunks = parseIffResult.value.filter { chunk -> chunk.type == NJCM }
|
||||||
|
val objects: MutableList<NjObject<Model>> = mutableListOf()
|
||||||
|
|
||||||
|
for (chunk in njcmChunks) {
|
||||||
|
objects.addAll(parseSiblingObjects(chunk.data, parse_model, context))
|
||||||
|
}
|
||||||
|
|
||||||
|
Success(objects, parseIffResult.problems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: cache model and object offsets so we don't reparse the same data.
|
||||||
|
private fun <Model, Context> parseSiblingObjects(
|
||||||
|
cursor: Cursor,
|
||||||
|
parse_model: (cursor: Cursor, context: Context) -> Model,
|
||||||
|
context: Context,
|
||||||
|
): List<NjObject<Model>> {
|
||||||
|
val evalFlags = cursor.u32()
|
||||||
|
val noTranslate = (evalFlags and 0b1u) != 0u
|
||||||
|
val noRotate = (evalFlags and 0b10u) != 0u
|
||||||
|
val noScale = (evalFlags and 0b100u) != 0u
|
||||||
|
val hidden = (evalFlags and 0b1000u) != 0u
|
||||||
|
val breakChildTrace = (evalFlags and 0b10000u) != 0u
|
||||||
|
val zxyRotationOrder = (evalFlags and 0b100000u) != 0u
|
||||||
|
val skip = (evalFlags and 0b1000000u) != 0u
|
||||||
|
val shapeSkip = (evalFlags and 0b10000000u) != 0u
|
||||||
|
|
||||||
|
val modelOffset = cursor.u32()
|
||||||
|
val pos = cursor.vec3F32()
|
||||||
|
val rotation = Vec3(
|
||||||
|
angleToRad(cursor.i32()),
|
||||||
|
angleToRad(cursor.i32()),
|
||||||
|
angleToRad(cursor.i32()),
|
||||||
|
)
|
||||||
|
val scale = cursor.vec3F32()
|
||||||
|
val childOffset = cursor.u32()
|
||||||
|
val siblingOffset = cursor.u32()
|
||||||
|
|
||||||
|
val model = if (modelOffset == 0u) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
cursor.seekStart(modelOffset)
|
||||||
|
parse_model(cursor, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
val children = if (childOffset == 0u) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
cursor.seekStart(childOffset)
|
||||||
|
parseSiblingObjects(cursor, parse_model, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
val siblings = if (siblingOffset == 0u) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
cursor.seekStart(siblingOffset)
|
||||||
|
parseSiblingObjects(cursor, parse_model, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
val obj = NjObject(
|
||||||
|
NjEvaluationFlags(
|
||||||
|
noTranslate,
|
||||||
|
noRotate,
|
||||||
|
noScale,
|
||||||
|
hidden,
|
||||||
|
breakChildTrace,
|
||||||
|
zxyRotationOrder,
|
||||||
|
skip,
|
||||||
|
shapeSkip,
|
||||||
|
),
|
||||||
|
model,
|
||||||
|
pos,
|
||||||
|
rotation,
|
||||||
|
scale,
|
||||||
|
children,
|
||||||
|
)
|
||||||
|
|
||||||
|
return listOf(obj) + siblings
|
||||||
|
}
|
@ -0,0 +1,555 @@
|
|||||||
|
package world.phantasmal.lib.fileFormats.ninja
|
||||||
|
|
||||||
|
import mu.KotlinLogging
|
||||||
|
import world.phantasmal.lib.cursor.Cursor
|
||||||
|
import world.phantasmal.lib.fileFormats.Vec2
|
||||||
|
import world.phantasmal.lib.fileFormats.Vec3
|
||||||
|
import world.phantasmal.lib.fileFormats.vec3F32
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
// TODO:
|
||||||
|
// - colors
|
||||||
|
// - bump maps
|
||||||
|
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
private const val ZERO_UBYTE: UByte = 0u
|
||||||
|
|
||||||
|
class NjcmModel(
|
||||||
|
/**
|
||||||
|
* Sparse list of vertices.
|
||||||
|
*/
|
||||||
|
val vertices: List<NjcmVertex>,
|
||||||
|
val meshes: List<NjcmTriangleStrip>,
|
||||||
|
val collisionSphereCenter: Vec3,
|
||||||
|
val collisionSphereRadius: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
class NjcmVertex(
|
||||||
|
val position: Vec3,
|
||||||
|
val normal: Vec3?,
|
||||||
|
val boneWeight: Float,
|
||||||
|
val boneWeightStatus: UByte,
|
||||||
|
val calcContinue: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
class NjcmTriangleStrip(
|
||||||
|
val ignoreLight: Boolean,
|
||||||
|
val ignoreSpecular: Boolean,
|
||||||
|
val ignoreAmbient: Boolean,
|
||||||
|
val useAlpha: Boolean,
|
||||||
|
val doubleSide: Boolean,
|
||||||
|
val flatShading: Boolean,
|
||||||
|
val environmentMapping: Boolean,
|
||||||
|
val clockwiseWinding: Boolean,
|
||||||
|
val hasTexCoords: Boolean,
|
||||||
|
val hasNormal: Boolean,
|
||||||
|
var textureId: UInt?,
|
||||||
|
var srcAlpha: UByte?,
|
||||||
|
var dstAlpha: UByte?,
|
||||||
|
val vertices: List<NjcmMeshVertex>,
|
||||||
|
)
|
||||||
|
|
||||||
|
class NjcmMeshVertex(
|
||||||
|
val index: UShort,
|
||||||
|
val normal: Vec3?,
|
||||||
|
val texCoords: Vec2?,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class NjcmChunk(val typeId: UByte) {
|
||||||
|
class Unknown(typeId: UByte) : NjcmChunk(typeId)
|
||||||
|
|
||||||
|
object Null : NjcmChunk(0u)
|
||||||
|
|
||||||
|
class Bits(typeId: UByte, val srcAlpha: UByte, val dstAlpha: UByte) : NjcmChunk(typeId)
|
||||||
|
|
||||||
|
class CachePolygonList(val cacheIndex: UByte, val offset: UInt) : NjcmChunk(4u)
|
||||||
|
|
||||||
|
class DrawPolygonList(val cacheIndex: UByte) : NjcmChunk(5u)
|
||||||
|
|
||||||
|
class Tiny(
|
||||||
|
typeId: UByte,
|
||||||
|
val flipU: Boolean,
|
||||||
|
val flipV: Boolean,
|
||||||
|
val clampU: Boolean,
|
||||||
|
val clampV: Boolean,
|
||||||
|
val mipmapDAdjust: UInt,
|
||||||
|
val filterMode: UInt,
|
||||||
|
val superSample: Boolean,
|
||||||
|
val textureId: UInt,
|
||||||
|
) : NjcmChunk(typeId)
|
||||||
|
|
||||||
|
class Material(
|
||||||
|
typeId: UByte,
|
||||||
|
val srcAlpha: UByte,
|
||||||
|
val dstAlpha: UByte,
|
||||||
|
val diffuse: NjcmArgb?,
|
||||||
|
val ambient: NjcmArgb?,
|
||||||
|
val specular: NjcmErgb?,
|
||||||
|
) : NjcmChunk(typeId)
|
||||||
|
|
||||||
|
class Vertex(typeId: UByte, val vertices: List<NjcmChunkVertex>) : NjcmChunk(typeId)
|
||||||
|
|
||||||
|
class Volume(typeId: UByte) : NjcmChunk(typeId)
|
||||||
|
|
||||||
|
class Strip(typeId: UByte, val triangleStrips: List<NjcmTriangleStrip>) : NjcmChunk(typeId)
|
||||||
|
|
||||||
|
object End : NjcmChunk(255u)
|
||||||
|
}
|
||||||
|
|
||||||
|
class NjcmChunkVertex(
|
||||||
|
val index: Int,
|
||||||
|
val position: Vec3,
|
||||||
|
val normal: Vec3?,
|
||||||
|
val boneWeight: Float,
|
||||||
|
val boneWeightStatus: UByte,
|
||||||
|
val calcContinue: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channels are in range [0, 1].
|
||||||
|
*/
|
||||||
|
class NjcmArgb(
|
||||||
|
val a: Float,
|
||||||
|
val r: Float,
|
||||||
|
val g: Float,
|
||||||
|
val b: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
class NjcmErgb(
|
||||||
|
val e: UByte,
|
||||||
|
val r: UByte,
|
||||||
|
val g: UByte,
|
||||||
|
val b: UByte,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun parseNjcmModel(cursor: Cursor, cachedChunkOffsets: MutableMap<UByte, UInt>): NjcmModel {
|
||||||
|
val vlistOffset = cursor.u32() // Vertex list
|
||||||
|
val plistOffset = cursor.u32() // Triangle strip index list
|
||||||
|
val boundingSphereCenter = cursor.vec3F32()
|
||||||
|
val boundingSphereRadius = cursor.f32()
|
||||||
|
val vertices: MutableList<NjcmVertex> = mutableListOf()
|
||||||
|
val meshes: MutableList<NjcmTriangleStrip> = mutableListOf()
|
||||||
|
|
||||||
|
if (vlistOffset != 0u) {
|
||||||
|
cursor.seekStart(vlistOffset)
|
||||||
|
|
||||||
|
for (chunk in parseChunks(cursor, cachedChunkOffsets, true)) {
|
||||||
|
if (chunk is NjcmChunk.Vertex) {
|
||||||
|
for (vertex in chunk.vertices) {
|
||||||
|
vertices[vertex.index] = NjcmVertex(
|
||||||
|
vertex.position,
|
||||||
|
vertex.normal,
|
||||||
|
vertex.boneWeight,
|
||||||
|
vertex.boneWeightStatus,
|
||||||
|
vertex.calcContinue,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plistOffset != 0u) {
|
||||||
|
cursor.seekStart(plistOffset)
|
||||||
|
|
||||||
|
var textureId: UInt? = null
|
||||||
|
var srcAlpha: UByte? = null
|
||||||
|
var dstAlpha: UByte? = null
|
||||||
|
|
||||||
|
for (chunk in parseChunks(cursor, cachedChunkOffsets, false)) {
|
||||||
|
when (chunk) {
|
||||||
|
is NjcmChunk.Bits -> {
|
||||||
|
srcAlpha = chunk.srcAlpha
|
||||||
|
dstAlpha = chunk.dstAlpha
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
is NjcmChunk.Tiny -> {
|
||||||
|
textureId = chunk.textureId
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
is NjcmChunk.Material -> {
|
||||||
|
srcAlpha = chunk.srcAlpha
|
||||||
|
dstAlpha = chunk.dstAlpha
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
is NjcmChunk.Strip -> {
|
||||||
|
for (strip in chunk.triangleStrips) {
|
||||||
|
strip.textureId = textureId
|
||||||
|
strip.srcAlpha = srcAlpha
|
||||||
|
strip.dstAlpha = dstAlpha
|
||||||
|
}
|
||||||
|
|
||||||
|
meshes.addAll(chunk.triangleStrips)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NjcmModel(
|
||||||
|
vertices,
|
||||||
|
meshes,
|
||||||
|
boundingSphereCenter,
|
||||||
|
boundingSphereRadius,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: don't reparse when DrawPolygonList chunk is encountered.
|
||||||
|
private fun parseChunks(
|
||||||
|
cursor: Cursor,
|
||||||
|
cachedChunkOffsets: MutableMap<UByte, UInt>,
|
||||||
|
wideEndChunks: Boolean,
|
||||||
|
): List<NjcmChunk> {
|
||||||
|
val chunks: MutableList<NjcmChunk> = mutableListOf()
|
||||||
|
var loop = true
|
||||||
|
|
||||||
|
while (loop) {
|
||||||
|
val typeId = cursor.u8()
|
||||||
|
val flags = cursor.u8()
|
||||||
|
val flagsUInt = flags.toUInt()
|
||||||
|
val chunkStartPosition = cursor.position
|
||||||
|
var size = 0u
|
||||||
|
|
||||||
|
when (typeId.toInt()) {
|
||||||
|
0 -> {
|
||||||
|
chunks.add(NjcmChunk.Null)
|
||||||
|
}
|
||||||
|
in 1..3 -> {
|
||||||
|
chunks.add(NjcmChunk.Bits(
|
||||||
|
typeId,
|
||||||
|
srcAlpha = ((flagsUInt shr 3).toUByte() and 0b111u),
|
||||||
|
dstAlpha = flags and 0b111u,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
4 -> {
|
||||||
|
val cacheIndex = flags
|
||||||
|
val offset = cursor.position
|
||||||
|
|
||||||
|
chunks.add(NjcmChunk.CachePolygonList(
|
||||||
|
cacheIndex,
|
||||||
|
offset,
|
||||||
|
))
|
||||||
|
|
||||||
|
cachedChunkOffsets[cacheIndex] = offset
|
||||||
|
loop = false
|
||||||
|
}
|
||||||
|
5 -> {
|
||||||
|
val cacheIndex = flags
|
||||||
|
val cachedOffset = cachedChunkOffsets[cacheIndex]
|
||||||
|
|
||||||
|
if (cachedOffset != null) {
|
||||||
|
cursor.seekStart(cachedOffset)
|
||||||
|
chunks.addAll(parseChunks(cursor, cachedChunkOffsets, wideEndChunks))
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.add(NjcmChunk.DrawPolygonList(
|
||||||
|
cacheIndex,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
in 8..9 -> {
|
||||||
|
size = 2u
|
||||||
|
val textureBitsAndId = cursor.u16().toUInt()
|
||||||
|
|
||||||
|
chunks.add(NjcmChunk.Tiny(
|
||||||
|
typeId,
|
||||||
|
flipU = (typeId.toUInt() and 0x80u) != 0u,
|
||||||
|
flipV = (typeId.toUInt() and 0x40u) != 0u,
|
||||||
|
clampU = (typeId.toUInt() and 0x20u) != 0u,
|
||||||
|
clampV = (typeId.toUInt() and 0x10u) != 0u,
|
||||||
|
mipmapDAdjust = typeId.toUInt() and 0b1111u,
|
||||||
|
filterMode = textureBitsAndId shr 14,
|
||||||
|
superSample = (textureBitsAndId and 0x40u) != 0u,
|
||||||
|
textureId = textureBitsAndId and 0x1fffu,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
in 17..31 -> {
|
||||||
|
size = 2u + 2u * cursor.u16()
|
||||||
|
|
||||||
|
var diffuse: NjcmArgb? = null
|
||||||
|
var ambient: NjcmArgb? = null
|
||||||
|
var specular: NjcmErgb? = null
|
||||||
|
|
||||||
|
if ((flagsUInt and 0b1u) != 0u) {
|
||||||
|
diffuse = NjcmArgb(
|
||||||
|
b = cursor.u8().toFloat() / 255f,
|
||||||
|
g = cursor.u8().toFloat() / 255f,
|
||||||
|
r = cursor.u8().toFloat() / 255f,
|
||||||
|
a = cursor.u8().toFloat() / 255f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((flagsUInt and 0b10u) != 0u) {
|
||||||
|
ambient = NjcmArgb(
|
||||||
|
b = cursor.u8().toFloat() / 255f,
|
||||||
|
g = cursor.u8().toFloat() / 255f,
|
||||||
|
r = cursor.u8().toFloat() / 255f,
|
||||||
|
a = cursor.u8().toFloat() / 255f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((flagsUInt and 0b100u) != 0u) {
|
||||||
|
specular = NjcmErgb(
|
||||||
|
b = cursor.u8(),
|
||||||
|
g = cursor.u8(),
|
||||||
|
r = cursor.u8(),
|
||||||
|
e = cursor.u8(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.add(NjcmChunk.Material(
|
||||||
|
typeId,
|
||||||
|
srcAlpha = ((flagsUInt shr 3).toUByte() and 0b111u),
|
||||||
|
dstAlpha = flags and 0b111u,
|
||||||
|
diffuse,
|
||||||
|
ambient,
|
||||||
|
specular,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
in 32..50 -> {
|
||||||
|
size = 2u + 4u * cursor.u16()
|
||||||
|
chunks.add(NjcmChunk.Vertex(
|
||||||
|
typeId,
|
||||||
|
vertices = parseVertexChunk(cursor, typeId, flags),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
in 56..58 -> {
|
||||||
|
size = 2u + 2u * cursor.u16()
|
||||||
|
chunks.add(NjcmChunk.Volume(
|
||||||
|
typeId,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
in 64..75 -> {
|
||||||
|
size = 2u + 2u * cursor.u16()
|
||||||
|
chunks.add(NjcmChunk.Strip(
|
||||||
|
typeId,
|
||||||
|
triangleStrips = parseTriangleStripChunk(cursor, typeId, flags),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
255 -> {
|
||||||
|
size = if (wideEndChunks) 2u else 0u
|
||||||
|
chunks.add(NjcmChunk.End)
|
||||||
|
loop = false
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
size = 2u + 2u * cursor.u16()
|
||||||
|
chunks.add(NjcmChunk.Unknown(
|
||||||
|
typeId,
|
||||||
|
))
|
||||||
|
logger.warn { "Unknown chunk type $typeId at offset ${chunkStartPosition}." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.seekStart(chunkStartPosition + size)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseVertexChunk(
|
||||||
|
cursor: Cursor,
|
||||||
|
chunkTypeId: UByte,
|
||||||
|
flags: UByte,
|
||||||
|
): List<NjcmChunkVertex> {
|
||||||
|
val boneWeightStatus = flags and 0b11u
|
||||||
|
val calcContinue = (flags and 0x80u) != ZERO_UBYTE
|
||||||
|
|
||||||
|
val index = cursor.u16()
|
||||||
|
val vertexCount = cursor.u16()
|
||||||
|
|
||||||
|
val vertices: MutableList<NjcmChunkVertex> = mutableListOf()
|
||||||
|
|
||||||
|
for (i in (0u).toUShort() until vertexCount) {
|
||||||
|
var vertexIndex = index + i
|
||||||
|
val position = cursor.vec3F32()
|
||||||
|
var normal: Vec3? = null
|
||||||
|
var boneWeight = 1f
|
||||||
|
|
||||||
|
when (chunkTypeId.toInt()) {
|
||||||
|
32 -> {
|
||||||
|
// NJDCVSH
|
||||||
|
cursor.seek(4) // Always 1.0
|
||||||
|
}
|
||||||
|
33 -> {
|
||||||
|
// NJDCVVNSH
|
||||||
|
cursor.seek(4) // Always 1.0
|
||||||
|
normal = cursor.vec3F32()
|
||||||
|
cursor.seek(4) // Always 0.0
|
||||||
|
}
|
||||||
|
in 35..40 -> {
|
||||||
|
if (chunkTypeId == (37u).toUByte()) {
|
||||||
|
// NJDCVNF
|
||||||
|
// NinjaFlags32
|
||||||
|
vertexIndex = index + cursor.u16()
|
||||||
|
boneWeight = cursor.u16().toFloat() / 255f
|
||||||
|
} else {
|
||||||
|
// Skip user flags and material information.
|
||||||
|
cursor.seek(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
41 -> {
|
||||||
|
normal = cursor.vec3F32()
|
||||||
|
}
|
||||||
|
in 42..47 -> {
|
||||||
|
normal = cursor.vec3F32()
|
||||||
|
|
||||||
|
if (chunkTypeId == (44u).toUByte()) {
|
||||||
|
// NJDCVVNNF
|
||||||
|
// NinjaFlags32
|
||||||
|
vertexIndex = index + cursor.u16()
|
||||||
|
boneWeight = cursor.u16().toFloat() / 255f
|
||||||
|
} else {
|
||||||
|
// Skip user flags and material information.
|
||||||
|
cursor.seek(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in 48..50 -> {
|
||||||
|
// 32-Bit vertex normal in format: reserved(2)|x(10)|y(10)|z(10)
|
||||||
|
val n = cursor.u32()
|
||||||
|
normal = Vec3(
|
||||||
|
((n shr 20) and 0x3ffu).toFloat() / 0x3ff,
|
||||||
|
((n shr 10) and 0x3ffu).toFloat() / 0x3ff,
|
||||||
|
(n and 0x3ffu).toFloat() / 0x3ff,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (chunkTypeId >= 49u) {
|
||||||
|
// Skip user flags and material information.
|
||||||
|
cursor.seek(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vertices.add(NjcmChunkVertex(
|
||||||
|
vertexIndex.toInt(),
|
||||||
|
position,
|
||||||
|
normal,
|
||||||
|
boneWeight,
|
||||||
|
boneWeightStatus,
|
||||||
|
calcContinue,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return vertices
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseTriangleStripChunk(
|
||||||
|
cursor: Cursor,
|
||||||
|
chunkTypeId: UByte,
|
||||||
|
flags: UByte,
|
||||||
|
): List<NjcmTriangleStrip> {
|
||||||
|
val ignoreLight = (flags and 0b1u) != ZERO_UBYTE
|
||||||
|
val ignoreSpecular = (flags and 0b10u) != ZERO_UBYTE
|
||||||
|
val ignoreAmbient = (flags and 0b100u) != ZERO_UBYTE
|
||||||
|
val useAlpha = (flags and 0b1000u) != ZERO_UBYTE
|
||||||
|
val doubleSide = (flags and 0b10000u) != ZERO_UBYTE
|
||||||
|
val flatShading = (flags and 0b100000u) != ZERO_UBYTE
|
||||||
|
val environmentMapping = (flags and 0b1000000u) != ZERO_UBYTE
|
||||||
|
|
||||||
|
val userOffsetAndStripCount = cursor.u16()
|
||||||
|
val userFlagsSize = (userOffsetAndStripCount.toUInt() shr 14).toInt()
|
||||||
|
val stripCount = userOffsetAndStripCount and 0x3fffu
|
||||||
|
|
||||||
|
var hasTexCoords = false
|
||||||
|
var hasColor = false
|
||||||
|
var hasNormal = false
|
||||||
|
var hasDoubleTexCoords = false
|
||||||
|
|
||||||
|
when (chunkTypeId.toInt()) {
|
||||||
|
64 -> {
|
||||||
|
}
|
||||||
|
65, 66 -> {
|
||||||
|
hasTexCoords = true
|
||||||
|
}
|
||||||
|
67 -> {
|
||||||
|
hasNormal = true
|
||||||
|
}
|
||||||
|
68, 69 -> {
|
||||||
|
hasTexCoords = true
|
||||||
|
hasNormal = true
|
||||||
|
}
|
||||||
|
70 -> {
|
||||||
|
hasColor = true
|
||||||
|
}
|
||||||
|
71, 72 -> {
|
||||||
|
hasTexCoords = true
|
||||||
|
hasColor = true
|
||||||
|
}
|
||||||
|
73 -> {
|
||||||
|
}
|
||||||
|
74, 75 -> {
|
||||||
|
hasDoubleTexCoords = true
|
||||||
|
}
|
||||||
|
else -> error("Unexpected chunk type ID: ${chunkTypeId}.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val strips: MutableList<NjcmTriangleStrip> = mutableListOf()
|
||||||
|
|
||||||
|
repeat(stripCount.toInt()) {
|
||||||
|
val windingFlagAndIndexCount = cursor.i16()
|
||||||
|
val clockwiseWinding = windingFlagAndIndexCount < 1
|
||||||
|
val indexCount = abs(windingFlagAndIndexCount.toInt())
|
||||||
|
|
||||||
|
val vertices: MutableList<NjcmMeshVertex> = mutableListOf()
|
||||||
|
|
||||||
|
for (j in 0..indexCount) {
|
||||||
|
val index = cursor.u16()
|
||||||
|
|
||||||
|
val texCoords = if (hasTexCoords) {
|
||||||
|
Vec2(cursor.u16().toFloat() / 255f, cursor.u16().toFloat() / 255f)
|
||||||
|
} else null
|
||||||
|
|
||||||
|
// Ignore ARGB8888 color.
|
||||||
|
if (hasColor) {
|
||||||
|
cursor.seek(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
val normal = if (hasNormal) {
|
||||||
|
Vec3(
|
||||||
|
cursor.u16().toFloat() / 255f,
|
||||||
|
cursor.u16().toFloat() / 255f,
|
||||||
|
cursor.u16().toFloat() / 255f,
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
|
||||||
|
// Ignore double texture coordinates (Ua, Vb, Ua, Vb).
|
||||||
|
if (hasDoubleTexCoords) {
|
||||||
|
cursor.seek(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User flags start at the third vertex because they are per-triangle.
|
||||||
|
if (j >= 2) {
|
||||||
|
cursor.seek(2 * userFlagsSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
vertices.add(NjcmMeshVertex(
|
||||||
|
index,
|
||||||
|
normal,
|
||||||
|
texCoords,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
strips.add(NjcmTriangleStrip(
|
||||||
|
ignoreLight,
|
||||||
|
ignoreSpecular,
|
||||||
|
ignoreAmbient,
|
||||||
|
useAlpha,
|
||||||
|
doubleSide,
|
||||||
|
flatShading,
|
||||||
|
environmentMapping,
|
||||||
|
clockwiseWinding,
|
||||||
|
hasTexCoords,
|
||||||
|
hasNormal,
|
||||||
|
textureId = null,
|
||||||
|
srcAlpha = null,
|
||||||
|
dstAlpha = null,
|
||||||
|
vertices,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strips
|
||||||
|
}
|
@ -0,0 +1,2 @@
|
|||||||
|
package world.phantasmal.lib.fileFormats.ninja
|
||||||
|
|
@ -1,4 +1,4 @@
|
|||||||
package world.phantasmal.lib.fileformats.quest
|
package world.phantasmal.lib.fileFormats.quest
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a configurable property for accessing parts of entity data of which the use is not
|
* Represents a configurable property for accessing parts of entity data of which the use is not
|
@ -1,4 +1,4 @@
|
|||||||
package world.phantasmal.lib.fileformats.quest
|
package world.phantasmal.lib.fileFormats.quest
|
||||||
|
|
||||||
interface EntityType {
|
interface EntityType {
|
||||||
/**
|
/**
|
@ -1,4 +1,4 @@
|
|||||||
package world.phantasmal.lib.fileformats.quest
|
package world.phantasmal.lib.fileFormats.quest
|
||||||
|
|
||||||
enum class Episode {
|
enum class Episode {
|
||||||
I,
|
I,
|
@ -1,4 +1,4 @@
|
|||||||
package world.phantasmal.lib.fileformats.quest
|
package world.phantasmal.lib.fileFormats.quest
|
||||||
|
|
||||||
private val FRIENDLY_NPC_PROPERTIES = listOf(
|
private val FRIENDLY_NPC_PROPERTIES = listOf(
|
||||||
EntityProp(name = "Movement distance", offset = 44, type = EntityPropType.F32),
|
EntityProp(name = "Movement distance", offset = 44, type = EntityPropType.F32),
|
@ -1,16 +1,16 @@
|
|||||||
package world.phantasmal.observable.value
|
package world.phantasmal.observable.value
|
||||||
|
|
||||||
import world.phantasmal.core.disposable.Disposable
|
|
||||||
import world.phantasmal.core.disposable.DisposableScope
|
import world.phantasmal.core.disposable.DisposableScope
|
||||||
import world.phantasmal.core.disposable.Scope
|
import world.phantasmal.core.disposable.Scope
|
||||||
import world.phantasmal.core.disposable.disposable
|
import world.phantasmal.core.disposable.disposable
|
||||||
import world.phantasmal.core.fastCast
|
import world.phantasmal.core.fastCast
|
||||||
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
|
|
||||||
class DependentVal<T>(
|
class DependentVal<T>(
|
||||||
private val dependencies: Iterable<Val<*>>,
|
private val dependencies: Iterable<Val<*>>,
|
||||||
private val operation: () -> T,
|
private val operation: () -> T,
|
||||||
) : AbstractVal<T>() {
|
) : AbstractVal<T>() {
|
||||||
private var dependencyScope = DisposableScope()
|
private var dependencyScope = DisposableScope(EmptyCoroutineContext)
|
||||||
private var internalValue: T? = null
|
private var internalValue: T? = null
|
||||||
|
|
||||||
override val value: T
|
override val value: T
|
||||||
|
@ -6,13 +6,14 @@ import world.phantasmal.core.disposable.disposable
|
|||||||
import world.phantasmal.core.fastCast
|
import world.phantasmal.core.fastCast
|
||||||
import world.phantasmal.observable.value.AbstractVal
|
import world.phantasmal.observable.value.AbstractVal
|
||||||
import world.phantasmal.observable.value.ValObserver
|
import world.phantasmal.observable.value.ValObserver
|
||||||
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
|
|
||||||
class FoldedVal<T, R>(
|
class FoldedVal<T, R>(
|
||||||
private val dependency: ListVal<T>,
|
private val dependency: ListVal<T>,
|
||||||
private val initial: R,
|
private val initial: R,
|
||||||
private val operation: (R, T) -> R,
|
private val operation: (R, T) -> R,
|
||||||
) : AbstractVal<R>() {
|
) : AbstractVal<R>() {
|
||||||
private var dependencyDisposable = DisposableScope()
|
private var dependencyDisposable = DisposableScope(EmptyCoroutineContext)
|
||||||
private var internalValue: R? = null
|
private var internalValue: R? = null
|
||||||
|
|
||||||
override val value: R
|
override val value: R
|
||||||
|
@ -6,6 +6,7 @@ import world.phantasmal.core.disposable.disposable
|
|||||||
import world.phantasmal.observable.Observable
|
import world.phantasmal.observable.Observable
|
||||||
import world.phantasmal.observable.Observer
|
import world.phantasmal.observable.Observer
|
||||||
import world.phantasmal.observable.value.*
|
import world.phantasmal.observable.value.*
|
||||||
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
|
|
||||||
typealias ObservablesExtractor<E> = (element: E) -> Array<Observable<*>>
|
typealias ObservablesExtractor<E> = (element: E) -> Array<Observable<*>>
|
||||||
|
|
||||||
@ -180,7 +181,7 @@ class SimpleListVal<E>(
|
|||||||
observables: Array<Observable<*>>,
|
observables: Array<Observable<*>>,
|
||||||
) {
|
) {
|
||||||
val observers: Array<DisposableScope> = Array(observables.size) {
|
val observers: Array<DisposableScope> = Array(observables.size) {
|
||||||
val scope = DisposableScope()
|
val scope = DisposableScope(EmptyCoroutineContext)
|
||||||
observables[it].observe(scope) {
|
observables[it].observe(scope) {
|
||||||
finalizeUpdate(
|
finalizeUpdate(
|
||||||
ListValChangeEvent.ElementChange(
|
ListValChangeEvent.ElementChange(
|
||||||
|
@ -2,9 +2,10 @@ package world.phantasmal.observable.test
|
|||||||
|
|
||||||
import world.phantasmal.core.disposable.DisposableScope
|
import world.phantasmal.core.disposable.DisposableScope
|
||||||
import world.phantasmal.core.disposable.Scope
|
import world.phantasmal.core.disposable.Scope
|
||||||
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
|
|
||||||
fun withScope(block: (Scope) -> Unit) {
|
fun withScope(block: (Scope) -> Unit) {
|
||||||
val scope = DisposableScope()
|
val scope = DisposableScope(EmptyCoroutineContext)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
block(scope)
|
block(scope)
|
||||||
|
@ -3,6 +3,7 @@ package world.phantasmal.observable.value
|
|||||||
import world.phantasmal.core.disposable.Disposable
|
import world.phantasmal.core.disposable.Disposable
|
||||||
import world.phantasmal.core.disposable.Scope
|
import world.phantasmal.core.disposable.Scope
|
||||||
import world.phantasmal.testUtils.TestSuite
|
import world.phantasmal.testUtils.TestSuite
|
||||||
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
|
|
||||||
class StaticValTests : TestSuite() {
|
class StaticValTests : TestSuite() {
|
||||||
@ -16,6 +17,8 @@ class StaticValTests : TestSuite() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private object DummyScope : Scope {
|
private object DummyScope : Scope {
|
||||||
|
override val coroutineContext = EmptyCoroutineContext
|
||||||
|
|
||||||
override fun add(disposable: Disposable) {
|
override fun add(disposable: Disposable) {
|
||||||
throw NotImplementedError()
|
throw NotImplementedError()
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package world.phantasmal.testUtils
|
package world.phantasmal.testUtils
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import world.phantasmal.core.disposable.DisposableScope
|
import world.phantasmal.core.disposable.DisposableScope
|
||||||
import world.phantasmal.core.disposable.Scope
|
import world.phantasmal.core.disposable.Scope
|
||||||
import world.phantasmal.core.disposable.TrackedDisposable
|
import world.phantasmal.core.disposable.TrackedDisposable
|
||||||
@ -16,7 +17,7 @@ abstract class TestSuite {
|
|||||||
@BeforeTest
|
@BeforeTest
|
||||||
fun before() {
|
fun before() {
|
||||||
initialDisposableCount = TrackedDisposable.disposableCount
|
initialDisposableCount = TrackedDisposable.disposableCount
|
||||||
_scope = DisposableScope()
|
_scope = DisposableScope(Job())
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterTest
|
@AfterTest
|
||||||
|
@ -27,7 +27,6 @@ kotlin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val coroutinesVersion: String by project.ext
|
|
||||||
val kotlinLoggingVersion: String by project.extra
|
val kotlinLoggingVersion: String by project.extra
|
||||||
val ktorVersion: String by project.extra
|
val ktorVersion: String by project.extra
|
||||||
|
|
||||||
|
@ -5,7 +5,6 @@ import io.ktor.client.features.json.*
|
|||||||
import io.ktor.client.features.json.serializer.*
|
import io.ktor.client.features.json.serializer.*
|
||||||
import kotlinx.browser.document
|
import kotlinx.browser.document
|
||||||
import kotlinx.browser.window
|
import kotlinx.browser.window
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import org.w3c.dom.PopStateEvent
|
import org.w3c.dom.PopStateEvent
|
||||||
import world.phantasmal.core.disposable.Disposable
|
import world.phantasmal.core.disposable.Disposable
|
||||||
@ -30,10 +29,7 @@ fun main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun init(): Disposable {
|
private fun init(): Disposable {
|
||||||
val scope = DisposableScope()
|
val scope = DisposableScope(UiDispatcher)
|
||||||
|
|
||||||
val crScope = CoroutineScope(UiDispatcher)
|
|
||||||
scope.disposable { crScope.cancel() }
|
|
||||||
|
|
||||||
val rootElement = document.body!!.root()
|
val rootElement = document.body!!.root()
|
||||||
|
|
||||||
@ -52,7 +48,6 @@ private fun init(): Disposable {
|
|||||||
|
|
||||||
Application(
|
Application(
|
||||||
scope,
|
scope,
|
||||||
crScope,
|
|
||||||
rootElement,
|
rootElement,
|
||||||
HttpAssetLoader(httpClient, basePath),
|
HttpAssetLoader(httpClient, basePath),
|
||||||
HistoryApplicationUrl(scope),
|
HistoryApplicationUrl(scope),
|
||||||
|
@ -24,7 +24,6 @@ import world.phantasmal.webui.dom.disposableListener
|
|||||||
|
|
||||||
class Application(
|
class Application(
|
||||||
scope: Scope,
|
scope: Scope,
|
||||||
crScope: CoroutineScope,
|
|
||||||
rootElement: HTMLElement,
|
rootElement: HTMLElement,
|
||||||
assetLoader: AssetLoader,
|
assetLoader: AssetLoader,
|
||||||
applicationUrl: ApplicationUrl,
|
applicationUrl: ApplicationUrl,
|
||||||
@ -43,7 +42,7 @@ class Application(
|
|||||||
disposableListener(scope, document, "drop", ::drop)
|
disposableListener(scope, document, "drop", ::drop)
|
||||||
|
|
||||||
// Initialize core stores shared by several submodules.
|
// Initialize core stores shared by several submodules.
|
||||||
val uiStore = UiStore(scope, crScope, applicationUrl)
|
val uiStore = UiStore(scope, applicationUrl)
|
||||||
|
|
||||||
// Controllers.
|
// Controllers.
|
||||||
val navigationController = NavigationController(scope, uiStore)
|
val navigationController = NavigationController(scope, uiStore)
|
||||||
@ -55,10 +54,10 @@ class Application(
|
|||||||
NavigationWidget(scope, navigationController),
|
NavigationWidget(scope, navigationController),
|
||||||
MainContentWidget(scope, mainContentController, mapOf(
|
MainContentWidget(scope, mainContentController, mapOf(
|
||||||
PwTool.QuestEditor to { s ->
|
PwTool.QuestEditor to { s ->
|
||||||
QuestEditor(s, crScope, uiStore, createEngine).widget
|
QuestEditor(s, uiStore, createEngine).widget
|
||||||
},
|
},
|
||||||
PwTool.HuntOptimizer to { s ->
|
PwTool.HuntOptimizer to { s ->
|
||||||
HuntOptimizer(s, crScope, assetLoader, uiStore).widget
|
HuntOptimizer(s, assetLoader, uiStore).widget
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
|
@ -17,7 +17,7 @@ class MainContentWidget(
|
|||||||
override fun Node.createElement() = div(className = "pw-application-main-content") {
|
override fun Node.createElement() = div(className = "pw-application-main-content") {
|
||||||
ctrl.tools.forEach { (tool, active) ->
|
ctrl.tools.forEach { (tool, active) ->
|
||||||
toolViews[tool]?.let { createWidget ->
|
toolViews[tool]?.let { createWidget ->
|
||||||
addChild(LazyLoader(scope, hidden = !active, createWidget))
|
addChild(LazyLoader(scope, hidden = !active, createWidget = createWidget))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,7 @@ private fun style() = """
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pw-application-navigation-spacer {
|
.pw-application-navigation-spacer {
|
||||||
flex: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pw-application-navigation-server {
|
.pw-application-navigation-server {
|
||||||
|
@ -7,14 +7,14 @@ import world.phantasmal.web.core.stores.PwTool
|
|||||||
import world.phantasmal.webui.dom.input
|
import world.phantasmal.webui.dom.input
|
||||||
import world.phantasmal.webui.dom.label
|
import world.phantasmal.webui.dom.label
|
||||||
import world.phantasmal.webui.dom.span
|
import world.phantasmal.webui.dom.span
|
||||||
import world.phantasmal.webui.widgets.Widget
|
import world.phantasmal.webui.widgets.Control
|
||||||
|
|
||||||
class PwToolButton(
|
class PwToolButton(
|
||||||
scope: Scope,
|
scope: Scope,
|
||||||
private val tool: PwTool,
|
private val tool: PwTool,
|
||||||
private val toggled: Observable<Boolean>,
|
private val toggled: Observable<Boolean>,
|
||||||
private val mouseDown: () -> Unit,
|
private val mouseDown: () -> Unit,
|
||||||
) : Widget(scope, ::style) {
|
) : Control(scope, ::style) {
|
||||||
private val inputId = "pw-application-pw-tool-button-${tool.name.toLowerCase()}"
|
private val inputId = "pw-application-pw-tool-button-${tool.name.toLowerCase()}"
|
||||||
|
|
||||||
override fun Node.createElement() =
|
override fun Node.createElement() =
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package world.phantasmal.web.core.stores
|
package world.phantasmal.web.core.stores
|
||||||
|
|
||||||
import kotlinx.browser.window
|
import kotlinx.browser.window
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import org.w3c.dom.events.KeyboardEvent
|
import org.w3c.dom.events.KeyboardEvent
|
||||||
import world.phantasmal.core.disposable.Scope
|
import world.phantasmal.core.disposable.Scope
|
||||||
import world.phantasmal.observable.value.MutableVal
|
import world.phantasmal.observable.value.MutableVal
|
||||||
@ -28,11 +27,7 @@ interface ApplicationUrl {
|
|||||||
fun replaceUrl(url: String)
|
fun replaceUrl(url: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
class UiStore(
|
class UiStore(scope: Scope, private val applicationUrl: ApplicationUrl) : Store(scope) {
|
||||||
scope: Scope,
|
|
||||||
crScope: CoroutineScope,
|
|
||||||
private val applicationUrl: ApplicationUrl,
|
|
||||||
) : Store(scope, crScope) {
|
|
||||||
private val _currentTool: MutableVal<PwTool>
|
private val _currentTool: MutableVal<PwTool>
|
||||||
|
|
||||||
private val _path = mutableVal("")
|
private val _path = mutableVal("")
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
package world.phantasmal.web.huntOptimizer
|
package world.phantasmal.web.huntOptimizer
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import world.phantasmal.core.disposable.Scope
|
import world.phantasmal.core.disposable.Scope
|
||||||
import world.phantasmal.web.core.AssetLoader
|
import world.phantasmal.web.core.AssetLoader
|
||||||
import world.phantasmal.web.core.stores.UiStore
|
import world.phantasmal.web.core.stores.UiStore
|
||||||
@ -12,11 +11,10 @@ import world.phantasmal.web.huntOptimizer.widgets.MethodsWidget
|
|||||||
|
|
||||||
class HuntOptimizer(
|
class HuntOptimizer(
|
||||||
scope: Scope,
|
scope: Scope,
|
||||||
crScope: CoroutineScope,
|
|
||||||
assetLoader: AssetLoader,
|
assetLoader: AssetLoader,
|
||||||
uiStore: UiStore,
|
uiStore: UiStore,
|
||||||
) {
|
) {
|
||||||
private val huntMethodStore = HuntMethodStore(scope, crScope, uiStore, assetLoader)
|
private val huntMethodStore = HuntMethodStore(scope, uiStore, assetLoader)
|
||||||
|
|
||||||
private val huntOptimizerController = HuntOptimizerController(scope, uiStore)
|
private val huntOptimizerController = HuntOptimizerController(scope, uiStore)
|
||||||
private val methodsController = MethodsController(scope, uiStore, huntMethodStore)
|
private val methodsController = MethodsController(scope, uiStore, huntMethodStore)
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
package world.phantasmal.web.huntOptimizer.controllers
|
package world.phantasmal.web.huntOptimizer.controllers
|
||||||
|
|
||||||
import world.phantasmal.core.disposable.Scope
|
import world.phantasmal.core.disposable.Scope
|
||||||
import world.phantasmal.lib.fileformats.quest.Episode
|
import world.phantasmal.lib.fileFormats.quest.Episode
|
||||||
import world.phantasmal.observable.value.list.ListVal
|
import world.phantasmal.observable.value.list.ListVal
|
||||||
import world.phantasmal.observable.value.list.MutableListVal
|
import world.phantasmal.observable.value.list.MutableListVal
|
||||||
import world.phantasmal.observable.value.list.mutableListVal
|
import world.phantasmal.observable.value.list.mutableListVal
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
package world.phantasmal.web.huntOptimizer.models
|
package world.phantasmal.web.huntOptimizer.models
|
||||||
|
|
||||||
import world.phantasmal.lib.fileformats.quest.Episode
|
import world.phantasmal.lib.fileFormats.quest.Episode
|
||||||
import world.phantasmal.lib.fileformats.quest.NpcType
|
import world.phantasmal.lib.fileFormats.quest.NpcType
|
||||||
import world.phantasmal.observable.value.Val
|
import world.phantasmal.observable.value.Val
|
||||||
import world.phantasmal.observable.value.mutableVal
|
import world.phantasmal.observable.value.mutableVal
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
package world.phantasmal.web.huntOptimizer.models
|
package world.phantasmal.web.huntOptimizer.models
|
||||||
|
|
||||||
import world.phantasmal.lib.fileformats.quest.Episode
|
import world.phantasmal.lib.fileFormats.quest.Episode
|
||||||
import world.phantasmal.lib.fileformats.quest.NpcType
|
import world.phantasmal.lib.fileFormats.quest.NpcType
|
||||||
|
|
||||||
class SimpleQuestModel(
|
class SimpleQuestModel(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
package world.phantasmal.web.huntOptimizer.stores
|
package world.phantasmal.web.huntOptimizer.stores
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import world.phantasmal.core.disposable.Scope
|
import world.phantasmal.core.disposable.Scope
|
||||||
import world.phantasmal.lib.fileformats.quest.Episode
|
import world.phantasmal.lib.fileFormats.quest.Episode
|
||||||
import world.phantasmal.lib.fileformats.quest.NpcType
|
import world.phantasmal.lib.fileFormats.quest.NpcType
|
||||||
import world.phantasmal.observable.value.list.ListVal
|
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.AssetLoader
|
import world.phantasmal.web.core.AssetLoader
|
||||||
@ -23,10 +22,9 @@ import kotlin.time.minutes
|
|||||||
|
|
||||||
class HuntMethodStore(
|
class HuntMethodStore(
|
||||||
scope: Scope,
|
scope: Scope,
|
||||||
crScope: CoroutineScope,
|
|
||||||
uiStore: UiStore,
|
uiStore: UiStore,
|
||||||
private val assetLoader: AssetLoader,
|
private val assetLoader: AssetLoader,
|
||||||
) : Store(scope, crScope) {
|
) : Store(scope) {
|
||||||
private val _methods = mutableListVal<HuntMethodModel>()
|
private val _methods = mutableListVal<HuntMethodModel>()
|
||||||
|
|
||||||
val methods: ListVal<HuntMethodModel> by lazy {
|
val methods: ListVal<HuntMethodModel> by lazy {
|
||||||
|
@ -2,7 +2,7 @@ package world.phantasmal.web.huntOptimizer.widgets
|
|||||||
|
|
||||||
import org.w3c.dom.Node
|
import org.w3c.dom.Node
|
||||||
import world.phantasmal.core.disposable.Scope
|
import world.phantasmal.core.disposable.Scope
|
||||||
import world.phantasmal.lib.fileformats.quest.Episode
|
import world.phantasmal.lib.fileFormats.quest.Episode
|
||||||
import world.phantasmal.web.huntOptimizer.controllers.MethodsController
|
import world.phantasmal.web.huntOptimizer.controllers.MethodsController
|
||||||
import world.phantasmal.webui.dom.bindChildrenTo
|
import world.phantasmal.webui.dom.bindChildrenTo
|
||||||
import world.phantasmal.webui.dom.div
|
import world.phantasmal.webui.dom.div
|
||||||
|
@ -1,20 +1,24 @@
|
|||||||
package world.phantasmal.web.questEditor
|
package world.phantasmal.web.questEditor
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import org.w3c.dom.HTMLCanvasElement
|
import org.w3c.dom.HTMLCanvasElement
|
||||||
import world.phantasmal.core.disposable.Scope
|
import world.phantasmal.core.disposable.Scope
|
||||||
import world.phantasmal.web.core.stores.UiStore
|
import world.phantasmal.web.core.stores.UiStore
|
||||||
import world.phantasmal.web.externals.Engine
|
import world.phantasmal.web.externals.Engine
|
||||||
|
import world.phantasmal.web.questEditor.controllers.QuestEditorToolbarController
|
||||||
import world.phantasmal.web.questEditor.widgets.QuestEditorRendererWidget
|
import world.phantasmal.web.questEditor.widgets.QuestEditorRendererWidget
|
||||||
|
import world.phantasmal.web.questEditor.widgets.QuestEditorToolbar
|
||||||
import world.phantasmal.web.questEditor.widgets.QuestEditorWidget
|
import world.phantasmal.web.questEditor.widgets.QuestEditorWidget
|
||||||
|
|
||||||
class QuestEditor(
|
class QuestEditor(
|
||||||
scope: Scope,
|
scope: Scope,
|
||||||
crScope: CoroutineScope,
|
|
||||||
uiStore: UiStore,
|
uiStore: UiStore,
|
||||||
createEngine: (HTMLCanvasElement) -> Engine,
|
createEngine: (HTMLCanvasElement) -> Engine,
|
||||||
) {
|
) {
|
||||||
val widget = QuestEditorWidget(scope, { scope ->
|
private val toolbarController = QuestEditorToolbarController(scope)
|
||||||
QuestEditorRendererWidget(scope, createEngine)
|
|
||||||
})
|
val widget = QuestEditorWidget(
|
||||||
|
scope,
|
||||||
|
QuestEditorToolbar(scope, toolbarController),
|
||||||
|
{ scope -> QuestEditorRendererWidget(scope, createEngine) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,33 @@
|
|||||||
|
package world.phantasmal.web.questEditor.controllers
|
||||||
|
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.w3c.files.File
|
||||||
|
import world.phantasmal.core.disposable.Scope
|
||||||
|
import world.phantasmal.webui.controllers.Controller
|
||||||
|
import world.phantasmal.webui.readFile
|
||||||
|
|
||||||
|
class QuestEditorToolbarController(
|
||||||
|
scope: Scope,
|
||||||
|
) : Controller(scope) {
|
||||||
|
fun filesOpened(files: List<File>) {
|
||||||
|
launch {
|
||||||
|
if (files.isEmpty()) return@launch
|
||||||
|
|
||||||
|
val qst = files.find { it.name.endsWith(".qst", ignoreCase = true) }
|
||||||
|
|
||||||
|
if (qst != null) {
|
||||||
|
val buffer = readFile(qst)
|
||||||
|
// TODO: Parse qst.
|
||||||
|
} else {
|
||||||
|
val bin = files.find { it.name.endsWith(".bin", ignoreCase = true) }
|
||||||
|
val dat = files.find { it.name.endsWith(".dat", ignoreCase = true) }
|
||||||
|
|
||||||
|
if (bin != null && dat != null) {
|
||||||
|
val binBuffer = readFile(bin)
|
||||||
|
val datBuffer = readFile(dat)
|
||||||
|
// TODO: Parse bin and dat.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
package world.phantasmal.web.questEditor.widgets
|
||||||
|
|
||||||
|
import org.w3c.dom.Node
|
||||||
|
import world.phantasmal.core.disposable.Scope
|
||||||
|
import world.phantasmal.web.questEditor.controllers.QuestEditorToolbarController
|
||||||
|
import world.phantasmal.webui.dom.div
|
||||||
|
import world.phantasmal.webui.widgets.FileButton
|
||||||
|
import world.phantasmal.webui.widgets.Toolbar
|
||||||
|
import world.phantasmal.webui.widgets.Widget
|
||||||
|
|
||||||
|
class QuestEditorToolbar(
|
||||||
|
scope: Scope,
|
||||||
|
private val ctrl: QuestEditorToolbarController,
|
||||||
|
) : Widget(scope) {
|
||||||
|
override fun Node.createElement() = div(className = "pw-quest-editor-toolbar") {
|
||||||
|
addChild(Toolbar(
|
||||||
|
scope,
|
||||||
|
children = listOf(
|
||||||
|
FileButton(
|
||||||
|
scope,
|
||||||
|
text = "Open file...",
|
||||||
|
accept = ".bin, .dat, .qst",
|
||||||
|
multiple = true,
|
||||||
|
filesSelected = ctrl::filesOpened
|
||||||
|
)
|
||||||
|
)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
@ -19,10 +19,12 @@ private class TestWidget(scope: Scope) : Widget(scope) {
|
|||||||
|
|
||||||
open class QuestEditorWidget(
|
open class QuestEditorWidget(
|
||||||
scope: Scope,
|
scope: Scope,
|
||||||
|
private val toolbar: QuestEditorToolbar,
|
||||||
private val createQuestRendererWidget: (Scope) -> Widget,
|
private val createQuestRendererWidget: (Scope) -> Widget,
|
||||||
) : Widget(scope, ::style) {
|
) : Widget(scope, ::style) {
|
||||||
override fun Node.createElement() =
|
override fun Node.createElement() =
|
||||||
div(className = "pw-quest-editor-quest-editor") {
|
div(className = "pw-quest-editor-quest-editor") {
|
||||||
|
addChild(toolbar)
|
||||||
addChild(DockWidget(
|
addChild(DockWidget(
|
||||||
scope,
|
scope,
|
||||||
item = DockedRow(
|
item = DockedRow(
|
||||||
|
@ -4,13 +4,12 @@ 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.browser.document
|
import kotlinx.browser.document
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import world.phantasmal.core.disposable.DisposableScope
|
import world.phantasmal.core.disposable.DisposableScope
|
||||||
import world.phantasmal.core.disposable.disposable
|
import world.phantasmal.core.disposable.disposable
|
||||||
import world.phantasmal.testUtils.TestSuite
|
import world.phantasmal.testUtils.TestSuite
|
||||||
import world.phantasmal.web.core.HttpAssetLoader
|
import world.phantasmal.web.core.HttpAssetLoader
|
||||||
import world.phantasmal.web.core.UiDispatcher
|
|
||||||
import world.phantasmal.web.core.stores.PwTool
|
import world.phantasmal.web.core.stores.PwTool
|
||||||
import world.phantasmal.web.externals.Engine
|
import world.phantasmal.web.externals.Engine
|
||||||
import world.phantasmal.web.test.TestApplicationUrl
|
import world.phantasmal.web.test.TestApplicationUrl
|
||||||
@ -20,7 +19,7 @@ class ApplicationTests : TestSuite() {
|
|||||||
@Test
|
@Test
|
||||||
fun initialization_and_shutdown_should_succeed_without_throwing() {
|
fun initialization_and_shutdown_should_succeed_without_throwing() {
|
||||||
(listOf(null) + PwTool.values().toList()).forEach { tool ->
|
(listOf(null) + PwTool.values().toList()).forEach { tool ->
|
||||||
val scope = DisposableScope()
|
val scope = DisposableScope(Job())
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val httpClient = HttpClient {
|
val httpClient = HttpClient {
|
||||||
@ -34,7 +33,6 @@ class ApplicationTests : TestSuite() {
|
|||||||
|
|
||||||
Application(
|
Application(
|
||||||
scope,
|
scope,
|
||||||
crScope = CoroutineScope(UiDispatcher),
|
|
||||||
rootElement = document.body!!,
|
rootElement = document.body!!,
|
||||||
assetLoader = HttpAssetLoader(httpClient, basePath = ""),
|
assetLoader = HttpAssetLoader(httpClient, basePath = ""),
|
||||||
applicationUrl = TestApplicationUrl(if (tool == null) "" else "/${tool.slug}"),
|
applicationUrl = TestApplicationUrl(if (tool == null) "" else "/${tool.slug}"),
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
package world.phantasmal.web.core.controllers
|
package world.phantasmal.web.core.controllers
|
||||||
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import world.phantasmal.testUtils.TestSuite
|
import world.phantasmal.testUtils.TestSuite
|
||||||
import world.phantasmal.web.core.stores.PwTool
|
import world.phantasmal.web.core.stores.PwTool
|
||||||
import world.phantasmal.web.core.stores.UiStore
|
import world.phantasmal.web.core.stores.UiStore
|
||||||
@ -43,48 +41,42 @@ class PathAwareTabControllerTests : TestSuite() {
|
|||||||
@Test
|
@Test
|
||||||
fun applicationUrl_changes_when_switch_to_tool_with_tabs() {
|
fun applicationUrl_changes_when_switch_to_tool_with_tabs() {
|
||||||
val appUrl = TestApplicationUrl("/")
|
val appUrl = TestApplicationUrl("/")
|
||||||
|
val uiStore = UiStore(scope, appUrl)
|
||||||
|
|
||||||
GlobalScope.launch {
|
PathAwareTabController(scope, uiStore, PwTool.HuntOptimizer, listOf(
|
||||||
val uiStore = UiStore(scope, this, appUrl)
|
PathAwareTab("A", "/a"),
|
||||||
|
PathAwareTab("B", "/b"),
|
||||||
|
PathAwareTab("C", "/c"),
|
||||||
|
))
|
||||||
|
|
||||||
PathAwareTabController(scope, uiStore, PwTool.HuntOptimizer, listOf(
|
assertFalse(appUrl.canGoBack)
|
||||||
PathAwareTab("A", "/a"),
|
assertFalse(appUrl.canGoForward)
|
||||||
PathAwareTab("B", "/b"),
|
assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value)
|
||||||
PathAwareTab("C", "/c"),
|
|
||||||
))
|
|
||||||
|
|
||||||
assertFalse(appUrl.canGoBack)
|
uiStore.setCurrentTool(PwTool.HuntOptimizer)
|
||||||
assertFalse(appUrl.canGoForward)
|
|
||||||
assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value)
|
|
||||||
|
|
||||||
uiStore.setCurrentTool(PwTool.HuntOptimizer)
|
assertEquals(1, appUrl.historyEntries)
|
||||||
|
assertFalse(appUrl.canGoForward)
|
||||||
|
assertEquals("/${PwTool.HuntOptimizer.slug}", appUrl.url.value)
|
||||||
|
|
||||||
assertEquals(1, appUrl.historyEntries)
|
appUrl.back()
|
||||||
assertFalse(appUrl.canGoForward)
|
|
||||||
assertEquals("/${PwTool.HuntOptimizer.slug}", appUrl.url.value)
|
|
||||||
|
|
||||||
appUrl.back()
|
assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value)
|
||||||
|
|
||||||
assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setup(
|
private fun setup(
|
||||||
block: (PathAwareTabController<PathAwareTab>, applicationUrl: TestApplicationUrl) -> Unit,
|
block: (PathAwareTabController<PathAwareTab>, applicationUrl: TestApplicationUrl) -> Unit,
|
||||||
) {
|
) {
|
||||||
val applicationUrl = TestApplicationUrl("/${PwTool.HuntOptimizer.slug}/b")
|
val applicationUrl = TestApplicationUrl("/${PwTool.HuntOptimizer.slug}/b")
|
||||||
|
val uiStore = UiStore(scope, applicationUrl)
|
||||||
|
uiStore.setCurrentTool(PwTool.HuntOptimizer)
|
||||||
|
|
||||||
GlobalScope.launch {
|
val ctrl = PathAwareTabController(scope, uiStore, PwTool.HuntOptimizer, listOf(
|
||||||
val uiStore = UiStore(scope, this, applicationUrl)
|
PathAwareTab("A", "/a"),
|
||||||
uiStore.setCurrentTool(PwTool.HuntOptimizer)
|
PathAwareTab("B", "/b"),
|
||||||
|
PathAwareTab("C", "/c"),
|
||||||
|
))
|
||||||
|
|
||||||
val ctrl = PathAwareTabController(scope, uiStore, PwTool.HuntOptimizer, listOf(
|
block(ctrl, applicationUrl)
|
||||||
PathAwareTab("A", "/a"),
|
|
||||||
PathAwareTab("B", "/b"),
|
|
||||||
PathAwareTab("C", "/c"),
|
|
||||||
))
|
|
||||||
|
|
||||||
block(ctrl, applicationUrl)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
package world.phantasmal.web.core.store
|
package world.phantasmal.web.core.store
|
||||||
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import world.phantasmal.testUtils.TestSuite
|
import world.phantasmal.testUtils.TestSuite
|
||||||
import world.phantasmal.web.core.stores.PwTool
|
import world.phantasmal.web.core.stores.PwTool
|
||||||
import world.phantasmal.web.core.stores.UiStore
|
import world.phantasmal.web.core.stores.UiStore
|
||||||
@ -13,63 +11,51 @@ class UiStoreTests : TestSuite() {
|
|||||||
@Test
|
@Test
|
||||||
fun applicationUrl_is_initialized_correctly() {
|
fun applicationUrl_is_initialized_correctly() {
|
||||||
val applicationUrl = TestApplicationUrl("/")
|
val applicationUrl = TestApplicationUrl("/")
|
||||||
|
val uiStore = UiStore(scope, applicationUrl)
|
||||||
|
|
||||||
GlobalScope.launch {
|
assertEquals(PwTool.Viewer, uiStore.currentTool.value)
|
||||||
val uiStore = UiStore(scope, this, applicationUrl)
|
assertEquals("/${PwTool.Viewer.slug}", applicationUrl.url.value)
|
||||||
|
|
||||||
assertEquals(PwTool.Viewer, uiStore.currentTool.value)
|
|
||||||
assertEquals("/${PwTool.Viewer.slug}", applicationUrl.url.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun applicationUrl_changes_when_tool_changes() {
|
fun applicationUrl_changes_when_tool_changes() {
|
||||||
val applicationUrl = TestApplicationUrl("/")
|
val applicationUrl = TestApplicationUrl("/")
|
||||||
|
val uiStore = UiStore(scope, applicationUrl)
|
||||||
|
|
||||||
GlobalScope.launch {
|
PwTool.values().forEach { tool ->
|
||||||
val uiStore = UiStore(scope, this, applicationUrl)
|
uiStore.setCurrentTool(tool)
|
||||||
|
|
||||||
PwTool.values().forEach { tool ->
|
assertEquals(tool, uiStore.currentTool.value)
|
||||||
uiStore.setCurrentTool(tool)
|
assertEquals("/${tool.slug}", applicationUrl.url.value)
|
||||||
|
|
||||||
assertEquals(tool, uiStore.currentTool.value)
|
|
||||||
assertEquals("/${tool.slug}", applicationUrl.url.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun applicationUrl_changes_when_path_changes() {
|
fun applicationUrl_changes_when_path_changes() {
|
||||||
val applicationUrl = TestApplicationUrl("/")
|
val applicationUrl = TestApplicationUrl("/")
|
||||||
|
val uiStore = UiStore(scope, applicationUrl)
|
||||||
|
|
||||||
GlobalScope.launch {
|
assertEquals(PwTool.Viewer, uiStore.currentTool.value)
|
||||||
val uiStore = UiStore(scope, this, applicationUrl)
|
assertEquals("/${PwTool.Viewer.slug}", applicationUrl.url.value)
|
||||||
|
|
||||||
assertEquals(PwTool.Viewer, uiStore.currentTool.value)
|
listOf("/models", "/textures", "/animations").forEach { prefix ->
|
||||||
assertEquals("/${PwTool.Viewer.slug}", applicationUrl.url.value)
|
uiStore.setPathPrefix(prefix, replace = false)
|
||||||
|
|
||||||
listOf("/models", "/textures", "/animations").forEach { prefix ->
|
assertEquals("/${PwTool.Viewer.slug}${prefix}", applicationUrl.url.value)
|
||||||
uiStore.setPathPrefix(prefix, replace = false)
|
|
||||||
|
|
||||||
assertEquals("/${PwTool.Viewer.slug}${prefix}", applicationUrl.url.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun currentTool_and_path_change_when_applicationUrl_changes() {
|
fun currentTool_and_path_change_when_applicationUrl_changes() {
|
||||||
val applicationUrl = TestApplicationUrl("/")
|
val applicationUrl = TestApplicationUrl("/")
|
||||||
|
val uiStore = UiStore(scope, applicationUrl)
|
||||||
|
|
||||||
GlobalScope.launch {
|
PwTool.values().forEach { tool ->
|
||||||
val uiStore = UiStore(scope, this, applicationUrl)
|
listOf("/a", "/b", "/c").forEach { path ->
|
||||||
|
applicationUrl.url.value = "/${tool.slug}$path"
|
||||||
|
|
||||||
PwTool.values().forEach { tool ->
|
assertEquals(tool, uiStore.currentTool.value)
|
||||||
listOf("/a", "/b", "/c").forEach { path ->
|
assertEquals(path, uiStore.path.value)
|
||||||
applicationUrl.url.value = "/${tool.slug}$path"
|
|
||||||
|
|
||||||
assertEquals(tool, uiStore.currentTool.value)
|
|
||||||
assertEquals(path, uiStore.path.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -77,27 +63,24 @@ class UiStoreTests : TestSuite() {
|
|||||||
@Test
|
@Test
|
||||||
fun browser_navigation_stack_is_manipulated_correctly() {
|
fun browser_navigation_stack_is_manipulated_correctly() {
|
||||||
val appUrl = TestApplicationUrl("/")
|
val appUrl = TestApplicationUrl("/")
|
||||||
|
val uiStore = UiStore(scope, appUrl)
|
||||||
|
|
||||||
GlobalScope.launch {
|
assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value)
|
||||||
val uiStore = UiStore(scope, this, appUrl)
|
|
||||||
|
|
||||||
assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value)
|
uiStore.setCurrentTool(PwTool.HuntOptimizer)
|
||||||
|
|
||||||
uiStore.setCurrentTool(PwTool.HuntOptimizer)
|
assertEquals("/${PwTool.HuntOptimizer.slug}", appUrl.url.value)
|
||||||
|
|
||||||
assertEquals("/${PwTool.HuntOptimizer.slug}", appUrl.url.value)
|
uiStore.setPathPrefix("/prefix", replace = true)
|
||||||
|
|
||||||
uiStore.setPathPrefix("/prefix", replace = true)
|
assertEquals("/${PwTool.HuntOptimizer.slug}/prefix", appUrl.url.value)
|
||||||
|
|
||||||
assertEquals("/${PwTool.HuntOptimizer.slug}/prefix", appUrl.url.value)
|
appUrl.back()
|
||||||
|
|
||||||
appUrl.back()
|
assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value)
|
||||||
|
|
||||||
assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value)
|
appUrl.forward()
|
||||||
|
|
||||||
appUrl.forward()
|
assertEquals("/${PwTool.HuntOptimizer.slug}/prefix", appUrl.url.value)
|
||||||
|
|
||||||
assertEquals("/${PwTool.HuntOptimizer.slug}/prefix", appUrl.url.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,12 +3,10 @@ package world.phantasmal.web.huntOptimizer
|
|||||||
import io.ktor.client.*
|
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.CoroutineScope
|
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import world.phantasmal.core.disposable.disposable
|
import world.phantasmal.core.disposable.disposable
|
||||||
import world.phantasmal.testUtils.TestSuite
|
import world.phantasmal.testUtils.TestSuite
|
||||||
import world.phantasmal.web.core.HttpAssetLoader
|
import world.phantasmal.web.core.HttpAssetLoader
|
||||||
import world.phantasmal.web.core.UiDispatcher
|
|
||||||
import world.phantasmal.web.core.stores.PwTool
|
import world.phantasmal.web.core.stores.PwTool
|
||||||
import world.phantasmal.web.core.stores.UiStore
|
import world.phantasmal.web.core.stores.UiStore
|
||||||
import world.phantasmal.web.test.TestApplicationUrl
|
import world.phantasmal.web.test.TestApplicationUrl
|
||||||
@ -26,13 +24,10 @@ class HuntOptimizerTests : TestSuite() {
|
|||||||
}
|
}
|
||||||
scope.disposable { httpClient.cancel() }
|
scope.disposable { httpClient.cancel() }
|
||||||
|
|
||||||
val crScope = CoroutineScope(UiDispatcher)
|
|
||||||
|
|
||||||
HuntOptimizer(
|
HuntOptimizer(
|
||||||
scope,
|
scope,
|
||||||
crScope,
|
|
||||||
assetLoader = HttpAssetLoader(httpClient, basePath = ""),
|
assetLoader = HttpAssetLoader(httpClient, basePath = ""),
|
||||||
uiStore = UiStore(scope, crScope, TestApplicationUrl("/${PwTool.HuntOptimizer}"))
|
uiStore = UiStore(scope, TestApplicationUrl("/${PwTool.HuntOptimizer}"))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,11 +3,9 @@ package world.phantasmal.web.questEditor
|
|||||||
import io.ktor.client.*
|
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.CoroutineScope
|
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import world.phantasmal.core.disposable.disposable
|
import world.phantasmal.core.disposable.disposable
|
||||||
import world.phantasmal.testUtils.TestSuite
|
import world.phantasmal.testUtils.TestSuite
|
||||||
import world.phantasmal.web.core.UiDispatcher
|
|
||||||
import world.phantasmal.web.core.stores.PwTool
|
import world.phantasmal.web.core.stores.PwTool
|
||||||
import world.phantasmal.web.core.stores.UiStore
|
import world.phantasmal.web.core.stores.UiStore
|
||||||
import world.phantasmal.web.externals.Engine
|
import world.phantasmal.web.externals.Engine
|
||||||
@ -26,12 +24,9 @@ class QuestEditorTests : TestSuite() {
|
|||||||
}
|
}
|
||||||
scope.disposable { httpClient.cancel() }
|
scope.disposable { httpClient.cancel() }
|
||||||
|
|
||||||
val crScope = CoroutineScope(UiDispatcher)
|
|
||||||
|
|
||||||
QuestEditor(
|
QuestEditor(
|
||||||
scope,
|
scope,
|
||||||
crScope,
|
uiStore = UiStore(scope, TestApplicationUrl("/${PwTool.QuestEditor}")),
|
||||||
uiStore = UiStore(scope, crScope, TestApplicationUrl("/${PwTool.QuestEditor}")),
|
|
||||||
createEngine = { Engine(it) }
|
createEngine = { Engine(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
36
webui/src/main/kotlin/world/phantasmal/webui/Files.kt
Normal file
36
webui/src/main/kotlin/world/phantasmal/webui/Files.kt
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package world.phantasmal.webui
|
||||||
|
|
||||||
|
import kotlinx.browser.document
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import org.khronos.webgl.ArrayBuffer
|
||||||
|
import org.w3c.dom.HTMLInputElement
|
||||||
|
import org.w3c.dom.asList
|
||||||
|
import org.w3c.files.File
|
||||||
|
import org.w3c.files.FileReader
|
||||||
|
|
||||||
|
fun openFiles(accept: String = "", multiple: Boolean = false, callback: (List<File>) -> Unit) {
|
||||||
|
val el = document.createElement("input") as HTMLInputElement
|
||||||
|
el.type = "file"
|
||||||
|
el.accept = accept
|
||||||
|
el.multiple = multiple
|
||||||
|
|
||||||
|
el.onchange = {
|
||||||
|
callback(el.files?.asList() ?: emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
el.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
suspend fun readFile(file: File): ArrayBuffer = suspendCancellableCoroutine { cont ->
|
||||||
|
val reader = FileReader()
|
||||||
|
reader.onloadend = {
|
||||||
|
if (reader.result is ArrayBuffer) {
|
||||||
|
cont.resume(reader.result.unsafeCast<ArrayBuffer>()) {}
|
||||||
|
} else {
|
||||||
|
cont.cancel(Exception(reader.error.message.unsafeCast<String>()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsArrayBuffer(file)
|
||||||
|
}
|
@ -1,6 +1,9 @@
|
|||||||
package world.phantasmal.webui.controllers
|
package world.phantasmal.webui.controllers
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import world.phantasmal.core.disposable.Scope
|
import world.phantasmal.core.disposable.Scope
|
||||||
import world.phantasmal.core.disposable.TrackedDisposable
|
import world.phantasmal.core.disposable.TrackedDisposable
|
||||||
|
|
||||||
abstract class Controller(protected val scope: Scope) : TrackedDisposable(scope.scope())
|
abstract class Controller(protected val scope: Scope) :
|
||||||
|
TrackedDisposable(scope.scope()),
|
||||||
|
CoroutineScope by scope
|
||||||
|
@ -3,14 +3,8 @@ package world.phantasmal.webui.stores
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import world.phantasmal.core.disposable.Scope
|
import world.phantasmal.core.disposable.Scope
|
||||||
import world.phantasmal.core.disposable.TrackedDisposable
|
import world.phantasmal.core.disposable.TrackedDisposable
|
||||||
import kotlin.coroutines.CoroutineContext
|
|
||||||
|
|
||||||
abstract class Store(
|
|
||||||
scope: Scope,
|
|
||||||
crScope: CoroutineScope,
|
|
||||||
) : TrackedDisposable(scope.scope()), CoroutineScope {
|
|
||||||
override val coroutineContext: CoroutineContext = crScope.coroutineContext
|
|
||||||
|
|
||||||
|
abstract class Store(scope: Scope) : TrackedDisposable(scope.scope()), CoroutineScope by scope {
|
||||||
override fun internalDispose() {
|
override fun internalDispose() {
|
||||||
// Do nothing.
|
// Do nothing.
|
||||||
}
|
}
|
||||||
|
113
webui/src/main/kotlin/world/phantasmal/webui/widgets/Button.kt
Normal file
113
webui/src/main/kotlin/world/phantasmal/webui/widgets/Button.kt
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
package world.phantasmal.webui.widgets
|
||||||
|
|
||||||
|
import org.w3c.dom.Node
|
||||||
|
import org.w3c.dom.events.MouseEvent
|
||||||
|
import world.phantasmal.core.disposable.Scope
|
||||||
|
import world.phantasmal.observable.value.Val
|
||||||
|
import world.phantasmal.observable.value.falseVal
|
||||||
|
import world.phantasmal.webui.dom.button
|
||||||
|
import world.phantasmal.webui.dom.span
|
||||||
|
|
||||||
|
open class Button(
|
||||||
|
scope: Scope,
|
||||||
|
hidden: Val<Boolean> = falseVal(),
|
||||||
|
disabled: Val<Boolean> = falseVal(),
|
||||||
|
private val text: String? = null,
|
||||||
|
private val textVal: Val<String>? = null,
|
||||||
|
private val onclick: ((MouseEvent) -> Unit)? = null,
|
||||||
|
) : Control(scope, ::style, hidden, disabled) {
|
||||||
|
override fun Node.createElement() =
|
||||||
|
button(className = "pw-button") {
|
||||||
|
onclick = this@Button.onclick
|
||||||
|
|
||||||
|
span(className = "pw-button-inner") {
|
||||||
|
span(className = "pw-button-center") {
|
||||||
|
if (textVal != null) {
|
||||||
|
textVal.observe {
|
||||||
|
textContent = it
|
||||||
|
hidden = it.isEmpty()
|
||||||
|
}
|
||||||
|
} else if (!text.isNullOrEmpty()) {
|
||||||
|
textContent = text
|
||||||
|
} else {
|
||||||
|
hidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("CssUnusedSymbol", "CssUnresolvedCustomProperty")
|
||||||
|
// language=css
|
||||||
|
private fun style() = """
|
||||||
|
.pw-button {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
align-content: stretch;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border: var(--pw-control-border);
|
||||||
|
color: var(--pw-control-text-color);
|
||||||
|
outline: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--pw-font-family), sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-button .pw-button-inner {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: var(--pw-control-bg-color);
|
||||||
|
height: 24px;
|
||||||
|
padding: 3px 5px;
|
||||||
|
border: var(--pw-control-inner-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-button:hover .pw-button-inner {
|
||||||
|
background-color: var(--pw-control-bg-color-hover);
|
||||||
|
border-color: hsl(0, 0%, 40%);
|
||||||
|
color: var(--pw-control-text-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-button:active .pw-button-inner {
|
||||||
|
background-color: hsl(0, 0%, 20%);
|
||||||
|
border-color: hsl(0, 0%, 30%);
|
||||||
|
color: hsl(0, 0%, 75%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-button:focus-within .pw-button-inner {
|
||||||
|
border: var(--pw-control-inner-border-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-button:disabled .pw-button-inner {
|
||||||
|
background-color: hsl(0, 0%, 15%);
|
||||||
|
border-color: hsl(0, 0%, 25%);
|
||||||
|
color: hsl(0, 0%, 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-button-inner > * {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-button-center {
|
||||||
|
flex-grow: 1;
|
||||||
|
text-align: left;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-button-left,
|
||||||
|
.pw-button-right {
|
||||||
|
display: inline-flex;
|
||||||
|
align-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
"""
|
@ -0,0 +1,16 @@
|
|||||||
|
package world.phantasmal.webui.widgets
|
||||||
|
|
||||||
|
import world.phantasmal.core.disposable.Scope
|
||||||
|
import world.phantasmal.observable.value.Val
|
||||||
|
import world.phantasmal.observable.value.falseVal
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents all widgets that allow for user interaction such as buttons, text inputs, combo boxes,
|
||||||
|
* etc. Controls are typically leaf nodes and thus typically don't have children.
|
||||||
|
*/
|
||||||
|
abstract class Control(
|
||||||
|
scope: Scope,
|
||||||
|
style: () -> String,
|
||||||
|
hidden: Val<Boolean> = falseVal(),
|
||||||
|
disabled: Val<Boolean> = falseVal(),
|
||||||
|
) : Widget(scope, style, hidden, disabled)
|
@ -0,0 +1,29 @@
|
|||||||
|
package world.phantasmal.webui.widgets
|
||||||
|
|
||||||
|
import org.w3c.dom.HTMLElement
|
||||||
|
import org.w3c.files.File
|
||||||
|
import world.phantasmal.core.disposable.Scope
|
||||||
|
import world.phantasmal.observable.value.Val
|
||||||
|
import world.phantasmal.observable.value.falseVal
|
||||||
|
import world.phantasmal.webui.openFiles
|
||||||
|
|
||||||
|
class FileButton(
|
||||||
|
scope: Scope,
|
||||||
|
hidden: Val<Boolean> = falseVal(),
|
||||||
|
disabled: Val<Boolean> = falseVal(),
|
||||||
|
text: String? = null,
|
||||||
|
textVal: Val<String>? = null,
|
||||||
|
private val accept: String = "",
|
||||||
|
private val multiple: Boolean = false,
|
||||||
|
private val filesSelected: ((List<File>) -> Unit)? = null,
|
||||||
|
) : Button(scope, hidden, disabled, text, textVal) {
|
||||||
|
override fun interceptElement(element: HTMLElement) {
|
||||||
|
element.classList.add("pw-file-button")
|
||||||
|
|
||||||
|
if (filesSelected != null) {
|
||||||
|
element.onclick = {
|
||||||
|
openFiles(accept, multiple, filesSelected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
package world.phantasmal.webui.widgets
|
||||||
|
|
||||||
|
import org.w3c.dom.Node
|
||||||
|
import world.phantasmal.core.disposable.Scope
|
||||||
|
import world.phantasmal.observable.value.Val
|
||||||
|
import world.phantasmal.observable.value.falseVal
|
||||||
|
import world.phantasmal.webui.dom.label
|
||||||
|
|
||||||
|
class Label(
|
||||||
|
scope: Scope,
|
||||||
|
hidden: Val<Boolean> = falseVal(),
|
||||||
|
disabled: Val<Boolean> = falseVal(),
|
||||||
|
private val text: String? = null,
|
||||||
|
private val textVal: Val<String>? = null,
|
||||||
|
private val htmlFor: String?,
|
||||||
|
) : Widget(scope, ::style, hidden, disabled) {
|
||||||
|
override fun Node.createElement() =
|
||||||
|
label(htmlFor) {
|
||||||
|
if (textVal != null) {
|
||||||
|
textVal.observe { textContent = it }
|
||||||
|
} else if (text != null) {
|
||||||
|
textContent = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("CssUnusedSymbol", "CssUnresolvedCustomProperty")
|
||||||
|
// language=css
|
||||||
|
private fun style() = """
|
||||||
|
.pw-label.disabled {
|
||||||
|
color: var(--pw-text-color-disabled);
|
||||||
|
}
|
||||||
|
"""
|
@ -0,0 +1,41 @@
|
|||||||
|
package world.phantasmal.webui.widgets
|
||||||
|
|
||||||
|
import world.phantasmal.core.disposable.Scope
|
||||||
|
import world.phantasmal.observable.value.Val
|
||||||
|
import world.phantasmal.observable.value.falseVal
|
||||||
|
|
||||||
|
enum class LabelPosition {
|
||||||
|
Before,
|
||||||
|
After
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class LabelledControl(
|
||||||
|
scope: Scope,
|
||||||
|
style: () -> String,
|
||||||
|
hidden: Val<Boolean> = falseVal(),
|
||||||
|
disabled: Val<Boolean> = falseVal(),
|
||||||
|
label: String? = null,
|
||||||
|
labelVal: Val<String>? = null,
|
||||||
|
val preferredLabelPosition: LabelPosition,
|
||||||
|
) : Control(scope, style, hidden, disabled) {
|
||||||
|
val label: Label? by lazy {
|
||||||
|
if (label == null && labelVal == null) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
var id = element.id
|
||||||
|
|
||||||
|
if (id.isBlank()) {
|
||||||
|
id = uniqueId()
|
||||||
|
element.id = id
|
||||||
|
}
|
||||||
|
|
||||||
|
Label(scope, hidden, disabled, label, labelVal, htmlFor = id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private var id = 0
|
||||||
|
|
||||||
|
private fun uniqueId() = "pw-labelled-control-id-${id++}"
|
||||||
|
}
|
||||||
|
}
|
@ -9,8 +9,9 @@ import world.phantasmal.webui.dom.div
|
|||||||
class LazyLoader(
|
class LazyLoader(
|
||||||
scope: Scope,
|
scope: Scope,
|
||||||
hidden: Val<Boolean> = falseVal(),
|
hidden: Val<Boolean> = falseVal(),
|
||||||
|
disabled: Val<Boolean> = falseVal(),
|
||||||
private val createWidget: (Scope) -> Widget,
|
private val createWidget: (Scope) -> Widget,
|
||||||
) : Widget(scope, ::style, hidden) {
|
) : Widget(scope, ::style, hidden, disabled) {
|
||||||
private var initialized = false
|
private var initialized = false
|
||||||
|
|
||||||
override fun Node.createElement() = div(className = "pw-lazy-loader") {
|
override fun Node.createElement() = div(className = "pw-lazy-loader") {
|
||||||
|
@ -12,46 +12,52 @@ import world.phantasmal.webui.dom.span
|
|||||||
class TabContainer<T : Tab>(
|
class TabContainer<T : Tab>(
|
||||||
scope: Scope,
|
scope: Scope,
|
||||||
hidden: Val<Boolean> = falseVal(),
|
hidden: Val<Boolean> = falseVal(),
|
||||||
|
disabled: Val<Boolean> = falseVal(),
|
||||||
private val ctrl: TabController<T>,
|
private val ctrl: TabController<T>,
|
||||||
private val createWidget: (Scope, T) -> Widget,
|
private val createWidget: (Scope, T) -> Widget,
|
||||||
) : Widget(scope, ::style, hidden) {
|
) : Widget(scope, ::style, hidden, disabled) {
|
||||||
override fun Node.createElement() = div(className = "pw-tab-container") {
|
override fun Node.createElement() =
|
||||||
div(className = "pw-tab-container-bar") {
|
div(className = "pw-tab-container") {
|
||||||
for (tab in ctrl.tabs) {
|
div(className = "pw-tab-container-bar") {
|
||||||
span(
|
for (tab in ctrl.tabs) {
|
||||||
className = "pw-tab-container-tab",
|
span(
|
||||||
title = tab.title,
|
className = "pw-tab-container-tab",
|
||||||
) {
|
title = tab.title,
|
||||||
textContent = tab.title
|
) {
|
||||||
|
textContent = tab.title
|
||||||
|
|
||||||
ctrl.activeTab.observe {
|
ctrl.activeTab.observe {
|
||||||
if (it == tab) {
|
if (it == tab) {
|
||||||
classList.add("active")
|
classList.add(ACTIVE_CLASS)
|
||||||
} else {
|
} else {
|
||||||
classList.remove("active")
|
classList.remove(ACTIVE_CLASS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
onmousedown = { ctrl.setActiveTab(tab) }
|
onmousedown = { ctrl.setActiveTab(tab) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(className = "pw-tab-container-panes") {
|
||||||
|
for (tab in ctrl.tabs) {
|
||||||
|
addChild(
|
||||||
|
LazyLoader(
|
||||||
|
scope,
|
||||||
|
hidden = ctrl.activeTab.transform { it != tab },
|
||||||
|
createWidget = { scope -> createWidget(scope, tab) }
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
div(className = "pw-tab-container-panes") {
|
|
||||||
for (tab in ctrl.tabs) {
|
|
||||||
addChild(
|
|
||||||
LazyLoader(
|
|
||||||
scope,
|
|
||||||
hidden = ctrl.activeTab.transform { it != tab },
|
|
||||||
createWidget = { scope -> createWidget(scope, tab) }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
selfOrAncestorHidden.observe(ctrl::hiddenChanged)
|
selfOrAncestorHidden.observe(ctrl::hiddenChanged)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ACTIVE_CLASS = "pw-active"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("CssUnresolvedCustomProperty", "CssUnusedSymbol")
|
@Suppress("CssUnresolvedCustomProperty", "CssUnusedSymbol")
|
||||||
@ -88,7 +94,7 @@ private fun style() = """
|
|||||||
color: var(--pw-tab-text-color-hover);
|
color: var(--pw-tab-text-color-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pw-tab-container-tab.active {
|
.pw-tab-container-tab.pw-active {
|
||||||
background-color: var(--pw-tab-bg-color-active);
|
background-color: var(--pw-tab-bg-color-active);
|
||||||
color: var(--pw-tab-text-color-active);
|
color: var(--pw-tab-text-color-active);
|
||||||
border-bottom-color: var(--pw-tab-bg-color-active);
|
border-bottom-color: var(--pw-tab-bg-color-active);
|
||||||
|
@ -0,0 +1,71 @@
|
|||||||
|
package world.phantasmal.webui.widgets
|
||||||
|
|
||||||
|
import org.w3c.dom.Node
|
||||||
|
import world.phantasmal.core.disposable.Scope
|
||||||
|
import world.phantasmal.observable.value.Val
|
||||||
|
import world.phantasmal.observable.value.falseVal
|
||||||
|
import world.phantasmal.webui.dom.div
|
||||||
|
|
||||||
|
class Toolbar(
|
||||||
|
scope: Scope,
|
||||||
|
hidden: Val<Boolean> = falseVal(),
|
||||||
|
disabled: Val<Boolean> = falseVal(),
|
||||||
|
children: List<Widget>,
|
||||||
|
) : Widget(scope, ::style, hidden, disabled) {
|
||||||
|
private val childWidgets = children
|
||||||
|
|
||||||
|
override fun Node.createElement() =
|
||||||
|
div(className = "pw-toolbar") {
|
||||||
|
childWidgets.forEach { child ->
|
||||||
|
// Group labelled controls and their labels together.
|
||||||
|
if (child is LabelledControl && child.label != null) {
|
||||||
|
div(className = "pw-toolbar-group") {
|
||||||
|
when (child.preferredLabelPosition) {
|
||||||
|
LabelPosition.Before -> {
|
||||||
|
addChild(child.label!!)
|
||||||
|
addChild(child)
|
||||||
|
}
|
||||||
|
LabelPosition.After -> {
|
||||||
|
addChild(child)
|
||||||
|
addChild(child.label!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addChild(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("CssUnusedSymbol", "CssUnresolvedCustomProperty")
|
||||||
|
// language=css
|
||||||
|
private fun style() = """
|
||||||
|
.pw-toolbar {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: var(--pw-border);
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-toolbar > * {
|
||||||
|
margin: 2px 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-toolbar > .pw-toolbar-group {
|
||||||
|
margin: 2px 3px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-toolbar > .pw-toolbar-group > * {
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-toolbar .pw-input {
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
"""
|
@ -20,7 +20,15 @@ import kotlin.reflect.KClass
|
|||||||
abstract class Widget(
|
abstract class Widget(
|
||||||
protected val scope: Scope,
|
protected val scope: Scope,
|
||||||
style: () -> String = NO_STYLE,
|
style: () -> String = NO_STYLE,
|
||||||
|
/**
|
||||||
|
* By default determines the hidden attribute of its [element].
|
||||||
|
*/
|
||||||
val hidden: Val<Boolean> = falseVal(),
|
val hidden: Val<Boolean> = falseVal(),
|
||||||
|
/**
|
||||||
|
* By default determines the disabled attribute of its [element] and whether or not the
|
||||||
|
* `pw-disabled` class is added.
|
||||||
|
*/
|
||||||
|
val disabled: Val<Boolean> = falseVal(),
|
||||||
) : TrackedDisposable(scope.scope()) {
|
) : TrackedDisposable(scope.scope()) {
|
||||||
private val _ancestorHidden = mutableVal(false)
|
private val _ancestorHidden = mutableVal(false)
|
||||||
private val _children = mutableListOf<Widget>()
|
private val _children = mutableListOf<Widget>()
|
||||||
@ -41,10 +49,21 @@ abstract class Widget(
|
|||||||
children.forEach { setAncestorHidden(it, hidden || ancestorHidden.value) }
|
children.forEach { setAncestorHidden(it, hidden || ancestorHidden.value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disabled.observe { disabled ->
|
||||||
|
if (disabled) {
|
||||||
|
el.setAttribute("disabled", "")
|
||||||
|
el.classList.add("pw-disabled")
|
||||||
|
} else {
|
||||||
|
el.removeAttribute("disabled")
|
||||||
|
el.classList.remove("pw-disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (initResizeObserverRequested) {
|
if (initResizeObserverRequested) {
|
||||||
initResizeObserver(el)
|
initResizeObserver(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interceptElement(el)
|
||||||
el
|
el
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,8 +84,16 @@ abstract class Widget(
|
|||||||
|
|
||||||
val children: List<Widget> = _children
|
val children: List<Widget> = _children
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called to initialize [element] when it is first accessed.
|
||||||
|
*/
|
||||||
protected abstract fun Node.createElement(): HTMLElement
|
protected abstract fun Node.createElement(): HTMLElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called right after [createElement] and the default initialization for [element] is done.
|
||||||
|
*/
|
||||||
|
protected open fun interceptElement(element: HTMLElement) {}
|
||||||
|
|
||||||
override fun internalDispose() {
|
override fun internalDispose() {
|
||||||
if (elementDelegate.isInitialized()) {
|
if (elementDelegate.isInitialized()) {
|
||||||
element.remove()
|
element.remove()
|
||||||
|
Loading…
Reference in New Issue
Block a user