mirror of
https://github.com/DaanVandenBosch/phantasmal-world.git
synced 2025-04-04 06:28:28 +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> {
|
||||
kotlinOptions.freeCompilerArgs += listOf(
|
||||
"-Xopt-in=kotlin.RequiresOptIn",
|
||||
"-Xopt-in=kotlin.ExperimentalUnsignedTypes",
|
||||
"-Xopt-in=kotlin.time.ExperimentalTime"
|
||||
)
|
||||
|
@ -2,6 +2,7 @@ plugins {
|
||||
kotlin("multiplatform")
|
||||
}
|
||||
|
||||
val coroutinesVersion: String by project.ext
|
||||
val kotlinLoggingVersion: String by project.extra
|
||||
|
||||
kotlin {
|
||||
@ -12,6 +13,7 @@ kotlin {
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
|
||||
api("io.github.microutils:kotlin-logging:$kotlinLoggingVersion")
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,11 @@
|
||||
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 var disposed = false
|
||||
|
||||
@ -9,7 +14,7 @@ class DisposableScope : Scope, Disposable {
|
||||
*/
|
||||
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) {
|
||||
require(!disposed) { "Scope already disposed." }
|
||||
@ -65,6 +70,11 @@ class DisposableScope : Scope, Disposable {
|
||||
override fun dispose() {
|
||||
if (!disposed) {
|
||||
disposeAll()
|
||||
|
||||
if (coroutineContext[Job] != null) {
|
||||
cancel()
|
||||
}
|
||||
|
||||
disposed = true
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,12 @@
|
||||
package world.phantasmal.core.disposable
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
/**
|
||||
* Container for disposables. Takes ownership of all held disposables and automatically disposes
|
||||
* them when the Scope is disposed.
|
||||
*/
|
||||
interface Scope {
|
||||
interface Scope: CoroutineScope {
|
||||
fun add(disposable: Disposable)
|
||||
|
||||
/**
|
||||
|
@ -1,12 +1,13 @@
|
||||
package world.phantasmal.core.disposable
|
||||
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlin.test.*
|
||||
|
||||
class DisposableScopeTests {
|
||||
@Test
|
||||
fun calling_add_or_addAll_increases_size_correctly() {
|
||||
TrackedDisposable.checkNoLeaks {
|
||||
val scope = DisposableScope()
|
||||
val scope = DisposableScope(Job())
|
||||
assertEquals(scope.size, 0)
|
||||
|
||||
scope.add(Dummy())
|
||||
@ -28,7 +29,7 @@ class DisposableScopeTests {
|
||||
@Test
|
||||
fun disposes_all_its_disposables_when_disposed() {
|
||||
TrackedDisposable.checkNoLeaks {
|
||||
val scope = DisposableScope()
|
||||
val scope = DisposableScope(Job())
|
||||
var disposablesDisposed = 0
|
||||
|
||||
for (i in 1..5) {
|
||||
@ -56,7 +57,7 @@ class DisposableScopeTests {
|
||||
@Test
|
||||
fun disposeAll_disposes_all_disposables() {
|
||||
TrackedDisposable.checkNoLeaks {
|
||||
val scope = DisposableScope()
|
||||
val scope = DisposableScope(Job())
|
||||
|
||||
var disposablesDisposed = 0
|
||||
|
||||
@ -79,7 +80,7 @@ class DisposableScopeTests {
|
||||
@Test
|
||||
fun size_and_is_empty_should_correctly_reflect_the_contained_disposables() {
|
||||
TrackedDisposable.checkNoLeaks {
|
||||
val scope = DisposableScope()
|
||||
val scope = DisposableScope(Job())
|
||||
|
||||
assertEquals(scope.size, 0)
|
||||
assertTrue(scope.isEmpty())
|
||||
@ -101,7 +102,7 @@ class DisposableScopeTests {
|
||||
@Test
|
||||
fun adding_disposables_after_being_disposed_throws() {
|
||||
TrackedDisposable.checkNoLeaks {
|
||||
val scope = DisposableScope()
|
||||
val scope = DisposableScope(Job())
|
||||
scope.dispose()
|
||||
|
||||
for (i in 1..3) {
|
||||
|
@ -1,5 +1,6 @@
|
||||
package world.phantasmal.core.disposable
|
||||
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
@ -50,6 +51,8 @@ class TrackedDisposableTests {
|
||||
}
|
||||
|
||||
private class DummyScope : Scope {
|
||||
override val coroutineContext = Job()
|
||||
|
||||
override fun add(disposable: Disposable) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
package world.phantasmal.lib.fileformats
|
||||
package world.phantasmal.lib.fileFormats
|
||||
|
||||
import mu.KotlinLogging
|
||||
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
|
@ -1,4 +1,4 @@
|
||||
package world.phantasmal.lib.fileformats.quest
|
||||
package world.phantasmal.lib.fileFormats.quest
|
||||
|
||||
interface EntityType {
|
||||
/**
|
@ -1,4 +1,4 @@
|
||||
package world.phantasmal.lib.fileformats.quest
|
||||
package world.phantasmal.lib.fileFormats.quest
|
||||
|
||||
enum class Episode {
|
||||
I,
|
@ -1,4 +1,4 @@
|
||||
package world.phantasmal.lib.fileformats.quest
|
||||
package world.phantasmal.lib.fileFormats.quest
|
||||
|
||||
private val FRIENDLY_NPC_PROPERTIES = listOf(
|
||||
EntityProp(name = "Movement distance", offset = 44, type = EntityPropType.F32),
|
@ -1,16 +1,16 @@
|
||||
package world.phantasmal.observable.value
|
||||
|
||||
import world.phantasmal.core.disposable.Disposable
|
||||
import world.phantasmal.core.disposable.DisposableScope
|
||||
import world.phantasmal.core.disposable.Scope
|
||||
import world.phantasmal.core.disposable.disposable
|
||||
import world.phantasmal.core.fastCast
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
class DependentVal<T>(
|
||||
private val dependencies: Iterable<Val<*>>,
|
||||
private val operation: () -> T,
|
||||
) : AbstractVal<T>() {
|
||||
private var dependencyScope = DisposableScope()
|
||||
private var dependencyScope = DisposableScope(EmptyCoroutineContext)
|
||||
private var internalValue: T? = null
|
||||
|
||||
override val value: T
|
||||
|
@ -6,13 +6,14 @@ import world.phantasmal.core.disposable.disposable
|
||||
import world.phantasmal.core.fastCast
|
||||
import world.phantasmal.observable.value.AbstractVal
|
||||
import world.phantasmal.observable.value.ValObserver
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
class FoldedVal<T, R>(
|
||||
private val dependency: ListVal<T>,
|
||||
private val initial: R,
|
||||
private val operation: (R, T) -> R,
|
||||
) : AbstractVal<R>() {
|
||||
private var dependencyDisposable = DisposableScope()
|
||||
private var dependencyDisposable = DisposableScope(EmptyCoroutineContext)
|
||||
private var internalValue: R? = null
|
||||
|
||||
override val value: R
|
||||
|
@ -6,6 +6,7 @@ import world.phantasmal.core.disposable.disposable
|
||||
import world.phantasmal.observable.Observable
|
||||
import world.phantasmal.observable.Observer
|
||||
import world.phantasmal.observable.value.*
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
typealias ObservablesExtractor<E> = (element: E) -> Array<Observable<*>>
|
||||
|
||||
@ -180,7 +181,7 @@ class SimpleListVal<E>(
|
||||
observables: Array<Observable<*>>,
|
||||
) {
|
||||
val observers: Array<DisposableScope> = Array(observables.size) {
|
||||
val scope = DisposableScope()
|
||||
val scope = DisposableScope(EmptyCoroutineContext)
|
||||
observables[it].observe(scope) {
|
||||
finalizeUpdate(
|
||||
ListValChangeEvent.ElementChange(
|
||||
|
@ -2,9 +2,10 @@ package world.phantasmal.observable.test
|
||||
|
||||
import world.phantasmal.core.disposable.DisposableScope
|
||||
import world.phantasmal.core.disposable.Scope
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
fun withScope(block: (Scope) -> Unit) {
|
||||
val scope = DisposableScope()
|
||||
val scope = DisposableScope(EmptyCoroutineContext)
|
||||
|
||||
try {
|
||||
block(scope)
|
||||
|
@ -3,6 +3,7 @@ package world.phantasmal.observable.value
|
||||
import world.phantasmal.core.disposable.Disposable
|
||||
import world.phantasmal.core.disposable.Scope
|
||||
import world.phantasmal.testUtils.TestSuite
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlin.test.Test
|
||||
|
||||
class StaticValTests : TestSuite() {
|
||||
@ -16,6 +17,8 @@ class StaticValTests : TestSuite() {
|
||||
}
|
||||
|
||||
private object DummyScope : Scope {
|
||||
override val coroutineContext = EmptyCoroutineContext
|
||||
|
||||
override fun add(disposable: Disposable) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
package world.phantasmal.testUtils
|
||||
|
||||
import kotlinx.coroutines.Job
|
||||
import world.phantasmal.core.disposable.DisposableScope
|
||||
import world.phantasmal.core.disposable.Scope
|
||||
import world.phantasmal.core.disposable.TrackedDisposable
|
||||
@ -16,7 +17,7 @@ abstract class TestSuite {
|
||||
@BeforeTest
|
||||
fun before() {
|
||||
initialDisposableCount = TrackedDisposable.disposableCount
|
||||
_scope = DisposableScope()
|
||||
_scope = DisposableScope(Job())
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
|
@ -27,7 +27,6 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
val coroutinesVersion: String by project.ext
|
||||
val kotlinLoggingVersion: 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 kotlinx.browser.document
|
||||
import kotlinx.browser.window
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.w3c.dom.PopStateEvent
|
||||
import world.phantasmal.core.disposable.Disposable
|
||||
@ -30,10 +29,7 @@ fun main() {
|
||||
}
|
||||
|
||||
private fun init(): Disposable {
|
||||
val scope = DisposableScope()
|
||||
|
||||
val crScope = CoroutineScope(UiDispatcher)
|
||||
scope.disposable { crScope.cancel() }
|
||||
val scope = DisposableScope(UiDispatcher)
|
||||
|
||||
val rootElement = document.body!!.root()
|
||||
|
||||
@ -52,7 +48,6 @@ private fun init(): Disposable {
|
||||
|
||||
Application(
|
||||
scope,
|
||||
crScope,
|
||||
rootElement,
|
||||
HttpAssetLoader(httpClient, basePath),
|
||||
HistoryApplicationUrl(scope),
|
||||
|
@ -24,7 +24,6 @@ import world.phantasmal.webui.dom.disposableListener
|
||||
|
||||
class Application(
|
||||
scope: Scope,
|
||||
crScope: CoroutineScope,
|
||||
rootElement: HTMLElement,
|
||||
assetLoader: AssetLoader,
|
||||
applicationUrl: ApplicationUrl,
|
||||
@ -43,7 +42,7 @@ class Application(
|
||||
disposableListener(scope, document, "drop", ::drop)
|
||||
|
||||
// Initialize core stores shared by several submodules.
|
||||
val uiStore = UiStore(scope, crScope, applicationUrl)
|
||||
val uiStore = UiStore(scope, applicationUrl)
|
||||
|
||||
// Controllers.
|
||||
val navigationController = NavigationController(scope, uiStore)
|
||||
@ -55,10 +54,10 @@ class Application(
|
||||
NavigationWidget(scope, navigationController),
|
||||
MainContentWidget(scope, mainContentController, mapOf(
|
||||
PwTool.QuestEditor to { s ->
|
||||
QuestEditor(s, crScope, uiStore, createEngine).widget
|
||||
QuestEditor(s, uiStore, createEngine).widget
|
||||
},
|
||||
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") {
|
||||
ctrl.tools.forEach { (tool, active) ->
|
||||
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 {
|
||||
flex: 1;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.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.label
|
||||
import world.phantasmal.webui.dom.span
|
||||
import world.phantasmal.webui.widgets.Widget
|
||||
import world.phantasmal.webui.widgets.Control
|
||||
|
||||
class PwToolButton(
|
||||
scope: Scope,
|
||||
private val tool: PwTool,
|
||||
private val toggled: Observable<Boolean>,
|
||||
private val mouseDown: () -> Unit,
|
||||
) : Widget(scope, ::style) {
|
||||
) : Control(scope, ::style) {
|
||||
private val inputId = "pw-application-pw-tool-button-${tool.name.toLowerCase()}"
|
||||
|
||||
override fun Node.createElement() =
|
||||
|
@ -1,7 +1,6 @@
|
||||
package world.phantasmal.web.core.stores
|
||||
|
||||
import kotlinx.browser.window
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.w3c.dom.events.KeyboardEvent
|
||||
import world.phantasmal.core.disposable.Scope
|
||||
import world.phantasmal.observable.value.MutableVal
|
||||
@ -28,11 +27,7 @@ interface ApplicationUrl {
|
||||
fun replaceUrl(url: String)
|
||||
}
|
||||
|
||||
class UiStore(
|
||||
scope: Scope,
|
||||
crScope: CoroutineScope,
|
||||
private val applicationUrl: ApplicationUrl,
|
||||
) : Store(scope, crScope) {
|
||||
class UiStore(scope: Scope, private val applicationUrl: ApplicationUrl) : Store(scope) {
|
||||
private val _currentTool: MutableVal<PwTool>
|
||||
|
||||
private val _path = mutableVal("")
|
||||
|
@ -1,6 +1,5 @@
|
||||
package world.phantasmal.web.huntOptimizer
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import world.phantasmal.core.disposable.Scope
|
||||
import world.phantasmal.web.core.AssetLoader
|
||||
import world.phantasmal.web.core.stores.UiStore
|
||||
@ -12,11 +11,10 @@ import world.phantasmal.web.huntOptimizer.widgets.MethodsWidget
|
||||
|
||||
class HuntOptimizer(
|
||||
scope: Scope,
|
||||
crScope: CoroutineScope,
|
||||
assetLoader: AssetLoader,
|
||||
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 methodsController = MethodsController(scope, uiStore, huntMethodStore)
|
||||
|
@ -1,7 +1,7 @@
|
||||
package world.phantasmal.web.huntOptimizer.controllers
|
||||
|
||||
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.MutableListVal
|
||||
import world.phantasmal.observable.value.list.mutableListVal
|
||||
|
@ -1,7 +1,7 @@
|
||||
package world.phantasmal.web.huntOptimizer.models
|
||||
|
||||
import world.phantasmal.lib.fileformats.quest.Episode
|
||||
import world.phantasmal.lib.fileformats.quest.NpcType
|
||||
import world.phantasmal.lib.fileFormats.quest.Episode
|
||||
import world.phantasmal.lib.fileFormats.quest.NpcType
|
||||
import world.phantasmal.observable.value.Val
|
||||
import world.phantasmal.observable.value.mutableVal
|
||||
import kotlin.time.Duration
|
||||
|
@ -1,7 +1,7 @@
|
||||
package world.phantasmal.web.huntOptimizer.models
|
||||
|
||||
import world.phantasmal.lib.fileformats.quest.Episode
|
||||
import world.phantasmal.lib.fileformats.quest.NpcType
|
||||
import world.phantasmal.lib.fileFormats.quest.Episode
|
||||
import world.phantasmal.lib.fileFormats.quest.NpcType
|
||||
|
||||
class SimpleQuestModel(
|
||||
val id: Int,
|
||||
|
@ -1,11 +1,10 @@
|
||||
package world.phantasmal.web.huntOptimizer.stores
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import world.phantasmal.core.disposable.Scope
|
||||
import world.phantasmal.lib.fileformats.quest.Episode
|
||||
import world.phantasmal.lib.fileformats.quest.NpcType
|
||||
import world.phantasmal.lib.fileFormats.quest.Episode
|
||||
import world.phantasmal.lib.fileFormats.quest.NpcType
|
||||
import world.phantasmal.observable.value.list.ListVal
|
||||
import world.phantasmal.observable.value.list.mutableListVal
|
||||
import world.phantasmal.web.core.AssetLoader
|
||||
@ -23,10 +22,9 @@ import kotlin.time.minutes
|
||||
|
||||
class HuntMethodStore(
|
||||
scope: Scope,
|
||||
crScope: CoroutineScope,
|
||||
uiStore: UiStore,
|
||||
private val assetLoader: AssetLoader,
|
||||
) : Store(scope, crScope) {
|
||||
) : Store(scope) {
|
||||
private val _methods = mutableListVal<HuntMethodModel>()
|
||||
|
||||
val methods: ListVal<HuntMethodModel> by lazy {
|
||||
|
@ -2,7 +2,7 @@ package world.phantasmal.web.huntOptimizer.widgets
|
||||
|
||||
import org.w3c.dom.Node
|
||||
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.webui.dom.bindChildrenTo
|
||||
import world.phantasmal.webui.dom.div
|
||||
|
@ -1,20 +1,24 @@
|
||||
package world.phantasmal.web.questEditor
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.w3c.dom.HTMLCanvasElement
|
||||
import world.phantasmal.core.disposable.Scope
|
||||
import world.phantasmal.web.core.stores.UiStore
|
||||
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.QuestEditorToolbar
|
||||
import world.phantasmal.web.questEditor.widgets.QuestEditorWidget
|
||||
|
||||
class QuestEditor(
|
||||
scope: Scope,
|
||||
crScope: CoroutineScope,
|
||||
uiStore: UiStore,
|
||||
createEngine: (HTMLCanvasElement) -> Engine,
|
||||
) {
|
||||
val widget = QuestEditorWidget(scope, { scope ->
|
||||
QuestEditorRendererWidget(scope, createEngine)
|
||||
})
|
||||
private val toolbarController = QuestEditorToolbarController(scope)
|
||||
|
||||
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(
|
||||
scope: Scope,
|
||||
private val toolbar: QuestEditorToolbar,
|
||||
private val createQuestRendererWidget: (Scope) -> Widget,
|
||||
) : Widget(scope, ::style) {
|
||||
override fun Node.createElement() =
|
||||
div(className = "pw-quest-editor-quest-editor") {
|
||||
addChild(toolbar)
|
||||
addChild(DockWidget(
|
||||
scope,
|
||||
item = DockedRow(
|
||||
|
@ -4,13 +4,12 @@ import io.ktor.client.*
|
||||
import io.ktor.client.features.json.*
|
||||
import io.ktor.client.features.json.serializer.*
|
||||
import kotlinx.browser.document
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import world.phantasmal.core.disposable.DisposableScope
|
||||
import world.phantasmal.core.disposable.disposable
|
||||
import world.phantasmal.testUtils.TestSuite
|
||||
import world.phantasmal.web.core.HttpAssetLoader
|
||||
import world.phantasmal.web.core.UiDispatcher
|
||||
import world.phantasmal.web.core.stores.PwTool
|
||||
import world.phantasmal.web.externals.Engine
|
||||
import world.phantasmal.web.test.TestApplicationUrl
|
||||
@ -20,7 +19,7 @@ class ApplicationTests : TestSuite() {
|
||||
@Test
|
||||
fun initialization_and_shutdown_should_succeed_without_throwing() {
|
||||
(listOf(null) + PwTool.values().toList()).forEach { tool ->
|
||||
val scope = DisposableScope()
|
||||
val scope = DisposableScope(Job())
|
||||
|
||||
try {
|
||||
val httpClient = HttpClient {
|
||||
@ -34,7 +33,6 @@ class ApplicationTests : TestSuite() {
|
||||
|
||||
Application(
|
||||
scope,
|
||||
crScope = CoroutineScope(UiDispatcher),
|
||||
rootElement = document.body!!,
|
||||
assetLoader = HttpAssetLoader(httpClient, basePath = ""),
|
||||
applicationUrl = TestApplicationUrl(if (tool == null) "" else "/${tool.slug}"),
|
||||
|
@ -1,7 +1,5 @@
|
||||
package world.phantasmal.web.core.controllers
|
||||
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import world.phantasmal.testUtils.TestSuite
|
||||
import world.phantasmal.web.core.stores.PwTool
|
||||
import world.phantasmal.web.core.stores.UiStore
|
||||
@ -43,48 +41,42 @@ class PathAwareTabControllerTests : TestSuite() {
|
||||
@Test
|
||||
fun applicationUrl_changes_when_switch_to_tool_with_tabs() {
|
||||
val appUrl = TestApplicationUrl("/")
|
||||
val uiStore = UiStore(scope, appUrl)
|
||||
|
||||
GlobalScope.launch {
|
||||
val uiStore = UiStore(scope, this, appUrl)
|
||||
PathAwareTabController(scope, uiStore, PwTool.HuntOptimizer, listOf(
|
||||
PathAwareTab("A", "/a"),
|
||||
PathAwareTab("B", "/b"),
|
||||
PathAwareTab("C", "/c"),
|
||||
))
|
||||
|
||||
PathAwareTabController(scope, uiStore, PwTool.HuntOptimizer, listOf(
|
||||
PathAwareTab("A", "/a"),
|
||||
PathAwareTab("B", "/b"),
|
||||
PathAwareTab("C", "/c"),
|
||||
))
|
||||
assertFalse(appUrl.canGoBack)
|
||||
assertFalse(appUrl.canGoForward)
|
||||
assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value)
|
||||
|
||||
assertFalse(appUrl.canGoBack)
|
||||
assertFalse(appUrl.canGoForward)
|
||||
assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value)
|
||||
uiStore.setCurrentTool(PwTool.HuntOptimizer)
|
||||
|
||||
uiStore.setCurrentTool(PwTool.HuntOptimizer)
|
||||
assertEquals(1, appUrl.historyEntries)
|
||||
assertFalse(appUrl.canGoForward)
|
||||
assertEquals("/${PwTool.HuntOptimizer.slug}", appUrl.url.value)
|
||||
|
||||
assertEquals(1, appUrl.historyEntries)
|
||||
assertFalse(appUrl.canGoForward)
|
||||
assertEquals("/${PwTool.HuntOptimizer.slug}", appUrl.url.value)
|
||||
appUrl.back()
|
||||
|
||||
appUrl.back()
|
||||
|
||||
assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value)
|
||||
}
|
||||
assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value)
|
||||
}
|
||||
|
||||
private fun setup(
|
||||
block: (PathAwareTabController<PathAwareTab>, applicationUrl: TestApplicationUrl) -> Unit,
|
||||
) {
|
||||
val applicationUrl = TestApplicationUrl("/${PwTool.HuntOptimizer.slug}/b")
|
||||
val uiStore = UiStore(scope, applicationUrl)
|
||||
uiStore.setCurrentTool(PwTool.HuntOptimizer)
|
||||
|
||||
GlobalScope.launch {
|
||||
val uiStore = UiStore(scope, this, applicationUrl)
|
||||
uiStore.setCurrentTool(PwTool.HuntOptimizer)
|
||||
val ctrl = PathAwareTabController(scope, uiStore, PwTool.HuntOptimizer, listOf(
|
||||
PathAwareTab("A", "/a"),
|
||||
PathAwareTab("B", "/b"),
|
||||
PathAwareTab("C", "/c"),
|
||||
))
|
||||
|
||||
val ctrl = PathAwareTabController(scope, uiStore, PwTool.HuntOptimizer, listOf(
|
||||
PathAwareTab("A", "/a"),
|
||||
PathAwareTab("B", "/b"),
|
||||
PathAwareTab("C", "/c"),
|
||||
))
|
||||
|
||||
block(ctrl, applicationUrl)
|
||||
}
|
||||
block(ctrl, applicationUrl)
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,5 @@
|
||||
package world.phantasmal.web.core.store
|
||||
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import world.phantasmal.testUtils.TestSuite
|
||||
import world.phantasmal.web.core.stores.PwTool
|
||||
import world.phantasmal.web.core.stores.UiStore
|
||||
@ -13,63 +11,51 @@ class UiStoreTests : TestSuite() {
|
||||
@Test
|
||||
fun applicationUrl_is_initialized_correctly() {
|
||||
val applicationUrl = TestApplicationUrl("/")
|
||||
val uiStore = UiStore(scope, applicationUrl)
|
||||
|
||||
GlobalScope.launch {
|
||||
val uiStore = UiStore(scope, this, applicationUrl)
|
||||
|
||||
assertEquals(PwTool.Viewer, uiStore.currentTool.value)
|
||||
assertEquals("/${PwTool.Viewer.slug}", applicationUrl.url.value)
|
||||
}
|
||||
assertEquals(PwTool.Viewer, uiStore.currentTool.value)
|
||||
assertEquals("/${PwTool.Viewer.slug}", applicationUrl.url.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applicationUrl_changes_when_tool_changes() {
|
||||
val applicationUrl = TestApplicationUrl("/")
|
||||
val uiStore = UiStore(scope, applicationUrl)
|
||||
|
||||
GlobalScope.launch {
|
||||
val uiStore = UiStore(scope, this, applicationUrl)
|
||||
PwTool.values().forEach { tool ->
|
||||
uiStore.setCurrentTool(tool)
|
||||
|
||||
PwTool.values().forEach { tool ->
|
||||
uiStore.setCurrentTool(tool)
|
||||
|
||||
assertEquals(tool, uiStore.currentTool.value)
|
||||
assertEquals("/${tool.slug}", applicationUrl.url.value)
|
||||
}
|
||||
assertEquals(tool, uiStore.currentTool.value)
|
||||
assertEquals("/${tool.slug}", applicationUrl.url.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applicationUrl_changes_when_path_changes() {
|
||||
val applicationUrl = TestApplicationUrl("/")
|
||||
val uiStore = UiStore(scope, applicationUrl)
|
||||
|
||||
GlobalScope.launch {
|
||||
val uiStore = UiStore(scope, this, applicationUrl)
|
||||
assertEquals(PwTool.Viewer, uiStore.currentTool.value)
|
||||
assertEquals("/${PwTool.Viewer.slug}", applicationUrl.url.value)
|
||||
|
||||
assertEquals(PwTool.Viewer, uiStore.currentTool.value)
|
||||
assertEquals("/${PwTool.Viewer.slug}", applicationUrl.url.value)
|
||||
listOf("/models", "/textures", "/animations").forEach { prefix ->
|
||||
uiStore.setPathPrefix(prefix, replace = false)
|
||||
|
||||
listOf("/models", "/textures", "/animations").forEach { prefix ->
|
||||
uiStore.setPathPrefix(prefix, replace = false)
|
||||
|
||||
assertEquals("/${PwTool.Viewer.slug}${prefix}", applicationUrl.url.value)
|
||||
}
|
||||
assertEquals("/${PwTool.Viewer.slug}${prefix}", applicationUrl.url.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun currentTool_and_path_change_when_applicationUrl_changes() {
|
||||
val applicationUrl = TestApplicationUrl("/")
|
||||
val uiStore = UiStore(scope, applicationUrl)
|
||||
|
||||
GlobalScope.launch {
|
||||
val uiStore = UiStore(scope, this, applicationUrl)
|
||||
PwTool.values().forEach { tool ->
|
||||
listOf("/a", "/b", "/c").forEach { path ->
|
||||
applicationUrl.url.value = "/${tool.slug}$path"
|
||||
|
||||
PwTool.values().forEach { tool ->
|
||||
listOf("/a", "/b", "/c").forEach { path ->
|
||||
applicationUrl.url.value = "/${tool.slug}$path"
|
||||
|
||||
assertEquals(tool, uiStore.currentTool.value)
|
||||
assertEquals(path, uiStore.path.value)
|
||||
}
|
||||
assertEquals(tool, uiStore.currentTool.value)
|
||||
assertEquals(path, uiStore.path.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -77,27 +63,24 @@ class UiStoreTests : TestSuite() {
|
||||
@Test
|
||||
fun browser_navigation_stack_is_manipulated_correctly() {
|
||||
val appUrl = TestApplicationUrl("/")
|
||||
val uiStore = UiStore(scope, appUrl)
|
||||
|
||||
GlobalScope.launch {
|
||||
val uiStore = UiStore(scope, this, appUrl)
|
||||
assertEquals("/${uiStore.defaultTool.slug}", appUrl.url.value)
|
||||
|
||||
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.features.json.*
|
||||
import io.ktor.client.features.json.serializer.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import world.phantasmal.core.disposable.disposable
|
||||
import world.phantasmal.testUtils.TestSuite
|
||||
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.UiStore
|
||||
import world.phantasmal.web.test.TestApplicationUrl
|
||||
@ -26,13 +24,10 @@ class HuntOptimizerTests : TestSuite() {
|
||||
}
|
||||
scope.disposable { httpClient.cancel() }
|
||||
|
||||
val crScope = CoroutineScope(UiDispatcher)
|
||||
|
||||
HuntOptimizer(
|
||||
scope,
|
||||
crScope,
|
||||
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.features.json.*
|
||||
import io.ktor.client.features.json.serializer.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import world.phantasmal.core.disposable.disposable
|
||||
import world.phantasmal.testUtils.TestSuite
|
||||
import world.phantasmal.web.core.UiDispatcher
|
||||
import world.phantasmal.web.core.stores.PwTool
|
||||
import world.phantasmal.web.core.stores.UiStore
|
||||
import world.phantasmal.web.externals.Engine
|
||||
@ -26,12 +24,9 @@ class QuestEditorTests : TestSuite() {
|
||||
}
|
||||
scope.disposable { httpClient.cancel() }
|
||||
|
||||
val crScope = CoroutineScope(UiDispatcher)
|
||||
|
||||
QuestEditor(
|
||||
scope,
|
||||
crScope,
|
||||
uiStore = UiStore(scope, crScope, TestApplicationUrl("/${PwTool.QuestEditor}")),
|
||||
uiStore = UiStore(scope, TestApplicationUrl("/${PwTool.QuestEditor}")),
|
||||
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
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import world.phantasmal.core.disposable.Scope
|
||||
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 world.phantasmal.core.disposable.Scope
|
||||
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() {
|
||||
// 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(
|
||||
scope: Scope,
|
||||
hidden: Val<Boolean> = falseVal(),
|
||||
disabled: Val<Boolean> = falseVal(),
|
||||
private val createWidget: (Scope) -> Widget,
|
||||
) : Widget(scope, ::style, hidden) {
|
||||
) : Widget(scope, ::style, hidden, disabled) {
|
||||
private var initialized = false
|
||||
|
||||
override fun Node.createElement() = div(className = "pw-lazy-loader") {
|
||||
|
@ -12,46 +12,52 @@ import world.phantasmal.webui.dom.span
|
||||
class TabContainer<T : Tab>(
|
||||
scope: Scope,
|
||||
hidden: Val<Boolean> = falseVal(),
|
||||
disabled: Val<Boolean> = falseVal(),
|
||||
private val ctrl: TabController<T>,
|
||||
private val createWidget: (Scope, T) -> Widget,
|
||||
) : Widget(scope, ::style, hidden) {
|
||||
override fun Node.createElement() = div(className = "pw-tab-container") {
|
||||
div(className = "pw-tab-container-bar") {
|
||||
for (tab in ctrl.tabs) {
|
||||
span(
|
||||
className = "pw-tab-container-tab",
|
||||
title = tab.title,
|
||||
) {
|
||||
textContent = tab.title
|
||||
) : Widget(scope, ::style, hidden, disabled) {
|
||||
override fun Node.createElement() =
|
||||
div(className = "pw-tab-container") {
|
||||
div(className = "pw-tab-container-bar") {
|
||||
for (tab in ctrl.tabs) {
|
||||
span(
|
||||
className = "pw-tab-container-tab",
|
||||
title = tab.title,
|
||||
) {
|
||||
textContent = tab.title
|
||||
|
||||
ctrl.activeTab.observe {
|
||||
if (it == tab) {
|
||||
classList.add("active")
|
||||
} else {
|
||||
classList.remove("active")
|
||||
ctrl.activeTab.observe {
|
||||
if (it == tab) {
|
||||
classList.add(ACTIVE_CLASS)
|
||||
} else {
|
||||
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 {
|
||||
selfOrAncestorHidden.observe(ctrl::hiddenChanged)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTIVE_CLASS = "pw-active"
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("CssUnresolvedCustomProperty", "CssUnusedSymbol")
|
||||
@ -88,7 +94,7 @@ private fun style() = """
|
||||
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);
|
||||
color: var(--pw-tab-text-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(
|
||||
protected val scope: Scope,
|
||||
style: () -> String = NO_STYLE,
|
||||
/**
|
||||
* By default determines the hidden attribute of its [element].
|
||||
*/
|
||||
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()) {
|
||||
private val _ancestorHidden = mutableVal(false)
|
||||
private val _children = mutableListOf<Widget>()
|
||||
@ -41,10 +49,21 @@ abstract class Widget(
|
||||
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) {
|
||||
initResizeObserver(el)
|
||||
}
|
||||
|
||||
interceptElement(el)
|
||||
el
|
||||
}
|
||||
|
||||
@ -65,8 +84,16 @@ abstract class Widget(
|
||||
|
||||
val children: List<Widget> = _children
|
||||
|
||||
/**
|
||||
* Called to initialize [element] when it is first accessed.
|
||||
*/
|
||||
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() {
|
||||
if (elementDelegate.isInitialized()) {
|
||||
element.remove()
|
||||
|
Loading…
Reference in New Issue
Block a user